@growgroup/visual-editor 0.1.6 → 0.2.1

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 (42) 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 +157 -43
  13. package/src/editor/components/EditorCanvas.tsx +1 -1
  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/useComponentEditMode.ts +93 -1
  34. package/src/editor/hooks/useIframeSetup.ts +10 -0
  35. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  36. package/src/editor/hooks/useResizablePanel.ts +40 -11
  37. package/src/editor/parts.ts +321 -0
  38. package/src/editor/utils/dom-utils.ts +5 -4
  39. package/src/index.ts +25 -0
  40. package/src/io.ts +54 -0
  41. package/src/styles/skin.css +159 -0
  42. package/src/types/editor-components.ts +23 -1
@@ -14,7 +14,7 @@
14
14
  * 実機に無い独自ボタンを足さない。実機にあるが未対応の機能はタブごと無効表示にする。
15
15
  */
16
16
 
17
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
+ import { forwardRef, lazy, Suspense, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
18
  import { createPortal } from 'react-dom';
19
19
  import { PEN_PRESETS, inkStyle, setInkPreset, setInkColor } from '../../utils/ink-style';
20
20
  import { generateElementId, updateSelectionBox } from '../../utils/dom-utils';
@@ -29,7 +29,7 @@ import {
29
29
  Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
30
30
  Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play,
31
31
  Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
32
- MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
32
+ MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
33
33
  PaintBucket, PenLine, Eraser, Highlighter, Table, Shapes, Sticker, Wand2,
34
34
  AArrowUp, AArrowDown, RemoveFormatting, Strikethrough, Superscript, Subscript,
35
35
  List, ListOrdered, IndentIncrease, IndentDecrease, AlignJustify, Baseline, RotateCw,
@@ -40,13 +40,21 @@ import {
40
40
  import { useEditorContext } from '../../EditorContext';
41
41
  import { flushAutoSave } from '../../autosave';
42
42
  import { useDeck, refreshDeck } from '../../../components/viewer/useDeck';
43
- import { moveSlide, deleteSlide, duplicateSlide, updateSlideMeta } from '../../../lib/deck';
43
+ import { moveSlide, deleteSlide, duplicateSlide, updateSlideMeta, insertSlide } from '../../../lib/deck';
44
44
  import { startCleanup } from '../../../components/SaveNote';
45
45
  import { unresolvedCount } from './PptComments';
46
46
  import { enterCropMode, type CropSession } from '../../utils/crop-mode';
47
47
  import { useGoogleFonts } from '../../hooks/useGoogleFonts';
48
48
  import { DeckSlideRender } from '../../../components/DeckSlideRender';
49
- import { PptDesignProposals } from './PptDesignProposals';
49
+ const PptDesignProposals = lazy(() => import('./PptDesignProposals').then((m) => ({ default: m.PptDesignProposals })));
50
+ import * as RibbonTabs from '@radix-ui/react-tabs';
51
+ import { Popover, PopoverTrigger, PopoverContent } from '../../../components/ui/popover';
52
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '../../../components/ui/dropdown-menu';
53
+ import { useResizablePanel } from '../../hooks/useResizablePanel';
54
+ import { ConfirmDialog } from '../shell/ConfirmDialog';
55
+ import { can, io } from '../../../io';
56
+ import { MIN_ZOOM, MAX_ZOOM, editorZoomApiRef } from '../../hooks/useCanvasControls';
57
+ import { toast } from 'sonner';
50
58
  import { EditorTopBar } from '../shell/EditorTopBar';
51
59
 
52
60
  /**
@@ -59,15 +67,7 @@ import type { EditorTool } from '../../../types/editor';
59
67
  export type PptTheme = 'light' | 'dark';
60
68
 
61
69
  /** OS設定に追従した初期テーマ(切替後はlocalStorageを優先) */
62
- export function initialPptTheme(): PptTheme {
63
- try {
64
- const saved = localStorage.getItem('gg-editor:ppt-theme');
65
- if (saved === 'light' || saved === 'dark') return saved;
66
- } catch { /* 記憶が読めなくても続行 */ }
67
- return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
68
- ? 'dark'
69
- : 'light';
70
- }
70
+ export { initialEditorTheme as initialPptTheme } from '../../contexts/EditorAppearanceContext';
71
71
 
72
72
  /** FVE(FrontendVisualEditor)のスコープから借りる操作群 */
73
73
  export type PptActions = {
@@ -96,7 +96,7 @@ export type PptActions = {
96
96
 
97
97
  /* ============================ テーマ ============================ */
98
98
 
99
- const PPT_ACCENT = '#ED6C47'; // PowerPointのブランド橙赤(選択枠・タブ下線)
99
+ const PPT_ACCENT = 'var(--ed-accent)'; // PowerPointのブランド橙赤(選択枠・タブ下線)
100
100
 
101
101
  type Palette = {
102
102
  chrome: string; text: string; sub: string; border: string;
@@ -106,14 +106,14 @@ type Palette = {
106
106
 
107
107
  const PALETTES: Record<PptTheme, Palette> = {
108
108
  light: {
109
- chrome: '#f6f5f4', text: '#252423', sub: '#8a8886', border: '#e1dfdd',
110
- hover: '#e6e4e2', activeBg: '#dedcda', control: '#ffffff', rail: '#f0efee',
111
- canvas: '#e9e7e6', disabled: '#b8b6b4',
109
+ chrome: '#ffffff', text: '#202938', sub: '#566477', border: '#dce2ea',
110
+ hover: '#e8edf4', activeBg: '#eaf0ff', control: '#f3f5f8', rail: '#ffffff',
111
+ canvas: '#eef1f5', disabled: '#8592a3',
112
112
  },
113
113
  dark: {
114
- chrome: '#282828', text: '#e8e6e3', sub: '#9d9b99', border: '#3d3b39',
115
- hover: '#3a3a3a', activeBg: '#4a4a4a', control: '#333333', rail: '#222222',
116
- canvas: '#3f3f3f', disabled: '#5f5d5b',
114
+ chrome: '#242932', text: '#f0f3f8', sub: '#b4bfce', border: '#3b4554',
115
+ hover: '#3c4554', activeBg: '#303e59', control: '#303744', rail: '#242932',
116
+ canvas: '#191d24', disabled: '#7e8ca0',
117
117
  },
118
118
  };
119
119
 
@@ -122,9 +122,7 @@ export const PPT_PALETTES = PALETTES;
122
122
  /* ============================ 小物 ============================ */
123
123
 
124
124
  /** リボンの大ボタン(アイコン上・ラベル下。実機の「新しいスライド」等の形) */
125
- function BigButton({
126
- icon: Icon, label, onClick, disabled, active, title, caret, pal,
127
- }: {
125
+ const BigButton = forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & {
128
126
  icon: React.ComponentType<{ className?: string }>;
129
127
  label: string;
130
128
  onClick?: () => void;
@@ -133,9 +131,15 @@ function BigButton({
133
131
  title?: string;
134
132
  caret?: boolean;
135
133
  pal: Palette;
136
- }) {
134
+ }>(function BigButton({
135
+ icon: Icon, label, onClick, disabled, active, title, caret, pal,
136
+ ...triggerProps
137
+ }, ref) {
137
138
  return (
138
139
  <button
140
+ {...triggerProps}
141
+ ref={ref}
142
+ aria-pressed={active}
139
143
  onClick={onClick}
140
144
  disabled={disabled}
141
145
  title={title ?? label}
@@ -154,12 +158,10 @@ function BigButton({
154
158
  </span>
155
159
  </button>
156
160
  );
157
- }
161
+ });
158
162
 
159
163
  /** リボンの小ボタン(正方形アイコン) */
160
- function SmallButton({
161
- icon: Icon, onClick, disabled, active, title, pal, label, chevron,
162
- }: {
164
+ const SmallButton = forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & {
163
165
  icon: React.ComponentType<{ className?: string }>;
164
166
  onClick?: () => void;
165
167
  disabled?: boolean;
@@ -169,13 +171,19 @@ function SmallButton({
169
171
  /** アイコン横に出すラベル(塗りつぶし等、ドロップダウンのトリガー用) */
170
172
  label?: string;
171
173
  chevron?: boolean;
172
- }) {
174
+ }>(function SmallButton({
175
+ icon: Icon, onClick, disabled, active, title, pal, label, chevron,
176
+ ...triggerProps
177
+ }, ref) {
173
178
  return (
174
179
  <button
180
+ {...triggerProps}
181
+ ref={ref}
182
+ aria-pressed={active}
175
183
  onClick={onClick}
176
184
  disabled={disabled}
177
185
  title={title}
178
- className={`flex h-6 items-center justify-center gap-1 rounded transition-colors ${label ? 'px-1.5' : 'w-6'}`}
186
+ className={`flex h-7 items-center justify-center gap-1 rounded transition-colors ${label ? 'px-1.5' : 'w-6'}`}
179
187
  style={{
180
188
  color: disabled ? pal.disabled : pal.text,
181
189
  backgroundColor: active ? pal.activeBg : undefined,
@@ -188,7 +196,7 @@ function SmallButton({
188
196
  {chevron && <ChevronDown className="h-3 w-3" />}
189
197
  </button>
190
198
  );
191
- }
199
+ });
192
200
 
193
201
  /** 縦書きボタン用(Typeを90度回して代用) */
194
202
  function TypeVertical({ className }: { className?: string }) {
@@ -230,76 +238,22 @@ function Dropdown({
230
238
  align?: 'left' | 'right';
231
239
  }) {
232
240
  const [open, setOpen] = useState(false);
233
- const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
234
- const ref = useRef<HTMLDivElement>(null);
235
- const menuRef = useRef<HTMLDivElement>(null);
236
-
237
- // 閉じるのは「外側クリック」と Esc だけ。色を選ぶたびに閉じると連続調整ができない
238
- useEffect(() => {
239
- if (!open) return;
240
- const onDown = (e: Event) => {
241
- const t = e.target as Node;
242
- // iframe内のクリックは親ページのノードを含まないので、そのまま外側扱いになる
243
- if (!ref.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false);
244
- };
245
- const onKey = (e: Event) => {
246
- if ((e as KeyboardEvent).key === 'Escape') setOpen(false);
247
- };
248
- const docs = listenDocs();
249
- for (const d of docs) {
250
- d.addEventListener('mousedown', onDown);
251
- d.addEventListener('keydown', onKey);
252
- }
253
- return () => {
254
- for (const d of docs) {
255
- d.removeEventListener('mousedown', onDown);
256
- d.removeEventListener('keydown', onKey);
257
- }
258
- };
259
- }, [open]);
260
-
261
- const toggle = () => {
262
- if (!open && ref.current) {
263
- // リボン帯は overflow-x-auto でメニューがクリップされるため、
264
- // body直下へポータルし fixed で出す(iframeにも隠れない)
265
- const r = ref.current.getBoundingClientRect();
266
- setPos({
267
- left: align === 'right' ? Math.max(8, r.right - 176) : r.left,
268
- top: r.bottom + 4,
269
- });
270
- }
271
- setOpen((v) => !v);
272
- };
273
-
241
+ if (content) return (
242
+ <Popover open={open} onOpenChange={setOpen}>
243
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
244
+ <PopoverContent data-ppt-menu="1" align={align === 'right' ? 'end' : 'start'} className="w-auto min-w-[176px] p-2" collisionPadding={8}>
245
+ {content(() => setOpen(false))}
246
+ {(items ?? []).map((item, i) => <button key={i} disabled={item.disabled} className="block w-full rounded px-3 py-2 text-left text-xs hover:bg-accent" onClick={() => { setOpen(false); item.onClick?.(); }}>{item.label}</button>)}
247
+ </PopoverContent>
248
+ </Popover>
249
+ );
274
250
  return (
275
- <div ref={ref} className="relative">
276
- <div onClick={toggle}>{trigger}</div>
277
- {open && pos &&
278
- createPortal(
279
- <div
280
- ref={menuRef}
281
- data-ppt-menu="1"
282
- className="fixed z-[10000] min-w-[176px] rounded-md border py-1 shadow-xl"
283
- style={{ left: pos.left, top: pos.top, backgroundColor: pal.control, borderColor: pal.border }}
284
- >
285
- {content && content(() => setOpen(false))}
286
- {(items ?? []).map((it, i) => (
287
- <button
288
- key={i}
289
- disabled={it.disabled}
290
- onClick={() => { setOpen(false); it.onClick?.(); }}
291
- className="block w-full px-3 py-1.5 text-left text-[12px] transition-colors"
292
- style={{ color: it.disabled ? pal.disabled : pal.text }}
293
- onMouseEnter={(e) => { if (!it.disabled) e.currentTarget.style.backgroundColor = pal.hover; }}
294
- onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
295
- >
296
- {it.label}
297
- </button>
298
- ))}
299
- </div>,
300
- document.body,
301
- )}
302
- </div>
251
+ <DropdownMenu open={open} onOpenChange={setOpen}>
252
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
253
+ <DropdownMenuContent data-ppt-menu="1" align={align === 'right' ? 'end' : 'start'} collisionPadding={8}>
254
+ {(items ?? []).map((item, i) => <DropdownMenuItem key={i} disabled={item.disabled} onSelect={item.onClick}>{item.label}</DropdownMenuItem>)}
255
+ </DropdownMenuContent>
256
+ </DropdownMenu>
303
257
  );
304
258
  }
305
259
 
@@ -411,36 +365,6 @@ export function PptTitleBar({
411
365
  actions: PptActions;
412
366
  }) {
413
367
  const pal = PALETTES[theme];
414
- const { getIframeDoc } = useEditorContext();
415
-
416
- // テーマに合わせてiframe内のキャンバス背景も塗り替える(中身はUI共通生成のため殻側から)
417
- useEffect(() => {
418
- let tries = 0;
419
- const paint = () => {
420
- const doc = getIframeDoc();
421
- const container = doc?.getElementById('canvas-container');
422
- if (container) {
423
- container.style.backgroundColor = pal.canvas;
424
- doc!.body.style.backgroundColor = pal.canvas;
425
- return true;
426
- }
427
- return false;
428
- };
429
- const timer = setInterval(() => {
430
- if (paint() || ++tries > 20) clearInterval(timer);
431
- }, 300);
432
- paint();
433
- return () => {
434
- clearInterval(timer);
435
- const doc = getIframeDoc();
436
- const container = doc?.getElementById('canvas-container');
437
- if (container) {
438
- container.style.backgroundColor = '#1a1a1a';
439
- doc!.body.style.backgroundColor = '#1a1a1a';
440
- }
441
- };
442
- }, [getIframeDoc, pal.canvas]);
443
-
444
368
  // 1段目の中身はFigma風UIと共通(EditorTopBar)。
445
369
  // 保存・共有・プレビュー・UI切替・閉じるの並びを両UIで揃えるため、
446
370
  // ここが持つのは PowerPoint 固有の操作(元に戻す/やり直し・検索・テーマ)だけ
@@ -463,14 +387,14 @@ export function PptTitleBar({
463
387
  rightExtra={
464
388
  <>
465
389
  <label
466
- className="flex h-6 items-center gap-1.5 rounded-md border px-2"
390
+ className="flex h-8 items-center gap-2 rounded-md border px-2"
467
391
  style={{ borderColor: pal.border, backgroundColor: pal.control }}
468
392
  >
469
393
  <Search className="h-3 w-3" style={{ color: pal.sub }} />
470
394
  <input
471
395
  value={search}
472
396
  onChange={(e) => onSearch(e.target.value)}
473
- placeholder="スライドを検索"
397
+ placeholder="スライドを検索" aria-label="スライドを検索"
474
398
  className="w-28 bg-transparent text-[11px] outline-none"
475
399
  style={{ color: pal.text }}
476
400
  />
@@ -1397,12 +1321,13 @@ export function PptRibbon({
1397
1321
  if (!(await flushAutoSave())) {
1398
1322
  if (!window.confirm('保存に失敗しました。変更を破棄して新しいスライドへ移動しますか?')) return;
1399
1323
  }
1400
- await fetch('/__deck/insert', {
1401
- method: 'POST',
1402
- body: JSON.stringify({ template: 'lib:Slide000', at: page }),
1403
- });
1404
- await refreshDeck().catch(() => undefined);
1405
- window.location.hash = `#/edit/${page + 1}`;
1324
+ try {
1325
+ await insertSlide('lib:Slide000', page);
1326
+ await refreshDeck();
1327
+ window.location.hash = `#/edit/${page + 1}`;
1328
+ } catch (error) {
1329
+ toast.error('スライドを追加できませんでした', { description: String(error) });
1330
+ }
1406
1331
  };
1407
1332
 
1408
1333
  const openSlideshow = () => {
@@ -1413,11 +1338,12 @@ export function PptRibbon({
1413
1338
  const inputStyle = { backgroundColor: pal.control, borderColor: pal.border, color: pal.text } as const;
1414
1339
 
1415
1340
  return (
1416
- <div style={{ backgroundColor: pal.chrome, color: pal.text }}>
1341
+ <RibbonTabs.Root value={tab} onValueChange={(value) => setTab(value as TabId)} className="ed-ribbon" style={{ backgroundColor: pal.chrome, color: pal.text }}>
1417
1342
  {/* タブ帯 */}
1418
- <div className="flex items-center gap-0.5 border-b px-2" style={{ borderColor: pal.border }}>
1419
- {TABS.map((t) => (
1420
- <button
1343
+ <div className="flex items-center gap-2 border-b px-4" style={{ borderColor: pal.border }}>
1344
+ <RibbonTabs.List aria-label="編集メニュー" className="flex min-w-0 flex-1 items-center overflow-x-auto">
1345
+ {TABS.filter((t) => t.enabled && (t.id !== "review" || can("commentAction")) && (t.id !== "transition" || !!io().deckOps?.updateMeta) && (t.id !== "slideshow" || can("exportDeck"))).map((t) => (
1346
+ <RibbonTabs.Trigger value={t.id}
1421
1347
  key={t.id}
1422
1348
  disabled={!t.enabled}
1423
1349
  onClick={() => setTab(t.id)}
@@ -1432,10 +1358,10 @@ export function PptRibbon({
1432
1358
  style={{ backgroundColor: theme === 'dark' ? '#ffffff' : PPT_ACCENT }}
1433
1359
  />
1434
1360
  )}
1435
- </button>
1361
+ </RibbonTabs.Trigger>
1436
1362
  ))}
1437
1363
  {selectedImage && (
1438
- <button
1364
+ <RibbonTabs.Trigger value="pictureformat"
1439
1365
  onClick={() => setTab('pictureformat')}
1440
1366
  className="relative whitespace-nowrap px-2.5 py-1.5 text-[12px] transition-colors"
1441
1367
  style={{ color: tab === 'pictureformat' ? PPT_ACCENT : pal.sub, fontWeight: 500 }}
@@ -1445,10 +1371,10 @@ export function PptRibbon({
1445
1371
  {tab === 'pictureformat' && (
1446
1372
  <span className="absolute bottom-0 left-2 right-2 h-[2.5px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
1447
1373
  )}
1448
- </button>
1374
+ </RibbonTabs.Trigger>
1449
1375
  )}
1450
1376
  {hasSelection && (
1451
- <button
1377
+ <RibbonTabs.Trigger value="shapeformat"
1452
1378
  onClick={() => setTab('shapeformat')}
1453
1379
  className="relative whitespace-nowrap px-2.5 py-1.5 text-[12px] transition-colors"
1454
1380
  style={{ color: tab === 'shapeformat' ? PPT_ACCENT : pal.sub, fontWeight: 500 }}
@@ -1458,10 +1384,12 @@ export function PptRibbon({
1458
1384
  {tab === 'shapeformat' && (
1459
1385
  <span className="absolute bottom-0 left-2 right-2 h-[2.5px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
1460
1386
  )}
1461
- </button>
1387
+ </RibbonTabs.Trigger>
1462
1388
  )}
1463
- <div className="ml-auto flex items-center gap-1.5 py-1">
1389
+ </RibbonTabs.List>
1390
+ {can("commentAction") && <div className="ml-auto flex items-center gap-1.5 py-1">
1464
1391
  <button
1392
+ aria-pressed={comments.open}
1465
1393
  onClick={comments.toggle}
1466
1394
  title="コメントパネルの表示/非表示"
1467
1395
  className="flex items-center gap-1 rounded-md border px-2 py-1 text-[12px]"
@@ -1481,17 +1409,17 @@ export function PptRibbon({
1481
1409
  </button>
1482
1410
  {/* 共有(書き出し)は共通トップバー(EditorTopBar)に一本化した。
1483
1411
  ここに置くとタイトルバーと二重になる */}
1484
- </div>
1412
+ </div>}
1485
1413
  </div>
1486
1414
 
1487
1415
  {/* リボン本体 */}
1488
- <div
1489
- className="flex h-[80px] shrink-0 items-center gap-0 overflow-x-auto overflow-y-hidden border-b px-2"
1416
+ <RibbonTabs.Content value={tab}
1417
+ className="flex h-[96px] shrink-0 items-center gap-0 overflow-x-auto overflow-y-hidden border-b px-2"
1490
1418
  style={{ borderColor: pal.border }}
1491
1419
  >
1492
1420
  {tab === 'home' && (
1493
1421
  <>
1494
- <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} title="白紙スライドをこの後ろに挿入" pal={pal} />
1422
+ {io().deckOps?.insert && <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} title="白紙スライドをこの後ろに挿入" pal={pal} />}
1495
1423
  <Sep pal={pal} />
1496
1424
  <div className="flex flex-col justify-center gap-1 px-1">
1497
1425
  <div className="flex items-center gap-1">
@@ -1718,7 +1646,7 @@ export function PptRibbon({
1718
1646
 
1719
1647
  {tab === 'insert' && (
1720
1648
  <>
1721
- <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} pal={pal} />
1649
+ {io().deckOps?.insert && <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} pal={pal} />}
1722
1650
  <Sep pal={pal} />
1723
1651
  <Dropdown
1724
1652
  pal={pal}
@@ -1729,7 +1657,7 @@ export function PptRibbon({
1729
1657
  />
1730
1658
  <Sep pal={pal} />
1731
1659
  <BigButton icon={ImageIcon} label="画像" onClick={actions.openFilePicker} pal={pal} />
1732
- <BigButton icon={LayoutGrid} label="メディア" onClick={actions.openMediaLibrary} title="メディアライブラリ" pal={pal} />
1660
+ {can("apiFetch") && <BigButton icon={LayoutGrid} label="メディア" onClick={actions.openMediaLibrary} title="メディアライブラリ" pal={pal} />}
1733
1661
  <Sep pal={pal} />
1734
1662
  <Dropdown
1735
1663
  pal={pal}
@@ -1806,7 +1734,7 @@ export function PptRibbon({
1806
1734
  <>
1807
1735
  <BigButton icon={Palette} label={'バリア\nブル'} onClick={actions.openVariables} title="デザイントークン(CSS変数)を編集" pal={pal} />
1808
1736
  <Sep pal={pal} />
1809
- <BigButton
1737
+ {can("notifySave") && <BigButton
1810
1738
  icon={Sparkles}
1811
1739
  label={'デザイ\nナー'}
1812
1740
  onClick={() => {
@@ -1816,14 +1744,14 @@ export function PptRibbon({
1816
1744
  }}
1817
1745
  title="AIで清書(TSX化)"
1818
1746
  pal={pal}
1819
- />
1820
- <BigButton
1747
+ />}
1748
+ {can("apiFetch") && <BigButton
1821
1749
  icon={Wand2}
1822
1750
  label={'デザイン\n提案'}
1823
1751
  onClick={() => setDesignOpen(true)}
1824
1752
  title="デザイン案を画像で提案し、選んだ案でスライドを書き直す"
1825
1753
  pal={pal}
1826
- />
1754
+ />}
1827
1755
  <Sep pal={pal} />
1828
1756
  <div className="flex flex-col justify-center px-2 text-[11px]" style={{ color: pal.sub }}>
1829
1757
  <span>スライドのサイズ</span>
@@ -1853,7 +1781,7 @@ export function PptRibbon({
1853
1781
  />
1854
1782
  ))}
1855
1783
  <Sep pal={pal} />
1856
- <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />
1784
+ {can("exportDeck") && <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />}
1857
1785
  <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1858
1786
  表示モード(スライドショー)で再生されます
1859
1787
  </div>
@@ -1881,7 +1809,7 @@ export function PptRibbon({
1881
1809
  />
1882
1810
  ))}
1883
1811
  <Sep pal={pal} />
1884
- <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />
1812
+ {can("exportDeck") && <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />}
1885
1813
  <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1886
1814
  {hasSelection ? '文書順に少しずつ遅れて出現します' : '要素を選択してください'}
1887
1815
  </div>
@@ -1925,7 +1853,7 @@ export function PptRibbon({
1925
1853
  {tab === 'slideshow' && (
1926
1854
  <>
1927
1855
  <BigButton icon={Play} label={'最初から\n再生'} onClick={() => window.open(`${window.location.origin}${window.location.pathname}#/1?clean`, '_blank')} pal={pal} />
1928
- <BigButton icon={Play} label={'この\nスライドから'} onClick={openSlideshow} pal={pal} />
1856
+ {can("exportDeck") && <BigButton icon={Play} label={'この\nスライドから'} onClick={openSlideshow} pal={pal} />}
1929
1857
  <Sep pal={pal} />
1930
1858
  <BigButton
1931
1859
  icon={Monitor}
@@ -2244,21 +2172,21 @@ export function PptRibbon({
2244
2172
 
2245
2173
  {tab === 'view' && (
2246
2174
  <>
2247
- <BigButton icon={ZoomIn} label="拡大" onClick={() => setZoom(Math.min(300, zoom + 10))} pal={pal} />
2248
- <BigButton icon={ZoomOut} label="縮小" onClick={() => setZoom(Math.max(10, zoom - 10))} pal={pal} />
2249
- <BigButton icon={Maximize} label={'画面に\n合わせる'} onClick={() => setZoom(fitZoom)} pal={pal} />
2175
+ <BigButton icon={ZoomIn} label="拡大" disabled={zoom >= MAX_ZOOM} onClick={() => setZoom(Math.min(MAX_ZOOM, zoom + 10))} pal={pal} />
2176
+ <BigButton icon={ZoomOut} label="縮小" disabled={zoom <= MIN_ZOOM} onClick={() => setZoom(Math.max(MIN_ZOOM, zoom - 10))} pal={pal} />
2177
+ <BigButton icon={Maximize} label={'画面に\n合わせる'} onClick={() => { const api = editorZoomApiRef.current; if (api) api.fit(); else setZoom(fitZoom); }} pal={pal} />
2250
2178
  <Sep pal={pal} />
2251
2179
  <BigButton icon={theme === 'dark' ? Sun : Moon} label={theme === 'dark' ? 'ライト' : 'ダーク'} onClick={onToggleTheme} pal={pal} />
2252
- <BigButton icon={PenTool} label={'Figma風\nUIへ'} onClick={onSwitchUi} pal={pal} />
2180
+ <BigButton icon={PenTool} label={'パネル\n表示'} onClick={onSwitchUi} pal={pal} />
2253
2181
  <Sep pal={pal} />
2254
- <BigButton icon={Play} label={'スライド\nショー'} onClick={openSlideshow} title={`${deckTitle ?? ''} を表示モードで開く`} pal={pal} />
2182
+ {can("exportDeck") && <BigButton icon={Play} label={'スライド\nショー'} onClick={openSlideshow} title={`${deckTitle ?? ''} を表示モードで開く`} pal={pal} />}
2255
2183
  </>
2256
2184
  )}
2257
- </div>
2258
- {designOpen && (
2185
+ </RibbonTabs.Content>
2186
+ {designOpen && (<Suspense fallback={<span role="status">読み込み中…</span>}>
2259
2187
  <PptDesignProposals page={page} pal={pal} onClose={() => setDesignOpen(false)} />
2260
- )}
2261
- </div>
2188
+ </Suspense>)}
2189
+ </RibbonTabs.Root>
2262
2190
  );
2263
2191
  }
2264
2192
 
@@ -2267,208 +2195,91 @@ export function PptRibbon({
2267
2195
  export function PptThumbnails({ page, theme, search }: { page: number; theme: PptTheme; search: string }) {
2268
2196
  const pal = PALETTES[theme];
2269
2197
  const deck = useDeck();
2198
+ const ops = io().deckOps;
2270
2199
  const currentRef = useRef<HTMLButtonElement>(null);
2271
- /** 右クリックメニュー(PowerPointと同じ操作面) */
2272
- const [menu, setMenu] = useState<{ page: number; x: number; y: number } | null>(null);
2273
- /** ドラッグ並び替えの状態 */
2274
2200
  const [dragFrom, setDragFrom] = useState<number | null>(null);
2275
2201
  const [dropAt, setDropAt] = useState<number | null>(null);
2276
2202
  const [busy, setBusy] = useState(false);
2277
-
2278
- useEffect(() => {
2279
- currentRef.current?.scrollIntoView({ block: 'nearest' });
2280
- }, [page]);
2281
-
2282
- useEffect(() => {
2283
- if (!menu) return;
2284
- const close = () => setMenu(null);
2285
- window.addEventListener('mousedown', close);
2286
- return () => window.removeEventListener('mousedown', close);
2287
- }, [menu]);
2203
+ const [deletePage, setDeletePage] = useState<number | null>(null);
2204
+ const { width, isDragging, resizeHandleProps } = useResizablePanel({ initialWidth: 224, minWidth: 192, maxWidth: 320, direction: 'right', storageKey: 'gg-editor:slides-width' });
2205
+ const thumbWidth = width - 48;
2206
+ useEffect(() => { currentRef.current?.scrollIntoView({ block: 'nearest' }); }, [page]);
2288
2207
 
2289
2208
  const goto = async (n: number) => {
2290
2209
  if (n === page) return;
2291
- // 未保存があれば黙って保存してから移る。保存できなかったときだけ従来の確認に落とす
2292
2210
  if (!(await flushAutoSave())) {
2293
2211
  if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) return;
2294
2212
  }
2295
2213
  window.location.hash = `#/edit/${n}`;
2296
2214
  };
2297
-
2298
- /** デッキ操作の共通処理。操作後にページ番号のずれを追随する */
2299
2215
  const run = async (fn: () => Promise<unknown>, nextPage?: number) => {
2300
2216
  if (busy) return;
2301
2217
  setBusy(true);
2302
2218
  try {
2219
+ if (!(await flushAutoSave())) throw new Error('編集中の内容を保存してから、もう一度お試しください。');
2303
2220
  await fn();
2304
2221
  await refreshDeck();
2305
- if (nextPage && nextPage !== page) {
2306
- window.location.hash = `#/edit/${nextPage}`;
2307
- } else {
2308
- // 番号は同じでも中身が別のスライドになっている(現在ページの削除や
2309
- // 手前の複製)。エディタに現在ページの読み直しを頼む
2310
- window.dispatchEvent(new CustomEvent('gg:deck-mutated'));
2311
- }
2222
+ if (nextPage && nextPage !== page) window.location.hash = `#/edit/${nextPage}`;
2223
+ else window.dispatchEvent(new CustomEvent('gg:deck-mutated'));
2312
2224
  } catch (e) {
2313
- window.alert(`操作に失敗しました: ${String(e).slice(0, 120)}`);
2314
- } finally {
2315
- setBusy(false);
2316
- }
2225
+ toast.error('操作に失敗しました', { description: String(e).slice(0, 160) });
2226
+ } finally { setBusy(false); }
2317
2227
  };
2318
-
2319
2228
  const doMove = (from: number, to: number) => {
2320
- if (to < 1 || to > deck.slides.length || from === to) return;
2321
- // 編集中のページ自身を動かしたら追随、他ページの移動で自分の番号がずれたら補正
2229
+ if (!ops?.move || to < 1 || to > deck.slides.length || from === to) return;
2322
2230
  let next = page;
2323
2231
  if (from === page) next = to;
2324
2232
  else if (from < page && to >= page) next = page - 1;
2325
2233
  else if (from > page && to <= page) next = page + 1;
2326
2234
  void run(() => moveSlide(from, to), next);
2327
2235
  };
2328
-
2329
- const doDelete = (n: number) => {
2330
- if (!window.confirm(`${n}枚目「${deck.slides[n - 1]?.title ?? ''}」を削除しますか?`)) return;
2331
- const next = n === page ? Math.max(1, Math.min(page, deck.slides.length - 1)) : n < page ? page - 1 : page;
2332
- void run(() => deleteSlide(n), next);
2333
- };
2334
-
2335
- const doDuplicate = (n: number) => {
2336
- const next = n < page ? page + 1 : page;
2337
- void run(() => duplicateSlide(n), next);
2338
- };
2339
-
2340
- const doToggleHidden = (n: number) => {
2341
- const hidden = !deck.slides[n - 1]?.hidden;
2342
- void run(() => updateSlideMeta(n, { hidden }));
2343
- };
2344
-
2345
- const q = search.trim();
2346
- const menuEntry = menu ? deck.slides[menu.page - 1] : null;
2236
+ const q = search.trim().toLocaleLowerCase();
2237
+ const slides = deck.slides.map((slide, i) => ({ slide, n: i + 1 })).filter(({ slide, n }) => !q || slide.title.toLocaleLowerCase().includes(q) || String(n) === q);
2238
+ const hasMenu = !!(ops?.move || ops?.duplicate || ops?.remove || ops?.updateMeta);
2347
2239
 
2348
2240
  return (
2349
- <div
2350
- className="relative w-[176px] shrink-0 overflow-y-auto border-r py-1.5"
2351
- style={{ backgroundColor: pal.rail, borderColor: pal.border, opacity: busy ? 0.6 : 1 }}
2352
- >
2353
- {deck.slides.map((s, i) => {
2354
- const n = i + 1;
2355
- if (q && !(s.title ?? '').includes(q) && String(n) !== q) return null;
2356
- const current = n === page;
2357
- return (
2358
- <button
2359
- key={s.id}
2360
- ref={current ? currentRef : undefined}
2361
- draggable
2362
- onDragStart={(e) => {
2363
- setDragFrom(n);
2364
- e.dataTransfer.effectAllowed = 'move';
2365
- }}
2366
- onDragOver={(e) => {
2367
- e.preventDefault();
2368
- // カーソルが項目の上半分なら手前、下半分なら後ろへ挿す
2369
- const r = e.currentTarget.getBoundingClientRect();
2370
- setDropAt(e.clientY < r.top + r.height / 2 ? n : n + 1);
2371
- }}
2372
- onDragEnd={() => {
2373
- if (dragFrom !== null && dropAt !== null) {
2374
- const to = dropAt > dragFrom ? dropAt - 1 : dropAt;
2375
- doMove(dragFrom, to);
2376
- }
2377
- setDragFrom(null);
2378
- setDropAt(null);
2379
- }}
2380
- onClick={() => void goto(n)}
2381
- onContextMenu={(e) => {
2382
- e.preventDefault();
2383
- setMenu({ page: n, x: e.clientX, y: e.clientY });
2384
- }}
2385
- className="relative flex w-full items-start gap-1.5 px-2 py-1 text-left"
2386
- style={{ opacity: dragFrom === n ? 0.4 : 1 }}
2387
- title={s.title ?? `${n}枚目`}
2388
- >
2389
- {/* 挿入位置インジケータ */}
2390
- {dropAt === n && dragFrom !== null && (
2391
- <span className="absolute left-2 right-2 top-0 h-[2px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
2392
- )}
2393
- {dropAt === n + 1 && dragFrom !== null && (
2394
- <span className="absolute bottom-0 left-2 right-2 h-[2px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
2395
- )}
2396
- <span
2397
- className="mt-0.5 w-4 shrink-0 text-right text-[10px] tabular-nums"
2398
- style={{ color: current ? PPT_ACCENT : pal.sub, fontWeight: current ? 700 : 400 }}
2399
- >
2400
- {n}
2401
- </span>
2402
- <span
2403
- className="relative block aspect-video w-full overflow-hidden rounded-[3px] bg-white"
2404
- style={{
2405
- boxShadow: current ? `0 0 0 2px ${PPT_ACCENT}` : `0 0 0 1px ${pal.border}`,
2406
- opacity: s.hidden ? 0.45 : 1,
2407
- }}
2408
- >
2409
- {/* 枠の実効幅にぴったり合わせて縮小する。
2410
- レール176 − ボタン左右padding16 − 番号列16 − 間隔6 = 138px。
2411
- ここがずれると右・下に隙間が出る(レール幅を変えたら要追従) */}
2412
- <span
2413
- className="pointer-events-none absolute left-0 top-0 origin-top-left"
2414
- style={{ width: 1920, height: 1080, transform: `scale(${138 / 1920})` }}
2415
- >
2416
- <MemoSlideRender page={n} template={s.template} edited={s.edited} />
2417
- </span>
2418
- {unresolvedCount(s.comments) > 0 && (
2419
- <span
2420
- className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#0F6CBD] px-1 text-[9px] font-bold text-white"
2421
- title={`未解決コメント ${unresolvedCount(s.comments)}件`}
2422
- >
2423
- {unresolvedCount(s.comments)}
2424
- </span>
2425
- )}
2426
- {s.hidden && (
2427
- <span
2428
- className="absolute inset-0 flex items-center justify-center"
2429
- title="非表示スライド(スライドショー・書き出しから除外)"
2430
- >
2431
- <span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white">
2432
- <EyeOff className="mr-0.5 inline h-2.5 w-2.5 align-[-2px]" />
2433
- 非表示
2434
- </span>
2241
+ <aside data-ppt-thumbnails aria-label="スライド一覧" className="relative flex shrink-0 flex-col border-r" style={{ width, backgroundColor: pal.rail, borderColor: pal.border }}>
2242
+ <div className="ed-panel-heading">スライド<span className="ml-auto text-xs font-normal text-gray-400">{page} / {deck.slides.length}</span></div>
2243
+ <div className="min-h-0 flex-1 overflow-y-auto py-2">
2244
+ {slides.length === 0 && <p className="ed-empty">{q ? '一致するスライドがありません。' : 'スライドがありません。'}</p>}
2245
+ {slides.map(({ slide: s, n }) => <div key={s.id} className="relative px-2 py-2" style={{ opacity: dragFrom === n ? .5 : 1 }}>
2246
+ <button ref={n === page ? currentRef : undefined} data-page-thumb={n} aria-current={n === page ? 'page' : undefined}
2247
+ disabled={busy} draggable={!!ops?.move && !busy}
2248
+ onDragStart={(e) => { setDragFrom(n); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(n)); }}
2249
+ onDragOver={(e) => { if (dragFrom === null || !ops?.move) return; e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropAt(e.clientY < r.top + r.height / 2 ? n : n + 1); }}
2250
+ onDrop={(e) => { e.preventDefault(); if (dragFrom !== null && dropAt !== null) doMove(dragFrom, dropAt > dragFrom ? dropAt - 1 : dropAt); setDragFrom(null); setDropAt(null); }}
2251
+ onDragEnd={() => { setDragFrom(null); setDropAt(null); }}
2252
+ onClick={() => void goto(n)} className="flex w-full gap-2 rounded p-1 text-left" title={`${n}. ${s.title}`}>
2253
+ <span className="w-4 shrink-0 text-xs tabular-nums" style={{ color: n === page ? PPT_ACCENT : pal.sub }}>{n}</span>
2254
+ <span className="min-w-0 flex-1">
2255
+ {can('renderContent') && <span className="relative block aspect-video overflow-hidden rounded bg-white"
2256
+ style={{ width: thumbWidth, boxShadow: n === page ? `0 0 0 2px ${PPT_ACCENT}` : `0 0 0 1px ${pal.border}`, opacity: s.hidden ? .5 : 1 }}>
2257
+ <span data-editor-preview className="pointer-events-none absolute left-0 top-0 origin-top-left" style={{ width: 1920, height: 1080, transform: `scale(${thumbWidth / 1920})` }}>
2258
+ <MemoSlideRender page={n} template={s.template} edited={s.edited} />
2435
2259
  </span>
2436
- )}
2260
+ </span>}
2261
+ <span className="mt-2 block truncate pr-6 text-xs" style={{ color: pal.text }}>{s.title || '無題'}{s.hidden ? '(非表示)' : ''}</span>
2262
+ {can('commentAction') && unresolvedCount(s.comments) > 0 && <span className="mt-1 block text-xs" style={{ color: pal.sub }}>未解決 {unresolvedCount(s.comments)}件</span>}
2437
2263
  </span>
2438
2264
  </button>
2439
- );
2440
- })}
2441
-
2442
- {/* 右クリックメニュー */}
2443
- {menu && (
2444
- <div
2445
- className="fixed z-[100] min-w-[176px] rounded-md border py-1 shadow-xl"
2446
- style={{ left: menu.x, top: menu.y, backgroundColor: pal.control, borderColor: pal.border }}
2447
- onMouseDown={(e) => e.stopPropagation()}
2448
- >
2449
- {([
2450
- ['上へ移動', ArrowUp, () => doMove(menu.page, menu.page - 1), menu.page <= 1],
2451
- ['下へ移動', ArrowDown, () => doMove(menu.page, menu.page + 1), menu.page >= deck.slides.length],
2452
- ['複製', Copy, () => doDuplicate(menu.page), false],
2453
- [menuEntry?.hidden ? '表示する' : '非表示スライドに設定', EyeOff, () => doToggleHidden(menu.page), false],
2454
- ['削除', Trash2, () => doDelete(menu.page), deck.slides.length <= 1],
2455
- ] as const).map(([label, Icon, fn, disabled], i) => (
2456
- <button
2457
- key={i}
2458
- disabled={disabled}
2459
- onClick={() => { setMenu(null); fn(); }}
2460
- className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px]"
2461
- style={{ color: disabled ? pal.disabled : label === '削除' ? '#e5534b' : pal.text }}
2462
- onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.backgroundColor = pal.hover; }}
2463
- onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
2464
- >
2465
- <Icon className="h-3.5 w-3.5" />
2466
- {label}
2467
- </button>
2468
- ))}
2469
- </div>
2470
- )}
2471
- </div>
2265
+ {dropAt !== null && dragFrom !== null && (dropAt === n || dropAt === n + 1) && <span className={`absolute inset-x-2 h-0.5 ${dropAt === n ? 'top-0' : 'bottom-0'}`} style={{ backgroundColor: PPT_ACCENT }} />}
2266
+ {hasMenu && <DropdownMenu>
2267
+ <DropdownMenuTrigger asChild><button className="ed-icon-button absolute bottom-1 right-2" disabled={busy} aria-label={`${n}枚目の操作`}><MoreHorizontal className="h-4 w-4" /></button></DropdownMenuTrigger>
2268
+ <DropdownMenuContent align="start">
2269
+ {ops?.move && <><DropdownMenuItem disabled={n === 1} onSelect={() => doMove(n, n - 1)}>上へ移動</DropdownMenuItem><DropdownMenuItem disabled={n === deck.slides.length} onSelect={() => doMove(n, n + 1)}>下へ移動</DropdownMenuItem></>}
2270
+ {ops?.duplicate && <DropdownMenuItem onSelect={() => void run(() => duplicateSlide(n), n < page ? page + 1 : page)}>複製</DropdownMenuItem>}
2271
+ {ops?.updateMeta && <DropdownMenuItem onSelect={() => void run(() => updateSlideMeta(n, { hidden: !s.hidden }))}>{s.hidden ? '表示する' : 'スライドを非表示にする'}</DropdownMenuItem>}
2272
+ {ops?.remove && <DropdownMenuItem disabled={deck.slides.length <= 1} onSelect={() => setDeletePage(n)}>削除</DropdownMenuItem>}
2273
+ </DropdownMenuContent>
2274
+ </DropdownMenu>}
2275
+ </div>)}
2276
+ </div>
2277
+ <div {...resizeHandleProps} />
2278
+ {isDragging && <div className="fixed inset-0 z-50 cursor-col-resize" />}
2279
+ <ConfirmDialog open={deletePage !== null} onOpenChange={(open) => { if (!open) setDeletePage(null); }} title="スライドを削除しますか?"
2280
+ description={`${deletePage ?? ''}枚目「${deck.slides[(deletePage ?? 1) - 1]?.title ?? ''}」を削除します。`}
2281
+ onConfirm={() => { if (deletePage !== null) { const next = deletePage === page ? Math.max(1, Math.min(page, deck.slides.length - 1)) : deletePage < page ? page - 1 : page; void run(() => deleteSlide(deletePage), next); } }} />
2282
+ </aside>
2472
2283
  );
2473
2284
  }
2474
2285
 
@@ -2479,15 +2290,16 @@ export function PptStatusBar({ page, total, theme }: { page: number; total: numb
2479
2290
  const { zoom, setZoom, fitZoom } = useEditorContext();
2480
2291
  return (
2481
2292
  <div
2482
- className="flex h-7 items-center gap-3 border-t px-3 text-[11px]"
2293
+ className="ed-footer"
2483
2294
  style={{ backgroundColor: pal.rail, borderColor: pal.border, color: pal.sub }}
2484
2295
  >
2485
2296
  <span>スライド {page} / {total}</span>
2486
2297
  <span className="ml-auto" />
2487
- <button onClick={() => setZoom(Math.max(10, zoom - 10))} className="rounded p-0.5" title="縮小" style={{ color: pal.sub }}>
2298
+ <button disabled={zoom <= MIN_ZOOM} onClick={() => setZoom(Math.max(MIN_ZOOM, zoom - 10))} className="rounded p-0.5" title="縮小" style={{ color: pal.sub }}>
2488
2299
  <ZoomOut className="h-3.5 w-3.5" />
2489
2300
  </button>
2490
2301
  <input
2302
+ aria-label="表示倍率"
2491
2303
  type="range"
2492
2304
  min={10}
2493
2305
  max={400}
@@ -2496,11 +2308,11 @@ export function PptStatusBar({ page, total, theme }: { page: number; total: numb
2496
2308
  className="w-28"
2497
2309
  style={{ accentColor: PPT_ACCENT }}
2498
2310
  />
2499
- <button onClick={() => setZoom(Math.min(300, zoom + 10))} className="rounded p-0.5" title="拡大" style={{ color: pal.sub }}>
2311
+ <button disabled={zoom >= MAX_ZOOM} onClick={() => setZoom(Math.min(MAX_ZOOM, zoom + 10))} className="rounded p-0.5" title="拡大" style={{ color: pal.sub }}>
2500
2312
  <ZoomIn className="h-3.5 w-3.5" />
2501
2313
  </button>
2502
2314
  <span className="w-9 text-right tabular-nums">{Math.round(zoom)}%</span>
2503
- <button onClick={() => setZoom(fitZoom)} className="rounded p-0.5" title="画面に合わせる" style={{ color: pal.sub }}>
2315
+ <button onClick={() => { const api = editorZoomApiRef.current; if (api) api.fit(); else setZoom(fitZoom); }} className="rounded p-0.5" title="画面に合わせる" style={{ color: pal.sub }}>
2504
2316
  <Maximize className="h-3.5 w-3.5" />
2505
2317
  </button>
2506
2318
  </div>