@leviosa-ai/detail-page-editor 0.7.2 → 0.7.4

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.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useState, type ChangeEvent } from "react";
3
+ import { useCallback, useMemo, useState, type ChangeEvent } from "react";
4
4
  import { ImagePlus, Info, Loader2, Pencil, Sparkles, X } from "lucide-react";
5
5
  import { useTranslation } from "react-i18next";
6
6
 
@@ -8,11 +8,11 @@ import { AnnotationDialog } from "./annotation-dialog";
8
8
  import { toDrawableDataUri } from "../../lib/detail-page/image-data-uri";
9
9
 
10
10
  import {
11
- DEFAULT_IMAGE_TIER,
12
- IMAGE_TIERS,
13
11
  IMAGE_TIER_META,
14
12
  isImageCreditBlocked,
15
13
  imageCreditRequired,
14
+ resolveDefaultImageTier,
15
+ resolveImageTiers,
16
16
  type ImageTier,
17
17
  } from "../../lib/detail-page/image-credit";
18
18
  import {
@@ -246,6 +246,12 @@ type AiGeneratePanelProps = {
246
246
  * 계산한다. 단일 값 `creditCost`보다 우선. 데모(dev-canvas)는 대체 단가 맵을 넘긴다.
247
247
  */
248
248
  costByTier?: Partial<Record<ImageTier, number>>;
249
+ /**
250
+ * 고르게 할 티어. 안 주면 셋 다(`IMAGE_TIERS`). 요금표에서 은퇴한 티어를 가진
251
+ * 소비자가 드롭다운에서 그 항목을 빼는 자리다 — 값이 없는 티어가 남아 있으면
252
+ * 누를 수는 있는데 아무도 청구를 못 한다.
253
+ */
254
+ tiers?: readonly ImageTier[];
249
255
  /**
250
256
  * (레거시) 단일 티어 크레딧. `costByTier` 미주입 시 선택 티어 관계없이 이 값을 쓴다.
251
257
  * 0/미지정이면 크레딧 UI 숨김.
@@ -286,6 +292,7 @@ export function AiGeneratePanel({
286
292
  uploadFile,
287
293
  onResult,
288
294
  costByTier,
295
+ tiers,
289
296
  creditCost = 0,
290
297
  creditBalance = 0,
291
298
  onBuyCredits,
@@ -296,7 +303,17 @@ export function AiGeneratePanel({
296
303
  // GIF 생성이 배선된 경우에만 gif 초기값을 존중(미배선이면 토글이 없어 갇힘).
297
304
  initialMode === "gif" && onGenerateGif ? "gif" : "image",
298
305
  );
299
- const [tier, setTier] = useState<ImageTier>(DEFAULT_IMAGE_TIER);
306
+ // 목록은 props 로 오지만 배열이라 매 렌더 새 참조다. 여기서 한 번 정규화해
307
+ // 아래 `useMemo`·초기값이 같은 값을 본다.
308
+ const tierOptions = useMemo(() => resolveImageTiers(tiers), [tiers]);
309
+ const [tier, setTier] = useState<ImageTier>(() =>
310
+ resolveDefaultImageTier(resolveImageTiers(tiers)),
311
+ );
312
+ // 목록이 좁아졌는데 고른 것이 그 밖이면(소비자가 티어를 은퇴시킨 뒤 열린 패널)
313
+ // 목록 안으로 되돌린다. 안 그러면 트리거가 빈 채로 뜨고 생성은 사라진 티어로 나간다.
314
+ const activeTier = tierOptions.includes(tier)
315
+ ? tier
316
+ : resolveDefaultImageTier(tierOptions);
300
317
  const [transparent, setTransparent] = useState(true);
301
318
  const [refUrl, setRefUrl] = useState<string | null>(null);
302
319
  const [refUploading, setRefUploading] = useState(false);
@@ -314,7 +331,7 @@ export function AiGeneratePanel({
314
331
  );
315
332
 
316
333
  // 활성 단가: GIF 모드는 단일 gifCreditCost, 이미지 모드는 선택 티어 단가.
317
- const tierCost = costByTier?.[tier] ?? creditCost;
334
+ const tierCost = costByTier?.[activeTier] ?? creditCost;
318
335
  const activeCost = isGif ? gifCreditCost : tierCost;
319
336
 
320
337
  // 잔액이 비용의 1.5배 미만이면 생성 차단(비용 0=미구성이면 게이트 비활성).
@@ -380,7 +397,7 @@ export function AiGeneratePanel({
380
397
  prompt: finalPrompt,
381
398
  referenceImages,
382
399
  mode: refUrl ? "new_reference" : "based_on_existing",
383
- tier,
400
+ tier: activeTier,
384
401
  brandId,
385
402
  annotatedImage,
386
403
  });
@@ -408,7 +425,7 @@ export function AiGeneratePanel({
408
425
  } finally {
409
426
  setLoading(false);
410
427
  }
411
- }, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, tier, t]);
428
+ }, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, activeTier, t]);
412
429
 
413
430
  const handleGenerate = useCallback(() => {
414
431
  void runGenerate();
@@ -529,7 +546,7 @@ export function AiGeneratePanel({
529
546
  <TooltipContent side="left" className="max-w-[240px] text-left">
530
547
  <p className="mb-1 font-dpe-semibold">{t("detailPage.aiGenerate.modelTooltipTitle")}</p>
531
548
  <ul className="space-y-1">
532
- {IMAGE_TIERS.map((tk) => (
549
+ {tierOptions.map((tk) => (
533
550
  <li key={tk}>
534
551
  <span className="font-dpe-medium">{IMAGE_TIER_META[tk].label}</span>
535
552
  <span className="opacity-80"> · {IMAGE_TIER_META[tk].quality}</span>
@@ -539,12 +556,12 @@ export function AiGeneratePanel({
539
556
  </TooltipContent>
540
557
  </Tooltip>
541
558
  </div>
542
- <Select value={tier} onValueChange={(v) => setTier(v as ImageTier)}>
559
+ <Select value={activeTier} onValueChange={(v) => setTier(v as ImageTier)}>
543
560
  <SelectTrigger className="h-10 w-full text-sm">
544
561
  <SelectValue />
545
562
  </SelectTrigger>
546
563
  <SelectContent>
547
- {IMAGE_TIERS.map((tk) => {
564
+ {tierOptions.map((tk) => {
548
565
  const meta = IMAGE_TIER_META[tk];
549
566
  const cost = costByTier?.[tk];
550
567
  return (
@@ -573,7 +590,7 @@ export function AiGeneratePanel({
573
590
  </SelectContent>
574
591
  </Select>
575
592
  <p className="mt-1.5 text-[11px] leading-4 text-dpe-ink-500">
576
- {IMAGE_TIER_META[tier].description}
593
+ {IMAGE_TIER_META[activeTier].description}
577
594
  </p>
578
595
  </div>
579
596
 
@@ -15,7 +15,11 @@ import { useTranslation } from "react-i18next";
15
15
  import { observer } from "./canvas-observer";
16
16
  import { CANVAS_MENU_ICONS } from "./canvas-context-menu";
17
17
  import { ImageCropOverlay, type CropElement } from "./image-crop-overlay";
18
- import { SelectionQuickToolbar, type QuickToolbarItem } from "./selection-quick-toolbar";
18
+ import {
19
+ SelectionQuickToolbar,
20
+ useQuickPopoverPlacement,
21
+ type QuickToolbarItem,
22
+ } from "./selection-quick-toolbar";
19
23
  import { selectedElementsDeep, type SelectableElement } from "./detail-page-selection";
20
24
  import { useEditorAi } from "./editor-ai-context";
21
25
  import {
@@ -39,7 +43,13 @@ type ToolStore = CanvasMenuStore & {
39
43
  getElementById?: (id: string) => SelectableElement | undefined;
40
44
  };
41
45
 
42
- /** 창 하나. 띠 바로 아래에 붙고, 안에 들어가는 것은 우측 패널이 쓰던 부품 그대로다. */
46
+ /**
47
+ * 창 하나. 안에 들어가는 것은 우측 패널이 쓰던 부품 그대로다.
48
+ *
49
+ * 붙는 자리는 띠가 정한다(`useQuickPopoverPlacement`). 아래가 좁으면 위로 뒤집고,
50
+ * 높이는 그 자리에 실제로 남은 만큼으로 잘린다 — 넘치는 만큼은 스스로 스크롤한다.
51
+ * 60vh·520px 상한은 그대로 두되, 남은 자리가 더 좁으면 그쪽이 이긴다.
52
+ */
43
53
  function Popover({
44
54
  children,
45
55
  width = 360,
@@ -47,11 +57,16 @@ function Popover({
47
57
  children: React.ReactNode;
48
58
  width?: number;
49
59
  }) {
60
+ const { side, maxHeight } = useQuickPopoverPlacement();
50
61
  return (
51
62
  <div
52
63
  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"
64
+ data-dp-quick-popover-side={side}
65
+ style={{ width, maxHeight: `min(60vh, 520px, ${maxHeight}px)` }}
66
+ className={[
67
+ "overflow-y-auto rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface shadow-lg",
68
+ side === "above" ? "absolute bottom-full left-0 mb-1.5" : "mt-1.5",
69
+ ].join(" ")}
55
70
  >
56
71
  {children}
57
72
  </div>
@@ -92,6 +92,14 @@ export type DetailPageEditorProps = {
92
92
  * 쓴다. 인증된 호스트가 공유 훅으로 feature_costs에서 계산해 주입한다.
93
93
  */
94
94
  imageCostByTier?: Partial<Record<ImageTier, number>>;
95
+ /**
96
+ * 고르게 할 이미지 티어. 안 주면 셋 다(basic/pro/max).
97
+ *
98
+ * 요금표에서 티어를 은퇴시킨 소비자가 쓰는 자리다 — 값이 없는 티어를 드롭다운에
99
+ * 남겨 두면 누를 수는 있는데 아무도 청구를 못 하고, 그 상태는 화면 어디에도 안
100
+ * 보인다. 에이전시는 `["pro", "max"]` 를 준다.
101
+ */
102
+ imageTiers?: readonly ImageTier[];
95
103
  /**
96
104
  * 생성 인스턴스 ID. 주어지면 텍스트 요소 선택 시 우측에 "프롬프트로 편집"이
97
105
  * 노출된다(소싱 서버 카피 엔진 호출). dev 하네스는 scratch ID를 주입한다.
@@ -435,6 +443,7 @@ export function DetailPageEditor({
435
443
  imageCreditCost = 0,
436
444
  imageCreditBalance = 0,
437
445
  imageCostByTier,
446
+ imageTiers,
438
447
  generatedId,
439
448
  structurePanel,
440
449
  productName,
@@ -662,6 +671,7 @@ export function DetailPageEditor({
662
671
  imageCreditCost,
663
672
  imageCreditBalance,
664
673
  imageCostByTier,
674
+ imageTiers,
665
675
  onBuyImageCredits: () => setPricingOpen(true),
666
676
  structurePanel,
667
677
  generatedId,
@@ -675,6 +685,7 @@ export function DetailPageEditor({
675
685
  imageCreditCost,
676
686
  imageCreditBalance,
677
687
  imageCostByTier,
688
+ imageTiers,
678
689
  structurePanel,
679
690
  generatedId,
680
691
  ],
@@ -721,6 +732,7 @@ export function DetailPageEditor({
721
732
  imageCreditCost,
722
733
  imageCreditBalance,
723
734
  imageCostByTier,
735
+ imageTiers,
724
736
  onGenerateGif,
725
737
  gifCreditCost,
726
738
  onRemoveBackground,
@@ -733,6 +745,7 @@ export function DetailPageEditor({
733
745
  imageCreditCost,
734
746
  imageCreditBalance,
735
747
  imageCostByTier,
748
+ imageTiers,
736
749
  onGenerateGif,
737
750
  gifCreditCost,
738
751
  onRemoveBackground,
@@ -2370,6 +2370,7 @@ export const ElementAiEditPanel = observer(function ElementAiEditPanel({
2370
2370
  initialMode={isGif ? "gif" : "image"}
2371
2371
  onResult={(src) => single.set({ src })}
2372
2372
  costByTier={ai.imageCostByTier}
2373
+ tiers={ai.imageTiers}
2373
2374
  creditCost={ai.imageCreditCost}
2374
2375
  creditBalance={ai.imageCreditBalance}
2375
2376
  onBuyCredits={ai.onBuyCredits}
@@ -138,6 +138,7 @@ export type BuildSectionsOptions = {
138
138
  imageCreditBalance?: number;
139
139
  /** 티어별(basic/pro/max) 크레딧 단가. 모델 드롭다운의 각 항목 비용에 쓴다. */
140
140
  imageCostByTier?: Partial<Record<ImageTier, number>>;
141
+ imageTiers?: readonly ImageTier[];
141
142
  /** 크레딧 부족 시 "크레딧 추가하기" 목적지(레비오사 pricing). */
142
143
  onBuyImageCredits?: () => void;
143
144
  /** AI 생성 결과를 현재 브랜드 중앙 에셋에 미러링. */
@@ -167,6 +168,7 @@ export function buildDetailPageSections({
167
168
  imageCreditCost,
168
169
  imageCreditBalance,
169
170
  imageCostByTier,
171
+ imageTiers,
170
172
  onBuyImageCredits,
171
173
  structurePanel,
172
174
  generatedId,
@@ -225,6 +227,7 @@ export function buildDetailPageSections({
225
227
  gifCreditCost={gifCreditCost}
226
228
  uploadFile={uploadFile}
227
229
  costByTier={imageCostByTier}
230
+ tiers={imageTiers}
228
231
  creditCost={imageCreditCost}
229
232
  creditBalance={imageCreditBalance}
230
233
  onBuyCredits={onBuyImageCredits}
@@ -29,6 +29,7 @@ export type EditorAiValue = {
29
29
  imageCreditCost?: number;
30
30
  imageCreditBalance?: number;
31
31
  imageCostByTier?: Partial<Record<ImageTier, number>>;
32
+ imageTiers?: readonly ImageTier[];
32
33
  onGenerateGif?: GenerateGifFn;
33
34
  gifCreditCost?: number;
34
35
  onRemoveBackground?: RemoveBackgroundFn;
@@ -13,7 +13,9 @@
13
13
  */
14
14
 
15
15
  import {
16
+ createContext,
16
17
  useCallback,
18
+ useContext,
17
19
  useEffect,
18
20
  useLayoutEffect,
19
21
  useRef,
@@ -42,6 +44,10 @@ const GAP = 10;
42
44
  const EDGE = 8;
43
45
  /** 띠의 대략 높이. 실측 전 첫 프레임에만 쓴다. */
44
46
  const BAR_HEIGHT = 40;
47
+ /** 띠와 그 아래(또는 위) 창 사이의 여백(px). 클래스의 `mt-1.5`/`mb-1.5`와 같은 값이다. */
48
+ const POPOVER_GAP = 6;
49
+ /** 창이 이보다 낮아지면 접힌 것과 다름없다 — 스스로 스크롤하게 두고 이 높이는 지킨다. */
50
+ const POPOVER_MIN = 200;
45
51
 
46
52
  /**
47
53
  * 띠의 자리. 위가 모자라면 아래로 뒤집고, 좌우는 작업 영역 안으로 민다.
@@ -63,6 +69,40 @@ export function toolbarPosition(
63
69
  };
64
70
  }
65
71
 
72
+ /**
73
+ * 창이 띠의 위에 설지 아래에 설지, 그리고 얼마나 높을 수 있는지.
74
+ *
75
+ * 자리 계산이 재는 것은 **띠**뿐이라(`size`가 `barRef` 것이다) 창은 지금까지 아무에게도
76
+ * 안 물어보고 띠 아래로 흘렀다. 상세페이지 사진은 대개 한 섹션을 통째로 덮어서 띠가
77
+ * 작업 영역 맨 아래에 붙는데, 그러면 창은 통째로 화면 밖에 그려진다 — 열려 있고,
78
+ * 접근성 트리에도 있고, 눈에는 없다. 그 자리에서는 위로 뒤집는다.
79
+ */
80
+ export function popoverPlacement(
81
+ barTop: number,
82
+ barHeight: number,
83
+ hostHeight: number,
84
+ ): { side: "above" | "below"; maxHeight: number } {
85
+ const below = hostHeight - (barTop + barHeight) - POPOVER_GAP - EDGE;
86
+ const above = barTop - POPOVER_GAP - EDGE;
87
+ const side = below >= above ? "below" : "above";
88
+ return {
89
+ side,
90
+ maxHeight: Math.max(POPOVER_MIN, side === "below" ? below : above),
91
+ };
92
+ }
93
+
94
+ export type QuickPopoverPlacement = ReturnType<typeof popoverPlacement>;
95
+
96
+ const QuickPopoverContext = createContext<QuickPopoverPlacement>({
97
+ side: "below",
98
+ maxHeight: 520,
99
+ });
100
+
101
+ /** 창을 그리는 쪽이 자기 자리를 집는 곳. 띠 밖에서 부르면 기본값(아래)이다. */
102
+ export function useQuickPopoverPlacement(): QuickPopoverPlacement {
103
+ return useContext(QuickPopoverContext);
104
+ }
105
+
66
106
  export function SelectionQuickToolbar({
67
107
  els,
68
108
  items,
@@ -179,6 +219,7 @@ export function SelectionQuickToolbar({
179
219
  { width: host.clientWidth, height: host.clientHeight },
180
220
  size,
181
221
  );
222
+ const placement = popoverPlacement(top, size.height, host.clientHeight);
182
223
 
183
224
  return (
184
225
  <div
@@ -217,7 +258,9 @@ export function SelectionQuickToolbar({
217
258
  </div>
218
259
  ))}
219
260
  </div>
220
- {children}
261
+ <QuickPopoverContext.Provider value={placement}>
262
+ {children}
263
+ </QuickPopoverContext.Provider>
221
264
  </div>
222
265
  );
223
266
  }
@@ -13,6 +13,27 @@ export type ImageTier = "basic" | "pro" | "max";
13
13
  /** 선택 UI에 노출할 티어 순서(저→고 품질). */
14
14
  export const IMAGE_TIERS: ImageTier[] = ["basic", "pro", "max"];
15
15
 
16
+ /**
17
+ * 이 편집기가 실제로 고르게 할 티어.
18
+ *
19
+ * 소비자마다 파는 것이 다르다. 소싱은 셋을 다 팔지만, 에이전시 요금표는 basic 을
20
+ * 은퇴시키고 pro 를 기본으로 뒀다 — 값이 없는 티어를 드롭다운에 남겨 두면 사용자는
21
+ * 고를 수 있는데 아무도 청구를 못 하는 자리가 생긴다.
22
+ *
23
+ * 안 주면 지금까지대로 셋 다. 준 값이 비었거나 아는 티어가 하나도 없으면 그것도
24
+ * 셋 다로 되돌린다 — 빈 드롭다운은 "AI 이미지를 못 만드는 편집기"이고, 그건 소비자가
25
+ * 배열 하나를 잘못 넘겨서 벌어질 만한 일이 아니다.
26
+ */
27
+ export function resolveImageTiers(allowed?: readonly ImageTier[]): ImageTier[] {
28
+ const picked = IMAGE_TIERS.filter((tier) => allowed?.includes(tier));
29
+ return picked.length > 0 ? picked : IMAGE_TIERS;
30
+ }
31
+
32
+ /** 목록 안의 기본 티어. 기본값이 빠진 목록이면 그 목록의 첫 항목. */
33
+ export function resolveDefaultImageTier(tiers: readonly ImageTier[]): ImageTier {
34
+ return tiers.includes(DEFAULT_IMAGE_TIER) ? DEFAULT_IMAGE_TIER : tiers[0];
35
+ }
36
+
16
37
  export const IMAGE_FEATURE_KEYS: Record<ImageTier, string> = {
17
38
  basic: "detail_page_image_basic",
18
39
  pro: "detail_page_image_pro",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",