@growgroup/visual-editor 0.1.5 → 0.2.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 +59 -2
- package/dist/editor.css +1 -1
- package/package.json +7 -3
- package/src/components/ui/alert-dialog.tsx +18 -4
- package/src/components/ui/context-menu.tsx +18 -4
- package/src/components/ui/dialog.tsx +19 -5
- package/src/components/ui/dropdown-menu.tsx +18 -4
- package/src/components/ui/popover.tsx +10 -2
- package/src/components/ui/select.tsx +10 -2
- package/src/components/ui/tooltip.tsx +10 -2
- package/src/editor/EditorToolbar.tsx +10 -20
- package/src/editor/FrontendVisualEditor.tsx +189 -43
- package/src/editor/components/EditorCanvas.tsx +8 -3
- package/src/editor/components/EditorContextMenu.tsx +53 -71
- package/src/editor/components/EditorFooter.tsx +36 -70
- package/src/editor/components/EditorHeader.tsx +37 -20
- package/src/editor/components/EditorLayerPanel.tsx +7 -9
- package/src/editor/components/EditorPropertyPanel.tsx +84 -8
- package/src/editor/components/LayerTreeItem.tsx +28 -5
- package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
- package/src/editor/components/ppt/PptChrome.tsx +156 -344
- package/src/editor/components/ppt/PptComments.tsx +135 -87
- package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
- package/src/editor/components/ppt/PptNotes.tsx +15 -15
- package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
- package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
- package/src/editor/components/shell/EditorTopBar.tsx +29 -68
- package/src/editor/components/shell/LeftPanel.tsx +20 -94
- package/src/editor/components/shell/PagesPanel.tsx +4 -2
- package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
- package/src/editor/editor-skin.css +2 -122
- package/src/editor/hooks/useCanvasControls.ts +3 -2
- package/src/editor/hooks/useComponentEditMode.ts +95 -1
- package/src/editor/hooks/useDragResize.ts +86 -6
- package/src/editor/hooks/useDrawingMode.ts +4 -2
- package/src/editor/hooks/useElementActions.ts +60 -1
- package/src/editor/hooks/useElementSelection.ts +35 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
- package/src/editor/hooks/useMediaLibrary.ts +4 -5
- package/src/editor/hooks/useResizablePanel.ts +40 -11
- package/src/editor/parts.ts +321 -0
- package/src/editor/utils/dom-utils.ts +135 -15
- package/src/editor/utils/flex-reorder.ts +268 -43
- package/src/editor/utils/geometry.ts +4 -3
- package/src/editor/utils/html-utils.ts +10 -5
- package/src/hooks/useEditorShortcuts.ts +39 -2
- package/src/index.ts +25 -0
- package/src/io.ts +54 -0
- package/src/styles/skin.css +159 -0
- package/src/types/editor-components.ts +23 -1
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
12
|
import { ChevronDown, ChevronUp, Loader2, Sparkles, StickyNote } from 'lucide-react';
|
|
13
13
|
import { PPT_PALETTES, type PptTheme } from './PptChrome';
|
|
14
|
-
import {
|
|
14
|
+
import { io } from '../../../io';
|
|
15
|
+
const notesApi = async <T,>(path: string, init?: RequestInit) => io().apiFetch!(path, init) as Promise<T>;
|
|
15
16
|
|
|
16
17
|
type Segment = { page: number; text?: string; note?: string };
|
|
17
18
|
|
|
@@ -28,8 +29,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
28
29
|
// 原稿ファイルを読み込む(1回)
|
|
29
30
|
useEffect(() => {
|
|
30
31
|
let alive = true;
|
|
31
|
-
|
|
32
|
-
.then((r) => r.json())
|
|
32
|
+
notesApi<{ segments?: Segment[] }>('/__presenter-script')
|
|
33
33
|
.then((data) => {
|
|
34
34
|
if (!alive) return;
|
|
35
35
|
const map = new Map<number, Segment>();
|
|
@@ -48,7 +48,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
48
48
|
if (!d) return;
|
|
49
49
|
dirtyRef.current = null;
|
|
50
50
|
try {
|
|
51
|
-
await
|
|
51
|
+
await notesApi('/__presenter-script/segment', {
|
|
52
52
|
method: 'POST',
|
|
53
53
|
body: JSON.stringify(d),
|
|
54
54
|
});
|
|
@@ -93,7 +93,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
93
93
|
/** 生成完了後にファイルを読み直し、表示中ページの原稿を差し替える */
|
|
94
94
|
const reloadSegments = useCallback(async () => {
|
|
95
95
|
try {
|
|
96
|
-
const data = await
|
|
96
|
+
const data = await notesApi<{ segments?: Segment[] }>('/__presenter-script');
|
|
97
97
|
const map = new Map<number, Segment>();
|
|
98
98
|
for (const seg of data.segments ?? []) map.set(seg.page, seg);
|
|
99
99
|
setSegments(map);
|
|
@@ -113,15 +113,13 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
113
113
|
setGenState({ running: true, message: '開始中…' });
|
|
114
114
|
setOpen(true);
|
|
115
115
|
try {
|
|
116
|
-
const
|
|
117
|
-
const { jobId, error } =
|
|
116
|
+
const data = await notesApi<{ jobId?: string; error?: string }>('/__script-gen', { method: 'POST', body: JSON.stringify(payload) });
|
|
117
|
+
const { jobId, error } = data;
|
|
118
118
|
if (!jobId) throw new Error(error || '開始できませんでした');
|
|
119
119
|
// 完了までポーリング(生成は1バッチ30秒〜)
|
|
120
120
|
for (;;) {
|
|
121
121
|
await new Promise((r) => setTimeout(r, 1500));
|
|
122
|
-
const st = await
|
|
123
|
-
await fetch(`/__script-gen/status/${jobId}`),
|
|
124
|
-
);
|
|
122
|
+
const st = await notesApi<{ state: string; message?: string; error?: string }>(`/__script-gen/status/${jobId}`);
|
|
125
123
|
setGenState({ running: st.state === 'running', message: st.message ?? '' });
|
|
126
124
|
if (st.state === 'done') {
|
|
127
125
|
await reloadSegments();
|
|
@@ -139,9 +137,9 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
139
137
|
return (
|
|
140
138
|
<div className="shrink-0 border-t" style={{ borderColor: pal.border, backgroundColor: pal.rail }}>
|
|
141
139
|
{/* 折りたたみバー(実機の「ノートを入力」) */}
|
|
142
|
-
<div className="flex h-
|
|
140
|
+
<div className="flex h-9 w-full items-center gap-1.5 px-3 text-[11px]">
|
|
143
141
|
<button
|
|
144
|
-
onClick={() => setOpen((v) => !v)}
|
|
142
|
+
aria-expanded={open} aria-label="発表者ノートを開閉" onClick={() => setOpen((v) => !v)}
|
|
145
143
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
|
146
144
|
style={{ color: hasContent ? pal.text : pal.sub }}
|
|
147
145
|
title="トークスクリプト(発表者コンソールの原稿)を編集"
|
|
@@ -151,8 +149,8 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
151
149
|
{genState.message
|
|
152
150
|
? genState.message
|
|
153
151
|
: hasContent
|
|
154
|
-
? '
|
|
155
|
-
: '
|
|
152
|
+
? '発表者ノートを編集'
|
|
153
|
+
: '発表者ノートを追加'}
|
|
156
154
|
</span>
|
|
157
155
|
</button>
|
|
158
156
|
{/* AI生成: このスライドだけ / 発表時間を決めて全スライド */}
|
|
@@ -218,7 +216,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
218
216
|
</button>
|
|
219
217
|
</span>
|
|
220
218
|
)}
|
|
221
|
-
<button onClick={() => setOpen((v) => !v)} className="shrink-0" style={{ color: pal.sub }}>
|
|
219
|
+
<button aria-expanded={open} aria-label="発表者ノートを開閉" onClick={() => setOpen((v) => !v)} className="shrink-0" style={{ color: pal.sub }}>
|
|
222
220
|
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
|
223
221
|
</button>
|
|
224
222
|
</div>
|
|
@@ -226,6 +224,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
226
224
|
{open && (
|
|
227
225
|
<div className="flex gap-2 px-3 pb-2">
|
|
228
226
|
<textarea
|
|
227
|
+
aria-label="発表する内容"
|
|
229
228
|
value={text}
|
|
230
229
|
onChange={(e) => { setText(e.target.value); queueSave(e.target.value, note); }}
|
|
231
230
|
placeholder={`スライド ${page} で話すこと…(発表者コンソールにそのまま出ます)`}
|
|
@@ -233,6 +232,7 @@ export function PptNotes({ page, theme }: { page: number; theme: PptTheme }) {
|
|
|
233
232
|
style={{ backgroundColor: pal.control, borderColor: pal.border, color: pal.text }}
|
|
234
233
|
/>
|
|
235
234
|
<textarea
|
|
235
|
+
aria-label="段取りメモ"
|
|
236
236
|
value={note}
|
|
237
237
|
onChange={(e) => { setNote(e.target.value); queueSave(text, e.target.value); }}
|
|
238
238
|
placeholder="段取りメモ(任意)…"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useEditorContext } from '../../EditorContext';
|
|
5
|
+
import type { PptTheme } from '../ppt/PptChrome';
|
|
6
|
+
|
|
7
|
+
/** 紙面のHTMLにはスタイルを追加せず、保存対象外のキャンバスの器だけを配色する。 */
|
|
8
|
+
export function CanvasAppearance({ theme }: { theme: PptTheme }) {
|
|
9
|
+
const { iframeReady, getIframeDoc } = useEditorContext();
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const doc = getIframeDoc();
|
|
12
|
+
if (!doc) return;
|
|
13
|
+
const color = theme === 'dark' ? '#191d24' : '#eef1f5';
|
|
14
|
+
doc.body.style.backgroundColor = color;
|
|
15
|
+
const container = doc.getElementById('canvas-container');
|
|
16
|
+
if (container) container.style.backgroundColor = color;
|
|
17
|
+
}, [theme, iframeReady, getIframeDoc]);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '../../../components/ui/alert-dialog';
|
|
4
|
+
|
|
5
|
+
export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = '削除する', onConfirm }: {
|
|
6
|
+
open: boolean; onOpenChange: (open: boolean) => void; title: string; description: string;
|
|
7
|
+
confirmLabel?: string; onConfirm: () => void;
|
|
8
|
+
}) {
|
|
9
|
+
return <AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
10
|
+
<AlertDialogContent>
|
|
11
|
+
<AlertDialogHeader><AlertDialogTitle>{title}</AlertDialogTitle><AlertDialogDescription>{description}</AlertDialogDescription></AlertDialogHeader>
|
|
12
|
+
<AlertDialogFooter><AlertDialogCancel>キャンセル</AlertDialogCancel><AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction></AlertDialogFooter>
|
|
13
|
+
</AlertDialogContent>
|
|
14
|
+
</AlertDialog>;
|
|
15
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* leftExtra / rightExtra / menuExtra のスロットで受け取り、この並びを崩さない。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { toast } from 'sonner';
|
|
17
18
|
import { useState } from 'react';
|
|
18
19
|
import {
|
|
19
20
|
DropdownMenu,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
DropdownMenuItem,
|
|
22
23
|
DropdownMenuTrigger,
|
|
23
24
|
} from '../../../components/ui/dropdown-menu';
|
|
24
|
-
import { ArrowLeft, ChevronDown, Download, FileText, Loader2, PenTool, Play, Save, X } from 'lucide-react';
|
|
25
|
+
import { AlertCircle, CheckCircle2, Clock3, ArrowLeft, ChevronDown, Download, FileText, Loader2, PenTool, Play, Save, X } from 'lucide-react';
|
|
25
26
|
import { useEditorContext } from '../../EditorContext';
|
|
26
27
|
import { getCleanHtml } from '../../utils/html-utils';
|
|
27
28
|
import { startExport, waitForExport, downloadExport, type ExportFormat } from '../../../lib/export';
|
|
@@ -46,20 +47,15 @@ export type TopBarPalette = {
|
|
|
46
47
|
|
|
47
48
|
/** Figma風の配色(editor-skin.css のパネル色に合わせる) */
|
|
48
49
|
const FIGMA_PALETTE: TopBarPalette = {
|
|
49
|
-
chrome: '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
border: '#444444',
|
|
53
|
-
hover: '#444444',
|
|
54
|
-
activeBg: '#3a3a3a',
|
|
55
|
-
control: '#1e1e1e',
|
|
56
|
-
disabled: '#5f5f5f',
|
|
50
|
+
chrome: 'var(--ed-panel)', text: 'var(--ed-text)', sub: 'var(--ed-muted)',
|
|
51
|
+
border: 'var(--ed-line)', hover: 'var(--ed-surface-hover)', activeBg: 'var(--ed-accent-soft)',
|
|
52
|
+
control: 'var(--ed-panel-raised)', disabled: 'var(--ed-muted)',
|
|
57
53
|
};
|
|
58
54
|
|
|
59
55
|
/** 共有ボタンの色だけはUIの「顔」なのでテーマごとに持つ */
|
|
60
56
|
const SHARE_COLOR = {
|
|
61
|
-
figma: { bg: '
|
|
62
|
-
ppt: { bg: '
|
|
57
|
+
figma: { bg: 'var(--ed-accent-strong)', fg: '#ffffff' },
|
|
58
|
+
ppt: { bg: 'var(--ed-accent-strong)', fg: '#ffffff' },
|
|
63
59
|
} as const;
|
|
64
60
|
|
|
65
61
|
export interface EditorTopBarProps {
|
|
@@ -143,14 +139,14 @@ export function EditorTopBar({
|
|
|
143
139
|
const st = await waitForExport(jobId, () => undefined);
|
|
144
140
|
downloadExport(st);
|
|
145
141
|
} catch (e) {
|
|
146
|
-
|
|
142
|
+
toast.error("書き出しに失敗しました", { description: String(e).slice(0, 120) });
|
|
147
143
|
} finally {
|
|
148
144
|
setExporting(null);
|
|
149
145
|
}
|
|
150
146
|
};
|
|
151
147
|
|
|
152
|
-
const iconBtn = '
|
|
153
|
-
const textBtn = '
|
|
148
|
+
const iconBtn = 'ed-icon-button';
|
|
149
|
+
const textBtn = 'ed-button';
|
|
154
150
|
const hoverIn = (e: React.MouseEvent<HTMLElement>) => {
|
|
155
151
|
if (!(e.currentTarget as HTMLButtonElement).disabled) e.currentTarget.style.backgroundColor = pal.hover;
|
|
156
152
|
};
|
|
@@ -185,8 +181,8 @@ export function EditorTopBar({
|
|
|
185
181
|
onClick={onSwitchUi}
|
|
186
182
|
title={
|
|
187
183
|
variant === 'ppt'
|
|
188
|
-
? '
|
|
189
|
-
: '
|
|
184
|
+
? 'パネル表示に切り替え'
|
|
185
|
+
: 'リボン表示に切り替え'
|
|
190
186
|
}
|
|
191
187
|
className={textBtn}
|
|
192
188
|
style={{ borderColor: pal.border, color: pal.sub }}
|
|
@@ -200,13 +196,13 @@ export function EditorTopBar({
|
|
|
200
196
|
P
|
|
201
197
|
</span>
|
|
202
198
|
)}
|
|
203
|
-
{variant === 'ppt' ? '
|
|
199
|
+
{variant === 'ppt' ? 'パネル表示' : 'リボン表示'}
|
|
204
200
|
</button>
|
|
205
201
|
)}
|
|
206
202
|
{leftExtra}
|
|
207
203
|
|
|
208
204
|
{/* 中央: デッキタイトル + スライド番号 */}
|
|
209
|
-
<div className="flex min-w-0 flex-1 items-center
|
|
205
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
210
206
|
{pageNumber != null && (
|
|
211
207
|
<span className="shrink-0 font-mono text-[11px] tabular-nums" style={{ color: pal.sub }}>
|
|
212
208
|
{String(pageNumber).padStart(3, '0')}
|
|
@@ -224,14 +220,15 @@ export function EditorTopBar({
|
|
|
224
220
|
<button
|
|
225
221
|
data-topbar="save"
|
|
226
222
|
onClick={() => void handleSave()}
|
|
227
|
-
disabled={saving
|
|
228
|
-
title={saving ? '保存中…' :
|
|
229
|
-
|
|
230
|
-
|
|
223
|
+
disabled={saving}
|
|
224
|
+
title={saving ? '保存中…' : '今すぐ保存(⌘S / Ctrl+S)'}
|
|
225
|
+
aria-label="保存"
|
|
226
|
+
className="ed-button"
|
|
231
227
|
onMouseEnter={hoverIn}
|
|
232
228
|
onMouseLeave={hoverOut}
|
|
233
229
|
>
|
|
234
230
|
<Save className="h-4 w-4" />
|
|
231
|
+
保存
|
|
235
232
|
</button>
|
|
236
233
|
{/* 書き出しは利用側の処理が要る。渡されていなければ出さない
|
|
237
234
|
(押すと失敗するボタンを残さないため) */}
|
|
@@ -257,7 +254,7 @@ export function EditorTopBar({
|
|
|
257
254
|
style={{ backgroundColor: share.bg, color: share.fg }}
|
|
258
255
|
>
|
|
259
256
|
{exporting && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
260
|
-
{exporting ? `${exporting}を書き出し中…` : '
|
|
257
|
+
{exporting ? `${exporting}を書き出し中…` : '書き出し'}
|
|
261
258
|
<ChevronDown className="h-3 w-3" />
|
|
262
259
|
</button>
|
|
263
260
|
</DropdownMenuTrigger>
|
|
@@ -313,7 +310,7 @@ export function EditorTopBar({
|
|
|
313
310
|
<button
|
|
314
311
|
data-topbar="close"
|
|
315
312
|
onClick={onClose}
|
|
316
|
-
title="
|
|
313
|
+
title="エディタを閉じる" aria-label="エディタを閉じる"
|
|
317
314
|
className={iconBtn}
|
|
318
315
|
style={{ color: pal.sub }}
|
|
319
316
|
onMouseEnter={hoverIn}
|
|
@@ -340,53 +337,17 @@ function SaveStatusPill({
|
|
|
340
337
|
status: EditorSaveStatus;
|
|
341
338
|
pal: TopBarPalette;
|
|
342
339
|
}) {
|
|
343
|
-
const note = {
|
|
344
|
-
saved: '保存済み',
|
|
345
|
-
dirty: '保存待ち',
|
|
346
|
-
saving: '保存中…',
|
|
347
|
-
error: '保存できません',
|
|
348
|
-
}[status];
|
|
349
|
-
|
|
350
|
-
if (variant === 'ppt') {
|
|
351
|
-
// PowerPoint実機の「自動保存」ピル(トグルは無く、ノブが進み具合を示す)
|
|
352
|
-
const on = status !== 'error';
|
|
353
|
-
return (
|
|
354
|
-
<span
|
|
355
|
-
data-topbar="save-status"
|
|
356
|
-
data-save-status={status}
|
|
357
|
-
className="flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px]"
|
|
358
|
-
style={{ backgroundColor: pal.hover, color: status === 'error' ? '#ff6b6b' : pal.sub }}
|
|
359
|
-
title={`自動保存: ${note}(編集の手が止まると保存します)`}
|
|
360
|
-
>
|
|
361
|
-
自動保存
|
|
362
|
-
<span
|
|
363
|
-
className="relative inline-block h-3 w-6 rounded-full"
|
|
364
|
-
style={{ backgroundColor: on ? '#C43E1C' : '#8a3a3a' }}
|
|
365
|
-
>
|
|
366
|
-
<span
|
|
367
|
-
className={`absolute right-0.5 top-0.5 h-2 w-2 rounded-full bg-white ${status === 'saving' ? 'animate-pulse' : ''}`}
|
|
368
|
-
/>
|
|
369
|
-
</span>
|
|
370
|
-
{note}
|
|
371
|
-
</span>
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
340
|
const view = {
|
|
376
|
-
saved: {
|
|
377
|
-
dirty: {
|
|
378
|
-
saving: {
|
|
379
|
-
error: {
|
|
341
|
+
saved: { label: '保存済み', Icon: CheckCircle2 },
|
|
342
|
+
dirty: { label: '未保存・自動保存待ち', Icon: Clock3 },
|
|
343
|
+
saving: { label: '保存中…', Icon: Loader2 },
|
|
344
|
+
error: { label: '保存失敗・再試行してください', Icon: AlertCircle },
|
|
380
345
|
}[status];
|
|
381
346
|
return (
|
|
382
|
-
<span
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ring-1 ring-inset ${view.box}`}
|
|
387
|
-
>
|
|
388
|
-
<span className={`h-1.5 w-1.5 rounded-full ${view.dot}`} />
|
|
389
|
-
{status === 'dirty' ? '未保存' : note}
|
|
347
|
+
<span data-topbar="save-status" data-save-status={status} role="status" aria-live="polite"
|
|
348
|
+
className="ed-save-status" title="編集が止まると自動保存します。保存ボタンからも保存できます。">
|
|
349
|
+
<view.Icon aria-hidden="true" className={`h-4 w-4 ${status === 'saving' ? 'animate-spin' : ''}`} />
|
|
350
|
+
{view.label}
|
|
390
351
|
</span>
|
|
391
352
|
);
|
|
392
353
|
}
|
|
@@ -1,111 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Figma風UIの左パネル。
|
|
5
|
-
*
|
|
6
|
-
* 既定は2段構成:
|
|
7
|
-
* 上: ページ切替(PagesPanel。スライドのプレビュー付き)
|
|
8
|
-
* 下: 現在ページのレイヤーツリー(EditorLayerPanel hideSlideList)
|
|
9
|
-
*
|
|
10
|
-
* `pagesSlot` を渡すと、その中身と レイヤーツリー をタブで出し分ける。
|
|
11
|
-
* ページ一覧を利用側が持つ場合(Webページ・ページ数が固定のサイト等)に、
|
|
12
|
-
* 左端へ独自パネルをもう1枚並べずに済ませるための口。左に2枚パネルが並ぶと
|
|
13
|
-
* 「左=1枚 / 中央=キャンバス / 右=プロパティ」というFigmaの骨格が崩れる。
|
|
14
|
-
*
|
|
15
|
-
* 幅はこのラッパーが1本のハンドルで持つ。段ごとに幅・ハンドルを持たせると
|
|
16
|
-
* 上下で幅が食い違ったり、リサイズが二重になったりするため。
|
|
17
|
-
*/
|
|
1
|
+
'use client';
|
|
18
2
|
|
|
19
3
|
import { useState, type ReactNode } from 'react';
|
|
4
|
+
import { Layers, Files } from 'lucide-react';
|
|
20
5
|
import { useEditorContext } from '../../EditorContext';
|
|
21
6
|
import { useResizablePanel } from '../../hooks/useResizablePanel';
|
|
22
7
|
import { EditorLayerPanel } from '../EditorLayerPanel';
|
|
23
8
|
import { PagesPanel } from './PagesPanel';
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../components/ui/tabs';
|
|
24
10
|
import { can } from '../../../io';
|
|
25
11
|
|
|
26
|
-
type LeftTab = 'pages' | 'layers';
|
|
27
|
-
|
|
28
12
|
export function LeftPanel({ page, pagesSlot }: { page: number; pagesSlot?: ReactNode }) {
|
|
29
|
-
// 一覧のサムネイルは利用側が描く(io.renderContent)。描けないなら段ごと出さない。
|
|
30
|
-
// Webページの用途ではページ切替を利用側のUIが持つことが多い
|
|
31
13
|
const { editorMode } = useEditorContext();
|
|
32
|
-
const showPages = can('renderContent') && editorMode !== 'webpage';
|
|
14
|
+
const showPages = !!pagesSlot || (can('renderContent') && editorMode !== 'webpage');
|
|
33
15
|
const { width, isDragging, resizeHandleProps } = useResizablePanel({
|
|
34
|
-
initialWidth:
|
|
35
|
-
minWidth: 180,
|
|
36
|
-
maxWidth: 480,
|
|
37
|
-
direction: 'right',
|
|
38
|
-
storageKey: 'gg-editor:left-panel-width',
|
|
16
|
+
initialWidth: 264, minWidth: 224, maxWidth: 440, direction: 'right', storageKey: 'gg-editor:left-panel-width',
|
|
39
17
|
});
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 既定は「ページ」。レイヤーは要素を触りはじめてから使うもので、
|
|
43
|
-
* 開いた直後に知りたいのは「どのページを見ているか」のため
|
|
44
|
-
*/
|
|
45
|
-
const [tab, setTab] = useState<LeftTab>('pages');
|
|
46
|
-
|
|
18
|
+
const [tab, setTab] = useState(showPages ? 'pages' : 'layers');
|
|
47
19
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
['layers', 'レイヤー'],
|
|
62
|
-
] as const).map(([key, label]) => {
|
|
63
|
-
const active = tab === key;
|
|
64
|
-
return (
|
|
65
|
-
<button
|
|
66
|
-
key={key}
|
|
67
|
-
type="button"
|
|
68
|
-
role="tab"
|
|
69
|
-
aria-selected={active}
|
|
70
|
-
data-left-tab={key}
|
|
71
|
-
onClick={() => setTab(key)}
|
|
72
|
-
className={`relative flex-1 px-2 py-2 text-[11px] font-medium transition-colors ${
|
|
73
|
-
active ? 'text-white' : 'text-gray-400 hover:text-gray-200'
|
|
74
|
-
}`}
|
|
75
|
-
>
|
|
76
|
-
{label}
|
|
77
|
-
{active && (
|
|
78
|
-
<span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[#0d99ff]" />
|
|
79
|
-
)}
|
|
80
|
-
</button>
|
|
81
|
-
);
|
|
82
|
-
})}
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
{/*
|
|
86
|
-
両方をマウントしたまま出し分ける。片方を外すと、戻ったときに
|
|
87
|
-
スクロール位置・検索語・展開状態が毎回初期化される
|
|
88
|
-
*/}
|
|
89
|
-
<div className={`min-h-0 flex-1 flex-col ${tab === 'pages' ? 'flex' : 'hidden'}`}>
|
|
90
|
-
{pagesSlot}
|
|
91
|
-
</div>
|
|
92
|
-
<div className={`min-h-0 flex-1 ${tab === 'layers' ? 'flex' : 'hidden'}`}>
|
|
93
|
-
<EditorLayerPanel hideSlideList />
|
|
94
|
-
</div>
|
|
95
|
-
</>
|
|
96
|
-
) : (
|
|
97
|
-
<>
|
|
98
|
-
{showPages && <PagesPanel page={page} />}
|
|
99
|
-
{/* 残りの高さいっぱいにレイヤーツリー(自前でスクロールする) */}
|
|
100
|
-
<div className="flex min-h-0 flex-1">
|
|
101
|
-
<EditorLayerPanel hideSlideList />
|
|
102
|
-
</div>
|
|
103
|
-
</>
|
|
104
|
-
)}
|
|
105
|
-
|
|
106
|
-
{/* リサイズハンドル(全高。上下どちらの段からでも掴める) */}
|
|
20
|
+
<aside data-left-panel aria-label="ページとレイヤー" className="relative flex shrink-0 flex-col overflow-hidden border-r border-[#444444] bg-[#2c2c2c]" style={{ width }}>
|
|
21
|
+
{showPages ? (
|
|
22
|
+
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
|
|
23
|
+
<TabsList className="ed-panel-tabs" aria-label="左パネル">
|
|
24
|
+
<TabsTrigger value="pages" data-left-tab="pages"><Files className="mr-2 h-4 w-4" />ページ</TabsTrigger>
|
|
25
|
+
<TabsTrigger value="layers" data-left-tab="layers"><Layers className="mr-2 h-4 w-4" />レイヤー</TabsTrigger>
|
|
26
|
+
</TabsList>
|
|
27
|
+
<TabsContent value="pages" forceMount className="ed-panel-tab-content">
|
|
28
|
+
{pagesSlot || <PagesPanel page={page} height="100%" />}
|
|
29
|
+
</TabsContent>
|
|
30
|
+
<TabsContent value="layers" forceMount className="ed-panel-tab-content"><EditorLayerPanel hideSlideList /></TabsContent>
|
|
31
|
+
</Tabs>
|
|
32
|
+
) : <><div className="ed-panel-heading"><Layers className="h-4 w-4" />レイヤー</div><EditorLayerPanel hideSlideList /></>}
|
|
107
33
|
<div {...resizeHandleProps} />
|
|
108
34
|
{isDragging && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
|
109
|
-
</
|
|
35
|
+
</aside>
|
|
110
36
|
);
|
|
111
37
|
}
|
|
@@ -82,13 +82,14 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
|
|
|
82
82
|
key={s.id}
|
|
83
83
|
ref={current ? currentRef : undefined}
|
|
84
84
|
data-page-thumb={n}
|
|
85
|
+
aria-current={current ? "page" : undefined}
|
|
85
86
|
onClick={() => void goto(n)}
|
|
86
87
|
className="flex w-full shrink-0 items-start gap-1.5 px-2 py-1 text-left"
|
|
87
88
|
title={s.title ? `${n}. ${s.title}` : `${n}枚目`}
|
|
88
89
|
>
|
|
89
90
|
<span
|
|
90
91
|
className="mt-0.5 w-4 shrink-0 text-right text-[10px] tabular-nums"
|
|
91
|
-
style={{ color: current ? '
|
|
92
|
+
style={{ color: current ? 'var(--ed-accent)' : 'var(--ed-muted)', fontWeight: current ? 700 : 400 }}
|
|
92
93
|
>
|
|
93
94
|
{n}
|
|
94
95
|
</span>
|
|
@@ -103,13 +104,14 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
|
|
|
103
104
|
>
|
|
104
105
|
{thumbWidth > 0 && (
|
|
105
106
|
<span
|
|
106
|
-
className="pointer-events-none absolute left-0 top-0 origin-top-left"
|
|
107
|
+
data-editor-preview className="pointer-events-none absolute left-0 top-0 origin-top-left"
|
|
107
108
|
style={{ width: 1920, height: 1080, transform: `scale(${thumbWidth / 1920})` }}
|
|
108
109
|
>
|
|
109
110
|
<MemoSlideRender page={n} template={s.template} edited={s.edited} />
|
|
110
111
|
</span>
|
|
111
112
|
)}
|
|
112
113
|
</span>
|
|
114
|
+
<span className="min-w-0 flex-1 self-center text-xs leading-relaxed">{s.title || "無題"}</span>
|
|
113
115
|
</button>
|
|
114
116
|
);
|
|
115
117
|
})}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
4
|
+
import type { PptTheme } from '../components/ppt/PptChrome';
|
|
5
|
+
|
|
6
|
+
const THEME_KEY = 'gg-editor:ppt-theme';
|
|
7
|
+
export const EditorAppearanceContext = createContext<PptTheme | undefined>(undefined);
|
|
8
|
+
|
|
9
|
+
export function initialEditorTheme(): PptTheme {
|
|
10
|
+
try {
|
|
11
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
12
|
+
if (saved === 'light' || saved === 'dark') return saved;
|
|
13
|
+
} catch { /* ストレージが使えなくてもOS設定で開く */ }
|
|
14
|
+
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useEditorTheme() {
|
|
18
|
+
const [theme, setTheme] = useState<PptTheme>(initialEditorTheme);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const query = matchMedia('(prefers-color-scheme: dark)');
|
|
21
|
+
const update = () => setTheme(initialEditorTheme());
|
|
22
|
+
query.addEventListener('change', update);
|
|
23
|
+
window.addEventListener('storage', update);
|
|
24
|
+
return () => {
|
|
25
|
+
query.removeEventListener('change', update);
|
|
26
|
+
window.removeEventListener('storage', update);
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
const toggleTheme = () => {
|
|
30
|
+
const next = theme === 'dark' ? 'light' : 'dark';
|
|
31
|
+
try { localStorage.setItem(THEME_KEY, next); } catch { /* 今回の表示には適用する */ }
|
|
32
|
+
setTheme(next);
|
|
33
|
+
};
|
|
34
|
+
return { theme, toggleTheme };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PortalはDOM上の親から継承できないためReactの文脈から同じスキンを付ける。 */
|
|
38
|
+
export function useEditorAppearance() {
|
|
39
|
+
const theme = useContext(EditorAppearanceContext);
|
|
40
|
+
return {
|
|
41
|
+
className: theme ? 'gg-editor-skin gg-editor-ui' : undefined,
|
|
42
|
+
'data-editor-theme': theme,
|
|
43
|
+
};
|
|
44
|
+
}
|