@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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 사전에 없는 한국어 낱말을 **국립국어원 한국어기초사전**에 한 번 물어본다.
|
|
3
|
+
*
|
|
4
|
+
* 아이콘 검색의 한국어는 사전 세 겹으로 받는다.
|
|
5
|
+
*
|
|
6
|
+
* 1. `ICON_KEYWORDS_KO` — 손으로 고른 상세페이지 말투. 순서까지 사람이 정한다.
|
|
7
|
+
* 2. `ICON_KEYWORDS_GENERATED` — Wiktionary에서 구운 것. 아이콘 이름에 실제로
|
|
8
|
+
* 있는 낱말로만 한정된다.
|
|
9
|
+
* 3. **여기** — 앞의 둘이 다 놓친 말. 국립국어원은 표제어 5만에 영어 대역어를 달고
|
|
10
|
+
* 있어서, 우리가 미리 예상하지 못한 말도 옮겨 준다.
|
|
11
|
+
*
|
|
12
|
+
* 이 층은 **없어도 되게** 만들었다. 키가 없거나, 하루 몫을 다 썼거나, 제공처가 죽으면
|
|
13
|
+
* 빈 배열을 돌려주고 검색은 앞의 두 사전만으로 계속 간다 — 결과가 조금 성길 뿐
|
|
14
|
+
* 오류로 끝나지 않는다.
|
|
15
|
+
*
|
|
16
|
+
* 키는 서버에만 있다(`NEXT_PUBLIC_` 아님). 이 모듈은 라우트 핸들러에서만 부른다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ENDPOINT = "https://krdict.korean.go.kr/api/search";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 제공처 앞단이 표기 없는 요청을 `<H1>Request Blocked</H1>`로 되돌린다. 키가 맞아도
|
|
23
|
+
* 400이 오므로 키 문제로 착각하기 쉽다 — 브라우저 표기를 붙여야 통과한다.
|
|
24
|
+
*/
|
|
25
|
+
const USER_AGENT =
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
27
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
28
|
+
|
|
29
|
+
/** 제공처가 10 미만을 거절한다(`Invalid num value`). */
|
|
30
|
+
const NUM = 10;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 한 사람이 하루에 쓸 수 있는 조회 수.
|
|
34
|
+
*
|
|
35
|
+
* 제공처 몫은 하루 5만이다. 한 사람이 그것을 다 태우면 나머지 전부가 조용히 나빠지므로
|
|
36
|
+
* 훨씬 낮은 자리에서 먼저 멈춘다. 캐시가 앞에 있어서 **처음 보는 낱말**만 여기 닿는다 —
|
|
37
|
+
* 100번이면 사람 하나가 하루에 새로 쳐 볼 만한 한국어를 넉넉히 덮는다.
|
|
38
|
+
*/
|
|
39
|
+
export const DAILY_LOOKUP_LIMIT = 100;
|
|
40
|
+
|
|
41
|
+
/** 옮긴 말은 오래 들고 있는다 — 사전은 안 변한다. */
|
|
42
|
+
const CACHE_TTL_MS = 12 * 60 * 60_000;
|
|
43
|
+
const CACHE_MAX_ENTRIES = 2_000;
|
|
44
|
+
|
|
45
|
+
type CacheEntry = { value: string[]; expiry: number };
|
|
46
|
+
const cache = new Map<string, CacheEntry>();
|
|
47
|
+
|
|
48
|
+
function readCache(word: string): string[] | null {
|
|
49
|
+
const hit = cache.get(word);
|
|
50
|
+
if (!hit) return null;
|
|
51
|
+
if (Date.now() >= hit.expiry) {
|
|
52
|
+
cache.delete(word);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return hit.value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeCache(word: string, value: string[]): void {
|
|
59
|
+
if (cache.size >= CACHE_MAX_ENTRIES) {
|
|
60
|
+
const oldest = cache.keys().next();
|
|
61
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
62
|
+
}
|
|
63
|
+
cache.set(word, { value, expiry: Date.now() + CACHE_TTL_MS });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 하루가 바뀌는 자리. 서버 시각 기준이라 자정 언저리에 한 번 헐거워질 수 있다. */
|
|
67
|
+
export function dayKey(now: Date = new Date()): string {
|
|
68
|
+
return now.toISOString().slice(0, 10);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const spent = new Map<string, { day: string; count: number }>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 이 사람 몫에서 한 번 빼낸다. 남아 있으면 `true`.
|
|
75
|
+
*
|
|
76
|
+
* 프로세스 메모리라 인스턴스마다 따로 센다 — 정확한 통제가 아니라 **한 사람이 제공처
|
|
77
|
+
* 몫을 통째로 태우는 것**을 막는 헐거운 울타리다. 그 이상이 필요해지면 여기만 갈아
|
|
78
|
+
* 끼우면 된다(호출부는 불리언만 본다).
|
|
79
|
+
*/
|
|
80
|
+
export function takeDailyBudget(userId: string, now: Date = new Date()): boolean {
|
|
81
|
+
const today = dayKey(now);
|
|
82
|
+
const seen = spent.get(userId);
|
|
83
|
+
if (!seen || seen.day !== today) {
|
|
84
|
+
spent.set(userId, { day: today, count: 1 });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (seen.count >= DAILY_LOOKUP_LIMIT) return false;
|
|
88
|
+
seen.count += 1;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 테스트에서 하루치를 되돌린다. */
|
|
93
|
+
export function resetKoreanDictionaryState(): void {
|
|
94
|
+
spent.clear();
|
|
95
|
+
cache.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 응답에서 영어 대역어만 뽑는다.
|
|
100
|
+
*
|
|
101
|
+
* 값이 `<![CDATA[...]]>`로 싸여 오고 사이사이 탭·줄바꿈이 잔뜩 낀 XML이라 파서를
|
|
102
|
+
* 들이지 않고 필요한 태그만 훑는다. 태그 하나가 얕고 이름이 고유해서 그것으로 충분하다.
|
|
103
|
+
*/
|
|
104
|
+
export function parseTransWords(xml: string): string[] {
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
const pattern = /<trans_word>\s*(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?\s*<\/trans_word>/g;
|
|
107
|
+
for (const match of xml.matchAll(pattern)) {
|
|
108
|
+
const word = normalizeTranslation(match[1]);
|
|
109
|
+
if (word && !out.includes(word)) out.push(word);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 대역어 한 줄을 견줄 수 있는 낱말로 다듬는다.
|
|
116
|
+
*
|
|
117
|
+
* 사전은 `apple`처럼 한 낱말도 주지만 `refrigerator, fridge`처럼 여럿을 한 칸에
|
|
118
|
+
* 담거나 `delivery (of goods)`처럼 괄호를 달기도 한다. 아이콘 이름과 견주려면
|
|
119
|
+
* **첫 낱말 덩어리 하나**면 된다.
|
|
120
|
+
*/
|
|
121
|
+
function normalizeTranslation(raw: string): string {
|
|
122
|
+
const first = raw.split(/[,;/]/)[0] ?? "";
|
|
123
|
+
return first
|
|
124
|
+
.replace(/\([^)]*\)/g, " ")
|
|
125
|
+
.replace(/[^A-Za-z\s-]/g, " ")
|
|
126
|
+
.trim()
|
|
127
|
+
.replace(/\s+/g, " ")
|
|
128
|
+
.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 키가 있는가. 없으면 이 층은 통째로 꺼진다. */
|
|
132
|
+
export function hasKoreanDictionaryKey(): boolean {
|
|
133
|
+
return Boolean(process.env.KOREAN_DICTIONARY_API_KEY);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type LookupOptions = {
|
|
137
|
+
userId: string;
|
|
138
|
+
signal?: AbortSignal;
|
|
139
|
+
/** 테스트에서 하루 경계를 고정한다. */
|
|
140
|
+
now?: Date;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 한국어 낱말 하나를 영어로. 못 옮기면 **빈 배열**이다(던지지 않는다).
|
|
145
|
+
*
|
|
146
|
+
* 캐시가 먼저다 — 캐시에 있으면 하루 몫을 쓰지 않는다. 이미 물어봐서 없다는 것을 안
|
|
147
|
+
* 낱말도 캐시에 남으므로 같은 말을 계속 두드리지 않는다.
|
|
148
|
+
*/
|
|
149
|
+
export async function lookupKorean(
|
|
150
|
+
word: string,
|
|
151
|
+
{ userId, signal, now }: LookupOptions,
|
|
152
|
+
): Promise<string[]> {
|
|
153
|
+
const key = process.env.KOREAN_DICTIONARY_API_KEY;
|
|
154
|
+
if (!key) return [];
|
|
155
|
+
|
|
156
|
+
const term = word.trim();
|
|
157
|
+
if (!term) return [];
|
|
158
|
+
|
|
159
|
+
const cached = readCache(term);
|
|
160
|
+
if (cached) return cached;
|
|
161
|
+
|
|
162
|
+
if (!takeDailyBudget(userId, now)) return [];
|
|
163
|
+
|
|
164
|
+
const url = new URL(ENDPOINT);
|
|
165
|
+
url.searchParams.set("key", key);
|
|
166
|
+
url.searchParams.set("q", term);
|
|
167
|
+
url.searchParams.set("part", "word");
|
|
168
|
+
url.searchParams.set("sort", "popular");
|
|
169
|
+
url.searchParams.set("num", String(NUM));
|
|
170
|
+
url.searchParams.set("translated", "y");
|
|
171
|
+
// 1 = 영어.
|
|
172
|
+
url.searchParams.set("trans_lang", "1");
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch(url, {
|
|
176
|
+
signal,
|
|
177
|
+
headers: { "User-Agent": USER_AGENT, Accept: "application/xml" },
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) return [];
|
|
180
|
+
const words = parseTransWords(await response.text());
|
|
181
|
+
writeCache(term, words);
|
|
182
|
+
return words;
|
|
183
|
+
} catch {
|
|
184
|
+
// 시간 초과·중단·제공처 장애. 이 층 없이 계속 간다.
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 레이어 트리에서 요소를 끌어 옮긴다 — 같은 부모 안 순서 바꾸기, 그룹 안으로 넣기,
|
|
3
|
+
* 그룹 밖으로 빼기 (Figma의 레이어 패널과 같은 조작).
|
|
4
|
+
*
|
|
5
|
+
* **왜 이렇게 도는가.** 스톡 편집기에는 요소의 부모를 바꾸는 공개 API가 없다.
|
|
6
|
+
* ``page.addElement``는 페이지에만 있고, ``setElementZIndex``는 같은 부모 안에서만
|
|
7
|
+
* 자리를 옮긴다. 부모를 바꾸는 유일한 공개 수단이 ``ungroupElements`` /
|
|
8
|
+
* ``groupElements`` 다 — 실제로 Canvas 내부도 이 둘에서만 MST ``detach``로 노드를
|
|
9
|
+
* 다른 부모에 옮겨 붙인다. 그래서 그룹을 **해체했다가 원하는 자식 목록으로 다시
|
|
10
|
+
* 묶는다**. ``groupElements(ids, attrs)``의 attrs가 기본값을 덮으므로 **그룹 id와
|
|
11
|
+
* name·custom을 그대로 넘겨** 같은 그룹으로 되살릴 수 있다(문서·계약이 그룹 id를
|
|
12
|
+
* 참조하므로 id 보존이 핵심이다). 자식 순서는 넘긴 ids 순서 그대로다.
|
|
13
|
+
*
|
|
14
|
+
* 해체·재구성한 그룹은 페이지 맨 끝(맨 앞)으로 붙으므로, 원래 있던 칸을 다시 세어
|
|
15
|
+
* 되돌린다. 삭제·이동으로 인덱스가 밀리니 "원래 내 앞에 있었고 지금도 살아있는 것"의
|
|
16
|
+
* 개수로 목표 칸을 구한다.
|
|
17
|
+
*
|
|
18
|
+
* **한계.** 페이지 직속 그룹만 다룬다. ``ungroupElements``는 자식을 ``e.page`` 로
|
|
19
|
+
* 올려버려서 중첩 그룹에 쓰면 계층이 납작해지고 엉뚱한 요소가 지워진다. 중첩 그룹이
|
|
20
|
+
* 얽힌 이동은 ``false``를 돌려주고 아무것도 건드리지 않는다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// children은 호출자마다 타입이 다르게 잡혀 있어(Canvas 모델·테스트 픽스처) unknown으로
|
|
24
|
+
// 받고 ``kids()`` 한 곳에서만 배열로 좁힌다.
|
|
25
|
+
export type LayerElement = {
|
|
26
|
+
id: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
children?: unknown;
|
|
29
|
+
toJSON?: () => Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ParentLike = {
|
|
33
|
+
id?: string;
|
|
34
|
+
children?: unknown;
|
|
35
|
+
setElementZIndex?: (id: string, index: number) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type StoreLike = {
|
|
39
|
+
activePage?: ParentLike;
|
|
40
|
+
selectElements?: (ids: string[]) => void;
|
|
41
|
+
groupElements?: (ids: string[], attrs?: Record<string, unknown>) => unknown;
|
|
42
|
+
ungroupElements?: (ids: string[]) => void;
|
|
43
|
+
history?: { transaction?: (fn: () => void) => unknown };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 드롭 지점: 어느 부모의(페이지는 null) 몇 번째 칸인가. index가 클수록 앞. */
|
|
47
|
+
export type DropSpot = { parentId: string | null; index: number };
|
|
48
|
+
|
|
49
|
+
/** 행에서 끌어놓는 위치. 목록은 앞→뒤 역순이라 before가 "더 앞". */
|
|
50
|
+
export type DropZone = "before" | "after" | "inside";
|
|
51
|
+
|
|
52
|
+
function kids(node: ParentLike | LayerElement | null | undefined): LayerElement[] {
|
|
53
|
+
return Array.isArray(node?.children) ? (node?.children as LayerElement[]) : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clamp(value: number, min: number, max: number): number {
|
|
57
|
+
return Math.max(min, Math.min(max, value));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ``id``의 직접 부모(페이지 직속이면 null)와 그 부모 안 인덱스. 없으면 null. */
|
|
61
|
+
export function locate(
|
|
62
|
+
page: ParentLike,
|
|
63
|
+
id: string,
|
|
64
|
+
): { parent: LayerElement | null; index: number } | null {
|
|
65
|
+
const walk = (
|
|
66
|
+
list: LayerElement[],
|
|
67
|
+
parent: LayerElement | null,
|
|
68
|
+
): { parent: LayerElement | null; index: number } | null => {
|
|
69
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
70
|
+
if (list[i].id === id) return { parent, index: i };
|
|
71
|
+
const found = walk(kids(list[i]), list[i]);
|
|
72
|
+
if (found) return found;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
return walk(kids(page), null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function elementById(page: ParentLike, id: string): LayerElement | null {
|
|
80
|
+
const walk = (list: LayerElement[]): LayerElement | null => {
|
|
81
|
+
for (const el of list) {
|
|
82
|
+
if (el.id === id) return el;
|
|
83
|
+
const found = walk(kids(el));
|
|
84
|
+
if (found) return found;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
return walk(kids(page));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function contains(root: LayerElement | null, id: string): boolean {
|
|
92
|
+
if (!root) return false;
|
|
93
|
+
return kids(root).some((child) => child.id === id || contains(child, id));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 놓은 행과 위치를 실제 드롭 지점으로 바꾼다.
|
|
98
|
+
*
|
|
99
|
+
* 목록은 앞(위)에 그려지는 것이 맨 위로 오도록 뒤집어 그리므로, 화면에서 "행 위"는
|
|
100
|
+
* 모델에서 그 행보다 **뒤 인덱스**(더 앞)다. 인덱스는 끌고 있는 요소를 뺀 목록 기준
|
|
101
|
+
* (``setElementZIndex``가 빼고 끼우는 것과 같은 계약)이다.
|
|
102
|
+
*/
|
|
103
|
+
export function dropSpot(
|
|
104
|
+
page: ParentLike,
|
|
105
|
+
rowId: string,
|
|
106
|
+
zone: DropZone,
|
|
107
|
+
dragId: string,
|
|
108
|
+
): DropSpot | null {
|
|
109
|
+
if (!rowId || !dragId || rowId === dragId) return null;
|
|
110
|
+
const row = elementById(page, rowId);
|
|
111
|
+
if (!row) return null;
|
|
112
|
+
|
|
113
|
+
if (zone === "inside") {
|
|
114
|
+
if (row.type !== "group") return null;
|
|
115
|
+
// 그룹 안에서는 맨 앞에 얹는다(Figma와 같다).
|
|
116
|
+
return { parentId: row.id, index: kids(row).length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const at = locate(page, rowId);
|
|
120
|
+
if (!at) return null;
|
|
121
|
+
const parent = at.parent;
|
|
122
|
+
const siblings = kids(parent ?? page)
|
|
123
|
+
.map((el) => el.id)
|
|
124
|
+
.filter((id) => id !== dragId);
|
|
125
|
+
const i = siblings.indexOf(rowId);
|
|
126
|
+
if (i < 0) return null;
|
|
127
|
+
return { parentId: parent?.id ?? null, index: zone === "before" ? i + 1 : i };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 그룹을 되살릴 때 쓸 속성. children은 빼고(재구성할 목록으로 대체) id는 지킨다. */
|
|
131
|
+
function groupAttrs(group: LayerElement): Record<string, unknown> {
|
|
132
|
+
const raw =
|
|
133
|
+
typeof group.toJSON === "function"
|
|
134
|
+
? { ...group.toJSON() }
|
|
135
|
+
: ({ ...group } as Record<string, unknown>);
|
|
136
|
+
delete raw.children;
|
|
137
|
+
raw.id = group.id;
|
|
138
|
+
return raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 재구성으로 페이지 끝에 붙은 요소를 원래 칸으로 되돌린다.
|
|
143
|
+
*
|
|
144
|
+
* ``beforeIds``는 손대기 전 페이지 직속 id 목록, ``originalIndex``는 그 안에서의 자리.
|
|
145
|
+
* 그 앞에 있었고 **지금도 남아 있는** 것의 수가 곧 목표 칸이다.
|
|
146
|
+
*/
|
|
147
|
+
function restorePageSlot(
|
|
148
|
+
page: ParentLike,
|
|
149
|
+
id: string,
|
|
150
|
+
beforeIds: string[],
|
|
151
|
+
originalIndex: number,
|
|
152
|
+
): void {
|
|
153
|
+
if (!page.setElementZIndex || originalIndex < 0) return;
|
|
154
|
+
const alive = new Set(kids(page).map((el) => el.id));
|
|
155
|
+
if (!alive.has(id)) return;
|
|
156
|
+
let target = 0;
|
|
157
|
+
for (let i = 0; i < originalIndex; i += 1) {
|
|
158
|
+
if (alive.has(beforeIds[i])) target += 1;
|
|
159
|
+
}
|
|
160
|
+
page.setElementZIndex(id, target);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runInTransaction(store: StoreLike, fn: () => void): void {
|
|
164
|
+
const transaction = store.history?.transaction;
|
|
165
|
+
if (typeof transaction !== "function") {
|
|
166
|
+
fn();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// 해체·재구성이 여러 액션으로 나뉘어도 undo 한 번에 되돌아가게 묶는다.
|
|
170
|
+
transaction.call(store.history, fn);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* ``dragId``를 ``spot``으로 옮긴다. 옮겼으면 true.
|
|
175
|
+
*
|
|
176
|
+
* 실패(중첩 그룹, 자기 자손으로 넣기, 필요한 API 없음)하면 아무것도 바꾸지 않고 false.
|
|
177
|
+
*/
|
|
178
|
+
export function moveLayer(store: unknown, dragId: string, spot: DropSpot): boolean {
|
|
179
|
+
const s = store as StoreLike;
|
|
180
|
+
const page = s.activePage;
|
|
181
|
+
if (!page || !dragId) return false;
|
|
182
|
+
|
|
183
|
+
const from = locate(page, dragId);
|
|
184
|
+
if (!from) return false;
|
|
185
|
+
|
|
186
|
+
const srcParentId = from.parent?.id ?? null;
|
|
187
|
+
const dstParentId = spot.parentId ?? null;
|
|
188
|
+
|
|
189
|
+
// 자기 자신 또는 자기 자손 안으로는 넣을 수 없다(트리가 끊긴다).
|
|
190
|
+
if (dstParentId === dragId) return false;
|
|
191
|
+
if (dstParentId && contains(elementById(page, dragId), dstParentId)) return false;
|
|
192
|
+
|
|
193
|
+
if (srcParentId === dstParentId) {
|
|
194
|
+
const parent = (from.parent ?? page) as ParentLike;
|
|
195
|
+
if (!parent.setElementZIndex) return false;
|
|
196
|
+
const count = kids(parent).length;
|
|
197
|
+
parent.setElementZIndex(dragId, clamp(spot.index, 0, Math.max(0, count - 1)));
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const srcGroup = from.parent;
|
|
202
|
+
const dstGroup = dstParentId ? elementById(page, dstParentId) : null;
|
|
203
|
+
if (dstParentId && (!dstGroup || dstGroup.type !== "group")) return false;
|
|
204
|
+
|
|
205
|
+
// 페이지 직속 그룹만 해체·재구성할 수 있다(위 주석의 한계).
|
|
206
|
+
const pageIds = kids(page).map((el) => el.id);
|
|
207
|
+
if (srcGroup && !pageIds.includes(srcGroup.id)) return false;
|
|
208
|
+
if (dstGroup && !pageIds.includes(dstGroup.id)) return false;
|
|
209
|
+
if (typeof s.ungroupElements !== "function" || typeof s.groupElements !== "function") {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const srcIndex = srcGroup ? pageIds.indexOf(srcGroup.id) : -1;
|
|
214
|
+
const dstIndex = dstGroup ? pageIds.indexOf(dstGroup.id) : -1;
|
|
215
|
+
const srcAttrs = srcGroup ? groupAttrs(srcGroup) : null;
|
|
216
|
+
const dstAttrs = dstGroup ? groupAttrs(dstGroup) : null;
|
|
217
|
+
const srcRemaining = srcGroup
|
|
218
|
+
? kids(srcGroup)
|
|
219
|
+
.map((el) => el.id)
|
|
220
|
+
.filter((id) => id !== dragId)
|
|
221
|
+
: [];
|
|
222
|
+
const dstNext = dstGroup ? kids(dstGroup).map((el) => el.id) : [];
|
|
223
|
+
if (dstGroup) dstNext.splice(clamp(spot.index, 0, dstNext.length), 0, dragId);
|
|
224
|
+
|
|
225
|
+
runInTransaction(s, () => {
|
|
226
|
+
if (srcGroup) {
|
|
227
|
+
s.ungroupElements?.([srcGroup.id]);
|
|
228
|
+
// 자식이 하나도 안 남으면 빈 그룹을 되살리지 않는다(Figma와 같다).
|
|
229
|
+
if (srcRemaining.length > 0) s.groupElements?.(srcRemaining, srcAttrs ?? {});
|
|
230
|
+
}
|
|
231
|
+
if (dstGroup) {
|
|
232
|
+
s.ungroupElements?.([dstGroup.id]);
|
|
233
|
+
s.groupElements?.(dstNext, dstAttrs ?? {});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 되살린 그룹들을 원래 칸으로. 원래 인덱스가 작은 것부터 처리해야 자리가 안 밀린다.
|
|
237
|
+
const restore: Array<{ id: string; index: number }> = [];
|
|
238
|
+
if (srcGroup && srcRemaining.length > 0) {
|
|
239
|
+
restore.push({ id: srcGroup.id, index: srcIndex });
|
|
240
|
+
}
|
|
241
|
+
if (dstGroup) restore.push({ id: dstGroup.id, index: dstIndex });
|
|
242
|
+
restore
|
|
243
|
+
.sort((a, b) => a.index - b.index)
|
|
244
|
+
.forEach((item) => restorePageSlot(page, item.id, pageIds, item.index));
|
|
245
|
+
|
|
246
|
+
// 그룹 밖으로 나온 경우엔 페이지에서의 칸도 정해준다.
|
|
247
|
+
if (!dstGroup && page.setElementZIndex) {
|
|
248
|
+
const count = kids(page).length;
|
|
249
|
+
page.setElementZIndex(dragId, clamp(spot.index, 0, Math.max(0, count - 1)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// group/ungroup이 선택을 바꿔놓으므로 끌던 요소로 되돌린다.
|
|
253
|
+
s.selectElements?.([dragId]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 새 상품 초안의 **무거운 몫**을 담아 두는 IndexedDB 한 칸.
|
|
3
|
+
*
|
|
4
|
+
* `sessionStorage` 는 보통 5MB 에서 막힌다. 레퍼런스 한 장이 4MB 까지 허용되므로
|
|
5
|
+
* (`MAX_REFERENCE_UPLOAD_BYTES`) 여섯 장이면 그 한도를 우습게 넘고, 넘기면 쓰기가
|
|
6
|
+
* **통째로** 실패해서 글자까지 같이 잃는다. IndexedDB 에는 그 한도가 없다.
|
|
7
|
+
*
|
|
8
|
+
* 열고 읽고 쓰고 지우는 것만 여기 둔다. 무엇을 담을지는 `new-product-draft` 가
|
|
9
|
+
* 정한다 — 이 칸은 그냥 통이다. 전부 best-effort 라 실패는 `null` / no-op 으로
|
|
10
|
+
* 떨어진다. 초안은 편의지 정합성의 근거가 아니다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DB_NAME = "leviosa-detail-page";
|
|
14
|
+
const DB_VERSION = 1;
|
|
15
|
+
const STORE = "new-product-draft";
|
|
16
|
+
/**
|
|
17
|
+
* 칸은 하나다. 초안은 "지금 만들던 것" 하나뿐이고, 여러 개를 쌓으면 어느 것이 살아
|
|
18
|
+
* 있는지 화면이 알 수 없다. 새로 저장하면 앞의 것을 덮는다.
|
|
19
|
+
*
|
|
20
|
+
* 탭 단위가 아니라 오리진 단위라, 두 탭에서 동시에 만들면 나중 탭이 이 칸을 덮는다.
|
|
21
|
+
* 그때 앞 탭은 자기 `sessionStorage`(탭 단위) 몫으로 떨어진다 — 글자와 S3 키는 남고
|
|
22
|
+
* 그림만 잃는 예전 동작이다.
|
|
23
|
+
*/
|
|
24
|
+
const KEY = "current";
|
|
25
|
+
|
|
26
|
+
function available(): boolean {
|
|
27
|
+
try {
|
|
28
|
+
return typeof indexedDB !== "undefined";
|
|
29
|
+
} catch {
|
|
30
|
+
// 잠긴 WebView 처럼 접근 자체가 던지는 자리가 있다.
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function openDb(): Promise<IDBDatabase> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
38
|
+
request.onupgradeneeded = () => {
|
|
39
|
+
const db = request.result;
|
|
40
|
+
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
|
41
|
+
};
|
|
42
|
+
request.onsuccess = () => resolve(request.result);
|
|
43
|
+
request.onerror = () => reject(request.error);
|
|
44
|
+
request.onblocked = () => reject(new Error("indexedDB open blocked"));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 담긴 것을 그대로 돌려준다. 없거나 못 읽으면 `null`. */
|
|
49
|
+
export async function readStoredDraft(): Promise<unknown> {
|
|
50
|
+
if (!available()) return null;
|
|
51
|
+
try {
|
|
52
|
+
const db = await openDb();
|
|
53
|
+
return await new Promise<unknown>((resolve) => {
|
|
54
|
+
const tx = db.transaction(STORE, "readonly");
|
|
55
|
+
const request = tx.objectStore(STORE).get(KEY);
|
|
56
|
+
request.onsuccess = () => resolve(request.result ?? null);
|
|
57
|
+
request.onerror = () => resolve(null);
|
|
58
|
+
tx.oncomplete = () => db.close();
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 통을 덮어쓴다. 한도를 넘겼거나 저장소가 막혔으면 조용히 지나간다. */
|
|
66
|
+
export async function writeStoredDraft(value: unknown): Promise<void> {
|
|
67
|
+
if (!available()) return;
|
|
68
|
+
try {
|
|
69
|
+
const db = await openDb();
|
|
70
|
+
await new Promise<void>((resolve) => {
|
|
71
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
72
|
+
tx.objectStore(STORE).put(value, KEY);
|
|
73
|
+
const finish = () => {
|
|
74
|
+
db.close();
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
tx.oncomplete = finish;
|
|
78
|
+
tx.onerror = finish;
|
|
79
|
+
tx.onabort = finish;
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
// best-effort — 메모리 사본으로 계속 간다.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 통을 비운다. */
|
|
87
|
+
export async function deleteStoredDraft(): Promise<void> {
|
|
88
|
+
if (!available()) return;
|
|
89
|
+
try {
|
|
90
|
+
const db = await openDb();
|
|
91
|
+
await new Promise<void>((resolve) => {
|
|
92
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
93
|
+
tx.objectStore(STORE).delete(KEY);
|
|
94
|
+
const finish = () => {
|
|
95
|
+
db.close();
|
|
96
|
+
resolve();
|
|
97
|
+
};
|
|
98
|
+
tx.oncomplete = finish;
|
|
99
|
+
tx.onerror = finish;
|
|
100
|
+
tx.onabort = finish;
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// 지우지 못해도 다음 저장이 덮는다.
|
|
104
|
+
}
|
|
105
|
+
}
|