@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.
@@ -49,7 +49,8 @@ type MenuStore = CanvasMenuStore & {
49
49
  selectElements?: (ids: string[]) => void;
50
50
  };
51
51
 
52
- const ICONS: Record<CanvasMenuAction, LucideIcon> = {
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 = ICONS[item.action];
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
- ): void {
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
- const FIGURE_PATCH: Record<string, Record<string, unknown>> = {
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
- <div className="absolute bottom-16 left-1/2 z-20 -translate-x-1/2 rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface/95 px-2 py-1 shadow-sm backdrop-blur-sm">
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
  }