@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,117 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+ import { BarChart3, QrCode, Shapes, Smile, Sparkles, Table, type LucideIcon } from "lucide-react";
6
+
7
+ import { DetailPageShapesPanel } from "./detail-page-shapes-panel";
8
+ import { DetailPageDecorationsPanel } from "./detail-page-decorations-panel";
9
+ import { DetailPageChartsPanel } from "./detail-page-charts-panel";
10
+ import { DetailPageTablesPanel } from "./detail-page-tables-panel";
11
+ import { DetailPageIconsPanel } from "./detail-page-icons-panel";
12
+ import { DetailPageQrPanel } from "./detail-page-qr-panel";
13
+ import { ElementRecentsStrip } from "./element-recents-strip";
14
+
15
+ /**
16
+ * "요소" 패널 — 도형·아이콘·QR·장식·차트·표를 한 탭 안에 접어 넣고, 위쪽 그룹 바에서 가른다.
17
+ *
18
+ * 레일이 14탭까지 늘어나 포화였다. 넣을 것이 아이콘·프레임까지 더 붙으면 레일 자체가
19
+ * 스크롤되어 "어디에 뭐가 있는지"가 무너진다. 그래서 **삽입 대상이 같은 성격인 것**
20
+ * (클릭 한 번으로 캔버스에 놓이는 벡터/데이터 요소)을 한 서랍으로 모은다. 사진·텍스트는
21
+ * 성격이 달라 그대로 둔다 — 사진은 검색·업로드가 붙고 텍스트는 스타일 프리셋이다.
22
+ *
23
+ * 맨 위의 "즐겨찾기 · 최근" 스트립은 그룹과 무관하게 항상 같은 자리에 있다 — 상세페이지는
24
+ * 같은 배지·아이콘을 20섹션 내내 반복해서 넣으므로 **검색의 절반은 다시 안 찾는 것**이다.
25
+ * 탭이 아니라 스트립인 이유는 레일 상한(12)이다.
26
+ */
27
+
28
+ export type ElementsGroupId =
29
+ | "shapes"
30
+ | "icons"
31
+ | "qr"
32
+ | "decorations"
33
+ | "charts"
34
+ | "tables";
35
+
36
+ /**
37
+ * 그룹 바는 3열 두 줄이다. 순서가 곧 자리라서 **도형 바로 아래 칸이 장식**이 된다 —
38
+ * 둘은 같은 열에 세로로 붙어 있어야 "도형에서 갈라져 나온 것"으로 읽힌다.
39
+ */
40
+ const GROUPS: ReadonlyArray<{
41
+ id: ElementsGroupId;
42
+ labelKey: string;
43
+ Icon: LucideIcon;
44
+ }> = [
45
+ { id: "shapes", labelKey: "detailPage.sidebar.shapes", Icon: Shapes },
46
+ { id: "icons", labelKey: "detailPage.sidebar.icons", Icon: Smile },
47
+ { id: "qr", labelKey: "detailPage.sidebar.qr", Icon: QrCode },
48
+ { id: "decorations", labelKey: "detailPage.sidebar.decorations", Icon: Sparkles },
49
+ { id: "charts", labelKey: "detailPage.sidebar.charts", Icon: BarChart3 },
50
+ { id: "tables", labelKey: "detailPage.sidebar.tables", Icon: Table },
51
+ ];
52
+
53
+ /**
54
+ * 마지막으로 본 그룹. 모듈 전역인 이유는 껍데기가 **열린 패널 하나만 렌더**하기
55
+ * 때문이다 — 다른 탭에 갔다 오면 이 컴포넌트는 언마운트됐다가 새로 뜬다. 탭이 셋으로
56
+ * 갈려 있을 때는 레일이 이 기억을 대신했으니, 접은 뒤에도 그 감각은 남겨 둔다.
57
+ * 세션 안에서만 살면 충분해서 저장소까지 가지 않는다.
58
+ */
59
+ let lastGroup: ElementsGroupId = "shapes";
60
+
61
+ export function DetailPageElementsPanel({
62
+ store,
63
+ defaultGroup,
64
+ }: {
65
+ store: unknown;
66
+ /** 처음 열 그룹. 안 주면 이 세션에서 마지막으로 봤던 그룹으로 돌아간다. */
67
+ defaultGroup?: ElementsGroupId;
68
+ }) {
69
+ const { t } = useTranslation("branding");
70
+ const [group, setGroup] = useState<ElementsGroupId>(defaultGroup ?? lastGroup);
71
+
72
+ const select = useCallback((next: ElementsGroupId) => {
73
+ lastGroup = next;
74
+ setGroup(next);
75
+ }, []);
76
+
77
+ return (
78
+ <div className="flex h-full flex-col">
79
+ <ElementRecentsStrip store={store} />
80
+ <div
81
+ role="tablist"
82
+ aria-label={t("detailPage.sidebar.elements")}
83
+ className="grid shrink-0 grid-cols-3 gap-0.5 border-b border-neutral-200 p-2"
84
+ >
85
+ {GROUPS.map(({ id, labelKey, Icon }) => {
86
+ const active = id === group;
87
+ return (
88
+ <button
89
+ key={id}
90
+ type="button"
91
+ role="tab"
92
+ aria-selected={active}
93
+ onClick={() => select(id)}
94
+ className={`flex flex-col items-center justify-center gap-0.5 rounded-md px-1 py-1.5 text-[11px] font-medium transition-colors ${
95
+ active
96
+ ? "bg-blue-50 text-blue-700"
97
+ : "text-neutral-500 hover:bg-neutral-100"
98
+ }`}
99
+ >
100
+ <Icon aria-hidden="true" size={14} />
101
+ {t(labelKey)}
102
+ </button>
103
+ );
104
+ })}
105
+ </div>
106
+ {/* 그룹 패널은 저마다 h-full 스크롤러라 남은 높이를 통째로 넘긴다. */}
107
+ <div className="min-h-0 flex-1">
108
+ {group === "shapes" ? <DetailPageShapesPanel store={store} /> : null}
109
+ {group === "icons" ? <DetailPageIconsPanel store={store} /> : null}
110
+ {group === "qr" ? <DetailPageQrPanel store={store} /> : null}
111
+ {group === "decorations" ? <DetailPageDecorationsPanel store={store} /> : null}
112
+ {group === "charts" ? <DetailPageChartsPanel store={store} /> : null}
113
+ {group === "tables" ? <DetailPageTablesPanel store={store} /> : null}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,231 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import Image from "next/image";
5
+ import { Check, ChevronDown, LoaderCircle } from "lucide-react";
6
+ import { Popover as PopoverPrimitive } from "radix-ui";
7
+ import { useTranslation } from "react-i18next";
8
+
9
+ import {
10
+ Command,
11
+ CommandEmpty,
12
+ CommandGroup,
13
+ CommandInput,
14
+ CommandItem,
15
+ CommandList,
16
+ } from "../ui/command";
17
+ import {
18
+ EDITOR_BUNDLE_FONTS,
19
+ EDITOR_CATALOG_FONTS,
20
+ filterFontsByTags,
21
+ getEditorFont,
22
+ type EditorFont,
23
+ } from "../../lib/detail-page-canvas/editor-fonts";
24
+ import { FONT_TAGS, type FontTag } from "../../lib/detail-page-canvas/font-tags";
25
+
26
+ export function DetailPageFontPicker({
27
+ value,
28
+ documentFamilies,
29
+ onSelect,
30
+ }: {
31
+ value: string;
32
+ documentFamilies: string[];
33
+ onSelect: (family: string) => Promise<void>;
34
+ }) {
35
+ const { t } = useTranslation("branding");
36
+ const [open, setOpen] = useState(false);
37
+ const [loading, setLoading] = useState<string | null>(null);
38
+ const [error, setError] = useState(false);
39
+ const [tags, setTags] = useState<FontTag[]>([]);
40
+ const selected = getEditorFont(value);
41
+ const catalogFonts = filterFontsByTags(EDITOR_CATALOG_FONTS, tags);
42
+ const bundleFonts = filterFontsByTags(EDITOR_BUNDLE_FONTS, tags);
43
+ // Document fonts carry no tags, so any active chip is a statement that they are
44
+ // not what the user is looking for.
45
+ const legacyFamilies = tags.length
46
+ ? []
47
+ : documentFamilies.filter((family) => !getEditorFont(family));
48
+
49
+ const toggleTag = (tag: FontTag) => {
50
+ setTags((current) =>
51
+ current.includes(tag)
52
+ ? current.filter((candidate) => candidate !== tag)
53
+ : [...current, tag],
54
+ );
55
+ };
56
+
57
+ const select = async (family: string) => {
58
+ setLoading(family);
59
+ setError(false);
60
+ try {
61
+ await onSelect(family);
62
+ setOpen(false);
63
+ } catch (fontError) {
64
+ console.error(`Failed to load detail-page font "${family}"`, fontError);
65
+ setError(true);
66
+ } finally {
67
+ setLoading(null);
68
+ }
69
+ };
70
+
71
+ const renderFont = (font: EditorFont) => (
72
+ <CommandItem
73
+ key={`${font.source}:${font.id}`}
74
+ value={`${font.label} ${font.labelEn} ${font.family}`}
75
+ onSelect={() => void select(font.family)}
76
+ disabled={loading !== null}
77
+ className="group min-h-[56px] gap-2 px-2 py-1"
78
+ >
79
+ <span className="flex h-11 min-w-0 flex-1 items-center overflow-hidden">
80
+ <Image
81
+ src={font.previewSrc}
82
+ alt={font.label}
83
+ width={560}
84
+ height={88}
85
+ className="h-auto w-full object-contain object-left"
86
+ unoptimized
87
+ />
88
+ </span>
89
+ {font.latinOnly ? (
90
+ <span className="shrink-0 rounded border border-neutral-200 px-1 py-0.5 text-[10px] leading-none text-neutral-500">
91
+ {t("detailPage.properties.fontLatinOnly")}
92
+ </span>
93
+ ) : null}
94
+ {loading === font.family ? (
95
+ <LoaderCircle
96
+ aria-label={t("detailPage.properties.fontLoading")}
97
+ className="shrink-0 animate-spin text-neutral-500"
98
+ size={15}
99
+ />
100
+ ) : value === font.family ? (
101
+ <Check aria-hidden="true" className="shrink-0" size={15} />
102
+ ) : null}
103
+ </CommandItem>
104
+ );
105
+
106
+ return (
107
+ <PopoverPrimitive.Root
108
+ open={open}
109
+ onOpenChange={(next) => {
110
+ setOpen(next);
111
+ if (!next) setError(false);
112
+ }}
113
+ >
114
+ <PopoverPrimitive.Trigger asChild>
115
+ <button
116
+ type="button"
117
+ aria-label={t("detailPage.properties.chooseFont")}
118
+ aria-expanded={open}
119
+ className="flex min-w-0 items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1.5 text-left text-sm text-neutral-900 outline-none hover:bg-neutral-50 focus:border-neutral-400"
120
+ >
121
+ <span className="min-w-0 truncate">{selected?.label ?? value}</span>
122
+ <ChevronDown aria-hidden="true" className="shrink-0 text-neutral-400" size={14} />
123
+ </button>
124
+ </PopoverPrimitive.Trigger>
125
+ <PopoverPrimitive.Portal>
126
+ <PopoverPrimitive.Content
127
+ side="left"
128
+ align="start"
129
+ sideOffset={8}
130
+ collisionPadding={12}
131
+ className="z-[120] w-[340px] overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-[0_10px_32px_rgba(0,0,0,0.14)]"
132
+ >
133
+ <div
134
+ role="group"
135
+ aria-label={t("detailPage.properties.fontTagFilter")}
136
+ className="flex flex-wrap gap-1 border-b border-neutral-100 px-2 py-2"
137
+ >
138
+ <button
139
+ type="button"
140
+ aria-pressed={tags.length === 0}
141
+ onClick={() => setTags([])}
142
+ className={`rounded-full border px-2 py-0.5 text-[11px] leading-4 transition-colors ${
143
+ tags.length === 0
144
+ ? "border-neutral-900 bg-neutral-900 text-white"
145
+ : "border-neutral-200 text-neutral-600 hover:bg-neutral-50"
146
+ }`}
147
+ >
148
+ {t("detailPage.properties.fontTagAll")}
149
+ </button>
150
+ {FONT_TAGS.map((tag) => (
151
+ <button
152
+ key={tag}
153
+ type="button"
154
+ aria-pressed={tags.includes(tag)}
155
+ onClick={() => toggleTag(tag)}
156
+ className={`rounded-full border px-2 py-0.5 text-[11px] leading-4 transition-colors ${
157
+ tags.includes(tag)
158
+ ? "border-neutral-900 bg-neutral-900 text-white"
159
+ : "border-neutral-200 text-neutral-600 hover:bg-neutral-50"
160
+ }`}
161
+ >
162
+ {t(`detailPage.fontTags.${tag}`)}
163
+ </button>
164
+ ))}
165
+ </div>
166
+ <Command>
167
+ <CommandInput
168
+ autoFocus
169
+ placeholder={t("detailPage.properties.fontSearch")}
170
+ aria-label={t("detailPage.properties.fontSearch")}
171
+ />
172
+ <CommandList className="max-h-[420px]">
173
+ <CommandEmpty>
174
+ {t("detailPage.properties.fontEmpty")}
175
+ </CommandEmpty>
176
+ {catalogFonts.length ? (
177
+ <CommandGroup heading={t("detailPage.properties.fontCatalog")}>
178
+ {catalogFonts.map(renderFont)}
179
+ </CommandGroup>
180
+ ) : null}
181
+ {bundleFonts.length ? (
182
+ <CommandGroup heading={t("detailPage.properties.fontBundle")}>
183
+ {bundleFonts.map(renderFont)}
184
+ </CommandGroup>
185
+ ) : null}
186
+ {legacyFamilies.length ? (
187
+ <CommandGroup heading={t("detailPage.properties.documentFonts")}>
188
+ {legacyFamilies.map((family) => (
189
+ <CommandItem
190
+ key={family}
191
+ value={family}
192
+ onSelect={() => void select(family)}
193
+ disabled={loading !== null}
194
+ className="gap-2 px-3 py-2.5"
195
+ >
196
+ <span className="min-w-0 flex-1 truncate">{family}</span>
197
+ {value === family ? <Check aria-hidden="true" size={15} /> : null}
198
+ </CommandItem>
199
+ ))}
200
+ </CommandGroup>
201
+ ) : null}
202
+ </CommandList>
203
+ </Command>
204
+ {error ? (
205
+ <p role="alert" className="border-t border-red-100 bg-red-50 px-3 py-2 text-xs text-red-600">
206
+ {t("detailPage.properties.fontLoadFailed")}
207
+ </p>
208
+ ) : null}
209
+ {selected ? (
210
+ <div className="border-t border-neutral-100 px-3 py-2 text-[11px] leading-4 text-neutral-500">
211
+ <a
212
+ href={selected.licenseUrl}
213
+ target="_blank"
214
+ rel="noreferrer"
215
+ className="font-medium text-neutral-700 underline decoration-neutral-300 underline-offset-2"
216
+ >
217
+ {t("detailPage.properties.fontLicense")}: {selected.licenseName}
218
+ </a>
219
+ {selected.licenseNoteKey ? (
220
+ <span className="ml-1">
221
+ · {t(`detailPage.fontLicenseNotes.${selected.licenseNoteKey}`)}
222
+ </span>
223
+ ) : null}
224
+ </div>
225
+ ) : null}
226
+ <PopoverPrimitive.Arrow className="fill-white" />
227
+ </PopoverPrimitive.Content>
228
+ </PopoverPrimitive.Portal>
229
+ </PopoverPrimitive.Root>
230
+ );
231
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import { observer } from "./canvas-observer";
4
+ import { useTranslation } from "react-i18next";
5
+ import { Redo2, Undo2 } from "lucide-react";
6
+
7
+ import {
8
+ safeRedo,
9
+ safeUndo,
10
+ type HistoryStore,
11
+ } from "./editor-history";
12
+
13
+ /**
14
+ * 되돌리기 / 다시 실행 버튼. Canvas store의 관측 가능한 ``history.canUndo`` /
15
+ * ``history.canRedo``를 읽어 비활성 상태를 반응형으로 갱신하고, 클릭 시
16
+ * 되돌리기/다시 실행을 실행한다. 실제 호출은 ``safeUndo`` / ``safeRedo``를 거치는데,
17
+ * 선택이 살아 있는 채로 undo하면 스톡 편집기가 MST assertion으로 죽기 때문이다
18
+ * (editor-history.ts 참고). 단축키(⌘/Ctrl+Z 등)는 ``EditorHotkeys``가 처리한다.
19
+ */
20
+
21
+ type HistoryStoreLike = {
22
+ history: {
23
+ canUndo: boolean;
24
+ canRedo: boolean;
25
+ undo: () => void;
26
+ redo: () => void;
27
+ };
28
+ };
29
+
30
+ function HistoryButton({
31
+ label,
32
+ onClick,
33
+ disabled,
34
+ children,
35
+ }: {
36
+ label: string;
37
+ onClick: () => void;
38
+ disabled: boolean;
39
+ children: React.ReactNode;
40
+ }) {
41
+ return (
42
+ <button
43
+ type="button"
44
+ title={label}
45
+ aria-label={label}
46
+ disabled={disabled}
47
+ onClick={onClick}
48
+ className={[
49
+ "flex h-9 w-9 items-center justify-center rounded-md transition-colors",
50
+ disabled
51
+ ? "cursor-not-allowed text-neutral-300"
52
+ : "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900",
53
+ ].join(" ")}
54
+ >
55
+ {children}
56
+ </button>
57
+ );
58
+ }
59
+
60
+ export const DetailPageHistoryButtons = observer(function DetailPageHistoryButtons({
61
+ store,
62
+ }: {
63
+ store: unknown;
64
+ }) {
65
+ const { t } = useTranslation("branding");
66
+ const { history } = store as HistoryStoreLike;
67
+ const s = store as HistoryStore;
68
+ return (
69
+ <div className="flex items-center">
70
+ <HistoryButton
71
+ label={t("editor.undo")}
72
+ onClick={() => safeUndo(s)}
73
+ disabled={!history.canUndo}
74
+ >
75
+ <Undo2 size={17} />
76
+ </HistoryButton>
77
+ <HistoryButton
78
+ label={t("editor.redo")}
79
+ onClick={() => safeRedo(s)}
80
+ disabled={!history.canRedo}
81
+ >
82
+ <Redo2 size={17} />
83
+ </HistoryButton>
84
+ </div>
85
+ );
86
+ });
87
+ DetailPageHistoryButtons.displayName = "DetailPageHistoryButtons";