@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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 문서 전체 텍스트 찾기·바꾸기.
3
+ *
4
+ * 상세페이지는 20섹션짜리다. 브랜드명·용량 표기(``30ml``)·성분 학명이 그 안에 흩어져
5
+ * 있고, "30ml → 50ml"를 손으로 찾아 고치는 건 빠뜨리는 작업이다.
6
+ */
7
+
8
+ export type SearchElement = {
9
+ id: string;
10
+ type?: string;
11
+ text?: string;
12
+ custom?: Record<string, unknown> | null;
13
+ children?: SearchElement[];
14
+ };
15
+
16
+ export type SearchPage = { id: string; children?: SearchElement[] };
17
+
18
+ export type SearchOptions = { caseSensitive?: boolean };
19
+
20
+ export type TextMatch = {
21
+ pageId: string;
22
+ elementId: string;
23
+ /** 그 요소 안에 몇 번 나오는가. */
24
+ count: number;
25
+ };
26
+
27
+ /**
28
+ * 차트 그룹 안인가.
29
+ *
30
+ * 표 안 텍스트는 **바꾼다** — 캔버스에서 고친 칸 글자는 다음 ``syncSpecGroup``에서
31
+ * ``harvestTableEdits``가 스펙으로 걷어 올리므로 살아남는다.
32
+ *
33
+ * 차트 안 텍스트는 **건너뛴다** — 차트에는 harvest가 없어서 다음 동기화에 스펙이
34
+ * 다시 그리며 되돌아간다. 조용히 되돌아가느니 아예 안 잡는 게 낫다. 그래서 세지도
35
+ * 않는다(카운터가 바꿀 수 없는 것을 세면 거짓말이 된다).
36
+ */
37
+ export function isChartGroup(el: SearchElement): boolean {
38
+ return !!el.custom && "chart" in el.custom;
39
+ }
40
+
41
+ /** 겹치지 않게 센다 — "aaa"에서 "aa"는 두 번이 아니라 한 번이다(치환과 같은 셈법). */
42
+ export function countOccurrences(
43
+ text: string,
44
+ query: string,
45
+ opts: SearchOptions = {},
46
+ ): number {
47
+ if (!query) return 0;
48
+ const hay = opts.caseSensitive ? text : text.toLowerCase();
49
+ const needle = opts.caseSensitive ? query : query.toLowerCase();
50
+ // split은 겹치지 않는 조각으로 자른다 — 정규식을 안 쓰므로 사용자가 넣은 `.`·`(`가
51
+ // 메타문자로 새지 않는다.
52
+ return hay.split(needle).length - 1;
53
+ }
54
+
55
+ /**
56
+ * 검색어가 든 텍스트 요소들. 그리는 순서(=페이지 순서, 그 안에서 자식 순서)를 지켜
57
+ * ↑/↓로 훑는 순서가 문서를 읽는 순서와 같게 한다.
58
+ */
59
+ export function collectTextMatches(
60
+ pages: ReadonlyArray<SearchPage>,
61
+ query: string,
62
+ opts: SearchOptions = {},
63
+ ): TextMatch[] {
64
+ if (!query) return [];
65
+ const out: TextMatch[] = [];
66
+ const visit = (el: SearchElement, pageId: string) => {
67
+ if (isChartGroup(el)) return;
68
+ if (el.type === "text" && typeof el.text === "string") {
69
+ const count = countOccurrences(el.text, query, opts);
70
+ if (count > 0) out.push({ pageId, elementId: el.id, count });
71
+ }
72
+ for (const child of el.children ?? []) visit(child, pageId);
73
+ };
74
+ for (const page of pages) {
75
+ for (const child of page.children ?? []) visit(child, page.id);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** 전체 등장 횟수. 요소 수가 아니라 실제로 바뀔 자리 수다. */
81
+ export function totalOccurrences(matches: ReadonlyArray<TextMatch>): number {
82
+ return matches.reduce((sum, m) => sum + m.count, 0);
83
+ }
84
+
85
+ /**
86
+ * 문자열 그대로 치환. 정규식을 안 쓴다 — 검색어의 ``.``·``(``와 바꿀 말의 ``$&``가
87
+ * 특수문자로 새면 사용자가 친 그대로가 안 들어간다.
88
+ */
89
+ export function replaceInText(
90
+ text: string,
91
+ query: string,
92
+ replacement: string,
93
+ opts: SearchOptions = {},
94
+ ): string {
95
+ if (!query) return text;
96
+ if (opts.caseSensitive) return text.split(query).join(replacement);
97
+
98
+ // 대소문자를 무시할 땐 자리만 소문자로 찾고 잘라내기는 원문에서 한다.
99
+ const hay = text.toLowerCase();
100
+ const needle = query.toLowerCase();
101
+ let out = "";
102
+ let from = 0;
103
+ for (;;) {
104
+ const at = hay.indexOf(needle, from);
105
+ if (at < 0) break;
106
+ out += text.slice(from, at) + replacement;
107
+ from = at + needle.length;
108
+ }
109
+ return out + text.slice(from);
110
+ }
111
+
112
+ /** ↑/↓로 감싸 돌기. 목록이 비면 null. */
113
+ export function stepIndex(
114
+ current: number,
115
+ length: number,
116
+ direction: 1 | -1,
117
+ ): number | null {
118
+ if (length <= 0) return null;
119
+ return (((current + direction) % length) + length) % length;
120
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 서식 복사·붙이기(Canva의 "서식 붓").
3
+ *
4
+ * 상세페이지는 **같은 톤의 소제목이 20섹션 반복되는 문서**다. 하나를 다듬어 놓고
5
+ * 나머지에 같은 폰트·크기·색·자간·하이라이트를 먹이려면 지금은 인스펙터에서 값을
6
+ * 하나씩 다시 맞춰야 한다.
7
+ *
8
+ * 스톡 편집기의 ``useCopyStyle``을 안 쓰는 이유가 셋 있다.
9
+ *
10
+ * 1. 공통 필드에 ``width``·``height``가 들어 있어, 같은 타입끼리 붙이면 **크기까지
11
+ * 바뀐다**. Canva의 서식 붓은 크기를 안 건드린다.
12
+ * 2. 타입별 필드 표에 ``group``이 없다. ``intersect(map[a.type], map[b.type])``에
13
+ * undefined가 들어가 그룹(=우리 문서의 대부분)에 대고 쓰면 터진다.
14
+ * 3. 발동 조건이 ``selectedElements[0]`` 변화 감지라, 우리 드릴인·다중선택과 섞이면
15
+ * 예측이 안 된다. 여기서는 복사·붙이기 두 동작만 둔다.
16
+ */
17
+
18
+ /** 타입이 무엇이든 옮겨도 되는 것 — 크기·좌표·내용은 하나도 없다. */
19
+ const COMMON = [
20
+ "opacity",
21
+ "blurEnabled",
22
+ "blurRadius",
23
+ "shadowEnabled",
24
+ "shadowBlur",
25
+ "shadowColor",
26
+ "shadowOffsetX",
27
+ "shadowOffsetY",
28
+ ] as const;
29
+
30
+ /**
31
+ * 타입별로 옮길 서식.
32
+ *
33
+ * ``fontSize``는 **넣는다** — 소제목 20개의 크기를 맞추는 게 이 기능의 알맹이다.
34
+ * 상자(``width``/``height``)는 안 건드리므로 글자만 커진다.
35
+ * ``crop*``은 **뺀다** — 어디를 잘라 보여줄지는 서식이 아니라 내용이다.
36
+ */
37
+ const BY_TYPE: Record<string, ReadonlyArray<string>> = {
38
+ text: [
39
+ ...COMMON,
40
+ "fontFamily",
41
+ "fontSize",
42
+ "fontWeight",
43
+ "fontStyle",
44
+ "lineHeight",
45
+ "letterSpacing",
46
+ "align",
47
+ "verticalAlign",
48
+ "textDecoration",
49
+ "fill",
50
+ "stroke",
51
+ "strokeWidth",
52
+ "backgroundEnabled",
53
+ "backgroundColor",
54
+ "backgroundOpacity",
55
+ "backgroundCornerRadius",
56
+ "backgroundPadding",
57
+ ],
58
+ figure: [...COMMON, "fill", "stroke", "strokeWidth", "dash", "cornerRadius"],
59
+ svg: [...COMMON, "colorsReplace", "borderColor", "borderSize", "cornerRadius"],
60
+ image: [...COMMON, "borderColor", "borderSize", "cornerRadius"],
61
+ };
62
+
63
+ /**
64
+ * 서식을 읽고 쓰는 데 필요한 것만. 나머지 필드는 이름으로 집으므로 열어 둔다 — 타입을
65
+ * 좁히면 호출부(캔버스 메뉴의 요소, 테스트 픽스처)가 전부 캐스팅을 달아야 한다.
66
+ */
67
+ export type FormatElement = {
68
+ type?: string;
69
+ set?: (props: Record<string, unknown>) => void;
70
+ [key: string]: unknown;
71
+ };
72
+
73
+ export type CopiedFormat = {
74
+ type: string;
75
+ props: Record<string, unknown>;
76
+ };
77
+
78
+ /** 서식을 복사할 수 있는 타입인가. 그룹·차트·표는 아니다(자기 스펙이 모양을 정한다). */
79
+ export function canCopyFormat(el: FormatElement | undefined | null): boolean {
80
+ return !!el?.type && el.type in BY_TYPE;
81
+ }
82
+
83
+ /** 배열·객체 값은 얕게 복사한다 — 원본과 같은 참조를 공유하면 한쪽을 고칠 때 둘 다 바뀐다. */
84
+ function detach(value: unknown): unknown {
85
+ if (Array.isArray(value)) return [...value];
86
+ if (value && typeof value === "object") return { ...(value as object) };
87
+ return value;
88
+ }
89
+
90
+ /**
91
+ * 요소의 서식을 떠 낸다. 값이 없는(``undefined``) 필드는 **키 자체를 안 만든다** —
92
+ * 나중에 붙일 때 대상의 멀쩡한 값을 undefined로 뭉개지 않기 위해서다.
93
+ */
94
+ export function copyFormat(el: FormatElement | undefined | null): CopiedFormat | null {
95
+ if (!canCopyFormat(el)) return null;
96
+ const type = el!.type!;
97
+ const bag = el as Record<string, unknown>;
98
+ const props: Record<string, unknown> = {};
99
+ for (const key of BY_TYPE[type]) {
100
+ const value = bag[key];
101
+ if (value === undefined) continue;
102
+ props[key] = detach(value);
103
+ }
104
+ return { type, props };
105
+ }
106
+
107
+ /**
108
+ * 이 대상에 실제로 먹일 값. 타입이 다르면 **교집합만** 간다 — 텍스트 서식을 도형에
109
+ * 붙이면 불투명도와 그림자만 옮는다.
110
+ */
111
+ export function formatToApply(
112
+ copied: CopiedFormat,
113
+ target: FormatElement | undefined | null,
114
+ ): Record<string, unknown> {
115
+ const fields = target?.type ? BY_TYPE[target.type] : undefined;
116
+ if (!fields) return {};
117
+ const out: Record<string, unknown> = {};
118
+ for (const key of fields) {
119
+ if (key in copied.props) out[key] = detach(copied.props[key]);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** 붙일 게 하나라도 있는가 — 메뉴 항목 비활성 판정. */
125
+ export function canPasteFormat(
126
+ copied: CopiedFormat | null,
127
+ targets: ReadonlyArray<FormatElement>,
128
+ ): boolean {
129
+ if (!copied || targets.length === 0) return false;
130
+ return targets.some((el) => Object.keys(formatToApply(copied, el)).length > 0);
131
+ }
132
+
133
+ /** 선택 전부에 붙인다. 실제로 바뀐 요소 수를 돌려준다. */
134
+ export function applyFormat(
135
+ copied: CopiedFormat,
136
+ targets: ReadonlyArray<FormatElement>,
137
+ ): number {
138
+ let touched = 0;
139
+ for (const el of targets) {
140
+ const props = formatToApply(copied, el);
141
+ if (!Object.keys(props).length) continue;
142
+ el.set?.(props);
143
+ touched += 1;
144
+ }
145
+ return touched;
146
+ }
147
+
148
+ // ── 세션 클립보드 ───────────────────────────────────────────────────────────
149
+ // 문서에 저장하지 않는다. 서식 붓은 "지금 이 작업 동안"의 도구다.
150
+
151
+ let held: CopiedFormat | null = null;
152
+
153
+ export function holdFormat(copied: CopiedFormat | null): void {
154
+ held = copied;
155
+ }
156
+
157
+ export function heldFormat(): CopiedFormat | null {
158
+ return held;
159
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Decode an animated image (WebP or GIF) into fully-composited RGBA frames.
3
+ *
4
+ * Two decoders, picked from the container magic rather than the URL — a
5
+ * presigned S3 link carries no reliable extension, and ``.webp`` is shared by
6
+ * still and animated files anyway.
7
+ *
8
+ * - **WebP** goes through WebCodecs ``ImageDecoder``, which hands back complete
9
+ * frames. Nothing to composite.
10
+ * - **GIF** goes through gifuct-js, which hands back per-frame *patches* in the
11
+ * frame's own sub-rectangle, so we paint them onto a persistent full-size
12
+ * canvas honoring GIF disposal methods — otherwise frames that only redraw a
13
+ * changed region come out corrupt.
14
+ *
15
+ * Where ``ImageDecoder`` is missing (older Safari, Firefox) an animated WebP
16
+ * decodes to its first frame and simply sits still. That is the same outcome as
17
+ * a failed decode, and the caller already handles a one-frame result.
18
+ *
19
+ * Browser-only (needs canvas); import lazily from export glue.
20
+ */
21
+
22
+ import { parseGIF, decompressFrames, type ParsedFrame } from "gifuct-js";
23
+
24
+ import { assetBytesUrl } from "./asset-bytes-url";
25
+ import { sniffAnimationType } from "./animation-sniff";
26
+
27
+ export type DecodedFrame = {
28
+ /** Full-size composited frame, ready to draw. */
29
+ canvas: HTMLCanvasElement;
30
+ /** This frame's on-screen duration (ms), clamped to a sane minimum. */
31
+ delayMs: number;
32
+ /** Cumulative start time of this frame within the loop (ms). */
33
+ timeMs: number;
34
+ };
35
+
36
+ export type DecodedAnimation = {
37
+ width: number;
38
+ height: number;
39
+ durationMs: number;
40
+ frames: DecodedFrame[];
41
+ };
42
+
43
+ /** Back-compat aliases — these were GIF-only names before WebP landed. */
44
+ export type DecodedGifFrame = DecodedFrame;
45
+ export type DecodedGif = DecodedAnimation;
46
+
47
+ /** GIF spec allows 0 delay; browsers floor it — mirror that so loops aren't instant. */
48
+ const MIN_DELAY_MS = 20;
49
+
50
+ async function fetchArrayBuffer(source: string | ArrayBuffer): Promise<ArrayBuffer> {
51
+ if (source instanceof ArrayBuffer) return source;
52
+ // 리다이렉트를 따라가면 CORS가 깨지는 주소가 있다 — assetBytesUrl 주석 참고.
53
+ const res = await fetch(assetBytesUrl(source));
54
+ if (!res.ok) throw new Error(`animation fetch failed: ${res.status}`);
55
+ return res.arrayBuffer();
56
+ }
57
+
58
+ function makeCanvas(w: number, h: number): HTMLCanvasElement {
59
+ const canvas = document.createElement("canvas");
60
+ canvas.width = Math.max(1, w);
61
+ canvas.height = Math.max(1, h);
62
+ return canvas;
63
+ }
64
+
65
+ type ImageDecoderCtor = new (init: { data: ArrayBuffer; type: string }) => {
66
+ completed: Promise<void>;
67
+ tracks: { ready: Promise<void>; selectedTrack?: { frameCount: number } | null };
68
+ decode: (opts: { frameIndex: number }) => Promise<{
69
+ image: CanvasImageSource & { displayWidth: number; displayHeight: number; duration: number | null; close: () => void };
70
+ }>;
71
+ close: () => void;
72
+ };
73
+
74
+ function imageDecoderCtor(): ImageDecoderCtor | null {
75
+ const ctor = (globalThis as { ImageDecoder?: ImageDecoderCtor }).ImageDecoder;
76
+ return typeof ctor === "function" ? ctor : null;
77
+ }
78
+
79
+ /** Draw the whole animation as a single still frame — the no-decoder fallback. */
80
+ async function decodeStill(buffer: ArrayBuffer, mime: string): Promise<DecodedAnimation> {
81
+ const blob = new Blob([buffer], { type: mime });
82
+ const url = URL.createObjectURL(blob);
83
+ try {
84
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
85
+ const el = new Image();
86
+ el.onload = () => resolve(el);
87
+ el.onerror = () => reject(new Error("still decode failed"));
88
+ el.src = url;
89
+ });
90
+ const canvas = makeCanvas(img.naturalWidth, img.naturalHeight);
91
+ canvas.getContext("2d")?.drawImage(img, 0, 0);
92
+ return {
93
+ width: canvas.width,
94
+ height: canvas.height,
95
+ durationMs: 0,
96
+ frames: [{ canvas, delayMs: MIN_DELAY_MS, timeMs: 0 }],
97
+ };
98
+ } finally {
99
+ URL.revokeObjectURL(url);
100
+ }
101
+ }
102
+
103
+ async function decodeWebp(buffer: ArrayBuffer): Promise<DecodedAnimation> {
104
+ const Ctor = imageDecoderCtor();
105
+ if (!Ctor) return decodeStill(buffer, "image/webp");
106
+
107
+ const decoder = new Ctor({ data: buffer, type: "image/webp" });
108
+ try {
109
+ // 트랙 메타데이터를 기다리는 약속은 ``tracks.ready`` 하나뿐이다. ``completed`` 만
110
+ // 기다리면 ``selectedTrack`` 이 아직 **null** 이라 프레임 수가 1로 읽히고, 움직이는
111
+ // WebP 가 통째로 정지 이미지가 된다 — 편집기에서 멈춰 보이고 내보내기도 한 장으로
112
+ // 나갔던 원인이다(실측: Chrome 150 에서 4프레임 WebP 가 completed 뒤엔 null,
113
+ // tracks.ready 뒤엔 4). 실패가 아니라서 아무 로그도 남지 않는다.
114
+ await decoder.tracks.ready;
115
+ await decoder.completed;
116
+ const count = decoder.tracks.selectedTrack?.frameCount ?? 1;
117
+ const frames: DecodedFrame[] = [];
118
+ let timeMs = 0;
119
+ let width = 0;
120
+ let height = 0;
121
+
122
+ for (let i = 0; i < count; i++) {
123
+ const { image } = await decoder.decode({ frameIndex: i });
124
+ try {
125
+ width = width || image.displayWidth;
126
+ height = height || image.displayHeight;
127
+ const canvas = makeCanvas(image.displayWidth, image.displayHeight);
128
+ canvas.getContext("2d")?.drawImage(image, 0, 0);
129
+ // VideoFrame.duration is microseconds; a still track reports null.
130
+ const delayMs = Math.max(MIN_DELAY_MS, Math.round((image.duration ?? 0) / 1000));
131
+ frames.push({ canvas, delayMs, timeMs });
132
+ timeMs += delayMs;
133
+ } finally {
134
+ image.close();
135
+ }
136
+ }
137
+ if (!frames.length) return decodeStill(buffer, "image/webp");
138
+ return { width, height, durationMs: timeMs, frames };
139
+ } finally {
140
+ decoder.close();
141
+ }
142
+ }
143
+
144
+ function decodeGifBuffer(buffer: ArrayBuffer): DecodedAnimation {
145
+ const parsed = parseGIF(buffer);
146
+ const rawFrames = decompressFrames(parsed, true);
147
+ const width = parsed.lsd.width;
148
+ const height = parsed.lsd.height;
149
+
150
+ // Persistent full-frame canvas we mutate patch-by-patch.
151
+ const stage = makeCanvas(width, height);
152
+ const stageCtx = stage.getContext("2d");
153
+ if (!stageCtx) throw new Error("2d context unavailable");
154
+ // Scratch canvas to turn a patch (RGBA) into something drawImage can place.
155
+ const patchCanvas = makeCanvas(width, height);
156
+ const patchCtx = patchCanvas.getContext("2d");
157
+ if (!patchCtx) throw new Error("2d context unavailable");
158
+
159
+ const frames: DecodedFrame[] = [];
160
+ let timeMs = 0;
161
+ let prevSnapshot: ImageData | null = null;
162
+ let prevFrame: ParsedFrame | null = null;
163
+
164
+ for (const frame of rawFrames) {
165
+ // Apply the *previous* frame's disposal before drawing this one.
166
+ if (prevFrame) {
167
+ const d = prevFrame.disposalType;
168
+ const { left, top, width: fw, height: fh } = prevFrame.dims;
169
+ if (d === 2) stageCtx.clearRect(left, top, fw, fh);
170
+ else if (d === 3 && prevSnapshot) stageCtx.putImageData(prevSnapshot, 0, 0);
171
+ }
172
+ if (frame.disposalType === 3) prevSnapshot = stageCtx.getImageData(0, 0, width, height);
173
+
174
+ const { left, top, width: fw, height: fh } = frame.dims;
175
+ const patch = new ImageData(new Uint8ClampedArray(frame.patch), fw, fh);
176
+ patchCtx.clearRect(0, 0, width, height);
177
+ patchCtx.putImageData(patch, left, top);
178
+ stageCtx.drawImage(patchCanvas, 0, 0);
179
+
180
+ const out = makeCanvas(width, height);
181
+ out.getContext("2d")?.drawImage(stage, 0, 0);
182
+
183
+ const delayMs = Math.max(MIN_DELAY_MS, frame.delay || 0);
184
+ frames.push({ canvas: out, delayMs, timeMs });
185
+ timeMs += delayMs;
186
+ prevFrame = frame;
187
+ }
188
+
189
+ return { width, height, durationMs: timeMs, frames };
190
+ }
191
+
192
+ /** Decode an animated WebP or GIF into composited frames. */
193
+ export async function decodeAnimation(
194
+ source: string | ArrayBuffer,
195
+ ): Promise<DecodedAnimation> {
196
+ const buffer = await fetchArrayBuffer(source);
197
+ const kind = sniffAnimationType(buffer);
198
+ if (kind === "webp") return decodeWebp(buffer);
199
+ if (kind === "gif") return decodeGifBuffer(buffer);
200
+ // Unknown container: it still may be something <img> can draw (PNG/JPEG).
201
+ return decodeStill(buffer, "image/*");
202
+ }
203
+
204
+ /** @deprecated Use {@link decodeAnimation} — kept so older imports keep working. */
205
+ export const decodeGif = decodeAnimation;
206
+
207
+ /** The frame visible at time ``tMs`` within the loop (wraps on duration). */
208
+ export function frameAtTime(gif: DecodedAnimation, tMs: number): DecodedFrame {
209
+ if (gif.frames.length <= 1 || gif.durationMs <= 0) return gif.frames[0];
210
+ const t = ((tMs % gif.durationMs) + gif.durationMs) % gif.durationMs;
211
+ // Linear scan is fine: frame counts are capped and small.
212
+ for (let i = gif.frames.length - 1; i >= 0; i--) {
213
+ if (t >= gif.frames[i].timeMs) return gif.frames[i];
214
+ }
215
+ return gif.frames[0];
216
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure timeline math for encoding a section as one animated GIF. A section may
3
+ * hold several source GIFs of different lengths; we sample a single shared
4
+ * timeline so the exported loop is seamless, then clamp fps and frame count so
5
+ * a full-width section never explodes into a multi-MB file.
6
+ */
7
+
8
+ export const GIF_MAX_FPS = 12;
9
+ export const GIF_MAX_FRAMES = 40;
10
+ /** Cap the shared loop so LCM of mismatched sources can't run away. */
11
+ export const GIF_MAX_LOOP_MS = 8000;
12
+
13
+ function gcd(a: number, b: number): number {
14
+ return b === 0 ? a : gcd(b, a % b);
15
+ }
16
+
17
+ function lcm(a: number, b: number): number {
18
+ if (a === 0 || b === 0) return 0;
19
+ return Math.abs(a * b) / gcd(a, b);
20
+ }
21
+
22
+ export type GifTimeline = {
23
+ /** Sample times (ms) from loop start; one exported frame each. */
24
+ times: number[];
25
+ /** Per-frame delay written into the output GIF (ms). */
26
+ frameDelayMs: number;
27
+ /** Total loop length the samples cover (ms). */
28
+ loopMs: number;
29
+ /** Effective frames-per-second after clamping. */
30
+ fps: number;
31
+ };
32
+
33
+ /**
34
+ * Build the shared sampling timeline for a section from its source GIF
35
+ * durations (ms). One source → its own duration; several → the LCM (so every
36
+ * source lands on a whole loop), capped by ``GIF_MAX_LOOP_MS``. Sample at up to
37
+ * ``maxFps``, then reduce fps if that would exceed ``maxFrames``.
38
+ */
39
+ export function buildGifTimeline(
40
+ durationsMs: number[],
41
+ opts: { maxFps?: number; maxFrames?: number; maxLoopMs?: number } = {},
42
+ ): GifTimeline {
43
+ const maxFps = opts.maxFps ?? GIF_MAX_FPS;
44
+ const maxFrames = opts.maxFrames ?? GIF_MAX_FRAMES;
45
+ const maxLoopMs = opts.maxLoopMs ?? GIF_MAX_LOOP_MS;
46
+
47
+ const valid = durationsMs.filter((d) => Number.isFinite(d) && d > 0);
48
+ if (valid.length === 0) {
49
+ return { times: [0], frameDelayMs: 1000 / maxFps, loopMs: 0, fps: maxFps };
50
+ }
51
+
52
+ // Seed the fold with the first duration (not 0) — lcm(0, x) is 0 and would
53
+ // collapse the whole loop.
54
+ let loopMs = valid.map((d) => Math.round(d)).reduce((acc, d) => lcm(acc, d));
55
+ if (!Number.isFinite(loopMs) || loopMs <= 0) loopMs = Math.max(...valid);
56
+ if (loopMs > maxLoopMs) loopMs = Math.max(...valid.map((d) => Math.min(d, maxLoopMs)));
57
+ loopMs = Math.min(loopMs, maxLoopMs);
58
+
59
+ let count = Math.max(1, Math.round((loopMs / 1000) * maxFps));
60
+ if (count > maxFrames) count = maxFrames;
61
+
62
+ const frameDelayMs = loopMs / count;
63
+ const times = Array.from({ length: count }, (_, i) => i * frameDelayMs);
64
+ return { times, frameDelayMs, loopMs, fps: 1000 / frameDelayMs };
65
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * ⌘G / ⌘⇧G(그리고 우클릭 메뉴)가 지금 선택에 대해 무엇을 해야 하는가.
3
+ *
4
+ * 스톡 편집기의 자기 핸들러(``canvas/canvas/hotkeys.js``)는 **첫 번째** 선택 요소만 보고,
5
+ * 그게 마침 그룹이면 그룹을 해제한다.
6
+ *
7
+ * const e = v.selectedElements[0];
8
+ * if (e && e.type === "group") { v.ungroupElements([e.id]); }
9
+ * else { v.groupElements(v.selectedElements.map(e => e.id)); }
10
+ *
11
+ * 그래서 그룹 둘을 shift로 골라 ⌘G를 누르면 중첩되는 게 아니라 첫 그룹이 조용히
12
+ * 풀려 자식들이 페이지로 쏟아진다(``groupElements`` 자체는 그룹 안 그룹을 잘 다룬다 —
13
+ * 핫키의 분기만 틀렸다).
14
+ *
15
+ * 단축키와 메뉴가 같은 판정을 써야 하므로 컴포넌트에서 떼어 놓았다.
16
+ */
17
+
18
+ /** ``groupAction``이 실제로 읽는 것 전부. */
19
+ export type GroupStore = {
20
+ selectedElementsIds?: string[];
21
+ pages?: Array<{ id: string; children?: Array<{ id: string; type?: string }> }>;
22
+ };
23
+
24
+ /**
25
+ * 선택 중 **페이지 직속** 자식인 id들. 그룹 묶기는 형제끼리만 뜻이 있다 — 드릴인해서
26
+ * 고른 그룹 자식을 ⌘G가 새 그룹으로 뽑아낼 이유가 없다.
27
+ */
28
+ export function groupableIds(store: GroupStore): string[] {
29
+ const selected = new Set(store.selectedElementsIds ?? []);
30
+ if (!selected.size) return [];
31
+ const out: string[] = [];
32
+ for (const page of store.pages ?? []) {
33
+ for (const child of page.children ?? []) {
34
+ if (selected.has(child.id)) out.push(child.id);
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export function groupAction(
41
+ store: GroupStore,
42
+ shift: boolean,
43
+ ): { kind: "group" | "ungroup"; ids: string[] } | null {
44
+ const ids = groupableIds(store);
45
+ if (!ids.length) return null;
46
+ const typeOf = (id: string) => {
47
+ for (const page of store.pages ?? []) {
48
+ for (const child of page.children ?? []) {
49
+ if (child.id === id) return child.type;
50
+ }
51
+ }
52
+ return undefined;
53
+ };
54
+ const groups = ids.filter((id) => typeOf(id) === "group");
55
+ // ⌘⇧G는 늘 해제. 그룹 하나만 골라 ⌘G를 눌렀을 때도 해제다(스톡 편집기의 토글을 유지 —
56
+ // 사람들이 이미 익힌 동작).
57
+ if (shift || (ids.length === 1 && groups.length === 1)) {
58
+ return groups.length ? { kind: "ungroup", ids: groups } : null;
59
+ }
60
+ if (ids.length < 2) return null; // 묶을 게 없다
61
+ return { kind: "group", ids };
62
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 한글을 라틴 글자로 옮긴다. **외래어를 되짚기 위한** 것이지 표기법을 지키려는 것이 아니다.
3
+ *
4
+ * 아이콘 이름은 전부 영어다. "사과"처럼 순우리말은 사전(`icon-keywords.ko.ts`)이
5
+ * 옮겨 주지만, "애플"·"카메라"처럼 **영어를 한글로 적은 말**은 사전에 다 담을 수 없다 —
6
+ * 브랜드·제품 이름은 끝이 없기 때문이다. 그런 말은 소리로 되짚는 편이 낫다.
7
+ *
8
+ * "애플" → `aepeul` → (`icon-fuzzy`의 자리표) → `apl` ← `apple`.
9
+ *
10
+ * 국어의 로마자 표기법을 그대로 따르지는 않는다(음운 변동·붙임표를 안 본다). 뒤에서
11
+ * 자리표가 모음을 뭉개고 비슷한 자음을 합치므로 여기서는 **글자 그대로** 옮기면 된다.
12
+ */
13
+
14
+ const BASE = 0xac00;
15
+ const LAST = 0xd7a3;
16
+
17
+ const CHO = [
18
+ "g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s",
19
+ "ss", "", "j", "jj", "ch", "k", "t", "p", "h",
20
+ ];
21
+
22
+ const JUNG = [
23
+ "a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa",
24
+ "wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i",
25
+ ];
26
+
27
+ const JONG = [
28
+ "", "k", "k", "ks", "n", "nj", "nh", "t", "l", "lk",
29
+ "lm", "lp", "ls", "lt", "lp", "lh", "m", "p", "ps", "t",
30
+ "t", "ng", "t", "t", "k", "t", "p", "t",
31
+ ];
32
+
33
+ /** 한글이 하나라도 있는가. */
34
+ export function hasHangul(text: string): boolean {
35
+ return /[가-힣]/.test(text);
36
+ }
37
+
38
+ /**
39
+ * 한글을 소리 나는 대로 라틴 글자로. 한글이 아닌 글자는 그대로 지나간다.
40
+ *
41
+ * 겹받침은 앞의 대표음만 남기는 대신 두 글자를 그대로 적는다(`ks`, `lm`) — 자리표가
42
+ * 어차피 뭉갠다.
43
+ */
44
+ export function romanizeHangul(text: string): string {
45
+ let out = "";
46
+ for (const char of text) {
47
+ const code = char.codePointAt(0) ?? 0;
48
+ if (code < BASE || code > LAST) {
49
+ out += char;
50
+ continue;
51
+ }
52
+ const index = code - BASE;
53
+ out += CHO[Math.floor(index / 588)];
54
+ out += JUNG[Math.floor((index % 588) / 28)];
55
+ out += JONG[index % 28];
56
+ }
57
+ return out;
58
+ }