@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,531 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { Plus, Trash2, Unlink } from "lucide-react";
7
+
8
+ import { ColorInput } from "../cardnews/color-input";
9
+ import { SpecPromptEditPanel } from "./spec-prompt-edit-panel";
10
+ import type { SpecEditPrompting } from "./table-inspector";
11
+ import {
12
+ NumberField,
13
+ Section,
14
+ ToggleButton,
15
+ } from "./inspector-controls";
16
+ import { formatChartValue, parseChartNumber } from "../../lib/detail-page/chart/format";
17
+ import { resolveChart } from "../../lib/detail-page/chart/normalize";
18
+ import {
19
+ MAX_PALETTE_SLOTS,
20
+ paletteScope,
21
+ paletteSlots,
22
+ withPaletteColor,
23
+ } from "../../lib/detail-page/chart/palette";
24
+ import { parseChartTable } from "../../lib/detail-page/chart/paste";
25
+ import { CHART_KINDS } from "../../lib/detail-page/chart/render";
26
+ import {
27
+ detachChart,
28
+ syncChartGroup,
29
+ type ElementLike,
30
+ type StoreLike,
31
+ } from "../../lib/detail-page/chart/sync";
32
+ import type { ChartData, ChartSpec, ChartStyle } from "../../lib/detail-page/chart/types";
33
+
34
+ /**
35
+ * 차트 인스펙터 — Canva의 "차트 편집"에 해당한다.
36
+ *
37
+ * 모든 변경은 여기서 스펙을 고쳐 ``syncChartGroup``으로 다시 그리는 경로 하나만 탄다.
38
+ * 그래서 값과 그림이 절대 어긋나지 않는다 — 지금 템플릿의 하드코딩 막대(값 따로, 길이
39
+ * 따로)가 겪던 문제가 그것이다.
40
+ *
41
+ * 캔버스의 막대는 **잠겨 있지 않다**. 잠그면 클릭이 아무것도 못 맞혀 차트를 아예 고를 수
42
+ * 없다(``spec-group/sync``의 규칙 4). 드릴인해서 막대를 직접 고칠 수는 있지만, 그렇게
43
+ * 고친 값은 다음 재생성 때 스펙에서 다시 그려져 덮인다.
44
+ *
45
+ * 종류를 바꿔도 ``spec.data``는 그대로라 데이터가 살아남는다.
46
+ */
47
+
48
+ // ── 셀 ──────────────────────────────────────────────────────────────────────
49
+
50
+ /** blur/Enter에 커밋하는 입력. 타이핑마다 다시 그리면 캔버스가 덜덜 떤다. */
51
+ function Cell({
52
+ value,
53
+ onCommit,
54
+ align = "left",
55
+ placeholder,
56
+ ariaLabel,
57
+ }: {
58
+ value: string;
59
+ onCommit: (next: string) => void;
60
+ align?: "left" | "right";
61
+ placeholder?: string;
62
+ ariaLabel: string;
63
+ }) {
64
+ const [local, setLocal] = useState(value);
65
+ useEffect(() => setLocal(value), [value]);
66
+ const commit = () => {
67
+ if (local !== value) onCommit(local);
68
+ };
69
+ return (
70
+ <input
71
+ type="text"
72
+ aria-label={ariaLabel}
73
+ value={local}
74
+ placeholder={placeholder}
75
+ onChange={(e) => setLocal(e.target.value)}
76
+ onBlur={commit}
77
+ onKeyDown={(e) => {
78
+ if (e.key === "Enter") {
79
+ commit();
80
+ (e.target as HTMLInputElement).blur();
81
+ }
82
+ }}
83
+ 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 ${
84
+ align === "right" ? "text-right tabular-nums" : ""
85
+ }`}
86
+ />
87
+ );
88
+ }
89
+
90
+ // ── 데이터 조작 ──────────────────────────────────────────────────────────────
91
+
92
+ function replaceAt<T>(list: T[], index: number, next: T): T[] {
93
+ return list.map((item, i) => (i === index ? next : item));
94
+ }
95
+
96
+ function withLabel(data: ChartData, index: number, label: string): ChartData {
97
+ return { ...data, labels: replaceAt(data.labels, index, label) };
98
+ }
99
+
100
+ function withValue(
101
+ data: ChartData,
102
+ row: number,
103
+ column: number,
104
+ value: number | null,
105
+ ): ChartData {
106
+ return {
107
+ ...data,
108
+ series: data.series.map((s, i) =>
109
+ i === column ? { ...s, values: replaceAt(s.values, row, value) } : s,
110
+ ),
111
+ };
112
+ }
113
+
114
+ function withRowAdded(data: ChartData): ChartData {
115
+ return {
116
+ labels: [...data.labels, ""],
117
+ series: data.series.map((s) => ({ ...s, values: [...s.values, null] })),
118
+ };
119
+ }
120
+
121
+ function withRowRemoved(data: ChartData, index: number): ChartData {
122
+ return {
123
+ labels: data.labels.filter((_, i) => i !== index),
124
+ series: data.series.map((s) => ({
125
+ ...s,
126
+ values: s.values.filter((_, i) => i !== index),
127
+ })),
128
+ };
129
+ }
130
+
131
+ /** 행을 지우면 강조가 가리키던 자리도 따라 옮기거나 풀린다. */
132
+ export function highlightAfterRemove(
133
+ highlight: number | null,
134
+ removed: number,
135
+ ): number | null {
136
+ if (highlight === null) return null;
137
+ if (highlight === removed) return null;
138
+ return highlight > removed ? highlight - 1 : highlight;
139
+ }
140
+
141
+ // ── 인스펙터 ────────────────────────────────────────────────────────────────
142
+
143
+ export const ChartInspector = observer(function ChartInspector({
144
+ store,
145
+ el,
146
+ spec,
147
+ prompting,
148
+ }: {
149
+ store: StoreLike;
150
+ el: ElementLike;
151
+ spec: ChartSpec;
152
+ prompting?: SpecEditPrompting;
153
+ }) {
154
+ const { t } = useTranslation("branding");
155
+ const [notice, setNotice] = useState<string | null>(null);
156
+
157
+ const apply = (patch: Partial<ChartSpec>) => {
158
+ syncChartGroup(store, el, { ...spec, ...patch });
159
+ };
160
+ const setData = (data: ChartData) => apply({ data });
161
+ const setOptions = (patch: Partial<ChartSpec["options"]>) =>
162
+ apply({ options: { ...spec.options, ...patch } });
163
+ const setStyle = (patch: Partial<ChartStyle>) =>
164
+ apply({ style: { ...spec.style, ...patch } });
165
+
166
+ const resolved = resolveChart(spec);
167
+ const rows = spec.data.labels.length;
168
+
169
+ const scope = paletteScope(spec);
170
+ const slots = paletteSlots(spec);
171
+ const slotsTruncated =
172
+ (scope === "series" ? spec.data.series.length : spec.data.labels.length) >
173
+ MAX_PALETTE_SLOTS;
174
+
175
+ /** 스프레드시트에서 붙여넣기 — 표 전체를 갈아 끼운다. */
176
+ const onPaste = (event: React.ClipboardEvent) => {
177
+ const text = event.clipboardData?.getData("text/plain") ?? "";
178
+ // 한 칸짜리 값은 그냥 입력이다. 여러 칸/여러 줄일 때만 표로 해석한다.
179
+ if (!text.includes("\t") && !text.includes("\n")) return;
180
+ const parsed = parseChartTable(text);
181
+ if (!parsed) return;
182
+ event.preventDefault();
183
+ setNotice(t("detailPage.chart.pasted", { count: parsed.labels.length }));
184
+ apply({
185
+ data: parsed,
186
+ options: {
187
+ ...spec.options,
188
+ // 붙여넣은 표는 행 수가 달라지므로 강조를 유지할 근거가 없다.
189
+ highlightIndex: null,
190
+ },
191
+ });
192
+ };
193
+
194
+ return (
195
+ <>
196
+ {prompting?.generatedId ? (
197
+ <Section title={t("detailPage.specPromptEdit.section")}>
198
+ <SpecPromptEditPanel
199
+ generatedId={prompting.generatedId}
200
+ specKind="chart"
201
+ elementId={String(el.id ?? "")}
202
+ currentSpec={spec}
203
+ // 서버가 준 스펙도 사용자가 고친 것과 같은 경로로 적용한다 — 그리는 곳이
204
+ // 하나여야 값과 그림이 안 어긋난다.
205
+ onApplied={(next) => syncChartGroup(store, el, next as ChartSpec)}
206
+ editsUsed={prompting.usage?.textUsed}
207
+ editLimit={prompting.usage?.textLimit}
208
+ unlimited={prompting.usage?.unlimited}
209
+ onUsage={prompting.onUsage}
210
+ onBuyMore={prompting.onBuyMore}
211
+ />
212
+ </Section>
213
+ ) : null}
214
+
215
+ <Section title={t("detailPage.chart.kind")}>
216
+ <div className="grid grid-cols-2 gap-1.5">
217
+ {CHART_KINDS.map((kind) => (
218
+ <button
219
+ key={kind}
220
+ type="button"
221
+ aria-pressed={kind === spec.kind}
222
+ onClick={() => apply({ kind })}
223
+ className={`h-8 rounded-md border text-xs transition-colors ${
224
+ kind === spec.kind
225
+ ? "border-neutral-300 bg-neutral-100 font-semibold text-neutral-900"
226
+ : "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
227
+ }`}
228
+ >
229
+ {t(`detailPage.chart.kinds.${kind}`)}
230
+ </button>
231
+ ))}
232
+ </div>
233
+ {resolved.hiddenSeries > 0 ? (
234
+ <p className="mt-2 text-[11px] text-amber-600">
235
+ {t("detailPage.chart.hiddenSeries", { count: resolved.hiddenSeries })}
236
+ </p>
237
+ ) : null}
238
+ </Section>
239
+
240
+ <Section title={t("detailPage.chart.data")}>
241
+ <div onPaste={onPaste}>
242
+ <div className="flex items-center gap-1.5 pb-1 text-[11px] text-neutral-400">
243
+ <span className="flex-1">{t("detailPage.chart.columnLabel")}</span>
244
+ {spec.data.series.map((series) => (
245
+ <span key={series.name} className="w-20 text-right">
246
+ {series.name}
247
+ </span>
248
+ ))}
249
+ <span className="w-6" />
250
+ </div>
251
+ <div className="flex flex-col gap-1.5">
252
+ {spec.data.labels.map((label, row) => (
253
+ <div key={row} className="flex items-center gap-1.5">
254
+ <div className="min-w-0 flex-1">
255
+ <Cell
256
+ ariaLabel={t("detailPage.chart.rowLabelAria", { row: row + 1 })}
257
+ value={label}
258
+ placeholder={t("detailPage.chart.columnLabel")}
259
+ onCommit={(next) => setData(withLabel(spec.data, row, next))}
260
+ />
261
+ </div>
262
+ {spec.data.series.map((series, column) => (
263
+ <div key={series.name} className="w-20 shrink-0">
264
+ <Cell
265
+ ariaLabel={t("detailPage.chart.rowValueAria", {
266
+ row: row + 1,
267
+ series: series.name,
268
+ })}
269
+ align="right"
270
+ value={formatChartValue(series.values[row] ?? null, {
271
+ decimals: spec.options.decimals,
272
+ grouping: false,
273
+ })}
274
+ onCommit={(next) =>
275
+ setData(
276
+ withValue(spec.data, row, column, parseChartNumber(next)),
277
+ )
278
+ }
279
+ />
280
+ </div>
281
+ ))}
282
+ <button
283
+ type="button"
284
+ aria-label={t("detailPage.chart.removeRow")}
285
+ title={t("detailPage.chart.removeRow")}
286
+ disabled={rows <= 1}
287
+ onClick={() =>
288
+ apply({
289
+ data: withRowRemoved(spec.data, row),
290
+ options: {
291
+ ...spec.options,
292
+ highlightIndex: highlightAfterRemove(
293
+ spec.options.highlightIndex,
294
+ row,
295
+ ),
296
+ },
297
+ })
298
+ }
299
+ className="flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-neutral-400 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
300
+ >
301
+ <Trash2 size={14} />
302
+ </button>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ <button
307
+ type="button"
308
+ onClick={() => setData(withRowAdded(spec.data))}
309
+ className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white text-xs font-medium text-neutral-600 hover:bg-neutral-50"
310
+ >
311
+ <Plus size={14} />
312
+ {t("detailPage.chart.addRow")}
313
+ </button>
314
+ <p className="mt-1.5 text-[11px] text-neutral-400">
315
+ {notice ?? t("detailPage.chart.pasteHint")}
316
+ </p>
317
+ </div>
318
+ </Section>
319
+
320
+ <Section title={t("detailPage.chart.display")}>
321
+ <div className="grid grid-cols-2 gap-2">
322
+ <label className="flex items-center gap-2">
323
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
324
+ {t("detailPage.chart.unit")}
325
+ </span>
326
+ <Cell
327
+ ariaLabel={t("detailPage.chart.unit")}
328
+ value={spec.options.unit}
329
+ placeholder="%"
330
+ onCommit={(unit) => setOptions({ unit })}
331
+ />
332
+ </label>
333
+ <label className="flex items-center gap-2">
334
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
335
+ {t("detailPage.chart.decimals")}
336
+ </span>
337
+ <NumberField
338
+ value={spec.options.decimals}
339
+ min={0}
340
+ max={3}
341
+ onChange={(decimals) => setOptions({ decimals })}
342
+ />
343
+ </label>
344
+ </div>
345
+
346
+ <div className="mt-2 flex items-center gap-1.5">
347
+ <ToggleButton
348
+ active={spec.options.showValue}
349
+ title={t("detailPage.chart.showValue")}
350
+ onClick={() => setOptions({ showValue: !spec.options.showValue })}
351
+ >
352
+ <span className="text-xs">{t("detailPage.chart.showValue")}</span>
353
+ </ToggleButton>
354
+ <ToggleButton
355
+ active={spec.style.showTrack}
356
+ title={t("detailPage.chart.showTrack")}
357
+ onClick={() => setStyle({ showTrack: !spec.style.showTrack })}
358
+ >
359
+ <span className="text-xs">{t("detailPage.chart.showTrack")}</span>
360
+ </ToggleButton>
361
+ </div>
362
+
363
+ <div className="mt-2 flex items-center gap-1.5">
364
+ {(["none", "desc", "asc"] as const).map((sort) => (
365
+ <ToggleButton
366
+ key={sort}
367
+ active={spec.options.sort === sort}
368
+ title={t(`detailPage.chart.sorts.${sort}`)}
369
+ onClick={() => setOptions({ sort })}
370
+ >
371
+ <span className="text-xs">{t(`detailPage.chart.sorts.${sort}`)}</span>
372
+ </ToggleButton>
373
+ ))}
374
+ </div>
375
+
376
+ <div className="mt-2 grid grid-cols-2 gap-2">
377
+ <label className="flex items-center gap-2">
378
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
379
+ {t("detailPage.chart.max")}
380
+ </span>
381
+ {spec.options.max === "auto" ? (
382
+ <button
383
+ type="button"
384
+ onClick={() => setOptions({ max: Math.round(resolved.max) })}
385
+ className="h-8 min-w-0 flex-1 rounded-md border border-neutral-200 bg-white text-xs text-neutral-500 hover:bg-neutral-50"
386
+ >
387
+ {t("detailPage.chart.maxAuto")}
388
+ </button>
389
+ ) : (
390
+ <NumberField
391
+ value={spec.options.max}
392
+ min={1}
393
+ onChange={(max) => setOptions({ max })}
394
+ />
395
+ )}
396
+ </label>
397
+ {spec.options.max === "auto" ? null : (
398
+ <button
399
+ type="button"
400
+ onClick={() => setOptions({ max: "auto" })}
401
+ className="h-8 rounded-md border border-neutral-200 bg-white text-xs text-neutral-500 hover:bg-neutral-50"
402
+ >
403
+ {t("detailPage.chart.maxToAuto")}
404
+ </button>
405
+ )}
406
+ </div>
407
+
408
+ <label className="mt-2 flex items-center gap-2">
409
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
410
+ {t("detailPage.chart.highlight")}
411
+ </span>
412
+ <select
413
+ aria-label={t("detailPage.chart.highlight")}
414
+ value={spec.options.highlightIndex ?? ""}
415
+ onChange={(event) =>
416
+ setOptions({
417
+ highlightIndex:
418
+ event.target.value === "" ? null : Number(event.target.value),
419
+ })
420
+ }
421
+ className="h-8 min-w-0 flex-1 rounded-md border border-neutral-200 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-neutral-400"
422
+ >
423
+ <option value="">{t("detailPage.chart.highlightNone")}</option>
424
+ {spec.data.labels.map((label, index) => (
425
+ <option key={index} value={index}>
426
+ {label || t("detailPage.chart.rowLabelAria", { row: index + 1 })}
427
+ </option>
428
+ ))}
429
+ </select>
430
+ </label>
431
+ </Section>
432
+
433
+ <Section title={t("detailPage.chart.colors")}>
434
+ {/* 팔레트는 종류마다 대응 대상이 다르다 — 막대·도넛은 항목별, 라인·스택은
435
+ 시리즈별. 강조 항목이 지정돼 있으면 첫 색 하나만 쓰이므로 슬롯도 하나다
436
+ (여러 개 펼치면 눌러도 아무 일 없는 컨트롤이 생긴다). */}
437
+ <div className="flex flex-col gap-2">
438
+ {slots.map((slot) => (
439
+ <label key={slot.index} className="flex items-center gap-2">
440
+ <span className="w-16 shrink-0 truncate text-xs text-neutral-500">
441
+ {scope === "highlight"
442
+ ? t("detailPage.chart.color.accent")
443
+ : slot.name ||
444
+ t(
445
+ scope === "series"
446
+ ? "detailPage.chart.color.seriesN"
447
+ : "detailPage.chart.color.itemN",
448
+ { n: slot.index + 1 },
449
+ )}
450
+ </span>
451
+ <ColorInput
452
+ value={slot.color}
453
+ onChange={(color) =>
454
+ apply(withPaletteColor(spec, slot.index, color))
455
+ }
456
+ />
457
+ </label>
458
+ ))}
459
+ {scope !== "highlight" && slotsTruncated ? (
460
+ <p className="text-[11px] leading-relaxed text-neutral-400">
461
+ {t("detailPage.chart.color.moreSlots", { count: MAX_PALETTE_SLOTS })}
462
+ </p>
463
+ ) : null}
464
+
465
+ {(
466
+ [
467
+ ["muted", spec.style.mutedColor],
468
+ ["track", spec.style.trackColor],
469
+ ["label", spec.style.labelColor],
470
+ ["value", spec.style.valueColor],
471
+ ] as const
472
+ ).map(([key, value]) => (
473
+ <label key={key} className="flex items-center gap-2">
474
+ <span className="w-16 shrink-0 text-xs text-neutral-500">
475
+ {t(`detailPage.chart.color.${key}`)}
476
+ </span>
477
+ <ColorInput
478
+ value={value}
479
+ onChange={(color) => {
480
+ if (key === "muted") setStyle({ mutedColor: color });
481
+ else if (key === "track") setStyle({ trackColor: color });
482
+ else if (key === "label") setStyle({ labelColor: color });
483
+ else setStyle({ valueColor: color });
484
+ }}
485
+ />
486
+ </label>
487
+ ))}
488
+ </div>
489
+ </Section>
490
+
491
+ <Section title={t("detailPage.chart.shape")}>
492
+ <div className="grid grid-cols-2 gap-2">
493
+ <label className="flex items-center gap-2">
494
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
495
+ {t("detailPage.chart.barSize")}
496
+ </span>
497
+ <NumberField
498
+ value={spec.style.barSize}
499
+ min={1}
500
+ onChange={(barSize) => setStyle({ barSize })}
501
+ />
502
+ </label>
503
+ <label className="flex items-center gap-2">
504
+ <span className="w-10 shrink-0 text-xs text-neutral-500">
505
+ {t("detailPage.chart.gap")}
506
+ </span>
507
+ <NumberField
508
+ value={spec.style.gap}
509
+ min={0}
510
+ onChange={(gap) => setStyle({ gap })}
511
+ />
512
+ </label>
513
+ </div>
514
+ </Section>
515
+
516
+ <Section title={t("detailPage.properties.actions")}>
517
+ <button
518
+ type="button"
519
+ onClick={() => detachChart(el)}
520
+ className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-neutral-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
521
+ >
522
+ <Unlink aria-hidden="true" size={15} />
523
+ {t("detailPage.chart.detach")}
524
+ </button>
525
+ <p className="mt-1.5 text-[11px] text-neutral-400">
526
+ {t("detailPage.chart.detachHint")}
527
+ </p>
528
+ </Section>
529
+ </>
530
+ );
531
+ });