@leviosa-ai/detail-page-editor 0.12.0 → 0.13.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/detail-page-download-dialog.tsx +29 -20
- package/components/detail-page/detail-page-editor.tsx +19 -2
- package/components/detail-page/detail-page-frame-header.tsx +103 -0
- package/components/detail-page/detail-page-pages-panel.tsx +18 -10
- package/components/detail-page/detail-page-pages-timeline.tsx +5 -2
- package/components/detail-page/detail-page-thumbnail-bus.ts +20 -9
- package/components/detail-page/edit-quota-ui.tsx +6 -6
- package/components/detail-page/frame-drag-bus.ts +65 -0
- package/components/detail-page/frame-drag-grip.tsx +37 -0
- package/components/detail-page/frame-drag-layer.tsx +261 -0
- package/components/detail-page/generation-progress-fill.tsx +4 -4
- package/components/detail-page/group-prompt-edit-panel.tsx +6 -6
- package/components/detail-page/leviosa-canvas-workspace.tsx +171 -28
- package/components/detail-page/prompt-edit-panel.tsx +6 -6
- package/components/detail-page/spec-prompt-edit-panel.tsx +7 -7
- package/components/detail-page/svg-prompt-edit-panel.tsx +6 -6
- package/components/ui/button.tsx +14 -1
- package/components/ui/dialog.tsx +6 -1
- package/components/ui/select.tsx +15 -2
- package/components/ui/slider.tsx +5 -0
- package/components/ui/switch.tsx +8 -1
- package/components/ui/tooltip.tsx +8 -1
- package/lib/detail-page/editor-profile.ts +14 -1
- package/lib/detail-page/frame-pages.ts +27 -0
- package/package.json +2 -2
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../ui/select";
|
|
20
20
|
import type { ExportDocument } from "../../lib/detail-page-canvas/export/document-model";
|
|
21
21
|
import { detectGifPages } from "../../lib/detail-page-canvas/export/gif-plan";
|
|
22
|
+
import { activeFramePages } from "../../lib/detail-page/frame-pages";
|
|
22
23
|
import { isMp4EncodeSupported } from "../../lib/detail-page-canvas/export/mp4-support";
|
|
23
24
|
import {
|
|
24
25
|
detailPageEditorProfile,
|
|
@@ -43,6 +44,8 @@ type ExportPageLike = {
|
|
|
43
44
|
id: string;
|
|
44
45
|
computedWidth: number;
|
|
45
46
|
computedHeight: number;
|
|
47
|
+
/** 이 판이 속한 프레임 이름이 여기 산다(`custom.frame`). */
|
|
48
|
+
custom?: unknown;
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
type ExportStoreLike = {
|
|
@@ -58,7 +61,8 @@ type ExportStoreLike = {
|
|
|
58
61
|
};
|
|
59
62
|
|
|
60
63
|
// 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
|
|
61
|
-
// 어디에 올릴지 고르며 맥락을 잡도록 노출한다.
|
|
64
|
+
// 어디에 올릴지 고르며 맥락을 잡도록 노출한다. 캐러셀은 인스타그램 한 곳으로만
|
|
65
|
+
// 나가므로 이 목록을 아예 안 띄운다 — `profile.registerPlatform` 이 그것을 정한다.
|
|
62
66
|
const PLATFORMS = [
|
|
63
67
|
{ value: "naver", label: "네이버 스마트 스토어" },
|
|
64
68
|
{ value: "coupang", label: "쿠팡" },
|
|
@@ -198,7 +202,10 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
198
202
|
|
|
199
203
|
const selectedPages = useMemo(() => {
|
|
200
204
|
if (scope === "current" && s.activePage) return [s.activePage];
|
|
201
|
-
|
|
205
|
+
// 후보 여러 벌이 한 문서에 있으면 «전체»는 문서 전체가 아니라 **보고 있는 한 벌**
|
|
206
|
+
// 이다. 넷을 다 내보내면 마흔 장짜리 묶음이 나오고, 그걸 원해서 누르는 사람은
|
|
207
|
+
// 없다. 꼬리표 없는 문서에서는 지금까지처럼 전부다.
|
|
208
|
+
return activeFramePages(s.pages, s.activePage?.id);
|
|
202
209
|
}, [scope, s.activePage, s.pages]);
|
|
203
210
|
|
|
204
211
|
const docWidth = useMemo(
|
|
@@ -220,7 +227,7 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
220
227
|
setError(null);
|
|
221
228
|
const mime = format === "png" ? "image/png" : "image/jpeg";
|
|
222
229
|
const ext = format === "png" ? "png" : "jpg";
|
|
223
|
-
const base = `${fileName}-${platform}
|
|
230
|
+
const base = profile.registerPlatform ? `${fileName}-${platform}` : fileName;
|
|
224
231
|
try {
|
|
225
232
|
if (format === "psd" || format === "svg" || format === "ai") {
|
|
226
233
|
// 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
|
|
@@ -384,23 +391,25 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
384
391
|
</Select>
|
|
385
392
|
</div>
|
|
386
393
|
|
|
387
|
-
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
<
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{p.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
394
|
+
{profile.registerPlatform && (
|
|
395
|
+
<div className="space-y-1.5">
|
|
396
|
+
<Label className="text-xs text-dpe-ink-500">
|
|
397
|
+
{t("editor.registerPlatform")}
|
|
398
|
+
</Label>
|
|
399
|
+
<Select value={platform} onValueChange={setPlatform}>
|
|
400
|
+
<SelectTrigger className="w-full">
|
|
401
|
+
<SelectValue />
|
|
402
|
+
</SelectTrigger>
|
|
403
|
+
<SelectContent>
|
|
404
|
+
{PLATFORMS.map((p) => (
|
|
405
|
+
<SelectItem key={p.value} value={p.value}>
|
|
406
|
+
{p.label}
|
|
407
|
+
</SelectItem>
|
|
408
|
+
))}
|
|
409
|
+
</SelectContent>
|
|
410
|
+
</Select>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
404
413
|
|
|
405
414
|
<div className="space-y-1.5">
|
|
406
415
|
<Label className="text-xs text-dpe-ink-500">{t("editor.pageScope")}</Label>
|
|
@@ -117,6 +117,16 @@ export type DetailPageEditorProps = {
|
|
|
117
117
|
structurePanel?: ReactNode;
|
|
118
118
|
/** 헤더에 표시할 현재 상품/상세페이지 이름. 없으면 기본 라벨. */
|
|
119
119
|
productName?: string;
|
|
120
|
+
/**
|
|
121
|
+
* 결과물이 될 벌 — 내려받기·발행이 향하는 곳.
|
|
122
|
+
*
|
|
123
|
+
* 문서에 안 적고 밖에서 받는다. 후보를 담은 화면은 이미 서버가 «고른 후보»를
|
|
124
|
+
* 들고 있고(목록 썸네일과 레퍼런스가 읽는 자리가 그것이다), 두 곳에 적어 두면
|
|
125
|
+
* 언젠가 서로 다른 답을 한다.
|
|
126
|
+
*/
|
|
127
|
+
chosenFrame?: string;
|
|
128
|
+
/** 안 주면 벌에 체크를 안 그린다. */
|
|
129
|
+
onChooseFrame?: (frameKey: string) => void;
|
|
120
130
|
/** 헤더 좌측 "뒤로가기" 동작. 없으면 버튼을 숨긴다. */
|
|
121
131
|
onBack?: () => void;
|
|
122
132
|
/**
|
|
@@ -152,6 +162,8 @@ export function DetailPageEditor({
|
|
|
152
162
|
generatedId,
|
|
153
163
|
structurePanel,
|
|
154
164
|
productName,
|
|
165
|
+
chosenFrame,
|
|
166
|
+
onChooseFrame,
|
|
155
167
|
onBack,
|
|
156
168
|
headerActions,
|
|
157
169
|
}: DetailPageEditorProps) {
|
|
@@ -289,14 +301,19 @@ export function DetailPageEditor({
|
|
|
289
301
|
/>
|
|
290
302
|
)}
|
|
291
303
|
<div className="relative min-w-0 flex-1">
|
|
292
|
-
<LeviosaCanvasWorkspace
|
|
304
|
+
<LeviosaCanvasWorkspace
|
|
305
|
+
store={store}
|
|
306
|
+
gap={4}
|
|
307
|
+
chosenFrame={chosenFrame}
|
|
308
|
+
onChooseFrame={onChooseFrame}
|
|
309
|
+
>
|
|
293
310
|
{findReplace}
|
|
294
311
|
<DetailPagePagesTimeline store={store} />
|
|
295
312
|
</LeviosaCanvasWorkspace>
|
|
296
313
|
</div>
|
|
297
314
|
</div>
|
|
298
315
|
);
|
|
299
|
-
}, [store, sidebarSections, SidebarSlot]);
|
|
316
|
+
}, [store, sidebarSections, SidebarSlot, chosenFrame, onChooseFrame]);
|
|
300
317
|
|
|
301
318
|
const aiValue = useMemo(
|
|
302
319
|
() => ({
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 벌 위에 붙는 것 — **이게 결과물인가**, 그리고 **바꾸는 길**.
|
|
5
|
+
*
|
|
6
|
+
* ## 화면에 라벨은 최대 둘
|
|
7
|
+
*
|
|
8
|
+
* 넷에 다 라벨을 달면 «라벨 넷»으로 읽히고, 무엇이 상태이고 무엇이 행동인지 모양으로
|
|
9
|
+
* 구분이 안 된다. Shopify 의 테마 목록도 Vercel 의 배포 목록도 반대로 한다 — 지금
|
|
10
|
+
* 나가는 것 **하나에만** 표를 달고, 바꾸는 행동은 조용한 곳에 둔다.
|
|
11
|
+
*
|
|
12
|
+
* 대표인 벌 → «대표» 글씨. 누를 것이 아니라 상태다
|
|
13
|
+
* 보고 있는 벌 → «대표로 지정» 테두리 버튼. 누르면 바뀐다
|
|
14
|
+
* 나머지 → 아무것도 없음
|
|
15
|
+
*
|
|
16
|
+
* 고르려면 먼저 그 벌을 본다 — 이미 하던 동작이라 클릭이 하나 느는 것이 아니다.
|
|
17
|
+
*
|
|
18
|
+
* ## 뱃지에 손잡이를 달지 않는다
|
|
19
|
+
*
|
|
20
|
+
* 상태를 나타내는 표는 누르는 것이 아니고, 누르는 것은 버튼처럼 생겨야 한다. 둘을
|
|
21
|
+
* 같은 모양으로 두면 어느 쪽이 무엇인지 읽어 봐야 안다. 그래서 대표는 **글씨**,
|
|
22
|
+
* 지정은 **테두리 있는 버튼**이다.
|
|
23
|
+
*
|
|
24
|
+
* ## 왜 «최종»이 아닌가
|
|
25
|
+
*
|
|
26
|
+
* 언제든 바꾸는 값인데 «최종»이라 적으면 되돌릴 수 없다는 느낌을 준다 — 누르기 전에
|
|
27
|
+
* 한 번 망설이게 된다. 게다가 계속 고칠 문서라 최종도 아니다. «대표»는 여럿 중 밖으로
|
|
28
|
+
* 나가는 하나라는 뜻이 이미 붙어 있는 말이고(대표 사진), 실제로 이 값이 목록 카드
|
|
29
|
+
* 썸네일과 레퍼런스로 나가는 판까지 정한다.
|
|
30
|
+
*
|
|
31
|
+
* ## 흰 판 위가 아니라 회색 바닥 위
|
|
32
|
+
*
|
|
33
|
+
* 작업물을 가리지 않는 자리다. 피그마가 프레임 이름을 두는 그 자리이기도 하다.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** 이 줄이 차지하는 높이(화면 px). 배율과 무관하게 읽혀야 하므로 안 줄인다. */
|
|
37
|
+
export const FRAME_HEAD_HEIGHT = 22;
|
|
38
|
+
|
|
39
|
+
const COPY = {
|
|
40
|
+
chosen: "대표",
|
|
41
|
+
choose: "대표로 지정",
|
|
42
|
+
hint: "내려받기와 발행은 대표만 나갑니다",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function DetailPageFrameHeader({
|
|
46
|
+
chosen,
|
|
47
|
+
selected,
|
|
48
|
+
onChoose,
|
|
49
|
+
}: {
|
|
50
|
+
chosen: boolean;
|
|
51
|
+
/** 지금 보고 있는 벌인가. 여기에만 바꾸는 버튼이 뜬다. */
|
|
52
|
+
selected: boolean;
|
|
53
|
+
/** 안 주면 아무것도 안 그린다 — 고를 것이 없는 문서도 있다. */
|
|
54
|
+
onChoose?: () => void;
|
|
55
|
+
}) {
|
|
56
|
+
if (!onChoose) return null;
|
|
57
|
+
|
|
58
|
+
const place = {
|
|
59
|
+
position: "absolute" as const,
|
|
60
|
+
left: 2,
|
|
61
|
+
bottom: "100%" as const,
|
|
62
|
+
marginBottom: 4,
|
|
63
|
+
height: FRAME_HEAD_HEIGHT - 4,
|
|
64
|
+
whiteSpace: "nowrap" as const,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (chosen) {
|
|
68
|
+
return (
|
|
69
|
+
<span
|
|
70
|
+
style={place}
|
|
71
|
+
title={COPY.hint}
|
|
72
|
+
className="flex items-center gap-1 text-[11px] font-dpe-bold text-dpe-ink-900"
|
|
73
|
+
>
|
|
74
|
+
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true">
|
|
75
|
+
<path
|
|
76
|
+
d="M3 8.5 6.5 12 13 4.5"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth="2.6"
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
strokeLinejoin="round"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
{COPY.chosen}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 보고 있지 않은 벌은 아무 말도 안 한다. 누르면 보게 되고, 그때 이 버튼이 뜬다.
|
|
90
|
+
if (!selected) return null;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
style={place}
|
|
96
|
+
title={COPY.hint}
|
|
97
|
+
onClick={onChoose}
|
|
98
|
+
className="flex items-center rounded-full border border-dpe-ink-400 bg-dpe-surface px-2 text-[11px] font-dpe-semibold text-dpe-ink-700 transition-colors hover:border-dpe-ink-900 hover:text-dpe-ink-900"
|
|
99
|
+
>
|
|
100
|
+
{COPY.choose}
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
|
|
4
|
+
import { activeFramePages } from "../../lib/detail-page/frame-pages";
|
|
4
5
|
|
|
5
6
|
import { Fragment, useCallback, useSyncExternalStore } from "react";
|
|
6
7
|
import { observer } from "./canvas-observer";
|
|
@@ -151,11 +152,14 @@ const PageRow = observer(function PageRow({
|
|
|
151
152
|
store,
|
|
152
153
|
page,
|
|
153
154
|
index,
|
|
155
|
+
canDuplicate,
|
|
154
156
|
thumb,
|
|
155
157
|
}: {
|
|
156
158
|
store: StoreLike;
|
|
157
159
|
page: PageLike;
|
|
158
160
|
index: number;
|
|
161
|
+
/** 이 벌에 한 장을 더 넣을 수 있는가. 세는 것은 목록이 한다. */
|
|
162
|
+
canDuplicate: boolean;
|
|
159
163
|
thumb: string | undefined;
|
|
160
164
|
}) {
|
|
161
165
|
const { t } = useTranslation("branding");
|
|
@@ -171,7 +175,6 @@ const PageRow = observer(function PageRow({
|
|
|
171
175
|
const active = store.activePage?.id === page.id;
|
|
172
176
|
const ratio = page.computedHeight / Math.max(1, page.computedWidth);
|
|
173
177
|
const { role, title } = pageLabel(page, index, t);
|
|
174
|
-
const maxPages = detailPageEditorProfile().maxPages;
|
|
175
178
|
|
|
176
179
|
const style: React.CSSProperties = {
|
|
177
180
|
transform: CSS.Transform.toString(transform),
|
|
@@ -238,7 +241,7 @@ const PageRow = observer(function PageRow({
|
|
|
238
241
|
<div className="flex shrink-0 flex-col gap-1">
|
|
239
242
|
<button
|
|
240
243
|
type="button"
|
|
241
|
-
disabled={
|
|
244
|
+
disabled={!canDuplicate}
|
|
242
245
|
onClick={(event) => {
|
|
243
246
|
event.stopPropagation();
|
|
244
247
|
page.clone?.();
|
|
@@ -311,22 +314,26 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
|
|
|
311
314
|
// 합계가 곧 문서 높이지만, 캐러셀은 판이 따로따로라 합계가 아무 뜻이 없다 —
|
|
312
315
|
// 8판짜리가 1080×10800 으로 보였다.
|
|
313
316
|
const profile = detailPageEditorProfile();
|
|
314
|
-
|
|
317
|
+
// 목록은 «지금 보고 있는 한 벌»만 담는다. 후보 넷을 담은 문서에서 마흔 줄을
|
|
318
|
+
// 늘어놓으면 어느 줄이 어느 벌인지 알 수가 없다.
|
|
319
|
+
const pages = activeFramePages(s.pages, s.activePage?.id);
|
|
320
|
+
const width = Math.round(pages[0]?.computedWidth ?? 0);
|
|
315
321
|
const shownHeight = profile.page.fixed
|
|
316
322
|
? Math.round(
|
|
317
323
|
typeof profile.page.height === "number"
|
|
318
324
|
? profile.page.height
|
|
319
|
-
: (
|
|
325
|
+
: (pages[0]?.computedHeight ?? 0),
|
|
320
326
|
)
|
|
321
|
-
: Math.round(
|
|
327
|
+
: Math.round(pages.reduce((acc, p) => acc + p.computedHeight, 0));
|
|
322
328
|
|
|
323
|
-
|
|
329
|
+
// 장 수 상한도 프레임마다다 — 한 벌이 열 장이라고 다른 벌까지 막을 이유가 없다.
|
|
330
|
+
const canAdd = pages.length < profile.maxPages;
|
|
324
331
|
|
|
325
332
|
return (
|
|
326
333
|
<div className="flex h-full flex-col">
|
|
327
334
|
<div className="flex items-baseline justify-between px-4 py-3">
|
|
328
335
|
<p className="text-base font-dpe-bold text-dpe-ink-900">
|
|
329
|
-
{t("detailPage.pages.totalPages", { count:
|
|
336
|
+
{t("detailPage.pages.totalPages", { count: pages.length })}
|
|
330
337
|
</p>
|
|
331
338
|
<p className="text-xs text-dpe-ink-400">
|
|
332
339
|
{width} × {shownHeight} px
|
|
@@ -341,17 +348,18 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
|
|
|
341
348
|
onDragEnd={handleDragEnd}
|
|
342
349
|
>
|
|
343
350
|
<SortableContext
|
|
344
|
-
items={
|
|
351
|
+
items={pages.map((p) => p.id)}
|
|
345
352
|
strategy={verticalListSortingStrategy}
|
|
346
353
|
>
|
|
347
354
|
<div className="space-y-2">
|
|
348
|
-
{
|
|
355
|
+
{pages.map((page, index) => (
|
|
349
356
|
<Fragment key={page.id}>
|
|
350
357
|
<PageRow
|
|
351
358
|
store={s}
|
|
352
359
|
page={page}
|
|
353
360
|
index={index}
|
|
354
|
-
|
|
361
|
+
canDuplicate={canAdd}
|
|
362
|
+
thumb={detailPageThumbnailBus.get(s, page.id)}
|
|
355
363
|
/>
|
|
356
364
|
{/* 새 화면은 «어디에» 가 먼저다. 목록 끝에 버튼 하나를 두면 넣고 나서
|
|
357
365
|
다시 끌어 옮기게 되므로, 넣을 자리마다 하나씩 둔다. */}
|
|
@@ -15,6 +15,7 @@ import { useSyncExternalStore } from "react";
|
|
|
15
15
|
|
|
16
16
|
import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
|
|
17
17
|
import { observer } from "./canvas-observer";
|
|
18
|
+
import { activeFramePages } from "../../lib/detail-page/frame-pages";
|
|
18
19
|
|
|
19
20
|
type PageLike = { id: string; name?: unknown };
|
|
20
21
|
type StoreLike = {
|
|
@@ -48,6 +49,8 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
|
|
|
48
49
|
() => 0,
|
|
49
50
|
);
|
|
50
51
|
const activeId = s.activePage?.id;
|
|
52
|
+
// 아래 띠도 목록과 같은 것을 본다 — 둘 다 «지금 어디 있나»를 말하는 물건이다.
|
|
53
|
+
const pages = activeFramePages(s.pages, activeId);
|
|
51
54
|
|
|
52
55
|
if (!pagesTimelineVisible(s)) return null;
|
|
53
56
|
|
|
@@ -57,9 +60,9 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
|
|
|
57
60
|
style={{ minHeight: PAGES_TIMELINE_HEIGHT }}
|
|
58
61
|
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"
|
|
59
62
|
>
|
|
60
|
-
{
|
|
63
|
+
{pages.map((page, index) => {
|
|
61
64
|
const active = page.id === activeId;
|
|
62
|
-
const thumb = detailPageThumbnailBus.get(page.id);
|
|
65
|
+
const thumb = detailPageThumbnailBus.get(s, page.id);
|
|
63
66
|
return (
|
|
64
67
|
<button
|
|
65
68
|
key={page.id}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tiny cross-component cache for page thumbnails (data URLs), keyed by page id.
|
|
4
|
+
* Tiny cross-component cache for page thumbnails (data URLs), keyed by store + page id.
|
|
5
5
|
*
|
|
6
6
|
* The stacked workspace owns the live Konva canvases (it renders the real
|
|
7
7
|
* ``<Page>`` components), so it captures thumbnails from the DOM canvas there and
|
|
@@ -12,22 +12,33 @@
|
|
|
12
12
|
* download for the same reason — real exports are server-side).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// 캐러셀마다 `p01`부터 다시 쓰므로 page id만 키로 쓰면 다른 문서의 그림이 섞인다.
|
|
16
|
+
// WeakMap이면 편집기를 닫았을 때 그 문서의 data URL도 같이 버려진다.
|
|
17
|
+
const cache = new WeakMap<object, Map<string, string>>();
|
|
16
18
|
const listeners = new Set<() => void>();
|
|
17
19
|
let version = 0;
|
|
18
20
|
|
|
21
|
+
function entries(scope: object): Map<string, string> {
|
|
22
|
+
const found = cache.get(scope);
|
|
23
|
+
if (found) return found;
|
|
24
|
+
const created = new Map<string, string>();
|
|
25
|
+
cache.set(scope, created);
|
|
26
|
+
return created;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export const detailPageThumbnailBus = {
|
|
20
|
-
get(id: string): string | undefined {
|
|
21
|
-
return cache.get(id);
|
|
30
|
+
get(scope: object, id: string): string | undefined {
|
|
31
|
+
return cache.get(scope)?.get(id);
|
|
22
32
|
},
|
|
23
|
-
set(id: string, dataUrl: string): void {
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
set(scope: object, id: string, dataUrl: string): void {
|
|
34
|
+
const scoped = entries(scope);
|
|
35
|
+
if (scoped.get(id) === dataUrl) return;
|
|
36
|
+
scoped.set(id, dataUrl);
|
|
26
37
|
version += 1;
|
|
27
38
|
listeners.forEach((l) => l());
|
|
28
39
|
},
|
|
29
|
-
has(id: string): boolean {
|
|
30
|
-
return cache.has(id);
|
|
40
|
+
has(scope: object, id: string): boolean {
|
|
41
|
+
return cache.get(scope)?.has(id) ?? false;
|
|
31
42
|
},
|
|
32
43
|
// Monotonic version for useSyncExternalStore — changes whenever a thumbnail lands.
|
|
33
44
|
getVersion(): number {
|
|
@@ -90,7 +90,7 @@ export function EditUsageBadge({
|
|
|
90
90
|
if (unlimited) {
|
|
91
91
|
return (
|
|
92
92
|
<span
|
|
93
|
-
className="ml-auto text-[11px] tabular-nums text-
|
|
93
|
+
className="ml-auto text-[11px] tabular-nums text-dpe-ink-500"
|
|
94
94
|
title={t("detailPage.editQuota.unlimitedTitle")}
|
|
95
95
|
>
|
|
96
96
|
{t("detailPage.editQuota.usedUnlimited", { used })}
|
|
@@ -104,10 +104,10 @@ export function EditUsageBadge({
|
|
|
104
104
|
className={
|
|
105
105
|
"ml-auto text-[11px] tabular-nums " +
|
|
106
106
|
(remaining === 0
|
|
107
|
-
? "text-
|
|
107
|
+
? "text-dpe-danger-600"
|
|
108
108
|
: warn
|
|
109
109
|
? "text-dpe-warn-500"
|
|
110
|
-
: "text-
|
|
110
|
+
: "text-dpe-ink-500")
|
|
111
111
|
}
|
|
112
112
|
title={t("detailPage.editQuota.remainingTitle", { remaining })}
|
|
113
113
|
>
|
|
@@ -130,17 +130,17 @@ export function EditQuotaBlock({
|
|
|
130
130
|
: t("detailPage.editQuota.textEdit");
|
|
131
131
|
return (
|
|
132
132
|
<div className="flex flex-col items-center gap-2 px-4 py-4 text-center">
|
|
133
|
-
<p className="text-[13px] font-dpe-medium text-
|
|
133
|
+
<p className="text-[13px] font-dpe-medium text-dpe-ink-900">
|
|
134
134
|
{t("detailPage.editQuota.quotaExhausted", { label })}
|
|
135
135
|
</p>
|
|
136
|
-
<p className="text-[11px] leading-4 text-
|
|
136
|
+
<p className="text-[11px] leading-4 text-dpe-ink-500">
|
|
137
137
|
{t("detailPage.editQuota.quotaHint")}
|
|
138
138
|
</p>
|
|
139
139
|
<button
|
|
140
140
|
type="button"
|
|
141
141
|
onClick={onBuyMore}
|
|
142
142
|
disabled={!onBuyMore}
|
|
143
|
-
className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-
|
|
143
|
+
className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-dpe-ink-900 px-3 py-1.5 text-xs font-dpe-medium text-dpe-on-accent disabled:opacity-40"
|
|
144
144
|
>
|
|
145
145
|
<Sparkles size={13} />
|
|
146
146
|
{t("detailPage.editQuota.buyCredits")}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 손잡이와 끌기 층을 잇는 한 줄.
|
|
3
|
+
*
|
|
4
|
+
* 손잡이는 **판 상자 안**에 그려진다(엔진의 판 위 슬롯). 끌리는 동안 보이는 것들은
|
|
5
|
+
* 모든 판 **위**에 뜬 층이 그린다. 둘은 트리에서 멀리 떨어져 있는데, 그 사이로
|
|
6
|
+
* props 를 나르자고 여러 겹을 뚫는 것보다 이 한 줄이 싸다 — 이 저장소가 썸네일과
|
|
7
|
+
* 화면 다시 만들기를 잇는 방식과 같다.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Starter = (pageId: string, event: React.PointerEvent) => void;
|
|
11
|
+
|
|
12
|
+
let starter: Starter | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 지금 끼어들 자리. 판들이 **밀려나며** 만드는 빈칸이라, 이 값은 판을 그리는 쪽이
|
|
16
|
+
* 읽어야 한다 — 위에 겹쳐 그리면 밑에 있는 판을 가릴 뿐이고 «사이가 벌어졌다»가
|
|
17
|
+
* 안 된다.
|
|
18
|
+
*/
|
|
19
|
+
export type FrameInsert = {
|
|
20
|
+
frameKey: string;
|
|
21
|
+
/** 그 벌 안에서 몇 번째 자리인가. */
|
|
22
|
+
at: number;
|
|
23
|
+
/** 빈칸 높이(화면 px) — 끌고 있는 판의 높이다. */
|
|
24
|
+
height: number;
|
|
25
|
+
/** 넘쳐서 못 놓는 자리인가. */
|
|
26
|
+
full: boolean;
|
|
27
|
+
} | null;
|
|
28
|
+
|
|
29
|
+
let insert: FrameInsert = null;
|
|
30
|
+
const listeners = new Set<() => void>();
|
|
31
|
+
|
|
32
|
+
export function setFrameInsert(next: FrameInsert): void {
|
|
33
|
+
const same =
|
|
34
|
+
insert === next ||
|
|
35
|
+
(insert &&
|
|
36
|
+
next &&
|
|
37
|
+
insert.frameKey === next.frameKey &&
|
|
38
|
+
insert.at === next.at &&
|
|
39
|
+
insert.full === next.full &&
|
|
40
|
+
insert.height === next.height);
|
|
41
|
+
if (same) return;
|
|
42
|
+
insert = next;
|
|
43
|
+
for (const listener of [...listeners]) listener();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getFrameInsert(): FrameInsert {
|
|
47
|
+
return insert;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribeFrameInsert(listener: () => void): () => void {
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => {
|
|
53
|
+
listeners.delete(listener);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 끌기 층이 자기를 걸어 둔다. */
|
|
58
|
+
export function setFrameDragStarter(fn: Starter | null): void {
|
|
59
|
+
starter = fn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 손잡이가 부른다. 층이 없으면 아무 일도 안 일어난다. */
|
|
63
|
+
export function startFrameDrag(pageId: string, event: React.PointerEvent): void {
|
|
64
|
+
starter?.(pageId, event);
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 판을 다른 벌로 끌 때 잡는 자리.
|
|
5
|
+
*
|
|
6
|
+
* **판 상자 안에** 산다. 예전에는 마우스 자리를 재어 판 위에 띄웠는데, 손잡이가 판
|
|
7
|
+
* 모서리에 있다 보니 바깥에서 다가가는 손은 «판 밖»을 지나 온다 — 그때마다 사라져서
|
|
8
|
+
* 잡으러 가는 내내 깜빡였다. 크기를 키워도 그대로였다. 안에 있으면 잴 것이 없고
|
|
9
|
+
* 깜빡일 일도 없다.
|
|
10
|
+
*
|
|
11
|
+
* **흐리게 두지 않는다.** 옅게 깔아 두면 «있는 줄 몰라서 못 잡는» 물건이 된다.
|
|
12
|
+
* 20px 짜리라 늘 또렷해도 시끄럽지 않다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { GripVertical } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
import { startFrameDrag } from "./frame-drag-bus";
|
|
18
|
+
|
|
19
|
+
export function FrameDragGrip({ pageId }: { pageId: string }) {
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
data-dp-frame-grip=""
|
|
24
|
+
title="다른 벌로 끌어오기"
|
|
25
|
+
aria-label="이 판을 다른 벌로 끌어오기"
|
|
26
|
+
onPointerDown={(event) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
event.stopPropagation();
|
|
29
|
+
startFrameDrag(pageId, event);
|
|
30
|
+
}}
|
|
31
|
+
style={{ position: "absolute", left: 5, top: 5, width: 20, height: 20, zIndex: 6 }}
|
|
32
|
+
className="flex cursor-grab items-center justify-center rounded bg-dpe-surface/80 text-dpe-ink-800 transition-colors hover:bg-dpe-ink-900 hover:text-dpe-on-accent"
|
|
33
|
+
>
|
|
34
|
+
<GripVertical aria-hidden="true" size={13} />
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|