@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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스펙 → Canvas 자식 노드.
|
|
3
|
+
*
|
|
4
|
+
* 자식은 ``figure``(바탕·선)와 ``text``(글자)뿐이다. 표에는 곡선 기하가 없어서 ``svg``
|
|
5
|
+
* 자식이 아예 없고, 따라서 **글자가 SVG 안에 갇히는 문제도 없다**(그 함정은
|
|
6
|
+
* ``chart/renderers/shared.ts``에 적혀 있다).
|
|
7
|
+
*
|
|
8
|
+
* 그리는 순서가 곧 쌓임 순서다: 바탕 → 얼룩 → 이름 칸 바탕 → 선 → 머리글 → 글자.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
estimateLineCount,
|
|
13
|
+
textHeight,
|
|
14
|
+
} from "../spec-group/text-metrics";
|
|
15
|
+
import type { Box, SpecNode, SpecRender } from "../spec-group/sync";
|
|
16
|
+
|
|
17
|
+
import { columnWidths, resolveTable, type ResolvedTable } from "./normalize";
|
|
18
|
+
import type { CellAlign, TableRule, TableSpec, TableStyle } from "./types";
|
|
19
|
+
|
|
20
|
+
/** 글자가 없어도 행이 납작해지지 않게 하는 최소 높이. */
|
|
21
|
+
const MIN_ROW_HEIGHT = 28;
|
|
22
|
+
|
|
23
|
+
function rect(
|
|
24
|
+
key: string,
|
|
25
|
+
box: Box,
|
|
26
|
+
fill: string,
|
|
27
|
+
extra: Record<string, unknown> = {},
|
|
28
|
+
): SpecNode {
|
|
29
|
+
return {
|
|
30
|
+
key,
|
|
31
|
+
props: {
|
|
32
|
+
type: "figure",
|
|
33
|
+
subType: "rect",
|
|
34
|
+
x: Math.round(box.x),
|
|
35
|
+
y: Math.round(box.y),
|
|
36
|
+
width: Math.max(0, Math.round(box.width)),
|
|
37
|
+
height: Math.max(0, Math.round(box.height)),
|
|
38
|
+
fill,
|
|
39
|
+
cornerRadius: 0,
|
|
40
|
+
...extra,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function text(
|
|
46
|
+
key: string,
|
|
47
|
+
box: { x: number; y: number; width: number },
|
|
48
|
+
value: string,
|
|
49
|
+
{
|
|
50
|
+
fontFamily,
|
|
51
|
+
fontSize,
|
|
52
|
+
fill,
|
|
53
|
+
align,
|
|
54
|
+
fontWeight,
|
|
55
|
+
lines,
|
|
56
|
+
}: {
|
|
57
|
+
fontFamily: string;
|
|
58
|
+
fontSize: number;
|
|
59
|
+
fill: string;
|
|
60
|
+
align: CellAlign;
|
|
61
|
+
fontWeight: number;
|
|
62
|
+
lines: number;
|
|
63
|
+
},
|
|
64
|
+
): SpecNode {
|
|
65
|
+
return {
|
|
66
|
+
key,
|
|
67
|
+
props: {
|
|
68
|
+
type: "text",
|
|
69
|
+
x: Math.round(box.x),
|
|
70
|
+
y: Math.round(box.y),
|
|
71
|
+
width: Math.max(1, Math.round(box.width)),
|
|
72
|
+
height: textHeight(fontSize) * lines,
|
|
73
|
+
text: value,
|
|
74
|
+
fontSize,
|
|
75
|
+
fontFamily,
|
|
76
|
+
fontWeight: String(fontWeight),
|
|
77
|
+
fill,
|
|
78
|
+
align,
|
|
79
|
+
lineHeight: 1.3,
|
|
80
|
+
verticalAlign: "top",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 선 하나를 얇은 사각형으로. 선은 ``figure``라 내보내기 네 경로가 전부 그린다. */
|
|
86
|
+
function ruleNode(key: string, box: Box, rule: TableRule): SpecNode | null {
|
|
87
|
+
if (!rule || rule.width <= 0) return null;
|
|
88
|
+
return rect(key, box, rule.color);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type CellFont = { fontFamily: string; fontSize: number; fill: string; fontWeight: number };
|
|
92
|
+
|
|
93
|
+
/** 열 인덱스에 맞는 글자 성격. 0번 칸만 따로 꾸밀 수 있다(브랜드의 이름 칸). */
|
|
94
|
+
function cellFont(style: TableStyle, column: number, isFirstColumn: boolean): CellFont {
|
|
95
|
+
if (isFirstColumn && column === 0) {
|
|
96
|
+
return {
|
|
97
|
+
fontFamily: style.firstFontFamily ?? style.fontFamily,
|
|
98
|
+
fontSize: style.firstSize ?? style.fontSize,
|
|
99
|
+
fill: style.firstColor,
|
|
100
|
+
fontWeight: style.firstWeight,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
fontFamily: style.fontFamily,
|
|
105
|
+
fontSize: style.fontSize,
|
|
106
|
+
fill: style.color,
|
|
107
|
+
fontWeight: 400,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 한 행의 높이 — 가장 많이 접히는 칸이 정한다. */
|
|
112
|
+
function rowHeight(
|
|
113
|
+
cells: string[],
|
|
114
|
+
widths: number[],
|
|
115
|
+
style: TableStyle,
|
|
116
|
+
isKeyValue: boolean,
|
|
117
|
+
): { height: number; lines: number[] } {
|
|
118
|
+
const lines = cells.map((cell, column) => {
|
|
119
|
+
const font = cellFont(style, column, isKeyValue);
|
|
120
|
+
const usable = Math.max(1, widths[column] - style.padX * 2);
|
|
121
|
+
return estimateLineCount(cell, font.fontSize, usable);
|
|
122
|
+
});
|
|
123
|
+
const tallest = cells.reduce((max, _cell, column) => {
|
|
124
|
+
const font = cellFont(style, column, isKeyValue);
|
|
125
|
+
return Math.max(max, textHeight(font.fontSize) * lines[column]);
|
|
126
|
+
}, 0);
|
|
127
|
+
return {
|
|
128
|
+
height: Math.max(MIN_ROW_HEIGHT, tallest + style.padY * 2),
|
|
129
|
+
lines,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 정렬을 고려한 글자 상자의 왼쪽 x. */
|
|
134
|
+
function cellX(left: number, style: TableStyle): number {
|
|
135
|
+
return left + style.padX;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 한 줄(머리글이든 본문이든)이 차지하는 세로 구간과, 칸별로 접힌 줄 수. */
|
|
139
|
+
export type LayoutRow = { y: number; height: number; lines: number[] };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 표를 그리기 전에 나오는 기하 — 열의 x/폭, 행의 y/높이.
|
|
143
|
+
*
|
|
144
|
+
* ``renderTable``이 이걸로 노드를 만들고, **캔버스 오버레이도 이걸로** 행·열 핸들을
|
|
145
|
+
* 놓는다. 둘이 따로 계산하면 핸들이 행 경계에서 조금씩 어긋나는데, 그건 눈에 바로
|
|
146
|
+
* 띄면서 원인은 안 보이는 종류의 어긋남이다.
|
|
147
|
+
*/
|
|
148
|
+
export type TableLayout = {
|
|
149
|
+
width: number;
|
|
150
|
+
height: number;
|
|
151
|
+
/** 그리는 열 수만큼. ``keyvalue``면 2개다(선언된 열이 더 많아도). */
|
|
152
|
+
columns: Array<{ x: number; width: number }>;
|
|
153
|
+
/** 머리글 행. 안 그리면 ``null``. */
|
|
154
|
+
header: LayoutRow | null;
|
|
155
|
+
/** 본문 행. ``spec.data.rows``와 인덱스가 같다(상한 초과분은 빠진다). */
|
|
156
|
+
rows: LayoutRow[];
|
|
157
|
+
/** 본문이 시작하는 y(위 선 아래). */
|
|
158
|
+
bodyTop: number;
|
|
159
|
+
resolved: ResolvedTable;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function tableLayout(spec: TableSpec): TableLayout {
|
|
163
|
+
const resolved: ResolvedTable = resolveTable(spec);
|
|
164
|
+
const style = spec.style;
|
|
165
|
+
const widths = columnWidths(spec, resolved);
|
|
166
|
+
const width = widths.reduce((sum, w) => sum + w, 0);
|
|
167
|
+
const columns: Array<{ x: number; width: number }> = [];
|
|
168
|
+
widths.reduce((x, w) => {
|
|
169
|
+
columns.push({ x, width: w });
|
|
170
|
+
return x + w;
|
|
171
|
+
}, 0);
|
|
172
|
+
|
|
173
|
+
const isKeyValue = spec.kind === "keyvalue";
|
|
174
|
+
let y = style.topRule ? style.topRule.width : 0;
|
|
175
|
+
const bodyTop = y;
|
|
176
|
+
|
|
177
|
+
let header: LayoutRow | null = null;
|
|
178
|
+
if (resolved.showHeader) {
|
|
179
|
+
const { height, lines } = rowHeight(resolved.header, widths, style, false);
|
|
180
|
+
header = { y, height, lines };
|
|
181
|
+
y += height;
|
|
182
|
+
// 머리글 아래 구분선도 자리를 차지한다 — 안 더하면 첫 본문 행이 선을 덮는다.
|
|
183
|
+
if (style.rowRule) y += style.rowRule.width;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rows: LayoutRow[] = [];
|
|
187
|
+
resolved.rows.forEach((cells, index) => {
|
|
188
|
+
const { height, lines } = rowHeight(cells, widths, style, isKeyValue);
|
|
189
|
+
rows.push({ y, height, lines });
|
|
190
|
+
y += height;
|
|
191
|
+
// 마지막 행 아래에는 선을 안 긋는다(바깥 테두리와 겹쳐 두 줄로 보인다).
|
|
192
|
+
if (index < resolved.rows.length - 1 && style.rowRule) y += style.rowRule.width;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
width,
|
|
197
|
+
height: Math.max(y, bodyTop + MIN_ROW_HEIGHT),
|
|
198
|
+
columns,
|
|
199
|
+
header,
|
|
200
|
+
rows,
|
|
201
|
+
bodyTop,
|
|
202
|
+
resolved,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function renderTable(spec: TableSpec): SpecRender {
|
|
207
|
+
const layout = tableLayout(spec);
|
|
208
|
+
const { resolved, width, height, bodyTop } = layout;
|
|
209
|
+
const style = spec.style;
|
|
210
|
+
const offsets = layout.columns.map((c) => c.x);
|
|
211
|
+
const widths = layout.columns.map((c) => c.width);
|
|
212
|
+
|
|
213
|
+
const isKeyValue = spec.kind === "keyvalue";
|
|
214
|
+
const nodes: SpecNode[] = [];
|
|
215
|
+
const fills: SpecNode[] = [];
|
|
216
|
+
const rules: SpecNode[] = [];
|
|
217
|
+
const glyphs: SpecNode[] = [];
|
|
218
|
+
|
|
219
|
+
// ── 머리글 ────────────────────────────────────────────────────────────────
|
|
220
|
+
if (resolved.showHeader && layout.header) {
|
|
221
|
+
const { y, height: rowH, lines } = layout.header;
|
|
222
|
+
if (style.headerFill) {
|
|
223
|
+
fills.push(rect("headerFill", { x: 0, y, width, height: rowH }, style.headerFill));
|
|
224
|
+
}
|
|
225
|
+
resolved.header.forEach((label, column) => {
|
|
226
|
+
glyphs.push(
|
|
227
|
+
text(
|
|
228
|
+
`header:${column}`,
|
|
229
|
+
{
|
|
230
|
+
x: cellX(offsets[column], style),
|
|
231
|
+
y: y + style.padY,
|
|
232
|
+
width: Math.max(1, widths[column] - style.padX * 2),
|
|
233
|
+
},
|
|
234
|
+
label,
|
|
235
|
+
{
|
|
236
|
+
fontFamily: style.fontFamily,
|
|
237
|
+
fontSize: style.fontSize,
|
|
238
|
+
fill: style.headerColor,
|
|
239
|
+
align: resolved.align[column],
|
|
240
|
+
fontWeight: style.headerWeight,
|
|
241
|
+
lines: lines[column],
|
|
242
|
+
},
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
const rule = ruleNode(
|
|
247
|
+
"headerRule",
|
|
248
|
+
{ x: 0, y: y + rowH, width, height: style.rowRule?.width ?? 0 },
|
|
249
|
+
style.rowRule,
|
|
250
|
+
);
|
|
251
|
+
if (rule) rules.push(rule);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 본문 ─────────────────────────────────────────────────────────────────
|
|
255
|
+
resolved.rows.forEach((cells, index) => {
|
|
256
|
+
const { y, height: rowH, lines } = layout.rows[index];
|
|
257
|
+
|
|
258
|
+
if (spec.options.zebra && index % 2 === 1) {
|
|
259
|
+
fills.push(rect(`zebra:${index}`, { x: 0, y, width, height: rowH }, style.zebraFill));
|
|
260
|
+
}
|
|
261
|
+
// 이름 칸 바탕 — "배경 색상으로 텍스트·배경의 명확한 분리"(브랜드 디자이너 요구).
|
|
262
|
+
if (style.firstFill && resolved.columnCount > 1) {
|
|
263
|
+
fills.push(
|
|
264
|
+
rect(`first:${index}`, { x: 0, y, width: widths[0], height: rowH }, style.firstFill),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
cells.forEach((cell, column) => {
|
|
269
|
+
const font = cellFont(style, column, isKeyValue);
|
|
270
|
+
const align: CellAlign =
|
|
271
|
+
isKeyValue && column === 0 ? style.firstAlign : resolved.align[column];
|
|
272
|
+
glyphs.push(
|
|
273
|
+
text(
|
|
274
|
+
`cell:${index}:${column}`,
|
|
275
|
+
{
|
|
276
|
+
x: cellX(offsets[column], style),
|
|
277
|
+
y: y + style.padY,
|
|
278
|
+
width: Math.max(1, widths[column] - style.padX * 2),
|
|
279
|
+
},
|
|
280
|
+
cell,
|
|
281
|
+
{ ...font, align, lines: lines[column] },
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// 마지막 행 아래에는 선을 안 긋는다(바깥 테두리와 겹쳐 두 줄로 보인다).
|
|
287
|
+
if (index < resolved.rows.length - 1) {
|
|
288
|
+
const rule = ruleNode(
|
|
289
|
+
`rowRule:${index}`,
|
|
290
|
+
{ x: 0, y: y + rowH, width, height: style.rowRule?.width ?? 0 },
|
|
291
|
+
style.rowRule,
|
|
292
|
+
);
|
|
293
|
+
if (rule) rules.push(rule);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── 표 전체에 걸리는 것 ───────────────────────────────────────────────────
|
|
298
|
+
const base: SpecNode[] = [];
|
|
299
|
+
if (style.outerBorder) {
|
|
300
|
+
// 테두리는 채움이 **있는** 사각형의 stroke로 그린다. 투명 채움(rgba(0,0,0,0))은
|
|
301
|
+
// .ai 내보내기에서 검은 박스로 굳은 전례가 있어서 쓰지 않는다.
|
|
302
|
+
base.push(
|
|
303
|
+
rect(
|
|
304
|
+
"base",
|
|
305
|
+
{ x: 0, y: 0, width, height },
|
|
306
|
+
style.bodyFill ?? "#FFFFFF",
|
|
307
|
+
{
|
|
308
|
+
stroke: style.outerBorder.color,
|
|
309
|
+
strokeWidth: style.outerBorder.width,
|
|
310
|
+
cornerRadius: style.outerBorder.radius,
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
} else if (style.bodyFill) {
|
|
315
|
+
base.push(rect("base", { x: 0, y: 0, width, height }, style.bodyFill));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const topRule = ruleNode(
|
|
319
|
+
"topRule",
|
|
320
|
+
{ x: 0, y: 0, width, height: bodyTop },
|
|
321
|
+
style.topRule,
|
|
322
|
+
);
|
|
323
|
+
if (topRule) rules.push(topRule);
|
|
324
|
+
|
|
325
|
+
if (style.columnRule && resolved.columnCount > 1 && style.firstWidth !== null) {
|
|
326
|
+
rules.push(
|
|
327
|
+
rect(
|
|
328
|
+
"columnRule",
|
|
329
|
+
{
|
|
330
|
+
x: widths[0],
|
|
331
|
+
y: bodyTop,
|
|
332
|
+
width: style.columnRule.width,
|
|
333
|
+
height: Math.max(0, height - bodyTop),
|
|
334
|
+
},
|
|
335
|
+
style.columnRule.color,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
nodes.push(...base, ...fills, ...rules, ...glyphs);
|
|
341
|
+
return { nodes, size: { width: Math.round(width), height: Math.round(height) } };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 스펙을 그대로 줄인 사본. 썸네일이 실제 결과와 같은 비율로 보이게 한다.
|
|
346
|
+
*
|
|
347
|
+
* 선 두께는 줄이지 않는다 — 0.3px 선은 썸네일에서 사라져서 "선이 없는 표"로 보인다.
|
|
348
|
+
*/
|
|
349
|
+
export function scaleTableStyle(spec: TableSpec, factor: number): TableSpec {
|
|
350
|
+
const scale = (value: number, min: number) =>
|
|
351
|
+
Math.max(min, Math.round(value * factor));
|
|
352
|
+
return {
|
|
353
|
+
...spec,
|
|
354
|
+
style: {
|
|
355
|
+
...spec.style,
|
|
356
|
+
fontSize: scale(spec.style.fontSize, 5),
|
|
357
|
+
firstSize:
|
|
358
|
+
spec.style.firstSize === null ? null : scale(spec.style.firstSize, 5),
|
|
359
|
+
padX: scale(spec.style.padX, 3),
|
|
360
|
+
padY: scale(spec.style.padY, 2),
|
|
361
|
+
firstWidth:
|
|
362
|
+
spec.style.firstWidth === null ? null : scale(spec.style.firstWidth, 24),
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 사용자가 트랜스포머로 늘린 결과를 스펙에 흡수한다.
|
|
369
|
+
*
|
|
370
|
+
* 가로는 ``frame.width``로 그대로 받는다. 세로는 행 높이가 내용에서 나오므로 늘린 만큼
|
|
371
|
+
* **칸 여백**을 키운다 — 글자 크기를 건드리면 브랜드 타이포가 흐트러진다. 여백은 상한을
|
|
372
|
+
* 둬서 한 번 크게 끈 뒤 표가 텅 비어 보이는 일을 막는다.
|
|
373
|
+
*/
|
|
374
|
+
export function absorbTableResize(spec: TableSpec, actual: Box): TableSpec {
|
|
375
|
+
const nextWidth = Math.max(120, Math.round(actual.width));
|
|
376
|
+
const previousHeight = Math.max(1, spec.frame.height);
|
|
377
|
+
const scale = actual.height / previousHeight;
|
|
378
|
+
const padY =
|
|
379
|
+
Number.isFinite(scale) && Math.abs(scale - 1) > 0.02
|
|
380
|
+
? Math.min(64, Math.max(4, Math.round(spec.style.padY * scale)))
|
|
381
|
+
: spec.style.padY;
|
|
382
|
+
if (nextWidth === spec.frame.width && padY === spec.style.padY) return spec;
|
|
383
|
+
return {
|
|
384
|
+
...spec,
|
|
385
|
+
frame: { ...spec.frame, width: nextWidth },
|
|
386
|
+
style: { ...spec.style, padY },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 표 스펙 ↔ Canvas 스토어.
|
|
3
|
+
*
|
|
4
|
+
* 스토어를 만지는 방법은 차트와 똑같아서 ``spec-group/sync``에 한 벌로 있다 —
|
|
5
|
+
* 키 기반 diff, 한 번의 undo, 그룹 base 좌표 0, 자식을 잠그지 않기. **함정 설명은
|
|
6
|
+
* 거기 있다.** 여기서는 "표를 어떻게 그리고 어떻게 알아보는가"만 정한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
detachSpecGroup,
|
|
11
|
+
groupBox,
|
|
12
|
+
insertSpecGroup,
|
|
13
|
+
readSpec,
|
|
14
|
+
syncSpecGroup,
|
|
15
|
+
writeSpec,
|
|
16
|
+
type Box,
|
|
17
|
+
type ElementLike,
|
|
18
|
+
type SpecBinding,
|
|
19
|
+
type StoreLike,
|
|
20
|
+
} from "../spec-group/sync";
|
|
21
|
+
|
|
22
|
+
import { harvestTableEdits, mergeCanvasCells } from "./harvest";
|
|
23
|
+
import { absorbTableResize, renderTable } from "./render";
|
|
24
|
+
import { TABLE_PART, type TableSpec } from "./types";
|
|
25
|
+
|
|
26
|
+
export type { ElementLike, StoreLike };
|
|
27
|
+
export { documentFontFamily } from "../spec-group/sync";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 저장된 문서에서 오는 값이라 형태를 믿지 않는다 — 최소한의 골격(버전·행 배열)만
|
|
31
|
+
* 확인하고, 세부 필드는 렌더러의 기본값 처리에 맡긴다.
|
|
32
|
+
*/
|
|
33
|
+
function parseTableSpec(raw: unknown): TableSpec | null {
|
|
34
|
+
const spec = raw as Partial<TableSpec>;
|
|
35
|
+
if (spec.v !== 1) return null;
|
|
36
|
+
if (spec.kind !== "keyvalue" && spec.kind !== "grid") return null;
|
|
37
|
+
if (!spec.data || !Array.isArray(spec.data.columns)) return null;
|
|
38
|
+
if (!Array.isArray(spec.data.rows)) return null;
|
|
39
|
+
if (!spec.data.rows.every((row) => Array.isArray(row))) return null;
|
|
40
|
+
return spec as TableSpec;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TABLE_BINDING: SpecBinding<TableSpec> = {
|
|
44
|
+
customKey: "table",
|
|
45
|
+
partKey: TABLE_PART,
|
|
46
|
+
defaultName: "표",
|
|
47
|
+
render: renderTable,
|
|
48
|
+
parse: parseTableSpec,
|
|
49
|
+
absorbResize: absorbTableResize,
|
|
50
|
+
harvest: mergeCanvasCells,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 그룹에서 표 스펙을 꺼낸다. 표가 아니면 ``null``. */
|
|
54
|
+
export function readTableSpec(el: ElementLike | null | undefined): TableSpec | null {
|
|
55
|
+
return readSpec(TABLE_BINDING, el);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 스펙을 그룹에 되쓴다(다른 custom 키는 보존). */
|
|
59
|
+
export function writeTableSpec(el: ElementLike, spec: TableSpec): void {
|
|
60
|
+
writeSpec(TABLE_BINDING, el, spec);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 그룹이 실제로 차지하는 상자. 그룹 모델이 아니라 자식에서 잰다. */
|
|
64
|
+
export function tableBox(group: ElementLike): Box | null {
|
|
65
|
+
return groupBox(group);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type InsertTableOptions = {
|
|
69
|
+
/** 프레임 좌상단(페이지 좌표). 없으면 페이지 가운데. */
|
|
70
|
+
origin?: { x: number; y: number };
|
|
71
|
+
/** 레이어 트리에 뜰 이름. */
|
|
72
|
+
name?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** 새 표를 페이지에 놓고 그 그룹을 돌려준다. */
|
|
76
|
+
export function insertTable(
|
|
77
|
+
store: StoreLike,
|
|
78
|
+
spec: TableSpec,
|
|
79
|
+
options: InsertTableOptions = {},
|
|
80
|
+
): ElementLike | null {
|
|
81
|
+
return insertSpecGroup(TABLE_BINDING, store, spec, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 스펙이 바뀐 표를 다시 그린다. 저장된 스펙(실제 frame이 채워진)을 돌려준다. */
|
|
85
|
+
export function syncTableGroup(
|
|
86
|
+
store: StoreLike,
|
|
87
|
+
group: ElementLike,
|
|
88
|
+
next: TableSpec,
|
|
89
|
+
): TableSpec {
|
|
90
|
+
return syncSpecGroup(TABLE_BINDING, store, group, next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 캔버스에서 고친 칸 글자를 반영한 스펙. 바뀐 게 없으면 ``spec``을 그대로 돌려준다.
|
|
95
|
+
*
|
|
96
|
+
* 다시 그리지 않는다 — 글자를 막 고친 참에 재생성하면 편집 중인 텍스트 상자가 통째로
|
|
97
|
+
* 갈려 포커스가 날아간다. 행·열을 건드리기 직전과, 칸 편집을 마쳤을 때 부른다.
|
|
98
|
+
*/
|
|
99
|
+
export function harvestTableGroup(group: ElementLike, spec: TableSpec): TableSpec {
|
|
100
|
+
const kids = Array.isArray(group.children) ? (group.children as ElementLike[]) : [];
|
|
101
|
+
return harvestTableEdits(spec, kids);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 표를 일반 그룹으로 푼다(되돌리기는 제공하지 않는다). */
|
|
105
|
+
export function detachTable(group: ElementLike): void {
|
|
106
|
+
detachSpecGroup(TABLE_BINDING, group);
|
|
107
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 표 스펙.
|
|
3
|
+
*
|
|
4
|
+
* 표는 element 타입이 아니라 ``custom.table``에 이 스펙을 얹은 ``group``이다(차트와 같다).
|
|
5
|
+
* 자식은 전부 ``figure``(칸 배경·구분선) + ``text``(칸 글자)라, 내보내기 네 경로가
|
|
6
|
+
* 어댑터 없이 그대로 그린다.
|
|
7
|
+
*
|
|
8
|
+
* **브랜드가 기준이다.** 브랜드 상세페이지 두 곳이 같은 골격에 드레싱만 다르게 쓴다:
|
|
9
|
+
* 첫 칸은 고정폭, 나머지는 남는 폭을 나눠 갖고, 행마다 구분선이 있다. 그 차이는
|
|
10
|
+
* ``style``의 값 네 개(``firstFill``·``columnRule``·``topRule``·``outerBorder``)로만
|
|
11
|
+
* 표현된다 — 그래서 렌더러에 브랜드별 분기가 없다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 첫 칸을 이름으로 쓰는 2열 스펙표 / 머리글을 둘 수 있는 N열 그리드. */
|
|
15
|
+
export type TableKind = "keyvalue" | "grid";
|
|
16
|
+
|
|
17
|
+
export type CellAlign = "left" | "center" | "right";
|
|
18
|
+
|
|
19
|
+
/** 선 하나. ``null``이면 그 선을 안 그린다. */
|
|
20
|
+
export type TableRule = { color: string; width: number } | null;
|
|
21
|
+
|
|
22
|
+
export type TableBorder = { color: string; width: number; radius: number } | null;
|
|
23
|
+
|
|
24
|
+
export type TableData = {
|
|
25
|
+
/** 열 이름. ``grid``에서 ``headerRow``가 켜지면 머리글로 그려진다. 열 수의 기준이다. */
|
|
26
|
+
columns: string[];
|
|
27
|
+
/** ``rows[r][c]``. 열 수보다 짧거나 길면 정규화가 맞춘다(원본은 안 자른다). */
|
|
28
|
+
rows: string[][];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type TableOptions = {
|
|
32
|
+
/** ``grid``에서 ``columns``를 머리글 행으로 그린다. ``keyvalue``에서는 무시된다. */
|
|
33
|
+
headerRow: boolean;
|
|
34
|
+
/** 열별 정렬. 모자라면 기본값으로 채운다. */
|
|
35
|
+
align: CellAlign[];
|
|
36
|
+
/** 짝수 행에 옅은 바탕을 깐다. */
|
|
37
|
+
zebra: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type TableStyle = {
|
|
41
|
+
fontFamily: string;
|
|
42
|
+
fontSize: number;
|
|
43
|
+
color: string;
|
|
44
|
+
/** 칸 좌우·상하 여백. 브랜드는 20~26px을 쓴다. */
|
|
45
|
+
padX: number;
|
|
46
|
+
padY: number;
|
|
47
|
+
|
|
48
|
+
// ── 첫 칸(이름 칸) ────────────────────────────────────────────────────────
|
|
49
|
+
/** 첫 칸 고정폭. ``null``이면 모든 열이 같은 폭을 나눠 갖는다. */
|
|
50
|
+
firstWidth: number | null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 열별 폭(px). ``null``이면 자동 — ``firstWidth``만 고정하고 나머지는 균등 분배한다.
|
|
54
|
+
*
|
|
55
|
+
* 사용자가 캔버스에서 열 경계를 끌면 그때 현재 자동 배치값으로 채워진다(그래서 처음
|
|
56
|
+
* 끄는 순간 표가 안 튄다). 값이 있으면 ``frame.width``에 **비례 정규화**되므로 표를
|
|
57
|
+
* 리사이즈해도 비율이 유지되고, 글자를 고쳐도 폭을 다시 만질 일이 없다 — 폭을 스펙에
|
|
58
|
+
* 담기를 미뤘던 이유가 이 정규화로 사라진다.
|
|
59
|
+
*
|
|
60
|
+
* 길이가 그리는 열 수와 다르면 무시하고 자동으로 돌아간다(종류를 바꿨을 때).
|
|
61
|
+
*/
|
|
62
|
+
columnWidths: number[] | null;
|
|
63
|
+
/** 첫 칸 바탕. ``null``이면 안 채운다. */
|
|
64
|
+
firstFill: string | null;
|
|
65
|
+
firstAlign: CellAlign;
|
|
66
|
+
firstColor: string;
|
|
67
|
+
firstWeight: number;
|
|
68
|
+
/** 첫 칸만 다른 서체를 쓰는 브랜드가 있다. ``null``이면 ``fontFamily``. */
|
|
69
|
+
firstFontFamily: string | null;
|
|
70
|
+
/** 첫 칸만 다른 크기를 쓸 때. ``null``이면 ``fontSize``. */
|
|
71
|
+
firstSize: number | null;
|
|
72
|
+
|
|
73
|
+
// ── 선 ───────────────────────────────────────────────────────────────────
|
|
74
|
+
/** 첫 칸과 값 칸을 가르는 세로선. */
|
|
75
|
+
columnRule: TableRule;
|
|
76
|
+
/** 행과 행 사이 가로선. */
|
|
77
|
+
rowRule: TableRule;
|
|
78
|
+
/** 표 맨 위 굵은 선. */
|
|
79
|
+
topRule: TableRule;
|
|
80
|
+
/** 바깥 테두리 + 라운드. */
|
|
81
|
+
outerBorder: TableBorder;
|
|
82
|
+
|
|
83
|
+
// ── 바탕 ─────────────────────────────────────────────────────────────────
|
|
84
|
+
/** 값 칸 바탕. ``null``이면 투명. */
|
|
85
|
+
bodyFill: string | null;
|
|
86
|
+
zebraFill: string;
|
|
87
|
+
headerFill: string | null;
|
|
88
|
+
headerColor: string;
|
|
89
|
+
headerWeight: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type TableSpec = {
|
|
93
|
+
v: 1;
|
|
94
|
+
kind: TableKind;
|
|
95
|
+
frame: { width: number; height: number };
|
|
96
|
+
data: TableData;
|
|
97
|
+
options: TableOptions;
|
|
98
|
+
style: TableStyle;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** 자식 ``custom``에서 부품 키가 사는 자리. 재생성 사이에 요소 id를 잇는 끈이다. */
|
|
102
|
+
export const TABLE_PART = "tablePart";
|