@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.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- 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
|
+
}
|