@leviosa-ai/detail-page-editor 0.5.0 → 0.6.1

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" },
@@ -23,10 +23,7 @@ import {
23
23
  import { CanvasStoreContext } from "./canvas-observer";
24
24
  import { LeviosaCanvasWorkspace } from "./leviosa-canvas-workspace";
25
25
  import { DetailPagePagesTimeline } from "./detail-page-pages-timeline";
26
- import {
27
- SidePanel as LeviosaSidePanel,
28
- ZoomButtons as LeviosaZoomButtons,
29
- } from "@leviosa-ai/canvas";
26
+ import { SidePanel as LeviosaSidePanel } from "@leviosa-ai/canvas";
30
27
  import { createCanvasStore } from "@leviosa-ai/canvas/store";
31
28
  import { collectFontRequests } from "@leviosa-ai/canvas/render/use-document-fonts";
32
29
  import { ensureCanvasKey } from "../../lib/detail-page-canvas/canvas-key";
@@ -35,6 +32,8 @@ import { normalizeDocumentAssetSrcs } from "../../lib/detail-page/asset-bytes-ur
35
32
  import type { DocumentJson } from "@leviosa-ai/canvas/types";
36
33
  import { buildDetailPageSections } from "./detail-page-sidebar-sections";
37
34
  import { DetailPageProperties } from "./detail-page-properties-panel";
35
+ import { EditorAiProvider } from "./editor-ai-context";
36
+ import { useDetailPageEditUsage } from "./edit-quota-ui";
38
37
  import type { ImageTier } from "../../lib/detail-page/image-credit";
39
38
  import { DetailPageHistoryButtons } from "./detail-page-history-buttons";
40
39
  import { DetailPageDownloadDialog } from "./detail-page-download-dialog";
@@ -448,6 +447,9 @@ export function DetailPageEditor({
448
447
  const SidebarSlot = slots?.EditorSidebar;
449
448
  const HeaderSlot = slots?.EditorHeader;
450
449
  const InspectorSlot = slots?.EditorInspector;
450
+ // 프롬프트 편집 사용량은 여기서 한 번만 조회한다 — 캔버스 위 띠와 우측 패널(표·차트)이
451
+ // 같은 숫자를 봐야 "몇 번 남았는가"가 갈라지지 않는다.
452
+ const { usage, applyUsage } = useDetailPageEditUsage(generatedId);
451
453
  const [saveError, setSaveError] = useState<string | null>(null);
452
454
  const [saveOk, setSaveOk] = useState(false);
453
455
  // 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
@@ -704,14 +706,39 @@ export function DetailPageEditor({
704
706
  {findReplace}
705
707
  <DetailPagePagesTimeline store={store} />
706
708
  </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">
708
- <LeviosaZoomButtons store={store} />
709
- </div>
710
709
  </div>
711
710
  </div>
712
711
  );
713
712
  }, [store, sidebarSections, SidebarSlot]);
714
713
 
714
+ const aiValue = useMemo(
715
+ () => ({
716
+ generatedId,
717
+ usage,
718
+ applyUsage,
719
+ onBuyCredits: () => setPricingOpen(true),
720
+ imageCreditCost,
721
+ imageCreditBalance,
722
+ imageCostByTier,
723
+ onGenerateGif,
724
+ gifCreditCost,
725
+ onRemoveBackground,
726
+ bgRemoveCreditCost,
727
+ }),
728
+ [
729
+ generatedId,
730
+ usage,
731
+ applyUsage,
732
+ imageCreditCost,
733
+ imageCreditBalance,
734
+ imageCostByTier,
735
+ onGenerateGif,
736
+ gifCreditCost,
737
+ onRemoveBackground,
738
+ bgRemoveCreditCost,
739
+ ],
740
+ );
741
+
715
742
  const historyPart = <DetailPageHistoryButtons store={store} />;
716
743
  const downloadPart = (
717
744
  <DetailPageDownloadDialog
@@ -785,19 +812,12 @@ export function DetailPageEditor({
785
812
  store={store}
786
813
  generatedId={generatedId}
787
814
  onBuyEditCredits={() => setPricingOpen(true)}
788
- imageCreditCost={imageCreditCost}
789
- imageCreditBalance={imageCreditBalance}
790
- imageCostByTier={imageCostByTier}
791
- onGenerateGif={onGenerateGif}
792
- gifCreditCost={gifCreditCost}
793
815
  onGenerateTextGif={onGenerateTextGif}
794
816
  textGifCreditCost={textGifCreditCost}
795
817
  onGenerateImageGif={onGenerateImageGif}
796
818
  imageGifCreditCost={imageGifCreditCost}
797
819
  onGenerateDataGif={onGenerateDataGif}
798
820
  dataGifCreditCost={dataGifCreditCost}
799
- onRemoveBackground={onRemoveBackground}
800
- bgRemoveCreditCost={bgRemoveCreditCost}
801
821
  />
802
822
  </div>
803
823
 
@@ -996,6 +1016,8 @@ export function DetailPageEditor({
996
1016
  // 꽂혀 있으면 `observer`로 싼 패널·오버레이가 이 스토어의 변경 신호를 받는다
997
1017
  // (canvas-observer.tsx).
998
1018
  <CanvasStoreContext.Provider value={store}>
1019
+ {/* 캔버스 위 띠가 쓰는 값들. props로 내리면 작업 영역이 통째로 다시 만들어진다. */}
1020
+ <EditorAiProvider value={aiValue}>
999
1021
  <div
1000
1022
  data-dpe-root=""
1001
1023
  className="flex h-screen min-h-[640px] flex-col bg-dpe-ink-100"
@@ -1024,6 +1046,7 @@ export function DetailPageEditor({
1024
1046
  <slots.PricingModal open={pricingOpen} onOpenChange={setPricingOpen} />
1025
1047
  ) : null}
1026
1048
  </div>
1049
+ </EditorAiProvider>
1027
1050
  </CanvasStoreContext.Provider>
1028
1051
  );
1029
1052
  }
@@ -23,6 +23,19 @@ type StoreLike = {
23
23
  selectPage: (id: string) => void;
24
24
  };
25
25
 
26
+ /**
27
+ * 이 띠가 차지하는 높이와, 떠 있는지 여부.
28
+ *
29
+ * 아래 띠(삽입·배율)가 이 위로 비켜서야 하는데, 그 셈을 작업 영역이 따로 하면
30
+ * 두 벌이 갈라진다 — 여기가 정본이고 높이는 아래 style 로 자기가 쓴다.
31
+ */
32
+ export const PAGES_TIMELINE_HEIGHT = 53;
33
+
34
+ export function pagesTimelineVisible(store: unknown): boolean {
35
+ const pages = (store as StoreLike | null)?.pages;
36
+ return Array.isArray(pages) && pages.length >= 2;
37
+ }
38
+
26
39
  export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline({
27
40
  store,
28
41
  }: {
@@ -36,11 +49,12 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
36
49
  );
37
50
  const activeId = s.activePage?.id;
38
51
 
39
- if (s.pages.length < 2) return null;
52
+ if (!pagesTimelineVisible(s)) return null;
40
53
 
41
54
  return (
42
55
  <div
43
56
  data-dp-pages-timeline=""
57
+ style={{ minHeight: PAGES_TIMELINE_HEIGHT }}
44
58
  className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex items-center gap-1.5 overflow-x-auto border-t border-dpe-ink-200 bg-dpe-surface/95 px-3 py-1.5 backdrop-blur-sm"
45
59
  >
46
60
  {s.pages.map((page, index) => {