Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db7de9c79 |
@@ -1,126 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- feature/**
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
|
||||||
env:
|
|
||||||
# CI에서는 실제 DB에 접속하지 않고 Prisma Client만 생성하면 되므로,
|
|
||||||
# 유효한 형식의 더미 DATABASE_URL 을 사용한다.
|
|
||||||
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
|
||||||
# Prisma 엔진 바이너리 체크섬 파일을 원격에서 가져오지 못하는 경우(예: 500 오류)에도
|
|
||||||
# CI가 계속 진행되도록 체크섬 누락을 무시한다.
|
|
||||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
|
||||||
run: |
|
|
||||||
npx prisma generate
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
env:
|
|
||||||
# API 유닛 테스트에서도 PrismaClient가 필요하므로,
|
|
||||||
# generate 단계와 동일한 더미 DATABASE_URL 을 사용한다.
|
|
||||||
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
|
||||||
# 테스트 중 Prisma가 엔진 체크섬 파일을 다시 확인하는 경우를 대비해 동일 설정을 적용한다.
|
|
||||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
|
||||||
run: |
|
|
||||||
npm test
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: |
|
|
||||||
npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
run: |
|
|
||||||
npx playwright test
|
|
||||||
|
|
||||||
pr_and_merge:
|
|
||||||
needs: test
|
|
||||||
# feature/* 브랜치에 push 되었을 때만 실행한다.
|
|
||||||
if: ${{ startsWith(github.ref, 'refs/heads/feature/') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y jq
|
|
||||||
|
|
||||||
- name: Create or update PR and try auto-merge
|
|
||||||
env:
|
|
||||||
CI_BASE_URL: ${{ secrets.CI_BASE_URL }}
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
CI_OWNER: ${{ secrets.CI_OWNER }}
|
|
||||||
CI_REPO: ${{ secrets.CI_REPO }}
|
|
||||||
BRANCH_REF: ${{ github.ref }}
|
|
||||||
BRANCH_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$CI_BASE_URL" ] || [ -z "$CI_TOKEN" ] || [ -z "$CI_OWNER" ] || [ -z "$CI_REPO" ]; then
|
|
||||||
echo "CI_* 시크릿이 설정되지 않아 PR/자동 머지를 건너뜁니다." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "현재 브랜치: $BRANCH_NAME ($BRANCH_REF)"
|
|
||||||
|
|
||||||
# 1) PR 생성 시도 (이미 존재하면 4xx를 허용)
|
|
||||||
create_pr_payload=$(jq -n \
|
|
||||||
--arg title "CI: $BRANCH_NAME" \
|
|
||||||
--arg head "$BRANCH_NAME" \
|
|
||||||
--arg base "main" \
|
|
||||||
'{title: $title, head: $head, base: $base}')
|
|
||||||
|
|
||||||
echo "PR 생성 시도..."
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls" \
|
|
||||||
-d "$create_pr_payload" || true
|
|
||||||
|
|
||||||
# 2) 열린 PR 목록에서 해당 브랜치의 PR 번호를 찾는다.
|
|
||||||
echo "열린 PR 목록 조회..."
|
|
||||||
pr_list=$(curl -sS \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls?state=open")
|
|
||||||
|
|
||||||
pr_number=$(echo "$pr_list" | jq ".[] | select(.head.ref == \"$BRANCH_NAME\") | .number" | head -n 1)
|
|
||||||
|
|
||||||
if [ -z "$pr_number" ]; then
|
|
||||||
echo "브랜치 $BRANCH_NAME 에 대한 열린 PR을 찾지 못했습니다. 종료합니다."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "브랜치 $BRANCH_NAME 에 대한 PR #$pr_number 발견"
|
|
||||||
|
|
||||||
# 3) main 대상으로 자동 머지 시도
|
|
||||||
merge_payload=$(jq -n '{Do: "merge"}')
|
|
||||||
|
|
||||||
echo "PR #$pr_number 자동 머지 시도..."
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls/$pr_number/merge" \
|
|
||||||
-d "$merge_payload" || true
|
|
||||||
@@ -16,10 +16,4 @@ export default defineConfig({
|
|||||||
use: { ...devices["Desktop Chrome"] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
|
||||||
command: "npm run dev",
|
|
||||||
url: "http://localhost:3000",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const formData = await req.formData();
|
|
||||||
|
|
||||||
const name = formData.get("name");
|
|
||||||
const email = formData.get("email");
|
|
||||||
const message = formData.get("message");
|
|
||||||
|
|
||||||
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
|
||||||
console.log("[forms/submit]", { name, email, message });
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
+233
-851
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type ButtonPropertiesPanelProps = {
|
|
||||||
buttonProps: ButtonBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<ButtonBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>버튼 텍스트</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[60px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 텍스트"
|
|
||||||
value={buttonProps.label}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { label: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>버튼 링크</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 링크"
|
|
||||||
value={buttonProps.href}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { href: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 정렬"
|
|
||||||
value={buttonProps.align ?? "left"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="버튼 크기"
|
|
||||||
value={(() => {
|
|
||||||
const s = buttonProps.size ?? "md";
|
|
||||||
return s === "xs" ? 1 : s === "sm" ? 2 : s === "lg" ? 4 : s === "xl" ? 5 : 3;
|
|
||||||
})()}
|
|
||||||
min={1}
|
|
||||||
max={5}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 작게", value: 1 },
|
|
||||||
{ id: "sm", label: "작게", value: 2 },
|
|
||||||
{ id: "md", label: "보통", value: 3 },
|
|
||||||
{ id: "lg", label: "크게", value: 4 },
|
|
||||||
{ id: "xl", label: "아주 크게", value: 5 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next = v <= 1 ? "xs" : v === 2 ? "sm" : v === 4 ? "lg" : v >= 5 ? "xl" : "md";
|
|
||||||
updateBlock(selectedBlockId, { size: next } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="버튼 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
|
|
||||||
? buttonProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>스타일</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 스타일"
|
|
||||||
value={buttonProps.variant ?? "solid"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
|
|
||||||
updateBlock(selectedBlockId, { variant: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="solid">채움</option>
|
|
||||||
<option value="outline">외곽선</option>
|
|
||||||
<option value="ghost">고스트</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="채움 색상"
|
|
||||||
ariaLabelColorInput="버튼 채움 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 채움 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
|
|
||||||
? buttonProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="외곽선 색상"
|
|
||||||
ariaLabelColorInput="버튼 외곽선 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 외곽선 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
|
|
||||||
? buttonProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = buttonProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={4}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 1 },
|
|
||||||
{ id: "md", label: "보통", value: 2 },
|
|
||||||
{ id: "lg", label: "크게", value: 3 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 4 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v === 1 ? "sm" : v === 3 ? "lg" : v >= 4 ? "full" : "md";
|
|
||||||
updateBlock(selectedBlockId, { borderRadius: next } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 크기"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
// 기본 버튼 텍스트 크기: md 스케일(16px) 기준
|
|
||||||
return 16;
|
|
||||||
})()}
|
|
||||||
min={10}
|
|
||||||
max={32}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "base", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
{ id: "2xl", label: "2XL", value: 24 },
|
|
||||||
{ id: "3xl", label: "3XL", value: 30 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
// 버튼은 텍스트 스케일 상태 없이 px만 저장하지만,
|
|
||||||
// 프리셋 값은 TextPropertiesPanel과 동일한 테이블을 사용한다.
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${px}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="줄 간격"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 1.4;
|
|
||||||
})()}
|
|
||||||
min={0.8}
|
|
||||||
max={3}
|
|
||||||
step={0.05}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 1.1 },
|
|
||||||
{ id: "normal", label: "보통", value: 1.4 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 1.8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: v.toString(),
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 간격"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) {
|
|
||||||
const em = Number(match[1]);
|
|
||||||
if (Number.isFinite(em)) return em * 16;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-2}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tighter", label: "아주 좁게", value: -1.5 },
|
|
||||||
{ id: "tight", label: "좁게", value: -0.5 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 1 },
|
|
||||||
{ id: "wider", label: "아주 넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
const em = px / 16;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<span>가로 전체 사용</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={buttonProps.fullWidth ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type DividerPropertiesPanelProps = {
|
|
||||||
dividerProps: DividerBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<DividerBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBlock }: DividerPropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="구분선 정렬"
|
|
||||||
value={dividerProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as DividerBlockProps["align"];
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>두께</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="구분선 두께"
|
|
||||||
value={dividerProps.thickness}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as DividerBlockProps["thickness"];
|
|
||||||
updateBlock(selectedBlockId, { thickness: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="thin">얇게</option>
|
|
||||||
<option value="medium">보통</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type ImagePropertiesPanelProps = {
|
|
||||||
imageProps: ImageBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<ImageBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="이미지 URL"
|
|
||||||
value={imageProps.src}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { src: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>대체 텍스트</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="대체 텍스트"
|
|
||||||
value={imageProps.alt}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { alt: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type ListPropertiesPanelProps = {
|
|
||||||
listProps: ListBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<ListBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ListPropertiesPanel({ listProps, selectedBlockId, updateBlock }: ListPropertiesPanelProps) {
|
|
||||||
const firstItem = listProps.items && listProps.items.length > 0 ? listProps.items[0] : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>첫 번째 아이템</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="리스트 첫 번째 아이템"
|
|
||||||
value={firstItem}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextItems = [...(listProps.items ?? [])];
|
|
||||||
if (nextItems.length === 0) {
|
|
||||||
nextItems.push(e.target.value);
|
|
||||||
} else {
|
|
||||||
nextItems[0] = e.target.value;
|
|
||||||
}
|
|
||||||
updateBlock(selectedBlockId, { items: nextItems } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={listProps.ordered}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { ordered: e.target.checked } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>번호 매기기</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="리스트 정렬"
|
|
||||||
value={listProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as ListBlockProps["align"];
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type SectionPropertiesPanelProps = {
|
|
||||||
sectionProps: SectionBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<SectionBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 섹션 배경 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>배경</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="섹션 배경"
|
|
||||||
value={sectionProps.background ?? "default"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<SectionBlockProps["background"]>;
|
|
||||||
updateBlock(selectedBlockId, { background: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="default">기본</option>
|
|
||||||
<option value="muted">Muted</option>
|
|
||||||
<option value="primary">Primary</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 상하 패딩 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>세로 패딩</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="섹션 세로 패딩"
|
|
||||||
value={sectionProps.paddingY ?? "md"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<SectionBlockProps["paddingY"]>;
|
|
||||||
updateBlock(selectedBlockId, { paddingY: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="sm">작게</option>
|
|
||||||
<option value="md">보통</option>
|
|
||||||
<option value="lg">크게</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
export type TextPropertiesPanelProps = {
|
|
||||||
textProps: TextBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void;
|
|
||||||
editingBlockId: string | null;
|
|
||||||
setEditingText: (value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TextPropertiesPanel({
|
|
||||||
textProps,
|
|
||||||
selectedBlockId,
|
|
||||||
updateBlock,
|
|
||||||
editingBlockId,
|
|
||||||
setEditingText,
|
|
||||||
}: TextPropertiesPanelProps) {
|
|
||||||
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
|
||||||
const fallbackScale =
|
|
||||||
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
|
||||||
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
|
||||||
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
|
||||||
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
|
||||||
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
|
||||||
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
|
||||||
const colorPalette = textProps.colorPalette ?? "default";
|
|
||||||
const maxWidthMode = textProps.maxWidthMode ?? "scale";
|
|
||||||
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
|
||||||
|
|
||||||
const fontSizeScaleToPx: Record<NonNullable<TextBlockProps["fontSizeScale"]>, number> = {
|
|
||||||
xs: 12,
|
|
||||||
sm: 14,
|
|
||||||
base: 16,
|
|
||||||
lg: 18,
|
|
||||||
xl: 20,
|
|
||||||
"2xl": 24,
|
|
||||||
"3xl": 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsedFontSize = (() => {
|
|
||||||
const raw = textProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return fontSizeScaleToPx[fontSizeScale];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const parsedLineHeight = (() => {
|
|
||||||
const raw = textProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 1.5;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 글자 간격: 내부 저장은 em 이지만, UI 에서는 px 단위로 보여준다.
|
|
||||||
const parsedLetterSpacingPx = (() => {
|
|
||||||
const raw = textProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) {
|
|
||||||
const em = Number(match[1]);
|
|
||||||
if (Number.isFinite(em)) {
|
|
||||||
return em * 16; // 1em = 16px 기준으로 환산
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const letterSpacingPreset = (() => {
|
|
||||||
const px = parsedLetterSpacingPx;
|
|
||||||
if (px <= -1) return "tighter" as const;
|
|
||||||
if (px < 0) return "tight" as const;
|
|
||||||
if (px < 1) return "normal" as const;
|
|
||||||
if (px < 3) return "wide" as const;
|
|
||||||
return "wider" as const;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const parsedFontWeight = (() => {
|
|
||||||
const raw = textProps.fontWeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]{3})/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
switch (fontWeightScale) {
|
|
||||||
case "medium":
|
|
||||||
return 500;
|
|
||||||
case "semibold":
|
|
||||||
return 600;
|
|
||||||
case "bold":
|
|
||||||
return 700;
|
|
||||||
default:
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-slate-400">선택한 텍스트 블록 내용</p>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="선택한 텍스트 블록 내용"
|
|
||||||
value={textProps.text}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, { text: value });
|
|
||||||
if (editingBlockId === selectedBlockId) {
|
|
||||||
setEditingText(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="정렬"
|
|
||||||
value={textProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as "left" | "center" | "right";
|
|
||||||
updateBlock(selectedBlockId, { align: value });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
|
||||||
<span>텍스트 스타일</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.underline
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
underline: !textProps.underline,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
밑줄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.strike
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strike: !textProps.strike,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
가운데줄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.italic
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
italic: !textProps.italic,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
이탤릭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 크기"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
|
|
||||||
min={10}
|
|
||||||
max={72}
|
|
||||||
step={1}
|
|
||||||
presets={(
|
|
||||||
["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] as NonNullable<
|
|
||||||
TextBlockProps["fontSizeScale"]
|
|
||||||
>[]
|
|
||||||
).map((scale) => ({
|
|
||||||
id: scale,
|
|
||||||
label:
|
|
||||||
scale === "xs"
|
|
||||||
? "XS"
|
|
||||||
: scale === "sm"
|
|
||||||
? "S"
|
|
||||||
: scale === "base"
|
|
||||||
? "M"
|
|
||||||
: scale === "lg"
|
|
||||||
? "L"
|
|
||||||
: scale === "xl"
|
|
||||||
? "XL"
|
|
||||||
: scale === "2xl"
|
|
||||||
? "2XL"
|
|
||||||
: "3XL",
|
|
||||||
value: fontSizeScaleToPx[scale],
|
|
||||||
}))}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
const raw = textProps.fontSizeCustom ?? "";
|
|
||||||
const suffixMatch = raw.match(/px|rem|em|%/);
|
|
||||||
const suffix = suffixMatch ? suffixMatch[0] : "px";
|
|
||||||
|
|
||||||
const entries = Object.entries(fontSizeScaleToPx) as [
|
|
||||||
NonNullable<TextBlockProps["fontSizeScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - px);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: fontSizeScale as NonNullable<TextBlockProps["fontSizeScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeScale: closest,
|
|
||||||
fontSizeCustom: `${px}${suffix}`,
|
|
||||||
fontSizeMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>글자 간격</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="글자 간격 프리셋"
|
|
||||||
value={letterSpacingPreset}
|
|
||||||
onChange={(e) => {
|
|
||||||
const preset = e.target.value as
|
|
||||||
| "tighter"
|
|
||||||
| "tight"
|
|
||||||
| "normal"
|
|
||||||
| "wide"
|
|
||||||
| "wider";
|
|
||||||
|
|
||||||
const presetPxMap: Record<
|
|
||||||
"tighter" | "tight" | "normal" | "wide" | "wider",
|
|
||||||
number
|
|
||||||
> = {
|
|
||||||
tighter: -1.5,
|
|
||||||
tight: -0.5,
|
|
||||||
normal: 0,
|
|
||||||
wide: 1,
|
|
||||||
wider: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const px = presetPxMap[preset];
|
|
||||||
const em = px / 16;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="tighter">아주 좁게</option>
|
|
||||||
<option value="tight">좁게</option>
|
|
||||||
<option value="normal">보통</option>
|
|
||||||
<option value="wide">넓게</option>
|
|
||||||
<option value="wider">아주 넓게</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<PropertySliderField
|
|
||||||
label=""
|
|
||||||
ariaLabelSlider="글자 간격 슬라이더"
|
|
||||||
ariaLabelInput="글자 간격 커스텀 (px)"
|
|
||||||
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
|
|
||||||
min={-2}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
onChange={(px) => {
|
|
||||||
const em = px / 16;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="줄 간격"
|
|
||||||
unitLabel=""
|
|
||||||
value={Number.isFinite(parsedLineHeight) ? parsedLineHeight : 1.5}
|
|
||||||
min={-1}
|
|
||||||
max={3}
|
|
||||||
step={0.05}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 1.25 },
|
|
||||||
{ id: "snug", label: "약간 좁게", value: 1.35 },
|
|
||||||
{ id: "normal", label: "보통", value: 1.5 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 1.7 },
|
|
||||||
{ id: "loose", label: "아주 넓게", value: 1.9 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
|
|
||||||
tight: 1.25,
|
|
||||||
snug: 1.35,
|
|
||||||
normal: 1.5,
|
|
||||||
relaxed: 1.7,
|
|
||||||
loose: 1.9,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entries = Object.entries(preset) as [
|
|
||||||
NonNullable<TextBlockProps["lineHeightScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - v);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: lineHeightScale as NonNullable<TextBlockProps["lineHeightScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightScale: closest,
|
|
||||||
lineHeightCustom: v.toString(),
|
|
||||||
lineHeightMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="굵기"
|
|
||||||
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
|
|
||||||
min={100}
|
|
||||||
max={900}
|
|
||||||
step={100}
|
|
||||||
presets={(
|
|
||||||
["normal", "medium", "semibold", "bold"] as NonNullable<
|
|
||||||
TextBlockProps["fontWeightScale"]
|
|
||||||
>[]
|
|
||||||
).map((scale) => ({
|
|
||||||
id: scale,
|
|
||||||
label:
|
|
||||||
scale === "normal"
|
|
||||||
? "보통"
|
|
||||||
: scale === "medium"
|
|
||||||
? "중간"
|
|
||||||
: scale === "semibold"
|
|
||||||
? "세미볼드"
|
|
||||||
: "볼드",
|
|
||||||
value:
|
|
||||||
scale === "normal"
|
|
||||||
? 400
|
|
||||||
: scale === "medium"
|
|
||||||
? 500
|
|
||||||
: scale === "semibold"
|
|
||||||
? 600
|
|
||||||
: 700,
|
|
||||||
}))}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const preset: Record<NonNullable<TextBlockProps["fontWeightScale"]>, number> = {
|
|
||||||
normal: 400,
|
|
||||||
medium: 500,
|
|
||||||
semibold: 600,
|
|
||||||
bold: 700,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entries = Object.entries(preset) as [
|
|
||||||
NonNullable<TextBlockProps["fontWeightScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - v);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: fontWeightScale as NonNullable<TextBlockProps["fontWeightScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontWeightScale: closest,
|
|
||||||
fontWeightCustom: v.toString(),
|
|
||||||
fontWeightMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
|
|
||||||
? textProps.colorCustom
|
|
||||||
: TEXT_COLOR_PALETTE.find((p) => p.id === colorPalette)?.color ?? "#ffffff")
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
colorCustom: hex,
|
|
||||||
colorMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
selectedPaletteId={colorPalette}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
const paletteId = item.id as NonNullable<TextBlockProps["colorPalette"]>;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
colorPalette: paletteId,
|
|
||||||
colorCustom: item.color,
|
|
||||||
colorMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>최대 너비</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="최대 너비 프리셋"
|
|
||||||
value={maxWidthScale}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
|
|
||||||
let custom = textProps.maxWidthCustom ?? "";
|
|
||||||
if (value === "prose") custom = "60ch";
|
|
||||||
else if (value === "narrow") custom = "40ch";
|
|
||||||
else custom = "";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthScale: value,
|
|
||||||
maxWidthMode: custom ? "custom" : "scale",
|
|
||||||
maxWidthCustom: custom,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">제한 없음</option>
|
|
||||||
<option value="prose">본문 폭 (60ch)</option>
|
|
||||||
<option value="narrow">좁게 (40ch)</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={20}
|
|
||||||
max={120}
|
|
||||||
step={5}
|
|
||||||
aria-label="최대 너비 슬라이더 (ch 단위)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = textProps.maxWidthCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+)ch/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
if (maxWidthScale === "prose") return 60;
|
|
||||||
if (maxWidthScale === "narrow") return 40;
|
|
||||||
return 120;
|
|
||||||
})()}
|
|
||||||
onChange={(e) => {
|
|
||||||
const ch = Number(e.target.value || 60);
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthCustom: `${ch}ch`,
|
|
||||||
maxWidthMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="최대 너비 커스텀"
|
|
||||||
placeholder="예: 600px, 40rem, 80%, 60ch"
|
|
||||||
value={textProps.maxWidthCustom ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthCustom: e.target.value,
|
|
||||||
maxWidthMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createBlogTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const postDefinitions = [
|
|
||||||
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const blogBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const post = postDefinitions[index] ?? postDefinitions[0];
|
|
||||||
|
|
||||||
const titleId = createId();
|
|
||||||
const summaryId = createId();
|
|
||||||
|
|
||||||
const titleProps: TextBlockProps = {
|
|
||||||
text: post.title,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const summaryProps: TextBlockProps = {
|
|
||||||
text: post.summary,
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const titleBlock: Block = {
|
|
||||||
id: titleId,
|
|
||||||
type: "text",
|
|
||||||
props: titleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryBlock: Block = {
|
|
||||||
id: summaryId,
|
|
||||||
type: "text",
|
|
||||||
props: summaryProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [titleBlock, summaryBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...blogBlocks];
|
|
||||||
const lastSelectedId = blogBlocks[blogBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createCtaTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "primary",
|
|
||||||
paddingY: "md",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstColumnId = `${sectionId}_col_1`;
|
|
||||||
|
|
||||||
const textId = createId();
|
|
||||||
const textProps: TextBlockProps = {
|
|
||||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
const textBlock: Block = {
|
|
||||||
id: textId,
|
|
||||||
type: "text",
|
|
||||||
props: textProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonId = createId();
|
|
||||||
const buttonProps: ButtonBlockProps = {
|
|
||||||
label: "CTA 버튼",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: false,
|
|
||||||
borderRadius: "md",
|
|
||||||
};
|
|
||||||
const buttonBlock: Block = {
|
|
||||||
id: buttonId,
|
|
||||||
type: "button",
|
|
||||||
props: buttonProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
|
|
||||||
const lastSelectedId = buttonId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFaqTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "md",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstColumnId = `${sectionId}_col_1`;
|
|
||||||
|
|
||||||
const faqPairs = [
|
|
||||||
{
|
|
||||||
question: "자주 묻는 질문 1",
|
|
||||||
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "자주 묻는 질문 2",
|
|
||||||
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "자주 묻는 질문 3",
|
|
||||||
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
|
|
||||||
const qId = createId();
|
|
||||||
const aId = createId();
|
|
||||||
|
|
||||||
const questionProps: TextBlockProps = {
|
|
||||||
text: pair.question,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const answerProps: TextBlockProps = {
|
|
||||||
text: pair.answer,
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const questionBlock: Block = {
|
|
||||||
id: qId,
|
|
||||||
type: "text",
|
|
||||||
props: questionProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const answerBlock: Block = {
|
|
||||||
id: aId,
|
|
||||||
type: "text",
|
|
||||||
props: answerProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [questionBlock, answerBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...faqBlocks];
|
|
||||||
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFeaturesTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "md",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const featureBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const titleId = createId();
|
|
||||||
const descId = createId();
|
|
||||||
|
|
||||||
const titleProps: TextBlockProps = {
|
|
||||||
text: `Feature ${index + 1} 제목`,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const descProps: TextBlockProps = {
|
|
||||||
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const title: Block = {
|
|
||||||
id: titleId,
|
|
||||||
type: "text",
|
|
||||||
props: titleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const description: Block = {
|
|
||||||
id: descId,
|
|
||||||
type: "text",
|
|
||||||
props: descProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [title, description];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...featureBlocks];
|
|
||||||
const lastSelectedId = featureBlocks[featureBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFooterTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "md",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstColumnId = `${sectionId}_col_1`;
|
|
||||||
|
|
||||||
const linksId = createId();
|
|
||||||
const linksProps: TextBlockProps = {
|
|
||||||
text: "이용약관 · 개인정보처리방침",
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
const linksBlock: Block = {
|
|
||||||
id: linksId,
|
|
||||||
type: "text",
|
|
||||||
props: linksProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyrightId = createId();
|
|
||||||
const copyrightProps: TextBlockProps = {
|
|
||||||
text: "© 2025 MyLanding. All rights reserved.",
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
const copyrightBlock: Block = {
|
|
||||||
id: copyrightId,
|
|
||||||
type: "text",
|
|
||||||
props: copyrightProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, linksBlock, copyrightBlock];
|
|
||||||
const lastSelectedId = copyrightId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
SectionBlockProps,
|
|
||||||
TextBlockProps,
|
|
||||||
ButtonBlockProps,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createHeroTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
id: `${sectionId}_col_1`,
|
|
||||||
span: 12,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstColumnId = `${sectionId}_col_1`;
|
|
||||||
|
|
||||||
const heroHeadlineId = createId();
|
|
||||||
const heroHeadlineProps: TextBlockProps = {
|
|
||||||
text: "Hero 제목을 여기에 입력하세요",
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
const heroHeadline: Block = {
|
|
||||||
id: heroHeadlineId,
|
|
||||||
type: "text",
|
|
||||||
props: heroHeadlineProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroSubId = createId();
|
|
||||||
const heroSubProps: TextBlockProps = {
|
|
||||||
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
const heroSub: Block = {
|
|
||||||
id: heroSubId,
|
|
||||||
type: "text",
|
|
||||||
props: heroSubProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroButtonId = createId();
|
|
||||||
const heroButtonProps: ButtonBlockProps = {
|
|
||||||
label: "지금 시작하기",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: false,
|
|
||||||
borderRadius: "md",
|
|
||||||
fontSizeCustom: "14px",
|
|
||||||
fillColorCustom: "",
|
|
||||||
strokeColorCustom: "",
|
|
||||||
};
|
|
||||||
const heroButton: Block = {
|
|
||||||
id: heroButtonId,
|
|
||||||
type: "button",
|
|
||||||
props: heroButtonProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, heroHeadline, heroSub, heroButton];
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId: heroButtonId };
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createPricingTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "lg",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const planDefinitions: Array<{ name: string; price: string }> = [
|
|
||||||
{ name: "Basic", price: "₩9,900/월" },
|
|
||||||
{ name: "Pro", price: "₩29,900/월" },
|
|
||||||
{ name: "Enterprise", price: "문의" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
|
||||||
|
|
||||||
const nameId = createId();
|
|
||||||
const priceId = createId();
|
|
||||||
|
|
||||||
const nameProps: TextBlockProps = {
|
|
||||||
text: plan.name,
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const priceProps: TextBlockProps = {
|
|
||||||
text: plan.price,
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const nameBlock: Block = {
|
|
||||||
id: nameId,
|
|
||||||
type: "text",
|
|
||||||
props: nameProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const priceBlock: Block = {
|
|
||||||
id: priceId,
|
|
||||||
type: "text",
|
|
||||||
props: priceProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [nameBlock, priceBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
|
|
||||||
const lastSelectedId = pricingBlocks[pricingBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createTeamTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "lg",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const memberDefinitions = [
|
|
||||||
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
|
|
||||||
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
|
|
||||||
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const teamBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const m = memberDefinitions[index] ?? memberDefinitions[0];
|
|
||||||
|
|
||||||
const nameId = createId();
|
|
||||||
const roleId = createId();
|
|
||||||
const bioId = createId();
|
|
||||||
|
|
||||||
const nameProps: TextBlockProps = {
|
|
||||||
text: m.name,
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const roleProps: TextBlockProps = {
|
|
||||||
text: m.role,
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const bioProps: TextBlockProps = {
|
|
||||||
text: m.bio,
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const nameBlock: Block = {
|
|
||||||
id: nameId,
|
|
||||||
type: "text",
|
|
||||||
props: nameProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleBlock: Block = {
|
|
||||||
id: roleId,
|
|
||||||
type: "text",
|
|
||||||
props: roleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const bioBlock: Block = {
|
|
||||||
id: bioId,
|
|
||||||
type: "text",
|
|
||||||
props: bioProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [nameBlock, roleBlock, bioBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...teamBlocks];
|
|
||||||
const lastSelectedId = teamBlocks[teamBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createTestimonialsTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const testimonialDefinitions: Array<{ body: string; author: string }> = [
|
|
||||||
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
|
|
||||||
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
|
|
||||||
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
|
|
||||||
|
|
||||||
const bodyId = createId();
|
|
||||||
const authorId = createId();
|
|
||||||
|
|
||||||
const bodyProps: TextBlockProps = {
|
|
||||||
text: t.body,
|
|
||||||
align: "left",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const authorProps: TextBlockProps = {
|
|
||||||
text: `- ${t.author}`,
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const bodyBlock: Block = {
|
|
||||||
id: bodyId,
|
|
||||||
type: "text",
|
|
||||||
props: bodyProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const authorBlock: Block = {
|
|
||||||
id: authorId,
|
|
||||||
type: "text",
|
|
||||||
props: authorProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [bodyBlock, authorBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...testimonialBlocks];
|
|
||||||
const lastSelectedId = testimonialBlocks[testimonialBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
import "../styles/builder.css";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
|
||||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
|
||||||
|
|
||||||
export default function PreviewPage() {
|
|
||||||
const blocks = useEditorStore((state) => state.blocks);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
|
||||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-semibold">Page Preview</h1>
|
|
||||||
<p className="text-xs text-slate-400">빌더로 만든 페이지를 미리 보는 화면</p>
|
|
||||||
</header>
|
|
||||||
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링 */}
|
|
||||||
<PublicPageRenderer blocks={blocks} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export type ColorPaletteItem = {
|
|
||||||
// 팔레트 식별자
|
|
||||||
id: string;
|
|
||||||
// UI에 보여줄 이름
|
|
||||||
label: string;
|
|
||||||
// HEX 색상 값
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ColorPickerFieldProps = {
|
|
||||||
// 필드 라벨 텍스트
|
|
||||||
label: string;
|
|
||||||
// 컬러 인풋에 대한 접근성 라벨
|
|
||||||
ariaLabelColorInput: string;
|
|
||||||
// HEX 텍스트 인풋에 대한 접근성 라벨
|
|
||||||
ariaLabelHexInput: string;
|
|
||||||
// 현재 선택된 색상 값 (HEX)
|
|
||||||
value: string;
|
|
||||||
// 값 변경 콜백
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
// 선택 가능한 팔레트 목록
|
|
||||||
palette?: ColorPaletteItem[];
|
|
||||||
// 현재 선택된 팔레트 ID (있다면 드롭다운 요약에 표시)
|
|
||||||
selectedPaletteId?: string;
|
|
||||||
// 팔레트 선택 시 호출되는 콜백 (팔레트 메타 정보까지 필요할 때 사용)
|
|
||||||
onPaletteSelect?: (item: ColorPaletteItem) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
|
|
||||||
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
|
|
||||||
// 기본/중립 계열
|
|
||||||
{ id: "default", label: "기본", color: "#e5e7eb" },
|
|
||||||
{ id: "muted", label: "연한", color: "#9ca3af" },
|
|
||||||
{ id: "strong", label: "강조", color: "#f9fafb" },
|
|
||||||
{ id: "neutral", label: "중립", color: "#94a3b8" },
|
|
||||||
|
|
||||||
// 브랜드/포인트 계열
|
|
||||||
{ id: "accent", label: "포인트", color: "#38bdf8" },
|
|
||||||
{ id: "accent-deep", label: "포인트 진하게", color: "#0ea5e9" },
|
|
||||||
{ id: "accent-soft", label: "포인트 연하게", color: "#bae6fd" },
|
|
||||||
|
|
||||||
// 상태 계열
|
|
||||||
{ id: "danger", label: "위험", color: "#f97373" },
|
|
||||||
{ id: "danger-deep", label: "위험 진하게", color: "#ef4444" },
|
|
||||||
{ id: "success", label: "성공", color: "#22c55e" },
|
|
||||||
{ id: "success-soft", label: "성공 연하게", color: "#bbf7d0" },
|
|
||||||
{ id: "warning", label: "경고", color: "#eab308" },
|
|
||||||
{ id: "info", label: "정보", color: "#0ea5e9" },
|
|
||||||
|
|
||||||
// 보라/핑크 계열
|
|
||||||
{ id: "purple", label: "보라", color: "#a855f7" },
|
|
||||||
{ id: "purple-soft", label: "연한 보라", color: "#e9d5ff" },
|
|
||||||
{ id: "pink", label: "핑크", color: "#ec4899" },
|
|
||||||
{ id: "pink-soft", label: "연한 핑크", color: "#f9a8d4" },
|
|
||||||
|
|
||||||
// 배경 대비용 어두운 텍스트
|
|
||||||
{ id: "dark", label: "어두운 텍스트", color: "#020617" },
|
|
||||||
{ id: "dark-muted", label: "어두운 연한", color: "#64748b" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ColorPickerField({
|
|
||||||
label,
|
|
||||||
ariaLabelColorInput,
|
|
||||||
ariaLabelHexInput,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
palette = [],
|
|
||||||
selectedPaletteId,
|
|
||||||
onPaletteSelect,
|
|
||||||
}: ColorPickerFieldProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
|
|
||||||
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 인풋 변경 시 그대로 onChange로 전달한다.
|
|
||||||
const handleHexInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 팔레트 버튼 클릭 시 해당 팔레트의 색상으로 onChange를 호출한다.
|
|
||||||
const handlePaletteClick = (item: ColorPaletteItem) => {
|
|
||||||
onChange(item.color);
|
|
||||||
if (onPaletteSelect) {
|
|
||||||
onPaletteSelect(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 현재 선택된 팔레트 정보를 계산한다.
|
|
||||||
const selectedPalette =
|
|
||||||
selectedPaletteId && palette.length > 0
|
|
||||||
? palette.find((item) => item.id === selectedPaletteId) ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
aria-label={ariaLabelColorInput}
|
|
||||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
|
|
||||||
value={value || "#ffffff"}
|
|
||||||
onChange={handleColorInputChange}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label={ariaLabelHexInput}
|
|
||||||
placeholder="예: #ff0000"
|
|
||||||
value={value}
|
|
||||||
onChange={handleHexInputChange}
|
|
||||||
/>
|
|
||||||
{palette.length > 0 ? (
|
|
||||||
<div className="relative text-[11px] flex-none">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-full"
|
|
||||||
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-slate-200">
|
|
||||||
{selectedPalette?.label ?? "색상 팔레트"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500 text-[10px]">▼</span>
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
|
|
||||||
{palette.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
|
|
||||||
selectedPaletteId === item.id
|
|
||||||
? "bg-sky-900/40 text-sky-100"
|
|
||||||
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
handlePaletteClick(item);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-full"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
||||||
|
|
||||||
export type NumericPresetOption = {
|
|
||||||
// 프리셋 식별자 (예: "tight", "normal")
|
|
||||||
id: string;
|
|
||||||
// UI 에 표시할 라벨
|
|
||||||
label: string;
|
|
||||||
// UI 기준 값 (px, 배율 등)
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NumericPropertyControlProps = {
|
|
||||||
// 필드 라벨 (예: 글자 크기, 줄 간격)
|
|
||||||
label: string;
|
|
||||||
// 단위 설명 (예: px, 배율 등)
|
|
||||||
unitLabel?: string;
|
|
||||||
// 현재 UI 값
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
|
|
||||||
presets?: NumericPresetOption[];
|
|
||||||
// 값 변경 시 호출되는 콜백 (UI 값 기준)
|
|
||||||
onChangeValue: (value: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NumericPropertyControl({
|
|
||||||
label,
|
|
||||||
unitLabel,
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
presets,
|
|
||||||
onChangeValue,
|
|
||||||
}: NumericPropertyControlProps) {
|
|
||||||
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
|
|
||||||
const currentPresetId = (() => {
|
|
||||||
if (!presets || presets.length === 0) return "";
|
|
||||||
let bestId = presets[0].id;
|
|
||||||
let bestDist = Math.abs(presets[0].value - value);
|
|
||||||
for (let i = 1; i < presets.length; i += 1) {
|
|
||||||
const dist = Math.abs(presets[i].value - value);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestId = presets[i].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestId;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
if (!presets || presets.length === 0) return;
|
|
||||||
const id = event.target.value;
|
|
||||||
const found = presets.find((p) => p.id === id);
|
|
||||||
if (!found) return;
|
|
||||||
onChangeValue(found.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>
|
|
||||||
{label}
|
|
||||||
{unitLabel ? ` ${unitLabel}` : ""}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{presets && presets.length > 0 ? (
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label={`${label} 프리셋`}
|
|
||||||
value={currentPresetId}
|
|
||||||
onChange={handlePresetChange}
|
|
||||||
>
|
|
||||||
{presets.map((preset) => (
|
|
||||||
<option key={preset.id} value={preset.id}>
|
|
||||||
{preset.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PropertySliderField
|
|
||||||
label=""
|
|
||||||
ariaLabelSlider={`${label} 슬라이더`}
|
|
||||||
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
|
|
||||||
value={Number.isFinite(value) ? value : 0}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
onChange={onChangeValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
|
|
||||||
export type PropertySliderFieldProps = {
|
|
||||||
// 슬라이더 그룹 전체 라벨 텍스트
|
|
||||||
label: string;
|
|
||||||
// 슬라이더 요소에 대한 접근성 라벨
|
|
||||||
ariaLabelSlider: string;
|
|
||||||
// 텍스트 입력 요소에 대한 접근성 라벨
|
|
||||||
ariaLabelInput: string;
|
|
||||||
// 현재 값 (슬라이더와 입력이 공유)
|
|
||||||
value: number;
|
|
||||||
// 최소 값
|
|
||||||
min: number;
|
|
||||||
// 최대 값
|
|
||||||
max: number;
|
|
||||||
// 슬라이더 스텝
|
|
||||||
step: number;
|
|
||||||
// 값 변경 콜백
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PropertySliderField({
|
|
||||||
label,
|
|
||||||
ariaLabelSlider,
|
|
||||||
ariaLabelInput,
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
onChange,
|
|
||||||
}: PropertySliderFieldProps) {
|
|
||||||
// 슬라이더 변경 시 상위에서 전달받은 onChange에 숫자 값으로 전달한다.
|
|
||||||
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const numeric = Number(event.target.value || 0);
|
|
||||||
onChange(numeric);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 입력 변경 시에도 숫자 값으로 파싱하여 onChange를 호출한다.
|
|
||||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const raw = event.target.value;
|
|
||||||
const numeric = Number(raw);
|
|
||||||
if (!Number.isNaN(numeric)) {
|
|
||||||
onChange(numeric);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
aria-label={ariaLabelSlider}
|
|
||||||
value={value}
|
|
||||||
onChange={handleSliderChange}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label={ariaLabelInput}
|
|
||||||
value={Number.isFinite(value) ? value : ""}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
TextBlockProps,
|
|
||||||
ButtonBlockProps,
|
|
||||||
ImageBlockProps,
|
|
||||||
SectionBlockProps,
|
|
||||||
FormBlockProps,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
|
||||||
interface PublicPageRendererProps {
|
|
||||||
blocks: Block[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
|
||||||
|
|
||||||
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
|
||||||
const [formMessage, setFormMessage] = useState<string>("");
|
|
||||||
|
|
||||||
const renderBlock = (block: Block) => {
|
|
||||||
if (block.type === "text") {
|
|
||||||
const props = block.props as TextBlockProps;
|
|
||||||
|
|
||||||
const alignClass =
|
|
||||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
|
||||||
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
key={block.id}
|
|
||||||
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
|
||||||
>
|
|
||||||
{props.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "button") {
|
|
||||||
const props = block.props as ButtonBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={block.id}
|
|
||||||
href={props.href}
|
|
||||||
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="whitespace-pre-wrap">{props.label}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "form") {
|
|
||||||
const props = block.props as FormBlockProps;
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const form = e.currentTarget;
|
|
||||||
const data = new FormData(form);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setFormStatus("submitting");
|
|
||||||
setFormMessage("");
|
|
||||||
|
|
||||||
const res = await fetch("/api/forms/submit", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setFormStatus("success");
|
|
||||||
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
|
|
||||||
form.reset();
|
|
||||||
} else {
|
|
||||||
setFormStatus("error");
|
|
||||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PublicPageRenderer] form submit error", error);
|
|
||||||
setFormStatus("error");
|
|
||||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-slate-200">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>이름</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name="name"
|
|
||||||
placeholder="이름을 입력하세요"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>이메일</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>메시지</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name="message"
|
|
||||||
placeholder="전달할 내용을 입력하세요"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={formStatus === "submitting"}
|
|
||||||
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{formStatus === "submitting" ? "전송 중..." : "폼 전송"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{formStatus !== "idle" && formMessage ? (
|
|
||||||
<p
|
|
||||||
className={`text-xs mt-1 ${
|
|
||||||
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "image") {
|
|
||||||
const props = block.props as ImageBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={block.id} className="w-full flex justify-center">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={props.src || ""} alt={props.alt} className="max-w-full h-auto object-contain" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSection = (section: Block) => {
|
|
||||||
const props = section.props as SectionBlockProps;
|
|
||||||
|
|
||||||
const bgClass =
|
|
||||||
props.background === "muted" ? "bg-slate-900" : props.background === "primary" ? "bg-sky-900" : "bg-slate-950";
|
|
||||||
|
|
||||||
const pyClass = props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
|
|
||||||
|
|
||||||
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section key={section.id} className={`${bgClass} ${pyClass}`}>
|
|
||||||
<div className="mx-auto max-w-5xl px-4">
|
|
||||||
<div className="flex gap-8">
|
|
||||||
{columns.map((col) => {
|
|
||||||
const basis = `${(col.span / 12) * 100}%`;
|
|
||||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={col.id} className="flex flex-col gap-4" style={{ flexBasis: basis }}>
|
|
||||||
{columnBlocks.map((b) => (
|
|
||||||
<div key={b.id}>{renderBlock(b)}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col bg-slate-950 text-slate-50">
|
|
||||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
|
||||||
{rootBlocks.length > 0 && (
|
|
||||||
<section className="bg-slate-950 py-12">
|
|
||||||
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
|
||||||
{rootBlocks.map((b) => (
|
|
||||||
<div key={b.id}>{renderBlock(b)}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 섹션 블록들 */}
|
|
||||||
{sectionBlocks.map((section) => renderSection(section))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +1,23 @@
|
|||||||
import { createStore } from "zustand";
|
import { createStore } from "zustand";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
|
||||||
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
|
||||||
import { createBlogTemplateBlocks } from "@/app/editor/templates/blogTemplate";
|
|
||||||
import { createTeamTemplateBlocks } from "@/app/editor/templates/teamTemplate";
|
|
||||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
|
||||||
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
|
||||||
import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
|
|
||||||
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
|
|
||||||
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
|
|
||||||
|
|
||||||
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록
|
// 블록 타입 정의: 텍스트/버튼/이미지/섹션 블록
|
||||||
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list" | "form";
|
export type BlockType = "text" | "button" | "image" | "section";
|
||||||
|
|
||||||
// 텍스트 블록 속성
|
// 텍스트 블록 속성
|
||||||
export interface TextBlockProps {
|
export interface TextBlockProps {
|
||||||
text: string;
|
text: string;
|
||||||
// 텍스트 정렬: 기본은 left
|
// 텍스트 정렬: 기본은 left
|
||||||
align: "left" | "center" | "right";
|
align: "left" | "center" | "right";
|
||||||
// 텍스트 크기: 기본은 base (기존 필드, 하위호환 유지)
|
// 텍스트 크기: 기본은 base
|
||||||
size: "sm" | "base" | "lg";
|
size: "sm" | "base" | "lg";
|
||||||
|
|
||||||
// 폰트 크기 모드: 프리셋 스케일 또는 커스텀 값
|
|
||||||
fontSizeMode?: "scale" | "custom";
|
|
||||||
// 폰트 크기 스케일 (scale 모드일 때 사용)
|
|
||||||
fontSizeScale?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
|
|
||||||
// 폰트 크기 커스텀 값 (예: "18px", "1.125rem")
|
|
||||||
fontSizeCustom?: string;
|
|
||||||
|
|
||||||
// 줄 간격 모드: 프리셋 스케일 또는 커스텀 값
|
|
||||||
lineHeightMode?: "scale" | "custom";
|
|
||||||
// 줄 간격 스케일
|
|
||||||
lineHeightScale?: "tight" | "snug" | "normal" | "relaxed" | "loose";
|
|
||||||
// 줄 간격 커스텀 값 (예: "1.4", "24px")
|
|
||||||
lineHeightCustom?: string;
|
|
||||||
|
|
||||||
// 폰트 굵기 모드: 프리셋 스케일 또는 커스텀 값
|
|
||||||
fontWeightMode?: "scale" | "custom";
|
|
||||||
// 폰트 굵기 스케일
|
|
||||||
fontWeightScale?: "normal" | "medium" | "semibold" | "bold";
|
|
||||||
// 폰트 굵기 커스텀 값 (예: "500", "650")
|
|
||||||
fontWeightCustom?: string;
|
|
||||||
|
|
||||||
// 글자 간격 커스텀 값 (em 단위, 예: "0.05em", "-0.02em")
|
|
||||||
letterSpacingCustom?: string;
|
|
||||||
|
|
||||||
// 텍스트 색상 모드: 팔레트 또는 커스텀 웹색상
|
|
||||||
colorMode?: "palette" | "custom";
|
|
||||||
// 텍스트 색상 팔레트 (디자인 토큰)
|
|
||||||
colorPalette?:
|
|
||||||
| "default"
|
|
||||||
| "muted"
|
|
||||||
| "strong"
|
|
||||||
| "accent"
|
|
||||||
| "danger"
|
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "info"
|
|
||||||
| "neutral";
|
|
||||||
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
|
|
||||||
colorCustom?: string;
|
|
||||||
|
|
||||||
// 최대 너비 모드: 프리셋 또는 커스텀 값
|
|
||||||
maxWidthMode?: "scale" | "custom";
|
|
||||||
// 최대 너비 스케일
|
|
||||||
maxWidthScale?: "none" | "prose" | "narrow";
|
|
||||||
// 최대 너비 커스텀 값 (예: "600px", "40rem")
|
|
||||||
maxWidthCustom?: string;
|
|
||||||
|
|
||||||
// 텍스트 장식
|
|
||||||
underline?: boolean;
|
|
||||||
strike?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버튼 블록 속성
|
// 버튼 블록 속성
|
||||||
export interface ButtonBlockProps {
|
export interface ButtonBlockProps {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
align?: "left" | "center" | "right";
|
// TODO: variant, size 등은 추후 확장
|
||||||
// 버튼 크기: 더 세분화된 스케일(xs~xl)
|
|
||||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
||||||
variant?: "solid" | "outline" | "ghost";
|
|
||||||
colorPalette?: "primary" | "muted" | "danger" | "success" | "neutral";
|
|
||||||
fullWidth?: boolean;
|
|
||||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
|
||||||
// 버튼 텍스트 크기 (예: "14px")
|
|
||||||
fontSizeCustom?: string;
|
|
||||||
// 버튼 텍스트 줄 간격 (예: "1.4")
|
|
||||||
lineHeightCustom?: string;
|
|
||||||
// 버튼 텍스트 글자 간격 (em 단위, 예: "0.05em")
|
|
||||||
letterSpacingCustom?: string;
|
|
||||||
// 채움(배경) 색상 커스텀 값
|
|
||||||
fillColorCustom?: string;
|
|
||||||
// 외곽선(보더) 색상 커스텀 값
|
|
||||||
strokeColorCustom?: string;
|
|
||||||
// 버튼 텍스트 색상 커스텀 값
|
|
||||||
textColorCustom?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 블록 속성
|
// 이미지 블록 속성
|
||||||
@@ -105,90 +26,31 @@ export interface ImageBlockProps {
|
|||||||
alt: string;
|
alt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 구분선 블록 속성
|
|
||||||
export interface DividerBlockProps {
|
|
||||||
align: "left" | "center" | "right";
|
|
||||||
thickness: "thin" | "medium";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 리스트 블록 속성
|
|
||||||
export interface ListBlockProps {
|
|
||||||
items: string[];
|
|
||||||
ordered: boolean;
|
|
||||||
align: "left" | "center" | "right";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 섹션 블록 속성
|
// 섹션 블록 속성
|
||||||
export interface SectionBlockProps {
|
export interface SectionBlockProps {
|
||||||
background: "default" | "muted" | "primary";
|
background: "default" | "muted" | "primary";
|
||||||
paddingY: "sm" | "md" | "lg";
|
paddingY: "sm" | "md" | "lg";
|
||||||
// 레이아웃 컬럼 정의 (12 그리드 기준 span)
|
|
||||||
columns: Array<{
|
|
||||||
id: string;
|
|
||||||
span: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 폼 블록 속성 (1차 MVP: 고정 contact 폼)
|
|
||||||
export interface FormBlockProps {
|
|
||||||
kind: "contact";
|
|
||||||
// 전송 대상: 현재는 internal 만 지원, 이후 webhook 등 확장 가능
|
|
||||||
submitTarget: "internal";
|
|
||||||
successMessage?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공통 블록 모델
|
// 공통 블록 모델
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
props:
|
props: TextBlockProps | ButtonBlockProps | ImageBlockProps | SectionBlockProps;
|
||||||
| TextBlockProps
|
|
||||||
| ButtonBlockProps
|
|
||||||
| ImageBlockProps
|
|
||||||
| SectionBlockProps
|
|
||||||
| DividerBlockProps
|
|
||||||
| ListBlockProps
|
|
||||||
| FormBlockProps;
|
|
||||||
// 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null)
|
|
||||||
sectionId?: string | null;
|
|
||||||
columnId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 에디터 상태 인터페이스
|
// 에디터 상태 인터페이스
|
||||||
export interface EditorState {
|
export interface EditorState {
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
selectedBlockId: string | null;
|
selectedBlockId: string | null;
|
||||||
history: Block[][];
|
|
||||||
future: Block[][];
|
|
||||||
addTextBlock: () => void;
|
addTextBlock: () => void;
|
||||||
addButtonBlock: () => void;
|
addButtonBlock: () => void;
|
||||||
addImageBlock: () => void;
|
addImageBlock: () => void;
|
||||||
addDividerBlock: () => void;
|
|
||||||
addListBlock: () => void;
|
|
||||||
addSectionBlock: () => void;
|
addSectionBlock: () => void;
|
||||||
addFormBlock: () => void;
|
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps>) => void;
|
||||||
addHeroTemplateSection: () => void;
|
|
||||||
addFeaturesTemplateSection: () => void;
|
|
||||||
addCtaTemplateSection: () => void;
|
|
||||||
addFaqTemplateSection: () => void;
|
|
||||||
addPricingTemplateSection: () => void;
|
|
||||||
addTestimonialsTemplateSection: () => void;
|
|
||||||
addBlogTemplateSection: () => void;
|
|
||||||
addTeamTemplateSection: () => void;
|
|
||||||
addFooterTemplateSection: () => void;
|
|
||||||
updateBlock: (
|
|
||||||
id: string,
|
|
||||||
partial: Partial<TextBlockProps> | Partial<ButtonBlockProps>,
|
|
||||||
) => void;
|
|
||||||
selectBlock: (id: string | null) => void;
|
selectBlock: (id: string | null) => void;
|
||||||
replaceBlocks: (blocks: Block[]) => void;
|
replaceBlocks: (blocks: Block[]) => void;
|
||||||
reorderBlocks: (activeId: string, overId: string) => void;
|
reorderBlocks: (activeId: string, overId: string) => void;
|
||||||
moveBlock: (id: string, sectionId: string | null, columnId: string | null) => void;
|
|
||||||
undo: () => void;
|
|
||||||
redo: () => void;
|
|
||||||
removeBlock: (id: string) => void;
|
|
||||||
duplicateBlock: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
||||||
@@ -200,31 +62,10 @@ const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
|||||||
const createEditorState = (set: any, get: any): EditorState => ({
|
const createEditorState = (set: any, get: any): EditorState => ({
|
||||||
blocks: [],
|
blocks: [],
|
||||||
selectedBlockId: null,
|
selectedBlockId: null,
|
||||||
history: [],
|
|
||||||
future: [],
|
|
||||||
|
|
||||||
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
||||||
addTextBlock: () => {
|
addTextBlock: () => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
|
|
||||||
const { selectedBlockId, blocks } = get();
|
|
||||||
let sectionId: string | null = null;
|
|
||||||
let columnId: string | null = null;
|
|
||||||
|
|
||||||
if (selectedBlockId) {
|
|
||||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
|
||||||
if (target) {
|
|
||||||
if (target.type === "section") {
|
|
||||||
const sProps = target.props as SectionBlockProps;
|
|
||||||
sectionId = target.id;
|
|
||||||
columnId = sProps.columns?.[0]?.id ?? null;
|
|
||||||
} else {
|
|
||||||
sectionId = (target as any).sectionId ?? null;
|
|
||||||
columnId = (target as any).columnId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlock: Block = {
|
const newBlock: Block = {
|
||||||
id,
|
id,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -233,205 +74,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
align: "left",
|
align: "left",
|
||||||
size: "base",
|
size: "base",
|
||||||
},
|
},
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
history: [...state.history, blocks],
|
|
||||||
future: [],
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
|
||||||
addHeroTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
|
||||||
addFeaturesTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
|
||||||
addBlogTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
|
||||||
addTeamTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
|
||||||
addFooterTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
|
||||||
addCtaTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
|
||||||
addFaqTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
|
||||||
addPricingTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
|
||||||
addTestimonialsTemplateSection: () => {
|
|
||||||
const sectionId = createId();
|
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
|
||||||
sectionId,
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: newBlocks,
|
|
||||||
selectedBlockId: lastSelectedId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
|
// 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
|
||||||
addImageBlock: () => {
|
addImageBlock: () => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
|
|
||||||
const { selectedBlockId, blocks } = get();
|
|
||||||
let sectionId: string | null = null;
|
|
||||||
let columnId: string | null = null;
|
|
||||||
|
|
||||||
if (selectedBlockId) {
|
|
||||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
|
||||||
if (target) {
|
|
||||||
if (target.type === "section") {
|
|
||||||
const sProps = target.props as SectionBlockProps;
|
|
||||||
sectionId = target.id;
|
|
||||||
columnId = sProps.columns?.[0]?.id ?? null;
|
|
||||||
} else {
|
|
||||||
sectionId = (target as any).sectionId ?? null;
|
|
||||||
columnId = (target as any).columnId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlock: Block = {
|
const newBlock: Block = {
|
||||||
id,
|
id,
|
||||||
type: "image",
|
type: "image",
|
||||||
@@ -439,87 +92,6 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
src: "",
|
src: "",
|
||||||
alt: "이미지 설명",
|
alt: "이미지 설명",
|
||||||
},
|
},
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
|
||||||
blocks: [...state.blocks, newBlock],
|
|
||||||
selectedBlockId: id,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// 구분선 블록 추가: 기본 정렬/두께와 함께 생성 후 선택 상태로 만든다
|
|
||||||
addDividerBlock: () => {
|
|
||||||
const id = createId();
|
|
||||||
|
|
||||||
const { selectedBlockId, blocks } = get();
|
|
||||||
let sectionId: string | null = null;
|
|
||||||
let columnId: string | null = null;
|
|
||||||
|
|
||||||
if (selectedBlockId) {
|
|
||||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
|
||||||
if (target) {
|
|
||||||
if (target.type === "section") {
|
|
||||||
const sProps = target.props as SectionBlockProps;
|
|
||||||
sectionId = target.id;
|
|
||||||
columnId = sProps.columns?.[0]?.id ?? null;
|
|
||||||
} else {
|
|
||||||
sectionId = (target as any).sectionId ?? null;
|
|
||||||
columnId = (target as any).columnId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlock: Block = {
|
|
||||||
id,
|
|
||||||
type: "divider",
|
|
||||||
props: {
|
|
||||||
align: "center",
|
|
||||||
thickness: "thin",
|
|
||||||
},
|
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
|
||||||
blocks: [...state.blocks, newBlock],
|
|
||||||
selectedBlockId: id,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// 리스트 블록 추가: 기본 항목/정렬과 함께 생성 후 선택 상태로 만든다
|
|
||||||
addListBlock: () => {
|
|
||||||
const id = createId();
|
|
||||||
|
|
||||||
const { selectedBlockId, blocks } = get();
|
|
||||||
let sectionId: string | null = null;
|
|
||||||
let columnId: string | null = null;
|
|
||||||
|
|
||||||
if (selectedBlockId) {
|
|
||||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
|
||||||
if (target) {
|
|
||||||
if (target.type === "section") {
|
|
||||||
const sProps = target.props as SectionBlockProps;
|
|
||||||
sectionId = target.id;
|
|
||||||
columnId = sProps.columns?.[0]?.id ?? null;
|
|
||||||
} else {
|
|
||||||
sectionId = (target as any).sectionId ?? null;
|
|
||||||
columnId = (target as any).columnId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlock: Block = {
|
|
||||||
id,
|
|
||||||
type: "list",
|
|
||||||
props: {
|
|
||||||
items: ["리스트 아이템 1"],
|
|
||||||
ordered: false,
|
|
||||||
align: "left",
|
|
||||||
},
|
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
@@ -537,15 +109,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
props: {
|
props: {
|
||||||
background: "default",
|
background: "default",
|
||||||
paddingY: "md",
|
paddingY: "md",
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
id: `${id}_col_1`,
|
|
||||||
span: 12,
|
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
@@ -557,42 +121,13 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
|
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
|
||||||
addButtonBlock: () => {
|
addButtonBlock: () => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
|
|
||||||
const { selectedBlockId, blocks } = get();
|
|
||||||
let sectionId: string | null = null;
|
|
||||||
let columnId: string | null = null;
|
|
||||||
|
|
||||||
if (selectedBlockId) {
|
|
||||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
|
||||||
if (target) {
|
|
||||||
if (target.type === "section") {
|
|
||||||
const sProps = target.props as SectionBlockProps;
|
|
||||||
sectionId = target.id;
|
|
||||||
columnId = sProps.columns?.[0]?.id ?? null;
|
|
||||||
} else {
|
|
||||||
sectionId = (target as any).sectionId ?? null;
|
|
||||||
columnId = (target as any).columnId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newBlock: Block = {
|
const newBlock: Block = {
|
||||||
id,
|
id,
|
||||||
type: "button",
|
type: "button",
|
||||||
props: {
|
props: {
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
href: "#",
|
href: "#",
|
||||||
align: "left",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: false,
|
|
||||||
borderRadius: "md",
|
|
||||||
fontSizeCustom: "14px",
|
|
||||||
fillColorCustom: "",
|
|
||||||
strokeColorCustom: "",
|
|
||||||
},
|
},
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
@@ -608,7 +143,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
block.id === id
|
block.id === id
|
||||||
? {
|
? {
|
||||||
...block,
|
...block,
|
||||||
props: { ...(block.props as any), ...(partial as any) },
|
props: { ...block.props, ...partial },
|
||||||
}
|
}
|
||||||
: block,
|
: block,
|
||||||
),
|
),
|
||||||
@@ -650,104 +185,6 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
return { blocks: updated };
|
return { blocks: updated };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
moveBlock: (id, sectionId, columnId) => {
|
|
||||||
set((state: EditorState) => ({
|
|
||||||
blocks: state.blocks.map((block: Block) =>
|
|
||||||
block.id === id
|
|
||||||
? {
|
|
||||||
...block,
|
|
||||||
sectionId,
|
|
||||||
columnId,
|
|
||||||
}
|
|
||||||
: block,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
removeBlock: (id) => {
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const current = state.blocks;
|
|
||||||
const index = current.findIndex((b) => b.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextBlocks = current.filter((b) => b.id !== id);
|
|
||||||
|
|
||||||
// 선택 상태는 삭제된 블록의 이전 블록 또는 다음 블록으로 이동한다.
|
|
||||||
let nextSelected: string | null = null;
|
|
||||||
if (nextBlocks.length > 0) {
|
|
||||||
const prevIndex = index - 1;
|
|
||||||
if (prevIndex >= 0) {
|
|
||||||
nextSelected = nextBlocks[prevIndex].id;
|
|
||||||
} else {
|
|
||||||
// 첫 블록이 삭제된 경우, 새 첫 블록을 선택한다.
|
|
||||||
nextSelected = nextBlocks[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
blocks: nextBlocks,
|
|
||||||
selectedBlockId: nextSelected,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
duplicateBlock: (id) => {
|
|
||||||
set((state: EditorState) => {
|
|
||||||
const current = state.blocks;
|
|
||||||
const index = current.findIndex((b) => b.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = current[index];
|
|
||||||
const newId = createId();
|
|
||||||
|
|
||||||
const cloned: Block = {
|
|
||||||
...original,
|
|
||||||
id: newId,
|
|
||||||
// props 는 얕은 복사로 충분 (현재 props 는 모두 평면 구조)
|
|
||||||
props: { ...(original.props as any) },
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextBlocks = [...current];
|
|
||||||
nextBlocks.splice(index + 1, 0, cloned);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
blocks: nextBlocks,
|
|
||||||
selectedBlockId: newId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
undo: () => {
|
|
||||||
const { history, blocks } = get();
|
|
||||||
if (history.length === 0) return;
|
|
||||||
|
|
||||||
const previous = history[history.length - 1];
|
|
||||||
const nextHistory = history.slice(0, -1);
|
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
|
||||||
blocks: previous,
|
|
||||||
selectedBlockId: previous.length > 0 ? previous[previous.length - 1].id : null,
|
|
||||||
history: nextHistory,
|
|
||||||
future: [...state.future, blocks],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
redo: () => {
|
|
||||||
const { future, blocks } = get();
|
|
||||||
if (future.length === 0) return;
|
|
||||||
|
|
||||||
const next = future[future.length - 1];
|
|
||||||
const nextFuture = future.slice(0, -1);
|
|
||||||
|
|
||||||
set((state: EditorState) => ({
|
|
||||||
blocks: next,
|
|
||||||
selectedBlockId: next.length > 0 ? next[next.length - 1].id : null,
|
|
||||||
history: [...state.history, blocks],
|
|
||||||
future: nextFuture,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
/* Builder export/global design system classes */
|
|
||||||
|
|
||||||
/*
|
|
||||||
디자인 토큰: 폰트/라인하이트/색상/텍스트 폭
|
|
||||||
- 에디터 프리뷰와 내보내기 HTML 모두 이 토큰과 pb-* 클래스를 공유한다.
|
|
||||||
*/
|
|
||||||
:root {
|
|
||||||
/* Font sizes (rem 단위, Tailwind 스케일과 유사) */
|
|
||||||
--pb-font-xs: 0.75rem; /* 12px */
|
|
||||||
--pb-font-sm: 0.875rem; /* 14px */
|
|
||||||
--pb-font-base: 1rem; /* 16px */
|
|
||||||
--pb-font-lg: 1.125rem; /* 18px */
|
|
||||||
--pb-font-xl: 1.25rem; /* 20px */
|
|
||||||
--pb-font-2xl: 1.5rem; /* 24px */
|
|
||||||
--pb-font-3xl: 1.875rem; /* 30px */
|
|
||||||
|
|
||||||
/* Line heights */
|
|
||||||
--pb-leading-tight: 1.25;
|
|
||||||
--pb-leading-snug: 1.35;
|
|
||||||
--pb-leading-normal: 1.5;
|
|
||||||
--pb-leading-relaxed: 1.7;
|
|
||||||
--pb-leading-loose: 1.9;
|
|
||||||
|
|
||||||
/* Text colors (팔레트) */
|
|
||||||
--pb-color-text-default: #e5e7eb; /* slate-200 정도 */
|
|
||||||
--pb-color-text-muted: #9ca3af; /* slate-400 */
|
|
||||||
--pb-color-text-strong: #f9fafb; /* slate-50 */
|
|
||||||
--pb-color-text-accent: #38bdf8; /* sky-400 */
|
|
||||||
--pb-color-text-danger: #f97373; /* red-400 근처 */
|
|
||||||
|
|
||||||
/* Text max width */
|
|
||||||
--pb-text-maxw-prose: 60ch;
|
|
||||||
--pb-text-maxw-narrow: 40ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 에디터/프리뷰 공통 기본 타이포 설정 (em/rem 기반) */
|
|
||||||
body {
|
|
||||||
font-size: 1rem; /* 기본 16px 기준 */
|
|
||||||
line-height: 1.5; /* 읽기 좋은 기본 라인하이트 */
|
|
||||||
letter-spacing: 0em; /* 기본 글자 간격은 0em */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text alignment */
|
|
||||||
.pb-text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.pb-text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.pb-text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text size scale (디자인 토큰 기반) */
|
|
||||||
.pb-text-xs {
|
|
||||||
font-size: var(--pb-font-xs);
|
|
||||||
}
|
|
||||||
.pb-text-sm {
|
|
||||||
font-size: var(--pb-font-sm);
|
|
||||||
}
|
|
||||||
.pb-text-base {
|
|
||||||
font-size: var(--pb-font-base);
|
|
||||||
}
|
|
||||||
.pb-text-lg {
|
|
||||||
font-size: var(--pb-font-lg);
|
|
||||||
}
|
|
||||||
.pb-text-xl {
|
|
||||||
font-size: var(--pb-font-xl);
|
|
||||||
}
|
|
||||||
.pb-text-2xl {
|
|
||||||
font-size: var(--pb-font-2xl);
|
|
||||||
}
|
|
||||||
.pb-text-3xl {
|
|
||||||
font-size: var(--pb-font-3xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line-height scale */
|
|
||||||
.pb-leading-tight {
|
|
||||||
line-height: var(--pb-leading-tight);
|
|
||||||
}
|
|
||||||
.pb-leading-snug {
|
|
||||||
line-height: var(--pb-leading-snug);
|
|
||||||
}
|
|
||||||
.pb-leading-normal {
|
|
||||||
line-height: var(--pb-leading-normal);
|
|
||||||
}
|
|
||||||
.pb-leading-relaxed {
|
|
||||||
line-height: var(--pb-leading-relaxed);
|
|
||||||
}
|
|
||||||
.pb-leading-loose {
|
|
||||||
line-height: var(--pb-leading-loose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font weight scale */
|
|
||||||
.pb-font-normal {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.pb-font-medium {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.pb-font-semibold {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pb-font-bold {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text color palette */
|
|
||||||
.pb-text-color-default {
|
|
||||||
color: var(--pb-color-text-default);
|
|
||||||
}
|
|
||||||
.pb-text-color-muted {
|
|
||||||
color: var(--pb-color-text-muted);
|
|
||||||
}
|
|
||||||
.pb-text-color-strong {
|
|
||||||
color: var(--pb-color-text-strong);
|
|
||||||
}
|
|
||||||
.pb-text-color-accent {
|
|
||||||
color: var(--pb-color-text-accent);
|
|
||||||
}
|
|
||||||
.pb-text-color-danger {
|
|
||||||
color: var(--pb-color-text-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional text colors for presets */
|
|
||||||
.pb-text-color-success {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.pb-text-color-warning {
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
.pb-text-color-info {
|
|
||||||
color: #0ea5e9;
|
|
||||||
}
|
|
||||||
.pb-text-color-neutral {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text decoration & style */
|
|
||||||
.pb-underline {
|
|
||||||
text-decoration-line: underline;
|
|
||||||
}
|
|
||||||
.pb-line-through {
|
|
||||||
text-decoration-line: line-through;
|
|
||||||
}
|
|
||||||
.pb-italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text max width presets */
|
|
||||||
.pb-text-maxw-prose {
|
|
||||||
max-width: var(--pb-text-maxw-prose);
|
|
||||||
}
|
|
||||||
.pb-text-maxw-narrow {
|
|
||||||
max-width: var(--pb-text-maxw-narrow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-base {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
.pb-btn-size-xs {
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-sm {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-md {
|
|
||||||
padding: 0.375rem 1rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-lg {
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-xl {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-radius-none {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-sm {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-md {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-lg {
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-full {
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-solid-primary {
|
|
||||||
background-color: #0ea5e9;
|
|
||||||
border-color: #0284c7;
|
|
||||||
color: #0b1120;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-muted {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #4b5563;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-danger {
|
|
||||||
background-color: #ef4444;
|
|
||||||
border-color: #b91c1c;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-success {
|
|
||||||
background-color: #22c55e;
|
|
||||||
border-color: #15803d;
|
|
||||||
color: #022c22;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-neutral {
|
|
||||||
background-color: #4b5563;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-outline-primary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #38bdf8;
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-muted {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #4b5563;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-danger {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #f97373;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-success {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #22c55e;
|
|
||||||
color: #bbf7d0;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-neutral {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #64748b;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-ghost-primary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #38bdf8;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-muted {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-danger {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #f97373;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-success {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-neutral {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section background variants */
|
|
||||||
.pb-section-bg-default {
|
|
||||||
background-color: rgba(15, 23, 42, 0.6); /* slate-900/60 느낌 */
|
|
||||||
}
|
|
||||||
.pb-section-bg-muted {
|
|
||||||
background-color: rgba(15, 23, 42, 0.4); /* slate-950/40 느낌 */
|
|
||||||
}
|
|
||||||
.pb-section-bg-primary {
|
|
||||||
background-color: rgba(8, 47, 73, 0.4); /* sky-950/40 느낌 */
|
|
||||||
border-color: rgba(8, 47, 73, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section vertical padding scale */
|
|
||||||
.pb-section-py-sm {
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.pb-section-py-md {
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.pb-section-py-lg {
|
|
||||||
padding-top: 2.5rem;
|
|
||||||
padding-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { POST as createProject } from "@/app/api/projects/route";
|
||||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
import { GET as getProjectBySlug } from "@/app/api/projects/[slug]/route";
|
||||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
|
||||||
const inMemoryProjects: any[] = [];
|
|
||||||
|
|
||||||
vi.mock("@/generated/prisma/client", () => {
|
|
||||||
class PrismaClientMock {
|
|
||||||
project = {
|
|
||||||
create: async ({ data }: any) => {
|
|
||||||
const project = { id: inMemoryProjects.length + 1, ...data };
|
|
||||||
inMemoryProjects.push(project);
|
|
||||||
return project;
|
|
||||||
},
|
|
||||||
findUnique: async ({ where: { slug } }: any) => {
|
|
||||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient: PrismaClientMock };
|
|
||||||
});
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
@@ -38,8 +19,6 @@ describe("/api/projects", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
|
||||||
|
|
||||||
const createResponse = await createProject(
|
const createResponse = await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -58,8 +37,6 @@ describe("/api/projects", () => {
|
|||||||
expect(created.title).toBe(payload.title);
|
expect(created.title).toBe(payload.title);
|
||||||
expect(created.slug).toBe(payload.slug);
|
expect(created.slug).toBe(payload.slug);
|
||||||
|
|
||||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
|
||||||
|
|
||||||
const getResponse = await getProjectBySlug(
|
const getResponse = await getProjectBySlug(
|
||||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
||||||
{ params: { slug: payload.slug } } as any,
|
{ params: { slug: payload.slug } } as any,
|
||||||
|
|||||||
+12
-370
@@ -18,7 +18,6 @@ test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트'
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
// 텍스트 블록을 하나 추가한다.
|
// 텍스트 블록을 하나 추가한다.
|
||||||
@@ -26,8 +25,8 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
|
|||||||
|
|
||||||
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
|
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
const block = canvas.getByTestId("editor-block").nth(0);
|
const block = canvas.getByText("새 텍스트");
|
||||||
await block.dblclick({ force: true });
|
await block.dblclick();
|
||||||
|
|
||||||
// 편집 모드에서 텍스트를 변경한다.
|
// 편집 모드에서 텍스트를 변경한다.
|
||||||
const editor = page.getByRole("textbox", { name: "텍스트 블록 내용 편집" });
|
const editor = page.getByRole("textbox", { name: "텍스트 블록 내용 편집" });
|
||||||
@@ -47,8 +46,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
|
|||||||
|
|
||||||
// 캔버스 블록을 클릭해서 선택한다.
|
// 캔버스 블록을 클릭해서 선택한다.
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
const firstBlock = canvas.getByTestId("editor-block").nth(0);
|
await canvas.getByText("새 텍스트").click();
|
||||||
await firstBlock.click({ force: true });
|
|
||||||
|
|
||||||
// 우측 속성 패널에서 텍스트를 수정한다.
|
// 우측 속성 패널에서 텍스트를 수정한다.
|
||||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||||
@@ -72,15 +70,16 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
|
|||||||
const secondBlock = blocks.nth(1);
|
const secondBlock = blocks.nth(1);
|
||||||
|
|
||||||
// 첫 번째 블록을 선택하고, 속성 패널에서 텍스트를 변경한다.
|
// 첫 번째 블록을 선택하고, 속성 패널에서 텍스트를 변경한다.
|
||||||
await firstBlock.click({ force: true });
|
await firstBlock.click();
|
||||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||||
await sidebarEditor.fill("첫 번째 블록");
|
await sidebarEditor.fill("첫 번째 블록");
|
||||||
|
|
||||||
// 두 번째 블록을 선택하고, 다시 속성 패널에서 텍스트를 변경한다.
|
// 두 번째 블록을 선택하고, 다시 속성 패널에서 텍스트를 변경한다.
|
||||||
await secondBlock.click({ force: true });
|
await secondBlock.click();
|
||||||
await sidebarEditor.fill("두 번째 블록");
|
await sidebarEditor.fill("두 번째 블록");
|
||||||
|
|
||||||
// 캔버스에는 두 번째 블록의 텍스트가 보여야 한다.
|
// 캔버스에는 두 블록의 텍스트가 모두 보여야 한다.
|
||||||
|
await expect(canvas.getByText("첫 번째 블록")).toBeVisible();
|
||||||
await expect(canvas.getByText("두 번째 블록")).toBeVisible();
|
await expect(canvas.getByText("두 번째 블록")).toBeVisible();
|
||||||
|
|
||||||
// 현재 선택된 블록은 두 번째 블록이어야 한다.
|
// 현재 선택된 블록은 두 번째 블록이어야 한다.
|
||||||
@@ -98,7 +97,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
|
|||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
const block = canvas.getByTestId("editor-block").nth(0);
|
const block = canvas.getByTestId("editor-block").nth(0);
|
||||||
await block.click({ force: true });
|
await block.click();
|
||||||
|
|
||||||
// 속성 패널에서 정렬을 가운데로 변경한다.
|
// 속성 패널에서 정렬을 가운데로 변경한다.
|
||||||
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
||||||
@@ -113,36 +112,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
|
|||||||
await expect(block).toHaveClass(/text-lg/);
|
await expect(block).toHaveClass(/text-lg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경할 수 있어야 한다", async ({ page }) => {
|
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
// 텍스트 블록을 하나 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
const block = canvas.getByTestId("editor-block").nth(0);
|
|
||||||
|
|
||||||
// 더블클릭하여 인라인 편집 모드로 진입한다.
|
|
||||||
await block.dblclick({ force: true });
|
|
||||||
|
|
||||||
// 인라인 툴바 버튼을 이용해 가운데 정렬 및 큰 글자 크기로 변경한다.
|
|
||||||
const centerAlignButton = page.getByRole("button", { name: "가운데 정렬" });
|
|
||||||
const largeSizeButton = page.getByRole("button", { name: "글자 크게" });
|
|
||||||
|
|
||||||
await centerAlignButton.click();
|
|
||||||
await largeSizeButton.click();
|
|
||||||
|
|
||||||
// 편집을 종료하기 위해 블록 밖을 클릭한다.
|
|
||||||
await canvas.click({ position: { x: 5, y: 5 } });
|
|
||||||
|
|
||||||
// 캔버스 블록에 text-center 와 text-lg 클래스가 적용되어 있어야 한다.
|
|
||||||
await expect(block).toHaveClass(/text-center/);
|
|
||||||
await expect(block).toHaveClass(/text-lg/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 블록 상태가 복원되어야 한다", async ({ page }) => {
|
test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 블록 상태가 복원되어야 한다", async ({ page }) => {
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
@@ -156,7 +126,7 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
|
|||||||
const secondBlock = blocks.nth(1);
|
const secondBlock = blocks.nth(1);
|
||||||
|
|
||||||
// 첫 번째 블록: 텍스트/정렬/크기 설정
|
// 첫 번째 블록: 텍스트/정렬/크기 설정
|
||||||
await firstBlock.click({ force: true });
|
await firstBlock.click();
|
||||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||||
await sidebarEditor.fill("첫 번째 JSON 블록");
|
await sidebarEditor.fill("첫 번째 JSON 블록");
|
||||||
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
||||||
@@ -165,7 +135,7 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
|
|||||||
await sizeSelect.selectOption("sm");
|
await sizeSelect.selectOption("sm");
|
||||||
|
|
||||||
// 두 번째 블록: 텍스트/정렬/크기 설정
|
// 두 번째 블록: 텍스트/정렬/크기 설정
|
||||||
await secondBlock.click({ force: true });
|
await secondBlock.click();
|
||||||
await sidebarEditor.fill("두 번째 JSON 블록");
|
await sidebarEditor.fill("두 번째 JSON 블록");
|
||||||
await alignSelect.selectOption("right");
|
await alignSelect.selectOption("right");
|
||||||
await sizeSelect.selectOption("lg");
|
await sizeSelect.selectOption("lg");
|
||||||
@@ -202,7 +172,6 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태가 aria-selected로 노출되어야 한다", async ({ page }) => {
|
test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태가 aria-selected로 노출되어야 한다", async ({ page }) => {
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
@@ -224,13 +193,12 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
|
|||||||
await expect(secondBlock).toHaveAttribute("aria-selected", "true");
|
await expect(secondBlock).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
// 첫 번째 블록을 클릭하면 선택 상태가 전환되어야 한다.
|
// 첫 번째 블록을 클릭하면 선택 상태가 전환되어야 한다.
|
||||||
await firstBlock.click({ force: true });
|
await firstBlock.click();
|
||||||
await expect(firstBlock).toHaveAttribute("aria-selected", "true");
|
await expect(firstBlock).toHaveAttribute("aria-selected", "true");
|
||||||
await expect(secondBlock).toHaveAttribute("aria-selected", "false");
|
await expect(secondBlock).toHaveAttribute("aria-selected", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다", async ({ page }) => {
|
test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다", async ({ page }) => {
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
const canvas = page.getByTestId("editor-canvas");
|
||||||
@@ -248,7 +216,7 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
|
|||||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||||
await sidebarEditor.fill("블록 A");
|
await sidebarEditor.fill("블록 A");
|
||||||
|
|
||||||
await secondBlock.click({ force: true });
|
await secondBlock.click();
|
||||||
await sidebarEditor.fill("블록 B");
|
await sidebarEditor.fill("블록 B");
|
||||||
|
|
||||||
// 두 번째 블록의 드래그 핸들을 첫 번째 블록 위치로 드래그앤드롭 한다.
|
// 두 번째 블록의 드래그 핸들을 첫 번째 블록 위치로 드래그앤드롭 한다.
|
||||||
@@ -285,329 +253,3 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
|
|||||||
const updatedButton = canvas.getByRole("button", { name: "자세히 보기" });
|
const updatedButton = canvas.getByRole("button", { name: "자세히 보기" });
|
||||||
await expect(updatedButton).toBeVisible();
|
await expect(updatedButton).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면 컬럼이 변경되어야 한다", async ({ page }) => {
|
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
|
|
||||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
|
||||||
|
|
||||||
// 섹션을 선택한다 (캔버스 첫 블록).
|
|
||||||
const sectionBlock = canvas.getByTestId("editor-block").nth(0);
|
|
||||||
await sectionBlock.click({ force: true });
|
|
||||||
|
|
||||||
const layoutSelect = page.getByRole("combobox", { name: "섹션 레이아웃" });
|
|
||||||
await layoutSelect.selectOption("2col");
|
|
||||||
|
|
||||||
// 왼쪽 컬럼에 텍스트 블록을 하나 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0);
|
|
||||||
const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
|
|
||||||
|
|
||||||
// 왼쪽 컬럼 안에 텍스트 블록이 존재하는지 확인한다.
|
|
||||||
const leftBlocks = leftColumn.getByTestId("editor-block");
|
|
||||||
await expect(leftBlocks).toHaveCount(1);
|
|
||||||
|
|
||||||
// 왼쪽 컬럼의 첫 블록을 오른쪽 컬럼 droppable 영역으로 드래그한다.
|
|
||||||
const leftBlock = leftBlocks.nth(0);
|
|
||||||
const handle = leftBlock.getByRole("button", { name: "블록 드래그 핸들" });
|
|
||||||
await handle.dragTo(rightColumn);
|
|
||||||
|
|
||||||
// 드래그 이후, 오른쪽 컬럼 안에 블록이 존재해야 하고 왼쪽 컬럼은 비어 있어야 한다.
|
|
||||||
const rightBlocks = rightColumn.getByTestId("editor-block");
|
|
||||||
await expect(rightBlocks).toHaveCount(1);
|
|
||||||
|
|
||||||
// 왼쪽 컬럼에는 더 이상 블록이 없어야 한다.
|
|
||||||
await expect(leftColumn.getByTestId("editor-block")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼 블록이 캔버스에 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// Hero 템플릿을 추가한다.
|
|
||||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
// 섹션/블록이 하나 이상 존재해야 한다.
|
|
||||||
const blocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(blocks.first()).toBeVisible();
|
|
||||||
|
|
||||||
// Hero 헤드라인 텍스트가 포함된 블록이 보여야 한다.
|
|
||||||
await expect(canvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
|
|
||||||
|
|
||||||
// CTA 버튼도 렌더되어 있어야 한다.
|
|
||||||
const ctaButton = canvas.getByRole("button", { name: "지금 시작하기" });
|
|
||||||
await expect(ctaButton).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/설명)이 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
// Feature 제목들이 렌더되어야 한다.
|
|
||||||
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Feature 2 제목")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Feature 3 제목")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된 섹션이 캔버스에 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
|
||||||
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
|
|
||||||
await expect(ctaButton).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍 이상 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
|
|
||||||
await expect(canvas.getByText("자주 묻는 질문 1")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("첫 번째 질문에 대한 답변을 여기에 입력하세요.")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("자주 묻는 질문 2")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("자주 묻는 질문 3")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("Basic")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("₩9,900/월")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Pro")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("₩29,900/월")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Enterprise")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("문의")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("팀 전체가 만족하는 빌더입니다.")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Blog 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("블로그 포스트 1")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("블로그 포스트 2")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("블로그 포스트 3")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Team 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("홍길동")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Product Designer")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("김영희")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Frontend Engineer")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("이철수")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("Backend Engineer")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Footer 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
await expect(canvas.getByText("이용약관 · 개인정보처리방침")).toBeVisible();
|
|
||||||
await expect(canvas.getByText("© 2025 MyLanding. All rights reserved.")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 텍스트 블록을 하나 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
|
||||||
|
|
||||||
// Cmd/Ctrl + Z 로 Undo 한다.
|
|
||||||
const undoShortcut = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
|
|
||||||
await page.keyboard.press(undoShortcut);
|
|
||||||
|
|
||||||
// Undo 후에는 블록이 0개가 되어야 한다.
|
|
||||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
|
|
||||||
|
|
||||||
// Cmd/Ctrl + Shift + Z 로 Redo 한다.
|
|
||||||
const redoShortcut = process.platform === "darwin" ? "Meta+Shift+Z" : "Control+Shift+Z";
|
|
||||||
await page.keyboard.press(redoShortcut);
|
|
||||||
|
|
||||||
// Redo 후 다시 블록이 1개가 되어야 한다.
|
|
||||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => {
|
|
||||||
test.skip(process.env.CI === "true");
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 텍스트 블록을 세 개 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
const blocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(blocks).toHaveCount(3);
|
|
||||||
|
|
||||||
// 첫 번째 블록을 클릭해 선택한다.
|
|
||||||
await blocks.nth(0).click({ force: true });
|
|
||||||
await expect(blocks.nth(0)).toHaveAttribute("data-selected", "true");
|
|
||||||
|
|
||||||
// ArrowDown 으로 두 번째 블록으로 이동.
|
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expect(blocks.nth(1)).toHaveAttribute("data-selected", "true");
|
|
||||||
|
|
||||||
// 다시 ArrowDown 으로 세 번째 블록으로 이동.
|
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expect(blocks.nth(2)).toHaveAttribute("data-selected", "true");
|
|
||||||
|
|
||||||
// ArrowDown 을 한 번 더 눌러도 마지막에서 더 내려가지 않는다.
|
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expect(blocks.nth(2)).toHaveAttribute("data-selected", "true");
|
|
||||||
|
|
||||||
// ArrowUp 으로 두 번째 블록으로 올라간다.
|
|
||||||
await page.keyboard.press("ArrowUp");
|
|
||||||
await expect(blocks.nth(1)).toHaveAttribute("data-selected", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할 수 있어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 텍스트 블록 두 개를 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
const blocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(blocks).toHaveCount(2);
|
|
||||||
|
|
||||||
// 두 번째 블록을 선택한 다음, 속성 패널에서 "블록 삭제" 버튼을 클릭한다.
|
|
||||||
await blocks.nth(1).click({ force: true });
|
|
||||||
await page.getByRole("button", { name: "블록 삭제" }).click();
|
|
||||||
|
|
||||||
// 하나의 블록만 남아야 하며, 첫 번째 블록이 남아 있어야 한다.
|
|
||||||
const remainingBlocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(remainingBlocks).toHaveCount(1);
|
|
||||||
await expect(remainingBlocks.nth(0)).toHaveAttribute("data-selected", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 텍스트 블록을 하나 추가한다.
|
|
||||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
let blocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(blocks).toHaveCount(1);
|
|
||||||
|
|
||||||
// 블록의 텍스트를 식별 가능한 값으로 바꾼다.
|
|
||||||
await blocks.nth(0).click({ force: true });
|
|
||||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
|
||||||
await sidebarEditor.fill("복제 대상 블록");
|
|
||||||
|
|
||||||
// Cmd/Ctrl + D 로 복제한다.
|
|
||||||
const duplicateShortcut = process.platform === "darwin" ? "Meta+D" : "Control+D";
|
|
||||||
await page.keyboard.press(duplicateShortcut);
|
|
||||||
|
|
||||||
// 블록이 두 개가 되어야 한다.
|
|
||||||
blocks = canvas.getByTestId("editor-block");
|
|
||||||
await expect(blocks).toHaveCount(2);
|
|
||||||
|
|
||||||
// 두 블록 모두 동일한 텍스트를 가지고 있어야 한다.
|
|
||||||
await expect(blocks.nth(0)).toContainText("복제 대상 블록");
|
|
||||||
await expect(blocks.nth(1)).toContainText("복제 대상 블록");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고 정렬/두께를 변경할 수 있어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 구분선 블록을 추가한다.
|
|
||||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
|
||||||
|
|
||||||
const blocks = canvas.getByTestId("editor-block");
|
|
||||||
const dividerBlock = blocks.nth(0);
|
|
||||||
|
|
||||||
// 블록 추가 시 자동으로 선택된다고 가정하고 기본 정렬이 가운데인지 확인한다.
|
|
||||||
await expect(dividerBlock).toHaveClass(/text-center/);
|
|
||||||
|
|
||||||
// 속성 패널에서 정렬을 오른쪽으로 변경하면 클래스가 바뀌어야 한다.
|
|
||||||
const alignSelect = page.getByRole("combobox", { name: "구분선 정렬" });
|
|
||||||
await alignSelect.selectOption("right");
|
|
||||||
await expect(dividerBlock).toHaveClass(/text-right/);
|
|
||||||
|
|
||||||
// 두께를 보통으로 변경해도 에러 없이 동작해야 한다.
|
|
||||||
const thicknessSelect = page.getByRole("combobox", { name: "구분선 두께" });
|
|
||||||
await thicknessSelect.selectOption("medium");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/정렬을 수정할 수 있어야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const canvas = page.getByTestId("editor-canvas");
|
|
||||||
|
|
||||||
// 리스트 블록을 추가한다.
|
|
||||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
|
||||||
|
|
||||||
const blocks = canvas.getByTestId("editor-block");
|
|
||||||
const listBlock = blocks.nth(0);
|
|
||||||
|
|
||||||
// 기본 아이템 텍스트가 렌더되어야 한다.
|
|
||||||
await expect(listBlock).toContainText("리스트 아이템 1");
|
|
||||||
|
|
||||||
// 속성 패널에서 첫 번째 아이템 텍스트를 변경하면 캔버스에도 반영되어야 한다.
|
|
||||||
const firstItemInput = page.getByRole("textbox", { name: "리스트 첫 번째 아이템" });
|
|
||||||
await firstItemInput.fill("첫 번째 할 일");
|
|
||||||
await expect(listBlock).toContainText("첫 번째 할 일");
|
|
||||||
|
|
||||||
// 번호 매기기 토글을 켜고, 정렬을 가운데로 변경해도 에러 없이 동작해야 한다.
|
|
||||||
const orderedCheckbox = page.getByRole("checkbox", { name: "번호 매기기" });
|
|
||||||
await orderedCheckbox.check();
|
|
||||||
|
|
||||||
const alignSelect = page.getByRole("combobox", { name: "리스트 정렬" });
|
|
||||||
await alignSelect.selectOption("center");
|
|
||||||
await expect(listBlock).toHaveClass(/text-center/);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
// 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다.
|
|
||||||
|
|
||||||
test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => {
|
|
||||||
// 프리뷰 페이지로 이동한다.
|
|
||||||
await page.goto("/preview");
|
|
||||||
|
|
||||||
// 프리뷰 전용 헤더가 있어야 한다.
|
|
||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
||||||
|
|
||||||
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
|
|
||||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
|
|
||||||
// 에디터에서 Hero 템플릿을 추가한다.
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
|
|
||||||
const editorCanvas = page.getByTestId("editor-canvas");
|
|
||||||
await expect(editorCanvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
|
|
||||||
await expect(editorCanvas.getByRole("button", { name: "지금 시작하기" })).toBeVisible();
|
|
||||||
|
|
||||||
// 헤더의 프리뷰 열기 링크를 통해 /preview 로 이동한다 (클라이언트 내 내비게이션 유지).
|
|
||||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
|
||||||
|
|
||||||
// 프리뷰 헤더가 보여야 한다.
|
|
||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
||||||
|
|
||||||
// 프리뷰에서도 Hero 헤드라인과 CTA 버튼이 그대로 보여야 한다.
|
|
||||||
await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
|
|
||||||
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
|
|
||||||
await expect(page.getByText("지금 시작하기")).toBeVisible();
|
|
||||||
|
|
||||||
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
|
|
||||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
const editorCanvas = page.getByTestId("editor-canvas");
|
|
||||||
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
|
|
||||||
await expect(editorCanvas.getByText("Feature 2 제목")).toBeVisible();
|
|
||||||
await expect(editorCanvas.getByText("Feature 3 제목")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByText("Feature 1 제목")).toBeVisible();
|
|
||||||
await expect(page.getByText("Feature 2 제목")).toBeVisible();
|
|
||||||
await expect(page.getByText("Feature 3 제목")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
const editorCanvas = page.getByTestId("editor-canvas");
|
|
||||||
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
|
||||||
await expect(editorCanvas.getByRole("button", { name: "CTA 버튼" })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
|
||||||
await expect(page.getByText("CTA 버튼")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템플릿 컨텐츠를 보여줘야 한다", async ({ page }) => {
|
|
||||||
// 에디터에서 여러 템플릿을 추가한다.
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
|
||||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
|
||||||
|
|
||||||
// 모바일 뷰포트로 전환한다.
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
|
|
||||||
// 프리뷰로 이동한다.
|
|
||||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
|
||||||
|
|
||||||
// 프리뷰 헤더와 주요 템플릿 텍스트들이 모두 보여야 한다.
|
|
||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
||||||
|
|
||||||
// Hero 헤드라인
|
|
||||||
await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
|
|
||||||
|
|
||||||
// Features 제목 일부
|
|
||||||
await expect(page.getByText("Feature 1 제목")).toBeVisible();
|
|
||||||
|
|
||||||
// Pricing 플랜 이름/가격 일부
|
|
||||||
await expect(page.getByText("Basic")).toBeVisible();
|
|
||||||
await expect(page.getByText("₩9,900/월")).toBeVisible();
|
|
||||||
|
|
||||||
// Testimonials 본문 일부
|
|
||||||
await expect(page.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
||||||
import { ColorPickerField } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
// 슬라이더 필드가 라벨과 슬라이더, 입력 박스를 렌더링하고 onChange를 호출하는지 테스트
|
|
||||||
|
|
||||||
describe("PropertySliderField", () => {
|
|
||||||
it("라벨과 슬라이더, 텍스트 입력을 렌더링한다", () => {
|
|
||||||
const handleChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PropertySliderField
|
|
||||||
label="글자 크기"
|
|
||||||
ariaLabelSlider="글자 크기 슬라이더"
|
|
||||||
ariaLabelInput="글자 크기 커스텀"
|
|
||||||
value={16}
|
|
||||||
min={10}
|
|
||||||
max={72}
|
|
||||||
step={1}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("글자 크기")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("글자 크기 슬라이더")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("글자 크기 커스텀")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("슬라이더 변경 시 onChange가 호출된다", () => {
|
|
||||||
const handleChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PropertySliderField
|
|
||||||
label="줄 간격"
|
|
||||||
ariaLabelSlider="줄 간격 슬라이더"
|
|
||||||
ariaLabelInput="줄 간격 커스텀"
|
|
||||||
value={1.5}
|
|
||||||
min={0.5}
|
|
||||||
max={3}
|
|
||||||
step={0.1}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const slider = screen.getByLabelText("줄 간격 슬라이더") as HTMLInputElement;
|
|
||||||
fireEvent.change(slider, { target: { value: "2" } });
|
|
||||||
|
|
||||||
expect(handleChange).toHaveBeenCalledWith(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컬러 피커 필드가 컬러 인풋과 텍스트 입력, 팔레트 버튼을 렌더링하고 상호작용 되는지 테스트
|
|
||||||
|
|
||||||
describe("ColorPickerField", () => {
|
|
||||||
it("HEX 인풋과 컬러 인풋을 렌더링한다", () => {
|
|
||||||
const handleChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="텍스트 색상 HEX"
|
|
||||||
value="#ffffff"
|
|
||||||
onChange={handleChange}
|
|
||||||
palette={[{ id: "default", label: "기본", color: "#e5e7eb" }]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("텍스트 색상")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("텍스트 색상 피커")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("텍스트 색상 HEX")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("컬러 인풋 변경 시 onChange가 호출된다", () => {
|
|
||||||
const handleChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="텍스트 색상 HEX"
|
|
||||||
value="#ffffff"
|
|
||||||
onChange={handleChange}
|
|
||||||
palette={[{ id: "default", label: "기본", color: "#e5e7eb" }]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const colorInput = screen.getByLabelText("텍스트 색상 피커") as HTMLInputElement;
|
|
||||||
fireEvent.change(colorInput, { target: { value: "#000000" } });
|
|
||||||
|
|
||||||
expect(handleChange).toHaveBeenCalledWith("#000000");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,9 +3,6 @@ import {
|
|||||||
createEditorStore,
|
createEditorStore,
|
||||||
type TextBlockProps,
|
type TextBlockProps,
|
||||||
type ButtonBlockProps,
|
type ButtonBlockProps,
|
||||||
type ImageBlockProps,
|
|
||||||
type DividerBlockProps,
|
|
||||||
type ListBlockProps,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
} from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
|
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
|
||||||
@@ -25,69 +22,6 @@ describe("editorStore", () => {
|
|||||||
expect(selectedBlockId).toBe(blocks[0].id);
|
expect(selectedBlockId).toBe(blocks[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("텍스트 블록에 글자 간격(letterSpacingCustom)을 em 단위로 설정할 수 있어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
const { blocks } = store.getState();
|
|
||||||
const textBlock = blocks[0];
|
|
||||||
const textProps = textBlock.props as TextBlockProps;
|
|
||||||
|
|
||||||
expect(textProps.letterSpacingCustom).toBeUndefined();
|
|
||||||
|
|
||||||
store.getState().updateBlock(textBlock.id, {
|
|
||||||
letterSpacingCustom: "0.05em",
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const { blocks: updatedBlocks } = store.getState();
|
|
||||||
const updatedTextProps = updatedBlocks[0].props as TextBlockProps;
|
|
||||||
|
|
||||||
expect(updatedTextProps.letterSpacingCustom).toBe("0.05em");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("구분선 블록을 추가하면 기본 align/thickness 와 함께 추가되고 선택되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addDividerBlock();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].type).toBe("divider");
|
|
||||||
|
|
||||||
const dividerProps = blocks[0].props as DividerBlockProps;
|
|
||||||
expect(dividerProps.align).toBe("center");
|
|
||||||
expect(dividerProps.thickness).toBe("thin");
|
|
||||||
|
|
||||||
expect((blocks[0] as any).sectionId).toBeNull();
|
|
||||||
expect((blocks[0] as any).columnId).toBeNull();
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("리스트 블록을 추가하면 기본 items/ordered/align 과 함께 추가되고 선택되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addListBlock();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].type).toBe("list");
|
|
||||||
|
|
||||||
const listProps = blocks[0].props as ListBlockProps;
|
|
||||||
expect(Array.isArray(listProps.items)).toBe(true);
|
|
||||||
expect(listProps.items.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(listProps.ordered).toBe(false);
|
|
||||||
expect(listProps.align).toBe("left");
|
|
||||||
|
|
||||||
expect((blocks[0] as any).sectionId).toBeNull();
|
|
||||||
expect((blocks[0] as any).columnId).toBeNull();
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reorderBlocks 호출 시 블록 배열 순서가 변경되어야 한다", () => {
|
it("reorderBlocks 호출 시 블록 배열 순서가 변경되어야 한다", () => {
|
||||||
const store = createEditorStore();
|
const store = createEditorStore();
|
||||||
|
|
||||||
@@ -143,499 +77,4 @@ describe("editorStore", () => {
|
|||||||
expect(updatedButtonProps.href).toBe("https://example.com");
|
expect(updatedButtonProps.href).toBe("https://example.com");
|
||||||
expect(selectedBlockId).toBe(updatedBlocks[0].id);
|
expect(selectedBlockId).toBe(updatedBlocks[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("이미지 블록을 추가하면 기본 src/alt와 함께 추가되고 선택되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addImageBlock();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].type).toBe("image");
|
|
||||||
|
|
||||||
const imageProps = blocks[0].props as ImageBlockProps;
|
|
||||||
expect(imageProps.src).toBe("");
|
|
||||||
expect(imageProps.alt).toBe("이미지 설명");
|
|
||||||
|
|
||||||
// 루트에서 추가한 경우에는 sectionId/columnId 가 null 이어야 한다.
|
|
||||||
expect((blocks[0] as any).sectionId).toBeNull();
|
|
||||||
expect((blocks[0] as any).columnId).toBeNull();
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("섹션이 선택된 상태에서 텍스트 블록을 추가하면 해당 섹션의 첫 컬럼에 배치되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 섹션 블록을 하나 추가하고 기본 1컬럼 레이아웃을 사용한다.
|
|
||||||
store.getState().addSectionBlock();
|
|
||||||
|
|
||||||
const { blocks: afterSection } = store.getState();
|
|
||||||
expect(afterSection).toHaveLength(1);
|
|
||||||
const sectionBlock = afterSection[0];
|
|
||||||
expect(sectionBlock.type).toBe("section");
|
|
||||||
|
|
||||||
const sectionProps = sectionBlock.props as any;
|
|
||||||
expect(Array.isArray(sectionProps.columns)).toBe(true);
|
|
||||||
expect(sectionProps.columns.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// 섹션을 선택한 후 텍스트 블록을 추가한다.
|
|
||||||
store.getState().selectBlock(sectionBlock.id);
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
const { blocks: finalBlocks } = store.getState();
|
|
||||||
expect(finalBlocks).toHaveLength(2);
|
|
||||||
|
|
||||||
const textBlock = finalBlocks.find((b) => b.type === "text")!;
|
|
||||||
|
|
||||||
// 새 텍스트 블록의 sectionId/columnId가 선택된 섹션과 첫 컬럼을 가리켜야 한다.
|
|
||||||
expect((textBlock as any).sectionId).toBe(sectionBlock.id);
|
|
||||||
expect((textBlock as any).columnId).toBe(sectionProps.columns[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("블록이 선택된 상태에서 새 텍스트 블록을 추가하면 동일한 섹션/컬럼에 배치되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 섹션 + 텍스트 블록을 만든다.
|
|
||||||
store.getState().addSectionBlock();
|
|
||||||
const { blocks: afterSection } = store.getState();
|
|
||||||
const sectionBlock = afterSection[0];
|
|
||||||
const sectionProps = sectionBlock.props as any;
|
|
||||||
|
|
||||||
store.getState().selectBlock(sectionBlock.id);
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks } = store.getState();
|
|
||||||
const firstText = blocks.find((b) => b.type === "text")!;
|
|
||||||
|
|
||||||
// 첫 텍스트 블록의 위치를 기준으로 한다.
|
|
||||||
store.getState().selectBlock(firstText.id);
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
blocks = store.getState().blocks;
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text");
|
|
||||||
expect(textBlocks).toHaveLength(2);
|
|
||||||
|
|
||||||
const [t1, t2] = textBlocks as any[];
|
|
||||||
|
|
||||||
// 두 번째 텍스트 블록도 같은 섹션/컬럼에 있어야 한다.
|
|
||||||
expect(t1.sectionId).toBe(sectionBlock.id);
|
|
||||||
expect(t2.sectionId).toBe(sectionBlock.id);
|
|
||||||
expect(t1.columnId).toBe(sectionProps.columns[0].id);
|
|
||||||
expect(t2.columnId).toBe(sectionProps.columns[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moveBlock 호출 시 블록의 sectionId/columnId가 변경되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 섹션 + 텍스트 블록 1개를 만든다.
|
|
||||||
store.getState().addSectionBlock();
|
|
||||||
const { blocks: afterSection } = store.getState();
|
|
||||||
const sectionBlock = afterSection[0];
|
|
||||||
const sectionProps = sectionBlock.props as any;
|
|
||||||
|
|
||||||
store.getState().selectBlock(sectionBlock.id);
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks } = store.getState();
|
|
||||||
const textBlock = blocks.find((b) => b.type === "text") as any;
|
|
||||||
|
|
||||||
// 초기 위치는 섹션의 첫 컬럼이어야 한다.
|
|
||||||
expect(textBlock.sectionId).toBe(sectionBlock.id);
|
|
||||||
expect(textBlock.columnId).toBe(sectionProps.columns[0].id);
|
|
||||||
|
|
||||||
// 섹션 레이아웃을 2컬럼으로 바꾸고 두 번째 컬럼으로 이동시킨다.
|
|
||||||
const updatedColumns = [
|
|
||||||
{ id: `${sectionBlock.id}_col_1`, span: 6 },
|
|
||||||
{ id: `${sectionBlock.id}_col_2`, span: 6 },
|
|
||||||
];
|
|
||||||
|
|
||||||
store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any);
|
|
||||||
|
|
||||||
store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id);
|
|
||||||
|
|
||||||
blocks = store.getState().blocks;
|
|
||||||
const moved = blocks.find((b) => b.id === textBlock.id) as any;
|
|
||||||
|
|
||||||
expect(moved.sectionId).toBe(sectionBlock.id);
|
|
||||||
expect(moved.columnId).toBe(updatedColumns[1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Hero 템플릿 섹션을 추가하면 섹션과 기본 텍스트/버튼 블록들이 한번에 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 템플릿 액션을 호출한다고 가정한다.
|
|
||||||
// Hero 템플릿은 기본적으로 1개의 섹션과 최소 1개의 텍스트/1개의 버튼 블록을 생성한다고 정의한다.
|
|
||||||
// (구체적인 props 내용은 구현에서 맞춰가되, 타입/개수/섹션/컬럼 배치만 검증한다.)
|
|
||||||
store.getState().addHeroTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
// 섹션 1개 + 텍스트/버튼 블록이 최소 1개씩 존재해야 한다.
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text");
|
|
||||||
const buttonBlocks = blocks.filter((b) => b.type === "button");
|
|
||||||
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(buttonBlocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
|
|
||||||
// 템플릿에서 생성된 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치된다고 가정한다.
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const firstColumnId = columns[0].id;
|
|
||||||
|
|
||||||
textBlocks.forEach((tb: any) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(tb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
buttonBlocks.forEach((bb: any) => {
|
|
||||||
expect(bb.sectionId).toBe(section.id);
|
|
||||||
expect(bb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 마지막으로 생성된 블록(예: CTA 버튼)이 선택되어 있다고 가정한다.
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addBlogTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBe(3);
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 3개의 포스트 카드에 대해 각 컬럼에 최소 2개 텍스트(제목 + 요약)를 가정한다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Team 템플릿 섹션을 추가하면 3명의 팀 카드(이름/역할/소개)가 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addTeamTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBe(3);
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 3명의 팀원 카드에 대해 각 컬럼에 최소 3개 텍스트(이름/역할/소개)를 가정한다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(9);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Footer 템플릿 섹션을 추가하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addFooterTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const firstColumnId = columns[0].id;
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(tb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Features 템플릿 섹션을 추가하면 3컬럼 섹션과 각 컬럼의 제목/설명 텍스트가 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// Features 템플릿 액션을 호출한다고 가정한다.
|
|
||||||
store.getState().addFeaturesTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBe(3);
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
// 최소 3개의 feature 항목(제목/설명)을 가정하고, 각 컬럼에는 2개의 텍스트(제목+설명)가 있다고 본다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
|
||||||
|
|
||||||
// 모든 텍스트 블록이 동일 섹션에 속하는지 확인한다.
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("CTA 템플릿 섹션을 추가하면 텍스트와 버튼이 포함된 섹션이 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// CTA 템플릿 액션을 호출한다고 가정한다.
|
|
||||||
store.getState().addCtaTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const firstColumnId = columns[0].id;
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
const buttonBlocks = blocks.filter((b) => b.type === "button") as any[];
|
|
||||||
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(buttonBlocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(tb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
buttonBlocks.forEach((bb) => {
|
|
||||||
expect(bb.sectionId).toBe(section.id);
|
|
||||||
expect(bb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FAQ 템플릿 섹션을 추가하면 질문/답변 쌍이 포함된 섹션이 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addFaqTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const firstColumnId = columns[0].id;
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 최소 3개의 FAQ 항목(질문+답변 쌍)을 가정한다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(tb.columnId).toBe(firstColumnId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Pricing 템플릿 섹션을 추가하면 요금제 카드(플랜 이름/가격/설명)가 3개 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addPricingTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBe(3);
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 3개의 요금제에 대해 각 컬럼에 최소 2개 텍스트(플랜 이름 + 가격/설명)를 가정한다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Testimonials 템플릿 섹션을 추가하면 후기(본문/작성자)가 3개 생성되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addTestimonialsTemplateSection();
|
|
||||||
|
|
||||||
const { blocks, selectedBlockId } = store.getState();
|
|
||||||
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
expect(sectionBlocks).toHaveLength(1);
|
|
||||||
|
|
||||||
const section = sectionBlocks[0] as any;
|
|
||||||
const columns = (section.props as any).columns;
|
|
||||||
expect(Array.isArray(columns)).toBe(true);
|
|
||||||
expect(columns.length).toBe(3);
|
|
||||||
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 3개의 후기 카드에 대해 각 컬럼에 최소 2개 텍스트(본문 + 작성자)를 가정한다.
|
|
||||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
|
||||||
|
|
||||||
textBlocks.forEach((tb) => {
|
|
||||||
expect(tb.sectionId).toBe(section.id);
|
|
||||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("undo 호출 시 마지막 변경 이전의 블록 상태로 되돌려야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 텍스트 블록을 두 개 추가한다.
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks, selectedBlockId } = store.getState();
|
|
||||||
expect(blocks).toHaveLength(2);
|
|
||||||
|
|
||||||
const firstId = blocks[0].id;
|
|
||||||
|
|
||||||
store.getState().undo();
|
|
||||||
|
|
||||||
({ blocks, selectedBlockId } = store.getState());
|
|
||||||
|
|
||||||
// 마지막 추가 이전 상태로 되돌아가야 하므로 블록은 1개만 남아야 한다.
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].id).toBe(firstId);
|
|
||||||
expect(selectedBlockId).toBe(firstId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redo 호출 시 undo로 되돌린 변경을 다시 적용해야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks } = store.getState();
|
|
||||||
expect(blocks).toHaveLength(2);
|
|
||||||
|
|
||||||
store.getState().undo();
|
|
||||||
|
|
||||||
({ blocks } = store.getState());
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
|
|
||||||
store.getState().redo();
|
|
||||||
|
|
||||||
({ blocks } = store.getState());
|
|
||||||
expect(blocks).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removeBlock 호출 시 해당 블록이 삭제되고 선택 상태가 적절히 변경되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 텍스트 블록 두 개를 추가한다.
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks, selectedBlockId } = store.getState();
|
|
||||||
expect(blocks).toHaveLength(2);
|
|
||||||
|
|
||||||
const firstId = blocks[0].id;
|
|
||||||
const secondId = blocks[1].id;
|
|
||||||
|
|
||||||
// 두 번째 블록을 선택한 상태에서 삭제한다.
|
|
||||||
store.getState().selectBlock(secondId);
|
|
||||||
store.getState().removeBlock(secondId);
|
|
||||||
|
|
||||||
({ blocks, selectedBlockId } = store.getState());
|
|
||||||
|
|
||||||
// 두 번째 블록만 삭제되고 첫 번째 블록은 남아 있어야 한다.
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].id).toBe(firstId);
|
|
||||||
expect(selectedBlockId).toBe(firstId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicateBlock 호출 시 같은 내용의 블록이 같은 위치 정보로 복제되어야 한다", () => {
|
|
||||||
const store = createEditorStore();
|
|
||||||
|
|
||||||
// 섹션 + 텍스트 블록을 하나 만든다.
|
|
||||||
store.getState().addSectionBlock();
|
|
||||||
const { blocks: afterSection } = store.getState();
|
|
||||||
const sectionBlock = afterSection[0] as any;
|
|
||||||
|
|
||||||
store.getState().selectBlock(sectionBlock.id);
|
|
||||||
store.getState().addTextBlock();
|
|
||||||
|
|
||||||
let { blocks } = store.getState();
|
|
||||||
const original = blocks.find((b) => b.type === "text") as any;
|
|
||||||
|
|
||||||
expect(original).toBeTruthy();
|
|
||||||
|
|
||||||
// duplicateBlock 으로 복제한다.
|
|
||||||
store.getState().duplicateBlock(original.id);
|
|
||||||
|
|
||||||
({ blocks } = store.getState());
|
|
||||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
|
||||||
|
|
||||||
// 텍스트 블록이 두 개가 되어야 한다.
|
|
||||||
expect(textBlocks).toHaveLength(2);
|
|
||||||
|
|
||||||
const [t1, t2] = textBlocks;
|
|
||||||
expect(t1.id).not.toBe(t2.id);
|
|
||||||
expect(t1.props).toEqual(t2.props);
|
|
||||||
expect(t2.sectionId).toBe(t1.sectionId);
|
|
||||||
expect(t2.columnId).toBe(t1.columnId);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user