@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,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 만든 GIF로 원본(텍스트·그룹·이미지·도형)을 **그 자리 그대로** 갈아 끼운다.
|
|
3
|
+
*
|
|
4
|
+
* 예전에는 결과 GIF를 페이지 가운데에 62% 폭으로 새로 얹었다. 그러면 원본은 그대로
|
|
5
|
+
* 남고 GIF는 엉뚱한 크기·자리에 떨어져서, 유저가 매번 지우고 옮기고 크기를 맞춰야 했다.
|
|
6
|
+
*
|
|
7
|
+
* 자리 계산은 **잎 요소들의 합집합 상자**로 한다. 스톡 편집기의 group은 캔버스에서 offset을
|
|
8
|
+
* 주지 않고 자식이 페이지 좌표를 그대로 들고 있어서(canvas/element.js의 group 렌더러는
|
|
9
|
+
* ``<Group>``에 x/y를 안 건다), 그룹 모델에 저장된 x/y/width/height는 실제로 보이는
|
|
10
|
+
* 상자와 어긋날 수 있다. 자식에서 다시 재는 게 유일하게 믿을 수 있는 값이다.
|
|
11
|
+
*
|
|
12
|
+
* 쌓임 순서(z-order)도 원본 자리를 따른다. ``page.addElement``는 페이지 맨 앞(children
|
|
13
|
+
* 끝)에 붙이므로, 그냥 두면 GIF가 항상 모든 요소 위로 튀어나와 원본이 뒤에 깔려 있던
|
|
14
|
+
* 관계가 무너진다. 그래서 넣은 뒤 ``page.setElementZIndex``로 원본이 있던 칸에 되돌린다.
|
|
15
|
+
*
|
|
16
|
+
* 원본이 **그룹 안**에 있었다면 GIF도 그 그룹 안 같은 칸에 넣는다(``layer-move``의
|
|
17
|
+
* 재부모화). 그래야 그룹 안에서의 앞뒤가 지켜지고, 나중에 그룹을 옮길 때 GIF도 따라간다.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { locate, moveLayer } from "./layer-move";
|
|
21
|
+
|
|
22
|
+
export type ElementLike = {
|
|
23
|
+
id?: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
x?: unknown;
|
|
26
|
+
y?: unknown;
|
|
27
|
+
width?: unknown;
|
|
28
|
+
height?: unknown;
|
|
29
|
+
rotation?: unknown;
|
|
30
|
+
cropX?: unknown;
|
|
31
|
+
cropY?: unknown;
|
|
32
|
+
cropWidth?: unknown;
|
|
33
|
+
cropHeight?: unknown;
|
|
34
|
+
children?: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Box = { x: number; y: number; width: number; height: number };
|
|
38
|
+
|
|
39
|
+
type PageLike = {
|
|
40
|
+
id?: string;
|
|
41
|
+
computedWidth?: number;
|
|
42
|
+
computedHeight?: number;
|
|
43
|
+
children?: unknown;
|
|
44
|
+
addElement: (opts: Record<string, unknown>) => unknown;
|
|
45
|
+
/** 페이지와 그룹이 공통으로 노출하는 유일한 재정렬 API(index가 클수록 앞). */
|
|
46
|
+
setElementZIndex?: (id: string, index: number) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type StoreLike = {
|
|
50
|
+
activePage?: PageLike;
|
|
51
|
+
pages: PageLike[];
|
|
52
|
+
deleteElements?: (ids: string[]) => void;
|
|
53
|
+
selectElements?: (ids: string[]) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function num(value: unknown, fallback = 0): number {
|
|
57
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 그룹이면 잎(그룹이 아닌 자식)들, 아니면 자기 자신. */
|
|
62
|
+
export function leafElements(els: ElementLike[]): ElementLike[] {
|
|
63
|
+
const out: ElementLike[] = [];
|
|
64
|
+
const walk = (el: ElementLike) => {
|
|
65
|
+
const children = Array.isArray(el.children)
|
|
66
|
+
? (el.children as ElementLike[])
|
|
67
|
+
: null;
|
|
68
|
+
if (el.type === "group" && children && children.length > 0) {
|
|
69
|
+
children.forEach(walk);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
out.push(el);
|
|
73
|
+
};
|
|
74
|
+
els.forEach(walk);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 요소들이 실제로 차지하는 상자.
|
|
80
|
+
*
|
|
81
|
+
* 회전은 상자 계산에서 무시한다 — 회전한 요소는 회전값을 그대로 GIF에 물려주므로,
|
|
82
|
+
* 회전 전 상자로 재는 게 맞다(회전 후 bbox로 재면 그 위에 회전을 또 걸어 커진다).
|
|
83
|
+
*/
|
|
84
|
+
export function unionBox(els: ElementLike[]): Box | null {
|
|
85
|
+
const leaves = leafElements(els);
|
|
86
|
+
if (leaves.length === 0) return null;
|
|
87
|
+
let left = Infinity;
|
|
88
|
+
let top = Infinity;
|
|
89
|
+
let right = -Infinity;
|
|
90
|
+
let bottom = -Infinity;
|
|
91
|
+
for (const el of leaves) {
|
|
92
|
+
const x = num(el.x);
|
|
93
|
+
const y = num(el.y);
|
|
94
|
+
left = Math.min(left, x);
|
|
95
|
+
top = Math.min(top, y);
|
|
96
|
+
right = Math.max(right, x + num(el.width));
|
|
97
|
+
bottom = Math.max(bottom, y + num(el.height));
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
|
|
100
|
+
return {
|
|
101
|
+
x: left,
|
|
102
|
+
y: top,
|
|
103
|
+
width: Math.max(1, right - left),
|
|
104
|
+
height: Math.max(1, bottom - top),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 상자를 사방으로 ``bleed``만큼 키운다(상자 밖으로 번지는 이펙트용 여백). */
|
|
109
|
+
export function expandBox(box: Box, bleed: number): Box {
|
|
110
|
+
const pad = Math.max(0, bleed);
|
|
111
|
+
return {
|
|
112
|
+
x: box.x - pad,
|
|
113
|
+
y: box.y - pad,
|
|
114
|
+
width: box.width + pad * 2,
|
|
115
|
+
height: box.height + pad * 2,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pageOf(store: StoreLike, el: ElementLike): PageLike | undefined {
|
|
120
|
+
const hit = (children: unknown): boolean =>
|
|
121
|
+
Array.isArray(children) &&
|
|
122
|
+
(children as ElementLike[]).some(
|
|
123
|
+
(child) => child.id === el.id || hit(child.children),
|
|
124
|
+
);
|
|
125
|
+
return (
|
|
126
|
+
store.pages.find((page) => hit(page.children)) ??
|
|
127
|
+
store.activePage ??
|
|
128
|
+
store.pages[0]
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function childList(node: PageLike | ElementLike): ElementLike[] {
|
|
133
|
+
return Array.isArray(node.children) ? (node.children as ElementLike[]) : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function idOf(el: ElementLike | undefined | null): string {
|
|
137
|
+
return String(el?.id ?? "");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* ``el``을 품고 있는 **페이지 직속 자식**의 인덱스. el 자신이 직속이면 그 인덱스다.
|
|
142
|
+
*
|
|
143
|
+
* 그룹 안의 요소는 페이지 children에 없으므로 조상을 타고 올라가 대표를 찾는다.
|
|
144
|
+
*/
|
|
145
|
+
function topLevelIndexOf(page: PageLike, el: ElementLike): number {
|
|
146
|
+
const target = idOf(el);
|
|
147
|
+
if (!target) return -1;
|
|
148
|
+
const holds = (node: ElementLike): boolean =>
|
|
149
|
+
idOf(node) === target || childList(node).some(holds);
|
|
150
|
+
return childList(page).findIndex(holds);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 갈아 끼운 뒤 GIF를 원본이 있던 페이지 칸으로 되돌린다.
|
|
155
|
+
*
|
|
156
|
+
* 페이지 직속 원본을 통째로 대체했으면 원본이 비운 칸에 그대로 앉는다. 그룹 안의
|
|
157
|
+
* 자식만 대체한 경우엔 일단 그 그룹 바로 위에 앉히고, 이어서 그룹 안으로 넣는다.
|
|
158
|
+
*/
|
|
159
|
+
function restoreZIndex(
|
|
160
|
+
page: PageLike,
|
|
161
|
+
gifId: string,
|
|
162
|
+
beforeIds: string[],
|
|
163
|
+
anchorTopIndex: number,
|
|
164
|
+
): void {
|
|
165
|
+
if (!page.setElementZIndex || !gifId || anchorTopIndex < 0) return;
|
|
166
|
+
const survivors = new Set(childList(page).map(idOf));
|
|
167
|
+
let target = 0;
|
|
168
|
+
for (let i = 0; i < anchorTopIndex; i += 1) {
|
|
169
|
+
if (survivors.has(beforeIds[i])) target += 1;
|
|
170
|
+
}
|
|
171
|
+
// 그룹은 남고 그 자식만 갈아 끼운 경우: 그룹 바로 위(원본이 그룹 안에서 그려지던 자리).
|
|
172
|
+
if (survivors.has(beforeIds[anchorTopIndex])) target += 1;
|
|
173
|
+
page.setElementZIndex(gifId, target);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type ReplaceOptions = {
|
|
177
|
+
/** 상자 밖으로 번지는 이펙트 여백(px). 텍스트 GIF가 서버에 넘긴 값과 같아야 한다. */
|
|
178
|
+
bleed?: number;
|
|
179
|
+
/** 원본이 사진 하나였다면 그 자르기(crop)를 물려받아 프레이밍을 유지한다. */
|
|
180
|
+
inheritCrop?: boolean;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ``els``를 지우고 그 자리에 GIF 이미지 요소를 넣는다. 넣은 요소를 돌려준다.
|
|
185
|
+
*
|
|
186
|
+
* 상자를 못 재면(요소가 없거나 좌표가 이상하면) 아무것도 지우지 않고 null을 돌려준다 —
|
|
187
|
+
* 원본을 날린 뒤 대체에 실패하는 게 제일 나쁘다.
|
|
188
|
+
*/
|
|
189
|
+
export function replaceWithGif(
|
|
190
|
+
store: unknown,
|
|
191
|
+
els: ElementLike[],
|
|
192
|
+
src: string,
|
|
193
|
+
options: ReplaceOptions = {},
|
|
194
|
+
): unknown {
|
|
195
|
+
const s = store as StoreLike;
|
|
196
|
+
if (!src || els.length === 0) return null;
|
|
197
|
+
const inner = unionBox(els);
|
|
198
|
+
if (!inner) return null;
|
|
199
|
+
const box = expandBox(inner, options.bleed ?? 0);
|
|
200
|
+
const page = pageOf(s, els[0]) ?? s.activePage ?? s.pages[0];
|
|
201
|
+
if (!page) return null;
|
|
202
|
+
|
|
203
|
+
const single = els.length === 1 ? els[0] : null;
|
|
204
|
+
const rotation = num(single?.rotation, 0);
|
|
205
|
+
const crop: Record<string, number> = {};
|
|
206
|
+
if (options.inheritCrop && single?.type === "image") {
|
|
207
|
+
// 스톡 편집기는 이미지를 cover-crop으로 채운다. 원본이 잘려 있었다면 그 자르기를
|
|
208
|
+
// 그대로 물려줘야 프레이밍이 안 바뀐다.
|
|
209
|
+
for (const key of ["cropX", "cropY", "cropWidth", "cropHeight"] as const) {
|
|
210
|
+
const value = single[key];
|
|
211
|
+
if (typeof value === "number" && Number.isFinite(value)) crop[key] = value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ids = els.map((el) => String(el.id ?? "")).filter(Boolean);
|
|
216
|
+
// 원본이 페이지 몇 번째 칸에 있었는지 지우기 전에 재둔다. 여럿을 대체할 때는 가장
|
|
217
|
+
// 앞(위)에 있던 것을 기준으로 삼는다 — 그것들이 덮고 있던 것을 GIF도 덮어야 한다.
|
|
218
|
+
const beforeIds = childList(page).map(idOf);
|
|
219
|
+
const anchorTopIndex = els.reduce(
|
|
220
|
+
(top, el) => Math.max(top, topLevelIndexOf(page, el)),
|
|
221
|
+
-1,
|
|
222
|
+
);
|
|
223
|
+
// 하나짜리 원본이 그룹 안에 있었다면 그 그룹의 같은 칸으로 돌려보낸다.
|
|
224
|
+
const nest = els.length === 1 ? locate(page, idOf(els[0])) : null;
|
|
225
|
+
const nestInto =
|
|
226
|
+
nest?.parent && idOf(nest.parent) !== idOf(els[0])
|
|
227
|
+
? { parentId: idOf(nest.parent), index: nest.index }
|
|
228
|
+
: null;
|
|
229
|
+
const added = page.addElement({
|
|
230
|
+
type: "image",
|
|
231
|
+
src,
|
|
232
|
+
x: Math.round(box.x),
|
|
233
|
+
y: Math.round(box.y),
|
|
234
|
+
width: Math.round(box.width),
|
|
235
|
+
height: Math.round(box.height),
|
|
236
|
+
...(rotation ? { rotation } : {}),
|
|
237
|
+
...crop,
|
|
238
|
+
// 레이어 트리에서 "image-2" 대신 "GIF"로 보이게 하고, 내보내기가 이 섹션을
|
|
239
|
+
// 애니메이션으로 다루도록 표시한다.
|
|
240
|
+
name: "GIF",
|
|
241
|
+
custom: { detailPageGif: true },
|
|
242
|
+
});
|
|
243
|
+
if (ids.length > 0) s.deleteElements?.(ids);
|
|
244
|
+
const gifId = idOf(added as ElementLike);
|
|
245
|
+
restoreZIndex(page, gifId, beforeIds, anchorTopIndex);
|
|
246
|
+
if (nestInto && gifId) {
|
|
247
|
+
// 그룹이 자식 하나로 사라졌을 수도 있다. 그때는 페이지 칸(위에서 되돌린 자리)이 답.
|
|
248
|
+
moveLayer(s, gifId, nestInto);
|
|
249
|
+
}
|
|
250
|
+
return added;
|
|
251
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 섹션(편집기 한 장) 높이 — 재는 법, 바꾸는 법.
|
|
3
|
+
*
|
|
4
|
+
* 상세페이지의 한 장은 상품 카드가 아니라 **세로로 이어 붙는 띠**다. 그래서 높이가 고정된
|
|
5
|
+
* 캔버스와 달리 장마다 다르고, 바꿀 수 있어야 한다("여기 좀 답답해요", "이 장이 너무 길어요").
|
|
6
|
+
* Canvas 는 페이지 높이를 모델에 들고 있으므로(``page.set({height})``) 바꾸는 것 자체는
|
|
7
|
+
* 한 줄이다. 여기 있는 것은 그 한 줄로는 안 되는 두 가지다.
|
|
8
|
+
*
|
|
9
|
+
* ## 1. 배경은 같이 늘어나야 한다
|
|
10
|
+
*
|
|
11
|
+
* 디컴포즈된 화면은 배경을 **요소로** 깐다 — 페이지를 꽉 채우는 사진, 그라데이션 사각형,
|
|
12
|
+
* 색 띠. 페이지만 늘리면 그 요소는 옛 높이 그대로 남아 아래에 흰 띠가 생긴다. 유저가 보기에
|
|
13
|
+
* 이건 기능이 아니라 고장이다. 그래서 **페이지를 꽉 채우고 있던** 요소는 늘어난 만큼 같이
|
|
14
|
+
* 늘린다. 판정은 Canvas 자신이 ``setSize({useMagic})`` 에서 쓰는 것과 같은 모양이다
|
|
15
|
+
* (원점에 붙어 있고 페이지만 한 크기).
|
|
16
|
+
*
|
|
17
|
+
* ## 2. 굽기 상한이 진짜 상한이다
|
|
18
|
+
*
|
|
19
|
+
* 편집기 문서를 HTML 로 굽는 서버(``editor_document_service``)는 페이지 높이를 120~8000px
|
|
20
|
+
* 로 자른다. 편집기에서 9000px 을 잡을 수 있게 두면 화면에서는 멀쩡한데 구운 결과만 조용히
|
|
21
|
+
* 잘린다 — 그건 편집기에서 막는 게 맞다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type SectionHeightElement = {
|
|
25
|
+
type?: string;
|
|
26
|
+
x?: unknown;
|
|
27
|
+
y?: unknown;
|
|
28
|
+
width?: unknown;
|
|
29
|
+
height?: unknown;
|
|
30
|
+
children?: SectionHeightElement[];
|
|
31
|
+
set?: (attrs: Record<string, unknown>) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SectionHeightPage = {
|
|
35
|
+
computedWidth?: number;
|
|
36
|
+
computedHeight?: number;
|
|
37
|
+
children?: SectionHeightElement[];
|
|
38
|
+
set?: (attrs: Record<string, unknown>) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 서버가 굽기에서 자르는 하한. 이보다 낮게 잡아도 구운 결과는 이 값이 된다. */
|
|
42
|
+
export const MIN_SECTION_HEIGHT = 200;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 굽기 상한과 **같은 수**여야 한다(``editor_document_service._render_editor_document_html``).
|
|
46
|
+
* 여기만 키우면 편집기에서는 되는데 구운 상세페이지만 잘린다.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_SECTION_HEIGHT = 8000;
|
|
49
|
+
|
|
50
|
+
function num(value: unknown, fallback = 0): number {
|
|
51
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
52
|
+
return Number.isFinite(n) ? n : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clampSectionHeight(value: unknown): number {
|
|
56
|
+
const n = Math.round(num(value, MIN_SECTION_HEIGHT));
|
|
57
|
+
return Math.max(MIN_SECTION_HEIGHT, Math.min(MAX_SECTION_HEIGHT, n));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 이 요소가 **페이지 배경 노릇을 하고 있는가**.
|
|
62
|
+
*
|
|
63
|
+
* 원점에 붙어 있고(1px 오차 허용) 페이지를 거의 꽉 채우는 것. 텍스트와 그룹은 제외한다 —
|
|
64
|
+
* 그룹은 자식이 좌표를 들고 있어 높이를 늘려도 화면이 달라지지 않고, 페이지만 한 텍스트
|
|
65
|
+
* 상자를 배경으로 잘못 잡으면 글줄이 세로로 늘어난다.
|
|
66
|
+
*/
|
|
67
|
+
export function isSectionBackdrop(
|
|
68
|
+
el: SectionHeightElement,
|
|
69
|
+
pageWidth: number,
|
|
70
|
+
pageHeight: number,
|
|
71
|
+
): boolean {
|
|
72
|
+
const type = String(el?.type ?? "");
|
|
73
|
+
if (type === "text" || type === "group") return false;
|
|
74
|
+
return (
|
|
75
|
+
num(el?.x) <= 1 &&
|
|
76
|
+
num(el?.y) <= 1 &&
|
|
77
|
+
num(el?.width) >= pageWidth - 2 &&
|
|
78
|
+
num(el?.height) >= pageHeight - 2
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 내용이 실제로 끝나는 y(px). "내용에 맞추기"가 이 값을 쓴다.
|
|
84
|
+
*
|
|
85
|
+
* 배경 요소는 세지 않는다 — 배경은 페이지를 따라다니는 것이라, 세면 "지금 높이"가 그대로
|
|
86
|
+
* 답이 되어 버튼이 아무 일도 안 하게 된다. 그룹 자식은 페이지 절대 좌표를 들고 있고
|
|
87
|
+
* (디컴포저가 그룹을 원점에 고정한다), 유저가 그룹을 끌면 그 이동량이 그룹 x/y 에 쌓이므로
|
|
88
|
+
* 둘을 더해야 진짜 자리가 나온다.
|
|
89
|
+
*/
|
|
90
|
+
export function sectionContentBottom(page: SectionHeightPage): number {
|
|
91
|
+
const pageWidth = num(page?.computedWidth);
|
|
92
|
+
const pageHeight = num(page?.computedHeight);
|
|
93
|
+
let bottom = 0;
|
|
94
|
+
const visit = (el: SectionHeightElement, offsetY: number) => {
|
|
95
|
+
const kids = el?.children;
|
|
96
|
+
if (kids?.length) {
|
|
97
|
+
const groupOffset = offsetY + num(el?.y);
|
|
98
|
+
for (const kid of kids) visit(kid, groupOffset);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
bottom = Math.max(bottom, offsetY + num(el?.y) + num(el?.height));
|
|
102
|
+
};
|
|
103
|
+
for (const el of page?.children ?? []) {
|
|
104
|
+
if (isSectionBackdrop(el, pageWidth, pageHeight)) continue;
|
|
105
|
+
visit(el, 0);
|
|
106
|
+
}
|
|
107
|
+
return Math.ceil(bottom);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 높이를 바꾼다(배경까지 같이). 실제로 적용된 높이를 돌려준다.
|
|
112
|
+
*
|
|
113
|
+
* 늘어난 양을 **먼저** 재고 나서 페이지를 바꾼다 — 순서를 바꾸면 배경 판정이 이미 새 높이를
|
|
114
|
+
* 보게 되어 아무것도 배경으로 안 잡힌다.
|
|
115
|
+
*/
|
|
116
|
+
export function applySectionHeight(
|
|
117
|
+
page: SectionHeightPage,
|
|
118
|
+
nextHeight: number,
|
|
119
|
+
): number {
|
|
120
|
+
const next = clampSectionHeight(nextHeight);
|
|
121
|
+
const prev = Math.round(num(page?.computedHeight));
|
|
122
|
+
if (prev > 0 && next !== prev) {
|
|
123
|
+
const pageWidth = num(page?.computedWidth);
|
|
124
|
+
for (const el of page?.children ?? []) {
|
|
125
|
+
if (!isSectionBackdrop(el, pageWidth, prev)) continue;
|
|
126
|
+
el.set?.({ height: Math.max(1, num(el.height) + (next - prev)) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
page?.set?.({ height: next });
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// 화면 재저작 요청용 초경량 pub/sub.
|
|
2
|
+
//
|
|
3
|
+
// 요청은 캔버스 옆 페이지 툴바(화면 하나에 붙어 있는 컨트롤)에서 나오고, 실제 일은
|
|
4
|
+
// 편집기 최상단(store·generatedId·모달을 쥔 쪽)이 한다. 그 사이에는 Canvas 워크스페이스가
|
|
5
|
+
// 있어 props 로 잇기 어렵다 — '내 이미지' 갱신과 같은 방식으로 모듈 레벨에서 느슨하게 잇는다.
|
|
6
|
+
|
|
7
|
+
type Listener = (pageId: string) => void;
|
|
8
|
+
|
|
9
|
+
const listeners = new Set<Listener>();
|
|
10
|
+
const availabilityWatchers = new Set<() => void>();
|
|
11
|
+
|
|
12
|
+
function notifyAvailability(): void {
|
|
13
|
+
for (const watcher of [...availabilityWatchers]) {
|
|
14
|
+
try {
|
|
15
|
+
watcher();
|
|
16
|
+
} catch {
|
|
17
|
+
// 구독자 하나가 터져도 나머지는 받는다.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 재저작 요청을 구독한다. 반환된 함수로 구독 해제. */
|
|
23
|
+
export function onSectionReauthorRequested(listener: Listener): () => void {
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
notifyAvailability();
|
|
26
|
+
return () => {
|
|
27
|
+
listeners.delete(listener);
|
|
28
|
+
notifyAvailability();
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 이 화면을 다시 만들어 달라고 요청한다(``pageId`` = 화면 라벨). */
|
|
33
|
+
export function requestSectionReauthor(pageId: string): void {
|
|
34
|
+
for (const listener of [...listeners]) {
|
|
35
|
+
try {
|
|
36
|
+
listener(pageId);
|
|
37
|
+
} catch {
|
|
38
|
+
// 리스너 하나가 터져도 나머지는 받는다.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 배선 여부 구독(``useSyncExternalStore`` 용).
|
|
45
|
+
*
|
|
46
|
+
* 툴바는 편집기보다 **먼저** 그려질 수 있다. 한 번 읽고 마는 헬퍼로는 배선이 끝난
|
|
47
|
+
* 뒤에도 버튼이 계속 숨어 있게 된다 — 그래서 구독 가능한 형태로 낸다.
|
|
48
|
+
*/
|
|
49
|
+
export function subscribeSectionReauthorAvailability(
|
|
50
|
+
watcher: () => void,
|
|
51
|
+
): () => void {
|
|
52
|
+
availabilityWatchers.add(watcher);
|
|
53
|
+
return () => {
|
|
54
|
+
availabilityWatchers.delete(watcher);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 지금 재저작이 배선돼 있는지. 미배선이면 툴바 버튼을 띄우지 않는다(막다른 길 방지). */
|
|
59
|
+
export function isSectionReauthorWired(): boolean {
|
|
60
|
+
return listeners.size > 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 편집기 도형(벡터) → 투명 PNG data URI.
|
|
3
|
+
*
|
|
4
|
+
* 도형을 GIF로 만들려면 이펙트 파이프라인에 픽셀을 줘야 한다. 서버에 SVG를 보내
|
|
5
|
+
* 헤드리스 브라우저로 굽는 방법도 있지만, 색 치환(``colorsReplace``)과 그라데이션까지
|
|
6
|
+
* 편집기와 똑같이 재현하려면 결국 렌더 규칙을 서버에 복제해야 한다. 브라우저는 이미
|
|
7
|
+
* 그 규칙을 들고 있으므로 여기서 굽고 픽셀만 올린다.
|
|
8
|
+
*
|
|
9
|
+
* 도형은 두 종류다.
|
|
10
|
+
* - ``svg``: 마크업이 ``src``에 data URI로 들어 있다(색 치환은 ``colorsReplace``).
|
|
11
|
+
* - ``figure``: 네이티브 도형. 마크업이 없어 subType/치수에서 만들어 낸다
|
|
12
|
+
* (``leviosa-canvas/paint/figure-svg``가 화면과 같은 규칙으로 그린다).
|
|
13
|
+
*
|
|
14
|
+
* 배경은 투명하게 둔다 — GIF 인코더가 투명 소스를 알아보고 투명 배경 GIF로 굽는다.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseStops } from "../../components/detail-page/fill-control";
|
|
18
|
+
import { encodeSvgDataUri } from "../detail-page-canvas/export/svg";
|
|
19
|
+
import { figureToSvg } from "@leviosa-ai/canvas/paint/figure-svg";
|
|
20
|
+
import {
|
|
21
|
+
ensureSvgNamespace,
|
|
22
|
+
loadSvgMarkup,
|
|
23
|
+
readColorReplace,
|
|
24
|
+
replaceSvgColors,
|
|
25
|
+
} from "@leviosa-ai/canvas/render/svg-source";
|
|
26
|
+
|
|
27
|
+
export type ShapeElementLike = {
|
|
28
|
+
type?: unknown;
|
|
29
|
+
src?: unknown;
|
|
30
|
+
width?: unknown;
|
|
31
|
+
height?: unknown;
|
|
32
|
+
fill?: unknown;
|
|
33
|
+
stroke?: unknown;
|
|
34
|
+
strokeWidth?: unknown;
|
|
35
|
+
dash?: unknown;
|
|
36
|
+
cornerRadius?: unknown;
|
|
37
|
+
subType?: unknown;
|
|
38
|
+
colorsReplace?: unknown;
|
|
39
|
+
id?: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 굽는 해상도 배율. 편집기 좌표는 작아서 1배로 구우면 이펙트에서 뭉갠다. */
|
|
43
|
+
const RASTER_SCALE = 2;
|
|
44
|
+
/** 한 변 상한(서버 GIF 소스 상한과 렌더 시간 양쪽을 고려). */
|
|
45
|
+
const MAX_EDGE = 1400;
|
|
46
|
+
|
|
47
|
+
function num(value: unknown, fallback: number): number {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
return Number.isFinite(n) ? n : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** GIF로 구울 수 있는 도형인지(그룹·텍스트·이미지는 각자의 경로가 따로 있다). */
|
|
53
|
+
export function isShapeElement(
|
|
54
|
+
el: ShapeElementLike | null | undefined,
|
|
55
|
+
): boolean {
|
|
56
|
+
const type = String(el?.type ?? "");
|
|
57
|
+
return type === "svg" || type === "figure";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ``linear-gradient(...)`` 채우기를 SVG ``<linearGradient>``로 바꾼다.
|
|
62
|
+
*
|
|
63
|
+
* 방향은 스톡 편집기가 캔버스에 그릴 때 쓰는 규칙과 같다(``useColor``: CSS 각도 A의 진행
|
|
64
|
+
* 방향이 ``(sin A, -cos A)``). 각도를 무시하면 편집기에서 가로로 흐르던 그라데이션이
|
|
65
|
+
* GIF에서만 세로로 흐른다.
|
|
66
|
+
*
|
|
67
|
+
* 그라데이션이 아니면 ``null``(호출부가 색 문자열을 그대로 쓴다).
|
|
68
|
+
*/
|
|
69
|
+
export function gradientToSvgPaint(
|
|
70
|
+
fill: unknown,
|
|
71
|
+
id: string,
|
|
72
|
+
): { def: string; ref: string } | null {
|
|
73
|
+
const parsed = parseStops(String(fill ?? ""));
|
|
74
|
+
if (!parsed) return null;
|
|
75
|
+
const rad = (parsed.angle * Math.PI) / 180;
|
|
76
|
+
const dx = Math.sin(rad) / 2;
|
|
77
|
+
const dy = Math.cos(rad) / 2;
|
|
78
|
+
const stops = parsed.stops
|
|
79
|
+
.map(
|
|
80
|
+
(stop) =>
|
|
81
|
+
`<stop offset="${Math.round(stop.pos)}%" stop-color="${stop.color}"/>`,
|
|
82
|
+
)
|
|
83
|
+
.join("");
|
|
84
|
+
return {
|
|
85
|
+
def:
|
|
86
|
+
`<linearGradient id="${id}" x1="${(0.5 - dx).toFixed(4)}" ` +
|
|
87
|
+
`y1="${(0.5 + dy).toFixed(4)}" x2="${(0.5 + dx).toFixed(4)}" ` +
|
|
88
|
+
`y2="${(0.5 - dy).toFixed(4)}">${stops}</linearGradient>`,
|
|
89
|
+
ref: `url(#${id})`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ``figureToSvg``가 만든 마크업의 그라데이션 채우기를 defs로 바꿔 끼운다.
|
|
95
|
+
*
|
|
96
|
+
* 그 함수는 ``fill``을 문자열 그대로 박기 때문에 ``linear-gradient(...)``가 무효한
|
|
97
|
+
* paint가 되어 도형이 통째로 검게 굳는다.
|
|
98
|
+
*/
|
|
99
|
+
export function patchFigureGradient(
|
|
100
|
+
markup: string,
|
|
101
|
+
el: ShapeElementLike,
|
|
102
|
+
): string {
|
|
103
|
+
const fill = String(el.fill ?? "");
|
|
104
|
+
const paint = gradientToSvgPaint(fill, `dp-grad-${String(el.id ?? "shape")}`);
|
|
105
|
+
if (!paint) return markup;
|
|
106
|
+
const withDefs = markup.includes("<defs>")
|
|
107
|
+
? markup.replace("<defs>", `<defs>${paint.def}`)
|
|
108
|
+
: markup.replace(/<svg([^>]*)>/, `<svg$1><defs>${paint.def}</defs>`);
|
|
109
|
+
return withDefs.split(`fill="${fill}"`).join(`fill="${paint.ref}"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 루트 ``<svg>``에 픽셀 크기를 못 박는다.
|
|
114
|
+
*
|
|
115
|
+
* ``<img>``로 SVG를 그릴 때 루트에 width/height가 없으면 브라우저가 기본 300×150을
|
|
116
|
+
* 고유 크기로 잡는다. 그 상태로 다른 비율의 캔버스에 그리면 도형이 눌리거나 늘어난다
|
|
117
|
+
* (viewBox만 있는 라이브러리 도형이 여기 해당한다).
|
|
118
|
+
*/
|
|
119
|
+
export function withExplicitSize(
|
|
120
|
+
markup: string,
|
|
121
|
+
width: number,
|
|
122
|
+
height: number,
|
|
123
|
+
): string {
|
|
124
|
+
const root = markup.match(/<svg\b[^>]*>/i);
|
|
125
|
+
if (!root) return markup;
|
|
126
|
+
const cleaned = root[0]
|
|
127
|
+
.replace(/\swidth="[^"]*"/i, "")
|
|
128
|
+
.replace(/\sheight="[^"]*"/i, "")
|
|
129
|
+
.replace(/\spreserveAspectRatio="[^"]*"/i, "")
|
|
130
|
+
.replace(
|
|
131
|
+
/^<svg/i,
|
|
132
|
+
`<svg width="${width}" height="${height}" preserveAspectRatio="none"`,
|
|
133
|
+
);
|
|
134
|
+
return markup.replace(root[0], cleaned);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 도형 요소 → ``<img>``에 바로 물릴 수 있는 SVG data URI.
|
|
139
|
+
*
|
|
140
|
+
* 색 치환이 있는 svg 도형은 스톡 편집기의 ``replaceColors``가 이미 data URI를 돌려주므로
|
|
141
|
+
* 마크업으로 되돌리지 않는다(되돌렸다 다시 인코딩하면 한글이 깨질 여지만 생긴다).
|
|
142
|
+
*/
|
|
143
|
+
export async function shapeSvgDataUri(
|
|
144
|
+
el: ShapeElementLike,
|
|
145
|
+
): Promise<string | null> {
|
|
146
|
+
const type = String(el.type ?? "");
|
|
147
|
+
const width = num(el.width, 0);
|
|
148
|
+
const height = num(el.height, 0);
|
|
149
|
+
if (width <= 0 || height <= 0) return null;
|
|
150
|
+
|
|
151
|
+
if (type === "figure") {
|
|
152
|
+
const markup = figureToSvg({
|
|
153
|
+
...el,
|
|
154
|
+
width,
|
|
155
|
+
height,
|
|
156
|
+
strokeWidth: num(el.strokeWidth, 0),
|
|
157
|
+
cornerRadius: num(el.cornerRadius, 0),
|
|
158
|
+
dash: Array.isArray(el.dash) ? el.dash : [],
|
|
159
|
+
});
|
|
160
|
+
if (!markup) return null;
|
|
161
|
+
return encodeSvgDataUri(
|
|
162
|
+
withExplicitSize(patchFigureGradient(markup, el), width, height),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (type !== "svg") return null;
|
|
166
|
+
const src = String(el.src ?? "");
|
|
167
|
+
if (!src) return null;
|
|
168
|
+
|
|
169
|
+
const raw = await loadSvgMarkup(src);
|
|
170
|
+
const sized = ensureSvgNamespace(withExplicitSize(raw, width, height));
|
|
171
|
+
return encodeSvgDataUri(
|
|
172
|
+
replaceSvgColors(sized, readColorReplace(el.colorsReplace)),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** SVG data URI를 투명 배경 PNG data URI로 굽는다. 실패하면 null. */
|
|
177
|
+
export async function rasterizeSvgDataUri(
|
|
178
|
+
uri: string,
|
|
179
|
+
width: number,
|
|
180
|
+
height: number,
|
|
181
|
+
): Promise<string | null> {
|
|
182
|
+
const w = Math.max(1, Math.round(width));
|
|
183
|
+
const h = Math.max(1, Math.round(height));
|
|
184
|
+
const scale = Math.min(RASTER_SCALE, MAX_EDGE / Math.max(w, h));
|
|
185
|
+
const canvas = document.createElement("canvas");
|
|
186
|
+
canvas.width = Math.max(1, Math.round(w * scale));
|
|
187
|
+
canvas.height = Math.max(1, Math.round(h * scale));
|
|
188
|
+
const ctx = canvas.getContext("2d");
|
|
189
|
+
if (!ctx) return null;
|
|
190
|
+
|
|
191
|
+
const image = await loadImage(uri);
|
|
192
|
+
if (!image) return null;
|
|
193
|
+
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
194
|
+
return canvas.toDataURL("image/png");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadImage(uri: string): Promise<HTMLImageElement | null> {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const image = new Image();
|
|
200
|
+
image.onload = () => resolve(image);
|
|
201
|
+
image.onerror = () => resolve(null);
|
|
202
|
+
// data URI는 캔버스를 오염시키지 않는다(외부 호스트가 아니라 CORS도 없다).
|
|
203
|
+
image.src = uri;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 선택 도형 → GIF 소스로 보낼 투명 PNG data URI.
|
|
209
|
+
*
|
|
210
|
+
* 도형이 아니거나 구울 수 없으면 ``null`` — 호출부가 안내를 띄운다.
|
|
211
|
+
*/
|
|
212
|
+
export async function shapeSourceImage(
|
|
213
|
+
el: ShapeElementLike,
|
|
214
|
+
): Promise<string | null> {
|
|
215
|
+
if (!isShapeElement(el)) return null;
|
|
216
|
+
const uri = await shapeSvgDataUri(el);
|
|
217
|
+
if (!uri) return null;
|
|
218
|
+
return rasterizeSvgDataUri(uri, num(el.width, 0), num(el.height, 0));
|
|
219
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 엑셀·구글시트에서 붙여넣은 텍스트를 격자로 쪼갠다.
|
|
3
|
+
*
|
|
4
|
+
* 클립보드의 ``text/plain``은 스프레드시트에서 탭 구분 텍스트로 온다. Canva·미리캔버스가
|
|
5
|
+
* 하는 것도 이거고, 파일 업로드보다 압도적으로 자주 쓰인다. CSV(쉼표)·세미콜론도 같이
|
|
6
|
+
* 받아 준다. 차트와 표가 같은 규칙으로 읽어야 사용자가 두 번 배우지 않는다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function splitLines(text: string): string[] {
|
|
10
|
+
return text
|
|
11
|
+
.replace(/\r\n?/g, "\n")
|
|
12
|
+
.split("\n")
|
|
13
|
+
.filter((line) => line.trim().length > 0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 구분자 추론: 탭 > 쉼표 > 세미콜론 > 연속 공백. */
|
|
17
|
+
export function pickDelimiter(lines: string[]): RegExp {
|
|
18
|
+
const joined = lines.join("\n");
|
|
19
|
+
if (joined.includes("\t")) return /\t/;
|
|
20
|
+
if (joined.includes(",")) return /,/;
|
|
21
|
+
if (joined.includes(";")) return /;/;
|
|
22
|
+
return /\s{2,}/;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cellsOf(line: string, delimiter: RegExp): string[] {
|
|
26
|
+
return line.split(delimiter).map((cell) => cell.trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 붙여넣은 텍스트를 문자열 격자로. 읽을 게 없으면 빈 배열. */
|
|
30
|
+
export function parseGrid(text: string): string[][] {
|
|
31
|
+
const lines = splitLines(text ?? "");
|
|
32
|
+
if (lines.length === 0) return [];
|
|
33
|
+
const delimiter = pickDelimiter(lines);
|
|
34
|
+
return lines.map((line) => cellsOf(line, delimiter));
|
|
35
|
+
}
|