@leviosa-ai/detail-page-editor 0.3.1 → 0.5.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.
@@ -14,7 +14,7 @@ import {
14
14
  readBubbleParams,
15
15
  type BubbleParams,
16
16
  type Pt,
17
- } from "../../lib/detail-page-canvas/bubble-path";
17
+ } from "@leviosa-ai/canvas/paint/bubble-path";
18
18
 
19
19
  /**
20
20
  * 말풍선 꼬리 끝점을 드래그하는 핸들 — Canva/미리캔버스식.
@@ -11,13 +11,13 @@ import {
11
11
  Text,
12
12
  } from "react-konva/es/ReactKonvaCore";
13
13
 
14
- import { roundedRectPath } from "../../lib/detail-page-canvas/clip-rect";
14
+ import { roundedRectPath } from "@leviosa-ai/canvas/paint/clip-rect";
15
15
  import {
16
16
  normalizeCanvasJsonForKonva,
17
17
  type ParsedGradient,
18
18
  type CanvasFallbackElement,
19
- } from "../../lib/detail-page-canvas/konva-fallback";
20
- import { computeHighlightBands } from "../../lib/detail-page-canvas/text-highlight-bands";
19
+ } from "@leviosa-ai/canvas/paint/konva-fallback";
20
+ import { computeHighlightBands } from "@leviosa-ai/canvas/paint/text-highlight-bands";
21
21
  import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
22
22
 
23
23
  /** Konva gradient/shadow/cornerRadius props derived from a fallback element. */
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useRef, useState, type ChangeEvent } from "react";
3
+ import { useMemo, useRef, useState, type ChangeEvent } from "react";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { ExternalLink, ImageOff, Loader2, Trash2, Upload } from "lucide-react";
6
6
  import Link from "next/link";
@@ -13,6 +13,14 @@ import type {
13
13
  BrandAssetKind,
14
14
  } from "./detail-page-host-context";
15
15
  import { insertPersonalImage } from "../../lib/detail-page/insert-image";
16
+ import {
17
+ BRAND_IMAGE_CATEGORIES,
18
+ BRAND_IMAGE_CATEGORY_LABEL_KEY,
19
+ countBrandImages,
20
+ groupBrandImages,
21
+ takeBrandImages,
22
+ type BrandImageCategory,
23
+ } from "../../lib/detail-page/brand-image-category";
16
24
  import { useProgressiveReveal } from "../../lib/detail-page/use-progressive-reveal";
17
25
 
18
26
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
@@ -102,6 +110,152 @@ function SourceTab({
102
110
  );
103
111
  }
104
112
 
113
+ /**
114
+ * 갈래 토글. **비어 있는 갈래는 내지 않는다** — 눌러 봐야 빈 화면인 칸은 고르는
115
+ * 일을 돕는 게 아니라 늘린다. 숫자는 거르기 전 전체라 갈아타도 흔들리지 않는다.
116
+ */
117
+ function CategoryFilter({
118
+ counts,
119
+ total,
120
+ value,
121
+ onChange,
122
+ }: {
123
+ counts: Record<BrandImageCategory, number>;
124
+ total: number;
125
+ value: BrandImageCategory | "all";
126
+ onChange: (next: BrandImageCategory | "all") => void;
127
+ }) {
128
+ const { t } = useTranslation("branding");
129
+ const available = BRAND_IMAGE_CATEGORIES.filter(
130
+ (category) => counts[category] > 0,
131
+ );
132
+ // 갈래가 하나뿐이면 토글은 전체와 같은 화면을 두 번 보여줄 뿐이다.
133
+ if (available.length < 2) return null;
134
+
135
+ return (
136
+ <div
137
+ role="group"
138
+ aria-label={t("detailPage.brandAssets.filterLabel")}
139
+ className="mb-2 flex flex-wrap gap-1"
140
+ >
141
+ <FilterChip
142
+ label={t("detailPage.brandAssets.filterAll")}
143
+ count={total}
144
+ active={value === "all"}
145
+ onClick={() => onChange("all")}
146
+ />
147
+ {available.map((category) => (
148
+ <FilterChip
149
+ key={category}
150
+ label={t(BRAND_IMAGE_CATEGORY_LABEL_KEY[category])}
151
+ count={counts[category]}
152
+ active={value === category}
153
+ onClick={() => onChange(category)}
154
+ />
155
+ ))}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function FilterChip({
161
+ label,
162
+ count,
163
+ active,
164
+ onClick,
165
+ }: {
166
+ label: string;
167
+ count: number;
168
+ active: boolean;
169
+ onClick: () => void;
170
+ }) {
171
+ return (
172
+ <button
173
+ type="button"
174
+ onClick={onClick}
175
+ aria-pressed={active}
176
+ className={
177
+ "rounded-dpe-lg border px-2 py-0.5 text-[11px] font-dpe-medium transition " +
178
+ (active
179
+ ? "border-dpe-ink-800 bg-dpe-ink-800 text-dpe-on-accent"
180
+ : "border-dpe-ink-200 text-dpe-ink-500 hover:border-dpe-ink-400 hover:text-dpe-ink-700")
181
+ }
182
+ >
183
+ {label}
184
+ <span className="ml-1 font-dpe-normal opacity-60">{count}</span>
185
+ </button>
186
+ );
187
+ }
188
+
189
+ /**
190
+ * 썸네일 한 칸.
191
+ *
192
+ * 높이를 ``img`` 가 아니라 **칸이** 잡는다. 사진은 뒤늦게(``loading="lazy"``) 도착
193
+ * 하는데 높이가 사진에 매달려 있으면 도착 전까지 칸이 납작하게 눌린다. 눌린 칸은
194
+ * 보기 나쁜 데서 끝나지 않는다 — 열두 장이 한 줌으로 접히면 바닥의 감시자가 계속
195
+ * 화면에 남아 다음 열두 장을 부르고, 그게 끝까지 이어져 결국 전부를 한꺼번에
196
+ * 불러온다. 스크롤한 만큼만 그리자는 얘기가 바로 여기서 무너진다.
197
+ */
198
+ function BrandImageCard({
199
+ asset,
200
+ store,
201
+ onDelete,
202
+ }: {
203
+ asset: BrandAsset;
204
+ store: unknown;
205
+ onDelete: () => void;
206
+ }) {
207
+ const { brand } = useDetailPageHost();
208
+ const { t } = useTranslation("branding");
209
+
210
+ return (
211
+ <div
212
+ data-dpe-part="asset-card"
213
+ className="group relative overflow-hidden rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-500"
214
+ >
215
+ <button
216
+ type="button"
217
+ disabled={!asset.download_url}
218
+ onClick={() =>
219
+ // presigned URL(download_url)은 15분이면 죽는다. 문서에 남는 주소는
220
+ // 만료 없는 경로여야 다시 열 때 403이 안 난다.
221
+ asset.download_url &&
222
+ insertPersonalImage(store, brand.brandAssetDocumentSrc(asset))
223
+ }
224
+ className="block h-24 w-full bg-dpe-ink-100 disabled:opacity-50"
225
+ title={t("detailPage.brandAssets.insertHint")}
226
+ >
227
+ {/* eslint-disable-next-line @next/next/no-img-element */}
228
+ <img
229
+ src={asset.download_url ?? ""}
230
+ crossOrigin="anonymous"
231
+ loading="lazy"
232
+ decoding="async"
233
+ alt={asset.display_name ?? asset.filename}
234
+ className="h-full w-full object-cover transition group-hover:opacity-90"
235
+ />
236
+ </button>
237
+ <span className="pointer-events-none absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-dpe-sm bg-dpe-scrim/70 px-1.5 py-0.5 text-[10px] text-dpe-on-accent">
238
+ {asset.display_name ?? asset.filename}
239
+ </span>
240
+ <button
241
+ type="button"
242
+ onClick={onDelete}
243
+ className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-dpe-md bg-dpe-surface/95 text-dpe-ink-400 shadow-sm hover:text-dpe-danger-600 group-hover:flex"
244
+ aria-label={t("detailPage.brandAssets.delete")}
245
+ >
246
+ <Trash2 aria-hidden="true" size={13} />
247
+ </button>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ /**
253
+ * 브랜드 자산 갈래. 안에서 다시 제품 · 모델 · 직접 생성 · 기타로 나눈다 —
254
+ * 무엇을 근거로 가르는지는 `lib/detail-page/brand-image-category.ts` 에 적혀 있다.
255
+ *
256
+ * 그리는 몫은 갈래 전체에 걸린 한 예산이고, 갈래를 갈아타면 예산도 처음으로
257
+ * 돌아간다. 나눠 놓고 여전히 200장을 굽는다면 나눈 값이 없다.
258
+ */
105
259
  function BrandAssetGallery({ store }: { store: unknown }) {
106
260
  const { brand, queryKeys, toast } = useDetailPageHost();
107
261
  const { t } = useTranslation("branding");
@@ -112,6 +266,7 @@ function BrandAssetGallery({ store }: { store: unknown }) {
112
266
  isLoading: brandsLoading,
113
267
  } = brand.useBrandWorkspace();
114
268
  const [error, setError] = useState<string | null>(null);
269
+ const [category, setCategory] = useState<BrandImageCategory | "all">("all");
115
270
  const fileRef = useRef<HTMLInputElement>(null);
116
271
  // GIF는 '브랜드 GIF' 패널이 맡는다 — 사진과 한 그리드에 섞이면 고르기 어렵다.
117
272
  const assetsQuery = useQuery({
@@ -120,9 +275,29 @@ function BrandAssetGallery({ store }: { store: unknown }) {
120
275
  enabled: Boolean(activeBrandId),
121
276
  staleTime: 4 * 60_000,
122
277
  });
123
- const items = (assetsQuery.data ?? []).filter(isImageAsset);
124
- // 브랜드를 옮기면 다시 처음부터 센다.
125
- const reveal = useProgressiveReveal(items.length, { resetKey: activeBrandId });
278
+ const items = useMemo(
279
+ () => (assetsQuery.data ?? []).filter(isImageAsset),
280
+ [assetsQuery.data],
281
+ );
282
+ const counts = useMemo(() => countBrandImages(items), [items]);
283
+ const sections = useMemo(() => groupBrandImages(items), [items]);
284
+ const shown = useMemo(
285
+ () =>
286
+ category === "all"
287
+ ? sections
288
+ : sections.filter((section) => section.category === category),
289
+ [sections, category],
290
+ );
291
+ const shownTotal = shown.reduce(
292
+ (sum, section) => sum + section.items.length,
293
+ 0,
294
+ );
295
+ // 브랜드를 옮기거나 갈래를 갈아타면 다시 처음부터 센다 — 200장을 보고 온 사람이
296
+ // 다른 갈래로 옮겨도 200장을 그대로 요청하면 서랍을 나눈 값이 사라진다.
297
+ const reveal = useProgressiveReveal(shownTotal, {
298
+ resetKey: `${activeBrandId ?? ""}:${category}`,
299
+ });
300
+ const revealed = takeBrandImages(shown, reveal.visible);
126
301
 
127
302
  const uploadMutation = useMutation({
128
303
  mutationFn: async (file: File) => {
@@ -209,58 +384,48 @@ function BrandAssetGallery({ store }: { store: unknown }) {
209
384
  </Link>
210
385
  </div>
211
386
  ) : (
212
- <div className="grid grid-cols-2 gap-2 overflow-y-auto">
213
- {items.slice(0, reveal.visible).map((asset) => (
214
- <div
215
- key={asset.id}
216
- data-dpe-part="asset-card"
217
- className="group relative overflow-hidden rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-500"
218
- >
219
- <button
220
- type="button"
221
- disabled={!asset.download_url}
222
- onClick={() =>
223
- // presigned URL(download_url)은 15분이면 죽는다. 문서에 남는 주소는
224
- // 만료 없는 경로여야 다시 열 때 403이 안 난다.
225
- asset.download_url &&
226
- insertPersonalImage(store, brand.brandAssetDocumentSrc(asset))
227
- }
228
- className="block w-full disabled:opacity-50"
229
- title={t("detailPage.brandAssets.insertHint")}
230
- >
231
- {/* eslint-disable-next-line @next/next/no-img-element */}
232
- <img
233
- src={asset.download_url ?? ""}
234
- crossOrigin="anonymous"
235
- loading="lazy"
236
- decoding="async"
237
- alt={asset.display_name ?? asset.filename}
238
- className="h-24 w-full object-cover transition group-hover:opacity-90"
239
- />
240
- </button>
241
- <span className="pointer-events-none absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-dpe-sm bg-dpe-scrim/70 px-1.5 py-0.5 text-[10px] text-dpe-on-accent">
242
- {asset.display_name ?? asset.filename}
243
- </span>
244
- <button
245
- type="button"
246
- onClick={() => deleteMutation.mutate(asset)}
247
- className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-dpe-md bg-dpe-surface/95 text-dpe-ink-400 shadow-sm hover:text-dpe-danger-600 group-hover:flex"
248
- aria-label={t("detailPage.brandAssets.delete")}
387
+ <>
388
+ <CategoryFilter
389
+ counts={counts}
390
+ total={items.length}
391
+ value={category}
392
+ onChange={setCategory}
393
+ />
394
+ <div className="min-h-0 flex-1 overflow-y-auto">
395
+ {revealed.map((section) => (
396
+ <section key={section.category} className="mb-4 last:mb-0">
397
+ {/* 갈래를 하나만 볼 때는 제목이 토글과 같은 말을 두 번 한다. */}
398
+ {category === "all" && (
399
+ <h3 className="mb-1.5 text-[11px] font-dpe-semibold text-dpe-ink-500">
400
+ {t(BRAND_IMAGE_CATEGORY_LABEL_KEY[section.category])}
401
+ <span className="ml-1 font-dpe-normal text-dpe-ink-300">
402
+ {counts[section.category]}
403
+ </span>
404
+ </h3>
405
+ )}
406
+ <div className="grid grid-cols-2 gap-2">
407
+ {section.items.map((asset) => (
408
+ <BrandImageCard
409
+ key={asset.id}
410
+ asset={asset}
411
+ store={store}
412
+ onDelete={() => deleteMutation.mutate(asset)}
413
+ />
414
+ ))}
415
+ </div>
416
+ </section>
417
+ ))}
418
+ {reveal.hasMore && (
419
+ <div
420
+ ref={reveal.sentinelRef}
421
+ data-testid="brand-assets-sentinel"
422
+ className="flex h-10 items-center justify-center text-dpe-ink-300"
249
423
  >
250
- <Trash2 aria-hidden="true" size={13} />
251
- </button>
252
- </div>
253
- ))}
254
- {reveal.hasMore && (
255
- <div
256
- ref={reveal.sentinelRef}
257
- data-testid="brand-assets-sentinel"
258
- className="col-span-2 flex h-10 items-center justify-center text-dpe-ink-300"
259
- >
260
- <Loader2 aria-hidden="true" className="animate-spin" size={16} />
261
- </div>
262
- )}
263
- </div>
424
+ <Loader2 aria-hidden="true" className="animate-spin" size={16} />
425
+ </div>
426
+ )}
427
+ </div>
428
+ </>
264
429
  )}
265
430
  </div>
266
431
  );
package/i18n/en.json CHANGED
@@ -369,7 +369,13 @@
369
369
  "uploadShape": "Upload brand SVG shape",
370
370
  "invalidShape": "Upload an SVG file up to 500KB.",
371
371
  "shapesEmpty": "No shapes saved for this brand.",
372
- "refresh": "Refresh"
372
+ "refresh": "Refresh",
373
+ "filterLabel": "Image category",
374
+ "filterAll": "All",
375
+ "filterProduct": "Product",
376
+ "filterModel": "Model",
377
+ "filterGenerated": "Generated",
378
+ "filterOther": "Other"
373
379
  },
374
380
  "brandGifs": {
375
381
  "notAGif": "Only GIF files can be uploaded.",
package/i18n/ko.json CHANGED
@@ -369,7 +369,13 @@
369
369
  "uploadShape": "브랜드 SVG 도형 업로드",
370
370
  "invalidShape": "500KB 이하 SVG 파일만 올릴 수 있어요.",
371
371
  "shapesEmpty": "이 브랜드에 저장된 도형이 없어요.",
372
- "refresh": "새로고침"
372
+ "refresh": "새로고침",
373
+ "filterLabel": "이미지 갈래",
374
+ "filterAll": "전체",
375
+ "filterProduct": "제품",
376
+ "filterModel": "모델",
377
+ "filterGenerated": "직접 생성",
378
+ "filterOther": "기타"
373
379
  },
374
380
  "brandGifs": {
375
381
  "notAGif": "GIF 파일만 올릴 수 있어요.",
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 브랜드 사진을 셀러가 찾는 방식대로 갈래 짓는다 — 제품 · 모델 · 직접 생성 · 기타.
3
+ *
4
+ * 브랜드가 오래 살수록 이 서랍은 수백 장이 된다. 한 그리드에 통째로 쌓아 두면
5
+ * "그때 그 모델 컷"을 찾는 일이 스크롤 노동이 된다. 다행히 근거는 이미 자산에 박혀
6
+ * 있다 — 업로드할 때 고른 ``asset_type``(제품/모델)과, 서버가 생성물에 찍어 주는
7
+ * ``metadata.source``.
8
+ *
9
+ * **썸네일 바이트만 봐서는 알 수 없는 것들이다.** 제품 사진과 모델 사진은 화면에서
10
+ * 구분이 되지만 "내가 캔버스에서 구운 것"인지는 그림으로 드러나지 않는다. 그래서
11
+ * 만든 시점에 찍힌 태그가 유일한 근거이고, 태그가 없으면 없는 사실을 만들지 않고
12
+ * '기타'로 둔다.
13
+ */
14
+
15
+ import type { BrandAsset } from "../../components/detail-page/detail-page-host-context";
16
+
17
+ export type BrandImageCategory = "product" | "model" | "generated" | "other";
18
+
19
+ /** 갈래 순서 = 화면 순서. 셀러가 부르는 순서대로 둔다. */
20
+ export const BRAND_IMAGE_CATEGORIES: readonly BrandImageCategory[] = [
21
+ "product",
22
+ "model",
23
+ "generated",
24
+ "other",
25
+ ];
26
+
27
+ export const BRAND_IMAGE_CATEGORY_LABEL_KEY: Record<
28
+ BrandImageCategory,
29
+ string
30
+ > = {
31
+ product: "detailPage.brandAssets.filterProduct",
32
+ model: "detailPage.brandAssets.filterModel",
33
+ generated: "detailPage.brandAssets.filterGenerated",
34
+ other: "detailPage.brandAssets.filterOther",
35
+ };
36
+
37
+ /**
38
+ * 캔버스·저작이 구워 브랜드 버킷에 바로 쓴 자산인가.
39
+ *
40
+ * 서버가 찍는 값은 지금 ``canvas_generated`` 하나지만, 굽는 자리가 늘 때마다 이
41
+ * 목록을 따라 고치게 두면 새 생성물이 조용히 '기타'로 샌다. 접미사로 보는 이유다.
42
+ * 셀러가 직접 올린 것(``canvas_upload``)은 여기 걸리지 않는다.
43
+ */
44
+ function isGeneratedAsset(asset: BrandAsset): boolean {
45
+ const source = asset.metadata?.source;
46
+ return typeof source === "string" && source.endsWith("generated");
47
+ }
48
+
49
+ /**
50
+ * 자산 하나의 갈래.
51
+ *
52
+ * ``asset_type`` 을 먼저 본다. 제품·모델은 올린 사람이 직접 고른 분류라 서버가
53
+ * 나중에 찍는 흔적보다 셀러의 의도에 가깝다 — 생성한 제품 컷을 제품 자리에서 못
54
+ * 찾으면 분류가 있으나 마나다.
55
+ */
56
+ export function brandImageCategory(asset: BrandAsset): BrandImageCategory {
57
+ if (asset.asset_type === "product_image") return "product";
58
+ if (asset.asset_type === "model_image") return "model";
59
+ if (isGeneratedAsset(asset)) return "generated";
60
+ return "other";
61
+ }
62
+
63
+ export interface BrandImageSection {
64
+ category: BrandImageCategory;
65
+ items: BrandAsset[];
66
+ }
67
+
68
+ /**
69
+ * 갈래별 장수. 토글에 붙는 숫자라 **거르기 전 전체**를 세야 한다 — 제품만 보는
70
+ * 중에 모델 숫자가 0으로 바뀌면 숫자가 아니라 착시가 된다.
71
+ */
72
+ export function countBrandImages(
73
+ assets: BrandAsset[],
74
+ ): Record<BrandImageCategory, number> {
75
+ const counts: Record<BrandImageCategory, number> = {
76
+ product: 0,
77
+ model: 0,
78
+ generated: 0,
79
+ other: 0,
80
+ };
81
+ for (const asset of assets) counts[brandImageCategory(asset)] += 1;
82
+ return counts;
83
+ }
84
+
85
+ /**
86
+ * 화면 구획으로 나눈다. **비어 있지 않은 갈래만** — 빈 제목만 네 줄 떠 있으면
87
+ * 패널이 고장 난 것처럼 보인다.
88
+ */
89
+ export function groupBrandImages(assets: BrandAsset[]): BrandImageSection[] {
90
+ const buckets = new Map<BrandImageCategory, BrandAsset[]>();
91
+ for (const asset of assets) {
92
+ const key = brandImageCategory(asset);
93
+ const bucket = buckets.get(key);
94
+ if (bucket) bucket.push(asset);
95
+ else buckets.set(key, [asset]);
96
+ }
97
+ return BRAND_IMAGE_CATEGORIES.map((category) => ({
98
+ category,
99
+ items: buckets.get(category) ?? [],
100
+ })).filter((section) => section.items.length > 0);
101
+ }
102
+
103
+ /**
104
+ * 앞에서부터 ``visible`` 장까지만 남긴다.
105
+ *
106
+ * 그릴 몫은 구획별이 아니라 **패널 전체 예산**이다. 구획마다 따로 세면 갈래가 넷일
107
+ * 때 첫 화면에 48장이 깔린다 — 스크롤한 만큼만 그리자는 얘기가 무색해진다.
108
+ */
109
+ export function takeBrandImages(
110
+ sections: BrandImageSection[],
111
+ visible: number,
112
+ ): BrandImageSection[] {
113
+ const taken: BrandImageSection[] = [];
114
+ let left = Math.max(0, visible);
115
+ for (const section of sections) {
116
+ if (left <= 0) break;
117
+ taken.push({ ...section, items: section.items.slice(0, left) });
118
+ left -= Math.min(left, section.items.length);
119
+ }
120
+ return taken;
121
+ }
@@ -47,11 +47,23 @@ export function useProgressiveReveal(
47
47
  const [count, setCount] = useState(initial);
48
48
  const [node, setNode] = useState<HTMLElement | null>(null);
49
49
 
50
- // 목록이 갈아끼워지면(브랜드 전환·새로고침) 다시 처음부터 센다. 안 그러면 200장을
51
- // 보고 온 사람이 다른 브랜드로 옮겨도 200장을 그대로 요청한다.
52
- useEffect(() => {
50
+ // 목록이 갈아끼워지면(브랜드 전환·갈래 전환·새로고침) 다시 처음부터 센다. 안 그러면
51
+ // 200장을 보고 온 사람이 다른 목록으로 옮겨도 200장을 그대로 요청한다.
52
+ //
53
+ // effect 가 아니라 **렌더 중에** 되돌린다. effect 로 미루면 갈아탄 직후 한 프레임
54
+ // 동안 새 목록에 옛 숫자가 걸려, 열두 장만 보자고 나눈 목록이 그 프레임에 스물넷을
55
+ // 굽는다. 되돌릴 값이 렌더에 이미 있는데 그림을 한 번 내보낼 이유가 없다.
56
+ const [lastResetKey, setLastResetKey] = useState<unknown>(resetKey);
57
+ const [lastInitial, setLastInitial] = useState(initial);
58
+ // 되돌린 값은 이번 렌더가 바로 쓴다. `count` 는 다음 렌더에나 바뀌므로, 이걸
59
+ // 안 두면 되돌리기로 해 놓고 정작 이번 렌더는 옛 숫자를 내보낸다.
60
+ let revealed = count;
61
+ if (!Object.is(lastResetKey, resetKey) || lastInitial !== initial) {
62
+ setLastResetKey(resetKey);
63
+ setLastInitial(initial);
53
64
  setCount(initial);
54
- }, [resetKey, initial]);
65
+ revealed = initial;
66
+ }
55
67
 
56
68
  useEffect(() => {
57
69
  if (!node || count >= total) return;
@@ -75,8 +87,8 @@ export function useProgressiveReveal(
75
87
  }, [node, count, total, step]);
76
88
 
77
89
  return {
78
- visible: Math.min(count, total),
90
+ visible: Math.min(revealed, total),
79
91
  sentinelRef: setNode,
80
- hasMore: count < total,
92
+ hasMore: revealed < total,
81
93
  };
82
94
  }
@@ -1,4 +1,4 @@
1
- import { parseCssGradient, linearGradientKonvaProps, radialGradientKonvaProps } from "../konva-fallback";
1
+ import { parseCssGradient, linearGradientKonvaProps, radialGradientKonvaProps } from "@leviosa-ai/canvas/paint/konva-fallback";
2
2
  import {
3
3
  documentWidth,
4
4
  pageHeight,
@@ -1,7 +1,7 @@
1
1
  import type { Layer, Psd } from "ag-psd";
2
2
 
3
3
  import { parseColor } from "./color";
4
- import { parseCssGradient } from "../konva-fallback";
4
+ import { parseCssGradient } from "@leviosa-ai/canvas/paint/konva-fallback";
5
5
  import {
6
6
  documentWidth,
7
7
  pageHeight,
@@ -2,7 +2,7 @@ import {
2
2
  linearGradientKonvaProps,
3
3
  parseCssGradient,
4
4
  radialGradientKonvaProps,
5
- } from "../konva-fallback";
5
+ } from "@leviosa-ai/canvas/paint/konva-fallback";
6
6
  import type { ExportElement } from "./document-model";
7
7
  import { cssFont, layoutText, normalizeFontWeight, transformText } from "./text-layout";
8
8
 
@@ -2,7 +2,7 @@ import {
2
2
  linearGradientKonvaProps,
3
3
  parseCssGradient,
4
4
  radialGradientKonvaProps,
5
- } from "../konva-fallback";
5
+ } from "@leviosa-ai/canvas/paint/konva-fallback";
6
6
  import {
7
7
  documentWidth,
8
8
  pageHeight,
@@ -1,4 +1,5 @@
1
- import { parseCssGradient, type ParsedGradient } from "./konva-fallback";
1
+ import { parseCssGradient, type ParsedGradient } from "@leviosa-ai/canvas/paint/konva-fallback";
2
+ import { isTransparentColor } from "@leviosa-ai/canvas/paint/text-highlight-bands";
2
3
 
3
4
  /**
4
5
  * The Canvas SDK editor paints a text element's background as a per-line
@@ -56,19 +57,6 @@ export type TextBackgroundPlan =
56
57
  }
57
58
  | null;
58
59
 
59
- /** True for ``"transparent"`` or any colour whose alpha channel is 0. */
60
- export function isTransparentColor(value: unknown): boolean {
61
- if (typeof value !== "string") return true;
62
- const color = value.trim().toLowerCase();
63
- if (!color || color === "transparent" || color === "none") return true;
64
- const rgba = color.match(/^rgba?\(([^)]+)\)$/);
65
- if (rgba) {
66
- const parts = rgba[1].split(",").map((part) => part.trim());
67
- if (parts.length === 4) return parseFloat(parts[3]) === 0;
68
- }
69
- return false;
70
- }
71
-
72
60
  /**
73
61
  * Decide what background rect (if any) to paint behind a Canvas text element.
74
62
  * Returns ``null`` when the stock per-line contour is already correct (e.g. a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
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.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -62,7 +62,7 @@
62
62
  "woff2-encoder": "2.0.0"
63
63
  },
64
64
  "peerDependencies": {
65
- "@leviosa-ai/canvas": "^0.1.1",
65
+ "@leviosa-ai/canvas": "^0.1.4",
66
66
  "@leviosa-ai/konva": "^0.8.0",
67
67
  "@tanstack/react-query": "^5.100.3",
68
68
  "konva": "^10.0.0",