@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,261 @@
1
+ "use client";
2
+
3
+ import { Plus, X } from "lucide-react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import { ColorInput } from "../cardnews/color-input";
7
+
8
+ /**
9
+ * 단색/그라데이션 겸용 채우기 컨트롤.
10
+ *
11
+ * 스톡 편집기는 gradient를 별도 필드가 아니라 ``fill``(또는 page ``background``)에 CSS
12
+ * ``linear-gradient(...)`` 문자열로 담는다(``useColor``가 렌더 시 Konva의
13
+ * fillLinearGradient*로 자동 변환). 우리 기본 ``ColorInput``은 hex 전용이라 gradient를
14
+ * 검게 뭉갠다 → 여기서 단색/그라데이션을 토글하고, 그라데이션이면 색 stop들(2개 이상, 각
15
+ * stop은 색+위치%) + 각도로 문자열을 만들어 같은 ``onChange``로 넘긴다. 텍스트 fill, 도형
16
+ * fill, 페이지 배경 어디에나 재사용 가능.
17
+ *
18
+ * ⚠️스톡 편집기는 ``linear-gradient``만 지원한다(``utils/gradient.js`` isGradient가
19
+ * "linear-gradient"만 검사, radial은 이미지 URL로 오인돼 렌더가 깨진다). 그래서 radial은
20
+ * 노출하지 않는다. 대신 stop은 N개까지(colorStops 전부 매핑) 지원한다.
21
+ */
22
+
23
+ export function isGradientValue(v: string): boolean {
24
+ return typeof v === "string" && v.indexOf("linear-gradient") >= 0;
25
+ }
26
+
27
+ export type GradientStop = { color: string; pos: number };
28
+ export type ParsedStops = { angle: number; stops: GradientStop[] };
29
+ export type ParsedGradient = { angle: number; from: string; to: string };
30
+
31
+ const COLOR_RE = /#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)/;
32
+
33
+ /** 최상위 콤마로만 자른다(rgb(...)/rgba(...) 안의 콤마는 보존). */
34
+ function splitTopLevel(s: string): string[] {
35
+ const out: string[] = [];
36
+ let depth = 0;
37
+ let cur = "";
38
+ for (const ch of s) {
39
+ if (ch === "(") depth += 1;
40
+ else if (ch === ")") depth -= 1;
41
+ if (ch === "," && depth === 0) {
42
+ out.push(cur.trim());
43
+ cur = "";
44
+ } else {
45
+ cur += ch;
46
+ }
47
+ }
48
+ if (cur.trim()) out.push(cur.trim());
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * ``linear-gradient(90deg, #f00 0%, #00f 50%, #0f0 100%)`` → {angle, stops}. 각도가
54
+ * 없으면 180(CSS 기본). stop 위치가 없으면 균등 분배. stop이 2개 미만이면 보정한다.
55
+ */
56
+ export function parseStops(v: string): ParsedStops | null {
57
+ if (!isGradientValue(v)) return null;
58
+ const open = v.indexOf("(");
59
+ const close = v.lastIndexOf(")");
60
+ if (open < 0 || close < 0) return null;
61
+ const segs = splitTopLevel(v.slice(open + 1, close));
62
+ if (segs.length === 0) return null;
63
+
64
+ let angle = 180;
65
+ let rest = segs;
66
+ // 첫 세그먼트에 색이 없으면 각도(또는 방향) 지정으로 본다.
67
+ if (!COLOR_RE.test(segs[0])) {
68
+ const m = segs[0].match(/(-?\d+(?:\.\d+)?)\s*deg/);
69
+ angle = m ? Number(m[1]) : 180;
70
+ rest = segs.slice(1);
71
+ }
72
+
73
+ const stops: GradientStop[] = [];
74
+ rest.forEach((seg, i) => {
75
+ const cm = seg.match(COLOR_RE);
76
+ if (!cm) return;
77
+ const color = cm[0];
78
+ const after = seg.slice((cm.index ?? 0) + color.length);
79
+ const pm = after.match(/(-?\d+(?:\.\d+)?)\s*%/);
80
+ const pos = pm
81
+ ? Number(pm[1])
82
+ : rest.length > 1
83
+ ? (i / (rest.length - 1)) * 100
84
+ : 0;
85
+ stops.push({ color, pos });
86
+ });
87
+
88
+ if (stops.length === 0) return null;
89
+ if (stops.length === 1) stops.push({ color: stops[0].color, pos: 100 });
90
+ return { angle, stops };
91
+ }
92
+
93
+ /** {angle, stops} → 스톡 편집기가 이해하는 ``linear-gradient`` 문자열. */
94
+ export function buildStops(g: ParsedStops): string {
95
+ const parts = g.stops
96
+ .map((s) => `${s.color} ${Math.round(clampPos(s.pos))}%`)
97
+ .join(", ");
98
+ return `linear-gradient(${g.angle}deg, ${parts})`;
99
+ }
100
+
101
+ function clampPos(n: number): number {
102
+ if (Number.isNaN(n)) return 0;
103
+ return Math.max(0, Math.min(100, n));
104
+ }
105
+
106
+ // ── from/to 편의 래퍼(기존 호출부·테스트 호환) ────────────────────────────────
107
+
108
+ /** 첫 stop과 끝 stop 색만 뽑는 2-stop 뷰. */
109
+ export function parseGradient(v: string): ParsedGradient | null {
110
+ const g = parseStops(v);
111
+ if (!g) return null;
112
+ return { angle: g.angle, from: g.stops[0].color, to: g.stops[g.stops.length - 1].color };
113
+ }
114
+
115
+ /** 2-stop {angle, from, to} → 문자열(0%/100%). */
116
+ export function buildGradient(g: ParsedGradient): string {
117
+ return `linear-gradient(${g.angle}deg, ${g.from} 0%, ${g.to} 100%)`;
118
+ }
119
+
120
+ export function FillControl({
121
+ value,
122
+ onChange,
123
+ }: {
124
+ value: string;
125
+ onChange: (v: string) => void;
126
+ }) {
127
+ const { t } = useTranslation("branding");
128
+ const parsed = parseStops(value);
129
+ const isGradient = parsed != null;
130
+ // 단색일 때 표시/편집할 색. 그라데이션이면 첫 stop 색을 단색 후보로 쓴다.
131
+ const solid = isGradient ? parsed.stops[0].color : value || "#000000";
132
+ const g: ParsedStops = parsed ?? {
133
+ angle: 180,
134
+ stops: [
135
+ { color: solid, pos: 0 },
136
+ { color: "#ffffff", pos: 100 },
137
+ ],
138
+ };
139
+
140
+ const emit = (next: ParsedStops) => onChange(buildStops(next));
141
+ const setStop = (i: number, patch: Partial<GradientStop>) =>
142
+ emit({ ...g, stops: g.stops.map((s, idx) => (idx === i ? { ...s, ...patch } : s)) });
143
+ const addStop = () => {
144
+ // 마지막 두 stop 사이 중간에 새 stop을 끼운다.
145
+ const last = g.stops[g.stops.length - 1];
146
+ const prev = g.stops[g.stops.length - 2] ?? last;
147
+ const mid = Math.round((prev.pos + last.pos) / 2);
148
+ const stops = [
149
+ ...g.stops.slice(0, g.stops.length - 1),
150
+ { color: last.color, pos: mid },
151
+ last,
152
+ ];
153
+ emit({ ...g, stops });
154
+ };
155
+ const removeStop = (i: number) => {
156
+ if (g.stops.length <= 2) return;
157
+ emit({ ...g, stops: g.stops.filter((_, idx) => idx !== i) });
158
+ };
159
+
160
+ return (
161
+ <div className="flex flex-col gap-2">
162
+ {/* 단색 / 그라데이션 토글 */}
163
+ <div className="grid grid-cols-2 gap-1.5">
164
+ <button
165
+ type="button"
166
+ onClick={() => onChange(solid)}
167
+ className={
168
+ !isGradient
169
+ ? "h-8 rounded-md bg-neutral-900 text-xs font-semibold text-white"
170
+ : "h-8 rounded-md border border-neutral-200 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
171
+ }
172
+ >
173
+ {t("detailPage.properties.fillSolid")}
174
+ </button>
175
+ <button
176
+ type="button"
177
+ onClick={() => emit(g)}
178
+ className={
179
+ isGradient
180
+ ? "h-8 rounded-md bg-neutral-900 text-xs font-semibold text-white"
181
+ : "h-8 rounded-md border border-neutral-200 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
182
+ }
183
+ >
184
+ {t("detailPage.properties.fillGradient")}
185
+ </button>
186
+ </div>
187
+
188
+ {!isGradient ? (
189
+ <ColorInput value={solid} onChange={onChange} />
190
+ ) : (
191
+ <div className="flex flex-col gap-2">
192
+ {/* 미리보기 바 */}
193
+ <div
194
+ className="h-6 w-full rounded-md border border-neutral-200"
195
+ style={{ background: value }}
196
+ aria-hidden="true"
197
+ />
198
+
199
+ {/* stop 목록 */}
200
+ <div className="flex flex-col gap-1.5">
201
+ {g.stops.map((s, i) => (
202
+ <div key={i} className="flex items-center gap-2">
203
+ <ColorInput
204
+ value={s.color}
205
+ onChange={(c) => setStop(i, { color: c })}
206
+ />
207
+ <div className="flex min-w-0 flex-1 items-center gap-1">
208
+ <input
209
+ type="number"
210
+ min={0}
211
+ max={100}
212
+ value={Math.round(clampPos(s.pos))}
213
+ onChange={(e) => setStop(i, { pos: Number(e.target.value) })}
214
+ aria-label={t("detailPage.properties.gradientStopPosition")}
215
+ className="w-14 rounded-md border border-neutral-200 px-1.5 py-1 text-xs tabular-nums focus:border-neutral-400 focus:outline-none"
216
+ />
217
+ <span className="text-[11px] text-neutral-400">%</span>
218
+ </div>
219
+ <button
220
+ type="button"
221
+ onClick={() => removeStop(i)}
222
+ disabled={g.stops.length <= 2}
223
+ aria-label={t("detailPage.properties.gradientRemoveStop")}
224
+ className="rounded p-1 text-neutral-400 hover:text-neutral-700 disabled:cursor-not-allowed disabled:opacity-30"
225
+ >
226
+ <X size={13} />
227
+ </button>
228
+ </div>
229
+ ))}
230
+ <button
231
+ type="button"
232
+ onClick={addStop}
233
+ className="flex items-center justify-center gap-1 rounded-md border border-dashed border-neutral-300 py-1.5 text-[11px] font-medium text-neutral-500 hover:border-neutral-400 hover:bg-neutral-50"
234
+ >
235
+ <Plus size={12} />
236
+ {t("detailPage.properties.gradientAddStop")}
237
+ </button>
238
+ </div>
239
+
240
+ {/* 각도 */}
241
+ <label className="flex items-center gap-3">
242
+ <span className="w-8 shrink-0 text-[11px] text-neutral-500">
243
+ {t("detailPage.properties.gradientAngle")}
244
+ </span>
245
+ <input
246
+ type="range"
247
+ min={0}
248
+ max={360}
249
+ value={g.angle}
250
+ onChange={(e) => emit({ ...g, angle: Number(e.target.value) })}
251
+ className="min-w-0 flex-1 accent-neutral-900"
252
+ />
253
+ <span className="w-10 text-right text-xs tabular-nums text-neutral-700">
254
+ {Math.round(g.angle)}°
255
+ </span>
256
+ </label>
257
+ </div>
258
+ )}
259
+ </div>
260
+ );
261
+ }
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { CaseSensitive, ChevronDown, ChevronUp, X } from "lucide-react";
7
+
8
+ import {
9
+ collectTextMatches,
10
+ replaceInText,
11
+ stepIndex,
12
+ totalOccurrences,
13
+ type SearchPage,
14
+ type TextMatch,
15
+ } from "../../lib/detail-page/find-replace";
16
+
17
+ /**
18
+ * ⌘F로 뜨는 찾기·바꾸기 패널.
19
+ *
20
+ * 좌측 사이드바에 안 넣는다 — 탭이 이미 12개다. 캔버스 우상단에 떠 있는 편이
21
+ * "지금 문서를 훑는 중"이라는 상태와도 맞다.
22
+ *
23
+ * 훑기는 **요소 단위**다. Konva 캔버스에서 글자 일부만 하이라이트할 수가 없어서,
24
+ * 같은 요소 안 두 번째 등장으로 "이동"해 봐야 화면이 그대로다. 대신 전체 등장
25
+ * 횟수를 따로 보여 준다 — 그게 실제로 바뀔 자리 수다.
26
+ */
27
+
28
+ type PanelElement = {
29
+ id: string;
30
+ text?: string;
31
+ set?: (props: Record<string, unknown>) => void;
32
+ };
33
+
34
+ type PanelStore = {
35
+ pages?: SearchPage[];
36
+ getElementById?: (id: string) => PanelElement | undefined;
37
+ selectElements?: (ids: string[]) => void;
38
+ selectPage?: (id: string) => void;
39
+ history?: { startTransaction?: () => void; endTransaction?: () => void };
40
+ };
41
+
42
+ /** 그 자리로 화면을 옮긴다. 섹션을 먼저 고르면 워크스페이스가 거기로 스크롤한다. */
43
+ export function revealMatch(store: PanelStore, match: TextMatch): void {
44
+ store.selectPage?.(match.pageId);
45
+ store.selectElements?.([match.elementId]);
46
+ }
47
+
48
+ export const FindReplacePanel = observer(function FindReplacePanel({
49
+ store,
50
+ }: {
51
+ store: unknown;
52
+ }) {
53
+ const { t } = useTranslation("branding");
54
+ const s = store as PanelStore;
55
+ const [open, setOpen] = useState(false);
56
+ const [query, setQuery] = useState("");
57
+ const [replacement, setReplacement] = useState("");
58
+ const [caseSensitive, setCaseSensitive] = useState(false);
59
+ const [cursor, setCursor] = useState(0);
60
+
61
+ const matches = useMemo(
62
+ () => (open ? collectTextMatches(s.pages ?? [], query, { caseSensitive }) : []),
63
+ // 관찰형이라 텍스트가 바뀌면 이 memo도 다시 돈다(pages는 mobx 배열).
64
+ [s, open, query, caseSensitive],
65
+ );
66
+ const total = totalOccurrences(matches);
67
+ const at = matches.length ? Math.min(cursor, matches.length - 1) : 0;
68
+
69
+ useEffect(() => {
70
+ const onKey = (e: KeyboardEvent) => {
71
+ if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "f" || e.shiftKey) {
72
+ return;
73
+ }
74
+ e.preventDefault();
75
+ e.stopPropagation();
76
+ setOpen(true);
77
+ };
78
+ document.addEventListener("keydown", onKey, { capture: true });
79
+ return () => document.removeEventListener("keydown", onKey, { capture: true });
80
+ }, []);
81
+
82
+ const step = useCallback(
83
+ (direction: 1 | -1) => {
84
+ const next = stepIndex(at, matches.length, direction);
85
+ if (next == null) return;
86
+ setCursor(next);
87
+ revealMatch(s, matches[next]);
88
+ },
89
+ [at, matches, s],
90
+ );
91
+
92
+ const replaceOne = useCallback(() => {
93
+ const match = matches[at];
94
+ if (!match) return;
95
+ const el = s.getElementById?.(match.elementId);
96
+ if (typeof el?.text !== "string") return;
97
+ el.set?.({ text: replaceInText(el.text, query, replacement, { caseSensitive }) });
98
+ // 그 요소가 목록에서 빠지면 같은 자리에 다음 것이 올라온다 — cursor는 그대로 둔다.
99
+ }, [matches, at, s, query, replacement, caseSensitive]);
100
+
101
+ const replaceAll = useCallback(() => {
102
+ if (!matches.length) return;
103
+ // ⌘Z 한 번에 전부 되돌아가게 묶는다.
104
+ s.history?.startTransaction?.();
105
+ try {
106
+ for (const match of matches) {
107
+ const el = s.getElementById?.(match.elementId);
108
+ if (typeof el?.text !== "string") continue;
109
+ el.set?.({
110
+ text: replaceInText(el.text, query, replacement, { caseSensitive }),
111
+ });
112
+ }
113
+ } finally {
114
+ s.history?.endTransaction?.();
115
+ }
116
+ setCursor(0);
117
+ }, [matches, s, query, replacement, caseSensitive]);
118
+
119
+ if (!open) return null;
120
+
121
+ const field =
122
+ "h-8 w-full rounded-md border border-neutral-200 px-2 text-[13px] text-neutral-800 outline-none focus:border-neutral-400";
123
+ const button =
124
+ "flex h-8 items-center justify-center rounded-md border border-neutral-200 px-2.5 text-[12px] text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40";
125
+
126
+ return (
127
+ <div
128
+ data-dp-find-replace
129
+ className="absolute right-4 top-4 z-40 w-[292px] rounded-lg border border-neutral-200 bg-white p-3 shadow-lg"
130
+ onKeyDown={(e) => {
131
+ if (e.key === "Escape") setOpen(false);
132
+ if (e.key === "Enter") step(e.shiftKey ? -1 : 1);
133
+ }}
134
+ >
135
+ <div className="mb-2 flex items-center justify-between">
136
+ <span className="text-[12px] font-medium text-neutral-500">
137
+ {t("detailPage.findReplace.title")}
138
+ </span>
139
+ <button
140
+ type="button"
141
+ aria-label={t("detailPage.findReplace.close")}
142
+ data-dp-find-close
143
+ onClick={() => setOpen(false)}
144
+ className="rounded p-0.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700"
145
+ >
146
+ <X size={14} />
147
+ </button>
148
+ </div>
149
+
150
+ <div className="flex items-center gap-1.5">
151
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus -- ⌘F는 바로 타이핑을 기대한다 */}
152
+ <input
153
+ autoFocus
154
+ data-dp-find-query
155
+ value={query}
156
+ onChange={(e) => {
157
+ setQuery(e.target.value);
158
+ setCursor(0);
159
+ }}
160
+ placeholder={t("detailPage.findReplace.findPlaceholder")}
161
+ className={field}
162
+ />
163
+ <button
164
+ type="button"
165
+ title={t("detailPage.findReplace.caseSensitive")}
166
+ aria-label={t("detailPage.findReplace.caseSensitive")}
167
+ aria-pressed={caseSensitive}
168
+ data-dp-find-case
169
+ onClick={() => setCaseSensitive((v) => !v)}
170
+ className={`${button} shrink-0 ${caseSensitive ? "bg-neutral-900 text-white hover:bg-neutral-900" : ""}`}
171
+ >
172
+ <CaseSensitive size={15} />
173
+ </button>
174
+ </div>
175
+
176
+ <div className="mt-1.5 flex items-center gap-1.5">
177
+ <input
178
+ data-dp-find-replacement
179
+ value={replacement}
180
+ onChange={(e) => setReplacement(e.target.value)}
181
+ placeholder={t("detailPage.findReplace.replacePlaceholder")}
182
+ className={field}
183
+ />
184
+ </div>
185
+
186
+ <div className="mt-2 flex items-center justify-between">
187
+ <span
188
+ data-dp-find-count
189
+ data-dp-find-current={matches.length ? at + 1 : 0}
190
+ data-dp-find-blocks={matches.length}
191
+ data-dp-find-total={total}
192
+ className="text-[12px] tabular-nums text-neutral-500"
193
+ >
194
+ {matches.length
195
+ ? t("detailPage.findReplace.count", {
196
+ current: at + 1,
197
+ blocks: matches.length,
198
+ total,
199
+ })
200
+ : t(query ? "detailPage.findReplace.none" : "detailPage.findReplace.idle")}
201
+ </span>
202
+ <div className="flex items-center gap-1">
203
+ <button
204
+ type="button"
205
+ aria-label={t("detailPage.findReplace.previous")}
206
+ data-dp-find-prev
207
+ disabled={!matches.length}
208
+ onClick={() => step(-1)}
209
+ className={`${button} px-1.5`}
210
+ >
211
+ <ChevronUp size={14} />
212
+ </button>
213
+ <button
214
+ type="button"
215
+ aria-label={t("detailPage.findReplace.next")}
216
+ data-dp-find-next
217
+ disabled={!matches.length}
218
+ onClick={() => step(1)}
219
+ className={`${button} px-1.5`}
220
+ >
221
+ <ChevronDown size={14} />
222
+ </button>
223
+ </div>
224
+ </div>
225
+
226
+ <div className="mt-2 flex items-center gap-1.5">
227
+ <button
228
+ type="button"
229
+ data-dp-find-replace-one
230
+ disabled={!matches.length}
231
+ onClick={replaceOne}
232
+ className={`${button} flex-1`}
233
+ >
234
+ {t("detailPage.findReplace.replace")}
235
+ </button>
236
+ <button
237
+ type="button"
238
+ data-dp-find-replace-all
239
+ disabled={!matches.length}
240
+ onClick={replaceAll}
241
+ className={`${button} flex-1`}
242
+ >
243
+ {t("detailPage.findReplace.replaceAll")}
244
+ </button>
245
+ </div>
246
+
247
+ <p className="mt-2 text-[11px] leading-relaxed text-neutral-400">
248
+ {t("detailPage.findReplace.chartHint")}
249
+ </p>
250
+ </div>
251
+ );
252
+ });
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 어두운(bg-neutral-900) 생성 버튼 위에 얹는 가짜 프로그레스 오버레이.
5
+ *
6
+ * 진행 신호를 두 겹으로 준다: (1) 버튼 전체를 은은하게 채우는 반투명 면,
7
+ * (2) 하단의 또렷한 3px 바. 둘 다 왼→오로 자라며, 라벨(퍼센트 포함)은 버튼이
8
+ * 직접 그린다. 부모 버튼은 `relative overflow-hidden`이어야 한다.
9
+ *
10
+ * 진행률 계산은 {@link useFakeProgress}가, 표시는 이 컴포넌트가 담당한다.
11
+ */
12
+ export function GenerationProgressFill({ progress }: { progress: number }) {
13
+ const pct = Math.max(0, Math.min(100, Math.round(progress * 100)));
14
+ return (
15
+ <>
16
+ <span
17
+ aria-hidden="true"
18
+ className="pointer-events-none absolute inset-y-0 left-0 bg-white/10"
19
+ style={{ width: `${pct}%`, transition: "width 200ms ease-out" }}
20
+ />
21
+ <span
22
+ aria-hidden="true"
23
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-white/25"
24
+ >
25
+ <span
26
+ className="block h-full bg-white"
27
+ style={{ width: `${pct}%`, transition: "width 200ms ease-out" }}
28
+ />
29
+ </span>
30
+ </>
31
+ );
32
+ }
33
+
34
+ /** 진행률(0~1)을 정수 퍼센트(0~100)로. 라벨에 "42%"처럼 붙일 때 사용. */
35
+ export function progressPercent(progress: number): number {
36
+ return Math.max(0, Math.min(100, Math.round(progress * 100)));
37
+ }
38
+
39
+ /**
40
+ * 밝은 배경 패널(카피·SVG 프롬프트 편집)의 하단에 얹는 얇은 프로그레스 라인.
41
+ * 테마 토큰(primary) 기반이라 라이트/다크 모두 자연스럽다. 부모는 `relative`여야
42
+ * 하며, 이 요소는 컨테이너 하단에 절대배치된다.
43
+ */
44
+ export function GenerationProgressLine({ progress }: { progress: number }) {
45
+ const pct = progressPercent(progress);
46
+ return (
47
+ <span
48
+ aria-hidden="true"
49
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-primary/15"
50
+ >
51
+ <span
52
+ className="block h-full bg-primary"
53
+ style={{ width: `${pct}%`, transition: "width 200ms ease-out" }}
54
+ />
55
+ </span>
56
+ );
57
+ }