@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.
Files changed (52) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +189 -43
  13. package/src/editor/components/EditorCanvas.tsx +8 -3
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useCanvasControls.ts +3 -2
  34. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  35. package/src/editor/hooks/useDragResize.ts +86 -6
  36. package/src/editor/hooks/useDrawingMode.ts +4 -2
  37. package/src/editor/hooks/useElementActions.ts +60 -1
  38. package/src/editor/hooks/useElementSelection.ts +35 -8
  39. package/src/editor/hooks/useIframeSetup.ts +10 -0
  40. package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
  41. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  42. package/src/editor/hooks/useResizablePanel.ts +40 -11
  43. package/src/editor/parts.ts +321 -0
  44. package/src/editor/utils/dom-utils.ts +135 -15
  45. package/src/editor/utils/flex-reorder.ts +268 -43
  46. package/src/editor/utils/geometry.ts +4 -3
  47. package/src/editor/utils/html-utils.ts +10 -5
  48. package/src/hooks/useEditorShortcuts.ts +39 -2
  49. package/src/index.ts +25 -0
  50. package/src/io.ts +54 -0
  51. package/src/styles/skin.css +159 -0
  52. 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 { readApiJson } from '../../utils/api-json';
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
- fetch('/__presenter-script')
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 fetch('/__presenter-script/segment', {
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 (await fetch('/__presenter-script')).json();
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 res = await fetch('/__script-gen', { method: 'POST', body: JSON.stringify(payload) });
117
- const { jobId, error } = await readApiJson<{ jobId?: string; error?: string }>(res);
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 readApiJson<{ state: string; message?: string; error?: string }>(
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-6 w-full items-center gap-1.5 px-3 text-[11px]">
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: '#2c2c2c',
50
- text: '#ffffff',
51
- sub: '#9ca3af',
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: '#0d99ff', fg: '#ffffff' },
62
- ppt: { bg: '#E8912D', fg: '#33230a' },
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
- window.alert(`書き出しに失敗しました: ${String(e).slice(0, 120)}`);
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 = 'flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors disabled:opacity-40';
153
- const textBtn = 'flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors';
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
- ? 'Figma風UIに切り替え(編集エンジンは共通)'
189
- : 'PowerPoint風UIに切り替え(編集エンジンは共通)'
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' ? 'Figma風' : '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 justify-center gap-2">
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 || !hasChanges}
228
- title={saving ? '保存中…' : hasChanges ? '今すぐ保存' : '保存済みです(変更があると自動でも保存されます)'}
229
- className={iconBtn}
230
- style={{ color: saving || !hasChanges ? pal.disabled : pal.text }}
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: { dot: 'bg-emerald-400', box: 'bg-emerald-400/10 text-emerald-300 ring-emerald-400/25', title: '自動保存済みです' },
377
- dirty: { dot: 'bg-amber-400', box: 'bg-amber-400/15 text-amber-300 ring-amber-400/30', title: '変更が止まると自動で保存されます' },
378
- saving: { dot: 'bg-sky-400 animate-pulse', box: 'bg-sky-400/15 text-sky-300 ring-sky-400/30', title: '保存しています' },
379
- error: { dot: 'bg-rose-400', box: 'bg-rose-500/15 text-rose-300 ring-rose-400/30', title: '自動保存に失敗しました。保存ボタンで再試行してください' },
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
- data-topbar="save-status"
384
- data-save-status={status}
385
- title={view.title}
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
- "use client";
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: 256,
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
- <div
49
- className="relative flex shrink-0 flex-col overflow-hidden border-r border-[#444444] bg-[#2c2c2c]"
50
- style={{ width: `${width}px` }}
51
- >
52
- {pagesSlot ? (
53
- <>
54
- <div
55
- role="tablist"
56
- aria-label="左パネル"
57
- className="flex shrink-0 items-stretch gap-1 border-b border-[#444444] px-1"
58
- >
59
- {([
60
- ['pages', 'ページ'],
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
- </div>
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 ? '#4fb8ff' : '#6b7280', fontWeight: current ? 700 : 400 }}
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
+ }