@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,380 @@
1
+ "use client";
2
+
3
+ import { Pipette } from "lucide-react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import { useTranslation } from "react-i18next";
7
+
8
+ // ── Preset palette — popular colors for cardnews/social media ──────────────
9
+ const PRESET_COLORS = [
10
+ // Row 1: Neutrals
11
+ "#000000", "#333333", "#666666", "#999999", "#CCCCCC", "#FFFFFF",
12
+ // Row 2: Warm
13
+ "#FF0000", "#FF4444", "#FF8800", "#FFBB00", "#FFEE00", "#FFE4E1",
14
+ // Row 3: Cool
15
+ "#0066FF", "#00AAFF", "#00CCCC", "#00CC66", "#00AA00", "#E0F0FF",
16
+ // Row 4: Purple/Pink
17
+ "#6600CC", "#9933FF", "#CC66FF", "#FF66CC", "#FF3399", "#F5E6FF",
18
+ // Row 5: Earth/Brand
19
+ "#8B4513", "#D2691E", "#F4A460", "#2F4F4F", "#1A1A2E", "#FFF8DC",
20
+ ];
21
+
22
+ const RECENT_COLORS_KEY = "leviosa-recent-colors";
23
+ const MAX_RECENT = 8;
24
+ const POPOVER_WIDTH = 224; // w-56 = 14rem = 224px
25
+ const POPOVER_HEIGHT_ESTIMATE = 320;
26
+
27
+ /** Checkerboard SVG data URI for transparent backgrounds */
28
+ export const CHECKER_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' y='4' width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' width='4' height='4' fill='%23fff'/%3E%3Crect y='4' width='4' height='4' fill='%23fff'/%3E%3C/svg%3E")`;
29
+
30
+ function getRecentColors(): string[] {
31
+ try {
32
+ const stored = localStorage.getItem(RECENT_COLORS_KEY);
33
+ return stored ? JSON.parse(stored) : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function normalizeRecentColor(color: string): string {
40
+ return color.trim().toUpperCase();
41
+ }
42
+
43
+ function addRecentColor(color: string): string[] {
44
+ const hex = normalizeRecentColor(color);
45
+ if (hex === "TRANSPARENT") return getRecentColors(); // don't store transparent in recents
46
+ const recent = getRecentColors()
47
+ .map(normalizeRecentColor)
48
+ .filter((c) => c !== hex);
49
+ recent.unshift(hex);
50
+ if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
51
+ try { localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(recent)); } catch { /* */ }
52
+ return recent;
53
+ }
54
+
55
+ /** Calculate popover position with viewport boundary clamping */
56
+ function calcPopoverPos(
57
+ anchorRect: DOMRect,
58
+ align: "start" | "end",
59
+ side: "auto" | "top" | "bottom",
60
+ ): { top: number; left: number } {
61
+ const vw = window.innerWidth;
62
+ const vh = window.innerHeight;
63
+
64
+ let top = side === "top"
65
+ ? anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6
66
+ : anchorRect.bottom + 6;
67
+ let left = align === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
68
+
69
+ // Flip upward if overflowing bottom
70
+ if (side === "auto" && top + POPOVER_HEIGHT_ESTIMATE > vh) {
71
+ top = anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6;
72
+ }
73
+ // Clamp to viewport edges
74
+ if (top < 4) top = 4;
75
+ if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
76
+ if (left < 4) left = 4;
77
+
78
+ return { top, left };
79
+ }
80
+
81
+ // ── Global: close other ColorInput popovers when a new one opens ──────────
82
+ const colorInputCloseCallbacks = new Set<() => void>();
83
+
84
+ function isTransparent(v: string): boolean {
85
+ return v === "transparent" || v === "rgba(0,0,0,0)" || v === "rgba(0, 0, 0, 0)";
86
+ }
87
+
88
+ /**
89
+ * Normalize any CSS colour the editor stores — ``rgb(255, 78, 154)`` from the
90
+ * decomposer, a named colour, a 3- or 6-digit hex — to a ``#rrggbb`` string.
91
+ * The swatch, preset/recent highlight, and hex field all expect hex, so a raw
92
+ * ``rgb(...)`` fill was falling through to ``#000000`` and painting the picker
93
+ * black even though the canvas rendered the real colour. Uses the browser's own
94
+ * parser (canvas ``fillStyle`` round-trip) so every valid CSS colour resolves;
95
+ * an invalid value or SSR falls back to black.
96
+ */
97
+ function toHexColor(value: string): string {
98
+ const v = value.trim();
99
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
100
+ if (/^#[0-9a-fA-F]{3}$/.test(v)) {
101
+ return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`.toLowerCase();
102
+ }
103
+ // The decomposer/Canvas store colours as ``rgb(r, g, b)`` / ``rgba(...)``.
104
+ // Parse those directly so the picker works without a canvas (and in tests);
105
+ // the alpha channel is dropped since the swatch/hex field are solid-only.
106
+ const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
107
+ if (rgb) {
108
+ const hex = (n: string) => Math.min(255, Number(n)).toString(16).padStart(2, "0");
109
+ return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`.toLowerCase();
110
+ }
111
+ // Named colours ("black", "tomato", …): let the browser resolve them.
112
+ if (typeof document !== "undefined") {
113
+ const ctx = document.createElement("canvas").getContext("2d");
114
+ if (ctx) {
115
+ ctx.fillStyle = "#000000";
116
+ ctx.fillStyle = v;
117
+ const resolved = ctx.fillStyle;
118
+ if (typeof resolved === "string" && resolved.startsWith("#")) {
119
+ return resolved.toLowerCase();
120
+ }
121
+ }
122
+ }
123
+ return "#000000";
124
+ }
125
+
126
+ interface ColorInputProps {
127
+ value: string;
128
+ onChange: (color: string) => void;
129
+ /** Size of the swatch button (default: "sm") */
130
+ size?: "sm" | "md";
131
+ /** Allow transparent color selection (default: false) */
132
+ allowTransparent?: boolean;
133
+ /** Horizontal popover alignment relative to the swatch (default: "end") */
134
+ popoverAlign?: "start" | "end";
135
+ /** Vertical popover placement preference (default: "auto") */
136
+ popoverSide?: "auto" | "top" | "bottom";
137
+ }
138
+
139
+ /**
140
+ * Color picker with swatch button → popover (presets, recent, eyedropper, hex, native picker).
141
+ */
142
+ export function ColorInput({
143
+ value,
144
+ onChange,
145
+ size = "sm",
146
+ allowTransparent = false,
147
+ popoverAlign = "end",
148
+ popoverSide = "auto",
149
+ }: ColorInputProps) {
150
+ const { t } = useTranslation("marketing");
151
+ const [open, setOpen] = useState(false);
152
+ const [measured, setMeasured] = useState(false);
153
+ const [hexInput, setHexInput] = useState("");
154
+ const [recentColors, setRecentColors] = useState<string[]>([]);
155
+ const containerRef = useRef<HTMLDivElement>(null);
156
+ const popoverRef = useRef<HTMLDivElement>(null);
157
+ const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null);
158
+ const swatchPx = size === "md" ? "w-8 h-8" : "w-7 h-7";
159
+
160
+ const isTransparentValue = isTransparent(value);
161
+ // Resolve the stored colour (which may be ``rgb(...)``/named) to hex so the
162
+ // swatch and hex field show the real colour instead of falling back to black.
163
+ const safeColor = useMemo(
164
+ () => (isTransparentValue ? "transparent" : toHexColor(value)),
165
+ [isTransparentValue, value],
166
+ );
167
+
168
+ const closePopover = useCallback(() => setOpen(false), []);
169
+
170
+ // Register/unregister close callback for mutual exclusion
171
+ useEffect(() => {
172
+ colorInputCloseCallbacks.add(closePopover);
173
+ return () => { colorInputCloseCallbacks.delete(closePopover); };
174
+ }, [closePopover]);
175
+
176
+ const openPopover = useCallback(() => {
177
+ // Close all other open ColorInput popovers
178
+ colorInputCloseCallbacks.forEach((cb) => { if (cb !== closePopover) cb(); });
179
+
180
+ setRecentColors(getRecentColors());
181
+ setHexInput(isTransparentValue ? "" : safeColor);
182
+ setMeasured(false);
183
+ if (containerRef.current) {
184
+ setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
185
+ }
186
+ setOpen(true);
187
+ }, [safeColor, isTransparentValue, closePopover, popoverAlign, popoverSide]);
188
+
189
+ // Track scroll & resize to reposition popover
190
+ useEffect(() => {
191
+ if (!open) return;
192
+ const reposition = () => {
193
+ if (containerRef.current) {
194
+ setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
195
+ }
196
+ };
197
+ // Capture phase to catch scrolls in any container (e.g. layer panel overflow-y-auto)
198
+ window.addEventListener("scroll", reposition, true);
199
+ window.addEventListener("resize", reposition);
200
+ return () => {
201
+ window.removeEventListener("scroll", reposition, true);
202
+ window.removeEventListener("resize", reposition);
203
+ };
204
+ }, [open, popoverAlign, popoverSide]);
205
+
206
+ // After popover renders, re-measure actual height and adjust position
207
+ useEffect(() => {
208
+ if (!open || !popoverRef.current || !containerRef.current) return;
209
+ const popoverEl = popoverRef.current;
210
+ const anchorRect = containerRef.current.getBoundingClientRect();
211
+ const actualHeight = popoverEl.offsetHeight;
212
+ const vh = window.innerHeight;
213
+ const vw = window.innerWidth;
214
+
215
+ let top = popoverSide === "top"
216
+ ? anchorRect.top - actualHeight - 6
217
+ : anchorRect.bottom + 6;
218
+ let left = popoverAlign === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
219
+
220
+ if (popoverSide === "auto" && top + actualHeight > vh) {
221
+ top = anchorRect.top - actualHeight - 6;
222
+ }
223
+ if (top < 4) top = 4;
224
+ if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
225
+ if (left < 4) left = 4;
226
+
227
+ // eslint-disable-next-line react-hooks/set-state-in-effect
228
+ setPopoverPos({ top, left });
229
+ setMeasured(true);
230
+ }, [open, popoverAlign, popoverSide]);
231
+
232
+ // Close on outside click (check both swatch container and portal popover)
233
+ useEffect(() => {
234
+ if (!open) return;
235
+ const handle = (e: MouseEvent | TouchEvent) => {
236
+ const target = e.target as Node;
237
+ if (
238
+ containerRef.current && !containerRef.current.contains(target) &&
239
+ popoverRef.current && !popoverRef.current.contains(target)
240
+ ) {
241
+ setOpen(false);
242
+ }
243
+ };
244
+ document.addEventListener("mousedown", handle);
245
+ document.addEventListener("touchstart", handle);
246
+ return () => {
247
+ document.removeEventListener("mousedown", handle);
248
+ document.removeEventListener("touchstart", handle);
249
+ };
250
+ }, [open]);
251
+
252
+ const selectColor = useCallback((color: string) => {
253
+ onChange(color);
254
+ setRecentColors(addRecentColor(color));
255
+ setHexInput(isTransparent(color) ? "" : color.toUpperCase());
256
+ }, [onChange]);
257
+
258
+ const openEyeDropper = useCallback(async () => {
259
+ try {
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
261
+ const dropper = new (window as any).EyeDropper();
262
+ const result = await dropper.open();
263
+ selectColor(result.sRGBHex);
264
+ } catch { /* cancelled */ }
265
+ }, [selectColor]);
266
+
267
+ const handleHexSubmit = useCallback(() => {
268
+ let hex = hexInput.trim();
269
+ if (!hex.startsWith("#")) hex = "#" + hex;
270
+ if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
271
+ selectColor(hex);
272
+ }
273
+ }, [hexInput, selectColor]);
274
+
275
+ return (
276
+ <div ref={containerRef} className="relative inline-block">
277
+ {/* Swatch button */}
278
+ <button
279
+ type="button"
280
+ onClick={() => open ? setOpen(false) : openPopover()}
281
+ className={`${swatchPx} rounded-lg border-2 border-[var(--color-border)] cursor-pointer shrink-0 transition-all hover:border-[var(--color-primary)] hover:shadow-md hover:scale-105 active:scale-95`}
282
+ style={{
283
+ backgroundColor: isTransparentValue ? undefined : safeColor,
284
+ backgroundImage: isTransparentValue ? CHECKER_BG : undefined,
285
+ backgroundSize: isTransparentValue ? "8px 8px" : undefined,
286
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.1)",
287
+ }}
288
+ title={isTransparentValue ? "transparent" : safeColor}
289
+ />
290
+
291
+ {/* Popover — rendered via portal to escape overflow:hidden containers */}
292
+ {open && popoverPos && createPortal(
293
+ <div
294
+ ref={popoverRef}
295
+ data-layer-panel
296
+ className={`fixed z-[200] w-56 bg-[var(--color-background)] border border-[var(--color-border)] rounded-xl shadow-lg p-3 space-y-2.5 transition-opacity duration-75 ${measured ? "opacity-100" : "opacity-0 pointer-events-none"}`}
297
+ style={{ top: popoverPos.top, left: popoverPos.left }}
298
+ >
299
+ {/* Transparent + Preset palette */}
300
+ <div className="grid grid-cols-6 gap-1.5">
301
+ {allowTransparent && (
302
+ <button
303
+ type="button"
304
+ onClick={() => selectColor("transparent")}
305
+ className={`w-7 h-7 rounded-md border transition-transform hover:scale-110 active:scale-95 relative overflow-hidden ${
306
+ isTransparentValue ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
307
+ }`}
308
+ style={{ backgroundImage: CHECKER_BG, backgroundSize: "8px 8px" }}
309
+ title={t("layer.transparent")}
310
+ >
311
+ {/* Diagonal line to indicate "none/transparent" */}
312
+ <span className="absolute inset-0 flex items-center justify-center">
313
+ <span className="block w-[130%] h-px bg-red-500 rotate-45" />
314
+ </span>
315
+ </button>
316
+ )}
317
+ {PRESET_COLORS.map((color) => (
318
+ <button
319
+ key={color}
320
+ type="button"
321
+ onClick={() => selectColor(color)}
322
+ className={`w-7 h-7 rounded-md border transition-transform hover:scale-110 active:scale-95 ${
323
+ !isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
324
+ }`}
325
+ style={{ backgroundColor: color }}
326
+ />
327
+ ))}
328
+ </div>
329
+
330
+ {/* Recent colors */}
331
+ <div>
332
+ <span className="text-[10px] text-[var(--color-text-secondary)] block mb-1">{t("cardnews.colorInput.recent")}</span>
333
+ <div className="flex gap-1.5">
334
+ {Array.from({ length: 8 }, (_, idx) => {
335
+ const color = recentColors[idx];
336
+ return color ? (
337
+ <button
338
+ key={color}
339
+ type="button"
340
+ onClick={() => selectColor(color)}
341
+ className={`size-5 rounded-md border transition-transform hover:scale-110 active:scale-95 ${
342
+ !isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
343
+ }`}
344
+ style={{ backgroundColor: color }}
345
+ />
346
+ ) : (
347
+ <span key={idx} className="size-5 rounded-md border border-transparent" />
348
+ );
349
+ })}
350
+ </div>
351
+ </div>
352
+
353
+ {/* Bottom row: Eyedropper + HEX input */}
354
+ <div className="flex items-center gap-1.5 pt-1 border-t border-[var(--color-border)]">
355
+ {"EyeDropper" in globalThis && (
356
+ <button
357
+ type="button"
358
+ onClick={openEyeDropper}
359
+ className="flex shrink-0 items-center gap-1 rounded-lg px-1.5 py-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-primary)]"
360
+ title={t("cardnews.colorInput.eyedropper")}
361
+ >
362
+ <Pipette className="w-3.5 h-3.5" />
363
+ <span>{t("cardnews.colorInput.eyedropper")}</span>
364
+ </button>
365
+ )}
366
+ <input
367
+ type="text"
368
+ value={hexInput}
369
+ onChange={(e) => setHexInput(e.target.value)}
370
+ onKeyDown={(e) => e.key === "Enter" && handleHexSubmit()}
371
+ placeholder="#000000"
372
+ className="min-w-0 flex-1 rounded-lg border border-[var(--color-border)] px-2 py-1.5 text-xs font-mono focus:outline-none focus:border-[var(--color-primary)]"
373
+ />
374
+ </div>
375
+ </div>,
376
+ document.body
377
+ )}
378
+ </div>
379
+ );
380
+ }