@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,150 @@
1
+ "use client";
2
+
3
+ // 프롬프트 편집 무료 한도(SVG 15 / 텍스트 30) UI 조각. 텍스트/SVG 편집 패널이 공유한다.
4
+ // - EditUsageBadge: "(N/M회)" 카운터. 한도 근접 시 색을 바꿔 경고.
5
+ // - EditQuotaBlock: 한도 소진 시 입력 대신 노출되는 하드 차단 + "편집 크레딧 추가하기" CTA.
6
+
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { Sparkles } from "lucide-react";
9
+ import { useTranslation } from "react-i18next";
10
+ import { useDetailPageHost } from "./detail-page-host-context";
11
+
12
+ export interface EditUsageState {
13
+ svgUsed: number;
14
+ svgLimit: number;
15
+ textUsed: number;
16
+ textLimit: number;
17
+ unlimited: boolean;
18
+ }
19
+
20
+ const EMPTY_USAGE: EditUsageState = {
21
+ svgUsed: 0,
22
+ svgLimit: 0,
23
+ textUsed: 0,
24
+ textLimit: 0,
25
+ unlimited: false,
26
+ };
27
+
28
+ /**
29
+ * 편집기 로드 시 인스턴스의 프롬프트 편집 사용량/한도를 한 번 조회하고,
30
+ * 편집 성공/차단 시 applyUsage로 낙관적 갱신한다. generatedId가 없으면(픽스처) no-op.
31
+ */
32
+ export function useDetailPageEditUsage(generatedId?: string) {
33
+ const { api } = useDetailPageHost();
34
+ const [usage, setUsage] = useState<EditUsageState>(EMPTY_USAGE);
35
+
36
+ useEffect(() => {
37
+ if (!generatedId) {
38
+ setUsage(EMPTY_USAGE);
39
+ return;
40
+ }
41
+ let cancelled = false;
42
+ const controller = new AbortController();
43
+ void (async () => {
44
+ try {
45
+ const u = await api.getDetailPageEditUsage(generatedId, controller.signal);
46
+ if (!cancelled) {
47
+ setUsage({
48
+ svgUsed: u.svg_used,
49
+ svgLimit: u.svg_limit,
50
+ textUsed: u.text_used,
51
+ textLimit: u.text_limit,
52
+ unlimited: u.unlimited,
53
+ });
54
+ }
55
+ } catch {
56
+ /* 미로그인/미배포 → 카운터 숨김(0 한도) */
57
+ }
58
+ })();
59
+ return () => {
60
+ cancelled = true;
61
+ controller.abort();
62
+ };
63
+ }, [api, generatedId]);
64
+
65
+ const applyUsage = useCallback(
66
+ (kind: "svg" | "text", used: number, limit: number) => {
67
+ setUsage((prev) =>
68
+ kind === "svg"
69
+ ? { ...prev, svgUsed: used, svgLimit: limit }
70
+ : { ...prev, textUsed: used, textLimit: limit },
71
+ );
72
+ },
73
+ [],
74
+ );
75
+
76
+ return { usage, applyUsage };
77
+ }
78
+
79
+ export function EditUsageBadge({
80
+ used,
81
+ limit,
82
+ unlimited = false,
83
+ }: {
84
+ used: number;
85
+ limit: number;
86
+ /** 한도 면제(scratch/시연) 시 분모·경고색 없이 '{used}회'만 표시한다. */
87
+ unlimited?: boolean;
88
+ }) {
89
+ const { t } = useTranslation("branding");
90
+ if (unlimited) {
91
+ return (
92
+ <span
93
+ className="ml-auto text-[11px] tabular-nums text-muted-foreground"
94
+ title={t("detailPage.editQuota.unlimitedTitle")}
95
+ >
96
+ {t("detailPage.editQuota.usedUnlimited", { used })}
97
+ </span>
98
+ );
99
+ }
100
+ const remaining = Math.max(0, limit - used);
101
+ const warn = remaining <= Math.max(1, Math.ceil(limit * 0.2));
102
+ return (
103
+ <span
104
+ className={
105
+ "ml-auto text-[11px] tabular-nums " +
106
+ (remaining === 0
107
+ ? "text-destructive"
108
+ : warn
109
+ ? "text-amber-500"
110
+ : "text-muted-foreground")
111
+ }
112
+ title={t("detailPage.editQuota.remainingTitle", { remaining })}
113
+ >
114
+ {t("detailPage.editQuota.usage", { used, limit })}
115
+ </span>
116
+ );
117
+ }
118
+
119
+ export function EditQuotaBlock({
120
+ kind,
121
+ onBuyMore,
122
+ }: {
123
+ kind: "svg" | "text";
124
+ onBuyMore?: () => void;
125
+ }) {
126
+ const { t } = useTranslation("branding");
127
+ const label =
128
+ kind === "svg"
129
+ ? t("detailPage.editQuota.shapeEdit")
130
+ : t("detailPage.editQuota.textEdit");
131
+ return (
132
+ <div className="flex flex-col items-center gap-2 px-4 py-4 text-center">
133
+ <p className="text-[13px] font-medium text-foreground">
134
+ {t("detailPage.editQuota.quotaExhausted", { label })}
135
+ </p>
136
+ <p className="text-[11px] leading-4 text-muted-foreground">
137
+ {t("detailPage.editQuota.quotaHint")}
138
+ </p>
139
+ <button
140
+ type="button"
141
+ onClick={onBuyMore}
142
+ disabled={!onBuyMore}
143
+ className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-40"
144
+ >
145
+ <Sparkles size={13} />
146
+ {t("detailPage.editQuota.buyCredits")}
147
+ </button>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Undo / redo that does not blow the editor up.
3
+ *
4
+ * Undoing a structural change (ungroup, group, delete) while the SELECTION still
5
+ * points at the elements that change crashes the stock editor outright:
6
+ *
7
+ * [mobx-state-tree] assertion failed: the creation of the observable instance
8
+ * must be done on the initializing phase
9
+ * Should not already be working. (React, right behind it)
10
+ *
11
+ * The undo re-parents / re-creates those MST nodes, and the selection-driven
12
+ * observers (selectedShapes → the Konva transformer) read them mid-patch, forcing
13
+ * a lazy MST instance to materialise inside the patch — which MST refuses. Verified
14
+ * against the live store on the STOCK Canvas workspace, so this is not something
15
+ * our overlays introduced: ungroup → undo crashes; ungroup → deselect → undo
16
+ * restores the group cleanly.
17
+ *
18
+ * So: drop the selection, run the history step, then re-select whatever survived.
19
+ */
20
+
21
+ export type HistoryStore = {
22
+ selectedElementsIds?: string[];
23
+ selectElements?: (ids: string[]) => void;
24
+ getElementById?: (id: string) => unknown;
25
+ history: { undo: () => void; redo: () => void };
26
+ };
27
+
28
+ function step(store: HistoryStore, run: () => void): void {
29
+ const before = store.selectedElementsIds?.slice() ?? [];
30
+ if (!before.length) {
31
+ run();
32
+ return;
33
+ }
34
+ store.selectElements?.([]);
35
+ // Deselecting is not enough on its own: React has to actually re-render and let
36
+ // Konva detach the transformer from the old nodes. Clearing the selection and
37
+ // undoing in the SAME tick still crashes — the patch lands while the observers
38
+ // are mid-flight. A macrotask hop puts the undo after React has committed.
39
+ setTimeout(() => {
40
+ run();
41
+ // Re-select only what the history step left behind: an id that no longer
42
+ // resolves would put the selection right back into the state it just escaped.
43
+ const alive = store.getElementById
44
+ ? before.filter((id) => store.getElementById?.(id))
45
+ : [];
46
+ if (alive.length) store.selectElements?.(alive);
47
+ }, 0);
48
+ }
49
+
50
+ export function safeUndo(store: HistoryStore): void {
51
+ step(store, () => store.history.undo());
52
+ }
53
+
54
+ export function safeRedo(store: HistoryStore): void {
55
+ step(store, () => store.history.redo());
56
+ }
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ import { safeRedo, safeUndo, type HistoryStore } from "./editor-history";
6
+ import {
7
+ runCanvasMenuAction,
8
+ type CanvasMenuStore,
9
+ type MenuElement,
10
+ } from "../../lib/detail-page/canvas-menu";
11
+ import { groupAction, type GroupStore } from "../../lib/detail-page/group-action";
12
+
13
+ /**
14
+ * Takes over the Canvas hotkeys that are broken for a document made of groups,
15
+ * and adds the ones it never had.
16
+ *
17
+ * ⌘G / ⌘⇧G — the stock editor's own handler ungroups the FIRST selected element whenever it
18
+ * happens to be a group, so shift-selecting two groups never nests them. The right
19
+ * dispatch lives in ``lib/detail-page/group-action.ts`` (the right-click menu needs
20
+ * the same call); here ⌘G groups whatever is selected and ⌘⇧G ungroups.
21
+ *
22
+ * ⌘⌥C / ⌘⌥V — 서식 복사·붙이기(``format-painter.ts``).
23
+ *
24
+ * ⌘Z / ⌘⇧Z / ⌘Y — see editor-history.ts: an undo with a live selection over the
25
+ * elements being restored crashes MST. Route both through the safe wrappers.
26
+ */
27
+
28
+ type HotkeyStore = HistoryStore &
29
+ GroupStore &
30
+ CanvasMenuStore & {
31
+ activePage?: { children?: Array<{ id: string; type?: string }> };
32
+ // ⌘C의 OS 클립보드 복사는 src까지 읽는다 — 메뉴가 보는 것보다 한 칸 넓다.
33
+ selectedElements?: Array<MenuElement & { src?: string }>;
34
+ };
35
+
36
+ function loadCrossOriginImage(src: string): Promise<HTMLImageElement> {
37
+ return new Promise((resolve, reject) => {
38
+ const img = new Image();
39
+ // 프리사인 S3/프록시 픽셀이 캔버스를 tainting하지 않도록 CORS 익명 요청(데이터
40
+ // URI는 불필요). export 경로와 같은 규약.
41
+ if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
42
+ img.onload = () => resolve(img);
43
+ img.onerror = () => reject(new Error("image decode failed"));
44
+ img.src = src;
45
+ });
46
+ }
47
+
48
+ /** 선택된 이미지/SVG 요소의 원본 픽셀을 PNG Blob으로 렌더한다(OS 클립보드용). */
49
+ async function elementToPngBlob(src: string): Promise<Blob> {
50
+ const img = await loadCrossOriginImage(src);
51
+ const canvas = document.createElement("canvas");
52
+ canvas.width = Math.max(1, img.naturalWidth);
53
+ canvas.height = Math.max(1, img.naturalHeight);
54
+ const ctx = canvas.getContext("2d");
55
+ if (!ctx) throw new Error("2d context unavailable");
56
+ ctx.drawImage(img, 0, 0);
57
+ const blob = await new Promise<Blob | null>((resolve) =>
58
+ canvas.toBlob(resolve, "image/png"),
59
+ );
60
+ if (!blob) throw new Error("toBlob failed");
61
+ return blob;
62
+ }
63
+
64
+ /**
65
+ * ⌘C(macOS) / Ctrl+C(Windows·Linux): 선택된 이미지(또는 svg 도형) 하나를 OS
66
+ * 클립보드에 PNG Blob으로 복사해 다른 앱(슬랙/포토샵 등)에 붙여넣을 수 있게 한다.
67
+ * Canvas 기본 복사는 편집기 내부 복제용이라 OS 클립보드로 안 나간다 — 이미지 선택
68
+ * 시에만 가로채 blob으로 내보낸다. 플랫폼 판별은 호출부의 ``metaKey || ctrlKey``.
69
+ *
70
+ * async blob 생성이 사용자 제스처 창을 넘길 수 있으므로 ClipboardItem에 Blob이 아닌
71
+ * **Promise를 넘겨** 브라우저가 제스처를 유지한 채 기다리게 한다.
72
+ * 처리하면 true(→ 기본 동작 차단), 대상이 아니면 false.
73
+ */
74
+ export function copySelectedImageToClipboard(store: {
75
+ selectedElements?: Array<{ type?: string; src?: string }>;
76
+ }): boolean {
77
+ const sel = store.selectedElements ?? [];
78
+ if (sel.length !== 1) return false;
79
+ const el = sel[0];
80
+ const src = el?.src;
81
+ if (!src || (el.type !== "image" && el.type !== "svg")) return false;
82
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) return false;
83
+ navigator.clipboard
84
+ .write([new ClipboardItem({ "image/png": elementToPngBlob(src) })])
85
+ .catch((err) => {
86
+ // taint(CORS 미허용)나 권한 거부 시 조용히 로그만 — 크래시 방지.
87
+ console.warn("[detail-page] 이미지 클립보드 복사 실패:", err);
88
+ });
89
+ return true;
90
+ }
91
+
92
+ /** Typing in a field is not a canvas shortcut. Same guard the stock editor's handler uses. */
93
+ function isTyping(): boolean {
94
+ const el = document.activeElement as HTMLElement | null;
95
+ if (!el) return false;
96
+ return (
97
+ el.tagName === "INPUT" ||
98
+ el.tagName === "TEXTAREA" ||
99
+ el.isContentEditable === true
100
+ );
101
+ }
102
+
103
+ export function EditorHotkeys({ store }: { store: unknown }) {
104
+ useEffect(() => {
105
+ const s = store as HotkeyStore;
106
+
107
+ const onKeyDown = (e: KeyboardEvent) => {
108
+ if (isTyping()) return;
109
+ const mod = e.metaKey || e.ctrlKey;
110
+ if (!mod) return;
111
+
112
+ // ⌘⌥C / ⌘⌥V — 서식 복사·붙이기. Canva의 "서식 붓"인데, 다음 클릭 하나를 가로채는
113
+ // 모드는 안 만든다: 드릴인·드래그·다중선택과 전부 충돌하고, 두 동작으로 나누면
114
+ // 다중 선택에 한 번에 붙일 수 있어 오히려 빠르다.
115
+ if (e.altKey && (e.code === "KeyC" || e.code === "KeyV") && !e.shiftKey) {
116
+ e.preventDefault();
117
+ e.stopPropagation();
118
+ runCanvasMenuAction(
119
+ s,
120
+ e.code === "KeyC" ? "copyFormat" : "pasteFormat",
121
+ );
122
+ return;
123
+ }
124
+
125
+ if (e.code === "KeyC" && !e.shiftKey && !e.altKey) {
126
+ // ⌘C(Mac)·Ctrl+C(Win/Linux) 모두 여기로 온다 — 위의 mod가 metaKey||ctrlKey.
127
+ // 이미지/도형이 선택돼 있으면 OS 클립보드로 복사(가로챔). 그 외(텍스트 선택 등)는
128
+ // 기본/Canvas 동작에 맡긴다.
129
+ if (copySelectedImageToClipboard(s)) {
130
+ e.preventDefault();
131
+ e.stopPropagation();
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (e.code === "KeyG") {
137
+ const action = groupAction(s, e.shiftKey);
138
+ // Always swallow ⌘G — leaving it to the stock editor's handler would ungroup the
139
+ // first selected group even when the user asked to nest two.
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ if (!action) return;
143
+ if (action.kind === "group") s.groupElements?.(action.ids);
144
+ else s.ungroupElements?.(action.ids);
145
+ return;
146
+ }
147
+
148
+ const key = e.key.toLowerCase();
149
+ if (key !== "z" && key !== "y") return;
150
+ const redo = key === "y" || e.shiftKey;
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ if (redo) safeRedo(s);
154
+ else safeUndo(s);
155
+ };
156
+
157
+ // Capture on the document: the stock editor's handleHotkey listens here too, and this
158
+ // has to win before it reaches the workspace.
159
+ document.addEventListener("keydown", onKeyDown, { capture: true });
160
+ return () =>
161
+ document.removeEventListener("keydown", onKeyDown, { capture: true });
162
+ }, [store]);
163
+
164
+ return null;
165
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+ import { Star } from "lucide-react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { encodeSvgDataUri } from "../../lib/detail-page-canvas/export/svg";
8
+ import { insertShape } from "../../lib/detail-page/insert-shape";
9
+ import {
10
+ getElementRecents,
11
+ rememberElement,
12
+ subscribeElementRecents,
13
+ toggleElementPin,
14
+ type ElementRecent,
15
+ } from "../../lib/detail-page/element-recents";
16
+
17
+ /**
18
+ * 요소 서랍 맨 위의 "즐겨찾기 · 최근" 스트립.
19
+ *
20
+ * 상세페이지는 같은 배지·구분선·체크 아이콘을 20섹션 내내 반복해서 넣는다. **검색의
21
+ * 절반은 다시 안 찾는 것**이라 서랍 어느 그룹에 있든 이 줄이 먼저 보인다.
22
+ *
23
+ * 탭으로 만들지 않은 이유는 레일 상한(12)이다 — 지금 11탭이고 남은 한 칸은 규격 검사
24
+ * 몫이다. 스트립은 그룹 바 위에 한 줄로 앉아 자리를 안 먹는다.
25
+ */
26
+
27
+ const EMPTY: ElementRecent[] = [];
28
+ /** 스냅샷은 참조가 안정적이어야 한다 — 매번 새 객체를 주면 무한 렌더가 된다. */
29
+ const SERVER_SNAPSHOT = { recent: EMPTY, pinned: EMPTY };
30
+
31
+ function useRecents() {
32
+ return useSyncExternalStore(
33
+ subscribeElementRecents,
34
+ getElementRecents,
35
+ // 서버에서는 저장소가 없다. 빈 상태로 그리고 물 주입 뒤에 채운다.
36
+ () => SERVER_SNAPSHOT,
37
+ );
38
+ }
39
+
40
+ export function ElementRecentsStrip({ store }: { store: unknown }) {
41
+ const { t } = useTranslation("branding");
42
+ const { recent, pinned } = useRecents();
43
+
44
+ const insert = useCallback(
45
+ (entry: ElementRecent) => {
46
+ insertShape(store, entry.markup, entry.viewBox);
47
+ rememberElement(entry);
48
+ },
49
+ [store],
50
+ );
51
+
52
+ // 즐겨찾기가 먼저, 그 뒤에 최근. 이미 꽂힌 것은 최근 쪽에서 뺀다.
53
+ const pinnedKeys = new Set(pinned.map((entry) => entry.key));
54
+ const items = [
55
+ ...pinned.map((entry) => ({ entry, isPinned: true })),
56
+ ...recent
57
+ .filter((entry) => !pinnedKeys.has(entry.key))
58
+ .map((entry) => ({ entry, isPinned: false })),
59
+ ];
60
+
61
+ if (!items.length) return null;
62
+
63
+ return (
64
+ <div className="shrink-0 border-b border-neutral-200 px-2 py-2">
65
+ <p className="mb-1.5 px-0.5 text-[11px] font-medium text-neutral-500">
66
+ {t("detailPage.recents.title")}
67
+ </p>
68
+ <div className="flex gap-1.5 overflow-x-auto pb-0.5">
69
+ {items.map(({ entry, isPinned }) => (
70
+ <div key={entry.key} className="group relative shrink-0">
71
+ <button
72
+ type="button"
73
+ onClick={() => insert(entry)}
74
+ title={entry.label ?? entry.key}
75
+ aria-label={entry.label ?? entry.key}
76
+ className="flex h-11 w-11 items-center justify-center rounded-lg border border-neutral-200 bg-white p-1.5 hover:border-neutral-400"
77
+ >
78
+ {/* eslint-disable-next-line @next/next/no-img-element */}
79
+ <img
80
+ src={encodeSvgDataUri(entry.markup)}
81
+ alt=""
82
+ className="max-h-full max-w-full object-contain"
83
+ />
84
+ </button>
85
+ <button
86
+ type="button"
87
+ onClick={() => toggleElementPin(entry)}
88
+ aria-label={t(
89
+ isPinned ? "detailPage.recents.unpin" : "detailPage.recents.pin",
90
+ )}
91
+ aria-pressed={isPinned}
92
+ className={`absolute -right-1 -top-1 rounded-full border border-neutral-200 bg-white p-0.5 transition-opacity ${
93
+ isPinned
94
+ ? "text-amber-500 opacity-100"
95
+ : "text-neutral-300 opacity-0 group-hover:opacity-100"
96
+ }`}
97
+ >
98
+ <Star aria-hidden="true" size={10} fill={isPinned ? "currentColor" : "none"} />
99
+ </button>
100
+ </div>
101
+ ))}
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,82 @@
1
+ import Konva from "konva";
2
+
3
+ /**
4
+ * Client-space geometry for a Canvas element, read from the Konva node it
5
+ * actually renders as (so zoom, scroll and the stacked workspace's per-page slots
6
+ * are all baked in — nothing here re-derives layout math).
7
+ *
8
+ * A GROUP is measured from its children, not from a node lookup: Canvas does not
9
+ * reliably stamp the element id onto the Konva.Group it creates, so `#<groupId>`
10
+ * resolves to nothing. Unioning the leaf rects gives the same box and works either
11
+ * way.
12
+ */
13
+
14
+ export type ClientRect = {
15
+ left: number;
16
+ top: number;
17
+ right: number;
18
+ bottom: number;
19
+ };
20
+
21
+ export type RectElement = {
22
+ id: string;
23
+ type?: string;
24
+ children?: RectElement[];
25
+ };
26
+
27
+ /** The rect of ONE element's own Konva node, in client (viewport) coordinates. */
28
+ export function konvaClientRect(id: string): ClientRect | null {
29
+ for (const stage of Konva.stages) {
30
+ const node = stage.findOne(`#${id}`);
31
+ if (!node) continue;
32
+ const box = node.getClientRect(); // stage px, zoom included
33
+ if (!box.width || !box.height) return null;
34
+ const s = stage.container().getBoundingClientRect();
35
+ return {
36
+ left: s.left + box.x,
37
+ top: s.top + box.y,
38
+ right: s.left + box.x + box.width,
39
+ bottom: s.top + box.y + box.height,
40
+ };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export function unionRect(rects: ReadonlyArray<ClientRect | null>): ClientRect | null {
46
+ let out: ClientRect | null = null;
47
+ for (const r of rects) {
48
+ if (!r) continue;
49
+ out = out
50
+ ? {
51
+ left: Math.min(out.left, r.left),
52
+ top: Math.min(out.top, r.top),
53
+ right: Math.max(out.right, r.right),
54
+ bottom: Math.max(out.bottom, r.bottom),
55
+ }
56
+ : r;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** Every leaf under `el` — the elements that really have a Konva node. */
62
+ export function leafIds(el: RectElement): string[] {
63
+ const kids = el.children;
64
+ if (!kids?.length) return [el.id];
65
+ return kids.flatMap(leafIds);
66
+ }
67
+
68
+ /** The box of any element, group or not, in client coordinates. */
69
+ export function elementClientRect(
70
+ el: RectElement,
71
+ rectOf: (id: string) => ClientRect | null = konvaClientRect,
72
+ ): ClientRect | null {
73
+ return unionRect(leafIds(el).map(rectOf));
74
+ }
75
+
76
+ export function pointInRect(
77
+ r: ClientRect | null,
78
+ p: { x: number; y: number },
79
+ ): boolean {
80
+ if (!r) return false;
81
+ return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
82
+ }