@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QR·바코드 → SVG. **전부 브라우저 안에서 끝난다** — 서버도, 크레딧도 안 든다.
|
|
3
|
+
*
|
|
4
|
+
* 나오는 마크업은 도형과 같은 규약이다: `<svg viewBox>` 하나에 색은 속성으로만 단다.
|
|
5
|
+
* 그래야 삽입 경로(`insertShape`)도, 우측 색 컨트롤(`svg-colors.ts`)도 그대로 먹는다.
|
|
6
|
+
*
|
|
7
|
+
* 모듈 하나를 `<rect>` 하나로 그리면 요소가 수백 개가 되고 색 스와치도 수백 개로 읽힌다.
|
|
8
|
+
* 그래서 **검은 모듈 전체를 `<path>` 하나로 합친다** — 색은 배경과 전경 둘뿐이다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import qrcode from "qrcode-generator";
|
|
12
|
+
|
|
13
|
+
export type CodeColors = {
|
|
14
|
+
/** 모듈·막대 색. */
|
|
15
|
+
foreground: string;
|
|
16
|
+
/** 여백 색. `"none"`이면 투명하게 둔다. */
|
|
17
|
+
background: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_COLORS: CodeColors = { foreground: "#000000", background: "#ffffff" };
|
|
21
|
+
|
|
22
|
+
/** 스캐너가 요구하는 여백. QR은 4모듈, EAN-13은 좌우 9·7모듈이 규격이다. */
|
|
23
|
+
const QR_QUIET_ZONE = 4;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* QR 코드 SVG.
|
|
27
|
+
*
|
|
28
|
+
* 오류정정은 M(약 15% 복원)으로 고정한다. 상세페이지의 QR은 인쇄물이 아니라 화면이나
|
|
29
|
+
* 종이 한 장이라 H까지 올릴 이유가 없고, 올리면 모듈이 촘촘해져 작게 넣었을 때 오히려
|
|
30
|
+
* 안 읽힌다. 버전은 0(자동) — 내용 길이에 맞춰 알아서 고른다.
|
|
31
|
+
*/
|
|
32
|
+
export function qrCodeSvg(
|
|
33
|
+
text: string,
|
|
34
|
+
colors: CodeColors = DEFAULT_COLORS,
|
|
35
|
+
): { markup: string; viewBox: string } | null {
|
|
36
|
+
const value = text.trim();
|
|
37
|
+
if (!value) return null;
|
|
38
|
+
|
|
39
|
+
let modules: boolean[][];
|
|
40
|
+
try {
|
|
41
|
+
const qr = qrcode(0, "M");
|
|
42
|
+
qr.addData(value);
|
|
43
|
+
qr.make();
|
|
44
|
+
const count = qr.getModuleCount();
|
|
45
|
+
modules = Array.from({ length: count }, (_, row) =>
|
|
46
|
+
Array.from({ length: count }, (_, col) => qr.isDark(row, col)),
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
// 내용이 너무 길어 버전 40에도 안 들어가는 경우.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const count = modules.length;
|
|
54
|
+
const size = count + QR_QUIET_ZONE * 2;
|
|
55
|
+
const path = modulesToPath(modules, QR_QUIET_ZONE);
|
|
56
|
+
const viewBox = `0 0 ${size} ${size}`;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
viewBox,
|
|
60
|
+
markup:
|
|
61
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">` +
|
|
62
|
+
backgroundRect(size, size, colors.background) +
|
|
63
|
+
`<path fill="${colors.foreground}" d="${path}"/>` +
|
|
64
|
+
`</svg>`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 어두운 모듈을 가로로 이어 붙여 한 path로. 인접한 칸을 합쳐 경로 길이를 줄인다. */
|
|
69
|
+
function modulesToPath(modules: boolean[][], offset: number): string {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
for (let row = 0; row < modules.length; row += 1) {
|
|
72
|
+
let col = 0;
|
|
73
|
+
while (col < modules[row].length) {
|
|
74
|
+
if (!modules[row][col]) {
|
|
75
|
+
col += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
let run = 1;
|
|
79
|
+
while (col + run < modules[row].length && modules[row][col + run]) run += 1;
|
|
80
|
+
parts.push(`M${col + offset} ${row + offset}h${run}v1h-${run}z`);
|
|
81
|
+
col += run;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parts.join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function backgroundRect(width: number, height: number, color: string): string {
|
|
88
|
+
if (!color || color === "none") return "";
|
|
89
|
+
return `<rect width="${width}" height="${height}" fill="${color}"/>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── EAN-13 ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* EAN-13만 낸다. CODE128·CODE39는 물류 라벨용이라 상세페이지에 실릴 일이 없고,
|
|
96
|
+
* 국내 상품 바코드(KAN)가 EAN-13이다. 필요해지면 여기 옆에 붙인다.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/** A(왼쪽 홀수 패리티) 집합. R은 이것의 보수, G는 R을 뒤집은 것이다. */
|
|
100
|
+
const EAN_L = [
|
|
101
|
+
"0001101", "0011001", "0010011", "0111101", "0100011",
|
|
102
|
+
"0110001", "0101111", "0111011", "0110111", "0001011",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const EAN_R = EAN_L.map((bits) =>
|
|
106
|
+
[...bits].map((bit) => (bit === "0" ? "1" : "0")).join(""),
|
|
107
|
+
);
|
|
108
|
+
const EAN_G = EAN_R.map((bits) => [...bits].reverse().join(""));
|
|
109
|
+
|
|
110
|
+
/** 첫 자리가 왼쪽 여섯 자리의 A/G 배치를 정한다 — 13번째 자리는 막대로 안 그려진다. */
|
|
111
|
+
const EAN_PARITY = [
|
|
112
|
+
"LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG",
|
|
113
|
+
"LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL",
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** 오른쪽에서부터 1·3 가중치를 번갈아 곱해 10의 보수를 취한다. */
|
|
117
|
+
export function ean13CheckDigit(digits12: string): number | null {
|
|
118
|
+
if (!/^\d{12}$/.test(digits12)) return null;
|
|
119
|
+
let sum = 0;
|
|
120
|
+
for (let i = 0; i < 12; i += 1) {
|
|
121
|
+
// 왼쪽부터 0-index 짝수 자리가 가중치 1이다(오른쪽에서 홀수 번째).
|
|
122
|
+
sum += Number(digits12[i]) * (i % 2 === 0 ? 1 : 3);
|
|
123
|
+
}
|
|
124
|
+
return (10 - (sum % 10)) % 10;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 12자리면 체크디짓을 붙이고, 13자리면 체크디짓이 맞는지 본다. */
|
|
128
|
+
export function normalizeEan13(input: string): string | null {
|
|
129
|
+
const digits = input.replace(/\D/g, "");
|
|
130
|
+
if (digits.length === 12) {
|
|
131
|
+
const check = ean13CheckDigit(digits);
|
|
132
|
+
return check === null ? null : `${digits}${check}`;
|
|
133
|
+
}
|
|
134
|
+
if (digits.length === 13) {
|
|
135
|
+
const check = ean13CheckDigit(digits.slice(0, 12));
|
|
136
|
+
return check !== null && check === Number(digits[12]) ? digits : null;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* EAN-13 SVG. 막대 95모듈 + 좌우 여백(9·7모듈)이 규격이고, 숫자를 아래에 얹는다.
|
|
143
|
+
*
|
|
144
|
+
* 가드 막대(양끝·가운데)는 숫자 영역까지 내려온다 — 장식이 아니라 스캐너가 시작·끝을
|
|
145
|
+
* 잡는 자리라 길이가 규격이다.
|
|
146
|
+
*/
|
|
147
|
+
export function ean13Svg(
|
|
148
|
+
input: string,
|
|
149
|
+
colors: CodeColors = DEFAULT_COLORS,
|
|
150
|
+
): { markup: string; viewBox: string; value: string } | null {
|
|
151
|
+
const value = normalizeEan13(input);
|
|
152
|
+
if (!value) return null;
|
|
153
|
+
|
|
154
|
+
const parity = EAN_PARITY[Number(value[0])];
|
|
155
|
+
let bits = "101";
|
|
156
|
+
for (let i = 0; i < 6; i += 1) {
|
|
157
|
+
const digit = Number(value[i + 1]);
|
|
158
|
+
bits += parity[i] === "L" ? EAN_L[digit] : EAN_G[digit];
|
|
159
|
+
}
|
|
160
|
+
bits += "01010";
|
|
161
|
+
for (let i = 0; i < 6; i += 1) bits += EAN_R[Number(value[i + 7])];
|
|
162
|
+
bits += "101";
|
|
163
|
+
|
|
164
|
+
const LEFT_QUIET = 11;
|
|
165
|
+
const RIGHT_QUIET = 7;
|
|
166
|
+
const BAR_HEIGHT = 68;
|
|
167
|
+
const GUARD_EXTRA = 5;
|
|
168
|
+
const TEXT_BASELINE = BAR_HEIGHT + GUARD_EXTRA + 8;
|
|
169
|
+
const width = LEFT_QUIET + bits.length + RIGHT_QUIET;
|
|
170
|
+
const height = TEXT_BASELINE + 3;
|
|
171
|
+
|
|
172
|
+
// 가드 막대가 서 있는 칸(비트 인덱스). 이 칸만 아래로 더 내려온다.
|
|
173
|
+
// 시작 3 · 왼쪽 42 · 가운데 5 · 오른쪽 42 · 끝 3 = 95모듈.
|
|
174
|
+
const guards = new Set<number>();
|
|
175
|
+
for (const [start, len] of [
|
|
176
|
+
[0, 3],
|
|
177
|
+
[45, 5],
|
|
178
|
+
[92, 3],
|
|
179
|
+
] as const) {
|
|
180
|
+
for (let i = 0; i < len; i += 1) guards.add(start + i);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bars: string[] = [];
|
|
184
|
+
for (let i = 0; i < bits.length; i += 1) {
|
|
185
|
+
if (bits[i] !== "1") continue;
|
|
186
|
+
const tall = guards.has(i);
|
|
187
|
+
bars.push(
|
|
188
|
+
`M${LEFT_QUIET + i} 0h1v${tall ? BAR_HEIGHT + GUARD_EXTRA : BAR_HEIGHT}h-1z`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const viewBox = `0 0 ${width} ${height}`;
|
|
193
|
+
// 숫자는 `monospace` 한 가지만 쓴다. 이 마크업은 data URI로 `<img>`에 실려 그려지므로
|
|
194
|
+
// 문서의 `@font-face`가 안 닿는다(차트 기획서에서 같은 이유로 SVG 라벨을 포기했다).
|
|
195
|
+
// 시스템마다 글꼴이 갈릴 수 있지만 스캐너가 읽는 것은 막대지 숫자가 아니다.
|
|
196
|
+
const digits = (text: string, x: number, anchor: string) =>
|
|
197
|
+
`<text x="${x}" y="${TEXT_BASELINE}" fill="${colors.foreground}" ` +
|
|
198
|
+
`font-family="monospace" font-size="9" text-anchor="${anchor}">${text}</text>`;
|
|
199
|
+
const label =
|
|
200
|
+
digits(value[0], LEFT_QUIET - 2, "end") +
|
|
201
|
+
digits(value.slice(1, 7), LEFT_QUIET + 24, "middle") +
|
|
202
|
+
digits(value.slice(7), LEFT_QUIET + 71, "middle");
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
value,
|
|
206
|
+
viewBox,
|
|
207
|
+
markup:
|
|
208
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">` +
|
|
209
|
+
backgroundRect(width, height, colors.background) +
|
|
210
|
+
`<path fill="${colors.foreground}" d="${bars.join("")}"/>` +
|
|
211
|
+
label +
|
|
212
|
+
`</svg>`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 첨부한 레퍼런스 사진을 **모델에 보낼 크기**로 줄인다.
|
|
3
|
+
*
|
|
4
|
+
* 유저가 붙이는 것은 대개 남의 상세페이지 스크린샷이라 길이가 몇천 픽셀이고 파일도
|
|
5
|
+
* 수 MB다. 그대로 보내면 요청 본문이 통째로 커지는데, 정작 모델이 읽는 것은 배치와
|
|
6
|
+
* 구성뿐이라 긴 변 1,024px 이면 충분하다. 비용도 여기서 갈린다 — 비전 입력은 픽셀이
|
|
7
|
+
* 아니라 타일 수로 계산되므로 줄인 만큼 그대로 싸진다.
|
|
8
|
+
*
|
|
9
|
+
* JPEG 로 굳힌다. 레퍼런스는 사진이지 도형이 아니라서 무손실이 필요 없고, PNG 로 두면
|
|
10
|
+
* 같은 그림이 서너 배 무겁다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 긴 변 상한. 이보다 작은 그림은 확대하지 않는다. */
|
|
14
|
+
export const REFERENCE_MAX_EDGE = 1024;
|
|
15
|
+
|
|
16
|
+
const QUALITY = 0.82;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 디코드 상한. 넘기면 줄이기를 포기하고 원본을 쓴다.
|
|
20
|
+
*
|
|
21
|
+
* 깨진 파일이나 디코더가 못 읽는 포맷은 ``onload`` 도 ``onerror`` 도 **안 뜬다.** 기다리는
|
|
22
|
+
* 쪽에 상한이 없으면 모달이 제출을 누른 채로 굳는다 — 유저에게는 그냥 먹통이다.
|
|
23
|
+
*/
|
|
24
|
+
const DECODE_TIMEOUT_MS = 3000;
|
|
25
|
+
|
|
26
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const img = new Image();
|
|
29
|
+
const timer = setTimeout(
|
|
30
|
+
() => reject(new Error("image decode timed out")),
|
|
31
|
+
DECODE_TIMEOUT_MS,
|
|
32
|
+
);
|
|
33
|
+
img.onload = () => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve(img);
|
|
36
|
+
};
|
|
37
|
+
img.onerror = () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(new Error("image decode failed"));
|
|
40
|
+
};
|
|
41
|
+
img.src = src;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readAsDataUri(file: Blob): Promise<string> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const reader = new FileReader();
|
|
48
|
+
reader.onloadend = () =>
|
|
49
|
+
typeof reader.result === "string"
|
|
50
|
+
? resolve(reader.result)
|
|
51
|
+
: reject(new Error("read failed"));
|
|
52
|
+
reader.onerror = () => reject(new Error("read failed"));
|
|
53
|
+
reader.readAsDataURL(file);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 파일 바이트를 그대로 data URI 로. 붙이자마자 썸네일을 띄우는 데 쓴다. */
|
|
58
|
+
export function readImageFileAsDataUri(file: File): Promise<string> {
|
|
59
|
+
return readAsDataUri(file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 줄이기 결과. ``width``/``height`` 는 **보낼 그림의 크기**다 — 판독 값이 그 크기로
|
|
64
|
+
* 정해지므로(비전 입력은 타일 수로 청구된다) 원본이 아니라 줄인 뒤 크기여야 한다.
|
|
65
|
+
* 디코드가 안 되면 0 이고, 그때는 부르는 쪽이 "모른다"로 다뤄야 한다.
|
|
66
|
+
*/
|
|
67
|
+
export type ShrunkReference = { uri: string; width: number; height: number };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 이미 읽어 둔 data URI 를 줄인다. 디코드나 캔버스가 안 되면 **원본을 그대로** 돌려준다 —
|
|
71
|
+
* 줄이지 못한 것이 아예 못 붙이는 것보다 낫다.
|
|
72
|
+
*/
|
|
73
|
+
export async function shrinkReferenceDataUri(
|
|
74
|
+
original: string,
|
|
75
|
+
): Promise<ShrunkReference> {
|
|
76
|
+
// 캔버스를 **디코드보다 먼저** 확인한다. 그릴 곳이 없으면 어차피 줄이지 못하는데,
|
|
77
|
+
// 순서를 뒤집으면 줄이지도 못할 그림을 몇 초씩 기다렸다가 포기하게 된다.
|
|
78
|
+
const canvas = document.createElement("canvas");
|
|
79
|
+
const ctx = canvas.getContext("2d");
|
|
80
|
+
if (!ctx) return { uri: original, width: 0, height: 0 };
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const img = await loadImage(original);
|
|
84
|
+
const longest = Math.max(img.naturalWidth, img.naturalHeight);
|
|
85
|
+
if (!longest) return { uri: original, width: 0, height: 0 };
|
|
86
|
+
const scale = Math.min(1, REFERENCE_MAX_EDGE / longest);
|
|
87
|
+
if (scale >= 1) {
|
|
88
|
+
// 줄일 것이 없어도 크기는 안다 — 여기서 0 을 내면 안 줄인 그림만 값이 비싸진다.
|
|
89
|
+
return { uri: original, width: img.naturalWidth, height: img.naturalHeight };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
93
|
+
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
94
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
95
|
+
return {
|
|
96
|
+
uri: canvas.toDataURL("image/jpeg", QUALITY),
|
|
97
|
+
width: canvas.width,
|
|
98
|
+
height: canvas.height,
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return { uri: original, width: 0, height: 0 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상품 사진을 브랜드 버킷에 올리는 길 — **줄이기 + presigned 업로드**.
|
|
3
|
+
*
|
|
4
|
+
* 생성 화면 안에만 있던 것을 꺼냈다. 새 상품 시작 화면(`/branding/detail-page-generator/new`)
|
|
5
|
+
* 도 같은 사진을 같은 자리에 올려야 하는데, 화면 두 곳이 각자 줄이면 압축 품질과 상한이
|
|
6
|
+
* 갈라진다 — 한쪽에서 올린 사진만 유독 뭉개지는 식이다.
|
|
7
|
+
*
|
|
8
|
+
* ## 왜 브라우저에서 먼저 줄이는가
|
|
9
|
+
*
|
|
10
|
+
* 원본 그대로 올리면 요즘 폰 사진 한 장이 8MB 를 넘는다. 열 장이면 업로드만 몇 분이고,
|
|
11
|
+
* 그 사이 유저는 아무것도 못 한다. 그리고 상세페이지에 실릴 크기는 어차피 긴 변
|
|
12
|
+
* 1600px 이라 그 위는 버리는 픽셀이다.
|
|
13
|
+
*
|
|
14
|
+
* 품질은 위에서부터 내려가며 상한(1.5MB)에 처음 걸리는 값을 쓴다. 고정 품질로 잡으면
|
|
15
|
+
* 단색 그림은 필요 이상으로 뭉개지고, 복잡한 그림은 상한을 못 지킨다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { DetailPageHostApi } from "../../components/detail-page/detail-page-host-context";
|
|
19
|
+
|
|
20
|
+
/** 고를 수 있는 원본 크기. 이보다 크면 캔버스에 그리기 전에 메모리가 먼저 눕는다. */
|
|
21
|
+
export const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
/** 줄인 뒤 목표 크기. */
|
|
23
|
+
export const MAX_COMPRESSED_REFERENCE_BYTES = 1.5 * 1024 * 1024;
|
|
24
|
+
/** 줄인 뒤 긴 변. 상세페이지가 쓰는 폭보다 넉넉하다. */
|
|
25
|
+
export const MAX_REFERENCE_IMAGE_DIMENSION = 1600;
|
|
26
|
+
|
|
27
|
+
export interface UploadedReferenceImage {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
previewUrl: string;
|
|
31
|
+
size: number;
|
|
32
|
+
contentType: string;
|
|
33
|
+
s3Key: string;
|
|
34
|
+
role: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
38
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function firstString(...values: unknown[]): string {
|
|
42
|
+
for (const value of values) {
|
|
43
|
+
if (typeof value !== "string") continue;
|
|
44
|
+
const normalized = value.trim();
|
|
45
|
+
if (normalized) return normalized;
|
|
46
|
+
}
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function blobToDataUrl(blob: Blob): Promise<string> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const reader = new FileReader();
|
|
53
|
+
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
54
|
+
reader.onerror = () => reject(reader.error);
|
|
55
|
+
reader.readAsDataURL(blob);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadImageElement(src: string): Promise<HTMLImageElement> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const image = new window.Image();
|
|
62
|
+
image.onload = () => resolve(image);
|
|
63
|
+
image.onerror = () => reject(new Error("Failed to load image"));
|
|
64
|
+
image.src = src;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function compressReferenceImage(file: File): Promise<Blob> {
|
|
69
|
+
const objectUrl = URL.createObjectURL(file);
|
|
70
|
+
try {
|
|
71
|
+
const image = await loadImageElement(objectUrl);
|
|
72
|
+
const scale = Math.min(
|
|
73
|
+
1,
|
|
74
|
+
MAX_REFERENCE_IMAGE_DIMENSION / Math.max(image.naturalWidth, image.naturalHeight),
|
|
75
|
+
);
|
|
76
|
+
const width = Math.max(1, Math.round(image.naturalWidth * scale));
|
|
77
|
+
const height = Math.max(1, Math.round(image.naturalHeight * scale));
|
|
78
|
+
const canvas = document.createElement("canvas");
|
|
79
|
+
canvas.width = width;
|
|
80
|
+
canvas.height = height;
|
|
81
|
+
const context = canvas.getContext("2d");
|
|
82
|
+
if (!context) throw new Error("Canvas is not available");
|
|
83
|
+
// 투명 PNG 를 JPEG 로 바꾸면 투명한 자리가 검게 남는다. 흰색을 먼저 깐다.
|
|
84
|
+
context.fillStyle = "#FFFFFF";
|
|
85
|
+
context.fillRect(0, 0, width, height);
|
|
86
|
+
context.drawImage(image, 0, 0, width, height);
|
|
87
|
+
|
|
88
|
+
for (const quality of [0.88, 0.82, 0.76, 0.7]) {
|
|
89
|
+
const blob = await new Promise<Blob | null>((resolve) =>
|
|
90
|
+
canvas.toBlob(resolve, "image/jpeg", quality),
|
|
91
|
+
);
|
|
92
|
+
if (!blob) continue;
|
|
93
|
+
if (blob.size <= MAX_COMPRESSED_REFERENCE_BYTES || quality === 0.7) {
|
|
94
|
+
return blob;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error("Image compression failed");
|
|
98
|
+
} finally {
|
|
99
|
+
URL.revokeObjectURL(objectUrl);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function uploadReferenceImage(input: {
|
|
104
|
+
blob: Blob;
|
|
105
|
+
fileName: string;
|
|
106
|
+
userId: string;
|
|
107
|
+
productId?: string;
|
|
108
|
+
generatedId?: string | null;
|
|
109
|
+
slotRole?: string;
|
|
110
|
+
/**
|
|
111
|
+
* presigned URL 을 발급하는 소싱 서버 호출. 훅을 못 쓰는 자리라 호출부가 넘긴다
|
|
112
|
+
* (`DetailPageHost.api.createDetailPageReferenceImageUploadUrl`).
|
|
113
|
+
*/
|
|
114
|
+
createUploadUrl: DetailPageHostApi["createDetailPageReferenceImageUploadUrl"];
|
|
115
|
+
}): Promise<{ s3Key: string; contentType: string }> {
|
|
116
|
+
const shouldAttachSlotMeta = Boolean(input.generatedId && input.slotRole);
|
|
117
|
+
const payload = await input.createUploadUrl({
|
|
118
|
+
user_id: input.userId,
|
|
119
|
+
product_id: input.productId || undefined,
|
|
120
|
+
generated_id: shouldAttachSlotMeta ? input.generatedId : undefined,
|
|
121
|
+
slot_role: shouldAttachSlotMeta ? input.slotRole : undefined,
|
|
122
|
+
file_name: input.fileName.replace(/\.[^.]+$/, ".jpg"),
|
|
123
|
+
content_type: "image/jpeg",
|
|
124
|
+
file_size_bytes: input.blob.size,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const uploadUrl = firstString(payload.upload_url, payload.uploadUrl);
|
|
128
|
+
const uploadMethod = firstString(payload.upload_method, payload.uploadMethod, "POST");
|
|
129
|
+
const s3Key = firstString(payload.s3_key, payload.s3Key, payload.key);
|
|
130
|
+
const contentType = firstString(payload.content_type, payload.contentType, "image/jpeg");
|
|
131
|
+
if (!uploadUrl) throw new Error("Upload URL is missing");
|
|
132
|
+
if (!s3Key) throw new Error("S3 key is missing");
|
|
133
|
+
|
|
134
|
+
const fields = asRecord(payload.form_fields ?? payload.formFields ?? payload.fields);
|
|
135
|
+
const uploadedFileName = input.fileName.replace(/\.[^.]+$/, ".jpg");
|
|
136
|
+
if (uploadMethod.toUpperCase() === "POST" && fields) {
|
|
137
|
+
const formData = new FormData();
|
|
138
|
+
Object.entries(fields).forEach(([key, value]) => {
|
|
139
|
+
formData.append(key, String(value));
|
|
140
|
+
});
|
|
141
|
+
const uploadFile =
|
|
142
|
+
input.blob instanceof File
|
|
143
|
+
? input.blob
|
|
144
|
+
: new File([input.blob], uploadedFileName, { type: contentType });
|
|
145
|
+
formData.append("file", uploadFile);
|
|
146
|
+
const uploadResponse = await fetch(uploadUrl, { method: "POST", body: formData });
|
|
147
|
+
if (
|
|
148
|
+
uploadResponse.status !== 204 &&
|
|
149
|
+
uploadResponse.status !== 201 &&
|
|
150
|
+
uploadResponse.status !== 200
|
|
151
|
+
) {
|
|
152
|
+
const errorText = await uploadResponse.text().catch(() => "");
|
|
153
|
+
throw new Error(errorText || "Reference image upload failed");
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
const presignedHeaders = asRecord(payload.headers);
|
|
157
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
158
|
+
method: uploadMethod || "PUT",
|
|
159
|
+
headers: {
|
|
160
|
+
...Object.fromEntries(
|
|
161
|
+
Object.entries(presignedHeaders ?? {}).map(([key, value]) => [
|
|
162
|
+
key,
|
|
163
|
+
String(value),
|
|
164
|
+
]),
|
|
165
|
+
),
|
|
166
|
+
"Content-Type": contentType,
|
|
167
|
+
},
|
|
168
|
+
body: input.blob,
|
|
169
|
+
});
|
|
170
|
+
if (
|
|
171
|
+
uploadResponse.status !== 204 &&
|
|
172
|
+
uploadResponse.status !== 201 &&
|
|
173
|
+
uploadResponse.status !== 200
|
|
174
|
+
) {
|
|
175
|
+
const errorText = await uploadResponse.text().catch(() => "");
|
|
176
|
+
throw new Error(errorText || "Reference image upload failed");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { s3Key, contentType };
|
|
181
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 재저작한 화면 하나를 문서에 갈아 끼운다.
|
|
3
|
+
*
|
|
4
|
+
* 서버는 그 화면만 다시 디컴포즈해 **페이지 하나**를 돌려준다. 문서를 통째로 다시
|
|
5
|
+
* 받지 않는 이유는 단순하다: 16개 화면을 전부 브라우저에 다시 태우면 유저가 수십 초를
|
|
6
|
+
* 기다린다. 슬롯 이름이 화면 단위로 스코프돼 있어(``<라벨>.<이름>``) 페이지 하나만
|
|
7
|
+
* 바꿔도 다른 화면의 슬롯과 섞이지 않는다.
|
|
8
|
+
*
|
|
9
|
+
* **자리를 지킨다.** 지웠다 뒤에 붙이면 재저작한 화면이 문서 맨 끝으로 간다 — 유저는
|
|
10
|
+
* 고쳐 달라고 했지 옮겨 달라고 하지 않았다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type CanvasDocument = {
|
|
14
|
+
pages?: Array<Record<string, unknown>>;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ``page.id`` 가 같은 페이지를 새 페이지로 교체한 문서를 돌려준다(원본 불변).
|
|
20
|
+
*
|
|
21
|
+
* 같은 id 의 페이지가 없으면 문서를 그대로 돌려준다 — 새 화면을 조용히 덧붙이면
|
|
22
|
+
* 유저 화면에는 고쳐진 것과 안 고쳐진 것이 나란히 남는다.
|
|
23
|
+
*/
|
|
24
|
+
export function replaceCanvasPage(
|
|
25
|
+
document: CanvasDocument,
|
|
26
|
+
page: Record<string, unknown>,
|
|
27
|
+
): CanvasDocument {
|
|
28
|
+
const pages = document.pages ?? [];
|
|
29
|
+
const id = String(page?.id ?? "");
|
|
30
|
+
const index = pages.findIndex((entry) => String(entry?.id ?? "") === id);
|
|
31
|
+
if (!id || index < 0) return document;
|
|
32
|
+
const next = pages.slice();
|
|
33
|
+
next[index] = page;
|
|
34
|
+
return { ...document, pages: next };
|
|
35
|
+
}
|