@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,586 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { Columns3, RotateCcw, Rows3, Trash2, Unlink } from "lucide-react";
7
+
8
+ import { ColorInput } from "../cardnews/color-input";
9
+ import { SpecPromptEditPanel } from "./spec-prompt-edit-panel";
10
+ import {
11
+ NumberField,
12
+ Section,
13
+ ToggleButton,
14
+ } from "./inspector-controls";
15
+ import {
16
+ alignAfterColumnRemove,
17
+ autoColumnWidths,
18
+ withCell,
19
+ withColumnAdded,
20
+ withColumnName,
21
+ withColumnRemoved,
22
+ withRowAdded,
23
+ withRowRemoved,
24
+ } from "../../lib/detail-page/table/edit";
25
+ import { MAX_COLUMNS, MAX_ROWS, resolveTable } from "../../lib/detail-page/table/normalize";
26
+ import { parseTableGrid } from "../../lib/detail-page/table/paste";
27
+ import {
28
+ detachTable,
29
+ syncTableGroup,
30
+ type ElementLike,
31
+ type StoreLike,
32
+ } from "../../lib/detail-page/table/sync";
33
+ import type {
34
+ CellAlign,
35
+ TableData,
36
+ TableRule,
37
+ TableSpec,
38
+ TableStyle,
39
+ } from "../../lib/detail-page/table/types";
40
+
41
+ /**
42
+ * 표 인스펙터.
43
+ *
44
+ * 모든 변경은 스펙을 고쳐 ``syncTableGroup``으로 다시 그리는 경로 하나만 탄다.
45
+ *
46
+ * 칸은 캔버스에서 드릴인해 직접 고쳐도 된다 — 그렇게 친 글자는 재생성 직전에 스펙으로
47
+ * 되받아진다(``table/harvest.ts``). 그래서 이 패널이 보여 주는 값도 저장된 스펙이 아니라
48
+ * **걷어 온 스펙**이다(속성 패널에서 ``harvestTableGroup``을 거쳐 내려온다). 둘이 어긋나면
49
+ * 사용자가 캔버스에서 고친 글자를 패널이 옛 값으로 되돌려 버린다.
50
+ */
51
+
52
+ /**
53
+ * 프롬프트 편집에 필요한 것들. 부모(속성 패널)가 들고 있는 값을 그대로 받는다.
54
+ *
55
+ * 타입을 여기서 따로 좁게 정의한다 — 속성 패널에서 끌어오면 순환 import가 된다
56
+ * (``inspector-controls``를 뽑아낸 것과 같은 이유).
57
+ */
58
+ export type SpecEditPrompting = {
59
+ /** 생성 인스턴스 ID. 없으면(픽스처 모드) 프롬프트 편집을 안 띄운다. */
60
+ generatedId?: string;
61
+ usage?: { textUsed?: number; textLimit?: number; unlimited?: boolean };
62
+ onUsage?: (used: number, limit: number) => void;
63
+ onBuyMore?: () => void;
64
+ };
65
+
66
+ const KINDS: TableSpec["kind"][] = ["keyvalue", "grid"];
67
+ const ALIGNS: CellAlign[] = ["left", "center", "right"];
68
+
69
+ // ── 셀 ──────────────────────────────────────────────────────────────────────
70
+
71
+ /** blur/Enter에 커밋하는 입력. 타이핑마다 다시 그리면 캔버스가 덜덜 떤다. */
72
+ function Cell({
73
+ value,
74
+ onCommit,
75
+ placeholder,
76
+ ariaLabel,
77
+ onPaste,
78
+ }: {
79
+ value: string;
80
+ onCommit: (next: string) => void;
81
+ placeholder?: string;
82
+ ariaLabel: string;
83
+ onPaste?: (event: React.ClipboardEvent) => void;
84
+ }) {
85
+ const [local, setLocal] = useState(value);
86
+ useEffect(() => setLocal(value), [value]);
87
+ const commit = () => {
88
+ if (local !== value) onCommit(local);
89
+ };
90
+ return (
91
+ <input
92
+ type="text"
93
+ aria-label={ariaLabel}
94
+ value={local}
95
+ placeholder={placeholder}
96
+ onChange={(e) => setLocal(e.target.value)}
97
+ onPaste={onPaste}
98
+ onBlur={commit}
99
+ onKeyDown={(e) => {
100
+ if (e.key === "Enter") {
101
+ commit();
102
+ (e.target as HTMLInputElement).blur();
103
+ }
104
+ }}
105
+ className="w-full min-w-0 rounded-md border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
106
+ />
107
+ );
108
+ }
109
+
110
+ // ── 선 컨트롤 ────────────────────────────────────────────────────────────────
111
+
112
+ function RuleControl({
113
+ label,
114
+ rule,
115
+ fallbackColor,
116
+ onChange,
117
+ }: {
118
+ label: string;
119
+ rule: TableRule;
120
+ fallbackColor: string;
121
+ onChange: (next: TableRule) => void;
122
+ }) {
123
+ return (
124
+ <div className="flex items-center gap-2">
125
+ <ToggleButton
126
+ active={rule !== null}
127
+ onClick={() =>
128
+ onChange(rule === null ? { color: fallbackColor, width: 1 } : null)
129
+ }
130
+ title={label}
131
+ >
132
+ {label}
133
+ </ToggleButton>
134
+ {rule ? (
135
+ <ColorInput
136
+ value={rule.color}
137
+ onChange={(color) => onChange({ ...rule, color })}
138
+ />
139
+ ) : null}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // ── 인스펙터 ────────────────────────────────────────────────────────────────
145
+
146
+ export const TableInspector = observer(function TableInspector({
147
+ store,
148
+ el,
149
+ spec,
150
+ prompting,
151
+ }: {
152
+ store: StoreLike;
153
+ el: ElementLike;
154
+ spec: TableSpec;
155
+ prompting?: SpecEditPrompting;
156
+ }) {
157
+ const { t } = useTranslation("branding");
158
+ const [notice, setNotice] = useState<string | null>(null);
159
+
160
+ const apply = (patch: Partial<TableSpec>) => {
161
+ syncTableGroup(store, el, { ...spec, ...patch });
162
+ };
163
+ const setData = (data: TableData) => apply({ data });
164
+ const setOptions = (patch: Partial<TableSpec["options"]>) =>
165
+ apply({ options: { ...spec.options, ...patch } });
166
+ const setStyle = (patch: Partial<TableStyle>) =>
167
+ apply({ style: { ...spec.style, ...patch } });
168
+
169
+ const resolved = resolveTable(spec);
170
+ const rowCount = spec.data.rows.length;
171
+ const columnCount = resolved.columnCount;
172
+ const isGrid = spec.kind === "grid";
173
+
174
+ /** 스프레드시트에서 붙여넣기 — 표 전체를 갈아 끼운다. */
175
+ const onPaste = (event: React.ClipboardEvent) => {
176
+ const text = event.clipboardData?.getData("text/plain") ?? "";
177
+ // 한 칸짜리 값은 그냥 입력이다. 여러 칸/여러 줄일 때만 표로 해석한다.
178
+ if (!text.includes("\t") && !text.includes("\n")) return;
179
+ const parsed = parseTableGrid(text);
180
+ if (!parsed) return;
181
+ event.preventDefault();
182
+ setNotice(t("detailPage.table.pasted", { count: parsed.rows.length }));
183
+ apply({ data: parsed });
184
+ };
185
+
186
+ return (
187
+ <>
188
+ {prompting?.generatedId ? (
189
+ <Section title={t("detailPage.specPromptEdit.section")}>
190
+ <SpecPromptEditPanel
191
+ generatedId={prompting.generatedId}
192
+ specKind="table"
193
+ elementId={String(el.id ?? "")}
194
+ currentSpec={spec}
195
+ // 서버가 준 스펙도 사용자가 고친 것과 같은 경로로 적용한다 — 그리는 곳이
196
+ // 하나여야 값과 그림이 안 어긋난다.
197
+ onApplied={(next) => syncTableGroup(store, el, next as TableSpec)}
198
+ editsUsed={prompting.usage?.textUsed}
199
+ editLimit={prompting.usage?.textLimit}
200
+ unlimited={prompting.usage?.unlimited}
201
+ onUsage={prompting.onUsage}
202
+ onBuyMore={prompting.onBuyMore}
203
+ />
204
+ </Section>
205
+ ) : null}
206
+
207
+ <Section title={t("detailPage.table.kind")}>
208
+ <div className="grid grid-cols-2 gap-1.5">
209
+ {KINDS.map((kind) => (
210
+ <button
211
+ key={kind}
212
+ type="button"
213
+ aria-pressed={kind === spec.kind}
214
+ onClick={() => apply({ kind })}
215
+ className={`h-8 rounded-md border text-xs transition-colors ${
216
+ kind === spec.kind
217
+ ? "border-neutral-300 bg-neutral-100 font-semibold text-neutral-900"
218
+ : "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
219
+ }`}
220
+ >
221
+ {t(`detailPage.table.kinds.${kind}`)}
222
+ </button>
223
+ ))}
224
+ </div>
225
+ {spec.kind === "keyvalue" && spec.data.columns.length > 2 ? (
226
+ <p className="mt-2 text-[11px] text-amber-600">
227
+ {t("detailPage.table.hiddenColumns", {
228
+ count: spec.data.columns.length - 2,
229
+ })}
230
+ </p>
231
+ ) : null}
232
+ </Section>
233
+
234
+ <Section title={t("detailPage.table.data")}>
235
+ <div>
236
+ {/* 머리글 행 — grid에서만 실제로 그려진다. */}
237
+ {isGrid ? (
238
+ <div className="flex items-center gap-1.5 pb-1.5">
239
+ {Array.from({ length: columnCount }, (_, column) => (
240
+ <div key={column} className="min-w-0 flex-1">
241
+ <Cell
242
+ ariaLabel={t("detailPage.table.columnNameAria", {
243
+ column: column + 1,
244
+ })}
245
+ value={spec.data.columns[column] ?? ""}
246
+ placeholder={t("detailPage.table.columnName")}
247
+ onPaste={onPaste}
248
+ onCommit={(next) => setData(withColumnName(spec.data, column, next))}
249
+ />
250
+ </div>
251
+ ))}
252
+ <button
253
+ type="button"
254
+ aria-label={t("detailPage.table.removeColumn")}
255
+ title={t("detailPage.table.removeColumn")}
256
+ disabled={columnCount <= 1}
257
+ onClick={() =>
258
+ apply({
259
+ data: withColumnRemoved(spec.data, columnCount - 1),
260
+ options: {
261
+ ...spec.options,
262
+ align: alignAfterColumnRemove(spec.options.align, columnCount - 1),
263
+ },
264
+ })
265
+ }
266
+ className="shrink-0 rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 disabled:opacity-30"
267
+ >
268
+ <Trash2 className="h-3.5 w-3.5" />
269
+ </button>
270
+ </div>
271
+ ) : null}
272
+
273
+ <div className="flex flex-col gap-1.5">
274
+ {spec.data.rows.map((cells, row) => (
275
+ <div key={row} className="flex items-center gap-1.5">
276
+ {Array.from({ length: columnCount }, (_, column) => (
277
+ <div key={column} className="min-w-0 flex-1">
278
+ <Cell
279
+ ariaLabel={t("detailPage.table.cellAria", {
280
+ row: row + 1,
281
+ column: column + 1,
282
+ })}
283
+ value={cells[column] ?? ""}
284
+ onPaste={onPaste}
285
+ onCommit={(next) => setData(withCell(spec.data, row, column, next))}
286
+ />
287
+ </div>
288
+ ))}
289
+ <button
290
+ type="button"
291
+ aria-label={t("detailPage.table.removeRow")}
292
+ title={t("detailPage.table.removeRow")}
293
+ disabled={rowCount <= 1}
294
+ onClick={() => apply({ data: withRowRemoved(spec.data, row) })}
295
+ className="shrink-0 rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 disabled:opacity-30"
296
+ >
297
+ <Trash2 className="h-3.5 w-3.5" />
298
+ </button>
299
+ </div>
300
+ ))}
301
+ </div>
302
+
303
+ <div className="mt-2 flex gap-1.5">
304
+ <button
305
+ type="button"
306
+ disabled={rowCount >= MAX_ROWS}
307
+ onClick={() => apply({ data: withRowAdded(spec.data) })}
308
+ className="flex flex-1 items-center justify-center gap-1 rounded-md border border-neutral-200 py-1.5 text-xs text-neutral-600 hover:bg-neutral-50 disabled:opacity-40"
309
+ >
310
+ <Rows3 className="h-3.5 w-3.5" />
311
+ {t("detailPage.table.addRow")}
312
+ </button>
313
+ <button
314
+ type="button"
315
+ disabled={!isGrid || columnCount >= MAX_COLUMNS}
316
+ onClick={() => apply({ data: withColumnAdded(spec.data) })}
317
+ title={isGrid ? undefined : t("detailPage.table.addColumnGridOnly")}
318
+ className="flex flex-1 items-center justify-center gap-1 rounded-md border border-neutral-200 py-1.5 text-xs text-neutral-600 hover:bg-neutral-50 disabled:opacity-40"
319
+ >
320
+ <Columns3 className="h-3.5 w-3.5" />
321
+ {t("detailPage.table.addColumn")}
322
+ </button>
323
+ </div>
324
+
325
+ <p className="mt-2 text-[11px] text-neutral-400">
326
+ {notice ?? `${t("detailPage.table.pasteHint")} ${t("detailPage.table.canvasHint")}`}
327
+ </p>
328
+ </div>
329
+ </Section>
330
+
331
+ <Section title={t("detailPage.table.layout")}>
332
+ <div className="flex flex-col gap-2">
333
+ {isGrid ? (
334
+ <ToggleButton
335
+ active={spec.options.headerRow}
336
+ onClick={() => setOptions({ headerRow: !spec.options.headerRow })}
337
+ title={t("detailPage.table.headerRow")}
338
+ >
339
+ {t("detailPage.table.headerRow")}
340
+ </ToggleButton>
341
+ ) : null}
342
+ <ToggleButton
343
+ active={spec.options.zebra}
344
+ onClick={() => setOptions({ zebra: !spec.options.zebra })}
345
+ title={t("detailPage.table.zebra")}
346
+ >
347
+ {t("detailPage.table.zebra")}
348
+ </ToggleButton>
349
+
350
+ {/* 캔버스에서 열 경계를 끌면 폭이 스펙에 박힌다. 그때 "첫 칸 폭"을 그대로 두면
351
+ 눌러도 아무 일이 안 일어나는 컨트롤이 된다 — 대신 자동으로 되돌리는 버튼을
352
+ 보여 주고, 되돌리면 아래 컨트롤이 다시 살아난다. */}
353
+ {spec.style.columnWidths ? (
354
+ <div className="flex items-center gap-2">
355
+ <button
356
+ type="button"
357
+ onClick={() => apply(autoColumnWidths(spec))}
358
+ className="flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1.5 text-xs text-neutral-600 hover:bg-neutral-50"
359
+ >
360
+ <RotateCcw className="h-3.5 w-3.5" />
361
+ {t("detailPage.table.autoWidths")}
362
+ </button>
363
+ <span className="text-[11px] text-neutral-400">
364
+ {t("detailPage.table.autoWidthsHint")}
365
+ </span>
366
+ </div>
367
+ ) : (
368
+ /* 첫 칸 고정폭 — 브랜드가 190px로 맞춰 쓰는 값. 끄면 모든 열이 같은 폭이다. */
369
+ <div className="flex items-center gap-2">
370
+ <ToggleButton
371
+ active={spec.style.firstWidth !== null}
372
+ onClick={() =>
373
+ setStyle({ firstWidth: spec.style.firstWidth === null ? 190 : null })
374
+ }
375
+ title={t("detailPage.table.firstWidth")}
376
+ >
377
+ {t("detailPage.table.firstWidth")}
378
+ </ToggleButton>
379
+ {spec.style.firstWidth !== null ? (
380
+ <NumberField
381
+ value={spec.style.firstWidth}
382
+ min={40}
383
+ max={480}
384
+ step={2}
385
+ onChange={(firstWidth) => setStyle({ firstWidth })}
386
+ />
387
+ ) : null}
388
+ </div>
389
+ )}
390
+
391
+ <div className="flex items-center gap-1.5">
392
+ <span className="w-16 shrink-0 text-[11px] text-neutral-500">
393
+ {t("detailPage.table.align")}
394
+ </span>
395
+ <div className="flex flex-1 gap-1">
396
+ {ALIGNS.map((align) => (
397
+ <button
398
+ key={align}
399
+ type="button"
400
+ aria-label={t(`detailPage.table.aligns.${align}`)}
401
+ aria-pressed={(spec.options.align[columnCount - 1] ?? "left") === align}
402
+ onClick={() => {
403
+ const next = [...spec.options.align];
404
+ while (next.length < columnCount) next.push("left");
405
+ next[columnCount - 1] = align;
406
+ setOptions({ align: next });
407
+ }}
408
+ className={`h-7 flex-1 rounded-md border text-[11px] ${
409
+ (spec.options.align[columnCount - 1] ?? "left") === align
410
+ ? "border-neutral-300 bg-neutral-100 font-semibold text-neutral-900"
411
+ : "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
412
+ }`}
413
+ >
414
+ {t(`detailPage.table.aligns.${align}`)}
415
+ </button>
416
+ ))}
417
+ </div>
418
+ </div>
419
+ <p className="text-[11px] text-neutral-400">
420
+ {t("detailPage.table.alignHint")}
421
+ </p>
422
+ </div>
423
+ </Section>
424
+
425
+ <Section title={t("detailPage.table.lines")}>
426
+ <div className="flex flex-col gap-2">
427
+ <RuleControl
428
+ label={t("detailPage.table.topRule")}
429
+ rule={spec.style.topRule}
430
+ fallbackColor={spec.style.firstColor}
431
+ onChange={(topRule) => setStyle({ topRule })}
432
+ />
433
+ <RuleControl
434
+ label={t("detailPage.table.rowRule")}
435
+ rule={spec.style.rowRule}
436
+ fallbackColor="#E3E5E8"
437
+ onChange={(rowRule) => setStyle({ rowRule })}
438
+ />
439
+ <RuleControl
440
+ label={t("detailPage.table.columnRule")}
441
+ rule={spec.style.columnRule}
442
+ fallbackColor="#E3E5E8"
443
+ onChange={(columnRule) => setStyle({ columnRule })}
444
+ />
445
+ <div className="flex items-center gap-2">
446
+ <ToggleButton
447
+ active={spec.style.outerBorder !== null}
448
+ onClick={() =>
449
+ setStyle({
450
+ outerBorder:
451
+ spec.style.outerBorder === null
452
+ ? { color: "#E3E5E8", width: 1, radius: 4 }
453
+ : null,
454
+ })
455
+ }
456
+ title={t("detailPage.table.outerBorder")}
457
+ >
458
+ {t("detailPage.table.outerBorder")}
459
+ </ToggleButton>
460
+ {spec.style.outerBorder ? (
461
+ <ColorInput
462
+ value={spec.style.outerBorder.color}
463
+ onChange={(color) => {
464
+ const border = spec.style.outerBorder;
465
+ if (border) setStyle({ outerBorder: { ...border, color } });
466
+ }}
467
+ />
468
+ ) : null}
469
+ </div>
470
+ </div>
471
+ </Section>
472
+
473
+ <Section title={t("detailPage.table.colors")}>
474
+ <div className="flex flex-col gap-2">
475
+ <div className="flex items-center gap-2">
476
+ <ToggleButton
477
+ active={spec.style.firstFill !== null}
478
+ onClick={() =>
479
+ setStyle({ firstFill: spec.style.firstFill === null ? "#F5F6F8" : null })
480
+ }
481
+ title={t("detailPage.table.firstFill")}
482
+ >
483
+ {t("detailPage.table.firstFill")}
484
+ </ToggleButton>
485
+ {spec.style.firstFill ? (
486
+ <ColorInput
487
+ value={spec.style.firstFill}
488
+ onChange={(firstFill) => setStyle({ firstFill })}
489
+ />
490
+ ) : null}
491
+ </div>
492
+ <div className="flex items-center gap-2">
493
+ <span className="w-20 shrink-0 text-[11px] text-neutral-500">
494
+ {t("detailPage.table.firstColor")}
495
+ </span>
496
+ <ColorInput
497
+ value={spec.style.firstColor}
498
+ onChange={(firstColor) => setStyle({ firstColor })}
499
+ />
500
+ </div>
501
+ <div className="flex items-center gap-2">
502
+ <span className="w-20 shrink-0 text-[11px] text-neutral-500">
503
+ {t("detailPage.table.textColor")}
504
+ </span>
505
+ <ColorInput
506
+ value={spec.style.color}
507
+ onChange={(color) => setStyle({ color })}
508
+ />
509
+ </div>
510
+ <div className="flex items-center gap-2">
511
+ <ToggleButton
512
+ active={spec.style.bodyFill !== null}
513
+ onClick={() =>
514
+ setStyle({ bodyFill: spec.style.bodyFill === null ? "#FFFFFF" : null })
515
+ }
516
+ title={t("detailPage.table.bodyFill")}
517
+ >
518
+ {t("detailPage.table.bodyFill")}
519
+ </ToggleButton>
520
+ {spec.style.bodyFill ? (
521
+ <ColorInput
522
+ value={spec.style.bodyFill}
523
+ onChange={(bodyFill) => setStyle({ bodyFill })}
524
+ />
525
+ ) : null}
526
+ </div>
527
+ </div>
528
+ </Section>
529
+
530
+ <Section title={t("detailPage.table.type")}>
531
+ <div className="flex flex-col gap-2">
532
+ <div className="flex items-center gap-2">
533
+ <span className="w-20 shrink-0 text-[11px] text-neutral-500">
534
+ {t("detailPage.table.fontSize")}
535
+ </span>
536
+ <NumberField
537
+ value={spec.style.fontSize}
538
+ min={9}
539
+ max={40}
540
+ step={1}
541
+ onChange={(fontSize) => setStyle({ fontSize })}
542
+ />
543
+ </div>
544
+ <div className="flex items-center gap-2">
545
+ <span className="w-20 shrink-0 text-[11px] text-neutral-500">
546
+ {t("detailPage.table.padX")}
547
+ </span>
548
+ <NumberField
549
+ value={spec.style.padX}
550
+ min={0}
551
+ max={60}
552
+ step={2}
553
+ onChange={(padX) => setStyle({ padX })}
554
+ />
555
+ </div>
556
+ <div className="flex items-center gap-2">
557
+ <span className="w-20 shrink-0 text-[11px] text-neutral-500">
558
+ {t("detailPage.table.padY")}
559
+ </span>
560
+ <NumberField
561
+ value={spec.style.padY}
562
+ min={0}
563
+ max={64}
564
+ step={2}
565
+ onChange={(padY) => setStyle({ padY })}
566
+ />
567
+ </div>
568
+ </div>
569
+ </Section>
570
+
571
+ <Section title={t("detailPage.table.detach")}>
572
+ <button
573
+ type="button"
574
+ onClick={() => detachTable(el)}
575
+ className="flex w-full items-center justify-center gap-1.5 rounded-md border border-neutral-200 py-1.5 text-xs text-neutral-600 hover:bg-neutral-50"
576
+ >
577
+ <Unlink className="h-3.5 w-3.5" />
578
+ {t("detailPage.table.detachAction")}
579
+ </button>
580
+ <p className="mt-1.5 text-[11px] text-neutral-400">
581
+ {t("detailPage.table.detachHint")}
582
+ </p>
583
+ </Section>
584
+ </>
585
+ );
586
+ });
@@ -0,0 +1,64 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { Slot } from "radix-ui"
4
+
5
+ import { cn } from "../../lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15
+ outline:
16
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost:
20
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
25
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
30
+ "icon-sm": "size-8",
31
+ "icon-lg": "size-10",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ size: "default",
37
+ },
38
+ }
39
+ )
40
+
41
+ function Button({
42
+ className,
43
+ variant = "default",
44
+ size = "default",
45
+ asChild = false,
46
+ ...props
47
+ }: React.ComponentProps<"button"> &
48
+ VariantProps<typeof buttonVariants> & {
49
+ asChild?: boolean
50
+ }) {
51
+ const Comp = asChild ? Slot.Root : "button"
52
+
53
+ return (
54
+ <Comp
55
+ data-slot="button"
56
+ data-variant={variant}
57
+ data-size={size}
58
+ className={cn(buttonVariants({ variant, size, className }))}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ export { Button, buttonVariants }