1차 싱크 완료
CI / test (push) Failing after 41m13s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-24 21:32:37 +09:00
parent 7a8ad7c057
commit 672cca5271
83 changed files with 6681 additions and 325 deletions
+89 -17
View File
@@ -113,17 +113,14 @@ export default function EditorPage() {
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const editorMainStyle: CSSProperties = {};
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const startEditing = (id: string, initialText: string) => {
selectBlock(id);
setEditingBlockId(id);
@@ -474,6 +471,20 @@ export default function EditorPage() {
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target) {
const tagName = target.tagName;
const isInputLike =
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
(target as HTMLElement).isContentEditable;
if (isInputLike) {
// 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고,
// 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다.
return;
}
}
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
@@ -518,9 +529,20 @@ export default function EditorPage() {
const isMac = navigator.platform.toLowerCase().includes("mac");
const metaKey = isMac ? event.metaKey : event.ctrlKey;
// Delete / Backspace 로 선택된 블록 삭제
// Delete / Backspace 로 선택된 블록 삭제 (멀티 선택 우선)
if (event.key === "Delete" || event.key === "Backspace") {
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
const { selectedBlockId: currentSelectedId, blocks: currentBlocks } = (useEditorStore as any).getState();
// 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다.
if (selectedBlockIds.length > 1) {
event.preventDefault();
const selectedSet = new Set(selectedBlockIds);
const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id));
replaceBlocks(nextBlocks);
return;
}
// 단일 선택만 있는 경우 기존 removeBlock 로직 사용
if (currentSelectedId) {
event.preventDefault();
removeBlock(currentSelectedId);
@@ -559,11 +581,12 @@ export default function EditorPage() {
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [undo, redo]);
}, [undo, redo, selectedBlockIds, replaceBlocks, removeBlock]);
const renderBlocks = (targetBlocks: Block[]) =>
targetBlocks.map((block) => {
const isSelected = block.id === selectedBlockId;
const isSelected =
(selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id);
const isEditing = block.id === editingBlockId;
return (
@@ -572,6 +595,9 @@ export default function EditorPage() {
<SortableEditorBlock
block={block}
isSelected={isSelected}
selectedBlockId={selectedBlockId ?? null}
selectedBlockIds={selectedBlockIds}
setSelectedBlockIds={setSelectedBlockIds}
isEditing={isEditing}
editingText={editingText}
startEditing={startEditing}
@@ -590,7 +616,7 @@ export default function EditorPage() {
});
return (
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
<main className="h-screen flex flex-col overflow-hidden">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
@@ -867,6 +893,9 @@ interface TextInlineToolbarProps {
interface SortableEditorBlockProps {
block: Block;
isSelected: boolean;
selectedBlockId: string | null;
selectedBlockIds: string[];
setSelectedBlockIds: React.Dispatch<React.SetStateAction<string[]>>;
isEditing: boolean;
editingText: string;
startEditing: (id: string, initialText: string) => void;
@@ -884,6 +913,9 @@ interface SortableEditorBlockProps {
function SortableEditorBlock({
block,
isSelected,
selectedBlockId,
selectedBlockIds,
setSelectedBlockIds,
isEditing,
editingText,
startEditing,
@@ -966,6 +998,11 @@ function SortableEditorBlock({
colorClass = `pb-text-color-${colorPalette}`;
}
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") {
textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim();
}
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
const maxWidthScale = textProps.maxWidthScale ?? "none";
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
@@ -1027,15 +1064,29 @@ function SortableEditorBlock({
ref={setNodeRef}
style={{ ...baseStyle, ...textStyleOverrides }}
data-testid="editor-block"
data-block-id={block.id}
data-selected={isSelected ? "true" : "false"}
aria-selected={isSelected ? "true" : "false"}
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
}`}
onClick={(event) => {
// 섹션 안 자식 블록을 클릭했을 때 섹션까지 선택되는 것을 막기 위해
// 클릭 이벤트를 여기서 중단하고 현재 블록만 선택한다.
// 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다.
const isMeta = event.metaKey || event.ctrlKey;
if (isMeta) {
event.stopPropagation();
setSelectedBlockIds((prev) => {
if (prev.includes(block.id)) {
return prev.filter((id) => id !== block.id);
}
return [...prev, block.id];
});
return;
}
// 일반 클릭: 현재 블록만 단일 선택으로 유지한다.
event.stopPropagation();
setSelectedBlockIds([block.id]);
selectBlock(block.id);
}}
onDoubleClick={(event) => {
@@ -1358,7 +1409,7 @@ function SortableEditorBlock({
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs text-slate-200">
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1402,7 +1453,9 @@ function SortableEditorBlock({
const variant = buttonProps.variant ?? "solid";
const colorPalette = buttonProps.colorPalette ?? "primary";
const radius = buttonProps.borderRadius ?? "md";
const fullWidth = buttonProps.fullWidth ?? false;
const baseFullWidth = buttonProps.fullWidth ?? false;
const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto");
const isFullWidth = widthMode === "full" || baseFullWidth;
const sizeClassBtn =
size === "xs"
@@ -1445,6 +1498,9 @@ function SortableEditorBlock({
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
buttonStyle.color = buttonProps.textColorCustom;
}
if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) {
buttonStyle.width = `${buttonProps.widthPx}px`;
}
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
}
@@ -1453,7 +1509,7 @@ function SortableEditorBlock({
}
return (
<div className={fullWidth ? "w-full" : "inline-block"}>
<div className={isFullWidth ? "w-full" : "inline-block"}>
<button
type="button"
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
@@ -1708,6 +1764,12 @@ function SortableEditorBlock({
})()}
{block.type === "section" && (() => {
const sectionProps = block.props as SectionBlockProps;
const isSectionSelected =
isSelected ||
(selectedBlockId != null &&
allBlocks.some(
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
));
const bgClass =
sectionProps.background === "muted"
@@ -1723,6 +1785,13 @@ function SortableEditorBlock({
? "py-10"
: "py-6";
const alignItemsClass =
sectionProps.alignItems === "center"
? "items-center"
: sectionProps.alignItems === "bottom"
? "items-end"
: "items-start";
const columns = sectionProps.columns && sectionProps.columns.length > 0
? sectionProps.columns
: [{ id: `${block.id}_col_fallback`, span: 12 }];
@@ -1738,7 +1807,10 @@ function SortableEditorBlock({
return (
<div
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
data-testid="editor-section"
className={`w-full ${bgClass} ${pyClass} rounded border ${
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
}`}
style={sectionStyle}
>
<div
@@ -1751,7 +1823,7 @@ function SortableEditorBlock({
}}
>
<div
className="flex"
className={`flex ${alignItemsClass}`}
style={{
columnGap:
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0