@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,344 @@
1
+ /**
2
+ * 표 기본값과 프리셋.
3
+ *
4
+ * **프리셋은 브랜드 상세페이지 실물에서 뽑았다.** 범용 표 라이브러리 기본값(굵은 격자·
5
+ * 회색 머리글)은 상세페이지에 얹는 순간 "AI티"가 난다. 근거가 되는 마크업은
6
+ * `docs/detail-page-table-element-plan.md` §0.2에 파일·줄 번호로 적어 뒀다.
7
+ *
8
+ * 구조(첫 칸 190px 고정, 행 구분선, 위 굵은 선)는 브랜드 값을 그대로 쓰고, **색은 중립
9
+ * 먹빛**으로 둔다. 브랜드 색을 그대로 구우면 다른 문서에 넣었을 때 혼자 튄다 — 색은
10
+ * 인스펙터에서 문서 팔레트에 맞춘다.
11
+ */
12
+
13
+ import type { CellAlign, TableSpec, TableStyle } from "./types";
14
+
15
+ /** 문서에서 폰트를 못 고를 때의 마지막 기본값. */
16
+ export const FALLBACK_TABLE_FONT = "Pretendard";
17
+
18
+ const INK = "#1F2124";
19
+ const BODY = "#3C4043";
20
+ const LINE = "#E3E5E8";
21
+ const LINE_SOFT = "#EFF1F3";
22
+ const CELL = "#F5F6F8";
23
+
24
+ /**
25
+ * 브랜드 두 곳이 똑같이 쓰는 골격.
26
+ *
27
+ * 첫 칸 190px · 글자 18px · 좌우 여백 22px · 상하 여백 20px.
28
+ */
29
+ export const DEFAULT_TABLE_STYLE: TableStyle = {
30
+ fontFamily: FALLBACK_TABLE_FONT,
31
+ fontSize: 18,
32
+ color: BODY,
33
+ padX: 22,
34
+ padY: 20,
35
+
36
+ firstWidth: 190,
37
+ columnWidths: null,
38
+ firstFill: null,
39
+ firstAlign: "left",
40
+ firstColor: INK,
41
+ firstWeight: 700,
42
+ firstFontFamily: null,
43
+ firstSize: null,
44
+
45
+ columnRule: null,
46
+ rowRule: { color: LINE_SOFT, width: 1 },
47
+ topRule: { color: INK, width: 1.5 },
48
+ outerBorder: null,
49
+
50
+ bodyFill: null,
51
+ zebraFill: LINE_SOFT,
52
+ headerFill: null,
53
+ headerColor: INK,
54
+ headerWeight: 700,
55
+ };
56
+
57
+ export const DEFAULT_TABLE_DATA = {
58
+ columns: ["항목", "내용"],
59
+ rows: [
60
+ ["용량", "50ml"],
61
+ ["제형", "가볍게 발리는 젤 크림"],
62
+ ["사용 시점", "아침 · 저녁 세안 후"],
63
+ ],
64
+ };
65
+
66
+ export type CreateTableOptions = {
67
+ kind?: TableSpec["kind"];
68
+ width?: number;
69
+ data?: TableSpec["data"];
70
+ style?: Partial<TableStyle>;
71
+ options?: Partial<TableSpec["options"]>;
72
+ };
73
+
74
+ export function createTableSpec({
75
+ kind = "keyvalue",
76
+ width = 646,
77
+ data = DEFAULT_TABLE_DATA,
78
+ style = {},
79
+ options = {},
80
+ }: CreateTableOptions = {}): TableSpec {
81
+ return {
82
+ v: 1,
83
+ kind,
84
+ frame: { width, height: 0 },
85
+ data: {
86
+ columns: [...data.columns],
87
+ rows: data.rows.map((row) => [...row]),
88
+ },
89
+ options: {
90
+ headerRow: true,
91
+ align: [],
92
+ zebra: false,
93
+ ...options,
94
+ },
95
+ style: { ...DEFAULT_TABLE_STYLE, ...style },
96
+ };
97
+ }
98
+
99
+ export type TablePreset = {
100
+ id: string;
101
+ /** 로케일 키(`detailPage.table.preset.*`). */
102
+ labelKey: string;
103
+ kind: TableSpec["kind"];
104
+ style: Partial<TableStyle>;
105
+ options?: Partial<TableSpec["options"]>;
106
+ data?: TableSpec["data"];
107
+ };
108
+
109
+ const SPEC_DATA = DEFAULT_TABLE_DATA;
110
+
111
+ const RIGHT_ALIGNED: CellAlign[] = ["left", "right"];
112
+
113
+ const SIZE_DATA = {
114
+ columns: ["SIZE", "총장", "어깨", "가슴"],
115
+ rows: [
116
+ ["S", "120", "32", "96"],
117
+ ["M", "123", "33", "102"],
118
+ ["L", "126", "34", "108"],
119
+ ],
120
+ };
121
+
122
+ /**
123
+ * 좌측 패널에 뜨는 프리셋.
124
+ *
125
+ * 1·2번이 브랜드 두 판이다 — **구조는 같고 스타일 값 네 개만 다르다**
126
+ * (`firstFill` · `columnRule` · `outerBorder` · `bodyFill`). 렌더러에 브랜드 분기가
127
+ * 없다는 걸 이 둘이 증명한다.
128
+ */
129
+ const PRESETS: TablePreset[] = [
130
+ {
131
+ // 헤어라인 스펙표 — 위 굵은 선 + 행 구분선만. 채움도 테두리도 없다.
132
+ id: "spec-hairline",
133
+ labelKey: "detailPage.table.preset.specHairline",
134
+ kind: "keyvalue",
135
+ style: {},
136
+ data: SPEC_DATA,
137
+ },
138
+ {
139
+ // 칸 구분 스펙표 — 이름 칸에 바탕을 깔고 세로선으로 가른다. 긴 값(전성분)에서
140
+ // 어느 줄의 값인지 눈으로 따라가려면 이 판이 필요하다.
141
+ id: "spec-celled",
142
+ labelKey: "detailPage.table.preset.specCelled",
143
+ kind: "keyvalue",
144
+ style: {
145
+ firstFill: CELL,
146
+ columnRule: { color: LINE, width: 1 },
147
+ rowRule: { color: LINE, width: 1 },
148
+ outerBorder: { color: LINE, width: 1, radius: 4 },
149
+ bodyFill: "#FFFFFF",
150
+ },
151
+ data: SPEC_DATA,
152
+ },
153
+ {
154
+ // 값 우측정렬 목록 — 수치를 오른쪽 끝에 세워 자릿수를 맞춘다.
155
+ id: "spec-right",
156
+ labelKey: "detailPage.table.preset.specRight",
157
+ kind: "keyvalue",
158
+ style: { firstWidth: 200, rowRule: { color: LINE, width: 1 } },
159
+ options: { align: RIGHT_ALIGNED },
160
+ data: {
161
+ columns: ["항목", "값"],
162
+ rows: [
163
+ ["보습 지속", "12시간"],
164
+ ["임상 만족도", "94%"],
165
+ ["끈적임 없음", "97%"],
166
+ ],
167
+ },
168
+ },
169
+ {
170
+ // 가운데 정렬 타입표 — 이름 칸을 가운데로 세우고 강조색을 준다(피부 타입·라인업).
171
+ id: "type-centered",
172
+ labelKey: "detailPage.table.preset.typeCentered",
173
+ kind: "keyvalue",
174
+ style: {
175
+ firstWidth: 168,
176
+ firstAlign: "center",
177
+ firstFill: CELL,
178
+ firstWeight: 700,
179
+ columnRule: { color: LINE, width: 1 },
180
+ rowRule: { color: LINE, width: 1 },
181
+ outerBorder: { color: LINE, width: 1, radius: 4 },
182
+ bodyFill: "#FFFFFF",
183
+ topRule: null,
184
+ },
185
+ data: {
186
+ columns: ["타입", "이렇게 쓰세요"],
187
+ rows: [
188
+ ["건성", "밤에 한 번 더 덧발라 주세요"],
189
+ ["지성", "얇게 한 겹만 펴 발라 주세요"],
190
+ ["복합성", "볼에만 덧발라 주세요"],
191
+ ],
192
+ },
193
+ },
194
+ {
195
+ // 머리글 있는 N열 그리드 — 사이즈표·비교표.
196
+ id: "grid-header",
197
+ labelKey: "detailPage.table.preset.gridHeader",
198
+ kind: "grid",
199
+ style: {
200
+ firstWidth: null,
201
+ headerFill: CELL,
202
+ rowRule: { color: LINE, width: 1 },
203
+ outerBorder: { color: LINE, width: 1, radius: 4 },
204
+ bodyFill: "#FFFFFF",
205
+ topRule: null,
206
+ padX: 16,
207
+ },
208
+ options: { headerRow: true, align: ["center", "center", "center", "center"] },
209
+ data: SIZE_DATA,
210
+ },
211
+ {
212
+ // 얼룩 그리드 — 행이 많은 비교표에서 줄을 눈으로 따라가게 한다.
213
+ id: "grid-zebra",
214
+ labelKey: "detailPage.table.preset.gridZebra",
215
+ kind: "grid",
216
+ style: {
217
+ firstWidth: null,
218
+ headerFill: INK,
219
+ headerColor: "#FFFFFF",
220
+ rowRule: null,
221
+ outerBorder: { color: LINE, width: 1, radius: 4 },
222
+ bodyFill: "#FFFFFF",
223
+ topRule: null,
224
+ padX: 16,
225
+ },
226
+ options: { headerRow: true, zebra: true, align: ["left", "center", "center", "center"] },
227
+ data: SIZE_DATA,
228
+ },
229
+ {
230
+ // 비교표 — 첫 열은 항목, 우리 칸에 바탕을 깔아 눈이 먼저 간다.
231
+ id: "grid-compare",
232
+ labelKey: "detailPage.table.preset.gridCompare",
233
+ kind: "grid",
234
+ style: {
235
+ firstWidth: 200,
236
+ headerFill: CELL,
237
+ rowRule: { color: LINE, width: 1 },
238
+ columnRule: { color: LINE, width: 1 },
239
+ outerBorder: { color: LINE, width: 1, radius: 4 },
240
+ topRule: null,
241
+ padX: 16,
242
+ },
243
+ options: { headerRow: true, align: ["left", "center", "center"] },
244
+ data: {
245
+ columns: ["비교 항목", "본 제품", "일반 제품"],
246
+ rows: [
247
+ ["보습 지속", "12시간", "6시간"],
248
+ ["끈적임", "없음", "있음"],
249
+ ["향료", "무첨가", "첨가"],
250
+ ],
251
+ },
252
+ },
253
+ {
254
+ // 체크 비교표 — 값 대신 기호만 세운다. 스펙 숫자가 없을 때 쓰는 판.
255
+ id: "grid-check",
256
+ labelKey: "detailPage.table.preset.gridCheck",
257
+ kind: "grid",
258
+ style: {
259
+ firstWidth: 240,
260
+ headerFill: INK,
261
+ headerColor: "#FFFFFF",
262
+ rowRule: { color: LINE_SOFT, width: 1 },
263
+ outerBorder: { color: LINE, width: 1, radius: 4 },
264
+ topRule: null,
265
+ padX: 16,
266
+ },
267
+ options: { headerRow: true, align: ["left", "center", "center"] },
268
+ data: {
269
+ columns: ["기능", "본 제품", "일반 제품"],
270
+ rows: [
271
+ ["저자극 테스트", "O", "X"],
272
+ ["비건 인증", "O", "X"],
273
+ ["국내 생산", "O", "O"],
274
+ ],
275
+ },
276
+ },
277
+ {
278
+ // 선 없는 여백형 — 표처럼 안 보이는 표. 감성 톤 상세페이지의 스펙 자리다.
279
+ id: "spec-airy",
280
+ labelKey: "detailPage.table.preset.specAiry",
281
+ kind: "keyvalue",
282
+ style: {
283
+ firstWidth: 170,
284
+ firstColor: "#8A8F96",
285
+ firstWeight: 500,
286
+ rowRule: null,
287
+ topRule: null,
288
+ padY: 14,
289
+ },
290
+ data: SPEC_DATA,
291
+ },
292
+ {
293
+ // 상자형 스펙표 — 큰 라운드 테두리 안에 넣는다. 섹션 하나를 통째로 채울 때.
294
+ id: "spec-boxed",
295
+ labelKey: "detailPage.table.preset.specBoxed",
296
+ kind: "keyvalue",
297
+ style: {
298
+ firstWidth: 180,
299
+ firstFill: CELL,
300
+ rowRule: { color: LINE, width: 1 },
301
+ outerBorder: { color: LINE, width: 1, radius: 14 },
302
+ topRule: null,
303
+ padX: 24,
304
+ padY: 22,
305
+ },
306
+ data: SPEC_DATA,
307
+ },
308
+ {
309
+ // 가격표 — 수치 열을 오른쪽 끝에 세워 자릿수를 맞춘다.
310
+ id: "grid-price",
311
+ labelKey: "detailPage.table.preset.gridPrice",
312
+ kind: "grid",
313
+ style: {
314
+ firstWidth: null,
315
+ headerFill: CELL,
316
+ rowRule: { color: LINE_SOFT, width: 1 },
317
+ outerBorder: { color: LINE, width: 1, radius: 4 },
318
+ topRule: null,
319
+ padX: 16,
320
+ },
321
+ options: { headerRow: true, align: ["left", "center", "right"] },
322
+ data: {
323
+ columns: ["구성", "수량", "정상가"],
324
+ rows: [
325
+ ["단품", "1개", "32,000"],
326
+ ["2개 세트", "2개", "58,000"],
327
+ ["선물 세트", "3개", "84,000"],
328
+ ],
329
+ },
330
+ },
331
+ ];
332
+
333
+ /**
334
+ * 좌측 패널이 읽는 최종 목록. 바탕을 안 정한 프리셋에는 흰 바탕을 깐다.
335
+ *
336
+ * 표는 선과 글자뿐이라 바탕이 없으면 섹션 배경을 그대로 탄다 — 어두운 섹션에 놓는
337
+ * 순간 먹빛 글씨가 사라지고, 흰 패널 위에서 그려진 미리보기와 결과가 달라진다.
338
+ * 기본값(``DEFAULT_TABLE_STYLE``)이 아니라 **프리셋에만** 건다. 기본값을 바꾸면 이미
339
+ * 문서에 박힌 표들이 다시 그려질 때 같이 변한다.
340
+ */
341
+ export const TABLE_PRESETS: TablePreset[] = PRESETS.map((preset) => ({
342
+ ...preset,
343
+ style: { bodyFill: "#FFFFFF", ...preset.style },
344
+ }));
@@ -0,0 +1,312 @@
1
+ /**
2
+ * 표 데이터를 고치는 순수 함수들.
3
+ *
4
+ * 인스펙터(우측 패널)와 캔버스 오버레이가 **같은 함수**를 쓴다. 원래는 인스펙터 안에
5
+ * 있었는데, 캔버스에서도 행·열을 넣고 빼게 되면서 두 벌로 갈라질 뻔했다 — 한쪽만
6
+ * ``align``을 따라 옮기는 순간 열을 지웠을 때 뒤 열들의 정렬이 한 칸씩 밀린다.
7
+ *
8
+ * 두 층으로 나눠 둔다:
9
+ * - ``with*`` — ``TableData``만 다룬다(인스펙터의 입력 칸들이 쓴다).
10
+ * - ``insertRow``/``removeColumn`` … — 스펙 전체를 받아 ``align``·상한까지 맞춘다.
11
+ * 캔버스 핸들은 이쪽을 쓴다. 눌렀을 때 할 일이 한 번에 끝나야 하기 때문이다.
12
+ */
13
+
14
+ import {
15
+ MAX_COLUMNS,
16
+ MAX_ROWS,
17
+ MIN_COLUMN_WIDTH,
18
+ columnWidths,
19
+ resolveTable,
20
+ } from "./normalize";
21
+ import type { CellAlign, TableData, TableSpec } from "./types";
22
+
23
+ /** 지금 행들이 실제로 쓰는 칸 수. 새 행을 만들 때 폭을 맞추는 기준이다. */
24
+ function widthOf(data: TableData): number {
25
+ return Math.max(1, data.columns.length, data.rows[0]?.length ?? 0);
26
+ }
27
+
28
+ function emptyRow(width: number): string[] {
29
+ return Array.from({ length: width }, () => "");
30
+ }
31
+
32
+ /** 범위를 벗어난 삽입 위치를 양끝으로 접는다(핸들이 경계에서 눌릴 수 있다). */
33
+ function clampIndex(index: number, length: number): number {
34
+ if (!Number.isFinite(index)) return length;
35
+ return Math.min(length, Math.max(0, Math.trunc(index)));
36
+ }
37
+
38
+ // ── TableData 층 ─────────────────────────────────────────────────────────────
39
+
40
+ export function withCell(
41
+ data: TableData,
42
+ row: number,
43
+ column: number,
44
+ value: string,
45
+ ): TableData {
46
+ return {
47
+ ...data,
48
+ rows: data.rows.map((cells, r) =>
49
+ r === row ? cells.map((cell, c) => (c === column ? value : cell)) : cells,
50
+ ),
51
+ };
52
+ }
53
+
54
+ export function withColumnName(data: TableData, column: number, name: string): TableData {
55
+ const columns = [...data.columns];
56
+ while (columns.length <= column) columns.push("");
57
+ columns[column] = name;
58
+ return { ...data, columns };
59
+ }
60
+
61
+ export function withRowAdded(data: TableData): TableData {
62
+ return { ...data, rows: [...data.rows, emptyRow(widthOf(data))] };
63
+ }
64
+
65
+ /** ``index`` 자리에 빈 행을 끼운다. ``index === rows.length``면 맨 뒤. */
66
+ export function withRowInsertedAt(data: TableData, index: number): TableData {
67
+ const at = clampIndex(index, data.rows.length);
68
+ const rows = [...data.rows];
69
+ rows.splice(at, 0, emptyRow(widthOf(data)));
70
+ return { ...data, rows };
71
+ }
72
+
73
+ export function withRowRemoved(data: TableData, index: number): TableData {
74
+ return { ...data, rows: data.rows.filter((_, i) => i !== index) };
75
+ }
76
+
77
+ export function withColumnAdded(data: TableData): TableData {
78
+ return {
79
+ columns: [...data.columns, ""],
80
+ rows: data.rows.map((cells) => [...cells, ""]),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * ``index`` 자리에 빈 열을 끼운다.
86
+ *
87
+ * 행마다 길이가 다를 수 있다(정규화는 렌더용 사본에서만 한다). 짧은 행은 삽입 위치까지
88
+ * 채운 뒤 끼워야 값이 옆 열로 밀리지 않는다.
89
+ */
90
+ export function withColumnInsertedAt(data: TableData, index: number): TableData {
91
+ const at = clampIndex(index, widthOf(data));
92
+ const columns = [...data.columns];
93
+ while (columns.length < at) columns.push("");
94
+ columns.splice(at, 0, "");
95
+ return {
96
+ columns,
97
+ rows: data.rows.map((cells) => {
98
+ const next = [...cells];
99
+ while (next.length < at) next.push("");
100
+ next.splice(at, 0, "");
101
+ return next;
102
+ }),
103
+ };
104
+ }
105
+
106
+ export function withColumnRemoved(data: TableData, index: number): TableData {
107
+ return {
108
+ columns: data.columns.filter((_, i) => i !== index),
109
+ rows: data.rows.map((cells) => cells.filter((_, i) => i !== index)),
110
+ };
111
+ }
112
+
113
+ /** 열을 지우면 그 열의 정렬 설정도 같이 빠져야 뒤 열들이 밀리지 않는다. */
114
+ export function alignAfterColumnRemove(align: CellAlign[], removed: number): CellAlign[] {
115
+ return align.filter((_, i) => i !== removed);
116
+ }
117
+
118
+ /** 열을 끼우면 정렬도 같은 자리에서 벌어져야 한다(새 열은 기본 정렬). */
119
+ export function alignAfterColumnInsert(align: CellAlign[], inserted: number): CellAlign[] {
120
+ const at = clampIndex(inserted, align.length);
121
+ const next = [...align];
122
+ while (next.length < at) next.push("left");
123
+ next.splice(at, 0, "left");
124
+ return next;
125
+ }
126
+
127
+ // ── TableSpec 층(캔버스 핸들이 쓰는 한 방 짜리) ──────────────────────────────
128
+
129
+ /** 행을 더 넣을 수 있는가. 상한에 닿으면 핸들의 ``+``를 숨긴다. */
130
+ export function canInsertRow(spec: TableSpec): boolean {
131
+ return spec.data.rows.length < MAX_ROWS;
132
+ }
133
+
134
+ /**
135
+ * 열을 더 넣을 수 있는가.
136
+ *
137
+ * ``keyvalue``는 이름·값 두 칸으로 **그리는** 종류라 열을 늘려도 화면이 안 변한다.
138
+ * 캔버스에서는 안 되는 걸 눌러 보게 두지 않고 아예 핸들을 감춘다.
139
+ */
140
+ export function canInsertColumn(spec: TableSpec): boolean {
141
+ return spec.kind === "grid" && resolveTable(spec).columnCount < MAX_COLUMNS;
142
+ }
143
+
144
+ /** 마지막 하나는 못 지운다 — 행이 0이면 표가 아니라 빈 상자가 된다. */
145
+ export function canRemoveRow(spec: TableSpec): boolean {
146
+ return spec.data.rows.length > 1;
147
+ }
148
+
149
+ export function canRemoveColumn(spec: TableSpec): boolean {
150
+ return spec.kind === "grid" && resolveTable(spec).columnCount > 1;
151
+ }
152
+
153
+ export function insertRow(spec: TableSpec, index: number): TableSpec {
154
+ if (!canInsertRow(spec)) return spec;
155
+ return { ...spec, data: withRowInsertedAt(spec.data, index) };
156
+ }
157
+
158
+ export function removeRow(spec: TableSpec, index: number): TableSpec {
159
+ if (!canRemoveRow(spec)) return spec;
160
+ return { ...spec, data: withRowRemoved(spec.data, index) };
161
+ }
162
+
163
+ /**
164
+ * 열을 넣고 뺄 때 **잡아 둔 폭도 같이 움직여야** 한다.
165
+ *
166
+ * 안 옮기면 폭 배열의 길이가 열 수와 어긋나 통째로 무시되고(자동으로 되돌아간다),
167
+ * 길이가 우연히 맞으면 더 나쁘다 — 엉뚱한 열이 남의 폭을 입는다.
168
+ */
169
+ function widthsAfterInsert(widths: number[] | null, at: number): number[] | null {
170
+ if (!widths) return null;
171
+ const next = [...widths];
172
+ const index = clampIndex(at, next.length);
173
+ // 새 열은 이웃만큼 준다. 남는 폭은 정규화가 알아서 다시 나눈다.
174
+ next.splice(index, 0, next[index] ?? next[index - 1] ?? MIN_COLUMN_WIDTH);
175
+ return next;
176
+ }
177
+
178
+ function widthsAfterRemove(widths: number[] | null, at: number): number[] | null {
179
+ if (!widths) return null;
180
+ return widths.filter((_, i) => i !== at);
181
+ }
182
+
183
+ export function insertColumn(spec: TableSpec, index: number): TableSpec {
184
+ if (!canInsertColumn(spec)) return spec;
185
+ return {
186
+ ...spec,
187
+ data: withColumnInsertedAt(spec.data, index),
188
+ options: {
189
+ ...spec.options,
190
+ align: alignAfterColumnInsert(spec.options.align, index),
191
+ },
192
+ style: {
193
+ ...spec.style,
194
+ columnWidths: widthsAfterInsert(spec.style.columnWidths, index),
195
+ },
196
+ };
197
+ }
198
+
199
+ export function removeColumn(spec: TableSpec, index: number): TableSpec {
200
+ if (!canRemoveColumn(spec)) return spec;
201
+ return {
202
+ ...spec,
203
+ data: withColumnRemoved(spec.data, index),
204
+ options: {
205
+ ...spec.options,
206
+ align: alignAfterColumnRemove(spec.options.align, index),
207
+ },
208
+ style: {
209
+ ...spec.style,
210
+ columnWidths: widthsAfterRemove(spec.style.columnWidths, index),
211
+ },
212
+ };
213
+ }
214
+
215
+ // ── 열 폭 ────────────────────────────────────────────────────────────────────
216
+
217
+ /** 배열에서 한 칸을 빼 다른 자리에 꽂는다. */
218
+ function moveAt<T>(list: T[], from: number, to: number): T[] {
219
+ const next = [...list];
220
+ const [taken] = next.splice(from, 1);
221
+ next.splice(to, 0, taken);
222
+ return next;
223
+ }
224
+
225
+ /**
226
+ * 열 경계 하나를 ``deltaPx``만큼 민 스펙.
227
+ *
228
+ * ``boundary``는 **경계 번호**(1 = 첫 열과 둘째 열 사이). 인접한 두 열만 주고받으므로
229
+ * 표 전체 폭은 안 변한다 — 하나를 넓히면 옆이 좁아지는, 엑셀·Canva와 같은 동작이다.
230
+ *
231
+ * 처음 끄는 순간 지금 화면에 보이는 자동 배치값으로 ``columnWidths``를 채운다. 그래야
232
+ * 자동 → 수동으로 넘어갈 때 표가 안 튄다.
233
+ */
234
+ export function resizeColumn(
235
+ spec: TableSpec,
236
+ boundary: number,
237
+ deltaPx: number,
238
+ ): TableSpec {
239
+ const resolved = resolveTable(spec);
240
+ if (boundary < 1 || boundary > resolved.columnCount - 1) return spec;
241
+
242
+ const current = columnWidths(spec, resolved);
243
+ const left = current[boundary - 1];
244
+ const right = current[boundary];
245
+ if (left === undefined || right === undefined) return spec;
246
+
247
+ // 두 열의 합은 보존한다. 어느 쪽도 최소 폭 아래로는 못 간다.
248
+ const pair = left + right;
249
+ const nextLeft = Math.min(
250
+ pair - MIN_COLUMN_WIDTH,
251
+ Math.max(MIN_COLUMN_WIDTH, left + deltaPx),
252
+ );
253
+ if (Math.abs(nextLeft - left) < 0.5) return spec;
254
+
255
+ const widths = [...current];
256
+ widths[boundary - 1] = nextLeft;
257
+ widths[boundary] = pair - nextLeft;
258
+ return { ...spec, style: { ...spec.style, columnWidths: widths } };
259
+ }
260
+
261
+ /** 열 폭을 자동으로 되돌린다(첫 칸만 ``firstWidth``, 나머지 균등). */
262
+ export function autoColumnWidths(spec: TableSpec): TableSpec {
263
+ if (spec.style.columnWidths === null) return spec;
264
+ return { ...spec, style: { ...spec.style, columnWidths: null } };
265
+ }
266
+
267
+ // ── 순서 바꾸기 ──────────────────────────────────────────────────────────────
268
+
269
+ export function moveRow(spec: TableSpec, from: number, to: number): TableSpec {
270
+ const count = spec.data.rows.length;
271
+ if (from === to || from < 0 || from >= count || to < 0 || to >= count) return spec;
272
+ return { ...spec, data: { ...spec.data, rows: moveAt(spec.data.rows, from, to) } };
273
+ }
274
+
275
+ /**
276
+ * 열 순서를 바꾼다.
277
+ *
278
+ * 데이터만 옮기면 안 된다 — 정렬(``align``)과 잡아 둔 폭은 **열에 붙은 성질**이라
279
+ * 같이 따라가야 한다. 안 그러면 수치 열을 왼쪽으로 옮겼을 때 우측정렬이 원래 자리에
280
+ * 남는다.
281
+ */
282
+ export function moveColumn(spec: TableSpec, from: number, to: number): TableSpec {
283
+ const count = resolveTable(spec).columnCount;
284
+ if (from === to || from < 0 || from >= count || to < 0 || to >= count) return spec;
285
+
286
+ const align = [...spec.options.align];
287
+ while (align.length < count) align.push("left");
288
+
289
+ return {
290
+ ...spec,
291
+ data: {
292
+ columns: moveAt(spec.data.columns, from, to),
293
+ rows: spec.data.rows.map((cells) =>
294
+ // 짧은 행은 자리를 채운 뒤 옮겨야 값이 옆 열로 새지 않는다.
295
+ moveAt(
296
+ cells.length < count
297
+ ? [...cells, ...Array.from({ length: count - cells.length }, () => "")]
298
+ : cells,
299
+ from,
300
+ to,
301
+ ),
302
+ ),
303
+ },
304
+ options: { ...spec.options, align: moveAt(align, from, to) },
305
+ style: {
306
+ ...spec.style,
307
+ columnWidths: spec.style.columnWidths
308
+ ? moveAt(spec.style.columnWidths, from, to)
309
+ : null,
310
+ },
311
+ };
312
+ }