@growgroup/visual-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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,447 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * メディアライブラリダイアログ
5
+ *
6
+ * 生成済みの画像・イラスト(GET /__media, 381件)を
7
+ * カテゴリ絞り込み+インクリメンタル検索で選び、キャンバスに挿入する。
8
+ *
9
+ * 設計メモ:
10
+ * - 381件を一度にDOMへ吐くと初回描画が重いので、IntersectionObserver の
11
+ * センチネルで PAGE_SIZE 件ずつ追加描画する。加えて <img loading="lazy"> で
12
+ * 実際のデコード・ネットワーク取得もビューポート近傍まで遅延させる。
13
+ * - 透過PNGが多いためサムネイル背景はチェッカー柄。
14
+ * - <img> 選択中は「新規挿入」ではなく「src差し替え」モードで開く。
15
+ */
16
+
17
+ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
19
+ import { Input } from '../../components/ui/input';
20
+ import { Button } from '../../components/ui/button';
21
+ import { Loader2, Search, X, ImageOff, RefreshCw, Replace, ImagePlus } from 'lucide-react';
22
+ import { cn } from '../../lib/utils';
23
+ import { useMediaLibrary, type MediaItem } from '../hooks/useMediaLibrary';
24
+
25
+ /** 一度に追加描画する件数 */
26
+ const PAGE_SIZE = 60;
27
+
28
+ /** カテゴリの表示順と日本語表記 */
29
+ const CATEGORY_ORDER = [
30
+ 'object',
31
+ 'concept',
32
+ 'deco',
33
+ 'background',
34
+ 'photo',
35
+ 'illust',
36
+ 'template',
37
+ 'upload',
38
+ ] as const;
39
+
40
+ const CATEGORY_LABELS: Record<string, string> = {
41
+ object: '素材',
42
+ concept: '概念図',
43
+ deco: 'あしらい',
44
+ background: '背景',
45
+ photo: '写真',
46
+ illust: '人物イラスト',
47
+ template: 'テンプレート',
48
+ upload: 'アップロード',
49
+ other: 'その他',
50
+ };
51
+
52
+ const categoryLabel = (category: string) => CATEGORY_LABELS[category] ?? category;
53
+
54
+ /** 透過素材が見やすいチェッカー柄 */
55
+ const CHECKER_STYLE: React.CSSProperties = {
56
+ backgroundColor: '#323232',
57
+ backgroundImage:
58
+ 'linear-gradient(45deg, #444444 25%, transparent 25%),' +
59
+ 'linear-gradient(-45deg, #444444 25%, transparent 25%),' +
60
+ 'linear-gradient(45deg, transparent 75%, #444444 75%),' +
61
+ 'linear-gradient(-45deg, transparent 75%, #444444 75%)',
62
+ backgroundSize: '14px 14px',
63
+ backgroundPosition: '0 0, 0 7px, 7px -7px, -7px 0',
64
+ };
65
+
66
+ export type MediaLibraryMode = 'insert' | 'replace';
67
+
68
+ export interface MediaLibraryDialogProps {
69
+ isOpen: boolean;
70
+ onClose: () => void;
71
+ /** 画像が選ばれたときに呼ばれる。挿入/差し替えの実処理は呼び出し側 */
72
+ onSelect: (item: MediaItem) => void;
73
+ /** 'replace' なら選択中の <img> の src を差し替える文言になる */
74
+ mode?: MediaLibraryMode;
75
+ /** 差し替えモードのとき、現在の src(選択状態の表示に使う) */
76
+ currentSrc?: string;
77
+ }
78
+
79
+ /** 検索用の正規化(大文字小文字・全角空白を吸収) */
80
+ const normalize = (value: string) => value.toLowerCase().replace(/ /g, ' ').trim();
81
+
82
+ interface MediaThumbProps {
83
+ item: MediaItem;
84
+ isCurrent: boolean;
85
+ onSelect: (item: MediaItem) => void;
86
+ onPreview: (item: MediaItem | null) => void;
87
+ }
88
+
89
+ function MediaThumb({ item, isCurrent, onSelect, onPreview }: MediaThumbProps) {
90
+ const [failed, setFailed] = useState(false);
91
+
92
+ return (
93
+ <button
94
+ type="button"
95
+ onClick={() => onSelect(item)}
96
+ onMouseEnter={() => onPreview(item)}
97
+ onMouseLeave={() => onPreview(null)}
98
+ onFocus={() => onPreview(item)}
99
+ onBlur={() => onPreview(null)}
100
+ title={item.usage ? `${item.label} — ${item.usage}` : item.label}
101
+ aria-label={`${item.label}(${categoryLabel(item.category)})${item.usage ? ` ${item.usage}` : ''}`}
102
+ aria-current={isCurrent || undefined}
103
+ className={cn(
104
+ 'group flex flex-col overflow-hidden rounded-md border text-left transition-colors',
105
+ 'border-[#444444] bg-[#2c2c2c] hover:border-[#0d99ff] hover:bg-[#333]',
106
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff] focus-visible:ring-offset-1 focus-visible:ring-offset-[#2c2c2c]',
107
+ isCurrent && 'border-[#0d99ff] ring-1 ring-[#0d99ff]'
108
+ )}
109
+ >
110
+ <div
111
+ className="flex aspect-[4/3] w-full items-center justify-center overflow-hidden p-1.5"
112
+ style={CHECKER_STYLE}
113
+ >
114
+ {failed ? (
115
+ <ImageOff className="h-5 w-5 text-gray-500" aria-hidden="true" />
116
+ ) : (
117
+ <img
118
+ src={item.url}
119
+ alt=""
120
+ loading="lazy"
121
+ decoding="async"
122
+ draggable={false}
123
+ onError={() => setFailed(true)}
124
+ className="max-h-full max-w-full object-contain transition-transform duration-150 group-hover:scale-[1.04]"
125
+ />
126
+ )}
127
+ </div>
128
+ <span className="truncate border-t border-[#444444] px-1.5 py-1 text-[10px] leading-4 text-gray-300 group-hover:text-white">
129
+ {item.label}
130
+ </span>
131
+ </button>
132
+ );
133
+ }
134
+
135
+ export function MediaLibraryDialog({
136
+ isOpen,
137
+ onClose,
138
+ onSelect,
139
+ mode = 'insert',
140
+ currentSrc,
141
+ }: MediaLibraryDialogProps) {
142
+ const { items, isLoading, error, reload } = useMediaLibrary({ enabled: isOpen });
143
+
144
+ const [query, setQuery] = useState('');
145
+ const [category, setCategory] = useState<string>('all');
146
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
147
+ const [preview, setPreview] = useState<MediaItem | null>(null);
148
+
149
+ const deferredQuery = useDeferredValue(query);
150
+ const scrollRef = useRef<HTMLDivElement>(null);
151
+ const sentinelRef = useRef<HTMLDivElement>(null);
152
+
153
+ // 開くたびに検索条件をリセット(前回の絞り込みが残っていると迷子になる)
154
+ useEffect(() => {
155
+ if (isOpen) {
156
+ setQuery('');
157
+ setCategory('all');
158
+ setVisibleCount(PAGE_SIZE);
159
+ setPreview(null);
160
+ }
161
+ }, [isOpen]);
162
+
163
+ // カテゴリ一覧(件数付き)。APIに無いカテゴリが増えても末尾に出す
164
+ const categories = useMemo(() => {
165
+ const counts = new Map<string, number>();
166
+ for (const item of items) {
167
+ counts.set(item.category, (counts.get(item.category) ?? 0) + 1);
168
+ }
169
+ const known = CATEGORY_ORDER.filter((c) => counts.has(c)).map((c) => c as string);
170
+ const unknown = [...counts.keys()].filter((c) => !known.includes(c)).sort();
171
+ return [...known, ...unknown].map((id) => ({
172
+ id,
173
+ label: categoryLabel(id),
174
+ count: counts.get(id) ?? 0,
175
+ }));
176
+ }, [items]);
177
+
178
+ // label / tags / name を対象にしたインクリメンタル検索
179
+ const filtered = useMemo(() => {
180
+ const q = normalize(deferredQuery);
181
+ const terms = q ? q.split(/\s+/).filter(Boolean) : [];
182
+ return items.filter((item) => {
183
+ if (category !== 'all' && item.category !== category) return false;
184
+ if (terms.length === 0) return true;
185
+ const haystack = normalize([item.label, item.name, ...item.tags].join(' '));
186
+ return terms.every((term) => haystack.includes(term));
187
+ });
188
+ }, [items, category, deferredQuery]);
189
+
190
+ // 絞り込みが変わったら描画件数とスクロール位置を戻す
191
+ useEffect(() => {
192
+ setVisibleCount(PAGE_SIZE);
193
+ scrollRef.current?.scrollTo({ top: 0 });
194
+ }, [category, deferredQuery]);
195
+
196
+ const visibleItems = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
197
+ const hasMore = visibleCount < filtered.length;
198
+
199
+ // 遅延描画: センチネルが見えたら次のページを描画
200
+ useEffect(() => {
201
+ if (!isOpen || !hasMore) return;
202
+ const sentinel = sentinelRef.current;
203
+ const root = scrollRef.current;
204
+ if (!sentinel || !root) return;
205
+
206
+ const observer = new IntersectionObserver(
207
+ (entries) => {
208
+ if (entries.some((e) => e.isIntersecting)) {
209
+ setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, filtered.length));
210
+ }
211
+ },
212
+ { root, rootMargin: '400px 0px' }
213
+ );
214
+ observer.observe(sentinel);
215
+ return () => observer.disconnect();
216
+ }, [isOpen, hasMore, filtered.length]);
217
+
218
+ const handleSelect = useCallback(
219
+ (item: MediaItem) => {
220
+ onSelect(item);
221
+ },
222
+ [onSelect]
223
+ );
224
+
225
+ const isReplace = mode === 'replace';
226
+ const detail = preview;
227
+
228
+ return (
229
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
230
+ <DialogContent
231
+ className="flex h-[82vh] max-h-[860px] w-[min(1080px,94vw)] max-w-none flex-col gap-0 overflow-hidden border-[#444444] bg-[#2c2c2c] p-0 text-white"
232
+ onOpenAutoFocus={(e) => {
233
+ // 検索欄にフォーカスを置きたいので既定のフォーカス移動は抑止
234
+ e.preventDefault();
235
+ }}
236
+ >
237
+ {/* ヘッダー */}
238
+ <div className="flex flex-col gap-3 border-b border-[#444444] px-4 py-3">
239
+ <div className="flex items-center gap-2 pr-8">
240
+ {isReplace ? (
241
+ <Replace className="h-4 w-4 shrink-0 text-[#4fb8ff]" aria-hidden="true" />
242
+ ) : (
243
+ <ImagePlus className="h-4 w-4 shrink-0 text-[#4fb8ff]" aria-hidden="true" />
244
+ )}
245
+ <DialogTitle className="text-sm font-medium text-white">メディアライブラリ</DialogTitle>
246
+ <span className="truncate text-xs text-gray-400">
247
+ {isReplace ? '選択中の画像を差し替えます' : 'キャンバスに画像を挿入します'}
248
+ </span>
249
+ </div>
250
+
251
+ {/* 検索 */}
252
+ <div className="relative">
253
+ <Search
254
+ className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500"
255
+ aria-hidden="true"
256
+ />
257
+ <label htmlFor="media-library-search" className="sr-only">
258
+ メディアを検索(名前・タグ)
259
+ </label>
260
+ <Input
261
+ id="media-library-search"
262
+ autoFocus
263
+ value={query}
264
+ onChange={(e) => setQuery(e.target.value)}
265
+ placeholder="名前・タグで検索(例: アイコン、SEO、人物)"
266
+ className="h-8 border-[#444444] bg-[#383838] pl-8 pr-8 text-xs text-white placeholder:text-gray-500"
267
+ />
268
+ {query && (
269
+ <button
270
+ type="button"
271
+ onClick={() => setQuery('')}
272
+ aria-label="検索条件をクリア"
273
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:bg-[#4a4a4a] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]"
274
+ >
275
+ <X className="h-3 w-3" />
276
+ </button>
277
+ )}
278
+ </div>
279
+
280
+ {/* カテゴリ絞り込み */}
281
+ <div
282
+ className="flex flex-wrap items-center gap-1"
283
+ role="group"
284
+ aria-label="カテゴリで絞り込み"
285
+ >
286
+ <CategoryChip
287
+ label="すべて"
288
+ count={items.length}
289
+ isActive={category === 'all'}
290
+ onClick={() => setCategory('all')}
291
+ />
292
+ {categories.map((c) => (
293
+ <CategoryChip
294
+ key={c.id}
295
+ label={c.label}
296
+ count={c.count}
297
+ isActive={category === c.id}
298
+ onClick={() => setCategory(c.id)}
299
+ />
300
+ ))}
301
+ </div>
302
+ </div>
303
+
304
+ {/* グリッド */}
305
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3">
306
+ {isLoading && items.length === 0 && (
307
+ <div className="flex h-full items-center justify-center gap-2 text-xs text-gray-400">
308
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
309
+ メディアを読み込んでいます…
310
+ </div>
311
+ )}
312
+
313
+ {error && (
314
+ <div className="flex h-full flex-col items-center justify-center gap-3 text-xs text-gray-300">
315
+ <span>{error}</span>
316
+ <Button
317
+ variant="ghost"
318
+ size="sm"
319
+ onClick={() => void reload()}
320
+ className="h-7 gap-1.5 border border-[#444444] bg-[#383838] text-xs hover:bg-[#4a4a4a]"
321
+ >
322
+ <RefreshCw className="h-3 w-3" />
323
+ 再読み込み
324
+ </Button>
325
+ </div>
326
+ )}
327
+
328
+ {!error && !isLoading && filtered.length === 0 && (
329
+ <div className="flex h-full items-center justify-center text-xs text-gray-400">
330
+ 該当するメディアがありません
331
+ </div>
332
+ )}
333
+
334
+ {filtered.length > 0 && (
335
+ <>
336
+ <div
337
+ className="grid gap-2"
338
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))' }}
339
+ role="group"
340
+ aria-label="メディア一覧"
341
+ >
342
+ {visibleItems.map((item) => (
343
+ <MediaThumb
344
+ key={item.url}
345
+ item={item}
346
+ isCurrent={!!currentSrc && currentSrc.endsWith(item.url)}
347
+ onSelect={handleSelect}
348
+ onPreview={setPreview}
349
+ />
350
+ ))}
351
+ </div>
352
+ {/* 遅延描画のセンチネル */}
353
+ {hasMore && (
354
+ <div
355
+ ref={sentinelRef}
356
+ className="flex items-center justify-center py-4 text-[11px] text-gray-500"
357
+ >
358
+ <Loader2 className="mr-1.5 h-3 w-3 animate-spin" aria-hidden="true" />
359
+ さらに読み込み中…
360
+ </div>
361
+ )}
362
+ </>
363
+ )}
364
+ </div>
365
+
366
+ {/* フッター: ホバー/フォーカス中の詳細 */}
367
+ <div className="flex min-h-[52px] items-center gap-3 border-t border-[#444444] bg-[#2c2c2c] px-4 py-2">
368
+ {detail ? (
369
+ <>
370
+ <div
371
+ className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded border border-[#444444]"
372
+ style={CHECKER_STYLE}
373
+ >
374
+ <img src={detail.url} alt="" className="max-h-full max-w-full object-contain" />
375
+ </div>
376
+ <div className="min-w-0 flex-1">
377
+ <div className="flex items-baseline gap-2">
378
+ <span className="truncate text-xs font-medium text-white">{detail.label}</span>
379
+ <span className="shrink-0 text-[10px] text-gray-500">
380
+ {categoryLabel(detail.category)}
381
+ </span>
382
+ </div>
383
+ <p className="truncate text-[11px] text-gray-400">
384
+ {detail.usage || detail.tags.join('・') || detail.name}
385
+ </p>
386
+ </div>
387
+ {detail.tags.length > 0 && (
388
+ <div className="hidden shrink-0 gap-1 md:flex">
389
+ {detail.tags.slice(0, 4).map((tag) => (
390
+ <span
391
+ key={tag}
392
+ className="rounded bg-[#383838] px-1.5 py-0.5 text-[10px] text-gray-300"
393
+ >
394
+ {tag}
395
+ </span>
396
+ ))}
397
+ </div>
398
+ )}
399
+ </>
400
+ ) : (
401
+ <p className="flex items-center gap-2 text-[11px] text-gray-400" aria-live="polite">
402
+ <span>
403
+ {filtered.length}件表示中
404
+ {filtered.length !== items.length && ` / 全${items.length}件`}
405
+ </span>
406
+ <span className="text-gray-600" aria-hidden="true">
407
+ |
408
+ </span>
409
+ <span>クリックで{isReplace ? '選択中の画像を差し替え' : 'キャンバスに挿入'}</span>
410
+ </p>
411
+ )}
412
+ </div>
413
+ </DialogContent>
414
+ </Dialog>
415
+ );
416
+ }
417
+
418
+ interface CategoryChipProps {
419
+ label: string;
420
+ count: number;
421
+ isActive: boolean;
422
+ onClick: () => void;
423
+ }
424
+
425
+ function CategoryChip({ label, count, isActive, onClick }: CategoryChipProps) {
426
+ return (
427
+ <button
428
+ type="button"
429
+ onClick={onClick}
430
+ aria-pressed={isActive}
431
+ className={cn(
432
+ 'rounded-full border px-2.5 py-1 text-[11px] transition-colors',
433
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]',
434
+ isActive
435
+ ? 'border-[#0d99ff] bg-[#0d99ff] text-white'
436
+ : 'border-[#444444] bg-[#383838] text-gray-300 hover:bg-[#4a4a4a] hover:text-white'
437
+ )}
438
+ >
439
+ {label}
440
+ <span className={cn('ml-1 tabular-nums', isActive ? 'text-blue-100' : 'text-gray-500')}>
441
+ {count}
442
+ </span>
443
+ </button>
444
+ );
445
+ }
446
+
447
+ export default MediaLibraryDialog;