@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,154 @@
1
+ "use client";
2
+
3
+ // "그룹 편집" — 그룹으로 묶인 여러 요소(텍스트·SVG 도형)를 자연어 요청 한 번으로 함께
4
+ // 수정한다. 소싱 서버의 그룹 편집 엔드포인트(/{generatedId}/group/prompt-edit)에 그룹
5
+ // 안 모든 텍스트/도형 + 지시를 한 번 POST 하고, id로 돌아온 결과를 각 요소에 적용한다.
6
+ // 텍스트는 서로 어울리게 한 번에 다시 쓰이고, 도형은 같은 지시로 각자 다시 그려진다.
7
+ // 슬롯 단건용 PromptEditPanel / SvgPromptEditPanel의 그룹 통합판(프롬프트 박스 1개).
8
+
9
+ import { useCallback, useState } from "react";
10
+ import { ArrowUp, Loader2, Sparkles } from "lucide-react";
11
+ import { useDetailPageHost } from "./detail-page-host-context";
12
+ import type {
13
+ DetailPageGroupEditItem,
14
+ DetailPageGroupEditResultItem,
15
+ } from "./detail-page-host-context";
16
+ import { EditQuotaBlock, EditUsageBadge } from "./edit-quota-ui";
17
+
18
+ interface GroupPromptEditPanelProps {
19
+ /** 생성 인스턴스 ID. 없으면(픽스처 모드) 이 패널은 렌더되지 않는다. */
20
+ generatedId: string;
21
+ /** 편집할 그룹 내 요소들(텍스트·도형)의 현재 상태. */
22
+ items: DetailPageGroupEditItem[];
23
+ /** 요소별 결과를 각 Canvas 요소에 반영하는 콜백(id로 매핑). */
24
+ onApplied: (results: DetailPageGroupEditResultItem[]) => void;
25
+ /** 텍스트 편집 사용 횟수/한도(부모가 관리). */
26
+ textUsed?: number;
27
+ textLimit?: number;
28
+ /** SVG 편집 사용 횟수/한도(부모가 관리). */
29
+ svgUsed?: number;
30
+ svgLimit?: number;
31
+ /** scratch 등 한도 면제면 카운터/차단을 숨긴다. */
32
+ unlimited?: boolean;
33
+ /** 편집 성공/차단 후 종류별 사용량 갱신을 부모에 알린다. */
34
+ onUsage?: (kind: "svg" | "text", used: number, limit: number) => void;
35
+ /** 한도 소진 시 "편집 크레딧 추가하기" 목적지(레비오사 결제면). */
36
+ onBuyMore?: () => void;
37
+ }
38
+
39
+ export function GroupPromptEditPanel({
40
+ generatedId,
41
+ items,
42
+ onApplied,
43
+ textUsed = 0,
44
+ textLimit = 0,
45
+ svgUsed = 0,
46
+ svgLimit = 0,
47
+ unlimited = false,
48
+ onUsage,
49
+ onBuyMore,
50
+ }: GroupPromptEditPanelProps) {
51
+ const { api, toast } = useDetailPageHost();
52
+ const [input, setInput] = useState("");
53
+ const [busy, setBusy] = useState(false);
54
+
55
+ const hasText = items.some((i) => i.kind === "text");
56
+ const hasSvg = items.some((i) => i.kind === "svg");
57
+ // 그룹에 포함된 종류 중 하나라도 한도를 소진하면 편집을 막는다(백엔드도 종류별로 막음).
58
+ const textBlocked = hasText && !unlimited && textLimit > 0 && textUsed >= textLimit;
59
+ const svgBlocked = hasSvg && !unlimited && svgLimit > 0 && svgUsed >= svgLimit;
60
+ const blocked = textBlocked || svgBlocked;
61
+ const blockedKind: "text" | "svg" = textBlocked ? "text" : "svg";
62
+
63
+ const send = useCallback(async () => {
64
+ const instruction = input.trim();
65
+ if (!instruction || busy || blocked || items.length === 0) return;
66
+ setBusy(true);
67
+ try {
68
+ const result = await api.groupPromptEditDetailPage(generatedId, {
69
+ instruction,
70
+ items,
71
+ });
72
+ if (typeof result.text_used === "number" && typeof result.text_limit === "number") {
73
+ onUsage?.("text", result.text_used, result.text_limit);
74
+ }
75
+ if (typeof result.svg_used === "number" && typeof result.svg_limit === "number") {
76
+ onUsage?.("svg", result.svg_used, result.svg_limit);
77
+ }
78
+ const changed = result.results.filter((r) => {
79
+ const src = items.find((i) => i.id === r.id);
80
+ if (!src) return false;
81
+ if (r.kind === "text") return (r.text ?? "").trim() !== (src.current_text ?? "").trim();
82
+ return (r.svg ?? "").trim() !== (src.current_svg ?? "").trim();
83
+ });
84
+ if (changed.length === 0) {
85
+ toast.info("바뀐 내용이 없어요. 다르게 요청해 보세요.");
86
+ } else {
87
+ onApplied(changed);
88
+ toast.success("그룹을 수정했어요.");
89
+ setInput("");
90
+ }
91
+ } catch (err) {
92
+ const quota = api.asEditQuotaError(err);
93
+ if (quota) {
94
+ onUsage?.(quota.kind, quota.limit, quota.limit);
95
+ toast.error("편집 한도를 모두 사용했어요.");
96
+ } else {
97
+ toast.error(err instanceof Error ? err.message : "수정 요청이 실패했어요.");
98
+ }
99
+ } finally {
100
+ setBusy(false);
101
+ }
102
+ }, [api, toast, input, busy, blocked, items, generatedId, onApplied, onUsage]);
103
+
104
+ // 배지는 그룹에 텍스트가 있으면 텍스트, 아니면 SVG 사용량을 대표로 보여준다.
105
+ const badgeUsed = hasText ? textUsed : svgUsed;
106
+ const badgeLimit = hasText ? textLimit : svgLimit;
107
+
108
+ return (
109
+ <div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
110
+ <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
111
+ <Sparkles size={13} className="text-primary" />
112
+ <span className="text-xs font-medium text-foreground">프롬프트로 편집</span>
113
+ {unlimited || badgeLimit > 0 ? (
114
+ <EditUsageBadge used={badgeUsed} limit={badgeLimit} unlimited={unlimited} />
115
+ ) : null}
116
+ </div>
117
+ {blocked ? (
118
+ <EditQuotaBlock kind={blockedKind} onBuyMore={onBuyMore} />
119
+ ) : (
120
+ <div className="flex items-end gap-1.5 px-2 py-2">
121
+ <textarea
122
+ value={input}
123
+ onChange={(e) => setInput(e.target.value)}
124
+ onKeyDown={(e) => {
125
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
126
+ e.preventDefault();
127
+ send();
128
+ }
129
+ }}
130
+ rows={2}
131
+ placeholder={
132
+ busy ? "수정 중…" : '어떻게 바꿀까요? (예: "더 힘있게", "톤을 통일해서")'
133
+ }
134
+ disabled={busy}
135
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
136
+ />
137
+ <button
138
+ type="button"
139
+ onClick={send}
140
+ disabled={busy || !input.trim()}
141
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40"
142
+ aria-label="프롬프트로 수정"
143
+ >
144
+ {busy ? (
145
+ <Loader2 size={15} className="animate-spin" />
146
+ ) : (
147
+ <ArrowUp size={15} />
148
+ )}
149
+ </button>
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
4
+ import { observer } from "./canvas-observer";
5
+
6
+ import {
7
+ elementClientRect,
8
+ type RectElement,
9
+ } from "./element-rects";
10
+ import { useHoveredLayerId } from "./hovered-layer";
11
+
12
+ /**
13
+ * Green outline on the canvas for the layer the pointer is over in the layers tree.
14
+ *
15
+ * The decomposed chart is a stack of a dozen rows all reading "도형" — the only way
16
+ * to tell which is which is to see it light up. Deliberately GREEN and fill-tinted,
17
+ * so it never reads as "selected" (blue box + handles); it is a preview, not a
18
+ * state, and it takes no pointer events.
19
+ */
20
+
21
+ const ACCENT = "rgb(16, 185, 129)";
22
+
23
+ type Store = { getElementById?: (id: string) => RectElement | undefined };
24
+
25
+ type Box = { left: number; top: number; width: number; height: number };
26
+
27
+ export const HoverHighlightOverlay = observer(function HoverHighlightOverlay({
28
+ store,
29
+ containerRef,
30
+ scrollRef,
31
+ }: {
32
+ store: unknown;
33
+ containerRef: RefObject<HTMLDivElement | null>;
34
+ scrollRef: RefObject<HTMLDivElement | null>;
35
+ }) {
36
+ const s = store as Store;
37
+ const hoveredId = useHoveredLayerId();
38
+ const [box, setBox] = useState<Box | null>(null);
39
+ const frame = useRef<number | null>(null);
40
+
41
+ const measure = () => {
42
+ const host = containerRef.current;
43
+ const el = hoveredId ? s.getElementById?.(hoveredId) : undefined;
44
+ if (!host || !el) {
45
+ setBox((prev) => (prev ? null : prev));
46
+ return;
47
+ }
48
+ const r = elementClientRect(el);
49
+ if (!r) {
50
+ setBox(null);
51
+ return;
52
+ }
53
+ const h = host.getBoundingClientRect();
54
+ setBox({
55
+ left: r.left - h.left,
56
+ top: r.top - h.top,
57
+ width: r.right - r.left,
58
+ height: r.bottom - r.top,
59
+ });
60
+ };
61
+
62
+ useLayoutEffect(() => {
63
+ const raf = requestAnimationFrame(measure);
64
+ return () => cancelAnimationFrame(raf);
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [hoveredId]);
67
+
68
+ // Keep it pinned while the user scrolls the stack with the tree still hovered.
69
+ useEffect(() => {
70
+ if (!hoveredId) return;
71
+ const scroller = scrollRef.current;
72
+ const onMove = () => {
73
+ if (frame.current !== null) return;
74
+ frame.current = requestAnimationFrame(() => {
75
+ frame.current = null;
76
+ measure();
77
+ });
78
+ };
79
+ scroller?.addEventListener("scroll", onMove, { passive: true });
80
+ window.addEventListener("resize", onMove);
81
+ return () => {
82
+ scroller?.removeEventListener("scroll", onMove);
83
+ window.removeEventListener("resize", onMove);
84
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
85
+ frame.current = null;
86
+ };
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [hoveredId, scrollRef]);
89
+
90
+ if (!box) return null;
91
+
92
+ return (
93
+ <div
94
+ data-dp-hover-highlight=""
95
+ style={{
96
+ position: "absolute",
97
+ left: box.left,
98
+ top: box.top,
99
+ width: box.width,
100
+ height: box.height,
101
+ border: `2px solid ${ACCENT}`,
102
+ background: "rgba(16, 185, 129, 0.12)",
103
+ boxSizing: "border-box",
104
+ borderRadius: 2,
105
+ pointerEvents: "none",
106
+ zIndex: 19, // under the selection overlay (20), over the canvas
107
+ }}
108
+ />
109
+ );
110
+ });
111
+ HoverHighlightOverlay.displayName = "HoverHighlightOverlay";
@@ -0,0 +1,34 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Which layer the pointer is over in the layers tree.
5
+ *
6
+ * The tree and the canvas live in different React subtrees (sidebar panel vs
7
+ * workspace) with no common provider between them, and this is transient pointer
8
+ * state that must never enter the Canvas document or its undo history — so it
9
+ * lives in a tiny external store instead of the element model.
10
+ */
11
+
12
+ let hovered: string | null = null;
13
+ const listeners = new Set<() => void>();
14
+
15
+ export function setHoveredLayerId(id: string | null): void {
16
+ if (id === hovered) return;
17
+ hovered = id;
18
+ for (const l of listeners) l();
19
+ }
20
+
21
+ export function getHoveredLayerId(): string | null {
22
+ return hovered;
23
+ }
24
+
25
+ function subscribe(l: () => void): () => void {
26
+ listeners.add(l);
27
+ return () => {
28
+ listeners.delete(l);
29
+ };
30
+ }
31
+
32
+ export function useHoveredLayerId(): string | null {
33
+ return useSyncExternalStore(subscribe, getHoveredLayerId, () => null);
34
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * 우측 인스펙터가 공유하는 작은 컨트롤들.
7
+ *
8
+ * ``detail-page-properties-panel``에 있던 것을 그대로 옮겼다. 차트 인스펙터가 같은 것을
9
+ * 써야 하는데, 패널이 차트 인스펙터를 import하고 차트 인스펙터가 다시 패널을 import하면
10
+ * 순환이 된다.
11
+ */
12
+
13
+ export function Section({
14
+ title,
15
+ children,
16
+ }: {
17
+ title: string;
18
+ children: React.ReactNode;
19
+ }) {
20
+ return (
21
+ <section className="border-t border-neutral-200 px-4 py-3 first:border-t-0">
22
+ <h4 className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
23
+ {title}
24
+ </h4>
25
+ {children}
26
+ </section>
27
+ );
28
+ }
29
+
30
+ /** Numeric field: no native spinners, commits on blur/Enter, arrow-key stepping. */
31
+ export function NumberField({
32
+ value,
33
+ onChange,
34
+ step = 1,
35
+ min,
36
+ max,
37
+ suffix,
38
+ label,
39
+ }: {
40
+ value: number;
41
+ onChange: (v: number) => void;
42
+ step?: number;
43
+ min?: number;
44
+ max?: number;
45
+ suffix?: string;
46
+ // 지정하면 이 라벨이 피그마식 드래그 스크럽 핸들이 된다(좌우 드래그로 값 증감).
47
+ label?: string;
48
+ }) {
49
+ const [local, setLocal] = useState(String(value));
50
+ useEffect(() => {
51
+ setLocal(String(Math.round(value * 100) / 100));
52
+ }, [value]);
53
+
54
+ const clamp = (n: number) =>
55
+ Math.max(min ?? -Infinity, Math.min(max ?? Infinity, n));
56
+ const precision = String(step).split(".")[1]?.length ?? 0;
57
+ const round = (n: number) => Number(clamp(n).toFixed(precision));
58
+ const commit = () => {
59
+ const n = Number(local);
60
+ if (local !== "" && !Number.isNaN(n)) onChange(clamp(n));
61
+ else setLocal(String(value));
62
+ };
63
+ const stepBy = (dir: number) => {
64
+ const base = Number(local);
65
+ const rounded = round((Number.isNaN(base) ? value : base) + dir * step);
66
+ setLocal(String(rounded));
67
+ onChange(rounded);
68
+ };
69
+
70
+ // 드래그 스크럽: 라벨을 누른 채 좌우로 끌면 1px당 step, Shift는 ×10.
71
+ const drag = useRef<{ x: number; base: number; moved: boolean } | null>(null);
72
+ const onScrubDown = (e: React.PointerEvent) => {
73
+ if (e.button !== 0) return;
74
+ e.preventDefault();
75
+ const base = Number(local);
76
+ drag.current = {
77
+ x: e.clientX,
78
+ base: Number.isNaN(base) ? value : base,
79
+ moved: false,
80
+ };
81
+ try {
82
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
83
+ } catch {
84
+ /* 캡처 미지원 환경 */
85
+ }
86
+ };
87
+ const onScrubMove = (e: React.PointerEvent) => {
88
+ const d = drag.current;
89
+ if (!d) return;
90
+ const dx = e.clientX - d.x;
91
+ if (!d.moved && Math.abs(dx) < 2) return;
92
+ d.moved = true;
93
+ const next = round(d.base + dx * step * (e.shiftKey ? 10 : 1));
94
+ setLocal(String(next));
95
+ onChange(next);
96
+ };
97
+ const onScrubUp = (e: React.PointerEvent) => {
98
+ if (!drag.current) return;
99
+ drag.current = null;
100
+ try {
101
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
102
+ } catch {
103
+ /* capture 이미 해제됨 */
104
+ }
105
+ };
106
+
107
+ return (
108
+ <div className="flex items-center rounded-md border border-neutral-200 bg-white pr-2 focus-within:border-neutral-400">
109
+ {label ? (
110
+ <span
111
+ aria-hidden="true"
112
+ onPointerDown={onScrubDown}
113
+ onPointerMove={onScrubMove}
114
+ onPointerUp={onScrubUp}
115
+ onPointerCancel={onScrubUp}
116
+ className="cursor-ew-resize select-none touch-none py-1.5 pl-2 pr-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-600"
117
+ title={`${label} — 좌우로 드래그해 값 조절 (Shift ×10)`}
118
+ >
119
+ {label}
120
+ </span>
121
+ ) : (
122
+ <span className="pl-2" />
123
+ )}
124
+ <input
125
+ type="text"
126
+ inputMode="decimal"
127
+ value={local}
128
+ onChange={(e) => setLocal(e.target.value)}
129
+ onBlur={commit}
130
+ onKeyDown={(e) => {
131
+ if (e.key === "Enter") {
132
+ commit();
133
+ (e.target as HTMLInputElement).blur();
134
+ } else if (e.key === "ArrowUp") {
135
+ e.preventDefault();
136
+ stepBy(1);
137
+ } else if (e.key === "ArrowDown") {
138
+ e.preventDefault();
139
+ stepBy(-1);
140
+ }
141
+ }}
142
+ className="w-full min-w-0 bg-transparent py-1.5 text-sm tabular-nums text-neutral-900 outline-none"
143
+ />
144
+ {suffix ? <span className="ml-1 text-xs text-neutral-400">{suffix}</span> : null}
145
+ </div>
146
+ );
147
+ }
148
+
149
+ export function ToggleButton({
150
+ active,
151
+ onClick,
152
+ title,
153
+ children,
154
+ }: {
155
+ active: boolean;
156
+ onClick: () => void;
157
+ title: string;
158
+ children: React.ReactNode;
159
+ }) {
160
+ return (
161
+ <button
162
+ type="button"
163
+ title={title}
164
+ onClick={onClick}
165
+ className={[
166
+ "flex h-8 flex-1 items-center justify-center rounded-md border transition-colors",
167
+ active
168
+ ? "border-neutral-800 bg-neutral-900 text-white"
169
+ : "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50",
170
+ ].join(" ")}
171
+ >
172
+ {children}
173
+ </button>
174
+ );
175
+ }