@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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 표 기본값과 프리셋.
|
|
3
|
+
*
|
|
4
|
+
* **프리셋은 브랜드 상세페이지 실물에서 뽑았다.** 범용 표 라이브러리 기본값(굵은 격자·
|
|
5
|
+
* 회색 머리글)은 상세페이지에 얹는 순간 "AI티"가 난다. 근거가 되는 마크업은
|
|
6
|
+
* `docs/detail-page-table-element-plan.md` §0.2에 파일·줄 번호로 적어 뒀다.
|
|
7
|
+
*
|
|
8
|
+
* 구조(첫 칸 190px 고정, 행 구분선, 위 굵은 선)는 브랜드 값을 그대로 쓰고, **색은 중립
|
|
9
|
+
* 먹빛**으로 둔다. 브랜드 색을 그대로 구우면 다른 문서에 넣었을 때 혼자 튄다 — 색은
|
|
10
|
+
* 인스펙터에서 문서 팔레트에 맞춘다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CellAlign, TableSpec, TableStyle } from "./types";
|
|
14
|
+
|
|
15
|
+
/** 문서에서 폰트를 못 고를 때의 마지막 기본값. */
|
|
16
|
+
export const FALLBACK_TABLE_FONT = "Pretendard";
|
|
17
|
+
|
|
18
|
+
const INK = "#1F2124";
|
|
19
|
+
const BODY = "#3C4043";
|
|
20
|
+
const LINE = "#E3E5E8";
|
|
21
|
+
const LINE_SOFT = "#EFF1F3";
|
|
22
|
+
const CELL = "#F5F6F8";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 브랜드 두 곳이 똑같이 쓰는 골격.
|
|
26
|
+
*
|
|
27
|
+
* 첫 칸 190px · 글자 18px · 좌우 여백 22px · 상하 여백 20px.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_TABLE_STYLE: TableStyle = {
|
|
30
|
+
fontFamily: FALLBACK_TABLE_FONT,
|
|
31
|
+
fontSize: 18,
|
|
32
|
+
color: BODY,
|
|
33
|
+
padX: 22,
|
|
34
|
+
padY: 20,
|
|
35
|
+
|
|
36
|
+
firstWidth: 190,
|
|
37
|
+
columnWidths: null,
|
|
38
|
+
firstFill: null,
|
|
39
|
+
firstAlign: "left",
|
|
40
|
+
firstColor: INK,
|
|
41
|
+
firstWeight: 700,
|
|
42
|
+
firstFontFamily: null,
|
|
43
|
+
firstSize: null,
|
|
44
|
+
|
|
45
|
+
columnRule: null,
|
|
46
|
+
rowRule: { color: LINE_SOFT, width: 1 },
|
|
47
|
+
topRule: { color: INK, width: 1.5 },
|
|
48
|
+
outerBorder: null,
|
|
49
|
+
|
|
50
|
+
bodyFill: null,
|
|
51
|
+
zebraFill: LINE_SOFT,
|
|
52
|
+
headerFill: null,
|
|
53
|
+
headerColor: INK,
|
|
54
|
+
headerWeight: 700,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_TABLE_DATA = {
|
|
58
|
+
columns: ["항목", "내용"],
|
|
59
|
+
rows: [
|
|
60
|
+
["용량", "50ml"],
|
|
61
|
+
["제형", "가볍게 발리는 젤 크림"],
|
|
62
|
+
["사용 시점", "아침 · 저녁 세안 후"],
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type CreateTableOptions = {
|
|
67
|
+
kind?: TableSpec["kind"];
|
|
68
|
+
width?: number;
|
|
69
|
+
data?: TableSpec["data"];
|
|
70
|
+
style?: Partial<TableStyle>;
|
|
71
|
+
options?: Partial<TableSpec["options"]>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function createTableSpec({
|
|
75
|
+
kind = "keyvalue",
|
|
76
|
+
width = 646,
|
|
77
|
+
data = DEFAULT_TABLE_DATA,
|
|
78
|
+
style = {},
|
|
79
|
+
options = {},
|
|
80
|
+
}: CreateTableOptions = {}): TableSpec {
|
|
81
|
+
return {
|
|
82
|
+
v: 1,
|
|
83
|
+
kind,
|
|
84
|
+
frame: { width, height: 0 },
|
|
85
|
+
data: {
|
|
86
|
+
columns: [...data.columns],
|
|
87
|
+
rows: data.rows.map((row) => [...row]),
|
|
88
|
+
},
|
|
89
|
+
options: {
|
|
90
|
+
headerRow: true,
|
|
91
|
+
align: [],
|
|
92
|
+
zebra: false,
|
|
93
|
+
...options,
|
|
94
|
+
},
|
|
95
|
+
style: { ...DEFAULT_TABLE_STYLE, ...style },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type TablePreset = {
|
|
100
|
+
id: string;
|
|
101
|
+
/** 로케일 키(`detailPage.table.preset.*`). */
|
|
102
|
+
labelKey: string;
|
|
103
|
+
kind: TableSpec["kind"];
|
|
104
|
+
style: Partial<TableStyle>;
|
|
105
|
+
options?: Partial<TableSpec["options"]>;
|
|
106
|
+
data?: TableSpec["data"];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const SPEC_DATA = DEFAULT_TABLE_DATA;
|
|
110
|
+
|
|
111
|
+
const RIGHT_ALIGNED: CellAlign[] = ["left", "right"];
|
|
112
|
+
|
|
113
|
+
const SIZE_DATA = {
|
|
114
|
+
columns: ["SIZE", "총장", "어깨", "가슴"],
|
|
115
|
+
rows: [
|
|
116
|
+
["S", "120", "32", "96"],
|
|
117
|
+
["M", "123", "33", "102"],
|
|
118
|
+
["L", "126", "34", "108"],
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 좌측 패널에 뜨는 프리셋.
|
|
124
|
+
*
|
|
125
|
+
* 1·2번이 브랜드 두 판이다 — **구조는 같고 스타일 값 네 개만 다르다**
|
|
126
|
+
* (`firstFill` · `columnRule` · `outerBorder` · `bodyFill`). 렌더러에 브랜드 분기가
|
|
127
|
+
* 없다는 걸 이 둘이 증명한다.
|
|
128
|
+
*/
|
|
129
|
+
const PRESETS: TablePreset[] = [
|
|
130
|
+
{
|
|
131
|
+
// 헤어라인 스펙표 — 위 굵은 선 + 행 구분선만. 채움도 테두리도 없다.
|
|
132
|
+
id: "spec-hairline",
|
|
133
|
+
labelKey: "detailPage.table.preset.specHairline",
|
|
134
|
+
kind: "keyvalue",
|
|
135
|
+
style: {},
|
|
136
|
+
data: SPEC_DATA,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
// 칸 구분 스펙표 — 이름 칸에 바탕을 깔고 세로선으로 가른다. 긴 값(전성분)에서
|
|
140
|
+
// 어느 줄의 값인지 눈으로 따라가려면 이 판이 필요하다.
|
|
141
|
+
id: "spec-celled",
|
|
142
|
+
labelKey: "detailPage.table.preset.specCelled",
|
|
143
|
+
kind: "keyvalue",
|
|
144
|
+
style: {
|
|
145
|
+
firstFill: CELL,
|
|
146
|
+
columnRule: { color: LINE, width: 1 },
|
|
147
|
+
rowRule: { color: LINE, width: 1 },
|
|
148
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
149
|
+
bodyFill: "#FFFFFF",
|
|
150
|
+
},
|
|
151
|
+
data: SPEC_DATA,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
// 값 우측정렬 목록 — 수치를 오른쪽 끝에 세워 자릿수를 맞춘다.
|
|
155
|
+
id: "spec-right",
|
|
156
|
+
labelKey: "detailPage.table.preset.specRight",
|
|
157
|
+
kind: "keyvalue",
|
|
158
|
+
style: { firstWidth: 200, rowRule: { color: LINE, width: 1 } },
|
|
159
|
+
options: { align: RIGHT_ALIGNED },
|
|
160
|
+
data: {
|
|
161
|
+
columns: ["항목", "값"],
|
|
162
|
+
rows: [
|
|
163
|
+
["보습 지속", "12시간"],
|
|
164
|
+
["임상 만족도", "94%"],
|
|
165
|
+
["끈적임 없음", "97%"],
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
// 가운데 정렬 타입표 — 이름 칸을 가운데로 세우고 강조색을 준다(피부 타입·라인업).
|
|
171
|
+
id: "type-centered",
|
|
172
|
+
labelKey: "detailPage.table.preset.typeCentered",
|
|
173
|
+
kind: "keyvalue",
|
|
174
|
+
style: {
|
|
175
|
+
firstWidth: 168,
|
|
176
|
+
firstAlign: "center",
|
|
177
|
+
firstFill: CELL,
|
|
178
|
+
firstWeight: 700,
|
|
179
|
+
columnRule: { color: LINE, width: 1 },
|
|
180
|
+
rowRule: { color: LINE, width: 1 },
|
|
181
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
182
|
+
bodyFill: "#FFFFFF",
|
|
183
|
+
topRule: null,
|
|
184
|
+
},
|
|
185
|
+
data: {
|
|
186
|
+
columns: ["타입", "이렇게 쓰세요"],
|
|
187
|
+
rows: [
|
|
188
|
+
["건성", "밤에 한 번 더 덧발라 주세요"],
|
|
189
|
+
["지성", "얇게 한 겹만 펴 발라 주세요"],
|
|
190
|
+
["복합성", "볼에만 덧발라 주세요"],
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
// 머리글 있는 N열 그리드 — 사이즈표·비교표.
|
|
196
|
+
id: "grid-header",
|
|
197
|
+
labelKey: "detailPage.table.preset.gridHeader",
|
|
198
|
+
kind: "grid",
|
|
199
|
+
style: {
|
|
200
|
+
firstWidth: null,
|
|
201
|
+
headerFill: CELL,
|
|
202
|
+
rowRule: { color: LINE, width: 1 },
|
|
203
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
204
|
+
bodyFill: "#FFFFFF",
|
|
205
|
+
topRule: null,
|
|
206
|
+
padX: 16,
|
|
207
|
+
},
|
|
208
|
+
options: { headerRow: true, align: ["center", "center", "center", "center"] },
|
|
209
|
+
data: SIZE_DATA,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
// 얼룩 그리드 — 행이 많은 비교표에서 줄을 눈으로 따라가게 한다.
|
|
213
|
+
id: "grid-zebra",
|
|
214
|
+
labelKey: "detailPage.table.preset.gridZebra",
|
|
215
|
+
kind: "grid",
|
|
216
|
+
style: {
|
|
217
|
+
firstWidth: null,
|
|
218
|
+
headerFill: INK,
|
|
219
|
+
headerColor: "#FFFFFF",
|
|
220
|
+
rowRule: null,
|
|
221
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
222
|
+
bodyFill: "#FFFFFF",
|
|
223
|
+
topRule: null,
|
|
224
|
+
padX: 16,
|
|
225
|
+
},
|
|
226
|
+
options: { headerRow: true, zebra: true, align: ["left", "center", "center", "center"] },
|
|
227
|
+
data: SIZE_DATA,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
// 비교표 — 첫 열은 항목, 우리 칸에 바탕을 깔아 눈이 먼저 간다.
|
|
231
|
+
id: "grid-compare",
|
|
232
|
+
labelKey: "detailPage.table.preset.gridCompare",
|
|
233
|
+
kind: "grid",
|
|
234
|
+
style: {
|
|
235
|
+
firstWidth: 200,
|
|
236
|
+
headerFill: CELL,
|
|
237
|
+
rowRule: { color: LINE, width: 1 },
|
|
238
|
+
columnRule: { color: LINE, width: 1 },
|
|
239
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
240
|
+
topRule: null,
|
|
241
|
+
padX: 16,
|
|
242
|
+
},
|
|
243
|
+
options: { headerRow: true, align: ["left", "center", "center"] },
|
|
244
|
+
data: {
|
|
245
|
+
columns: ["비교 항목", "본 제품", "일반 제품"],
|
|
246
|
+
rows: [
|
|
247
|
+
["보습 지속", "12시간", "6시간"],
|
|
248
|
+
["끈적임", "없음", "있음"],
|
|
249
|
+
["향료", "무첨가", "첨가"],
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
// 체크 비교표 — 값 대신 기호만 세운다. 스펙 숫자가 없을 때 쓰는 판.
|
|
255
|
+
id: "grid-check",
|
|
256
|
+
labelKey: "detailPage.table.preset.gridCheck",
|
|
257
|
+
kind: "grid",
|
|
258
|
+
style: {
|
|
259
|
+
firstWidth: 240,
|
|
260
|
+
headerFill: INK,
|
|
261
|
+
headerColor: "#FFFFFF",
|
|
262
|
+
rowRule: { color: LINE_SOFT, width: 1 },
|
|
263
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
264
|
+
topRule: null,
|
|
265
|
+
padX: 16,
|
|
266
|
+
},
|
|
267
|
+
options: { headerRow: true, align: ["left", "center", "center"] },
|
|
268
|
+
data: {
|
|
269
|
+
columns: ["기능", "본 제품", "일반 제품"],
|
|
270
|
+
rows: [
|
|
271
|
+
["저자극 테스트", "O", "X"],
|
|
272
|
+
["비건 인증", "O", "X"],
|
|
273
|
+
["국내 생산", "O", "O"],
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
// 선 없는 여백형 — 표처럼 안 보이는 표. 감성 톤 상세페이지의 스펙 자리다.
|
|
279
|
+
id: "spec-airy",
|
|
280
|
+
labelKey: "detailPage.table.preset.specAiry",
|
|
281
|
+
kind: "keyvalue",
|
|
282
|
+
style: {
|
|
283
|
+
firstWidth: 170,
|
|
284
|
+
firstColor: "#8A8F96",
|
|
285
|
+
firstWeight: 500,
|
|
286
|
+
rowRule: null,
|
|
287
|
+
topRule: null,
|
|
288
|
+
padY: 14,
|
|
289
|
+
},
|
|
290
|
+
data: SPEC_DATA,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
// 상자형 스펙표 — 큰 라운드 테두리 안에 넣는다. 섹션 하나를 통째로 채울 때.
|
|
294
|
+
id: "spec-boxed",
|
|
295
|
+
labelKey: "detailPage.table.preset.specBoxed",
|
|
296
|
+
kind: "keyvalue",
|
|
297
|
+
style: {
|
|
298
|
+
firstWidth: 180,
|
|
299
|
+
firstFill: CELL,
|
|
300
|
+
rowRule: { color: LINE, width: 1 },
|
|
301
|
+
outerBorder: { color: LINE, width: 1, radius: 14 },
|
|
302
|
+
topRule: null,
|
|
303
|
+
padX: 24,
|
|
304
|
+
padY: 22,
|
|
305
|
+
},
|
|
306
|
+
data: SPEC_DATA,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
// 가격표 — 수치 열을 오른쪽 끝에 세워 자릿수를 맞춘다.
|
|
310
|
+
id: "grid-price",
|
|
311
|
+
labelKey: "detailPage.table.preset.gridPrice",
|
|
312
|
+
kind: "grid",
|
|
313
|
+
style: {
|
|
314
|
+
firstWidth: null,
|
|
315
|
+
headerFill: CELL,
|
|
316
|
+
rowRule: { color: LINE_SOFT, width: 1 },
|
|
317
|
+
outerBorder: { color: LINE, width: 1, radius: 4 },
|
|
318
|
+
topRule: null,
|
|
319
|
+
padX: 16,
|
|
320
|
+
},
|
|
321
|
+
options: { headerRow: true, align: ["left", "center", "right"] },
|
|
322
|
+
data: {
|
|
323
|
+
columns: ["구성", "수량", "정상가"],
|
|
324
|
+
rows: [
|
|
325
|
+
["단품", "1개", "32,000"],
|
|
326
|
+
["2개 세트", "2개", "58,000"],
|
|
327
|
+
["선물 세트", "3개", "84,000"],
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 좌측 패널이 읽는 최종 목록. 바탕을 안 정한 프리셋에는 흰 바탕을 깐다.
|
|
335
|
+
*
|
|
336
|
+
* 표는 선과 글자뿐이라 바탕이 없으면 섹션 배경을 그대로 탄다 — 어두운 섹션에 놓는
|
|
337
|
+
* 순간 먹빛 글씨가 사라지고, 흰 패널 위에서 그려진 미리보기와 결과가 달라진다.
|
|
338
|
+
* 기본값(``DEFAULT_TABLE_STYLE``)이 아니라 **프리셋에만** 건다. 기본값을 바꾸면 이미
|
|
339
|
+
* 문서에 박힌 표들이 다시 그려질 때 같이 변한다.
|
|
340
|
+
*/
|
|
341
|
+
export const TABLE_PRESETS: TablePreset[] = PRESETS.map((preset) => ({
|
|
342
|
+
...preset,
|
|
343
|
+
style: { bodyFill: "#FFFFFF", ...preset.style },
|
|
344
|
+
}));
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 표 데이터를 고치는 순수 함수들.
|
|
3
|
+
*
|
|
4
|
+
* 인스펙터(우측 패널)와 캔버스 오버레이가 **같은 함수**를 쓴다. 원래는 인스펙터 안에
|
|
5
|
+
* 있었는데, 캔버스에서도 행·열을 넣고 빼게 되면서 두 벌로 갈라질 뻔했다 — 한쪽만
|
|
6
|
+
* ``align``을 따라 옮기는 순간 열을 지웠을 때 뒤 열들의 정렬이 한 칸씩 밀린다.
|
|
7
|
+
*
|
|
8
|
+
* 두 층으로 나눠 둔다:
|
|
9
|
+
* - ``with*`` — ``TableData``만 다룬다(인스펙터의 입력 칸들이 쓴다).
|
|
10
|
+
* - ``insertRow``/``removeColumn`` … — 스펙 전체를 받아 ``align``·상한까지 맞춘다.
|
|
11
|
+
* 캔버스 핸들은 이쪽을 쓴다. 눌렀을 때 할 일이 한 번에 끝나야 하기 때문이다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
MAX_COLUMNS,
|
|
16
|
+
MAX_ROWS,
|
|
17
|
+
MIN_COLUMN_WIDTH,
|
|
18
|
+
columnWidths,
|
|
19
|
+
resolveTable,
|
|
20
|
+
} from "./normalize";
|
|
21
|
+
import type { CellAlign, TableData, TableSpec } from "./types";
|
|
22
|
+
|
|
23
|
+
/** 지금 행들이 실제로 쓰는 칸 수. 새 행을 만들 때 폭을 맞추는 기준이다. */
|
|
24
|
+
function widthOf(data: TableData): number {
|
|
25
|
+
return Math.max(1, data.columns.length, data.rows[0]?.length ?? 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyRow(width: number): string[] {
|
|
29
|
+
return Array.from({ length: width }, () => "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 범위를 벗어난 삽입 위치를 양끝으로 접는다(핸들이 경계에서 눌릴 수 있다). */
|
|
33
|
+
function clampIndex(index: number, length: number): number {
|
|
34
|
+
if (!Number.isFinite(index)) return length;
|
|
35
|
+
return Math.min(length, Math.max(0, Math.trunc(index)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── TableData 층 ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function withCell(
|
|
41
|
+
data: TableData,
|
|
42
|
+
row: number,
|
|
43
|
+
column: number,
|
|
44
|
+
value: string,
|
|
45
|
+
): TableData {
|
|
46
|
+
return {
|
|
47
|
+
...data,
|
|
48
|
+
rows: data.rows.map((cells, r) =>
|
|
49
|
+
r === row ? cells.map((cell, c) => (c === column ? value : cell)) : cells,
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function withColumnName(data: TableData, column: number, name: string): TableData {
|
|
55
|
+
const columns = [...data.columns];
|
|
56
|
+
while (columns.length <= column) columns.push("");
|
|
57
|
+
columns[column] = name;
|
|
58
|
+
return { ...data, columns };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function withRowAdded(data: TableData): TableData {
|
|
62
|
+
return { ...data, rows: [...data.rows, emptyRow(widthOf(data))] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** ``index`` 자리에 빈 행을 끼운다. ``index === rows.length``면 맨 뒤. */
|
|
66
|
+
export function withRowInsertedAt(data: TableData, index: number): TableData {
|
|
67
|
+
const at = clampIndex(index, data.rows.length);
|
|
68
|
+
const rows = [...data.rows];
|
|
69
|
+
rows.splice(at, 0, emptyRow(widthOf(data)));
|
|
70
|
+
return { ...data, rows };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function withRowRemoved(data: TableData, index: number): TableData {
|
|
74
|
+
return { ...data, rows: data.rows.filter((_, i) => i !== index) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function withColumnAdded(data: TableData): TableData {
|
|
78
|
+
return {
|
|
79
|
+
columns: [...data.columns, ""],
|
|
80
|
+
rows: data.rows.map((cells) => [...cells, ""]),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ``index`` 자리에 빈 열을 끼운다.
|
|
86
|
+
*
|
|
87
|
+
* 행마다 길이가 다를 수 있다(정규화는 렌더용 사본에서만 한다). 짧은 행은 삽입 위치까지
|
|
88
|
+
* 채운 뒤 끼워야 값이 옆 열로 밀리지 않는다.
|
|
89
|
+
*/
|
|
90
|
+
export function withColumnInsertedAt(data: TableData, index: number): TableData {
|
|
91
|
+
const at = clampIndex(index, widthOf(data));
|
|
92
|
+
const columns = [...data.columns];
|
|
93
|
+
while (columns.length < at) columns.push("");
|
|
94
|
+
columns.splice(at, 0, "");
|
|
95
|
+
return {
|
|
96
|
+
columns,
|
|
97
|
+
rows: data.rows.map((cells) => {
|
|
98
|
+
const next = [...cells];
|
|
99
|
+
while (next.length < at) next.push("");
|
|
100
|
+
next.splice(at, 0, "");
|
|
101
|
+
return next;
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function withColumnRemoved(data: TableData, index: number): TableData {
|
|
107
|
+
return {
|
|
108
|
+
columns: data.columns.filter((_, i) => i !== index),
|
|
109
|
+
rows: data.rows.map((cells) => cells.filter((_, i) => i !== index)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 열을 지우면 그 열의 정렬 설정도 같이 빠져야 뒤 열들이 밀리지 않는다. */
|
|
114
|
+
export function alignAfterColumnRemove(align: CellAlign[], removed: number): CellAlign[] {
|
|
115
|
+
return align.filter((_, i) => i !== removed);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 열을 끼우면 정렬도 같은 자리에서 벌어져야 한다(새 열은 기본 정렬). */
|
|
119
|
+
export function alignAfterColumnInsert(align: CellAlign[], inserted: number): CellAlign[] {
|
|
120
|
+
const at = clampIndex(inserted, align.length);
|
|
121
|
+
const next = [...align];
|
|
122
|
+
while (next.length < at) next.push("left");
|
|
123
|
+
next.splice(at, 0, "left");
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── TableSpec 층(캔버스 핸들이 쓰는 한 방 짜리) ──────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** 행을 더 넣을 수 있는가. 상한에 닿으면 핸들의 ``+``를 숨긴다. */
|
|
130
|
+
export function canInsertRow(spec: TableSpec): boolean {
|
|
131
|
+
return spec.data.rows.length < MAX_ROWS;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 열을 더 넣을 수 있는가.
|
|
136
|
+
*
|
|
137
|
+
* ``keyvalue``는 이름·값 두 칸으로 **그리는** 종류라 열을 늘려도 화면이 안 변한다.
|
|
138
|
+
* 캔버스에서는 안 되는 걸 눌러 보게 두지 않고 아예 핸들을 감춘다.
|
|
139
|
+
*/
|
|
140
|
+
export function canInsertColumn(spec: TableSpec): boolean {
|
|
141
|
+
return spec.kind === "grid" && resolveTable(spec).columnCount < MAX_COLUMNS;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 마지막 하나는 못 지운다 — 행이 0이면 표가 아니라 빈 상자가 된다. */
|
|
145
|
+
export function canRemoveRow(spec: TableSpec): boolean {
|
|
146
|
+
return spec.data.rows.length > 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function canRemoveColumn(spec: TableSpec): boolean {
|
|
150
|
+
return spec.kind === "grid" && resolveTable(spec).columnCount > 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function insertRow(spec: TableSpec, index: number): TableSpec {
|
|
154
|
+
if (!canInsertRow(spec)) return spec;
|
|
155
|
+
return { ...spec, data: withRowInsertedAt(spec.data, index) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function removeRow(spec: TableSpec, index: number): TableSpec {
|
|
159
|
+
if (!canRemoveRow(spec)) return spec;
|
|
160
|
+
return { ...spec, data: withRowRemoved(spec.data, index) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 열을 넣고 뺄 때 **잡아 둔 폭도 같이 움직여야** 한다.
|
|
165
|
+
*
|
|
166
|
+
* 안 옮기면 폭 배열의 길이가 열 수와 어긋나 통째로 무시되고(자동으로 되돌아간다),
|
|
167
|
+
* 길이가 우연히 맞으면 더 나쁘다 — 엉뚱한 열이 남의 폭을 입는다.
|
|
168
|
+
*/
|
|
169
|
+
function widthsAfterInsert(widths: number[] | null, at: number): number[] | null {
|
|
170
|
+
if (!widths) return null;
|
|
171
|
+
const next = [...widths];
|
|
172
|
+
const index = clampIndex(at, next.length);
|
|
173
|
+
// 새 열은 이웃만큼 준다. 남는 폭은 정규화가 알아서 다시 나눈다.
|
|
174
|
+
next.splice(index, 0, next[index] ?? next[index - 1] ?? MIN_COLUMN_WIDTH);
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function widthsAfterRemove(widths: number[] | null, at: number): number[] | null {
|
|
179
|
+
if (!widths) return null;
|
|
180
|
+
return widths.filter((_, i) => i !== at);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function insertColumn(spec: TableSpec, index: number): TableSpec {
|
|
184
|
+
if (!canInsertColumn(spec)) return spec;
|
|
185
|
+
return {
|
|
186
|
+
...spec,
|
|
187
|
+
data: withColumnInsertedAt(spec.data, index),
|
|
188
|
+
options: {
|
|
189
|
+
...spec.options,
|
|
190
|
+
align: alignAfterColumnInsert(spec.options.align, index),
|
|
191
|
+
},
|
|
192
|
+
style: {
|
|
193
|
+
...spec.style,
|
|
194
|
+
columnWidths: widthsAfterInsert(spec.style.columnWidths, index),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function removeColumn(spec: TableSpec, index: number): TableSpec {
|
|
200
|
+
if (!canRemoveColumn(spec)) return spec;
|
|
201
|
+
return {
|
|
202
|
+
...spec,
|
|
203
|
+
data: withColumnRemoved(spec.data, index),
|
|
204
|
+
options: {
|
|
205
|
+
...spec.options,
|
|
206
|
+
align: alignAfterColumnRemove(spec.options.align, index),
|
|
207
|
+
},
|
|
208
|
+
style: {
|
|
209
|
+
...spec.style,
|
|
210
|
+
columnWidths: widthsAfterRemove(spec.style.columnWidths, index),
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 열 폭 ────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/** 배열에서 한 칸을 빼 다른 자리에 꽂는다. */
|
|
218
|
+
function moveAt<T>(list: T[], from: number, to: number): T[] {
|
|
219
|
+
const next = [...list];
|
|
220
|
+
const [taken] = next.splice(from, 1);
|
|
221
|
+
next.splice(to, 0, taken);
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 열 경계 하나를 ``deltaPx``만큼 민 스펙.
|
|
227
|
+
*
|
|
228
|
+
* ``boundary``는 **경계 번호**(1 = 첫 열과 둘째 열 사이). 인접한 두 열만 주고받으므로
|
|
229
|
+
* 표 전체 폭은 안 변한다 — 하나를 넓히면 옆이 좁아지는, 엑셀·Canva와 같은 동작이다.
|
|
230
|
+
*
|
|
231
|
+
* 처음 끄는 순간 지금 화면에 보이는 자동 배치값으로 ``columnWidths``를 채운다. 그래야
|
|
232
|
+
* 자동 → 수동으로 넘어갈 때 표가 안 튄다.
|
|
233
|
+
*/
|
|
234
|
+
export function resizeColumn(
|
|
235
|
+
spec: TableSpec,
|
|
236
|
+
boundary: number,
|
|
237
|
+
deltaPx: number,
|
|
238
|
+
): TableSpec {
|
|
239
|
+
const resolved = resolveTable(spec);
|
|
240
|
+
if (boundary < 1 || boundary > resolved.columnCount - 1) return spec;
|
|
241
|
+
|
|
242
|
+
const current = columnWidths(spec, resolved);
|
|
243
|
+
const left = current[boundary - 1];
|
|
244
|
+
const right = current[boundary];
|
|
245
|
+
if (left === undefined || right === undefined) return spec;
|
|
246
|
+
|
|
247
|
+
// 두 열의 합은 보존한다. 어느 쪽도 최소 폭 아래로는 못 간다.
|
|
248
|
+
const pair = left + right;
|
|
249
|
+
const nextLeft = Math.min(
|
|
250
|
+
pair - MIN_COLUMN_WIDTH,
|
|
251
|
+
Math.max(MIN_COLUMN_WIDTH, left + deltaPx),
|
|
252
|
+
);
|
|
253
|
+
if (Math.abs(nextLeft - left) < 0.5) return spec;
|
|
254
|
+
|
|
255
|
+
const widths = [...current];
|
|
256
|
+
widths[boundary - 1] = nextLeft;
|
|
257
|
+
widths[boundary] = pair - nextLeft;
|
|
258
|
+
return { ...spec, style: { ...spec.style, columnWidths: widths } };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 열 폭을 자동으로 되돌린다(첫 칸만 ``firstWidth``, 나머지 균등). */
|
|
262
|
+
export function autoColumnWidths(spec: TableSpec): TableSpec {
|
|
263
|
+
if (spec.style.columnWidths === null) return spec;
|
|
264
|
+
return { ...spec, style: { ...spec.style, columnWidths: null } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── 순서 바꾸기 ──────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export function moveRow(spec: TableSpec, from: number, to: number): TableSpec {
|
|
270
|
+
const count = spec.data.rows.length;
|
|
271
|
+
if (from === to || from < 0 || from >= count || to < 0 || to >= count) return spec;
|
|
272
|
+
return { ...spec, data: { ...spec.data, rows: moveAt(spec.data.rows, from, to) } };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 열 순서를 바꾼다.
|
|
277
|
+
*
|
|
278
|
+
* 데이터만 옮기면 안 된다 — 정렬(``align``)과 잡아 둔 폭은 **열에 붙은 성질**이라
|
|
279
|
+
* 같이 따라가야 한다. 안 그러면 수치 열을 왼쪽으로 옮겼을 때 우측정렬이 원래 자리에
|
|
280
|
+
* 남는다.
|
|
281
|
+
*/
|
|
282
|
+
export function moveColumn(spec: TableSpec, from: number, to: number): TableSpec {
|
|
283
|
+
const count = resolveTable(spec).columnCount;
|
|
284
|
+
if (from === to || from < 0 || from >= count || to < 0 || to >= count) return spec;
|
|
285
|
+
|
|
286
|
+
const align = [...spec.options.align];
|
|
287
|
+
while (align.length < count) align.push("left");
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
...spec,
|
|
291
|
+
data: {
|
|
292
|
+
columns: moveAt(spec.data.columns, from, to),
|
|
293
|
+
rows: spec.data.rows.map((cells) =>
|
|
294
|
+
// 짧은 행은 자리를 채운 뒤 옮겨야 값이 옆 열로 새지 않는다.
|
|
295
|
+
moveAt(
|
|
296
|
+
cells.length < count
|
|
297
|
+
? [...cells, ...Array.from({ length: count - cells.length }, () => "")]
|
|
298
|
+
: cells,
|
|
299
|
+
from,
|
|
300
|
+
to,
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
},
|
|
304
|
+
options: { ...spec.options, align: moveAt(align, from, to) },
|
|
305
|
+
style: {
|
|
306
|
+
...spec.style,
|
|
307
|
+
columnWidths: spec.style.columnWidths
|
|
308
|
+
? moveAt(spec.style.columnWidths, from, to)
|
|
309
|
+
: null,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|