@leviosa-ai/detail-page-editor 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/detail-page/canvas-context-menu.tsx +3 -2
- package/components/detail-page/canvas-insert-toolbar.tsx +264 -0
- package/components/detail-page/canvas-selection-tools.tsx +216 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +5 -4
- package/components/detail-page/detail-page-editor.tsx +38 -8
- package/components/detail-page/detail-page-my-images-panel.tsx +220 -55
- package/components/detail-page/detail-page-properties-panel.tsx +254 -315
- package/components/detail-page/detail-page-text-panel.tsx +6 -5
- package/components/detail-page/editor-ai-context.tsx +55 -0
- package/components/detail-page/element-rects.ts +42 -0
- package/components/detail-page/image-crop-overlay.tsx +535 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +23 -0
- package/components/detail-page/selection-quick-toolbar.tsx +223 -0
- package/i18n/en.json +45 -1
- package/i18n/ko.json +45 -1
- package/lib/detail-page/brand-image-category.ts +121 -0
- package/lib/detail-page/image-crop.ts +334 -0
- package/lib/detail-page/insert-shape.ts +3 -3
- package/lib/detail-page/selection-actions.ts +76 -0
- package/lib/detail-page/use-progressive-reveal.ts +18 -6
- package/package.json +1 -1
|
@@ -49,7 +49,8 @@ type MenuStore = CanvasMenuStore & {
|
|
|
49
49
|
selectElements?: (ids: string[]) => void;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/** 항목 → 아이콘. 우클릭 메뉴와 캔버스 위 띠의 "더보기"가 같은 그림을 쓴다. */
|
|
53
|
+
export const CANVAS_MENU_ICONS: Record<CanvasMenuAction, LucideIcon> = {
|
|
53
54
|
duplicate: Copy,
|
|
54
55
|
lock: Lock,
|
|
55
56
|
unlock: Unlock,
|
|
@@ -203,7 +204,7 @@ export const CanvasContextMenu = observer(function CanvasContextMenu({
|
|
|
203
204
|
className="fixed z-[71] rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface py-1 shadow-lg"
|
|
204
205
|
>
|
|
205
206
|
{items.map((item) => {
|
|
206
|
-
const Icon =
|
|
207
|
+
const Icon = CANVAS_MENU_ICONS[item.action];
|
|
207
208
|
const label = t(`detailPage.canvasMenu.${item.action}`);
|
|
208
209
|
return (
|
|
209
210
|
<div key={item.action}>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 캔버스 아래 삽입 띠.
|
|
5
|
+
*
|
|
6
|
+
* 글상자 하나, 네모 하나를 넣으려고 좌측 패널을 열고 → 갈래를 찾고 → 격자에서 고르는
|
|
7
|
+
* 왕복이 매번 붙었다. 가장 자주 넣는 것 둘(텍스트·기본 도형)만 손이 있는 자리로 내린다.
|
|
8
|
+
* **좌측 패널을 대신하지 않는다** — 카탈로그 전체는 여전히 거기 있고, 여기 드롭다운
|
|
9
|
+
* 마지막 줄이 그 자리로 데려간다.
|
|
10
|
+
*
|
|
11
|
+
* 넣는 셈은 좌측 패널과 **같은 함수**를 부른다(`insertText`·`insertFigure`·`insertShape`).
|
|
12
|
+
* 두 벌로 적으면 크기·자리 규칙이 갈라지고, 그건 눈으로는 한참 뒤에야 보인다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
16
|
+
import { ChevronDown, Shapes, Type as TypeIcon } from "lucide-react";
|
|
17
|
+
import { useTranslation } from "react-i18next";
|
|
18
|
+
|
|
19
|
+
import { FIGURE_PATCH, insertFigure } from "./detail-page-basic-shapes";
|
|
20
|
+
import { TEXT_SIZE_PRESETS, insertText } from "./detail-page-text-panel";
|
|
21
|
+
import { insertShape } from "../../lib/detail-page/insert-shape";
|
|
22
|
+
import {
|
|
23
|
+
BASIC_SHAPES,
|
|
24
|
+
NATIVE_SHAPE_IDS,
|
|
25
|
+
shapeMarkup,
|
|
26
|
+
} from "../../lib/detail-page/basic-shapes";
|
|
27
|
+
|
|
28
|
+
type StoreLike = {
|
|
29
|
+
selectElements?: (ids: string[]) => void;
|
|
30
|
+
openSidePanel?: (name: string) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 띠에 내리는 도형. 카탈로그 70개 중 손이 제일 자주 가는 것만 고른다. */
|
|
34
|
+
const QUICK_SHAPE_IDS = ["triangle", "diamond", "pentagon", "hexagon", "ring", "pill"];
|
|
35
|
+
|
|
36
|
+
function selectInserted(store: unknown, created: unknown) {
|
|
37
|
+
const id = (created as { id?: string } | null)?.id;
|
|
38
|
+
if (id) (store as StoreLike).selectElements?.([id]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Menu({
|
|
42
|
+
open,
|
|
43
|
+
children,
|
|
44
|
+
}: {
|
|
45
|
+
open: boolean;
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
}) {
|
|
48
|
+
if (!open) return null;
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
role="menu"
|
|
52
|
+
className="absolute bottom-11 left-1/2 z-10 w-44 -translate-x-1/2 rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface py-1 shadow-lg"
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function MenuItem({
|
|
60
|
+
label,
|
|
61
|
+
onClick,
|
|
62
|
+
children,
|
|
63
|
+
testId,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
onClick: () => void;
|
|
67
|
+
children?: ReactNode;
|
|
68
|
+
testId?: string;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
role="menuitem"
|
|
74
|
+
data-dp-insert-item={testId}
|
|
75
|
+
onClick={onClick}
|
|
76
|
+
className="flex h-[30px] w-full items-center gap-2 px-3 text-left text-[13px] text-dpe-ink-700 transition-colors hover:bg-dpe-ink-100"
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
{label}
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 갈래 하나. 본체를 누르면 기본값이 바로 들어가고, 화살표를 누르면 목록이 열린다 —
|
|
86
|
+
* 두 손짓을 한 버튼에 겹치면 "누르면 무엇이 나오는지"를 매번 확인하게 된다.
|
|
87
|
+
*/
|
|
88
|
+
function InsertGroup({
|
|
89
|
+
label,
|
|
90
|
+
icon,
|
|
91
|
+
onPrimary,
|
|
92
|
+
open,
|
|
93
|
+
onToggle,
|
|
94
|
+
children,
|
|
95
|
+
}: {
|
|
96
|
+
label: string;
|
|
97
|
+
icon: ReactNode;
|
|
98
|
+
onPrimary: () => void;
|
|
99
|
+
open: boolean;
|
|
100
|
+
onToggle: () => void;
|
|
101
|
+
children: ReactNode;
|
|
102
|
+
}) {
|
|
103
|
+
return (
|
|
104
|
+
<div className="relative flex items-center">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
data-dp-insert-primary={label}
|
|
108
|
+
onClick={onPrimary}
|
|
109
|
+
className="flex h-9 items-center gap-1.5 rounded-l-dpe-lg px-2.5 text-[13px] font-dpe-medium text-dpe-ink-700 transition-colors hover:bg-dpe-ink-100 hover:text-dpe-ink-900"
|
|
110
|
+
>
|
|
111
|
+
{icon}
|
|
112
|
+
{label}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
aria-haspopup="menu"
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
aria-label={`${label} …`}
|
|
119
|
+
onClick={onToggle}
|
|
120
|
+
className={[
|
|
121
|
+
"flex h-9 w-6 items-center justify-center rounded-r-dpe-lg text-dpe-ink-500 transition-colors hover:bg-dpe-ink-100 hover:text-dpe-ink-900",
|
|
122
|
+
open ? "bg-dpe-ink-100 text-dpe-ink-900" : "",
|
|
123
|
+
].join(" ")}
|
|
124
|
+
>
|
|
125
|
+
<ChevronDown aria-hidden="true" size={13} />
|
|
126
|
+
</button>
|
|
127
|
+
<Menu open={open}>{children}</Menu>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function CanvasInsertToolbar({ store }: { store: unknown }) {
|
|
133
|
+
const { t } = useTranslation("branding");
|
|
134
|
+
const [open, setOpen] = useState<"text" | "shape" | null>(null);
|
|
135
|
+
const root = useRef<HTMLDivElement>(null);
|
|
136
|
+
|
|
137
|
+
// 바깥을 누르면 닫는다. 캔버스에서 요소를 고르는 손짓과 겹치면 안 되므로 캡처가 아니라
|
|
138
|
+
// 보통 단계에서 듣는다.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!open) return;
|
|
141
|
+
const close = (event: PointerEvent) => {
|
|
142
|
+
if (!root.current?.contains(event.target as Node)) setOpen(null);
|
|
143
|
+
};
|
|
144
|
+
const onKey = (event: KeyboardEvent) => {
|
|
145
|
+
if (event.key === "Escape") setOpen(null);
|
|
146
|
+
};
|
|
147
|
+
window.addEventListener("pointerdown", close);
|
|
148
|
+
window.addEventListener("keydown", onKey);
|
|
149
|
+
return () => {
|
|
150
|
+
window.removeEventListener("pointerdown", close);
|
|
151
|
+
window.removeEventListener("keydown", onKey);
|
|
152
|
+
};
|
|
153
|
+
}, [open]);
|
|
154
|
+
|
|
155
|
+
const addText = (preset: (typeof TEXT_SIZE_PRESETS)[number]) => {
|
|
156
|
+
selectInserted(
|
|
157
|
+
store,
|
|
158
|
+
insertText(store, t(`detailPage.text.${preset.key}`), {
|
|
159
|
+
fontSize: preset.fontSize,
|
|
160
|
+
fontWeight: preset.fontWeight,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
setOpen(null);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const addFigure = (id: string) => {
|
|
167
|
+
selectInserted(store, insertFigure(store, FIGURE_PATCH[id] ?? {}));
|
|
168
|
+
setOpen(null);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const addShape = (id: string) => {
|
|
172
|
+
const shape = BASIC_SHAPES.find((item) => item.id === id);
|
|
173
|
+
if (!shape) return;
|
|
174
|
+
selectInserted(store, insertShape(store, shapeMarkup(shape), shape.viewBox));
|
|
175
|
+
setOpen(null);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const openPanel = (name: string) => {
|
|
179
|
+
(store as StoreLike).openSidePanel?.(name);
|
|
180
|
+
setOpen(null);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
ref={root}
|
|
186
|
+
data-dp-insert-toolbar=""
|
|
187
|
+
className="flex items-center gap-0.5 rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface/95 px-1 py-1 shadow-md backdrop-blur-sm"
|
|
188
|
+
>
|
|
189
|
+
<InsertGroup
|
|
190
|
+
label={t("detailPage.insert.text")}
|
|
191
|
+
icon={<TypeIcon aria-hidden="true" size={15} />}
|
|
192
|
+
// 기본값은 본문이다 — 가장 자주 쓰고, 크기는 넣은 뒤 우측에서 바로 고칠 수 있다.
|
|
193
|
+
onPrimary={() => addText(TEXT_SIZE_PRESETS[2] ?? TEXT_SIZE_PRESETS[0])}
|
|
194
|
+
open={open === "text"}
|
|
195
|
+
onToggle={() => setOpen((prev) => (prev === "text" ? null : "text"))}
|
|
196
|
+
>
|
|
197
|
+
{TEXT_SIZE_PRESETS.map((preset) => (
|
|
198
|
+
<MenuItem
|
|
199
|
+
key={preset.key}
|
|
200
|
+
testId={`text-${preset.key}`}
|
|
201
|
+
label={t(`detailPage.text.${preset.key}`)}
|
|
202
|
+
onClick={() => addText(preset)}
|
|
203
|
+
/>
|
|
204
|
+
))}
|
|
205
|
+
<div className="my-1 border-t border-dpe-ink-100" />
|
|
206
|
+
<MenuItem
|
|
207
|
+
testId="text-panel"
|
|
208
|
+
label={t("detailPage.insert.moreText")}
|
|
209
|
+
onClick={() => openPanel("text")}
|
|
210
|
+
/>
|
|
211
|
+
</InsertGroup>
|
|
212
|
+
|
|
213
|
+
<InsertGroup
|
|
214
|
+
label={t("detailPage.insert.shape")}
|
|
215
|
+
icon={<Shapes aria-hidden="true" size={15} />}
|
|
216
|
+
onPrimary={() => addFigure("rect")}
|
|
217
|
+
open={open === "shape"}
|
|
218
|
+
onToggle={() => setOpen((prev) => (prev === "shape" ? null : "shape"))}
|
|
219
|
+
>
|
|
220
|
+
{NATIVE_SHAPE_IDS.map((id) => (
|
|
221
|
+
<MenuItem
|
|
222
|
+
key={id}
|
|
223
|
+
testId={`figure-${id}`}
|
|
224
|
+
label={t(`detailPage.shapes.basic.${id}`)}
|
|
225
|
+
onClick={() => addFigure(id)}
|
|
226
|
+
>
|
|
227
|
+
<span
|
|
228
|
+
aria-hidden="true"
|
|
229
|
+
className="block h-3.5 w-3.5 shrink-0 bg-dpe-ink-300"
|
|
230
|
+
style={{
|
|
231
|
+
borderRadius: id === "circle" ? "9999px" : id === "rounded" ? 4 : 0,
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
234
|
+
</MenuItem>
|
|
235
|
+
))}
|
|
236
|
+
<div className="my-1 border-t border-dpe-ink-100" />
|
|
237
|
+
{QUICK_SHAPE_IDS.map((id) => {
|
|
238
|
+
const shape = BASIC_SHAPES.find((item) => item.id === id);
|
|
239
|
+
if (!shape) return null;
|
|
240
|
+
return (
|
|
241
|
+
<MenuItem
|
|
242
|
+
key={id}
|
|
243
|
+
testId={`shape-${id}`}
|
|
244
|
+
label={t(`detailPage.shapes.basic.${id}`)}
|
|
245
|
+
onClick={() => addShape(id)}
|
|
246
|
+
>
|
|
247
|
+
<span
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
className="block h-3.5 w-3.5 shrink-0 [&>svg]:h-full [&>svg]:w-full"
|
|
250
|
+
dangerouslySetInnerHTML={{ __html: shapeMarkup(shape) }}
|
|
251
|
+
/>
|
|
252
|
+
</MenuItem>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
<div className="my-1 border-t border-dpe-ink-100" />
|
|
256
|
+
<MenuItem
|
|
257
|
+
testId="shape-panel"
|
|
258
|
+
label={t("detailPage.insert.moreShapes")}
|
|
259
|
+
onClick={() => openPanel("elements")}
|
|
260
|
+
/>
|
|
261
|
+
</InsertGroup>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 고른 것에 딸린 도구들을 한 자리에 모은다 — 띠, 그 아래 열리는 창, 그리고 자르기.
|
|
5
|
+
*
|
|
6
|
+
* 무엇을 띄울지는 `lib/detail-page/selection-actions.ts`가 정하고(그려 보지 않고 잴 수
|
|
7
|
+
* 있게), 자리 잡기는 `selection-quick-toolbar.tsx`가, 실제 편집 부품은 우측 패널이
|
|
8
|
+
* 쓰던 것을 그대로 부른다. 여기서는 **잇기만** 한다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useState, type RefObject } from "react";
|
|
12
|
+
import { Crop, Eraser, MoreHorizontal, Sparkles } from "lucide-react";
|
|
13
|
+
import { useTranslation } from "react-i18next";
|
|
14
|
+
|
|
15
|
+
import { observer } from "./canvas-observer";
|
|
16
|
+
import { CANVAS_MENU_ICONS } from "./canvas-context-menu";
|
|
17
|
+
import { ImageCropOverlay, type CropElement } from "./image-crop-overlay";
|
|
18
|
+
import { SelectionQuickToolbar, type QuickToolbarItem } from "./selection-quick-toolbar";
|
|
19
|
+
import { selectedElementsDeep, type SelectableElement } from "./detail-page-selection";
|
|
20
|
+
import { useEditorAi } from "./editor-ai-context";
|
|
21
|
+
import {
|
|
22
|
+
BgRemoveSection,
|
|
23
|
+
ElementAiEditPanel,
|
|
24
|
+
} from "./detail-page-properties-panel";
|
|
25
|
+
import {
|
|
26
|
+
quickActions,
|
|
27
|
+
type ActionElement,
|
|
28
|
+
type QuickActionId,
|
|
29
|
+
} from "../../lib/detail-page/selection-actions";
|
|
30
|
+
import {
|
|
31
|
+
canvasMenuItems,
|
|
32
|
+
runCanvasMenuAction,
|
|
33
|
+
type CanvasMenuStore,
|
|
34
|
+
} from "../../lib/detail-page/canvas-menu";
|
|
35
|
+
|
|
36
|
+
type ToolStore = CanvasMenuStore & {
|
|
37
|
+
selectedElements?: SelectableElement[];
|
|
38
|
+
selectedElementsIds?: string[];
|
|
39
|
+
getElementById?: (id: string) => SelectableElement | undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 창 하나. 띠 바로 아래에 붙고, 안에 들어가는 것은 우측 패널이 쓰던 부품 그대로다. */
|
|
43
|
+
function Popover({
|
|
44
|
+
children,
|
|
45
|
+
width = 360,
|
|
46
|
+
}: {
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
width?: number;
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-dp-quick-popover=""
|
|
53
|
+
style={{ width, maxHeight: "min(60vh, 520px)" }}
|
|
54
|
+
className="mt-1.5 overflow-y-auto rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface shadow-lg"
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const CanvasSelectionTools = observer(function CanvasSelectionTools({
|
|
62
|
+
store,
|
|
63
|
+
containerRef,
|
|
64
|
+
scrollRef,
|
|
65
|
+
}: {
|
|
66
|
+
store: unknown;
|
|
67
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
68
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
69
|
+
}) {
|
|
70
|
+
const { t } = useTranslation("branding");
|
|
71
|
+
const s = store as ToolStore;
|
|
72
|
+
const ai = useEditorAi();
|
|
73
|
+
const els = selectedElementsDeep(s);
|
|
74
|
+
const key = els.map((el) => el.id).join(",");
|
|
75
|
+
const [open, setOpen] = useState<QuickActionId | null>(null);
|
|
76
|
+
const [cropId, setCropId] = useState<string | null>(null);
|
|
77
|
+
|
|
78
|
+
// 고른 것이 바뀌면 열려 있던 창은 남의 요소를 만지게 된다 — 닫는다.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setOpen(null);
|
|
81
|
+
setCropId(null);
|
|
82
|
+
}, [key]);
|
|
83
|
+
|
|
84
|
+
const closeCrop = useCallback(() => setCropId(null), []);
|
|
85
|
+
|
|
86
|
+
const actions = quickActions(els as unknown as ActionElement[], {
|
|
87
|
+
hasGeneration: Boolean(ai.generatedId),
|
|
88
|
+
canRemoveBackground: Boolean(ai.onRemoveBackground),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const single = els.length === 1 ? els[0] : null;
|
|
92
|
+
const cropTarget = cropId
|
|
93
|
+
? ((s.getElementById?.(cropId) ?? null) as CropElement | null)
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
const toggle = (id: QuickActionId) => setOpen((prev) => (prev === id ? null : id));
|
|
97
|
+
|
|
98
|
+
const label: Record<QuickActionId, string> = {
|
|
99
|
+
crop: t("detailPage.quickToolbar.crop"),
|
|
100
|
+
bgRemove: t("detailPage.quickToolbar.bgRemove"),
|
|
101
|
+
promptEdit: t("detailPage.quickToolbar.promptEdit"),
|
|
102
|
+
more: t("detailPage.quickToolbar.more"),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const items: QuickToolbarItem[] = actions.map((id) => {
|
|
106
|
+
if (id === "crop") {
|
|
107
|
+
return {
|
|
108
|
+
key: id,
|
|
109
|
+
label: label[id],
|
|
110
|
+
icon: <Crop aria-hidden="true" size={15} />,
|
|
111
|
+
onClick: () => {
|
|
112
|
+
setOpen(null);
|
|
113
|
+
if (single) setCropId(single.id);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (id === "bgRemove") {
|
|
118
|
+
return {
|
|
119
|
+
key: id,
|
|
120
|
+
label: label[id],
|
|
121
|
+
icon: <Eraser aria-hidden="true" size={15} />,
|
|
122
|
+
active: open === id,
|
|
123
|
+
onClick: () => toggle(id),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (id === "promptEdit") {
|
|
127
|
+
return {
|
|
128
|
+
key: id,
|
|
129
|
+
label: label[id],
|
|
130
|
+
icon: <Sparkles aria-hidden="true" size={15} className="text-dpe-ai" />,
|
|
131
|
+
active: open === id,
|
|
132
|
+
onClick: () => toggle(id),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
key: id,
|
|
137
|
+
label: label[id],
|
|
138
|
+
icon: <MoreHorizontal aria-hidden="true" size={16} />,
|
|
139
|
+
active: open === id,
|
|
140
|
+
separated: actions.length > 1,
|
|
141
|
+
onClick: () => toggle(id),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 자르는 동안에는 띠를 숨긴다 — 자르기 줄이 그 자리에 뜬다.
|
|
146
|
+
if (cropTarget) {
|
|
147
|
+
return (
|
|
148
|
+
<ImageCropOverlay
|
|
149
|
+
el={cropTarget}
|
|
150
|
+
containerRef={containerRef}
|
|
151
|
+
scrollRef={scrollRef}
|
|
152
|
+
onClose={closeCrop}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<SelectionQuickToolbar
|
|
159
|
+
els={els}
|
|
160
|
+
items={items}
|
|
161
|
+
containerRef={containerRef}
|
|
162
|
+
scrollRef={scrollRef}
|
|
163
|
+
>
|
|
164
|
+
{open === "promptEdit" ? (
|
|
165
|
+
<Popover>
|
|
166
|
+
<ElementAiEditPanel
|
|
167
|
+
store={s as never}
|
|
168
|
+
els={els as never}
|
|
169
|
+
/>
|
|
170
|
+
</Popover>
|
|
171
|
+
) : null}
|
|
172
|
+
|
|
173
|
+
{open === "bgRemove" && single && ai.onRemoveBackground ? (
|
|
174
|
+
<Popover width={300}>
|
|
175
|
+
<BgRemoveSection
|
|
176
|
+
el={single as never}
|
|
177
|
+
onRemove={ai.onRemoveBackground}
|
|
178
|
+
creditCost={ai.bgRemoveCreditCost}
|
|
179
|
+
/>
|
|
180
|
+
</Popover>
|
|
181
|
+
) : null}
|
|
182
|
+
|
|
183
|
+
{open === "more" ? (
|
|
184
|
+
<Popover width={200}>
|
|
185
|
+
<div role="menu" className="py-1">
|
|
186
|
+
{canvasMenuItems(s).map((item) => {
|
|
187
|
+
const Icon = CANVAS_MENU_ICONS[item.action];
|
|
188
|
+
return (
|
|
189
|
+
<div key={item.action}>
|
|
190
|
+
{item.separated ? (
|
|
191
|
+
<div className="my-1 border-t border-dpe-ink-100" />
|
|
192
|
+
) : null}
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
role="menuitem"
|
|
196
|
+
data-dp-quick-menu-action={item.action}
|
|
197
|
+
disabled={item.disabled}
|
|
198
|
+
onClick={() => {
|
|
199
|
+
runCanvasMenuAction(s, item.action);
|
|
200
|
+
setOpen(null);
|
|
201
|
+
}}
|
|
202
|
+
className="flex h-[30px] w-full items-center gap-2.5 px-3 text-left text-[13px] text-dpe-ink-700 transition-colors hover:bg-dpe-ink-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
|
203
|
+
>
|
|
204
|
+
<Icon size={14} className="shrink-0 text-dpe-ink-500" />
|
|
205
|
+
{t(`detailPage.canvasMenu.${item.action}`)}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
</Popover>
|
|
212
|
+
) : null}
|
|
213
|
+
</SelectionQuickToolbar>
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
CanvasSelectionTools.displayName = "CanvasSelectionTools";
|
|
@@ -41,12 +41,12 @@ type StoreLike = { activePage?: PageLike; pages: PageLike[] };
|
|
|
41
41
|
export function insertFigure(
|
|
42
42
|
store: unknown,
|
|
43
43
|
patch: Record<string, unknown>,
|
|
44
|
-
):
|
|
44
|
+
): unknown {
|
|
45
45
|
const s = store as StoreLike;
|
|
46
46
|
const page = s.activePage ?? s.pages[0];
|
|
47
|
-
if (!page) return;
|
|
47
|
+
if (!page) return null;
|
|
48
48
|
const size = Math.round(Math.min(page.computedWidth * 0.28, 260));
|
|
49
|
-
page.addElement({
|
|
49
|
+
return page.addElement({
|
|
50
50
|
type: "figure",
|
|
51
51
|
subType: "rect",
|
|
52
52
|
fill: SHAPE_FILL,
|
|
@@ -58,7 +58,8 @@ export function insertFigure(
|
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** 네이티브 도형 id → 삽입 속성. 좌측 패널과 캔버스 아래 삽입 띠가 같이 쓴다. */
|
|
62
|
+
export const FIGURE_PATCH: Record<string, Record<string, unknown>> = {
|
|
62
63
|
rect: {},
|
|
63
64
|
rounded: { cornerRadius: 24 },
|
|
64
65
|
circle: { subType: "circle" },
|
|
@@ -35,6 +35,8 @@ import { normalizeDocumentAssetSrcs } from "../../lib/detail-page/asset-bytes-ur
|
|
|
35
35
|
import type { DocumentJson } from "@leviosa-ai/canvas/types";
|
|
36
36
|
import { buildDetailPageSections } from "./detail-page-sidebar-sections";
|
|
37
37
|
import { DetailPageProperties } from "./detail-page-properties-panel";
|
|
38
|
+
import { EditorAiProvider } from "./editor-ai-context";
|
|
39
|
+
import { useDetailPageEditUsage } from "./edit-quota-ui";
|
|
38
40
|
import type { ImageTier } from "../../lib/detail-page/image-credit";
|
|
39
41
|
import { DetailPageHistoryButtons } from "./detail-page-history-buttons";
|
|
40
42
|
import { DetailPageDownloadDialog } from "./detail-page-download-dialog";
|
|
@@ -448,6 +450,9 @@ export function DetailPageEditor({
|
|
|
448
450
|
const SidebarSlot = slots?.EditorSidebar;
|
|
449
451
|
const HeaderSlot = slots?.EditorHeader;
|
|
450
452
|
const InspectorSlot = slots?.EditorInspector;
|
|
453
|
+
// 프롬프트 편집 사용량은 여기서 한 번만 조회한다 — 캔버스 위 띠와 우측 패널(표·차트)이
|
|
454
|
+
// 같은 숫자를 봐야 "몇 번 남았는가"가 갈라지지 않는다.
|
|
455
|
+
const { usage, applyUsage } = useDetailPageEditUsage(generatedId);
|
|
451
456
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
452
457
|
const [saveOk, setSaveOk] = useState(false);
|
|
453
458
|
// 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
|
|
@@ -704,7 +709,8 @@ export function DetailPageEditor({
|
|
|
704
709
|
{findReplace}
|
|
705
710
|
<DetailPagePagesTimeline store={store} />
|
|
706
711
|
</LeviosaCanvasWorkspace>
|
|
707
|
-
|
|
712
|
+
{/* 배율은 오른쪽 아래로 — 가운데 아래는 삽입 띠 자리다(작업 영역이 그린다). */}
|
|
713
|
+
<div className="absolute bottom-4 right-4 z-20 rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface/95 px-2 py-1 shadow-sm backdrop-blur-sm">
|
|
708
714
|
<LeviosaZoomButtons store={store} />
|
|
709
715
|
</div>
|
|
710
716
|
</div>
|
|
@@ -712,6 +718,34 @@ export function DetailPageEditor({
|
|
|
712
718
|
);
|
|
713
719
|
}, [store, sidebarSections, SidebarSlot]);
|
|
714
720
|
|
|
721
|
+
const aiValue = useMemo(
|
|
722
|
+
() => ({
|
|
723
|
+
generatedId,
|
|
724
|
+
usage,
|
|
725
|
+
applyUsage,
|
|
726
|
+
onBuyCredits: () => setPricingOpen(true),
|
|
727
|
+
imageCreditCost,
|
|
728
|
+
imageCreditBalance,
|
|
729
|
+
imageCostByTier,
|
|
730
|
+
onGenerateGif,
|
|
731
|
+
gifCreditCost,
|
|
732
|
+
onRemoveBackground,
|
|
733
|
+
bgRemoveCreditCost,
|
|
734
|
+
}),
|
|
735
|
+
[
|
|
736
|
+
generatedId,
|
|
737
|
+
usage,
|
|
738
|
+
applyUsage,
|
|
739
|
+
imageCreditCost,
|
|
740
|
+
imageCreditBalance,
|
|
741
|
+
imageCostByTier,
|
|
742
|
+
onGenerateGif,
|
|
743
|
+
gifCreditCost,
|
|
744
|
+
onRemoveBackground,
|
|
745
|
+
bgRemoveCreditCost,
|
|
746
|
+
],
|
|
747
|
+
);
|
|
748
|
+
|
|
715
749
|
const historyPart = <DetailPageHistoryButtons store={store} />;
|
|
716
750
|
const downloadPart = (
|
|
717
751
|
<DetailPageDownloadDialog
|
|
@@ -785,19 +819,12 @@ export function DetailPageEditor({
|
|
|
785
819
|
store={store}
|
|
786
820
|
generatedId={generatedId}
|
|
787
821
|
onBuyEditCredits={() => setPricingOpen(true)}
|
|
788
|
-
imageCreditCost={imageCreditCost}
|
|
789
|
-
imageCreditBalance={imageCreditBalance}
|
|
790
|
-
imageCostByTier={imageCostByTier}
|
|
791
|
-
onGenerateGif={onGenerateGif}
|
|
792
|
-
gifCreditCost={gifCreditCost}
|
|
793
822
|
onGenerateTextGif={onGenerateTextGif}
|
|
794
823
|
textGifCreditCost={textGifCreditCost}
|
|
795
824
|
onGenerateImageGif={onGenerateImageGif}
|
|
796
825
|
imageGifCreditCost={imageGifCreditCost}
|
|
797
826
|
onGenerateDataGif={onGenerateDataGif}
|
|
798
827
|
dataGifCreditCost={dataGifCreditCost}
|
|
799
|
-
onRemoveBackground={onRemoveBackground}
|
|
800
|
-
bgRemoveCreditCost={bgRemoveCreditCost}
|
|
801
828
|
/>
|
|
802
829
|
</div>
|
|
803
830
|
|
|
@@ -996,6 +1023,8 @@ export function DetailPageEditor({
|
|
|
996
1023
|
// 꽂혀 있으면 `observer`로 싼 패널·오버레이가 이 스토어의 변경 신호를 받는다
|
|
997
1024
|
// (canvas-observer.tsx).
|
|
998
1025
|
<CanvasStoreContext.Provider value={store}>
|
|
1026
|
+
{/* 캔버스 위 띠가 쓰는 값들. props로 내리면 작업 영역이 통째로 다시 만들어진다. */}
|
|
1027
|
+
<EditorAiProvider value={aiValue}>
|
|
999
1028
|
<div
|
|
1000
1029
|
data-dpe-root=""
|
|
1001
1030
|
className="flex h-screen min-h-[640px] flex-col bg-dpe-ink-100"
|
|
@@ -1024,6 +1053,7 @@ export function DetailPageEditor({
|
|
|
1024
1053
|
<slots.PricingModal open={pricingOpen} onOpenChange={setPricingOpen} />
|
|
1025
1054
|
) : null}
|
|
1026
1055
|
</div>
|
|
1056
|
+
</EditorAiProvider>
|
|
1027
1057
|
</CanvasStoreContext.Provider>
|
|
1028
1058
|
);
|
|
1029
1059
|
}
|