@growgroup/visual-editor 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorToolbar.tsx +11 -1
- package/src/editor/FrontendVisualEditor.tsx +183 -2
- package/src/editor/components/CatalogInsertDialog.tsx +647 -0
- package/src/editor/components/ppt/PptChrome.tsx +6 -1
- package/src/editor/hooks/index.ts +1 -1
- package/src/editor/hooks/useLinkNavigation.ts +29 -9
- package/src/editor/utils/catalog-inserts.ts +79 -0
- package/src/editor/utils/drop-target.ts +7 -3
- package/src/editor/utils/tailwind-utils.ts +5 -1
- package/src/index.ts +4 -0
- package/src/io.ts +71 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「台帳から挿入」ダイアログ
|
|
5
|
+
*
|
|
6
|
+
* デザインシステムの台帳(利用側が束ねる目録)から、セクション / パーツ / ページの雛形を選んで
|
|
7
|
+
* 紙面に入れる。エディタは目録の中身を知らない ── `io.loadInserts` が返した
|
|
8
|
+
* グループと項目をそのまま出し、選ばれた id を `io.fetchInsert` / `io.createContent` に渡すだけ。
|
|
9
|
+
*
|
|
10
|
+
* 設計メモ:
|
|
11
|
+
* - `loadInserts` が無ければ利用側がこのダイアログを開く導線ごと出さない(io の capability ベース)
|
|
12
|
+
* - セクション・パーツは紙面へ挿入、ページの雛形(`level` が `TPL` / `PAG`)は
|
|
13
|
+
* 「新しいページを作る」に回る。混ぜると「挿したのに紙面が変わらない」になるため、
|
|
14
|
+
* ボタンの文言も押したあとの動きも分ける
|
|
15
|
+
* - 見本は `previewUrl` の iframe。紙面と同じ Tailwind を読ませたいので sandbox は付けない
|
|
16
|
+
* (台帳は利用側が用意した自分のページで、外から来た URL ではない)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
|
20
|
+
import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
|
|
21
|
+
import { Input } from '../../components/ui/input';
|
|
22
|
+
import { Button } from '../../components/ui/button';
|
|
23
|
+
import { Checkbox } from '../../components/ui/checkbox';
|
|
24
|
+
import { Loader2, Search, X, RefreshCw, LayoutTemplate, FilePlus2, Blocks } from 'lucide-react';
|
|
25
|
+
import { cn } from '../../lib/utils';
|
|
26
|
+
import { io, type EditorInsertCatalog, type EditorInsertGroup, type EditorInsertItem } from '../../io';
|
|
27
|
+
|
|
28
|
+
/** ページの雛形(紙面に挿さず、新しいページを作る)か */
|
|
29
|
+
export function isPageLevel(item: EditorInsertItem): boolean {
|
|
30
|
+
const level = (item.level ?? '').toUpperCase();
|
|
31
|
+
return level === 'TPL' || level === 'PAG';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** パスとして受け付ける形。`/` 始まりの英小文字・数字・ハイフン・スラッシュ */
|
|
35
|
+
const PATH_PATTERN = /^\/[a-z0-9\-/]*$/;
|
|
36
|
+
|
|
37
|
+
/** 検索用の正規化(大文字小文字・全角空白を吸収) */
|
|
38
|
+
const normalize = (value: string) => value.toLowerCase().replace(/ /g, ' ').trim();
|
|
39
|
+
|
|
40
|
+
export interface CatalogInsertDialogProps {
|
|
41
|
+
isOpen: boolean;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
/** slide では TPL / PAG のグループと赤字ダミーの選択を出さない */
|
|
44
|
+
editorMode: 'webpage' | 'slide';
|
|
45
|
+
/** 紙面へ挿入する。呼び出し側が fetchInsert → 挿入 → 履歴 → 選択までを行う */
|
|
46
|
+
onInsert: (item: EditorInsertItem, opts: { dummy: boolean }) => Promise<void>;
|
|
47
|
+
/** ページを作る。`io.createContent` が無ければ渡さない(ボタンを出さない) */
|
|
48
|
+
onCreatePage?: (item: EditorInsertItem, input: { title: string; path: string }) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CatalogInsertDialog({
|
|
52
|
+
isOpen,
|
|
53
|
+
onClose,
|
|
54
|
+
editorMode,
|
|
55
|
+
onInsert,
|
|
56
|
+
onCreatePage,
|
|
57
|
+
}: CatalogInsertDialogProps) {
|
|
58
|
+
const isSlide = editorMode === 'slide';
|
|
59
|
+
|
|
60
|
+
const [catalog, setCatalog] = useState<EditorInsertCatalog | null>(null);
|
|
61
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
const [groupId, setGroupId] = useState<string>('all');
|
|
64
|
+
const [family, setFamily] = useState<string>('all');
|
|
65
|
+
const [query, setQuery] = useState('');
|
|
66
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
67
|
+
const [dummy, setDummy] = useState(true);
|
|
68
|
+
const [busy, setBusy] = useState(false);
|
|
69
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
70
|
+
const [pageFormOpen, setPageFormOpen] = useState(false);
|
|
71
|
+
|
|
72
|
+
const deferredQuery = useDeferredValue(query);
|
|
73
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
74
|
+
|
|
75
|
+
const load = useCallback(async () => {
|
|
76
|
+
const loadInserts = io().loadInserts;
|
|
77
|
+
if (!loadInserts) return;
|
|
78
|
+
setIsLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
setCatalog(await loadInserts());
|
|
82
|
+
} catch (e) {
|
|
83
|
+
setCatalog(null);
|
|
84
|
+
setError(e instanceof Error ? e.message : '台帳を読み込めませんでした');
|
|
85
|
+
} finally {
|
|
86
|
+
setIsLoading(false);
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
// 開くたびに読み直し、絞り込みも戻す(前回の絞り込みが残ると迷子になる)
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!isOpen) return;
|
|
93
|
+
setGroupId('all');
|
|
94
|
+
setFamily('all');
|
|
95
|
+
setQuery('');
|
|
96
|
+
setSelectedId(null);
|
|
97
|
+
setActionError(null);
|
|
98
|
+
setPageFormOpen(false);
|
|
99
|
+
void load();
|
|
100
|
+
}, [isOpen, load]);
|
|
101
|
+
|
|
102
|
+
/** slide では紙面に挿すものだけを出す(ページの雛形は「ページを作る」ものなので出さない) */
|
|
103
|
+
const groups = useMemo<EditorInsertGroup[]>(() => {
|
|
104
|
+
const all = catalog?.groups ?? [];
|
|
105
|
+
if (!isSlide) return all;
|
|
106
|
+
return all
|
|
107
|
+
.map((g) => ({ ...g, items: g.items.filter((item) => !isPageLevel(item)) }))
|
|
108
|
+
.filter((g) => g.items.length > 0);
|
|
109
|
+
}, [catalog, isSlide]);
|
|
110
|
+
|
|
111
|
+
const itemsOfGroup = useMemo(
|
|
112
|
+
() => (groupId === 'all' ? groups.flatMap((g) => g.items) : (groups.find((g) => g.id === groupId)?.items ?? [])),
|
|
113
|
+
[groups, groupId],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/** 系統(family)の一覧。選んでいるグループの中だけを出す */
|
|
117
|
+
const families = useMemo(() => {
|
|
118
|
+
const counts = new Map<string, number>();
|
|
119
|
+
for (const item of itemsOfGroup) {
|
|
120
|
+
if (!item.family) continue;
|
|
121
|
+
counts.set(item.family, (counts.get(item.family) ?? 0) + 1);
|
|
122
|
+
}
|
|
123
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0], 'ja')).map(([id, count]) => ({ id, count }));
|
|
124
|
+
}, [itemsOfGroup]);
|
|
125
|
+
|
|
126
|
+
// グループを変えると、前のグループにしか無い系統が残ることがある
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (family !== 'all' && !families.some((f) => f.id === family)) setFamily('all');
|
|
129
|
+
}, [families, family]);
|
|
130
|
+
|
|
131
|
+
const filtered = useMemo(() => {
|
|
132
|
+
const terms = normalize(deferredQuery).split(/\s+/).filter(Boolean);
|
|
133
|
+
return itemsOfGroup.filter((item) => {
|
|
134
|
+
if (family !== 'all' && item.family !== family) return false;
|
|
135
|
+
if (terms.length === 0) return true;
|
|
136
|
+
const haystack = normalize([item.name, item.description ?? '', item.family ?? ''].join(' '));
|
|
137
|
+
return terms.every((term) => haystack.includes(term));
|
|
138
|
+
});
|
|
139
|
+
}, [itemsOfGroup, family, deferredQuery]);
|
|
140
|
+
|
|
141
|
+
const selected = useMemo(
|
|
142
|
+
() => filtered.find((i) => i.id === selectedId) ?? null,
|
|
143
|
+
[filtered, selectedId],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// 絞り込みで選択中の項目が消えたら、選択も外す(右の詰めだけが残らないように)
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (selectedId && !filtered.some((i) => i.id === selectedId)) setSelectedId(null);
|
|
149
|
+
}, [filtered, selectedId]);
|
|
150
|
+
|
|
151
|
+
const handleInsert = useCallback(async () => {
|
|
152
|
+
if (!selected || busy) return;
|
|
153
|
+
setBusy(true);
|
|
154
|
+
setActionError(null);
|
|
155
|
+
try {
|
|
156
|
+
await onInsert(selected, { dummy: isSlide ? false : dummy });
|
|
157
|
+
onClose();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
setActionError(e instanceof Error ? e.message : '挿入できませんでした');
|
|
160
|
+
} finally {
|
|
161
|
+
setBusy(false);
|
|
162
|
+
}
|
|
163
|
+
}, [selected, busy, onInsert, isSlide, dummy, onClose]);
|
|
164
|
+
|
|
165
|
+
const handleCreatePage = useCallback(
|
|
166
|
+
async (input: { title: string; path: string }) => {
|
|
167
|
+
if (!selected || !onCreatePage) return;
|
|
168
|
+
setBusy(true);
|
|
169
|
+
setActionError(null);
|
|
170
|
+
try {
|
|
171
|
+
await onCreatePage(selected, input);
|
|
172
|
+
setPageFormOpen(false);
|
|
173
|
+
onClose();
|
|
174
|
+
} catch (e) {
|
|
175
|
+
setActionError(e instanceof Error ? e.message : 'ページを作れませんでした');
|
|
176
|
+
} finally {
|
|
177
|
+
setBusy(false);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
[selected, onCreatePage, onClose],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const selectedIsPage = !!selected && isPageLevel(selected);
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
188
|
+
<DialogContent
|
|
189
|
+
data-catalog-insert-dialog
|
|
190
|
+
className="flex h-[82vh] max-h-[860px] w-[min(1180px,95vw)] max-w-none flex-col gap-0 overflow-hidden border-[#444444] bg-[#2c2c2c] p-0 text-white"
|
|
191
|
+
onOpenAutoFocus={(e) => {
|
|
192
|
+
// 検索欄にフォーカスを置きたいので既定のフォーカス移動は抑止
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
requestAnimationFrame(() => searchRef.current?.focus());
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{/* ヘッダー */}
|
|
198
|
+
<div className="flex flex-col gap-3 border-b border-[#444444] px-4 py-3">
|
|
199
|
+
<div className="flex items-center gap-2 pr-8">
|
|
200
|
+
<Blocks className="h-4 w-4 shrink-0 text-[#4fb8ff]" aria-hidden="true" />
|
|
201
|
+
<DialogTitle className="text-sm font-medium text-white">台帳から挿入</DialogTitle>
|
|
202
|
+
<span className="truncate text-xs text-gray-400">
|
|
203
|
+
デザインシステムの台帳から、セクション・パーツ{isSlide ? '' : '・ページの雛形'}を選びます
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div className="relative">
|
|
208
|
+
<Search
|
|
209
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500"
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
/>
|
|
212
|
+
<label htmlFor="catalog-insert-search" className="sr-only">
|
|
213
|
+
台帳を検索(名前・説明・系統)
|
|
214
|
+
</label>
|
|
215
|
+
<Input
|
|
216
|
+
id="catalog-insert-search"
|
|
217
|
+
ref={searchRef}
|
|
218
|
+
data-catalog-search
|
|
219
|
+
value={query}
|
|
220
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
221
|
+
placeholder="名前・説明・系統で検索(例: ヒーロー、お知らせ、フォーム)"
|
|
222
|
+
className="h-8 border-[#444444] bg-[#383838] pl-8 pr-8 text-xs text-white placeholder:text-gray-500"
|
|
223
|
+
/>
|
|
224
|
+
{query && (
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => setQuery('')}
|
|
228
|
+
aria-label="検索条件をクリア"
|
|
229
|
+
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]"
|
|
230
|
+
>
|
|
231
|
+
<X className="h-3 w-3" />
|
|
232
|
+
</button>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="flex min-h-0 flex-1">
|
|
238
|
+
{/* 左: グループと系統 */}
|
|
239
|
+
<div className="w-48 shrink-0 overflow-y-auto border-r border-[#444444] px-2 py-3">
|
|
240
|
+
<p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-gray-500">分類</p>
|
|
241
|
+
<RailButton
|
|
242
|
+
label="すべて"
|
|
243
|
+
count={groups.reduce((n, g) => n + g.items.length, 0)}
|
|
244
|
+
isActive={groupId === 'all'}
|
|
245
|
+
onClick={() => setGroupId('all')}
|
|
246
|
+
/>
|
|
247
|
+
{groups.map((g) => (
|
|
248
|
+
<RailButton
|
|
249
|
+
key={g.id}
|
|
250
|
+
label={g.label}
|
|
251
|
+
count={g.items.length}
|
|
252
|
+
isActive={groupId === g.id}
|
|
253
|
+
onClick={() => setGroupId(g.id)}
|
|
254
|
+
/>
|
|
255
|
+
))}
|
|
256
|
+
|
|
257
|
+
{families.length > 0 && (
|
|
258
|
+
<>
|
|
259
|
+
<p className="px-2 pb-1 pt-4 text-[10px] font-medium uppercase tracking-wide text-gray-500">系統</p>
|
|
260
|
+
<RailButton
|
|
261
|
+
label="すべて"
|
|
262
|
+
count={itemsOfGroup.length}
|
|
263
|
+
isActive={family === 'all'}
|
|
264
|
+
onClick={() => setFamily('all')}
|
|
265
|
+
/>
|
|
266
|
+
{families.map((f) => (
|
|
267
|
+
<RailButton
|
|
268
|
+
key={f.id}
|
|
269
|
+
label={f.id}
|
|
270
|
+
count={f.count}
|
|
271
|
+
isActive={family === f.id}
|
|
272
|
+
onClick={() => setFamily(f.id)}
|
|
273
|
+
/>
|
|
274
|
+
))}
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* 中央: カード一覧 */}
|
|
280
|
+
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-4 py-3">
|
|
281
|
+
{isLoading && (
|
|
282
|
+
<div className="flex h-full items-center justify-center gap-2 text-xs text-gray-400">
|
|
283
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
284
|
+
台帳を読み込んでいます…
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{error && (
|
|
289
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 text-xs text-gray-300">
|
|
290
|
+
<span>{error}</span>
|
|
291
|
+
<Button
|
|
292
|
+
variant="ghost"
|
|
293
|
+
size="sm"
|
|
294
|
+
onClick={() => void load()}
|
|
295
|
+
className="h-7 gap-1.5 border border-[#444444] bg-[#383838] text-xs hover:bg-[#4a4a4a]"
|
|
296
|
+
>
|
|
297
|
+
<RefreshCw className="h-3 w-3" />
|
|
298
|
+
再読み込み
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{!isLoading && !error && filtered.length === 0 && (
|
|
304
|
+
<div className="flex h-full items-center justify-center text-xs text-gray-400">
|
|
305
|
+
該当する項目がありません
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{filtered.length > 0 && (
|
|
310
|
+
<div
|
|
311
|
+
className="grid gap-2"
|
|
312
|
+
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))' }}
|
|
313
|
+
role="group"
|
|
314
|
+
aria-label="台帳の一覧"
|
|
315
|
+
>
|
|
316
|
+
{filtered.map((item) => (
|
|
317
|
+
<InsertCard
|
|
318
|
+
key={item.id}
|
|
319
|
+
item={item}
|
|
320
|
+
isSelected={item.id === selectedId}
|
|
321
|
+
onSelect={() => setSelectedId(item.id)}
|
|
322
|
+
/>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* 右: 選んだ 1 件の詳細 */}
|
|
329
|
+
<div className="flex w-[340px] shrink-0 flex-col border-l border-[#444444]">
|
|
330
|
+
{selected ? (
|
|
331
|
+
<>
|
|
332
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
333
|
+
<PreviewFrame item={selected} height={260} width={1280} />
|
|
334
|
+
<h3 className="mt-3 text-sm font-medium text-white">{selected.name}</h3>
|
|
335
|
+
<p className="mt-0.5 text-[11px] text-gray-500">
|
|
336
|
+
{[selected.family, selected.level, selected.source === 'local' ? 'この案件' : null]
|
|
337
|
+
.filter(Boolean)
|
|
338
|
+
.join(' · ')}
|
|
339
|
+
</p>
|
|
340
|
+
{selected.description && (
|
|
341
|
+
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-gray-300">
|
|
342
|
+
{selected.description}
|
|
343
|
+
</p>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div className="space-y-2 border-t border-[#444444] p-3">
|
|
348
|
+
{actionError && (
|
|
349
|
+
<p role="alert" className="text-[11px] text-red-400">
|
|
350
|
+
{actionError}
|
|
351
|
+
</p>
|
|
352
|
+
)}
|
|
353
|
+
{!isSlide && !selectedIsPage && (
|
|
354
|
+
<label className="flex cursor-pointer items-center gap-2 text-[11px] text-gray-300">
|
|
355
|
+
<Checkbox
|
|
356
|
+
data-catalog-dummy
|
|
357
|
+
checked={dummy}
|
|
358
|
+
onCheckedChange={(v) => setDummy(v === true)}
|
|
359
|
+
/>
|
|
360
|
+
見出し・リード文を赤字ダミーにする
|
|
361
|
+
</label>
|
|
362
|
+
)}
|
|
363
|
+
{selectedIsPage ? (
|
|
364
|
+
<Button
|
|
365
|
+
data-catalog-create-page
|
|
366
|
+
disabled={!onCreatePage || busy}
|
|
367
|
+
onClick={() => setPageFormOpen(true)}
|
|
368
|
+
className="h-8 w-full gap-1.5 bg-[#0d99ff] text-xs text-white hover:bg-[#0c8ce9]"
|
|
369
|
+
title={onCreatePage ? undefined : 'このエディタではページを作れません'}
|
|
370
|
+
>
|
|
371
|
+
<FilePlus2 className="h-3.5 w-3.5" />
|
|
372
|
+
ページを作る
|
|
373
|
+
</Button>
|
|
374
|
+
) : (
|
|
375
|
+
<Button
|
|
376
|
+
data-catalog-insert
|
|
377
|
+
disabled={busy}
|
|
378
|
+
onClick={() => void handleInsert()}
|
|
379
|
+
className="h-8 w-full gap-1.5 bg-[#0d99ff] text-xs text-white hover:bg-[#0c8ce9]"
|
|
380
|
+
>
|
|
381
|
+
{busy ? (
|
|
382
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
383
|
+
) : (
|
|
384
|
+
<LayoutTemplate className="h-3.5 w-3.5" />
|
|
385
|
+
)}
|
|
386
|
+
挿入
|
|
387
|
+
</Button>
|
|
388
|
+
)}
|
|
389
|
+
<p className="text-[10px] leading-4 text-gray-500">
|
|
390
|
+
{selectedIsPage
|
|
391
|
+
? 'タイトルとパスを決めて新しいページを作り、そのページを編集します'
|
|
392
|
+
: '選択中の要素の直後(選択が無ければページの末尾)に入ります'}
|
|
393
|
+
</p>
|
|
394
|
+
</div>
|
|
395
|
+
</>
|
|
396
|
+
) : (
|
|
397
|
+
<p className="flex h-full items-center justify-center px-6 text-center text-[11px] leading-5 text-gray-500">
|
|
398
|
+
左の一覧から 1 件選ぶと、
|
|
399
|
+
<br />
|
|
400
|
+
ここに見本と挿入ボタンが出ます
|
|
401
|
+
</p>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
</DialogContent>
|
|
406
|
+
</Dialog>
|
|
407
|
+
|
|
408
|
+
{selected && selectedIsPage && onCreatePage && (
|
|
409
|
+
<CreatePageDialog
|
|
410
|
+
open={pageFormOpen}
|
|
411
|
+
onOpenChange={setPageFormOpen}
|
|
412
|
+
item={selected}
|
|
413
|
+
busy={busy}
|
|
414
|
+
error={actionError}
|
|
415
|
+
onSubmit={handleCreatePage}
|
|
416
|
+
/>
|
|
417
|
+
)}
|
|
418
|
+
</>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ============================ 部品 ============================ */
|
|
423
|
+
|
|
424
|
+
function RailButton({
|
|
425
|
+
label,
|
|
426
|
+
count,
|
|
427
|
+
isActive,
|
|
428
|
+
onClick,
|
|
429
|
+
}: {
|
|
430
|
+
label: string;
|
|
431
|
+
count: number;
|
|
432
|
+
isActive: boolean;
|
|
433
|
+
onClick: () => void;
|
|
434
|
+
}) {
|
|
435
|
+
return (
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
onClick={onClick}
|
|
439
|
+
aria-pressed={isActive}
|
|
440
|
+
className={cn(
|
|
441
|
+
'flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors',
|
|
442
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]',
|
|
443
|
+
isActive ? 'bg-[#0d99ff] text-white' : 'text-gray-300 hover:bg-[#444444] hover:text-white',
|
|
444
|
+
)}
|
|
445
|
+
>
|
|
446
|
+
<span className="truncate">{label}</span>
|
|
447
|
+
<span className={cn('shrink-0 tabular-nums text-[10px]', isActive ? 'text-blue-100' : 'text-gray-500')}>
|
|
448
|
+
{count}
|
|
449
|
+
</span>
|
|
450
|
+
</button>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 見本。`previewUrl` があれば iframe を縮小して出す。
|
|
456
|
+
* 表示だけなので操作は通さない(pointer-events: none)。台帳のページを一覧で開くので `loading="lazy"`。
|
|
457
|
+
*/
|
|
458
|
+
function PreviewFrame({ item, height, width }: { item: EditorInsertItem; height: number; width: number }) {
|
|
459
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
460
|
+
|
|
461
|
+
// 器の実幅に合わせて縮小率を決める。器はグリッドの列幅で伸び縮みするので
|
|
462
|
+
// 大きさが変わるたびに測り直す(CSS 変数に書くので React の再描画は起きない)
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const box = boxRef.current;
|
|
465
|
+
if (!box) return;
|
|
466
|
+
const apply = () => {
|
|
467
|
+
const scale = (box.clientWidth || width) / width;
|
|
468
|
+
box.style.setProperty('--catalog-preview-scale', String(scale));
|
|
469
|
+
// 縮めたあとの高さが器を満たすように、縮める前の高さを逆算する(足りないと下に地が出る)
|
|
470
|
+
box.style.setProperty('--catalog-preview-h', `${Math.ceil(height / scale)}px`);
|
|
471
|
+
};
|
|
472
|
+
apply();
|
|
473
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
474
|
+
const ro = new ResizeObserver(apply);
|
|
475
|
+
ro.observe(box);
|
|
476
|
+
return () => ro.disconnect();
|
|
477
|
+
}, [width, height]);
|
|
478
|
+
|
|
479
|
+
if (!item.previewUrl) {
|
|
480
|
+
return (
|
|
481
|
+
<div
|
|
482
|
+
className="flex items-center justify-center rounded border border-[#444444] bg-[#383838] text-[10px] text-gray-500"
|
|
483
|
+
style={{ height }}
|
|
484
|
+
>
|
|
485
|
+
見本はありません
|
|
486
|
+
</div>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
// 台帳の見本は紙面と同じ幅(PC)で作られている。器の幅に合うよう縮めて上から見せる
|
|
490
|
+
return (
|
|
491
|
+
<div
|
|
492
|
+
ref={boxRef}
|
|
493
|
+
className="relative overflow-hidden rounded border border-[#444444] bg-white"
|
|
494
|
+
style={{ height }}
|
|
495
|
+
aria-hidden="true"
|
|
496
|
+
>
|
|
497
|
+
<iframe
|
|
498
|
+
src={item.previewUrl}
|
|
499
|
+
title={`${item.name} の見本`}
|
|
500
|
+
loading="lazy"
|
|
501
|
+
tabIndex={-1}
|
|
502
|
+
className="pointer-events-none absolute left-0 top-0 origin-top-left border-0"
|
|
503
|
+
style={{
|
|
504
|
+
width,
|
|
505
|
+
height: 'var(--catalog-preview-h, 600px)',
|
|
506
|
+
transform: 'scale(var(--catalog-preview-scale, 0.2))',
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function InsertCard({
|
|
514
|
+
item,
|
|
515
|
+
isSelected,
|
|
516
|
+
onSelect,
|
|
517
|
+
}: {
|
|
518
|
+
item: EditorInsertItem;
|
|
519
|
+
isSelected: boolean;
|
|
520
|
+
onSelect: () => void;
|
|
521
|
+
}) {
|
|
522
|
+
return (
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
data-catalog-item={item.id}
|
|
526
|
+
onClick={onSelect}
|
|
527
|
+
aria-pressed={isSelected}
|
|
528
|
+
title={item.description ? `${item.name} — ${item.description}` : item.name}
|
|
529
|
+
className={cn(
|
|
530
|
+
'flex flex-col overflow-hidden rounded-md border text-left transition-colors',
|
|
531
|
+
'border-[#444444] bg-[#2c2c2c] hover:border-[#0d99ff] hover:bg-[#333]',
|
|
532
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]',
|
|
533
|
+
isSelected && 'border-[#0d99ff] ring-1 ring-[#0d99ff]',
|
|
534
|
+
)}
|
|
535
|
+
>
|
|
536
|
+
<PreviewFrame item={item} height={96} width={1280} />
|
|
537
|
+
<span className="truncate border-t border-[#444444] px-2 pb-0.5 pt-1 text-[11px] leading-4 text-gray-200">
|
|
538
|
+
{item.name}
|
|
539
|
+
</span>
|
|
540
|
+
<span className="truncate px-2 pb-1.5 text-[10px] leading-4 text-gray-500">
|
|
541
|
+
{item.family ?? item.description ?? item.level ?? ''}
|
|
542
|
+
</span>
|
|
543
|
+
</button>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 「ページを作る」の入力(タイトル・パス) */
|
|
548
|
+
function CreatePageDialog({
|
|
549
|
+
open,
|
|
550
|
+
onOpenChange,
|
|
551
|
+
item,
|
|
552
|
+
busy,
|
|
553
|
+
error,
|
|
554
|
+
onSubmit,
|
|
555
|
+
}: {
|
|
556
|
+
open: boolean;
|
|
557
|
+
onOpenChange: (open: boolean) => void;
|
|
558
|
+
item: EditorInsertItem;
|
|
559
|
+
busy: boolean;
|
|
560
|
+
error: string | null;
|
|
561
|
+
onSubmit: (input: { title: string; path: string }) => Promise<void>;
|
|
562
|
+
}) {
|
|
563
|
+
const [title, setTitle] = useState('');
|
|
564
|
+
const [path, setPath] = useState('/');
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (!open) return;
|
|
568
|
+
setTitle(item.name);
|
|
569
|
+
setPath('/');
|
|
570
|
+
}, [open, item.name]);
|
|
571
|
+
|
|
572
|
+
const pathError = path && !PATH_PATTERN.test(path) ? '「/」で始まる半角英小文字・数字・ハイフンで入れてください' : null;
|
|
573
|
+
const canSubmit = !!title.trim() && !!path && !pathError && !busy;
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
577
|
+
<DialogContent
|
|
578
|
+
data-catalog-page-dialog
|
|
579
|
+
className="w-[min(420px,92vw)] border-[#444444] bg-[#2c2c2c] p-4 text-white"
|
|
580
|
+
>
|
|
581
|
+
<DialogTitle className="text-sm font-medium">ページを作る</DialogTitle>
|
|
582
|
+
<p className="text-[11px] text-gray-400">雛形: {item.name}</p>
|
|
583
|
+
<form
|
|
584
|
+
className="mt-2 space-y-3"
|
|
585
|
+
onSubmit={(e) => {
|
|
586
|
+
e.preventDefault();
|
|
587
|
+
if (canSubmit) void onSubmit({ title: title.trim(), path });
|
|
588
|
+
}}
|
|
589
|
+
>
|
|
590
|
+
<div className="space-y-1">
|
|
591
|
+
<label htmlFor="catalog-page-title" className="text-[11px] text-gray-400">
|
|
592
|
+
タイトル
|
|
593
|
+
</label>
|
|
594
|
+
<Input
|
|
595
|
+
id="catalog-page-title"
|
|
596
|
+
data-catalog-page-title
|
|
597
|
+
autoFocus
|
|
598
|
+
value={title}
|
|
599
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
600
|
+
className="h-8 border-[#444444] bg-[#383838] text-xs text-white"
|
|
601
|
+
/>
|
|
602
|
+
</div>
|
|
603
|
+
<div className="space-y-1">
|
|
604
|
+
<label htmlFor="catalog-page-path" className="text-[11px] text-gray-400">
|
|
605
|
+
パス
|
|
606
|
+
</label>
|
|
607
|
+
<Input
|
|
608
|
+
id="catalog-page-path"
|
|
609
|
+
data-catalog-page-path
|
|
610
|
+
value={path}
|
|
611
|
+
onChange={(e) => setPath(e.target.value)}
|
|
612
|
+
placeholder="/company/about"
|
|
613
|
+
aria-invalid={!!pathError}
|
|
614
|
+
className="h-8 border-[#444444] bg-[#383838] font-mono text-xs text-white"
|
|
615
|
+
/>
|
|
616
|
+
{pathError && (
|
|
617
|
+
<p role="alert" className="text-[10px] text-red-400">
|
|
618
|
+
{pathError}
|
|
619
|
+
</p>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
{error && (
|
|
623
|
+
<p role="alert" className="text-[11px] text-red-400">
|
|
624
|
+
{error}
|
|
625
|
+
</p>
|
|
626
|
+
)}
|
|
627
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
628
|
+
<Button type="button" variant="ghost" className="h-8 text-xs" onClick={() => onOpenChange(false)}>
|
|
629
|
+
キャンセル
|
|
630
|
+
</Button>
|
|
631
|
+
<Button
|
|
632
|
+
type="submit"
|
|
633
|
+
data-catalog-page-submit
|
|
634
|
+
disabled={!canSubmit}
|
|
635
|
+
className="h-8 gap-1.5 bg-[#0d99ff] text-xs text-white hover:bg-[#0c8ce9]"
|
|
636
|
+
>
|
|
637
|
+
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
|
638
|
+
作る
|
|
639
|
+
</Button>
|
|
640
|
+
</div>
|
|
641
|
+
</form>
|
|
642
|
+
</DialogContent>
|
|
643
|
+
</Dialog>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export default CatalogInsertDialog;
|
|
@@ -28,7 +28,7 @@ import { applyShadowPreset, paintTarget } from '../../utils/element-effects';
|
|
|
28
28
|
import {
|
|
29
29
|
Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
|
|
30
30
|
Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
|
|
31
|
-
Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
|
|
31
|
+
Boxes, Blocks, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
|
|
32
32
|
Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play, Crosshair,
|
|
33
33
|
Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
|
|
34
34
|
MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
|
|
@@ -88,6 +88,8 @@ export type PptActions = {
|
|
|
88
88
|
ungroupElements?: () => void;
|
|
89
89
|
openFilePicker: () => void;
|
|
90
90
|
openMediaLibrary: () => void;
|
|
91
|
+
/** 「台帳から挿入」。io.loadInserts が無ければ渡さない(ボタンを出さない) */
|
|
92
|
+
openCatalogInserts?: () => void;
|
|
91
93
|
openComponents: () => void;
|
|
92
94
|
openVariables: () => void;
|
|
93
95
|
activeTool: EditorTool;
|
|
@@ -1739,6 +1741,9 @@ export function PptRibbon({
|
|
|
1739
1741
|
pal={pal}
|
|
1740
1742
|
/>
|
|
1741
1743
|
<BigButton icon={Boxes} label={'コンポー\nネント'} onClick={actions.openComponents} pal={pal} />
|
|
1744
|
+
{actions.openCatalogInserts && (
|
|
1745
|
+
<BigButton icon={Blocks} label={'台帳から\n挿入'} onClick={actions.openCatalogInserts} title="デザインシステムの台帳から挿入" pal={pal} />
|
|
1746
|
+
)}
|
|
1742
1747
|
</>
|
|
1743
1748
|
)}
|
|
1744
1749
|
|
|
@@ -25,7 +25,7 @@ export { useContextMenuHandler } from './useContextMenuHandler';
|
|
|
25
25
|
export { useIframeSetup } from './useIframeSetup';
|
|
26
26
|
export { useFocusManagement } from './useFocusManagement';
|
|
27
27
|
export { useInlineTextSelection } from './useInlineTextSelection';
|
|
28
|
-
export { useLinkNavigation } from './useLinkNavigation';
|
|
28
|
+
export { useLinkNavigation, useRevealElement } from './useLinkNavigation';
|
|
29
29
|
export { useKeepArtboardInPlace } from './useKeepArtboardInPlace';
|
|
30
30
|
export { useResizablePanel } from './useResizablePanel';
|
|
31
31
|
|