@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,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스펙을 든 그룹(차트·표) ↔ Canvas 스토어를 잇는 **유일한** 지점.
|
|
3
|
+
*
|
|
4
|
+
* 차트도 표도 element 타입이 아니라 ``group`` 하나이고, 스펙은 그 그룹의 ``custom``에
|
|
5
|
+
* 얹힌다(차트는 ``custom.chart``, 표는 ``custom.table``). 데이터나 종류가 바뀌면 여기서
|
|
6
|
+
* 자식을 다시 만든다. 무엇을 그리느냐만 ``SpecBinding``으로 갈리고, **스토어를 만지는
|
|
7
|
+
* 방법은 하나뿐**이다 — 아래 규칙 넷은 값을 치르고 얻은 것이라 두 벌로 갈라지면 안 된다.
|
|
8
|
+
*
|
|
9
|
+
* 1. **키 기반 diff.** 전부 지우고 다시 만들지 않는다. 자식 ``custom[partKey]``에 박은
|
|
10
|
+
* 키로 기존 자식을 찾아 ``set``하므로 요소 id가 유지되고, 선택·레이어 트리·undo가
|
|
11
|
+
* 안 흔들린다.
|
|
12
|
+
* 2. **한 번의 undo.** 전체를 ``history.startTransaction``/``endTransaction``으로 감싼다.
|
|
13
|
+
* 안 감싸면 값 하나 고친 게 자식 수만큼의 undo 단계로 쪼개진다.
|
|
14
|
+
* 3. **그룹 base 좌표는 0.** 자식이 페이지 좌표를 들고 있어서 그룹에 x/y가 남으면 자식이
|
|
15
|
+
* 이중으로 밀린다. 상자는 그룹 모델이 아니라 자식 합집합으로 잰다.
|
|
16
|
+
* 4. **자식을 ``selectable: false``로 잠그지 않는다.** 스톡 편집기는 그룹 자체에 히트 영역을
|
|
17
|
+
* 두지 않고 자식을 맞혀 ``el.top``(바깥 그룹)으로 올려 선택한다
|
|
18
|
+
* (``canvas/canvas/element.js``의 group 렌더러가 ``listening``을 자식에게 맡긴다).
|
|
19
|
+
* 자식을 잠그면 클릭이 아무것도 못 맞혀 **차트·표를 아예 고를 수 없게 된다.**
|
|
20
|
+
* 그래서 잠그는 대신 기본 동작에 기댄다 — 한 번 클릭하면 그룹이 잡히고, 안의 칸은
|
|
21
|
+
* 드릴인(한 번 더 클릭)이라는 의도적인 동작으로만 닿는다.
|
|
22
|
+
*
|
|
23
|
+
* canvas를 import하지 않고 구조적 타입으로만 접근한다 — 그래야 이 모듈이 캔버스 없이
|
|
24
|
+
* 테스트된다.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type ElementLike = {
|
|
28
|
+
id?: string;
|
|
29
|
+
type?: string;
|
|
30
|
+
x?: unknown;
|
|
31
|
+
y?: unknown;
|
|
32
|
+
width?: unknown;
|
|
33
|
+
height?: unknown;
|
|
34
|
+
custom?: unknown;
|
|
35
|
+
children?: unknown;
|
|
36
|
+
selectable?: boolean;
|
|
37
|
+
set?: (props: Record<string, unknown>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* 그룹 모델도 페이지와 같은 ``addElement``를 노출한다
|
|
40
|
+
* (``canvas/model/group-model.js``). 이게 있어서 재생성 때 새 자식을 그룹 **안에**
|
|
41
|
+
* 바로 넣을 수 있다.
|
|
42
|
+
*
|
|
43
|
+
* 대안은 ``layer-move``의 해체·재구성이다(그쪽은 attrs에 그룹 id를 실어 보내
|
|
44
|
+
* 같은 그룹으로 되살리므로 id는 지켜진다). 다만 그 경로는 ``ungroupElements``가
|
|
45
|
+
* 자식을 ``e.page``로 올려버려 **페이지 직속 그룹만** 다룰 수 있고, z-order 복원과
|
|
46
|
+
* 선택 되돌리기가 딸려온다. 차트·표는 값 하나 고칠 때마다 도는 길이라 여기서는
|
|
47
|
+
* 자식만 밀어 넣는 쪽이 맞다.
|
|
48
|
+
*/
|
|
49
|
+
addElement?: (
|
|
50
|
+
props: Record<string, unknown>,
|
|
51
|
+
options?: { skipSelect?: boolean },
|
|
52
|
+
) => unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type PageLike = {
|
|
56
|
+
computedWidth?: number;
|
|
57
|
+
computedHeight?: number;
|
|
58
|
+
children?: unknown;
|
|
59
|
+
addElement: (
|
|
60
|
+
props: Record<string, unknown>,
|
|
61
|
+
options?: { skipSelect?: boolean },
|
|
62
|
+
) => unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type HistoryLike = {
|
|
66
|
+
startTransaction?: () => void;
|
|
67
|
+
endTransaction?: (skipSave?: boolean) => void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type StoreLike = {
|
|
71
|
+
activePage?: PageLike;
|
|
72
|
+
pages: PageLike[];
|
|
73
|
+
history?: HistoryLike;
|
|
74
|
+
groupElements?: (ids: string[], attrs?: Record<string, unknown>) => unknown;
|
|
75
|
+
deleteElements?: (ids: string[]) => void;
|
|
76
|
+
selectElements?: (ids: string[]) => void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type Box = { x: number; y: number; width: number; height: number };
|
|
80
|
+
|
|
81
|
+
/** 렌더러가 내놓는 자식 하나. ``key``는 재생성 사이에 요소 id를 잇는 끈이다. */
|
|
82
|
+
export type SpecNode = { key: string; props: Record<string, unknown> };
|
|
83
|
+
export type SpecRender = { nodes: SpecNode[]; size: { width: number; height: number } };
|
|
84
|
+
|
|
85
|
+
/** 스펙이 공통으로 갖는 골격. ``frame``은 리사이즈 판정에 쓰인다. */
|
|
86
|
+
export type SpecBase = { v: 1; frame: { width: number; height: number } };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 한 종류(차트·표)를 이 코어에 물리는 어댑터.
|
|
90
|
+
*
|
|
91
|
+
* ``parse``는 저장된 문서에서 온 값을 검사한다 — 형태를 믿지 않고 최소 골격만 본다.
|
|
92
|
+
* ``absorbResize``는 사용자가 트랜스포머로 늘린 결과를 스펙에 되먹인다(없으면 무시).
|
|
93
|
+
*/
|
|
94
|
+
export type SpecBinding<S extends SpecBase> = {
|
|
95
|
+
/** ``group.custom``에서 스펙이 사는 키. */
|
|
96
|
+
customKey: string;
|
|
97
|
+
/** 자식 ``custom``에서 부품 키가 사는 키. */
|
|
98
|
+
partKey: string;
|
|
99
|
+
/** 레이어 트리에 뜰 기본 이름. */
|
|
100
|
+
defaultName: string;
|
|
101
|
+
render: (spec: S) => SpecRender;
|
|
102
|
+
parse: (raw: unknown) => S | null;
|
|
103
|
+
absorbResize?: (spec: S, box: Box) => S;
|
|
104
|
+
/**
|
|
105
|
+
* 캔버스에서 자식을 직접 고친 걸 스펙으로 되받는다(``base``는 저장돼 있던 스펙,
|
|
106
|
+
* ``incoming``은 이번에 적용하려는 스펙).
|
|
107
|
+
*
|
|
108
|
+
* 다시 그리기 **직전**이 유일하게 안전한 시점이다 — 여기서 안 걷으면 바로 다음 줄에서
|
|
109
|
+
* 자식이 스펙 값으로 덮인다. 없으면 되받기 없이 그대로 그린다(차트가 그렇다).
|
|
110
|
+
*/
|
|
111
|
+
harvest?: (base: S, incoming: S, children: ElementLike[]) => S;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function num(value: unknown, fallback = 0): number {
|
|
115
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
116
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function customOf(el: ElementLike): Record<string, unknown> {
|
|
120
|
+
return el.custom && typeof el.custom === "object"
|
|
121
|
+
? ({ ...el.custom } as Record<string, unknown>)
|
|
122
|
+
: {};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function childrenOf(node: ElementLike | PageLike): ElementLike[] {
|
|
126
|
+
return Array.isArray(node.children) ? (node.children as ElementLike[]) : [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 하나의 undo 단계로 묶는다. history가 없으면(테스트 페이크) 그냥 실행한다. */
|
|
130
|
+
function inTransaction<T>(store: StoreLike, run: () => T): T {
|
|
131
|
+
const history = store.history;
|
|
132
|
+
history?.startTransaction?.();
|
|
133
|
+
try {
|
|
134
|
+
return run();
|
|
135
|
+
} finally {
|
|
136
|
+
history?.endTransaction?.();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 스펙 읽기/쓰기 ───────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** 그룹에서 스펙을 꺼낸다. 그 종류가 아니면 ``null``. */
|
|
143
|
+
export function readSpec<S extends SpecBase>(
|
|
144
|
+
binding: SpecBinding<S>,
|
|
145
|
+
el: ElementLike | null | undefined,
|
|
146
|
+
): S | null {
|
|
147
|
+
if (!el || el.type !== "group") return null;
|
|
148
|
+
const custom = el.custom as Record<string, unknown> | undefined;
|
|
149
|
+
const raw = custom?.[binding.customKey];
|
|
150
|
+
if (!raw || typeof raw !== "object") return null;
|
|
151
|
+
return binding.parse(raw);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 스펙을 그룹에 되쓴다(다른 custom 키는 보존). */
|
|
155
|
+
export function writeSpec<S extends SpecBase>(
|
|
156
|
+
binding: SpecBinding<S>,
|
|
157
|
+
el: ElementLike,
|
|
158
|
+
spec: S,
|
|
159
|
+
): void {
|
|
160
|
+
el.set?.({ custom: { ...customOf(el), [binding.customKey]: spec } });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 배치 ────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** 그룹이 실제로 차지하는 상자. 그룹 모델의 x/y/width/height가 아니라 자식에서 잰다. */
|
|
166
|
+
export function groupBox(group: ElementLike): Box | null {
|
|
167
|
+
const kids = childrenOf(group);
|
|
168
|
+
if (kids.length === 0) return null;
|
|
169
|
+
let left = Infinity;
|
|
170
|
+
let top = Infinity;
|
|
171
|
+
let right = -Infinity;
|
|
172
|
+
let bottom = -Infinity;
|
|
173
|
+
for (const kid of kids) {
|
|
174
|
+
const x = num(kid.x);
|
|
175
|
+
const y = num(kid.y);
|
|
176
|
+
left = Math.min(left, x);
|
|
177
|
+
top = Math.min(top, y);
|
|
178
|
+
right = Math.max(right, x + num(kid.width));
|
|
179
|
+
bottom = Math.max(bottom, y + num(kid.height));
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
|
|
182
|
+
return {
|
|
183
|
+
x: left,
|
|
184
|
+
y: top,
|
|
185
|
+
width: Math.max(1, right - left),
|
|
186
|
+
height: Math.max(1, bottom - top),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type InsertSpecOptions = {
|
|
191
|
+
/** 프레임 좌상단(페이지 좌표). 없으면 페이지 가운데. */
|
|
192
|
+
origin?: { x: number; y: number };
|
|
193
|
+
/** 레이어 트리에 뜰 이름. */
|
|
194
|
+
name?: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 새 스펙 그룹을 페이지에 놓고 그 그룹을 돌려준다.
|
|
199
|
+
*
|
|
200
|
+
* ``spec.frame.height``는 렌더 결과로 채워져 그룹에 저장된다 — 이 값이 있어야 다음
|
|
201
|
+
* 재생성 때 "사용자가 세로로 늘렸는지"를 판정할 수 있다.
|
|
202
|
+
*/
|
|
203
|
+
export function insertSpecGroup<S extends SpecBase>(
|
|
204
|
+
binding: SpecBinding<S>,
|
|
205
|
+
store: StoreLike,
|
|
206
|
+
spec: S,
|
|
207
|
+
{ origin, name = binding.defaultName }: InsertSpecOptions = {},
|
|
208
|
+
): ElementLike | null {
|
|
209
|
+
const page = store.activePage ?? store.pages[0];
|
|
210
|
+
if (!page || !store.groupElements) return null;
|
|
211
|
+
|
|
212
|
+
const render = binding.render(spec);
|
|
213
|
+
const placed: S = {
|
|
214
|
+
...spec,
|
|
215
|
+
frame: { width: render.size.width, height: render.size.height },
|
|
216
|
+
};
|
|
217
|
+
const pageWidth = num(page.computedWidth, render.size.width);
|
|
218
|
+
const pageHeight = num(page.computedHeight, render.size.height);
|
|
219
|
+
const at = origin ?? {
|
|
220
|
+
x: Math.round((pageWidth - render.size.width) / 2),
|
|
221
|
+
y: Math.round((pageHeight - render.size.height) / 2),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const childProps = (key: string, props: Record<string, unknown>) => ({
|
|
225
|
+
...props,
|
|
226
|
+
custom: { [binding.partKey]: key },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return inTransaction(store, () => {
|
|
230
|
+
const ids: string[] = [];
|
|
231
|
+
for (const node of render.nodes) {
|
|
232
|
+
// skipSelect: 부품을 하나 넣을 때마다 선택이 옮겨 다니면 그 사이 우측 인스펙터가
|
|
233
|
+
// 깜빡인다. 최종 선택은 groupElements가 그룹으로 잡아 준다.
|
|
234
|
+
const added = page.addElement(
|
|
235
|
+
childProps(node.key, {
|
|
236
|
+
...node.props,
|
|
237
|
+
x: at.x + num(node.props.x),
|
|
238
|
+
y: at.y + num(node.props.y),
|
|
239
|
+
}),
|
|
240
|
+
{ skipSelect: true },
|
|
241
|
+
) as ElementLike | undefined;
|
|
242
|
+
const id = added?.id;
|
|
243
|
+
if (typeof id === "string") ids.push(id);
|
|
244
|
+
}
|
|
245
|
+
if (ids.length === 0) return null;
|
|
246
|
+
const group = store.groupElements?.(ids, {
|
|
247
|
+
name,
|
|
248
|
+
custom: { [binding.customKey]: placed },
|
|
249
|
+
}) as ElementLike | undefined;
|
|
250
|
+
return group ?? null;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 스펙이 바뀐 그룹을 다시 그린다.
|
|
256
|
+
*
|
|
257
|
+
* 그리기 전에 현재 상자를 재서 사용자의 리사이즈를 스펙에 흡수하고, 그린 뒤의 실제
|
|
258
|
+
* 높이를 ``frame.height``에 되먹인다. 위치는 **현재 상자의 좌상단**을 유지한다 —
|
|
259
|
+
* 데이터를 고쳤다고 차트·표가 페이지 가운데로 튀면 안 된다.
|
|
260
|
+
*/
|
|
261
|
+
export function syncSpecGroup<S extends SpecBase>(
|
|
262
|
+
binding: SpecBinding<S>,
|
|
263
|
+
store: StoreLike,
|
|
264
|
+
group: ElementLike,
|
|
265
|
+
next: S,
|
|
266
|
+
): S {
|
|
267
|
+
// 되받기가 먼저다. 아래 루프가 자식을 스펙 값으로 ``set``하므로, 여기서 안 걷은
|
|
268
|
+
// 캔버스 편집은 이 함수 안에서 사라진다.
|
|
269
|
+
const stored = readSpec(binding, group);
|
|
270
|
+
const harvested =
|
|
271
|
+
stored && binding.harvest ? binding.harvest(stored, next, childrenOf(group)) : next;
|
|
272
|
+
|
|
273
|
+
const box = groupBox(group);
|
|
274
|
+
const spec =
|
|
275
|
+
box && binding.absorbResize ? binding.absorbResize(harvested, box) : harvested;
|
|
276
|
+
const render = binding.render(spec);
|
|
277
|
+
const at = box ? { x: box.x, y: box.y } : { x: 0, y: 0 };
|
|
278
|
+
|
|
279
|
+
const existing = new Map<string, ElementLike>();
|
|
280
|
+
for (const kid of childrenOf(group)) {
|
|
281
|
+
const key = (kid.custom as Record<string, unknown> | undefined)?.[binding.partKey];
|
|
282
|
+
if (typeof key === "string") existing.set(key, kid);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const saved: S = {
|
|
286
|
+
...spec,
|
|
287
|
+
frame: { width: render.size.width, height: render.size.height },
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const childProps = (key: string, props: Record<string, unknown>) => ({
|
|
291
|
+
...props,
|
|
292
|
+
custom: { [binding.partKey]: key },
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
inTransaction(store, () => {
|
|
296
|
+
const kept = new Set<string>();
|
|
297
|
+
for (const node of render.nodes) {
|
|
298
|
+
kept.add(node.key);
|
|
299
|
+
const props: Record<string, unknown> = {
|
|
300
|
+
...node.props,
|
|
301
|
+
x: at.x + num(node.props.x),
|
|
302
|
+
y: at.y + num(node.props.y),
|
|
303
|
+
};
|
|
304
|
+
const found = existing.get(node.key);
|
|
305
|
+
if (found) {
|
|
306
|
+
// 타입이 달라졌으면(막대 → 도넛처럼) 갈아 끼울 수 없다. 지우고 다시 만든다.
|
|
307
|
+
if (found.type === props.type) {
|
|
308
|
+
found.set?.(childProps(node.key, props));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
store.deleteElements?.([String(found.id ?? "")]);
|
|
312
|
+
existing.delete(node.key);
|
|
313
|
+
}
|
|
314
|
+
// 페이지가 아니라 **그룹 안**에 넣는다. 페이지에 넣으면 그룹 밖으로 떨어져 나온다.
|
|
315
|
+
group.addElement?.(childProps(node.key, props), { skipSelect: true });
|
|
316
|
+
}
|
|
317
|
+
const stale = [...existing.entries()]
|
|
318
|
+
.filter(([key]) => !kept.has(key))
|
|
319
|
+
.map(([, el]) => String(el.id ?? ""))
|
|
320
|
+
.filter(Boolean);
|
|
321
|
+
if (stale.length > 0) store.deleteElements?.(stale);
|
|
322
|
+
writeSpec(binding, group, saved);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return saved;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 스펙 그룹을 일반 그룹으로 푼다.
|
|
330
|
+
*
|
|
331
|
+
* 스펙과 부품 표시를 떼면 인스펙터가 평범한 그룹으로 보고, 데이터를 고쳐도 다시 그리는
|
|
332
|
+
* 일이 없어진다. 되돌리기(다시 차트·표로 만들기)는 제공하지 않는다 — 사용자가 자유롭게
|
|
333
|
+
* 만진 뒤라 스펙과 화면이 이미 다르기 때문이다.
|
|
334
|
+
*/
|
|
335
|
+
export function detachSpecGroup<S extends SpecBase>(
|
|
336
|
+
binding: SpecBinding<S>,
|
|
337
|
+
group: ElementLike,
|
|
338
|
+
): void {
|
|
339
|
+
const custom = customOf(group);
|
|
340
|
+
delete custom[binding.customKey];
|
|
341
|
+
group.set?.({ custom });
|
|
342
|
+
for (const kid of childrenOf(group)) {
|
|
343
|
+
const kidCustom = customOf(kid);
|
|
344
|
+
delete kidCustom[binding.partKey];
|
|
345
|
+
kid.set?.({ custom: kidCustom });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 문서에서 가장 많이 쓰인 본문 폰트. 새로 넣는 차트·표가 페이지 톤을 따라가게 한다.
|
|
351
|
+
*
|
|
352
|
+
* 못 찾으면 ``undefined``를 돌려 호출자가 기본 폰트로 떨어지게 둔다.
|
|
353
|
+
*/
|
|
354
|
+
export function documentFontFamily(store: StoreLike): string | undefined {
|
|
355
|
+
const tally = new Map<string, number>();
|
|
356
|
+
const walk = (nodes: ElementLike[]) => {
|
|
357
|
+
for (const node of nodes) {
|
|
358
|
+
if (node.type === "group") walk(childrenOf(node));
|
|
359
|
+
else if (node.type === "text") {
|
|
360
|
+
const family = (node as { fontFamily?: unknown }).fontFamily;
|
|
361
|
+
if (typeof family === "string" && family) {
|
|
362
|
+
tally.set(family, (tally.get(family) ?? 0) + 1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
for (const page of store.pages ?? []) walk(childrenOf(page));
|
|
368
|
+
let best: string | undefined;
|
|
369
|
+
let most = 0;
|
|
370
|
+
for (const [family, count] of tally) {
|
|
371
|
+
if (count > most) {
|
|
372
|
+
most = count;
|
|
373
|
+
best = family;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return best;
|
|
377
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 폰트 없이 재는 글자 치수. 차트·표 레이아웃이 같은 자를 쓴다.
|
|
3
|
+
*
|
|
4
|
+
* 캔버스도 폰트도 없이 도는 순수 계산이라 서버·테스트·썸네일 어디서나 같은 값이 나온다.
|
|
5
|
+
* 정확할 필요는 없고 **겹치지만 않으면** 된다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 텍스트 한 줄이 차지하는 높이 비율. 레이아웃 계산과 실제 렌더가 어긋나지 않게 한 곳에서만 잰다. */
|
|
9
|
+
export const LINE_HEIGHT = 1.3;
|
|
10
|
+
|
|
11
|
+
export function textHeight(fontSize: number): number {
|
|
12
|
+
return Math.round(fontSize * LINE_HEIGHT);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 글자 폭 어림값.
|
|
17
|
+
*
|
|
18
|
+
* 한글·한자·가나는 정사각에 가깝고 라틴/숫자는 그 절반쯤이다.
|
|
19
|
+
*/
|
|
20
|
+
export function estimateTextWidth(text: string, fontSize: number): number {
|
|
21
|
+
let units = 0;
|
|
22
|
+
for (const char of text) {
|
|
23
|
+
units += /[ᄀ-ᇿ-가-一-鿿-ヿ]/.test(char) ? 1 : 0.55;
|
|
24
|
+
}
|
|
25
|
+
return units * fontSize;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 폭이 정해진 상자에서 글자가 차지할 줄 수.
|
|
30
|
+
*
|
|
31
|
+
* 폭 추정이라 실제 Konva 줄바꿈과 한두 글자 어긋날 수 있다. 적게 세면 글자가 칸 밖으로
|
|
32
|
+
* 흘러 아래 행의 구분선과 겹치므로, 폭을 살짝 넉넉히 잡아 **많이 세는 쪽으로** 기운다.
|
|
33
|
+
*/
|
|
34
|
+
export function estimateLineCount(
|
|
35
|
+
text: string,
|
|
36
|
+
fontSize: number,
|
|
37
|
+
boxWidth: number,
|
|
38
|
+
): number {
|
|
39
|
+
if (!text) return 1;
|
|
40
|
+
const usable = Math.max(1, boxWidth);
|
|
41
|
+
const explicit = text.split("\n");
|
|
42
|
+
let lines = 0;
|
|
43
|
+
for (const segment of explicit) {
|
|
44
|
+
const width = estimateTextWidth(segment, fontSize) * 1.04;
|
|
45
|
+
lines += Math.max(1, Math.ceil(width / usable));
|
|
46
|
+
}
|
|
47
|
+
return Math.max(1, lines);
|
|
48
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 무료 스톡 사진(Pexels) 검색 — 편집기 "사진" 패널이 쓰는 얇은 층.
|
|
3
|
+
*
|
|
4
|
+
* 검색은 우리 서버(`/api/stock-photos`)를 거친다(키를 브라우저에 안 내린다).
|
|
5
|
+
* 고른 사진은 **우리 S3로 옮겨 담은 뒤** 캔버스에 얹는다. 남의 주소를 문서에 그대로
|
|
6
|
+
* 박으면 상세페이지가 그 서버의 수명에 묶이고, 나중에 서버가 다시 그릴 때도 그 주소를
|
|
7
|
+
* 다시 받아야 한다. 브랜드 이미지·AI 이미지가 이미 같은 규칙으로 산다.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type StockPhoto = {
|
|
11
|
+
id: string;
|
|
12
|
+
/** 격자에 뿌리는 작은 그림. */
|
|
13
|
+
thumb: string;
|
|
14
|
+
/** 캔버스에 얹을 원본급 그림. */
|
|
15
|
+
full: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
alt: string;
|
|
19
|
+
photographer: string;
|
|
20
|
+
photographerUrl: string;
|
|
21
|
+
/** 제공처의 사진 페이지 — 출처 표기 링크가 여기로 간다. */
|
|
22
|
+
pageUrl: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StockPhotoResponse = {
|
|
26
|
+
photos: StockPhoto[];
|
|
27
|
+
hasMore: boolean;
|
|
28
|
+
/** 서버에 키가 없으면 false — 패널은 오류 대신 안내를 띄운다. */
|
|
29
|
+
configured: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type StockPhotoQuery = {
|
|
33
|
+
query: string;
|
|
34
|
+
page: number;
|
|
35
|
+
perPage?: number;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function searchStockPhotos({
|
|
40
|
+
query,
|
|
41
|
+
page,
|
|
42
|
+
perPage,
|
|
43
|
+
signal,
|
|
44
|
+
}: StockPhotoQuery): Promise<StockPhotoResponse> {
|
|
45
|
+
const params = new URLSearchParams({ page: String(page) });
|
|
46
|
+
if (query.trim()) params.set("q", query.trim());
|
|
47
|
+
if (perPage) params.set("per_page", String(perPage));
|
|
48
|
+
|
|
49
|
+
const response = await fetch(`/api/stock-photos?${params.toString()}`, {
|
|
50
|
+
signal,
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) throw new Error(`stock-photos ${response.status}`);
|
|
53
|
+
return (await response.json()) as StockPhotoResponse;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extensionOf(url: string): string {
|
|
57
|
+
const match = /\.(jpe?g|png|webp)(?:$|\?)/i.exec(url);
|
|
58
|
+
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 사진을 받아 파일로 만든다. 업로더가 File을 받으므로 여기서 Blob을 감싼다.
|
|
63
|
+
*
|
|
64
|
+
* Pexels CDN은 `Access-Control-Allow-Origin: *`을 준다 — 브라우저에서 바로 받을 수
|
|
65
|
+
* 있다는 뜻이고, 이건 나중에 캔버스를 내보낼 때도 같은 이유로 필요하다.
|
|
66
|
+
*/
|
|
67
|
+
export async function fetchStockPhotoFile(photo: StockPhoto): Promise<File> {
|
|
68
|
+
const response = await fetch(photo.full, { mode: "cors" });
|
|
69
|
+
if (!response.ok) throw new Error(`stock-photo-download ${response.status}`);
|
|
70
|
+
const blob = await response.blob();
|
|
71
|
+
const type = blob.type || "image/jpeg";
|
|
72
|
+
return new File([blob], `pexels-${photo.id}.${extensionOf(photo.full)}`, {
|
|
73
|
+
type,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 사진을 우리 쪽 주소로 옮겨 담는다. 업로더가 없으면(예: 개발용 하니스) 원본 주소를
|
|
79
|
+
* 그대로 돌려준다 — 넣는 일 자체는 막지 않는다.
|
|
80
|
+
*/
|
|
81
|
+
export async function mirrorStockPhoto(
|
|
82
|
+
photo: StockPhoto,
|
|
83
|
+
uploadFile?: (file: File) => Promise<string>,
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
if (!uploadFile) return photo.full;
|
|
86
|
+
const file = await fetchStockPhotoFile(photo);
|
|
87
|
+
return uploadFile(file);
|
|
88
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG 요소의 색을 **읽어 내는** 자리.
|
|
3
|
+
*
|
|
4
|
+
* 갈아 끼우는 쪽(`replaceSvgColors` / `colorsReplace`)은 렌더러에 이미 있었는데
|
|
5
|
+
* **그 값을 쓰는 UI가 리포에 하나도 없었다.** 도형을 넣으면 소스 색 그대로 박제됐고,
|
|
6
|
+
* 서식 복사로 다른 도형 색을 옮겨오는 우회로만 있었다. 우측 인스펙터에 색 구획을 세우려면
|
|
7
|
+
* 먼저 "이 마크업에 어떤 색이 몇 개 있는가"를 알아야 해서 그 절반을 여기 둔다.
|
|
8
|
+
*
|
|
9
|
+
* 정규화·치환은 렌더러 것을 그대로 쓴다 — 판정이 갈리면 화면과 컨트롤이 다른 말을 한다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { normalizeColor } from "@leviosa-ai/canvas/render/svg-source";
|
|
13
|
+
|
|
14
|
+
const COLOR_ATTRS = "fill|stroke|stop-color|flood-color|lighting-color";
|
|
15
|
+
const ATTR_RE = new RegExp(`\\b(?:${COLOR_ATTRS})\\s*=\\s*(["'])(.*?)\\1`, "gi");
|
|
16
|
+
const STYLE_DECL_RE = new RegExp(`\\b(?:${COLOR_ATTRS})\\s*:\\s*([^;"']+)`, "gi");
|
|
17
|
+
|
|
18
|
+
/** 색이 아닌 값들. 스와치로 내면 안 된다. */
|
|
19
|
+
const NOT_A_COLOR = new Set(["none", "transparent", "inherit", "unset", "initial"]);
|
|
20
|
+
|
|
21
|
+
function isColor(raw: string): boolean {
|
|
22
|
+
const value = raw.trim().toLowerCase();
|
|
23
|
+
if (!value || NOT_A_COLOR.has(value)) return false;
|
|
24
|
+
// `url(#grad)`는 그라데이션 참조지 색이 아니다.
|
|
25
|
+
return !value.startsWith("url(");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 마크업에 실제로 쓰인 색을 **등장 순서대로, 중복 없이** 뽑는다.
|
|
30
|
+
*
|
|
31
|
+
* 표기가 달라도(`#abc` / `#AABBCC` / `rgb(...)`) 같은 색이면 하나로 본다 —
|
|
32
|
+
* 치환도 정규화해서 견주므로 스와치 하나가 두 표기를 한꺼번에 바꾼다.
|
|
33
|
+
* `currentColor`는 그대로 남긴다: 색이 맞고, 삽입 시 구체 색으로 바뀐다(§`applyCurrentColor`).
|
|
34
|
+
*/
|
|
35
|
+
export function extractSvgColors(markup: string): string[] {
|
|
36
|
+
if (!markup) return [];
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
|
|
40
|
+
const take = (raw: string) => {
|
|
41
|
+
if (!isColor(raw)) return;
|
|
42
|
+
const key = normalizeColor(raw);
|
|
43
|
+
if (seen.has(key)) return;
|
|
44
|
+
seen.add(key);
|
|
45
|
+
out.push(key);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const match of markup.matchAll(ATTR_RE)) take(match[2]);
|
|
49
|
+
for (const match of markup.matchAll(STYLE_DECL_RE)) take(match[1]);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `currentColor`를 구체 색으로 박는다.
|
|
55
|
+
*
|
|
56
|
+
* 모노크롬 아이콘 세트는 전부 `currentColor` 규약이다. 그런데 우리는 마크업을 data URI로
|
|
57
|
+
* 담아 `<img>`로 그리므로 **문서의 색을 상속받지 못한다** — 넣는 순간 정해야 한다.
|
|
58
|
+
* 구체 색으로 박아 두면 그 뒤로는 공용 도형·내 도형과 **같은 색 컨트롤**이 붙는다.
|
|
59
|
+
*/
|
|
60
|
+
export function applyCurrentColor(markup: string, color: string): string {
|
|
61
|
+
if (!markup) return markup;
|
|
62
|
+
return markup.replace(/currentColor/gi, color);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 지금 화면에 보여 줄 색. `colorsReplace`에 바뀐 값이 있으면 그것, 없으면 원래 색.
|
|
67
|
+
* 인스펙터의 스와치가 이 값을 쓴다.
|
|
68
|
+
*/
|
|
69
|
+
export function effectiveColor(
|
|
70
|
+
original: string,
|
|
71
|
+
replaced: Map<string, string>,
|
|
72
|
+
): string {
|
|
73
|
+
return replaced.get(normalizeColor(original)) ?? original;
|
|
74
|
+
}
|