@leviosa-ai/detail-page-editor 0.1.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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,324 @@
1
+ "use client";
2
+
3
+ import type { ReactElement, ReactNode } from "react";
4
+ import { SectionTab } from "@leviosa-ai/canvas";
5
+ import {
6
+ BookMarked,
7
+ Component,
8
+ Files,
9
+ Film,
10
+ Image,
11
+ Images,
12
+ Layers,
13
+ LayoutTemplate,
14
+ Palette,
15
+ Shapes,
16
+ Sparkles,
17
+ Type,
18
+ type LucideIcon,
19
+ } from "lucide-react";
20
+
21
+ import {
22
+ AiGeneratePanel,
23
+ type GenerateGifFn,
24
+ type GenerateImageFn,
25
+ } from "./ai-generate-panel";
26
+ import type { ImageTier } from "../../lib/detail-page/image-credit";
27
+ import { DetailPagePagesPanel } from "./detail-page-pages-panel";
28
+ import { DetailPageLayersPanel } from "./detail-page-layers-panel";
29
+ import { DetailPageMyImagesPanel } from "./detail-page-my-images-panel";
30
+ import { DetailPageElementsPanel } from "./detail-page-elements-panel";
31
+ import { DetailPageMyShapesPanel } from "./detail-page-my-shapes-panel";
32
+ import { DetailPageBrandGifsPanel } from "./detail-page-brand-gifs-panel";
33
+ import { DetailPageReferencesPanel } from "./detail-page-references-panel";
34
+ import { DetailPageBrandKitPanel } from "./detail-page-brand-kit-panel";
35
+ import { DetailPageTextPanel } from "./detail-page-text-panel";
36
+ import { DetailPagePhotosPanel } from "./detail-page-photos-panel";
37
+
38
+ /**
39
+ * 좌측 패널의 섹션 목록.
40
+ *
41
+ * 처음에는 스톡 `<SidePanel>`에 우리 섹션을 끼워 넣는 hookable 방식이었다(그쪽 DOM에도
42
+ * `canvas-side-panel` 클래스가 그대로 남아 있는 것을 보고 따라 했다). 지금은 껍데기까지
43
+ * 우리 것이라(`@leviosa-ai/canvas`의 `SidePanel`) 스톡 섹션이 하나도 없다 — 남아 있던
44
+ * 셋(텍스트·사진·기본 도형)은 이번에 우리 패널로 갈아 끼웠다. 라벨을 SDK i18n에
45
+ * deep-merge하던 코드도 같이 사라졌다(이제 그냥 우리 `t`를 쓴다).
46
+ *
47
+ * Order: 만드는 도구(구조 · 페이지 · 텍스트 · 사진 · 요소 · AI 생성) → 구분선 →
48
+ * 브랜드 자산(브랜드 킷 · 브랜드 이미지 · 브랜드 GIF · 내 레퍼런스 · 내 도형) · 레이어.
49
+ *
50
+ * 도형·차트·표는 각자 탭이었다가 "요소" 하나로 접혔다. 레일이 14탭이라 이미 포화였고,
51
+ * 아이콘·프레임·배경이 로드맵대로 붙으면 레일이 스크롤된다 —
52
+ * ``docs/detail-page-editor-panel-roadmap.md`` §4. 접는 기준은 "삽입 대상의 성격"이다.
53
+ */
54
+
55
+ type CanvasSection = {
56
+ name: string;
57
+ Tab: (props: Record<string, unknown>) => ReactElement | null;
58
+ Panel: (props: { store: unknown }) => ReactElement | null;
59
+ visibleInList?: boolean;
60
+ };
61
+
62
+ /** 탭 라벨 번역기. 편집기가 자기 ``t``를 넘긴다. */
63
+ export type SidebarTranslate = (key: string) => string;
64
+
65
+ /** 번역기를 안 받았을 때(테스트·구 호출부) 쓰는 한국어 기본값. */
66
+ const FALLBACK_LABELS: Record<string, string> = {
67
+ "detailPage.sidebar.pages": "페이지",
68
+ "detailPage.sidebar.text": "텍스트",
69
+ "detailPage.sidebar.photos": "사진",
70
+ "detailPage.sidebar.elements": "요소",
71
+ "detailPage.sidebar.shapes": "도형",
72
+ "detailPage.sidebar.charts": "차트",
73
+ "detailPage.sidebar.tables": "표",
74
+ "detailPage.sidebar.ai": "AI 생성",
75
+ "detailPage.sidebar.brandKit": "브랜드 킷",
76
+ "detailPage.sidebar.brandImages": "브랜드 이미지",
77
+ "detailPage.sidebar.brandGifs": "브랜드 GIF",
78
+ "detailPage.sidebar.brandShapes": "브랜드 도형",
79
+ "detailPage.sidebar.brandReferences": "내 레퍼런스",
80
+ "detailPage.sidebar.layers": "레이어",
81
+ };
82
+
83
+ function fallbackTranslate(key: string): string {
84
+ return FALLBACK_LABELS[key] ?? key;
85
+ }
86
+
87
+ // 껍데기가 ``active``/``onClick``을 렌더할 때 넣어 준다.
88
+ const Tab = SectionTab as unknown as (
89
+ props: Record<string, unknown> & { name: string; children?: ReactNode },
90
+ ) => ReactElement;
91
+
92
+ // ``dividerBefore``는 탭 위에 가로선을 하나 얹는다. 껍데기는 탭을 감싸는 래퍼 없이
93
+ // 그대로 나열하므로, 구분선은 프래그먼트로 형제 하나를 더 내보내는 방식으로만 끼워
94
+ // 넣을 수 있다.
95
+ function makeIconTab(
96
+ label: string,
97
+ Icon: LucideIcon,
98
+ { dividerBefore = false }: { dividerBefore?: boolean } = {},
99
+ ) {
100
+ function IconTab(props: Record<string, unknown>): ReactElement {
101
+ return (
102
+ <>
103
+ {dividerBefore ? (
104
+ <span
105
+ aria-hidden="true"
106
+ className="mx-3 my-1.5 block border-t border-neutral-200"
107
+ />
108
+ ) : null}
109
+ <Tab name={label} {...props}>
110
+ <Icon size={18} />
111
+ </Tab>
112
+ </>
113
+ );
114
+ }
115
+ IconTab.displayName = `IconTab(${label})`;
116
+ return IconTab;
117
+ }
118
+
119
+ export type BuildSectionsOptions = {
120
+ /**
121
+ * 탭 라벨 번역기(보통 편집기의 ``useTranslation("branding").t``).
122
+ *
123
+ * 안 주면 한국어 기본값으로 떨어진다 — 예전에는 라벨이 아예 한국어로 박혀 있어서
124
+ * 영어로 쓰는 유저에게도 한국어 레일이 나왔다.
125
+ */
126
+ t?: SidebarTranslate;
127
+ /** 프롬프트 → 이미지 URL 생성 콜백(실서비스에서 주입). 없으면 패널은 안내만 표시. */
128
+ onGenerateImage?: GenerateImageFn;
129
+ /** 프롬프트 → 애니메이션 GIF URL 생성 콜백. 없으면 GIF 모드는 안내만 표시. */
130
+ onGenerateGif?: GenerateGifFn;
131
+ /** AI GIF 1회 생성 비용(크레딧). 0/미지정이면 크레딧 UI 숨김. */
132
+ gifCreditCost?: number;
133
+ /** 참조 이미지 업로드. 보통 에디터의 uploadFile을 그대로 전달. */
134
+ uploadFile?: (file: File) => Promise<string>;
135
+ /** AI 이미지 1회 생성 비용(크레딧). 0/미지정이면 크레딧 UI 숨김. */
136
+ imageCreditCost?: number;
137
+ /** 현재 보유 크레딧 잔액(1.5× 안전 마진 게이트용). */
138
+ imageCreditBalance?: number;
139
+ /** 티어별(basic/pro/max) 크레딧 단가. 모델 드롭다운의 각 항목 비용에 쓴다. */
140
+ imageCostByTier?: Partial<Record<ImageTier, number>>;
141
+ /** 크레딧 부족 시 "크레딧 추가하기" 목적지(레비오사 pricing). */
142
+ onBuyImageCredits?: () => void;
143
+ /** AI 생성 결과를 현재 브랜드 중앙 에셋에 미러링. */
144
+ /**
145
+ * "구조" 패널 — 지금 화면 구성을 다른 조합으로 갈아 끼운다.
146
+ *
147
+ * 데이터(아키타입·현재 벡터·플랜 잠금)는 편집기가 아니라 편집기를 띄운 화면이
148
+ * 안다. 그래서 만들어진 노드를 그대로 받는다. 안 주면 탭 자체가 안 뜬다 —
149
+ * 레거시 템플릿처럼 조합이 없는 문서에서는 열어도 할 게 없다.
150
+ */
151
+ structurePanel?: ReactNode;
152
+ /**
153
+ * 지금 편집 중인 상세페이지 id.
154
+ *
155
+ * "내 레퍼런스" 패널이 이 문서를 브랜드 버킷에 저장하는 데 쓴다. 없으면 저장 버튼 없이
156
+ * 목록만 뜬다 — dev 하니스처럼 서버 인스턴스가 없는 문서에서는 저장할 것이 없다.
157
+ */
158
+ generatedId?: string;
159
+ };
160
+
161
+ export function buildDetailPageSections({
162
+ t: translate,
163
+ onGenerateImage,
164
+ onGenerateGif,
165
+ gifCreditCost,
166
+ uploadFile,
167
+ imageCreditCost,
168
+ imageCreditBalance,
169
+ imageCostByTier,
170
+ onBuyImageCredits,
171
+ structurePanel,
172
+ generatedId,
173
+ }: BuildSectionsOptions): CanvasSection[] {
174
+ const t = translate ?? fallbackTranslate;
175
+
176
+ // 탭은 라벨을 품고 있으므로 언어가 바뀌면 새로 만들어야 한다. 이 함수는 편집기에서
177
+ // useMemo 안에서만 불리므로 렌더마다 새 컴포넌트가 생기지는 않는다.
178
+ const PagesTab = makeIconTab(t("detailPage.sidebar.pages"), Files);
179
+ const TextTab = makeIconTab(t("detailPage.sidebar.text"), Type);
180
+ const PhotosTab = makeIconTab(t("detailPage.sidebar.photos"), Image);
181
+ const AiTab = makeIconTab(t("detailPage.sidebar.ai"), Sparkles);
182
+ // 브랜드 자산 구역의 첫 탭 — 여기서부터 "내가 가진 것"이라 위에 선을 하나 긋는다.
183
+ const BrandKitTab = makeIconTab(t("detailPage.sidebar.brandKit"), Palette, {
184
+ dividerBefore: true,
185
+ });
186
+ const MyImagesTab = makeIconTab(t("detailPage.sidebar.brandImages"), Images);
187
+ const BrandGifsTab = makeIconTab(t("detailPage.sidebar.brandGifs"), Film);
188
+ // 도형·차트·표를 품는 한 탭. 아이콘은 "내 도형"(Shapes)과 겹치지 않게 갈랐다.
189
+ const ElementsTab = makeIconTab(t("detailPage.sidebar.elements"), Component);
190
+ const MyShapesTab = makeIconTab(t("detailPage.sidebar.brandShapes"), Shapes);
191
+ const ReferencesTab = makeIconTab(
192
+ t("detailPage.sidebar.brandReferences"),
193
+ BookMarked,
194
+ );
195
+ const LayersTab = makeIconTab(t("detailPage.sidebar.layers"), Layers);
196
+ const StructureTab = makeIconTab(
197
+ t("detailPage.sidebar.structure"),
198
+ LayoutTemplate,
199
+ );
200
+
201
+ const textSection: CanvasSection = {
202
+ name: "text",
203
+ Tab: TextTab,
204
+ Panel: ({ store }) => <DetailPageTextPanel store={store} />,
205
+ };
206
+
207
+ // 사진: 올리기 + 무료 스톡 사진 검색(Pexels). 검색은 우리 서버가 중계하고, 고른
208
+ // 사진은 우리 S3로 옮겨 담은 뒤 얹는다.
209
+ const photosSection: CanvasSection = {
210
+ name: "photos",
211
+ Tab: PhotosTab,
212
+ Panel: ({ store }) => (
213
+ <DetailPagePhotosPanel store={store} uploadFile={uploadFile} />
214
+ ),
215
+ };
216
+
217
+ const aiSection: CanvasSection = {
218
+ name: "ai-generate",
219
+ Tab: AiTab,
220
+ Panel: ({ store }) => (
221
+ <AiGeneratePanel
222
+ store={store}
223
+ onGenerate={onGenerateImage}
224
+ onGenerateGif={onGenerateGif}
225
+ gifCreditCost={gifCreditCost}
226
+ uploadFile={uploadFile}
227
+ costByTier={imageCostByTier}
228
+ creditCost={imageCreditCost}
229
+ creditBalance={imageCreditBalance}
230
+ onBuyCredits={onBuyImageCredits}
231
+ />
232
+ ),
233
+ };
234
+
235
+ // 브랜드 이미지: 브랜드 자산 중 사진(GIF 제외) 갤러리(클릭 삽입).
236
+ const myImagesSection: CanvasSection = {
237
+ name: "my-images",
238
+ Tab: MyImagesTab,
239
+ Panel: ({ store }) => <DetailPageMyImagesPanel store={store} />,
240
+ };
241
+
242
+ // 브랜드 GIF: 같은 자산 중 GIF만, 만들어진 경로(텍스트/이펙트/프롬프트/도형)별로
243
+ // 나눠 보여준다. 이미지와 섞여 있으면 썸네일만으로 구분이 안 돼 고르기 어렵다.
244
+ const brandGifsSection: CanvasSection = {
245
+ name: "brand-gifs",
246
+ Tab: BrandGifsTab,
247
+ Panel: ({ store }) => <DetailPageBrandGifsPanel store={store} />,
248
+ };
249
+
250
+ // 내 레퍼런스: 저작한 상세페이지를 브랜드 버킷에 넣고 다음 상품에서 다시 꺼내 쓴다.
251
+ // 브랜드 이미지와 가르는 이유는 고르는 자리가 다르기 때문이다 — 여기 있는 것은
252
+ // 페이지에 넣을 사진이 아니라 "이런 식으로" 참고할 남(이었던 내) 화면이다.
253
+ const referencesSection: CanvasSection = {
254
+ name: "brand-references",
255
+ Tab: ReferencesTab,
256
+ Panel: () => <DetailPageReferencesPanel generatedId={generatedId} />,
257
+ };
258
+
259
+ const brandKitSection: CanvasSection = {
260
+ name: "brand-kit",
261
+ Tab: BrandKitTab,
262
+ Panel: ({ store }) => <DetailPageBrandKitPanel store={store} />,
263
+ };
264
+
265
+ // 요소: 클릭 한 번으로 캔버스에 놓이는 것들을 한 서랍에 모은다.
266
+ // - 도형: 공용 라이브러리(우리 템플릿에서 추린 범용 SVG) + 스톡 기본 도형/라인.
267
+ // - 차트: 데이터가 붙은 프리셋. 놓으면 그룹 하나로 들어가고 값·종류는 우측에서.
268
+ // - 표: 브랜드 상세페이지에서 실제로 쓰는 프리셋. 행·열·칸은 우측에서.
269
+ // 아이콘·프레임이 붙을 자리도 여기다.
270
+ const elementsSection: CanvasSection = {
271
+ name: "elements",
272
+ Tab: ElementsTab,
273
+ Panel: ({ store }) => <DetailPageElementsPanel store={store} />,
274
+ };
275
+
276
+ // 내 도형: 유저가 저장/업로드한 개인 도형 갤러리(공용과 분리, 클릭 삽입).
277
+ const myShapesSection: CanvasSection = {
278
+ name: "my-shapes",
279
+ Tab: MyShapesTab,
280
+ Panel: ({ store }) => <DetailPageMyShapesPanel store={store} />,
281
+ };
282
+
283
+ // Custom pages section: the stock PagesPanel renders blank thumbnails because
284
+ // this Canvas build's toDataURL is async. Our panel awaits it.
285
+ const pagesSection: CanvasSection = {
286
+ name: "pages",
287
+ Tab: PagesTab,
288
+ Panel: ({ store }) => <DetailPagePagesPanel store={store} />,
289
+ };
290
+
291
+ // 레이어: 스톡 LayersSection은 activePage.children를 평면 나열해 그룹 계층이
292
+ // 안 보인다. 그룹을 재귀로 들여쓰고 접을 수 있는 Figma식 트리로 대체한다.
293
+ const layersSection: CanvasSection = {
294
+ name: "layers",
295
+ Tab: LayersTab,
296
+ Panel: ({ store }) => <DetailPageLayersPanel store={store} />,
297
+ };
298
+
299
+ // 구조: 화면 구성을 통째로 다른 조합으로 갈아 끼운다. 카피는 어휘가 같아 옮겨
300
+ // 앉으므로(재바인딩) 글을 다시 쓰지 않는다. 조합이 없는 문서에서는 안 뜬다.
301
+ const structureSection: CanvasSection | null = structurePanel
302
+ ? {
303
+ name: "structure",
304
+ Tab: StructureTab,
305
+ Panel: () => <>{structurePanel}</>,
306
+ }
307
+ : null;
308
+
309
+ return [
310
+ ...(structureSection ? [structureSection] : []),
311
+ pagesSection,
312
+ textSection,
313
+ photosSection,
314
+ elementsSection,
315
+ aiSection,
316
+ // ↓ 여기부터 브랜드 자산(브랜드 킷 탭이 위에 구분선을 그린다).
317
+ brandKitSection,
318
+ myImagesSection,
319
+ brandGifsSection,
320
+ referencesSection,
321
+ myShapesSection,
322
+ layersSection,
323
+ ];
324
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import {
7
+ TABLE_PRESETS,
8
+ createTableSpec,
9
+ type TablePreset,
10
+ } from "../../lib/detail-page/table/defaults";
11
+ import { tablePreviewSvg } from "../../lib/detail-page/table/preview";
12
+ import { scaleTableStyle } from "../../lib/detail-page/table/render";
13
+ import {
14
+ documentFontFamily,
15
+ insertTable,
16
+ type StoreLike,
17
+ } from "../../lib/detail-page/table/sync";
18
+ import { encodeSvgDataUri } from "../../lib/detail-page-canvas/export/svg";
19
+
20
+ /**
21
+ * "표" 패널 — 프리셋을 클릭하면 캔버스에 표가 놓인다.
22
+ *
23
+ * 썸네일은 별도 자산이 아니라 **실제 렌더러 출력**을 SVG로 옮긴 것이다(``preview.ts``).
24
+ * 프리셋을 고치면 썸네일이 저절로 따라오고, 카탈로그와 결과가 어긋나지 않는다.
25
+ *
26
+ * 넣는 표는 페이지 폭의 80%, 폰트는 문서에서 가장 많이 쓰인 것을 물려받는다.
27
+ */
28
+
29
+ /** 썸네일 기준 폭. 실제 삽입 폭(≈650)과의 비율로 스타일을 줄인다. */
30
+ const PREVIEW_WIDTH = 240;
31
+ const REFERENCE_WIDTH = 650;
32
+
33
+ function previewUri(preset: TablePreset): string {
34
+ const full = createTableSpec({
35
+ kind: preset.kind,
36
+ width: PREVIEW_WIDTH,
37
+ style: preset.style,
38
+ options: preset.options,
39
+ data: preset.data,
40
+ });
41
+ return encodeSvgDataUri(
42
+ tablePreviewSvg(scaleTableStyle(full, PREVIEW_WIDTH / REFERENCE_WIDTH)),
43
+ );
44
+ }
45
+
46
+ export function DetailPageTablesPanel({ store }: { store: unknown }) {
47
+ const { t } = useTranslation("branding");
48
+ const s = store as StoreLike;
49
+
50
+ // 프리셋은 코드 상수라 세션 내내 안 바뀐다. 매 렌더마다 SVG를 다시 굽지 않는다.
51
+ const cells = useMemo(
52
+ () => TABLE_PRESETS.map((preset) => ({ preset, uri: previewUri(preset) })),
53
+ [],
54
+ );
55
+
56
+ const insert = useCallback(
57
+ (preset: TablePreset) => {
58
+ const page = s.activePage ?? s.pages?.[0];
59
+ const pageWidth =
60
+ typeof page?.computedWidth === "number" ? page.computedWidth : 860;
61
+ const font = documentFontFamily(s);
62
+ insertTable(
63
+ s,
64
+ createTableSpec({
65
+ kind: preset.kind,
66
+ width: Math.round(pageWidth * 0.8),
67
+ style: { ...preset.style, ...(font ? { fontFamily: font } : {}) },
68
+ options: preset.options,
69
+ data: preset.data,
70
+ }),
71
+ { name: t(preset.labelKey) },
72
+ );
73
+ },
74
+ [s, t],
75
+ );
76
+
77
+ return (
78
+ <div className="flex h-full flex-col overflow-y-auto p-3">
79
+ <p className="mb-2 text-xs font-medium text-neutral-500">
80
+ {t("detailPage.table.panelTitle")}
81
+ </p>
82
+ <div className="grid grid-cols-1 gap-2">
83
+ {cells.map(({ preset, uri }) => (
84
+ <button
85
+ key={preset.id}
86
+ type="button"
87
+ onClick={() => insert(preset)}
88
+ title={t("detailPage.table.insertHint")}
89
+ className="flex flex-col items-center gap-1.5 rounded-lg border border-neutral-200 p-2 hover:border-neutral-400"
90
+ >
91
+ {/* eslint-disable-next-line @next/next/no-img-element */}
92
+ <img
93
+ src={uri}
94
+ alt={t(preset.labelKey)}
95
+ className="h-16 w-full object-contain"
96
+ />
97
+ <span className="text-[11px] text-neutral-500">{t(preset.labelKey)}</span>
98
+ </button>
99
+ ))}
100
+ </div>
101
+ <p className="mt-3 text-[11px] leading-relaxed text-neutral-400">
102
+ {t("detailPage.table.panelHint")}
103
+ </p>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { documentFontFamily } from "../../lib/detail-page/spec-group/sync";
6
+ import type { StoreLike } from "../../lib/detail-page/spec-group/sync";
7
+ import {
8
+ REFERENCE_WIDTH,
9
+ TEXT_PRESETS,
10
+ insertTextPreset,
11
+ type TextPreset,
12
+ } from "../../lib/detail-page/text-presets";
13
+
14
+ /**
15
+ * "텍스트" 패널 — 스톡 `TextSection`을 대신한다.
16
+ *
17
+ * 위: 크기별 글상자 하나. 아래: **여러 줄이 한 덩어리로 들어가는 프리셋**.
18
+ * 스톡 패널도 아래쪽에 같은 성격의 격자를 달고 있었지만 벤더 서버에서 받아 오는
19
+ * 영문 포스터 디자인이라 우리 상세페이지에 쓸 자리가 없었다 — 짜임을 우리 것으로
20
+ * 다시 적었다(`lib/detail-page/text-presets.ts`).
21
+ *
22
+ * 넣는 글자는 **문서에서 가장 많이 쓰인 서체**로 들어간다(표·차트와 같은 규칙).
23
+ * 서체·색은 넣은 뒤 우측 인스펙터에서 고른다 — 여기서 미리 고르게 하면 같은 일을
24
+ * 두 군데서 하게 된다.
25
+ */
26
+
27
+ type PageLike = {
28
+ computedWidth: number;
29
+ computedHeight: number;
30
+ addElement: (opts: Record<string, unknown>) => unknown;
31
+ };
32
+
33
+ type Preset = {
34
+ key: string;
35
+ fontSize: number;
36
+ fontWeight: number;
37
+ /** 미리보기에 쓰는 화면 크기(실제 삽입 크기와 별개). */
38
+ previewSize: number;
39
+ };
40
+
41
+ const PRESETS: Preset[] = [
42
+ { key: "heading", fontSize: 48, fontWeight: 700, previewSize: 22 },
43
+ { key: "subheading", fontSize: 28, fontWeight: 600, previewSize: 17 },
44
+ { key: "body", fontSize: 18, fontWeight: 400, previewSize: 14 },
45
+ { key: "caption", fontSize: 13, fontWeight: 400, previewSize: 12 },
46
+ ];
47
+
48
+ /** 미리보기 칸의 폭(px). 프리셋 좌표를 이 폭에 맞춰 줄인다. */
49
+ const PREVIEW_WIDTH = 232;
50
+
51
+ export function insertText(
52
+ store: unknown,
53
+ text: string,
54
+ { fontSize, fontWeight }: { fontSize: number; fontWeight: number },
55
+ ): void {
56
+ const s = store as { activePage?: PageLike; pages: PageLike[] };
57
+ const page = s.activePage ?? s.pages[0];
58
+ if (!page) return;
59
+ const width = Math.round(page.computedWidth * 0.7);
60
+ // 한 줄 높이는 폰트 크기의 1.3배 — 넣자마자 상자가 글자를 자르지 않게.
61
+ const height = Math.round(fontSize * 1.3);
62
+ page.addElement({
63
+ type: "text",
64
+ text,
65
+ fontFamily: "Pretendard",
66
+ fontSize,
67
+ fontWeight,
68
+ fill: "#111111",
69
+ align: "center",
70
+ width,
71
+ height,
72
+ x: Math.round((page.computedWidth - width) / 2),
73
+ y: Math.round((page.computedHeight - height) / 2),
74
+ });
75
+ }
76
+
77
+ /**
78
+ * 프리셋 미리보기.
79
+ *
80
+ * 별도 자산이 아니라 **카탈로그를 그대로 축소해** 그린다 — 프리셋을 고치면 미리보기가
81
+ * 저절로 따라오고, 목록과 실제 결과가 어긋나지 않는다.
82
+ */
83
+ function PresetPreview({
84
+ preset,
85
+ label,
86
+ }: {
87
+ preset: TextPreset;
88
+ label: (nodeKey: string) => string;
89
+ }) {
90
+ const scale = PREVIEW_WIDTH / REFERENCE_WIDTH;
91
+ return (
92
+ <div
93
+ aria-hidden="true"
94
+ className="relative w-full"
95
+ style={{ height: preset.height * scale }}
96
+ >
97
+ {preset.nodes.map((node) => (
98
+ <div
99
+ key={node.key}
100
+ className="absolute truncate"
101
+ style={{
102
+ top: node.y * scale,
103
+ left: (node.x ?? 0) * scale,
104
+ width: (node.width ?? REFERENCE_WIDTH) * scale,
105
+ fontSize: Math.max(7, node.fontSize * scale),
106
+ fontWeight: node.fontWeight,
107
+ color: node.fill ?? "#111111",
108
+ textAlign: node.align,
109
+ letterSpacing: node.letterSpacing
110
+ ? node.letterSpacing * scale
111
+ : undefined,
112
+ }}
113
+ >
114
+ {label(node.key)}
115
+ </div>
116
+ ))}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ export function DetailPageTextPanel({ store }: { store: unknown }) {
122
+ const { t } = useTranslation("branding");
123
+
124
+ return (
125
+ <div className="flex h-full flex-col gap-2 overflow-y-auto p-3">
126
+ {PRESETS.map((preset) => {
127
+ const label = t(`detailPage.text.${preset.key}`);
128
+ return (
129
+ <button
130
+ key={preset.key}
131
+ type="button"
132
+ onClick={() =>
133
+ insertText(store, label, {
134
+ fontSize: preset.fontSize,
135
+ fontWeight: preset.fontWeight,
136
+ })
137
+ }
138
+ className="rounded-lg border border-neutral-200 px-3 py-3 text-left text-neutral-900 hover:border-neutral-400"
139
+ style={{
140
+ fontSize: preset.previewSize,
141
+ fontWeight: preset.fontWeight,
142
+ }}
143
+ >
144
+ {label}
145
+ </button>
146
+ );
147
+ })}
148
+
149
+ <p className="mt-2 px-1 text-xs font-medium text-neutral-500">
150
+ {t("detailPage.textPresets.title")}
151
+ </p>
152
+ {TEXT_PRESETS.map((preset) => {
153
+ const label = (nodeKey: string) =>
154
+ t(`detailPage.textPresets.${preset.key}.${nodeKey}`);
155
+ return (
156
+ <button
157
+ key={preset.key}
158
+ type="button"
159
+ title={t(`detailPage.textPresets.${preset.key}.name`)}
160
+ onClick={() =>
161
+ insertTextPreset(store as StoreLike, preset, {
162
+ text: label,
163
+ fontFamily: documentFontFamily(store as StoreLike),
164
+ name: t(`detailPage.textPresets.${preset.key}.name`),
165
+ })
166
+ }
167
+ className="rounded-lg border border-neutral-200 px-3 py-3 hover:border-neutral-400"
168
+ >
169
+ <PresetPreview preset={preset} label={label} />
170
+ </button>
171
+ );
172
+ })}
173
+
174
+ <p className="mt-1 px-1 text-xs leading-relaxed text-neutral-500">
175
+ {t("detailPage.text.hint")}
176
+ </p>
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Tiny cross-component cache for page thumbnails (data URLs), keyed by page id.
5
+ *
6
+ * The stacked workspace owns the live Konva canvases (it renders the real
7
+ * ``<Page>`` components), so it captures thumbnails from the DOM canvas there and
8
+ * publishes them here; the 페이지 side-panel — a sibling React subtree — subscribes
9
+ * and reads them. We can't use ``store.toDataURL`` because this build resolves
10
+ * ``react-konva`` against a different Konva instance than the stock editor's exporter, so
11
+ * the exporter never finds the rendered stage (the editor itself disables
12
+ * download for the same reason — real exports are server-side).
13
+ */
14
+
15
+ const cache = new Map<string, string>();
16
+ const listeners = new Set<() => void>();
17
+ let version = 0;
18
+
19
+ export const detailPageThumbnailBus = {
20
+ get(id: string): string | undefined {
21
+ return cache.get(id);
22
+ },
23
+ set(id: string, dataUrl: string): void {
24
+ if (cache.get(id) === dataUrl) return;
25
+ cache.set(id, dataUrl);
26
+ version += 1;
27
+ listeners.forEach((l) => l());
28
+ },
29
+ has(id: string): boolean {
30
+ return cache.has(id);
31
+ },
32
+ // Monotonic version for useSyncExternalStore — changes whenever a thumbnail lands.
33
+ getVersion(): number {
34
+ return version;
35
+ },
36
+ subscribe(listener: () => void): () => void {
37
+ listeners.add(listener);
38
+ return () => {
39
+ listeners.delete(listener);
40
+ };
41
+ },
42
+ };