@growgroup/visual-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,2508 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PowerPoint風UIのクローム。macOS版PowerPointの構造に合わせた3段構成:
5
+ * 1段目 タイトルバー … 自動保存ピル・保存・元に戻す/やり直し・中央にファイル名・検索
6
+ * 2段目 タブ帯 … ホーム/挿入/描画/…/表示 + 右端に共有(オレンジ)=書き出し
7
+ * 3段目 リボン … タブごとのコントロール群(縦罫線で区切るmac流。グループ名は出さない)
8
+ * 左はスライドサムネイル、下はステータスバー(ズームスライダー)。
9
+ * ライト/ダークの両テーマ(初期値はOS設定に追従、切替は記憶)。
10
+ *
11
+ * 【設計の約束】ここは**見た目の殻だけ**。編集エンジン(EditorCanvasのiframe・
12
+ * 選択・ドラッグ・保存・原本TSXへの書き戻し)はFigma風UIと完全に共通。
13
+ * エンジンに新しい操作を足すときはFigma風UI側に実装し、ここからは参照する。
14
+ * 実機に無い独自ボタンを足さない。実機にあるが未対応の機能はタブごと無効表示にする。
15
+ */
16
+
17
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { createPortal } from 'react-dom';
19
+ import { PEN_PRESETS, inkStyle, setInkPreset, setInkColor } from '../../utils/ink-style';
20
+ import { generateElementId, updateSelectionBox } from '../../utils/dom-utils';
21
+ import { alignElements, distributeElements, type AlignMode, type DistributeAxis } from '../../utils/align-elements';
22
+ import { isEyeDropperSupported, pickScreenColor } from '../../utils/eyedropper';
23
+ import { applyTextHighlight, applyTextColor } from '../../utils/text-highlight';
24
+ import { SHAPE_GROUPS, setPendingShape, pendingShape, shapeStyles, type ShapeDef } from '../../utils/shape-library';
25
+ import { applyShadowPreset, paintTarget } from '../../utils/element-effects';
26
+ import {
27
+ Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
28
+ Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
29
+ Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
30
+ Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play,
31
+ Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
32
+ MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
33
+ PaintBucket, PenLine, Eraser, Highlighter, Table, Shapes, Sticker, Wand2,
34
+ AArrowUp, AArrowDown, RemoveFormatting, Strikethrough, Superscript, Subscript,
35
+ List, ListOrdered, IndentIncrease, IndentDecrease, AlignJustify, Baseline, RotateCw,
36
+ AlignHorizontalJustifyStart, AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd,
37
+ AlignVerticalJustifyStart, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd,
38
+ AlignHorizontalSpaceAround, AlignVerticalSpaceAround, Pipette,
39
+ } from 'lucide-react';
40
+ import { useEditorContext } from '../../EditorContext';
41
+ import { flushAutoSave } from '../../autosave';
42
+ import { useDeck, refreshDeck } from '../../../components/viewer/useDeck';
43
+ import { moveSlide, deleteSlide, duplicateSlide, updateSlideMeta } from '../../../lib/deck';
44
+ import { startCleanup } from '../../../components/SaveNote';
45
+ import { unresolvedCount } from './PptComments';
46
+ import { enterCropMode, type CropSession } from '../../utils/crop-mode';
47
+ import { useGoogleFonts } from '../../hooks/useGoogleFonts';
48
+ import { DeckSlideRender } from '../../../components/DeckSlideRender';
49
+ import { PptDesignProposals } from './PptDesignProposals';
50
+ import { EditorTopBar } from '../shell/EditorTopBar';
51
+
52
+ /**
53
+ * サムネイルのスライド描画はメモ化する。ズームのコミット等で親が再レンダーしても、
54
+ * (page, template, edited) が同じ150枚のReactツリーを組み直さないため
55
+ */
56
+ const MemoSlideRender = memo(DeckSlideRender);
57
+ import type { EditorTool } from '../../../types/editor';
58
+
59
+ export type PptTheme = 'light' | 'dark';
60
+
61
+ /** 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
+ }
71
+
72
+ /** FVE(FrontendVisualEditor)のスコープから借りる操作群 */
73
+ export type PptActions = {
74
+ undo: () => void;
75
+ redo: () => void;
76
+ canUndo: boolean;
77
+ canRedo: boolean;
78
+ deleteElement?: () => void;
79
+ duplicateElement?: () => void;
80
+ bringToFront?: () => void;
81
+ bringForward?: () => void;
82
+ sendBackward?: () => void;
83
+ sendToBack?: () => void;
84
+ groupElements?: () => void;
85
+ ungroupElements?: () => void;
86
+ openFilePicker: () => void;
87
+ openMediaLibrary: () => void;
88
+ openComponents: () => void;
89
+ openVariables: () => void;
90
+ activeTool: EditorTool;
91
+ setActiveTool: (t: EditorTool) => void;
92
+ /** 右ペイン「図の書式設定」の開閉 */
93
+ toggleFormatPane?: () => void;
94
+ formatPaneOpen?: boolean;
95
+ };
96
+
97
+ /* ============================ テーマ ============================ */
98
+
99
+ const PPT_ACCENT = '#ED6C47'; // PowerPointのブランド橙赤(選択枠・タブ下線)
100
+
101
+ type Palette = {
102
+ chrome: string; text: string; sub: string; border: string;
103
+ hover: string; activeBg: string; control: string; rail: string;
104
+ canvas: string; disabled: string;
105
+ };
106
+
107
+ const PALETTES: Record<PptTheme, Palette> = {
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',
112
+ },
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',
117
+ },
118
+ };
119
+
120
+ export const PPT_PALETTES = PALETTES;
121
+
122
+ /* ============================ 小物 ============================ */
123
+
124
+ /** リボンの大ボタン(アイコン上・ラベル下。実機の「新しいスライド」等の形) */
125
+ function BigButton({
126
+ icon: Icon, label, onClick, disabled, active, title, caret, pal,
127
+ }: {
128
+ icon: React.ComponentType<{ className?: string }>;
129
+ label: string;
130
+ onClick?: () => void;
131
+ disabled?: boolean;
132
+ active?: boolean;
133
+ title?: string;
134
+ caret?: boolean;
135
+ pal: Palette;
136
+ }) {
137
+ return (
138
+ <button
139
+ onClick={onClick}
140
+ disabled={disabled}
141
+ title={title ?? label}
142
+ className="flex h-[64px] min-w-[52px] flex-col items-center justify-center gap-1 rounded px-2 text-[11px] leading-tight transition-colors"
143
+ style={{
144
+ color: disabled ? pal.disabled : pal.text,
145
+ backgroundColor: active ? pal.activeBg : undefined,
146
+ }}
147
+ onMouseEnter={(e) => { if (!disabled && !active) e.currentTarget.style.backgroundColor = pal.hover; }}
148
+ onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = ''; }}
149
+ >
150
+ <Icon className="h-[22px] w-[22px]" />
151
+ <span className="flex items-center gap-0.5 whitespace-pre-line text-center">
152
+ {label}
153
+ {caret && <ChevronDown className="h-3 w-3" />}
154
+ </span>
155
+ </button>
156
+ );
157
+ }
158
+
159
+ /** リボンの小ボタン(正方形アイコン) */
160
+ function SmallButton({
161
+ icon: Icon, onClick, disabled, active, title, pal, label, chevron,
162
+ }: {
163
+ icon: React.ComponentType<{ className?: string }>;
164
+ onClick?: () => void;
165
+ disabled?: boolean;
166
+ active?: boolean;
167
+ title: string;
168
+ pal: Palette;
169
+ /** アイコン横に出すラベル(塗りつぶし等、ドロップダウンのトリガー用) */
170
+ label?: string;
171
+ chevron?: boolean;
172
+ }) {
173
+ return (
174
+ <button
175
+ onClick={onClick}
176
+ disabled={disabled}
177
+ title={title}
178
+ className={`flex h-6 items-center justify-center gap-1 rounded transition-colors ${label ? 'px-1.5' : 'w-6'}`}
179
+ style={{
180
+ color: disabled ? pal.disabled : pal.text,
181
+ backgroundColor: active ? pal.activeBg : undefined,
182
+ }}
183
+ onMouseEnter={(e) => { if (!disabled && !active) e.currentTarget.style.backgroundColor = pal.hover; }}
184
+ onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = ''; }}
185
+ >
186
+ <Icon className="h-4 w-4" />
187
+ {label && <span className="text-[11px]">{label}</span>}
188
+ {chevron && <ChevronDown className="h-3 w-3" />}
189
+ </button>
190
+ );
191
+ }
192
+
193
+ /** 縦書きボタン用(Typeを90度回して代用) */
194
+ function TypeVertical({ className }: { className?: string }) {
195
+ return <Type className={`${className ?? ''} rotate-90`} />;
196
+ }
197
+
198
+ /** グループ区切り(mac版はラベルなしの縦罫線) */
199
+ function Sep({ pal }: { pal: Palette }) {
200
+ return <div className="mx-1.5 h-[56px] w-px self-center" style={{ backgroundColor: pal.border }} />;
201
+ }
202
+
203
+ /**
204
+ * 開いているメニューを閉じる判定に使う文書。
205
+ * リボンは親ページ、編集面はiframeの中にあり、iframe内の mousedown は親ページまで
206
+ * 上がってこない。両方に外側クリックを張らないと、キャンバスを触っても閉じない。
207
+ */
208
+ function listenDocs(): Document[] {
209
+ const docs: Document[] = [document];
210
+ for (const f of Array.from(document.querySelectorAll('iframe'))) {
211
+ try {
212
+ if (f.contentDocument) docs.push(f.contentDocument);
213
+ } catch {
214
+ /* 別オリジンのiframeは触れないので無視 */
215
+ }
216
+ }
217
+ return docs;
218
+ }
219
+
220
+ /** 簡易ドロップダウン */
221
+ function Dropdown({
222
+ trigger, items, content, pal, align = 'left',
223
+ }: {
224
+ trigger: React.ReactNode;
225
+ items?: { label: string; onClick?: () => void; disabled?: boolean }[];
226
+ /** items の代わりに任意のパネルを出す(色や太さの選択UIなど) */
227
+ content?: (close: () => void) => React.ReactNode;
228
+ pal: Palette;
229
+ /** 画面右端のトリガーは right にしないとメニューがはみ出す */
230
+ align?: 'left' | 'right';
231
+ }) {
232
+ 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
+
274
+ 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>
303
+ );
304
+ }
305
+
306
+ /* ============================ 選択要素へのスタイル適用 ============================ */
307
+
308
+ /**
309
+ * リボンの書式ボタンが使う、選択中要素への直接スタイル適用。
310
+ * Figma風UIの右パネルと同じ入口 notifyIframeChange() を通すので、
311
+ * 履歴・HTML同期・保存時のdirty検出は完全に共通で効く。
312
+ */
313
+ /** 図形本体(枠ではなく中身)へ当てるスタイル */
314
+ const PAINT_PROPS = new Set([
315
+ 'backgroundColor', 'background', 'backgroundImage', 'clipPath', 'borderRadius',
316
+ 'border', 'borderColor', 'borderWidth', 'borderStyle',
317
+ 'borderTopWidth', 'borderTopStyle', 'borderTopColor',
318
+ ]);
319
+
320
+ function useSelectionStyle() {
321
+ const { getIframeDoc, selectedElement, selectedElementIds, notifyIframeChange } =
322
+ useEditorContext();
323
+
324
+ const targets = useCallback((): HTMLElement[] => {
325
+ const doc = getIframeDoc();
326
+ if (!doc) return [];
327
+ const ids = selectedElementIds.length
328
+ ? selectedElementIds
329
+ : selectedElement
330
+ ? [selectedElement.id]
331
+ : [];
332
+ return ids
333
+ .map((id) => doc.querySelector(`[data-element-id="${id}"]`) as HTMLElement | null)
334
+ .filter((el): el is HTMLElement => !!el);
335
+ }, [getIframeDoc, selectedElement, selectedElementIds]);
336
+
337
+ const apply = useCallback(
338
+ (styles: Record<string, string>) => {
339
+ const els = targets();
340
+ if (!els.length) return;
341
+ for (const el of els) {
342
+ for (const [k, v] of Object.entries(styles)) {
343
+ // 効果用の枠が選択されている場合、塗り・枠線・切り抜きは中身の図形へ、
344
+ // 位置や大きさは枠へ当てる(枠に塗ると矩形が出てしまうため)
345
+ const target = PAINT_PROPS.has(k) ? paintTarget(el) : el;
346
+ (target.style as unknown as Record<string, string>)[k] = v;
347
+ }
348
+ }
349
+ notifyIframeChange();
350
+ },
351
+ [targets, notifyIframeChange],
352
+ );
353
+
354
+ const readComputed = useCallback(
355
+ (prop: string): string => {
356
+ const els = targets();
357
+ if (!els.length) return '';
358
+ const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
359
+ const el = PAINT_PROPS.has(camel) ? paintTarget(els[0]) : els[0];
360
+ const win = el.ownerDocument.defaultView;
361
+ return win ? win.getComputedStyle(el).getPropertyValue(prop) : '';
362
+ },
363
+ [targets],
364
+ );
365
+
366
+ /** 属性の付け外し(アニメーション data-anim 等)。null で除去 */
367
+ const applyAttr = useCallback(
368
+ (name: string, value: string | null) => {
369
+ const els = targets();
370
+ if (!els.length) return;
371
+ for (const el of els) {
372
+ if (value === null) el.removeAttribute(name);
373
+ else el.setAttribute(name, value);
374
+ }
375
+ notifyIframeChange();
376
+ },
377
+ [targets, notifyIframeChange],
378
+ );
379
+
380
+ const readAttr = useCallback(
381
+ (name: string): string | null => {
382
+ const els = targets();
383
+ return els.length ? els[0].getAttribute(name) : null;
384
+ },
385
+ [targets],
386
+ );
387
+
388
+ return { apply, applyAttr, readAttr, readComputed, targets, hasSelection: targets().length > 0 };
389
+ }
390
+
391
+ /* ============================ 1段目: タイトルバー ============================ */
392
+
393
+ export function PptTitleBar({
394
+ title, page, theme, onToggleTheme, search, onSearch, onSave, onClose, onSwitchUi, saveStatus = 'saved', actions,
395
+ }: {
396
+ title?: string;
397
+ /** 編集中スライドの番号(1始まり) */
398
+ page?: number;
399
+ theme: PptTheme;
400
+ onToggleTheme: () => void;
401
+ search: string;
402
+ onSearch: (v: string) => void;
403
+ onSave: (html: string) => Promise<void> | void;
404
+ onClose: () => void;
405
+ onSwitchUi: () => void;
406
+ /**
407
+ * 自動保存の状態。エディタ本体(FrontendVisualEditor)が算出したものを表示するだけ。
408
+ * dirty=このあと自動保存される / saving=保存中 / saved=保存済み / error=自動保存に失敗
409
+ */
410
+ saveStatus?: 'saved' | 'dirty' | 'saving' | 'error';
411
+ actions: PptActions;
412
+ }) {
413
+ 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
+ // 1段目の中身はFigma風UIと共通(EditorTopBar)。
445
+ // 保存・共有・プレビュー・UI切替・閉じるの並びを両UIで揃えるため、
446
+ // ここが持つのは PowerPoint 固有の操作(元に戻す/やり直し・検索・テーマ)だけ
447
+ return (
448
+ <EditorTopBar
449
+ variant="ppt"
450
+ palette={pal}
451
+ title={title}
452
+ pageNumber={page}
453
+ saveStatus={saveStatus}
454
+ onSave={onSave}
455
+ onClose={onClose}
456
+ onSwitchUi={onSwitchUi}
457
+ leftExtra={
458
+ <>
459
+ <SmallButton icon={Undo2} title="元に戻す" onClick={actions.undo} disabled={!actions.canUndo} pal={pal} />
460
+ <SmallButton icon={Redo2} title="やり直し" onClick={actions.redo} disabled={!actions.canRedo} pal={pal} />
461
+ </>
462
+ }
463
+ rightExtra={
464
+ <>
465
+ <label
466
+ className="flex h-6 items-center gap-1.5 rounded-md border px-2"
467
+ style={{ borderColor: pal.border, backgroundColor: pal.control }}
468
+ >
469
+ <Search className="h-3 w-3" style={{ color: pal.sub }} />
470
+ <input
471
+ value={search}
472
+ onChange={(e) => onSearch(e.target.value)}
473
+ placeholder="スライドを検索"
474
+ className="w-28 bg-transparent text-[11px] outline-none"
475
+ style={{ color: pal.text }}
476
+ />
477
+ </label>
478
+ <SmallButton
479
+ icon={theme === 'dark' ? Sun : Moon}
480
+ title={theme === 'dark' ? 'ライトモードに切り替え' : 'ダークモードに切り替え'}
481
+ onClick={onToggleTheme}
482
+ pal={pal}
483
+ />
484
+ </>
485
+ }
486
+ />
487
+ );
488
+ }
489
+
490
+ /* ============================ 2〜3段目: タブ帯+リボン ============================ */
491
+
492
+ type TabId = 'home' | 'insert' | 'draw' | 'design' | 'transition' | 'animation' | 'slideshow' | 'review' | 'view' | 'shapeformat' | 'pictureformat';
493
+
494
+ /** 図のスタイル ギャラリー(実機の「図のスタイル」相当) */
495
+ const PICTURE_STYLES: { label: string; styles: Record<string, string> }[] = [
496
+ { label: 'なし', styles: { border: '', borderRadius: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '', WebkitBoxReflect: '', filter: '' } },
497
+ { label: '枠線(細)', styles: { border: '1px solid var(--color-rule)', borderRadius: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '' } },
498
+ { label: '枠線(太)', styles: { border: '8px solid var(--color-ink)', borderRadius: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '' } },
499
+ { label: '角丸', styles: { borderRadius: '24px', border: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '' } },
500
+ { label: '楕円', styles: { borderRadius: '50%', border: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '' } },
501
+ { label: '影付き', styles: { filter: 'drop-shadow(0 10px 20px rgba(0,0,0,0.35))', boxShadow: '', border: '', borderRadius: '', clipPath: '', padding: '', backgroundColor: '' } },
502
+ { label: '白枠+影', styles: { border: '', padding: '12px', backgroundColor: '#ffffff', filter: 'drop-shadow(0 10px 20px rgba(0,0,0,0.28))', boxShadow: '', borderRadius: '2px', clipPath: '' } },
503
+ { label: '角丸+影', styles: { borderRadius: '20px', filter: 'drop-shadow(0 14px 24px rgba(0,0,0,0.3))', boxShadow: '', border: '', clipPath: '', padding: '', backgroundColor: '' } },
504
+ { label: '反射付き', styles: { WebkitBoxReflect: 'below 4px linear-gradient(transparent 62%, rgba(255,255,255,0.45))', border: '', borderRadius: '', boxShadow: '', clipPath: '', padding: '' } },
505
+ ];
506
+
507
+ /** 修整・色・アート効果(CSS filter で表現する) */
508
+ const PICTURE_FILTERS: { group: string; items: { label: string; value: string }[] }[] = [
509
+ {
510
+ group: '修整',
511
+ items: [
512
+ { label: '標準', value: '' },
513
+ { label: '明るく +20%', value: 'brightness(1.2)' },
514
+ { label: '暗く -20%', value: 'brightness(0.8)' },
515
+ { label: 'コントラスト +30%', value: 'contrast(1.3)' },
516
+ { label: 'コントラスト -30%', value: 'contrast(0.75)' },
517
+ { label: 'くっきり', value: 'contrast(1.15) saturate(1.1)' },
518
+ ],
519
+ },
520
+ {
521
+ group: '色',
522
+ items: [
523
+ { label: '彩度 +40%', value: 'saturate(1.4)' },
524
+ { label: '彩度 -40%', value: 'saturate(0.6)' },
525
+ { label: 'グレースケール', value: 'grayscale(1)' },
526
+ { label: 'セピア', value: 'sepia(0.75)' },
527
+ { label: '寒色(色相 -20°)', value: 'hue-rotate(-20deg)' },
528
+ { label: '暖色(色相 +20°)', value: 'hue-rotate(20deg)' },
529
+ ],
530
+ },
531
+ {
532
+ group: 'アート効果',
533
+ items: [
534
+ { label: 'ぼかし', value: 'blur(3px)' },
535
+ { label: 'モノクロ+高コントラスト', value: 'grayscale(1) contrast(1.4)' },
536
+ { label: '淡色(下敷き向け)', value: 'grayscale(0.4) brightness(1.15) opacity(0.85)' },
537
+ ],
538
+ },
539
+ ];
540
+
541
+ /** 塗り・枠線のスウォッチ。デザイントークン(CSS変数)を優先し、書き戻してもトークン参照が残る */
542
+ const SWATCHES: { label: string; value: string }[] = [
543
+ { label: '墨', value: 'var(--color-ink)' },
544
+ { label: '墨70', value: 'var(--color-ink-70)' },
545
+ { label: '墨45', value: 'var(--color-ink-45)' },
546
+ { label: '罫線', value: 'var(--color-rule)' },
547
+ { label: '地', value: 'var(--color-paper)' },
548
+ { label: '面', value: 'var(--color-surface)' },
549
+ { label: '白', value: '#ffffff' },
550
+ { label: '緑', value: 'var(--color-gg-green)' },
551
+ { label: '緑200', value: 'var(--color-gg-green-200)' },
552
+ { label: '緑50', value: 'var(--color-gg-green-50)' },
553
+ { label: '赤', value: 'var(--color-gg-red)' },
554
+ ];
555
+
556
+ /** 挿入できるアイコン(Material Symbolsのリガチャ名 + 日本語検索語) */
557
+ const MATERIAL_ICONS: { name: string; ja: string }[] = [
558
+ { name: 'home', ja: '家 ホーム' }, { name: 'search', ja: '検索 虫眼鏡' }, { name: 'settings', ja: '設定 歯車' },
559
+ { name: 'favorite', ja: 'ハート お気に入り' }, { name: 'star', ja: '星 評価' }, { name: 'check_circle', ja: 'チェック 完了 OK' },
560
+ { name: 'cancel', ja: 'バツ 中止 NG' }, { name: 'warning', ja: '警告 注意' }, { name: 'info', ja: '情報' },
561
+ { name: 'help', ja: 'ヘルプ 質問' }, { name: 'person', ja: '人 ユーザー' }, { name: 'group', ja: '二人 チーム' },
562
+ { name: 'groups', ja: '組織 集団 人々' }, { name: 'business_center', ja: '仕事 カバン' }, { name: 'apartment', ja: 'ビル 会社 建物' },
563
+ { name: 'factory', ja: '工場 製造' }, { name: 'storefront', ja: '店舗 店' }, { name: 'school', ja: '学校 教育 帽子' },
564
+ { name: 'badge', ja: '社員証 名札 採用' }, { name: 'handshake', ja: '握手 提携 契約' }, { name: 'diversity_3', ja: '多様性 チーム 輪' },
565
+ { name: 'trending_up', ja: '上昇 グラフ 成長' }, { name: 'trending_down', ja: '下降 減少' }, { name: 'bar_chart', ja: '棒グラフ' },
566
+ { name: 'pie_chart', ja: '円グラフ' }, { name: 'monitoring', ja: '分析 折れ線' }, { name: 'query_stats', ja: '統計 分析 虫眼鏡' },
567
+ { name: 'payments', ja: '支払い お金 紙幣' }, { name: 'savings', ja: '貯金 豚 コスト' }, { name: 'account_balance', ja: '銀行 行政 神殿' },
568
+ { name: 'shopping_cart', ja: 'カート 購入 EC' }, { name: 'sell', ja: '値札 販売 タグ' }, { name: 'campaign', ja: 'メガホン 宣伝 広報' },
569
+ { name: 'lightbulb', ja: '電球 アイデア' }, { name: 'rocket_launch', ja: 'ロケット 立ち上げ 開始' }, { name: 'flag', ja: '旗 目標 ゴール' },
570
+ { name: 'verified', ja: '認証 保証 バッジ' }, { name: 'thumb_up', ja: 'いいね 賛成' }, { name: 'schedule', ja: '時計 時間 スケジュール' },
571
+ { name: 'calendar_month', ja: 'カレンダー 日程' }, { name: 'mail', ja: 'メール 封筒' }, { name: 'call', ja: '電話' },
572
+ { name: 'chat', ja: 'チャット 会話 吹き出し' }, { name: 'forum', ja: '掲示板 対話' }, { name: 'notifications', ja: 'ベル 通知' },
573
+ { name: 'description', ja: '書類 文書 資料' }, { name: 'folder', ja: 'フォルダ' }, { name: 'edit', ja: '鉛筆 編集' },
574
+ { name: 'delete', ja: 'ゴミ箱 削除' }, { name: 'download', ja: 'ダウンロード' }, { name: 'upload', ja: 'アップロード' },
575
+ { name: 'share', ja: '共有 シェア' }, { name: 'link', ja: 'リンク 鎖' }, { name: 'attach_file', ja: '添付 クリップ' },
576
+ { name: 'visibility', ja: '目 閲覧 表示' }, { name: 'lock', ja: '鍵 セキュリティ' }, { name: 'shield', ja: '盾 保護 安全' },
577
+ { name: 'key', ja: '鍵 キー' }, { name: 'security', ja: 'セキュリティ 盾' }, { name: 'cloud', ja: 'クラウド 雲' },
578
+ { name: 'database', ja: 'データベース' }, { name: 'devices', ja: 'デバイス PC スマホ' }, { name: 'smartphone', ja: 'スマホ 携帯' },
579
+ { name: 'computer', ja: 'パソコン PC' }, { name: 'language', ja: '地球 Web 言語' }, { name: 'public', ja: '地球 グローバル' },
580
+ { name: 'code', ja: 'コード 開発' }, { name: 'terminal', ja: 'ターミナル 開発' }, { name: 'build', ja: 'スパナ 構築 工具' },
581
+ { name: 'construction', ja: '工事 整備 工具' }, { name: 'bolt', ja: '稲妻 高速 電力' }, { name: 'speed', ja: 'スピード メーター' },
582
+ { name: 'eco', ja: '葉 エコ 環境' }, { name: 'recycling', ja: 'リサイクル 循環' }, { name: 'local_shipping', ja: 'トラック 配送 物流' },
583
+ { name: 'train', ja: '電車 鉄道' }, { name: 'flight', ja: '飛行機 出張' }, { name: 'map', ja: '地図' },
584
+ { name: 'location_on', ja: 'ピン 場所 位置' }, { name: 'emoji_events', ja: 'トロフィー 優勝 実績' }, { name: 'military_tech', ja: 'メダル 表彰' },
585
+ { name: 'auto_awesome', ja: 'キラキラ AI 生成' }, { name: 'psychology', ja: '頭脳 思考 心理' }, { name: 'science', ja: 'フラスコ 研究 実験' },
586
+ { name: 'health_and_safety', ja: '医療 健康 安全' }, { name: 'volunteer_activism', ja: '支援 寄付 手とハート' }, { name: 'support_agent', ja: 'サポート オペレーター' },
587
+ { name: 'touch_app', ja: 'タップ 操作 指' }, { name: 'ads_click', ja: 'クリック 的' }, { name: 'open_in_new', ja: '外部リンク 別窓' },
588
+ { name: 'arrow_forward', ja: '矢印 右' }, { name: 'arrow_back', ja: '矢印 左' }, { name: 'expand_more', ja: '矢印 下 開く' },
589
+ { name: 'add_circle', ja: 'プラス 追加' }, { name: 'remove_circle', ja: 'マイナス 削除' }, { name: 'sync', ja: '同期 更新 循環' },
590
+ ];
591
+
592
+ /**
593
+ * リストマーカーの語彙(実機の箇条書き/段落番号ギャラリー相当)。
594
+ * 文字列マーカー(list-style-type: "・ ")は全行同じ記号、
595
+ * キーワード(decimal等)は自動で連番になる。
596
+ */
597
+ const BULLET_MARKS: { label: string; value: string }[] = [
598
+ { label: '●', value: 'disc' },
599
+ { label: '○', value: 'circle' },
600
+ { label: '■', value: 'square' },
601
+ { label: '・', value: '"・ "' },
602
+ { label: '–', value: '"– "' },
603
+ { label: '✓', value: '"✓ "' },
604
+ { label: '▶', value: '"▶ "' },
605
+ { label: '※', value: '"※ "' },
606
+ ];
607
+ const NUMBER_MARKS: { label: string; value: string }[] = [
608
+ { label: '1.', value: 'decimal' },
609
+ { label: '01.', value: 'decimal-leading-zero' },
610
+ { label: 'a.', value: 'lower-alpha' },
611
+ { label: 'A.', value: 'upper-alpha' },
612
+ { label: 'i.', value: 'lower-roman' },
613
+ { label: 'I.', value: 'upper-roman' },
614
+ { label: '一', value: 'cjk-ideographic' },
615
+ { label: 'イ', value: 'katakana-iroha' },
616
+ ];
617
+
618
+ const SHADOWS: { label: string; preset: 'none' | 'sm' | 'md' | 'lg' }[] = [
619
+ { label: '影なし', preset: 'none' },
620
+ { label: '弱', preset: 'sm' },
621
+ { label: '中', preset: 'md' },
622
+ { label: '強', preset: 'lg' },
623
+ ];
624
+
625
+ /** リストマーカーのギャラリー(箇条書き/段落番号の▾) */
626
+ function MarkGallery({
627
+ pal, marks, onPick, onNone,
628
+ }: {
629
+ pal: Palette;
630
+ marks: { label: string; value: string }[];
631
+ onPick: (v: string) => void;
632
+ onNone: () => void;
633
+ }) {
634
+ return (
635
+ <div className="px-2 py-1.5">
636
+ <div className="grid grid-cols-4 gap-1">
637
+ {marks.map((m) => (
638
+ <button
639
+ key={m.value}
640
+ title={m.value}
641
+ onMouseDown={(e) => e.preventDefault()}
642
+ onClick={() => onPick(m.value)}
643
+ className="flex h-8 w-9 items-center justify-center rounded border text-[13px]"
644
+ style={{ borderColor: pal.border, color: pal.text }}
645
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
646
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
647
+ >
648
+ {m.label}
649
+ </button>
650
+ ))}
651
+ </div>
652
+ <button
653
+ onMouseDown={(e) => e.preventDefault()}
654
+ onClick={onNone}
655
+ className="mt-1.5 w-full rounded px-2 py-1 text-left text-[12px]"
656
+ style={{ color: pal.text }}
657
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
658
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
659
+ >
660
+ リストを解除
661
+ </button>
662
+ </div>
663
+ );
664
+ }
665
+
666
+ /** 蛍光ペンの色(実機と同じ、彩度の高い蛍光色) */
667
+ const HIGHLIGHT_SWATCHES: { label: string; value: string }[] = [
668
+ { label: '黄', value: '#FFF176' },
669
+ { label: '緑', value: '#B9F6A6' },
670
+ { label: '水', value: '#A5E7FF' },
671
+ { label: '桃', value: '#FFB3D1' },
672
+ { label: '橙', value: '#FFD08A' },
673
+ { label: '紫', value: '#D7BCFF' },
674
+ { label: '灰', value: '#E0E0E0' },
675
+ { label: 'マーカー緑', value: 'var(--color-mark-green)' },
676
+ { label: 'マーカー赤', value: 'var(--color-mark-red)' },
677
+ ];
678
+
679
+ /**
680
+ * スポイト。画面のどこからでも色を1点拾う(Figma風UIの色ピッカーと同じ入口)。
681
+ * 未対応ブラウザ(Safari/Firefox)ではボタン自体を出さない。
682
+ */
683
+ function EyeDropperButton({ pal, onPick, title }: { pal: Palette; onPick: (v: string) => void; title?: string }) {
684
+ if (!isEyeDropperSupported()) return null;
685
+ return (
686
+ <button
687
+ title={title ?? 'スポイト(画面から色を取得)'}
688
+ data-eyedropper="1"
689
+ // 押した瞬間に iframe 内の選択が捨てられないよう mousedown を止める。
690
+ // EyeDropper はクリック(=ユーザー操作)から直接開くので、これでも起動できる
691
+ onMouseDown={(e) => e.preventDefault()}
692
+ onClick={() => { void pickScreenColor().then((hex) => { if (hex) onPick(hex); }); }}
693
+ className="flex h-5 w-5 items-center justify-center rounded-[3px] border"
694
+ style={{ borderColor: pal.border, color: pal.text }}
695
+ >
696
+ <Pipette className="h-3 w-3" />
697
+ </button>
698
+ );
699
+ }
700
+
701
+ /** スウォッチ+任意色+なし、の色選択パネル(塗り/枠線色/蛍光ペンの共用) */
702
+ function ColorPanel({
703
+ pal, onPick, onNone, noneLabel, swatches,
704
+ }: {
705
+ pal: Palette;
706
+ onPick: (v: string) => void;
707
+ onNone?: () => void;
708
+ noneLabel?: string;
709
+ swatches?: { label: string; value: string }[];
710
+ }) {
711
+ return (
712
+ <div className="px-2 py-1.5">
713
+ <div className="grid grid-cols-6 gap-1">
714
+ {(swatches ?? SWATCHES).map((sw) => (
715
+ <button
716
+ key={sw.label}
717
+ title={sw.label}
718
+ // mousedown を止めないと、押した瞬間に iframe 内の範囲選択が捨てられる
719
+ onMouseDown={(e) => e.preventDefault()}
720
+ onClick={() => onPick(sw.value)}
721
+ className="h-5 w-5 rounded-[3px] border"
722
+ style={{ backgroundColor: sw.value, borderColor: pal.border }}
723
+ />
724
+ ))}
725
+ <label className="relative h-5 w-5 cursor-pointer rounded-[3px] border" title="任意の色"
726
+ style={{ borderColor: pal.border, background: 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' }}>
727
+ <input type="color" className="absolute inset-0 cursor-pointer opacity-0"
728
+ onChange={(e) => onPick(e.target.value)} />
729
+ </label>
730
+ <EyeDropperButton pal={pal} onPick={onPick} />
731
+ </div>
732
+ {onNone && (
733
+ <button
734
+ onMouseDown={(e) => e.preventDefault()}
735
+ onClick={onNone}
736
+ className="mt-1.5 w-full rounded px-2 py-1 text-left text-[12px]"
737
+ style={{ color: pal.text }}
738
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
739
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
740
+ >
741
+ {noneLabel ?? 'なし'}
742
+ </button>
743
+ )}
744
+ </div>
745
+ );
746
+ }
747
+
748
+ /**
749
+ * グラデーションのプリセット。デッキのトークンを参照しつつ既定色を添えている。
750
+ * linear-gradient() は中に未定義の var() が1つでもあると宣言ごと無効になり、
751
+ * 「選んだのに何も起きない」になるため、フォールバックは必須。
752
+ */
753
+ const GRADIENT_PRESETS: { label: string; value: string }[] = [
754
+ { label: '緑(濃)', value: 'linear-gradient(180deg, var(--color-gg-green,#316a40) 0%, var(--color-gg-green-900,#1e4227) 100%)' },
755
+ { label: '緑(淡)', value: 'linear-gradient(180deg, var(--color-gg-green-50,#eaf1ec) 0%, var(--color-gg-green-200,#c2d5c8) 100%)' },
756
+ { label: '白→グレー', value: 'linear-gradient(180deg, #ffffff 0%, var(--color-rule-weak,#e8ebe8) 100%)' },
757
+ { label: '墨', value: 'linear-gradient(135deg, var(--color-ink-70,#3a3a3a) 0%, var(--color-ink-900,#1c1c1c) 100%)' },
758
+ { label: '暖色', value: 'linear-gradient(135deg, var(--color-gg-red-600,#d64426) 0%, var(--color-gg-red-900,#7a1000) 100%)' },
759
+ { label: '寒色', value: 'linear-gradient(135deg, #5b8db8 0%, #1e3a5f 100%)' },
760
+ ];
761
+
762
+ /** カスタムグラデーションの向き(CSSの角度は 0deg=上へ, 90deg=右へ) */
763
+ const GRADIENT_DIRS: { label: string; deg: number; title: string }[] = [
764
+ { label: '↑', deg: 0, title: '下から上へ' },
765
+ { label: '↗', deg: 45, title: '左下から右上へ' },
766
+ { label: '→', deg: 90, title: '左から右へ' },
767
+ { label: '↘', deg: 135, title: '左上から右下へ' },
768
+ { label: '↓', deg: 180, title: '上から下へ' },
769
+ ];
770
+
771
+ /**
772
+ * グラデーション塗り(プリセット6種 + 2色カスタム)。
773
+ * 単色に戻す・塗りを消すのは呼び出し側(背景画像と背景色の打ち消し合いを1箇所で見る)。
774
+ */
775
+ function GradientPanel({ pal, onPick }: { pal: Palette; onPick: (css: string) => void }) {
776
+ const [from, setFrom] = useState('#4c8a5c');
777
+ const [to, setTo] = useState('#1e4227');
778
+ const [deg, setDeg] = useState(180);
779
+ const css = (f: string, t: string, d: number) => `linear-gradient(${d}deg, ${f} 0%, ${t} 100%)`;
780
+
781
+ return (
782
+ <div className="px-2 py-1.5">
783
+ <div className="mb-1 text-[11px]" style={{ color: pal.sub }}>グラデーション</div>
784
+ <div className="grid grid-cols-6 gap-1">
785
+ {GRADIENT_PRESETS.map((g) => (
786
+ <button
787
+ key={g.label}
788
+ title={g.label}
789
+ data-gradient-preset={g.label}
790
+ // 押した瞬間に iframe 内の選択が捨てられないよう mousedown を止める
791
+ onMouseDown={(e) => e.preventDefault()}
792
+ onClick={() => onPick(g.value)}
793
+ className="h-5 w-5 rounded-[3px] border"
794
+ style={{ backgroundImage: g.value, borderColor: pal.border }}
795
+ />
796
+ ))}
797
+ </div>
798
+ {/* カスタム: 2色。色ピッカーは preventDefault するとネイティブUIが開かないので素通し。
799
+ スポイトはどちらの色に入るか迷わないよう、色ごとに1つずつ置く */}
800
+ <div className="mt-1.5 flex items-center gap-1 text-[11px]" style={{ color: pal.sub }}>
801
+ <span className="w-6">開始</span>
802
+ <input
803
+ type="color" value={from} title="開始色" data-gradient-from="1"
804
+ onChange={(e) => { setFrom(e.target.value); onPick(css(e.target.value, to, deg)); }}
805
+ className="h-5 w-7 cursor-pointer rounded-[3px] border bg-transparent p-0"
806
+ style={{ borderColor: pal.border }}
807
+ />
808
+ <EyeDropperButton pal={pal} title="スポイトで開始色を取得"
809
+ onPick={(hex) => { setFrom(hex); onPick(css(hex, to, deg)); }} />
810
+ <span className="ml-1 w-6">終了</span>
811
+ <input
812
+ type="color" value={to} title="終了色" data-gradient-to="1"
813
+ onChange={(e) => { setTo(e.target.value); onPick(css(from, e.target.value, deg)); }}
814
+ className="h-5 w-7 cursor-pointer rounded-[3px] border bg-transparent p-0"
815
+ style={{ borderColor: pal.border }}
816
+ />
817
+ <EyeDropperButton pal={pal} title="スポイトで終了色を取得"
818
+ onPick={(hex) => { setTo(hex); onPick(css(from, hex, deg)); }} />
819
+ </div>
820
+ <div className="mt-1 flex items-center gap-1">
821
+ {GRADIENT_DIRS.map((d) => (
822
+ <button
823
+ key={d.deg}
824
+ title={d.title}
825
+ onMouseDown={(e) => e.preventDefault()}
826
+ onClick={() => { setDeg(d.deg); onPick(css(from, to, d.deg)); }}
827
+ className="h-5 w-5 rounded-[3px] border text-[11px] leading-none"
828
+ style={{
829
+ borderColor: deg === d.deg ? PPT_ACCENT : pal.border,
830
+ color: pal.text,
831
+ backgroundColor: deg === d.deg ? pal.activeBg : undefined,
832
+ }}
833
+ >
834
+ {d.label}
835
+ </button>
836
+ ))}
837
+ </div>
838
+ <button
839
+ onMouseDown={(e) => e.preventDefault()}
840
+ onClick={() => onPick(css(from, to, deg))}
841
+ title="このグラデーションを適用"
842
+ className="mt-1 block h-4 w-full rounded-[3px] border"
843
+ style={{ backgroundImage: css(from, to, deg), borderColor: pal.border }}
844
+ />
845
+ </div>
846
+ );
847
+ }
848
+
849
+ /* ============================ 配置(整列) ============================ */
850
+
851
+ /** 整列ドロップダウンの1行 */
852
+ function AlignRow({
853
+ pal, icon: Icon, label, disabled, onClick,
854
+ }: {
855
+ pal: Palette;
856
+ icon: React.ComponentType<{ className?: string }>;
857
+ label: string;
858
+ disabled?: boolean;
859
+ onClick: () => void;
860
+ }) {
861
+ return (
862
+ <button
863
+ disabled={disabled}
864
+ // 押した瞬間に iframe 内の選択が捨てられると、整列する相手が居なくなる
865
+ onMouseDown={(e) => e.preventDefault()}
866
+ onClick={onClick}
867
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors"
868
+ style={{ color: disabled ? pal.disabled : pal.text }}
869
+ onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.backgroundColor = pal.hover; }}
870
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
871
+ >
872
+ <Icon className="h-3.5 w-3.5 shrink-0" />
873
+ {label}
874
+ </button>
875
+ );
876
+ }
877
+
878
+ /**
879
+ * 整列ドロップダウンの「配置」セクション。ホームタブと図の書式タブで共用する。
880
+ * 複数選択なら選択群、単一選択ならアートボードが基準(align-elements.ts)。
881
+ */
882
+ function AlignSection({
883
+ pal, count, tailLabel, onAlign, onDistribute,
884
+ }: {
885
+ pal: Palette;
886
+ /** 選択中の要素数。0なら全無効、3未満なら等間隔を無効(PowerPointと同じ) */
887
+ count: number;
888
+ /** 下に続く既存セクション(重ね順など)の見出し */
889
+ tailLabel: string;
890
+ onAlign: (mode: AlignMode) => void;
891
+ onDistribute: (axis: DistributeAxis) => void;
892
+ }) {
893
+ const none = count === 0;
894
+ return (
895
+ <div className="pb-1">
896
+ <div className="px-3 py-1 text-[11px]" style={{ color: pal.sub }}>配置</div>
897
+ <AlignRow pal={pal} icon={AlignHorizontalJustifyStart} label="左揃え" disabled={none} onClick={() => onAlign('left')} />
898
+ <AlignRow pal={pal} icon={AlignHorizontalJustifyCenter} label="左右中央揃え" disabled={none} onClick={() => onAlign('hcenter')} />
899
+ <AlignRow pal={pal} icon={AlignHorizontalJustifyEnd} label="右揃え" disabled={none} onClick={() => onAlign('right')} />
900
+ <AlignRow pal={pal} icon={AlignVerticalJustifyStart} label="上揃え" disabled={none} onClick={() => onAlign('top')} />
901
+ <AlignRow pal={pal} icon={AlignVerticalJustifyCenter} label="上下中央揃え" disabled={none} onClick={() => onAlign('vcenter')} />
902
+ <AlignRow pal={pal} icon={AlignVerticalJustifyEnd} label="下揃え" disabled={none} onClick={() => onAlign('bottom')} />
903
+ <AlignRow pal={pal} icon={AlignHorizontalSpaceAround} label="左右に整列" disabled={count < 3} onClick={() => onDistribute('h')} />
904
+ <AlignRow pal={pal} icon={AlignVerticalSpaceAround} label="上下に整列" disabled={count < 3} onClick={() => onDistribute('v')} />
905
+ <div className="mx-2 my-1 border-t" style={{ borderColor: pal.border }} />
906
+ <div className="px-3 py-1 text-[11px]" style={{ color: pal.sub }}>{tailLabel}</div>
907
+ </div>
908
+ );
909
+ }
910
+
911
+ /** 表の行×列を選ぶグリッド(実機の「表の挿入」) */
912
+ function TableGridPicker({ pal, onPick }: { pal: Palette; onPick: (rows: number, cols: number) => void }) {
913
+ const [hover, setHover] = useState<{ r: number; c: number }>({ r: 0, c: 0 });
914
+ return (
915
+ <div className="px-2 py-1.5">
916
+ <div className="mb-1 text-[11px]" style={{ color: pal.sub }}>
917
+ {hover.r > 0 ? `${hover.c} 列 × ${hover.r} 行の表` : '表のサイズを選択'}
918
+ </div>
919
+ <div className="grid grid-cols-8 gap-[3px]" onMouseLeave={() => setHover({ r: 0, c: 0 })}>
920
+ {Array.from({ length: 64 }, (_, i) => {
921
+ const r = Math.floor(i / 8) + 1;
922
+ const c = (i % 8) + 1;
923
+ const on = r <= hover.r && c <= hover.c;
924
+ return (
925
+ <button
926
+ key={i}
927
+ onMouseEnter={() => setHover({ r, c })}
928
+ onClick={() => onPick(r, c)}
929
+ className="h-4 w-4 rounded-[2px] border"
930
+ style={{
931
+ borderColor: on ? '#0F6CBD' : pal.border,
932
+ backgroundColor: on ? 'rgba(15,108,189,0.25)' : pal.control,
933
+ }}
934
+ />
935
+ );
936
+ })}
937
+ </div>
938
+ </div>
939
+ );
940
+ }
941
+
942
+ /** アイコン検索ピッカー(Material Symbols)。glyph表示は index.css のフォント読込に依存 */
943
+ function IconPicker({ pal, onPick }: { pal: Palette; onPick: (name: string) => void }) {
944
+ const [q, setQ] = useState('');
945
+ const kw = q.trim().toLowerCase();
946
+ const list = MATERIAL_ICONS.filter((it) => !kw || it.name.includes(kw) || it.ja.includes(q.trim()));
947
+ return (
948
+ <div className="w-[264px] px-2 py-1.5">
949
+ <input
950
+ value={q}
951
+ onChange={(e) => setQ(e.target.value)}
952
+ placeholder="検索(例: グラフ, home)…"
953
+ autoFocus
954
+ className="mb-1.5 w-full rounded border px-2 py-1 text-[12px] outline-none"
955
+ style={{ backgroundColor: pal.control, borderColor: pal.border, color: pal.text }}
956
+ />
957
+ <div className="grid max-h-[240px] grid-cols-7 gap-0.5 overflow-y-auto">
958
+ {list.map((it) => (
959
+ <button
960
+ key={it.name}
961
+ title={`${it.ja} (${it.name})`}
962
+ onClick={() => onPick(it.name)}
963
+ className="flex h-8 w-8 items-center justify-center rounded"
964
+ style={{ color: pal.text }}
965
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
966
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
967
+ >
968
+ <span className="material-symbols-outlined" style={{ fontSize: 22 }}>{it.name}</span>
969
+ </button>
970
+ ))}
971
+ {list.length === 0 && (
972
+ <div className="col-span-7 py-3 text-center text-[11px]" style={{ color: pal.sub }}>見つかりません</div>
973
+ )}
974
+ </div>
975
+ </div>
976
+ );
977
+ }
978
+
979
+ /** 図形のサムネイル(実際のclip-pathで描くので見た目と結果が一致する) */
980
+ function ShapeThumb({ def, color }: { def: ShapeDef; color: string }) {
981
+ const st = shapeStyles(def);
982
+ const w = 26;
983
+ const h = Math.max(10, Math.min(26, Math.round(26 * (def.h / def.w))));
984
+ return (
985
+ <span
986
+ style={{
987
+ display: 'block',
988
+ width: w,
989
+ height: h,
990
+ backgroundColor: st.backgroundColor === 'transparent' ? 'transparent' : color,
991
+ opacity: 0.9,
992
+ clipPath: st.clipPath,
993
+ borderRadius: st.borderRadius,
994
+ border: st.border ? `3px solid ${color}` : undefined,
995
+ boxSizing: 'border-box',
996
+ }}
997
+ />
998
+ );
999
+ }
1000
+
1001
+ /**
1002
+ * 図形ギャラリー(実機の「図形」メニュー)。
1003
+ * 図形を選ぶとキャンバスがドラッグ待ちになり、
1004
+ * ドラッグすればその大きさ、クリックだけなら既定サイズで作られる。
1005
+ */
1006
+ function ShapeMenuContent({
1007
+ pal, close, setTool, pickShape, activeShapeId,
1008
+ }: {
1009
+ pal: Palette;
1010
+ close: () => void;
1011
+ setTool: (t: EditorTool) => void;
1012
+ pickShape: (def: ShapeDef) => void;
1013
+ activeShapeId?: string;
1014
+ }) {
1015
+ const lines: { icon: React.ComponentType<{ className?: string }>; label: string; tool: EditorTool }[] = [
1016
+ { icon: Minus, label: '直線', tool: 'line' },
1017
+ { icon: MoveUpRight, label: '矢印', tool: 'arrow' },
1018
+ { icon: PenTool, label: 'ペン(曲線)', tool: 'pen' },
1019
+ { icon: Pencil, label: 'フリーハンド', tool: 'pencil' },
1020
+ { icon: Type, label: 'テキストボックス', tool: 'text' },
1021
+ ];
1022
+ return (
1023
+ <div className="max-h-[420px] w-[300px] overflow-y-auto">
1024
+ <div className="px-2 pt-1.5 text-[10px]" style={{ color: pal.sub }}>
1025
+ 図形を選んでからキャンバスをドラッグ(クリックだけなら既定サイズ)
1026
+ </div>
1027
+ <div className="px-2 pb-1 pt-2 text-[10.5px] font-semibold" style={{ color: pal.sub }}>線</div>
1028
+ <div className="flex items-center gap-1 px-2">
1029
+ {lines.map((t) => (
1030
+ <button
1031
+ key={t.tool}
1032
+ title={t.label}
1033
+ onClick={() => { setTool(t.tool); close(); }}
1034
+ className="flex h-8 w-8 items-center justify-center rounded"
1035
+ style={{ color: pal.text }}
1036
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
1037
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
1038
+ >
1039
+ <t.icon className="h-4 w-4" />
1040
+ </button>
1041
+ ))}
1042
+ </div>
1043
+ {SHAPE_GROUPS.map((g) => (
1044
+ <div key={g.group}>
1045
+ <div className="px-2 pb-1 pt-2 text-[10.5px] font-semibold" style={{ color: pal.sub }}>{g.group}</div>
1046
+ <div className="grid grid-cols-8 gap-0.5 px-2">
1047
+ {g.shapes.map((sh) => (
1048
+ <button
1049
+ key={sh.id}
1050
+ title={sh.label}
1051
+ onClick={() => { pickShape(sh); close(); }}
1052
+ className="flex h-8 w-8 items-center justify-center rounded"
1053
+ style={{ backgroundColor: activeShapeId === sh.id ? pal.activeBg : undefined }}
1054
+ onMouseEnter={(e) => { if (activeShapeId !== sh.id) e.currentTarget.style.backgroundColor = pal.hover; }}
1055
+ onMouseLeave={(e) => { if (activeShapeId !== sh.id) e.currentTarget.style.backgroundColor = ''; }}
1056
+ >
1057
+ <ShapeThumb def={sh} color={pal.text} />
1058
+ </button>
1059
+ ))}
1060
+ </div>
1061
+ </div>
1062
+ ))}
1063
+ <div className="h-2" />
1064
+ </div>
1065
+ );
1066
+ }
1067
+
1068
+ const TABS: { id: TabId; label: string; enabled: boolean }[] = [
1069
+ { id: 'home', label: 'ホーム', enabled: true },
1070
+ { id: 'insert', label: '挿入', enabled: true },
1071
+ { id: 'draw', label: '描画', enabled: true },
1072
+ { id: 'design', label: 'デザイン', enabled: true },
1073
+ { id: 'transition', label: '画面切り替え', enabled: true },
1074
+ { id: 'animation', label: 'アニメーション', enabled: true },
1075
+ { id: 'slideshow', label: 'スライド ショー', enabled: true },
1076
+ { id: 'review', label: '校閲', enabled: true },
1077
+ { id: 'view', label: '表示', enabled: true },
1078
+ ];
1079
+
1080
+ export function PptRibbon({
1081
+ actions, theme, onToggleTheme, onSwitchUi, page, deckTitle, comments,
1082
+ }: {
1083
+ actions: PptActions;
1084
+ theme: PptTheme;
1085
+ onToggleTheme: () => void;
1086
+ onSwitchUi: () => void;
1087
+ page: number;
1088
+ deckTitle?: string;
1089
+ comments: { open: boolean; toggle: () => void; newComment: () => void };
1090
+ }) {
1091
+ const pal = PALETTES[theme];
1092
+ const [tab, setTab] = useState<TabId>('home');
1093
+ /** デザイン提案パネル(生成〜適用は数分かかるのでモーダルで進捗を出す) */
1094
+ const [designOpen, setDesignOpen] = useState(false);
1095
+ const { apply: applyRaw, applyAttr, readAttr, readComputed, targets, hasSelection } = useSelectionStyle();
1096
+ const { getIframeDoc, notifyIframeChange, iframeReady } = useEditorContext();
1097
+ const deck = useDeck();
1098
+ const currentEntry = deck.slides[page - 1];
1099
+ const { selectedElement, selectedElementIds, zoom, setZoom, fitZoom } = useEditorContext();
1100
+ const [rev, setRev] = useState(0);
1101
+ // フォント一覧(デバイスフォント + Google Fonts)。Figma風UIの右パネルと同じフック
1102
+ const { fonts, systemFonts, loadFontInIframe } = useGoogleFonts();
1103
+ const cropRef = useRef<CropSession | null>(null);
1104
+ const [cropping, setCropping] = useState(false);
1105
+
1106
+ /** 選択中の<img>(トリミング対象)。ラッパー(.gg-crop)選択時は中の画像 */
1107
+ const selectedImage = useMemo((): HTMLImageElement | null => {
1108
+ const doc = getIframeDoc();
1109
+ if (!doc || !selectedElement) return null;
1110
+ const el = doc.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`);
1111
+ if (!el) return null;
1112
+ if (el.tagName === 'IMG') return el as HTMLImageElement;
1113
+ if (el.classList.contains('gg-crop')) return el.querySelector('img');
1114
+ return null;
1115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1116
+ }, [selectedElement, getIframeDoc, rev]);
1117
+
1118
+ const toggleCrop = useCallback(() => {
1119
+ if (cropRef.current?.active) {
1120
+ cropRef.current.commit();
1121
+ return;
1122
+ }
1123
+ const doc = getIframeDoc();
1124
+ if (!doc || !selectedImage) return;
1125
+ cropRef.current = enterCropMode(doc, selectedImage, (changed) => {
1126
+ setCropping(false);
1127
+ cropRef.current = null;
1128
+ if (changed) notifyIframeChange();
1129
+ });
1130
+ if (cropRef.current) setCropping(true);
1131
+ }, [getIframeDoc, selectedImage, notifyIframeChange]);
1132
+
1133
+ /**
1134
+ * 実機PowerPointの挙動: 画像をダブルクリックすると「図の書式設定」タブが開き、
1135
+ * タブが開いた状態でもう一度ダブルクリックするとトリミングに入る。
1136
+ *
1137
+ * 選択状態(selectedImage)はダブルクリック時点ではまだReactに伝播していない
1138
+ * ことがあるため、クロップはイベントの対象画像から直接開始する。
1139
+ */
1140
+ const tabRef = useRef(tab);
1141
+ useEffect(() => { tabRef.current = tab; }, [tab]);
1142
+
1143
+ const startCropOn = useCallback(
1144
+ (img: HTMLImageElement) => {
1145
+ if (cropRef.current?.active) return;
1146
+ const doc = getIframeDoc();
1147
+ if (!doc) return;
1148
+ cropRef.current = enterCropMode(doc, img, (changed) => {
1149
+ setCropping(false);
1150
+ cropRef.current = null;
1151
+ if (changed) notifyIframeChange();
1152
+ });
1153
+ if (cropRef.current) setCropping(true);
1154
+ },
1155
+ [getIframeDoc, notifyIframeChange],
1156
+ );
1157
+
1158
+ useEffect(() => {
1159
+ const doc = getIframeDoc();
1160
+ if (!doc) return;
1161
+ const onDblClick = (e: MouseEvent) => {
1162
+ if (doc.body.classList.contains('gg-cropping')) return; // クロップ中はそちらに任せる
1163
+ const t = e.target as HTMLElement;
1164
+ const img =
1165
+ t.tagName === 'IMG'
1166
+ ? (t as HTMLImageElement)
1167
+ : t.closest?.('.gg-crop, [data-gg-fx-host]')?.querySelector('img') ?? null;
1168
+ if (!img) return;
1169
+ if (tabRef.current !== 'pictureformat') {
1170
+ setTab('pictureformat');
1171
+ } else {
1172
+ startCropOn(img);
1173
+ }
1174
+ };
1175
+ doc.addEventListener('dblclick', onDblClick);
1176
+ return () => doc.removeEventListener('dblclick', onDblClick);
1177
+ }, [getIframeDoc, iframeReady, startCropOn]);
1178
+
1179
+
1180
+ const apply = useCallback(
1181
+ (styles: Record<string, string>) => {
1182
+ applyRaw(styles);
1183
+ setRev((n) => n + 1);
1184
+ },
1185
+ [applyRaw],
1186
+ );
1187
+
1188
+ /**
1189
+ * 図形の塗り。background-image は background-color の上に描かれるので、
1190
+ * 単色を選んだらグラデーションを消し、グラデーションを選んだら下の色は残さない
1191
+ * (打ち消し合いを1箇所で見ないと「選んだのに変わらない」が起きる)。
1192
+ * '' ではなく 'none' を書くのは、クラス由来のグラデーションにも勝つため。
1193
+ */
1194
+ const setFill = useCallback((color: string) => {
1195
+ apply({ backgroundColor: color, backgroundImage: 'none' });
1196
+ }, [apply]);
1197
+
1198
+ const setGradientFill = useCallback((css: string) => {
1199
+ apply({ backgroundImage: css });
1200
+ }, [apply]);
1201
+
1202
+ const clearFill = useCallback(() => {
1203
+ apply({ backgroundColor: 'transparent', backgroundImage: 'none' });
1204
+ }, [apply]);
1205
+
1206
+ /** 位置を書き換えたあとの後始末(履歴へ積み、選択枠を描き直す) */
1207
+ const afterMove = useCallback((moved: HTMLElement[]) => {
1208
+ if (!moved.length) return;
1209
+ notifyIframeChange();
1210
+ const doc = getIframeDoc();
1211
+ if (doc) {
1212
+ requestAnimationFrame(() => {
1213
+ for (const el of moved) updateSelectionBox(doc, el);
1214
+ });
1215
+ }
1216
+ setRev((n) => n + 1);
1217
+ }, [notifyIframeChange, getIframeDoc]);
1218
+
1219
+ const runAlign = useCallback((mode: AlignMode) => {
1220
+ afterMove(alignElements(targets(), mode));
1221
+ }, [targets, afterMove]);
1222
+
1223
+ const runDistribute = useCallback((axis: DistributeAxis) => {
1224
+ afterMove(distributeElements(targets(), axis));
1225
+ }, [targets, afterMove]);
1226
+
1227
+ /** 蛍光ペン: 箱ではなく文字に効かせる(範囲選択があればその範囲だけ) */
1228
+ const highlight = useCallback(
1229
+ (color: string | null) => {
1230
+ const doc = getIframeDoc();
1231
+ if (!doc) return;
1232
+ if (applyTextHighlight(doc, targets(), color)) notifyIframeChange();
1233
+ setRev((n) => n + 1);
1234
+ },
1235
+ [getIframeDoc, targets, notifyIframeChange],
1236
+ );
1237
+
1238
+ /** 文字色: 範囲選択があればその範囲だけ、無ければ要素へ */
1239
+ const textColor = useCallback(
1240
+ (color: string) => {
1241
+ const doc = getIframeDoc();
1242
+ if (!doc) return;
1243
+ if (applyTextColor(doc, targets(), color)) notifyIframeChange();
1244
+ setRev((n) => n + 1);
1245
+ },
1246
+ [getIframeDoc, targets, notifyIframeChange],
1247
+ );
1248
+ // 実機同様、選択が無くなったらコンテキストタブを閉じる
1249
+ useEffect(() => {
1250
+ if (tab === 'shapeformat' && !hasSelection) setTab('home');
1251
+ if (tab === 'pictureformat' && !selectedImage) {
1252
+ // ダブルクリック直後は選択がまだReactへ伝播していないことがある。
1253
+ // DOM上の選択(.selected)に画像が居るなら閉じない
1254
+ const doc = getIframeDoc();
1255
+ const sel = doc?.querySelector('.selected');
1256
+ const imgSelected =
1257
+ !!sel && (sel.tagName === 'IMG' || !!(sel as HTMLElement).querySelector?.('img'));
1258
+ if (!imgSelected) setTab('home');
1259
+ }
1260
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1261
+ }, [tab, hasSelection, selectedImage]);
1262
+
1263
+ /** 画像へスタイルを当てる(.gg-cropで包まれている場合も中の<img>に効かせる) */
1264
+ const applyToImage = useCallback(
1265
+ (styles: Record<string, string>) => {
1266
+ if (!selectedImage) return;
1267
+ for (const [k, v] of Object.entries(styles)) {
1268
+ (selectedImage.style as unknown as Record<string, string>)[k] = v;
1269
+ }
1270
+ notifyIframeChange();
1271
+ setRev((n) => n + 1);
1272
+ },
1273
+ [selectedImage, notifyIframeChange],
1274
+ );
1275
+
1276
+ /** 回転・反転(transform をまとめて置き換える) */
1277
+ const transformImage = useCallback(
1278
+ (kind: 'cw' | 'ccw' | 'flipH' | 'flipV' | 'reset') => {
1279
+ if (!selectedImage) return;
1280
+ const cur = selectedImage.style.transform || '';
1281
+ const deg = Number(/rotate\((-?\d+)deg\)/.exec(cur)?.[1] ?? 0);
1282
+ const flipH = /scaleX\(-1\)/.test(cur);
1283
+ const flipV = /scaleY\(-1\)/.test(cur);
1284
+ let next = '';
1285
+ if (kind === 'reset') next = '';
1286
+ else {
1287
+ const d = kind === 'cw' ? deg + 90 : kind === 'ccw' ? deg - 90 : deg;
1288
+ const h = kind === 'flipH' ? !flipH : flipH;
1289
+ const v = kind === 'flipV' ? !flipV : flipV;
1290
+ next = [d % 360 !== 0 ? `rotate(${d % 360}deg)` : '', h ? 'scaleX(-1)' : '', v ? 'scaleY(-1)' : '']
1291
+ .filter(Boolean)
1292
+ .join(' ');
1293
+ }
1294
+ applyToImage({ transform: next });
1295
+ },
1296
+ [selectedImage, applyToImage],
1297
+ );
1298
+
1299
+ /** 枠線の色/太さを変えるとき、枠線が無ければ実線を立てる(PowerPointの挙動) */
1300
+ const ensureBorder = useCallback((styles: Record<string, string>) => {
1301
+ const w = parseFloat(readComputed('border-top-width'));
1302
+ const st = readComputed('border-top-style');
1303
+ const out = { ...styles };
1304
+ if (!('borderWidth' in out) && (!Number.isFinite(w) || w === 0)) out.borderWidth = '2px';
1305
+ if (!('borderStyle' in out) && (st === 'none' || st === '')) out.borderStyle = 'solid';
1306
+ apply(out);
1307
+ }, [readComputed, apply]);
1308
+
1309
+ const borderRadiusPx = useMemo(() => {
1310
+ const v = parseFloat(readComputed('border-radius'));
1311
+ return Number.isFinite(v) ? Math.round(v) : 0;
1312
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1313
+ }, [selectedElement, selectedElementIds, readComputed, rev]);
1314
+
1315
+ const opacityPct = useMemo(() => {
1316
+ const v = parseFloat(readComputed('opacity'));
1317
+ return Number.isFinite(v) ? Math.round(v * 100) : 100;
1318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1319
+ }, [selectedElement, selectedElementIds, readComputed, rev]);
1320
+
1321
+ const sizeWH = useMemo(() => {
1322
+ const w = parseFloat(readComputed('width'));
1323
+ const h = parseFloat(readComputed('height'));
1324
+ return {
1325
+ w: Number.isFinite(w) ? Math.round(w) : '',
1326
+ h: Number.isFinite(h) ? Math.round(h) : '',
1327
+ };
1328
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1329
+ }, [selectedElement, selectedElementIds, readComputed, rev]);
1330
+
1331
+ const fontSize = useMemo(() => {
1332
+ const v = parseFloat(readComputed('font-size'));
1333
+ return Number.isFinite(v) ? Math.round(v) : '';
1334
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1335
+ }, [selectedElement, selectedElementIds, readComputed, rev]);
1336
+
1337
+ const isBold = useMemo(() => {
1338
+ const w = parseInt(readComputed('font-weight'), 10);
1339
+ return Number.isFinite(w) && w >= 600;
1340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1341
+ }, [selectedElement, selectedElementIds, readComputed, rev]);
1342
+
1343
+ /** 図形・表・アイコンをアートボード中央へ挿入する共通処理 */
1344
+ const insertAtCenter = useCallback(
1345
+ (w: number, build: (doc: Document, el: HTMLDivElement) => void) => {
1346
+ const doc = getIframeDoc();
1347
+ const artboard = doc?.getElementById('artboard');
1348
+ if (!doc || !artboard) return;
1349
+ const el = doc.createElement('div');
1350
+ el.setAttribute('data-element-id', generateElementId('shape'));
1351
+ el.setAttribute('data-editable', 'true');
1352
+ el.style.position = 'absolute';
1353
+ el.style.left = `${Math.round((1920 - w) / 2)}px`;
1354
+ el.style.width = `${w}px`;
1355
+ build(doc, el);
1356
+ const h = parseFloat(el.style.height) || 200;
1357
+ el.style.top = `${Math.round((1080 - h) / 2)}px`;
1358
+ artboard.appendChild(el);
1359
+ notifyIframeChange(true);
1360
+ },
1361
+ [getIframeDoc, notifyIframeChange],
1362
+ );
1363
+
1364
+ /** 図形ギャラリーで選ぶ = 次のドラッグ/クリックでその図形を作る */
1365
+ const pickShape = useCallback((def: ShapeDef) => {
1366
+ setPendingShape(def);
1367
+ actions.setActiveTool('shape');
1368
+ setRev((n) => n + 1);
1369
+ }, [actions]);
1370
+
1371
+ const insertTable = useCallback((rows: number, cols: number) => {
1372
+ const w = Math.min(1600, Math.max(560, cols * 200));
1373
+ insertAtCenter(w, (_doc, el) => {
1374
+ el.setAttribute('data-shape-type', 'table');
1375
+ const th = (i: number) =>
1376
+ `<th style="border:1px solid var(--color-rule); padding:12px 16px; background:var(--color-surface); font-weight:700; text-align:left;">項目${i + 1}</th>`;
1377
+ const td = '<td style="border:1px solid var(--color-rule); padding:12px 16px;">テキスト</td>';
1378
+ const head = Array.from({ length: cols }, (_, i) => th(i)).join('');
1379
+ const body = Array.from({ length: Math.max(0, rows - 1) }, () => `<tr>${Array.from({ length: cols }, () => td).join('')}</tr>`).join('');
1380
+ el.innerHTML =
1381
+ `<table style="width:100%; border-collapse:collapse; background:var(--color-paper); font-size:22px; line-height:1.5; color:var(--color-ink);">` +
1382
+ `<thead><tr>${head}</tr></thead>${body ? `<tbody>${body}</tbody>` : ''}</table>`;
1383
+ });
1384
+ }, [insertAtCenter]);
1385
+
1386
+ const insertIcon = useCallback((name: string) => {
1387
+ insertAtCenter(96, (_doc, el) => {
1388
+ el.setAttribute('data-shape-type', 'icon');
1389
+ el.style.height = '96px';
1390
+ el.innerHTML = `<span class="material-symbols-outlined" style="font-size:96px; color:var(--color-ink);">${name}</span>`;
1391
+ });
1392
+ }, [insertAtCenter]);
1393
+
1394
+ /** 新しいスライド = 白紙をこのページの直後に挿入して移動 */
1395
+ const insertBlank = async () => {
1396
+ // 未保存があれば黙って保存してから移る。保存できなかったときだけ従来の確認に落とす
1397
+ if (!(await flushAutoSave())) {
1398
+ if (!window.confirm('保存に失敗しました。変更を破棄して新しいスライドへ移動しますか?')) return;
1399
+ }
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}`;
1406
+ };
1407
+
1408
+ const openSlideshow = () => {
1409
+ window.open(`${window.location.origin}${window.location.pathname}#/${page}?clean`, '_blank');
1410
+ };
1411
+
1412
+ const inputCls = 'rounded border px-1 text-[12px] outline-none';
1413
+ const inputStyle = { backgroundColor: pal.control, borderColor: pal.border, color: pal.text } as const;
1414
+
1415
+ return (
1416
+ <div style={{ backgroundColor: pal.chrome, color: pal.text }}>
1417
+ {/* タブ帯 */}
1418
+ <div className="flex items-center gap-0.5 border-b px-2" style={{ borderColor: pal.border }}>
1419
+ {TABS.map((t) => (
1420
+ <button
1421
+ key={t.id}
1422
+ disabled={!t.enabled}
1423
+ onClick={() => setTab(t.id)}
1424
+ title={t.enabled ? undefined : 'Web版では未対応の機能です'}
1425
+ className="relative whitespace-nowrap px-2.5 py-1.5 text-[12px] transition-colors"
1426
+ style={{ color: !t.enabled ? pal.disabled : tab === t.id ? pal.text : pal.sub }}
1427
+ >
1428
+ {t.label}
1429
+ {tab === t.id && t.enabled && (
1430
+ <span
1431
+ className="absolute bottom-0 left-2 right-2 h-[2.5px] rounded-full"
1432
+ style={{ backgroundColor: theme === 'dark' ? '#ffffff' : PPT_ACCENT }}
1433
+ />
1434
+ )}
1435
+ </button>
1436
+ ))}
1437
+ {selectedImage && (
1438
+ <button
1439
+ onClick={() => setTab('pictureformat')}
1440
+ className="relative whitespace-nowrap px-2.5 py-1.5 text-[12px] transition-colors"
1441
+ style={{ color: tab === 'pictureformat' ? PPT_ACCENT : pal.sub, fontWeight: 500 }}
1442
+ title="選択中の画像の書式(実機のコンテキストタブ相当)"
1443
+ >
1444
+ 図の書式設定
1445
+ {tab === 'pictureformat' && (
1446
+ <span className="absolute bottom-0 left-2 right-2 h-[2.5px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
1447
+ )}
1448
+ </button>
1449
+ )}
1450
+ {hasSelection && (
1451
+ <button
1452
+ onClick={() => setTab('shapeformat')}
1453
+ className="relative whitespace-nowrap px-2.5 py-1.5 text-[12px] transition-colors"
1454
+ style={{ color: tab === 'shapeformat' ? PPT_ACCENT : pal.sub, fontWeight: 500 }}
1455
+ title="選択中の図形・要素の書式(実機のコンテキストタブ相当)"
1456
+ >
1457
+ 図形の書式
1458
+ {tab === 'shapeformat' && (
1459
+ <span className="absolute bottom-0 left-2 right-2 h-[2.5px] rounded-full" style={{ backgroundColor: PPT_ACCENT }} />
1460
+ )}
1461
+ </button>
1462
+ )}
1463
+ <div className="ml-auto flex items-center gap-1.5 py-1">
1464
+ <button
1465
+ onClick={comments.toggle}
1466
+ title="コメントパネルの表示/非表示"
1467
+ className="flex items-center gap-1 rounded-md border px-2 py-1 text-[12px]"
1468
+ style={{
1469
+ borderColor: pal.border,
1470
+ color: comments.open ? '#0F6CBD' : pal.sub,
1471
+ backgroundColor: comments.open ? pal.activeBg : 'transparent',
1472
+ }}
1473
+ >
1474
+ <MessageSquare className="h-3.5 w-3.5" />
1475
+ コメント
1476
+ {unresolvedCount(currentEntry?.comments) > 0 && (
1477
+ <span className="rounded-full bg-[#0F6CBD] px-1.5 text-[10px] font-bold text-white">
1478
+ {unresolvedCount(currentEntry?.comments)}
1479
+ </span>
1480
+ )}
1481
+ </button>
1482
+ {/* 共有(書き出し)は共通トップバー(EditorTopBar)に一本化した。
1483
+ ここに置くとタイトルバーと二重になる */}
1484
+ </div>
1485
+ </div>
1486
+
1487
+ {/* リボン本体 */}
1488
+ <div
1489
+ className="flex h-[80px] shrink-0 items-center gap-0 overflow-x-auto overflow-y-hidden border-b px-2"
1490
+ style={{ borderColor: pal.border }}
1491
+ >
1492
+ {tab === 'home' && (
1493
+ <>
1494
+ <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} title="白紙スライドをこの後ろに挿入" pal={pal} />
1495
+ <Sep pal={pal} />
1496
+ <div className="flex flex-col justify-center gap-1 px-1">
1497
+ <div className="flex items-center gap-1">
1498
+ <select
1499
+ disabled={!hasSelection}
1500
+ className={`${inputCls} h-6 w-[136px] disabled:opacity-40`}
1501
+ style={inputStyle}
1502
+ value=""
1503
+ onChange={(e) => {
1504
+ const family = e.target.value;
1505
+ if (!family) return;
1506
+ const font = fonts.find((f) => f.family === family);
1507
+ const doc = getIframeDoc();
1508
+ // Googleフォントはiframeへ読み込んでから適用する
1509
+ if (doc) loadFontInIframe(family, doc);
1510
+ const stack =
1511
+ font?.stack ??
1512
+ `"${family}", ${font?.category === 'serif' ? 'serif' : font?.category === 'monospace' ? 'monospace' : 'sans-serif'}`;
1513
+ apply({ fontFamily: stack });
1514
+ }}
1515
+ >
1516
+ <option value="">フォント</option>
1517
+ <optgroup label="デバイスフォント">
1518
+ {systemFonts.map((f) => (
1519
+ <option key={f.family} value={f.family}>{f.family}</option>
1520
+ ))}
1521
+ </optgroup>
1522
+ <optgroup label="Google Fonts">
1523
+ {fonts.filter((f) => !systemFonts.some((sf) => sf.family === f.family)).map((f) => (
1524
+ <option key={f.family} value={f.family}>{f.family}</option>
1525
+ ))}
1526
+ </optgroup>
1527
+ </select>
1528
+ <input
1529
+ type="number"
1530
+ list="gg-ppt-font-sizes"
1531
+ disabled={!hasSelection}
1532
+ className={`${inputCls} h-6 w-[56px] disabled:opacity-40`}
1533
+ style={inputStyle}
1534
+ value={fontSize}
1535
+ placeholder="pt"
1536
+ onChange={(e) => {
1537
+ const n = parseInt(e.target.value, 10);
1538
+ if (Number.isFinite(n) && n >= 6 && n <= 400) apply({ fontSize: `${n}px` });
1539
+ }}
1540
+ />
1541
+ <datalist id="gg-ppt-font-sizes">
1542
+ {[12, 14, 16, 18, 20, 24, 28, 32, 36, 44, 52, 64, 80, 96].map((n) => (
1543
+ <option key={n} value={n} />
1544
+ ))}
1545
+ </datalist>
1546
+ <SmallButton icon={AArrowUp} title="フォントサイズを大きく" disabled={!hasSelection}
1547
+ onClick={() => { const n = Number(fontSize) || 16; apply({ fontSize: `${Math.min(400, n + 2)}px` }); }} pal={pal} />
1548
+ <SmallButton icon={AArrowDown} title="フォントサイズを小さく" disabled={!hasSelection}
1549
+ onClick={() => { const n = Number(fontSize) || 16; apply({ fontSize: `${Math.max(6, n - 2)}px` }); }} pal={pal} />
1550
+ <SmallButton icon={RemoveFormatting} title="書式のクリア(文字の装飾を既定に戻す)" disabled={!hasSelection}
1551
+ onClick={() => apply({ fontWeight: '', fontStyle: '', textDecoration: '', letterSpacing: '', color: '', backgroundColor: '', verticalAlign: '' })} pal={pal} />
1552
+ </div>
1553
+ <div className="flex items-center gap-0.5">
1554
+ <SmallButton icon={Bold} title="太字" active={isBold} disabled={!hasSelection}
1555
+ onClick={() => apply({ fontWeight: isBold ? '400' : '700' })} pal={pal} />
1556
+ <SmallButton icon={Italic} title="斜体" disabled={!hasSelection}
1557
+ onClick={() => apply({ fontStyle: readComputed('font-style') === 'italic' ? 'normal' : 'italic' })} pal={pal} />
1558
+ <SmallButton icon={Underline} title="下線" disabled={!hasSelection}
1559
+ onClick={() => apply({ textDecoration: readComputed('text-decoration-line').includes('underline') ? 'none' : 'underline' })} pal={pal} />
1560
+ <SmallButton icon={Strikethrough} title="取り消し線" disabled={!hasSelection}
1561
+ onClick={() => apply({ textDecoration: readComputed('text-decoration-line').includes('line-through') ? 'none' : 'line-through' })} pal={pal} />
1562
+ <SmallButton icon={Superscript} title="上付き" disabled={!hasSelection}
1563
+ onClick={() => { const on = readComputed('vertical-align') === 'super'; apply({ verticalAlign: on ? 'baseline' : 'super', fontSize: on ? '' : '0.65em' }); }} pal={pal} />
1564
+ <SmallButton icon={Subscript} title="下付き" disabled={!hasSelection}
1565
+ onClick={() => { const on = readComputed('vertical-align') === 'sub'; apply({ verticalAlign: on ? 'baseline' : 'sub', fontSize: on ? '' : '0.65em' }); }} pal={pal} />
1566
+ <Dropdown
1567
+ pal={pal}
1568
+ trigger={<SmallButton icon={Baseline} title="文字の間隔" chevron pal={pal} />}
1569
+ items={[
1570
+ { label: '狭く (-0.02em)', onClick: () => apply({ letterSpacing: '-0.02em' }) },
1571
+ { label: '標準', onClick: () => apply({ letterSpacing: '0' }) },
1572
+ { label: '広く (0.06em)', onClick: () => apply({ letterSpacing: '0.06em' }) },
1573
+ { label: 'より広く (0.12em)', onClick: () => apply({ letterSpacing: '0.12em' }) },
1574
+ ]}
1575
+ />
1576
+ <Dropdown
1577
+ pal={pal}
1578
+ trigger={
1579
+ <span className="relative inline-flex h-6 w-7 cursor-pointer items-center justify-center rounded" title="蛍光ペン(文字の背景色)" style={{ color: pal.text }}>
1580
+ <Highlighter className="h-4 w-4" />
1581
+ <span className="absolute bottom-0.5 left-1 right-1 h-[3px]" style={{ backgroundColor: '#FFF176' }} />
1582
+ </span>
1583
+ }
1584
+ content={(close) => (
1585
+ <ColorPanel
1586
+ pal={pal}
1587
+ swatches={HIGHLIGHT_SWATCHES}
1588
+ onPick={(v) => { highlight(v); close(); }}
1589
+ onNone={() => { highlight(null); close(); }}
1590
+ noneLabel="色なし(蛍光ペンを消す)"
1591
+ />
1592
+ )}
1593
+ />
1594
+ <label
1595
+ className={`relative flex h-6 w-6 items-center justify-center rounded ${hasSelection ? 'cursor-pointer' : 'opacity-40'}`}
1596
+ title="文字色"
1597
+ style={{ color: pal.text }}
1598
+ >
1599
+ <Type className="h-4 w-4" />
1600
+ <span className="absolute bottom-0.5 left-1 right-1 h-[3px]" style={{ backgroundColor: PPT_ACCENT }} />
1601
+ <input type="color" disabled={!hasSelection} className="absolute inset-0 cursor-pointer opacity-0"
1602
+ onChange={(e) => textColor(e.target.value)} />
1603
+ </label>
1604
+ </div>
1605
+ </div>
1606
+ <Sep pal={pal} />
1607
+ {/* 段落グループ(実機の2段構成) */}
1608
+ <div className="flex flex-col justify-center gap-1">
1609
+ <div className="flex items-center gap-0.5">
1610
+ <SmallButton icon={List} title="箇条書き(▾で行頭記号を選択)" disabled={!hasSelection}
1611
+ onClick={() => { const on = readComputed('display') === 'list-item'; apply({ display: on ? '' : 'list-item', listStyleType: on ? '' : 'disc', listStylePosition: on ? '' : 'inside' }); }} pal={pal} />
1612
+ <Dropdown
1613
+ pal={pal}
1614
+ trigger={
1615
+ <button className="flex h-6 w-3 items-center justify-center rounded" title="行頭記号を選択" style={{ color: pal.sub }}>
1616
+ <ChevronDown className="h-3 w-3" />
1617
+ </button>
1618
+ }
1619
+ content={(close) => (
1620
+ <MarkGallery pal={pal} marks={BULLET_MARKS}
1621
+ onPick={(v) => { apply({ display: 'list-item', listStyleType: v, listStylePosition: 'inside' }); close(); }}
1622
+ onNone={() => { apply({ display: '', listStyleType: '', listStylePosition: '' }); close(); }} />
1623
+ )}
1624
+ />
1625
+ <SmallButton icon={ListOrdered} title="段落番号(▾で番号の種類を選択)" disabled={!hasSelection}
1626
+ onClick={() => { const on = readComputed('list-style-type') === 'decimal'; apply({ display: on ? '' : 'list-item', listStyleType: on ? '' : 'decimal', listStylePosition: on ? '' : 'inside' }); }} pal={pal} />
1627
+ <Dropdown
1628
+ pal={pal}
1629
+ trigger={
1630
+ <button className="flex h-6 w-3 items-center justify-center rounded" title="番号の種類を選択" style={{ color: pal.sub }}>
1631
+ <ChevronDown className="h-3 w-3" />
1632
+ </button>
1633
+ }
1634
+ content={(close) => (
1635
+ <MarkGallery pal={pal} marks={NUMBER_MARKS}
1636
+ onPick={(v) => { apply({ display: 'list-item', listStyleType: v, listStylePosition: 'inside' }); close(); }}
1637
+ onNone={() => { apply({ display: '', listStyleType: '', listStylePosition: '' }); close(); }} />
1638
+ )}
1639
+ />
1640
+ <SmallButton icon={IndentDecrease} title="インデントを減らす" disabled={!hasSelection}
1641
+ onClick={() => { const cur = parseFloat(readComputed('padding-left')) || 0; apply({ paddingLeft: `${Math.max(0, cur - 24)}px` }); }} pal={pal} />
1642
+ <SmallButton icon={IndentIncrease} title="インデントを増やす" disabled={!hasSelection}
1643
+ onClick={() => { const cur = parseFloat(readComputed('padding-left')) || 0; apply({ paddingLeft: `${cur + 24}px` }); }} pal={pal} />
1644
+ <Dropdown
1645
+ pal={pal}
1646
+ trigger={<SmallButton icon={AlignJustify} title="行間" chevron pal={pal} />}
1647
+ items={['1.0', '1.15', '1.3', '1.5', '1.8', '2.0'].map((v) => ({
1648
+ label: `行間 ${v}`,
1649
+ onClick: () => apply({ lineHeight: v }),
1650
+ }))}
1651
+ />
1652
+ </div>
1653
+ <div className="flex items-center gap-0.5">
1654
+ <SmallButton icon={AlignLeft} title="左揃え" disabled={!hasSelection} onClick={() => apply({ textAlign: 'left' })} pal={pal} />
1655
+ <SmallButton icon={AlignCenter} title="中央揃え" disabled={!hasSelection} onClick={() => apply({ textAlign: 'center' })} pal={pal} />
1656
+ <SmallButton icon={AlignRight} title="右揃え" disabled={!hasSelection} onClick={() => apply({ textAlign: 'right' })} pal={pal} />
1657
+ <SmallButton icon={AlignJustify} title="両端揃え" disabled={!hasSelection} onClick={() => apply({ textAlign: 'justify' })} pal={pal} />
1658
+ <SmallButton icon={TypeVertical} title="縦書き(もう一度で解除)" disabled={!hasSelection}
1659
+ onClick={() => { const on = readComputed('writing-mode') === 'vertical-rl'; apply({ writingMode: on ? '' : 'vertical-rl' }); }} pal={pal} />
1660
+ </div>
1661
+ </div>
1662
+ <Sep pal={pal} />
1663
+ <div className="flex flex-col justify-center gap-1">
1664
+ <div className="flex items-center gap-0.5">
1665
+ <SmallButton icon={Copy} title="複製" disabled={!actions.duplicateElement} onClick={actions.duplicateElement} pal={pal} />
1666
+ <SmallButton icon={Trash2} title="削除" disabled={!actions.deleteElement} onClick={actions.deleteElement} pal={pal} />
1667
+ <SmallButton
1668
+ icon={Crop}
1669
+ title={cropping ? 'トリミングを確定 (Enter)' : '画像をトリミング'}
1670
+ disabled={!selectedImage && !cropping}
1671
+ active={cropping}
1672
+ onClick={toggleCrop}
1673
+ pal={pal}
1674
+ />
1675
+ </div>
1676
+ </div>
1677
+ <Sep pal={pal} />
1678
+ <BigButton icon={ImageIcon} label="画像" onClick={actions.openFilePicker} pal={pal} />
1679
+ <Dropdown
1680
+ pal={pal}
1681
+ trigger={<BigButton icon={Square} label="図形" caret pal={pal} />}
1682
+ content={(close) => (
1683
+ <ShapeMenuContent pal={pal} close={close} setTool={actions.setActiveTool} pickShape={pickShape}
1684
+ activeShapeId={actions.activeTool === 'shape' ? pendingShape.id : undefined} />
1685
+ )}
1686
+ />
1687
+ <BigButton
1688
+ icon={Type}
1689
+ label={'テキスト\nボックス'}
1690
+ active={actions.activeTool === 'text'}
1691
+ onClick={() => actions.setActiveTool(actions.activeTool === 'text' ? 'select' : 'text')}
1692
+ pal={pal}
1693
+ />
1694
+ <Sep pal={pal} />
1695
+ <Dropdown
1696
+ pal={pal}
1697
+ trigger={<BigButton icon={LayoutGrid} label="整列" caret pal={pal} />}
1698
+ content={() => (
1699
+ <AlignSection
1700
+ pal={pal}
1701
+ count={targets().length}
1702
+ tailLabel="重ね順・グループ"
1703
+ onAlign={runAlign}
1704
+ onDistribute={runDistribute}
1705
+ />
1706
+ )}
1707
+ items={[
1708
+ { label: '最前面へ移動', onClick: actions.bringToFront, disabled: !actions.bringToFront },
1709
+ { label: '前面へ移動', onClick: actions.bringForward, disabled: !actions.bringForward },
1710
+ { label: '背面へ移動', onClick: actions.sendBackward, disabled: !actions.sendBackward },
1711
+ { label: '最背面へ移動', onClick: actions.sendToBack, disabled: !actions.sendToBack },
1712
+ { label: 'グループ化', onClick: actions.groupElements, disabled: !actions.groupElements },
1713
+ { label: 'グループ解除', onClick: actions.ungroupElements, disabled: !actions.ungroupElements },
1714
+ ]}
1715
+ />
1716
+ </>
1717
+ )}
1718
+
1719
+ {tab === 'insert' && (
1720
+ <>
1721
+ <BigButton icon={Plus} label={'新しい\nスライド'} onClick={() => void insertBlank()} pal={pal} />
1722
+ <Sep pal={pal} />
1723
+ <Dropdown
1724
+ pal={pal}
1725
+ trigger={<BigButton icon={Table} label="表" caret pal={pal} />}
1726
+ content={(close) => (
1727
+ <TableGridPicker pal={pal} onPick={(r, c) => { insertTable(r, c); close(); }} />
1728
+ )}
1729
+ />
1730
+ <Sep pal={pal} />
1731
+ <BigButton icon={ImageIcon} label="画像" onClick={actions.openFilePicker} pal={pal} />
1732
+ <BigButton icon={LayoutGrid} label="メディア" onClick={actions.openMediaLibrary} title="メディアライブラリ" pal={pal} />
1733
+ <Sep pal={pal} />
1734
+ <Dropdown
1735
+ pal={pal}
1736
+ trigger={<BigButton icon={Shapes} label="図形" caret pal={pal} />}
1737
+ content={(close) => (
1738
+ <ShapeMenuContent pal={pal} close={close} setTool={actions.setActiveTool} pickShape={pickShape}
1739
+ activeShapeId={actions.activeTool === 'shape' ? pendingShape.id : undefined} />
1740
+ )}
1741
+ />
1742
+ <Dropdown
1743
+ pal={pal}
1744
+ trigger={<BigButton icon={Sticker} label="アイコン" caret pal={pal} />}
1745
+ content={(close) => (
1746
+ <IconPicker pal={pal} onPick={(name) => { insertIcon(name); close(); }} />
1747
+ )}
1748
+ />
1749
+ <Sep pal={pal} />
1750
+ <BigButton
1751
+ icon={Type}
1752
+ label={'テキスト\nボックス'}
1753
+ active={actions.activeTool === 'text'}
1754
+ onClick={() => actions.setActiveTool(actions.activeTool === 'text' ? 'select' : 'text')}
1755
+ pal={pal}
1756
+ />
1757
+ <BigButton icon={Boxes} label={'コンポー\nネント'} onClick={actions.openComponents} pal={pal} />
1758
+ </>
1759
+ )}
1760
+
1761
+ {tab === 'draw' && (
1762
+ <>
1763
+ <BigButton icon={MousePointer2} label="選択" active={actions.activeTool === 'select'} onClick={() => actions.setActiveTool('select')} pal={pal} />
1764
+ <BigButton icon={Eraser} label={'消し\nゴム'} active={actions.activeTool === 'eraser'} onClick={() => actions.setActiveTool('eraser')} title="ペンのストロークをクリック/なぞって消す (E)" pal={pal} />
1765
+ <Sep pal={pal} />
1766
+ {/* 実機のペンギャラリー相当。選ぶとそのインクで描き始める(Escで終了) */}
1767
+ <div className="flex items-center gap-1 self-center rounded-md border px-1.5 py-1" style={{ borderColor: pal.border, backgroundColor: pal.control }}>
1768
+ {PEN_PRESETS.map((p) => {
1769
+ const active =
1770
+ inkStyle.presetId === p.id &&
1771
+ (actions.activeTool === 'pen' || actions.activeTool === 'pencil');
1772
+ return (
1773
+ <button
1774
+ key={p.id}
1775
+ title={`${p.label} — クリックで描画開始`}
1776
+ onClick={() => { setInkPreset(p); actions.setActiveTool(p.tool); setRev((n) => n + 1); }}
1777
+ className="flex h-11 w-9 flex-col items-center justify-end gap-0.5 rounded pb-1 transition-transform"
1778
+ style={{
1779
+ backgroundColor: active ? pal.activeBg : 'transparent',
1780
+ transform: active ? 'translateY(-3px)' : undefined,
1781
+ }}
1782
+ >
1783
+ {p.cap === 'butt'
1784
+ ? <Highlighter className="h-5 w-5" style={{ color: p.color }} />
1785
+ : p.tool === 'pencil'
1786
+ ? <Pencil className="h-5 w-5" style={{ color: p.color }} />
1787
+ : <PenTool className="h-5 w-5" style={{ color: p.color }} />}
1788
+ <span className="h-[4px] w-6 rounded-full" style={{ backgroundColor: p.color, opacity: p.opacity }} />
1789
+ </button>
1790
+ );
1791
+ })}
1792
+ {/* 任意色(太さはプリセット維持) */}
1793
+ <label className="relative flex h-11 w-7 cursor-pointer items-center justify-center" title="ペンの色を変更">
1794
+ <span className="h-5 w-5 rounded-full border" style={{ borderColor: pal.border, background: 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' }} />
1795
+ <input type="color" className="absolute inset-0 cursor-pointer opacity-0"
1796
+ onChange={(e) => { setInkColor(e.target.value); if (actions.activeTool !== 'pen' && actions.activeTool !== 'pencil') actions.setActiveTool('pen'); setRev((n) => n + 1); }} />
1797
+ </label>
1798
+ </div>
1799
+ <div className="ml-2 self-center text-[11px]" style={{ color: pal.sub }}>
1800
+ 続けて描けます。Escまたは「選択」で終了
1801
+ </div>
1802
+ </>
1803
+ )}
1804
+
1805
+ {tab === 'design' && (
1806
+ <>
1807
+ <BigButton icon={Palette} label={'バリア\nブル'} onClick={actions.openVariables} title="デザイントークン(CSS変数)を編集" pal={pal} />
1808
+ <Sep pal={pal} />
1809
+ <BigButton
1810
+ icon={Sparkles}
1811
+ label={'デザイ\nナー'}
1812
+ onClick={() => {
1813
+ if (window.confirm('このスライドの下書き(編集内容)をAIがデザインシステムに則って清書し、独立したTSXにします。実行しますか?(1〜3分)')) {
1814
+ startCleanup(page);
1815
+ }
1816
+ }}
1817
+ title="AIで清書(TSX化)"
1818
+ pal={pal}
1819
+ />
1820
+ <BigButton
1821
+ icon={Wand2}
1822
+ label={'デザイン\n提案'}
1823
+ onClick={() => setDesignOpen(true)}
1824
+ title="デザイン案を画像で提案し、選んだ案でスライドを書き直す"
1825
+ pal={pal}
1826
+ />
1827
+ <Sep pal={pal} />
1828
+ <div className="flex flex-col justify-center px-2 text-[11px]" style={{ color: pal.sub }}>
1829
+ <span>スライドのサイズ</span>
1830
+ <span className="mt-1 font-medium" style={{ color: pal.text }}>16:9 (1920 × 1080)</span>
1831
+ </div>
1832
+ </>
1833
+ )}
1834
+
1835
+ {tab === 'transition' && (
1836
+ <>
1837
+ {([
1838
+ ['none', 'なし', X],
1839
+ ['fade', 'フェード', Film],
1840
+ ['push', 'プッシュ', MoveUpRight],
1841
+ ['zoom', 'ズーム', ZoomIn],
1842
+ ] as const).map(([val, label, Icon]) => (
1843
+ <BigButton
1844
+ key={val}
1845
+ icon={Icon}
1846
+ label={label}
1847
+ active={(currentEntry?.transition ?? 'none') === val}
1848
+ onClick={() => {
1849
+ void updateSlideMeta(page, { transition: val === 'none' ? null : val }).then(() => refreshDeck());
1850
+ }}
1851
+ title={`このスライドの切り替え効果: ${label}`}
1852
+ pal={pal}
1853
+ />
1854
+ ))}
1855
+ <Sep pal={pal} />
1856
+ <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />
1857
+ <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1858
+ 表示モード(スライドショー)で再生されます
1859
+ </div>
1860
+ </>
1861
+ )}
1862
+
1863
+ {tab === 'animation' && (
1864
+ <>
1865
+ {([
1866
+ [null, 'なし', X],
1867
+ ['fade', 'フェード', Film],
1868
+ ['up', '下から', ArrowUp],
1869
+ ['left', '左から', MoveUpRight],
1870
+ ['zoom', 'ズーム', ZoomIn],
1871
+ ] as const).map(([val, label, Icon]) => (
1872
+ <BigButton
1873
+ key={label}
1874
+ icon={Icon}
1875
+ label={label}
1876
+ disabled={!hasSelection}
1877
+ active={hasSelection && readAttr('data-anim') === val}
1878
+ onClick={() => applyAttr('data-anim', val)}
1879
+ title={val ? `選択要素に「${label}」の出現アニメーション` : '出現アニメーションを外す'}
1880
+ pal={pal}
1881
+ />
1882
+ ))}
1883
+ <Sep pal={pal} />
1884
+ <BigButton icon={Play} label={'プレ\nビュー'} onClick={openSlideshow} title="表示モードで再生して確認" pal={pal} />
1885
+ <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1886
+ {hasSelection ? '文書順に少しずつ遅れて出現します' : '要素を選択してください'}
1887
+ </div>
1888
+ </>
1889
+ )}
1890
+
1891
+ {tab === 'review' && (
1892
+ <>
1893
+ <BigButton icon={MessageSquare} label={'新しい\nコメント'} onClick={comments.newComment}
1894
+ title="コメントを追加(要素を選択していればその要素に添付)" pal={pal} />
1895
+ <BigButton icon={MessageSquare} label={'コメント\nの表示'} active={comments.open} onClick={comments.toggle} pal={pal} />
1896
+ <Sep pal={pal} />
1897
+ {(() => {
1898
+ const withComments = deck.slides
1899
+ .map((sl, i) => ({ n: i + 1, c: unresolvedCount(sl.comments) }))
1900
+ .filter((x) => x.c > 0)
1901
+ .map((x) => x.n);
1902
+ const prev = [...withComments].reverse().find((n) => n < page);
1903
+ const next = withComments.find((n) => n > page);
1904
+ const goToPage = (n?: number) => {
1905
+ if (!n) return;
1906
+ window.location.hash = `#/edit/${n}`;
1907
+ };
1908
+ return (
1909
+ <>
1910
+ <BigButton icon={ChevronLeft} label={'前の\nコメント'} disabled={!prev} onClick={() => goToPage(prev)}
1911
+ title="未解決コメントのある前のスライドへ" pal={pal} />
1912
+ <BigButton icon={ChevronRight} label={'次の\nコメント'} disabled={!next} onClick={() => goToPage(next)}
1913
+ title="未解決コメントのある次のスライドへ" pal={pal} />
1914
+ <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1915
+ {withComments.length
1916
+ ? `未解決コメントのあるスライド: ${withComments.slice(0, 8).join(', ')}${withComments.length > 8 ? '…' : ''}`
1917
+ : '未解決のコメントはありません'}
1918
+ </div>
1919
+ </>
1920
+ );
1921
+ })()}
1922
+ </>
1923
+ )}
1924
+
1925
+ {tab === 'slideshow' && (
1926
+ <>
1927
+ <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} />
1929
+ <Sep pal={pal} />
1930
+ <BigButton
1931
+ icon={Monitor}
1932
+ label={'発表者\nビュー'}
1933
+ onClick={() => window.open(`${window.location.origin}/presenter.html`, '_blank')}
1934
+ title="原稿・タイマー・次スライド付きのコンソール(トークスクリプトを表示)"
1935
+ pal={pal}
1936
+ />
1937
+ <BigButton
1938
+ icon={MonitorUp}
1939
+ label={'画面共有用\nウィンドウ'}
1940
+ onClick={() => window.open(`${window.location.origin}/audience.html`, '_blank')}
1941
+ title="共有・投影するスライドだけの画面。発表者ビューと自動で同期します"
1942
+ pal={pal}
1943
+ />
1944
+ <div className="ml-1 flex items-center px-1 text-[11px]" style={{ color: pal.sub }}>
1945
+ 発表者ビューでページを送ると、画面共有用ウィンドウが同じブラウザ内で自動同期します
1946
+ </div>
1947
+ </>
1948
+ )}
1949
+
1950
+ {tab === 'shapeformat' && (
1951
+ <>
1952
+ {/* 塗りつぶし・枠線・効果 = PowerPointの図形スタイル群 */}
1953
+ <div className="flex items-center gap-0.5 px-1">
1954
+ <Dropdown
1955
+ pal={pal}
1956
+ trigger={
1957
+ <SmallButton icon={PaintBucket} title="図形の塗りつぶし" label="塗りつぶし" chevron pal={pal} />
1958
+ }
1959
+ content={() => (
1960
+ // 色を選んでも閉じない(連続調整のため)。閉じるのは外側クリックとEscだけ
1961
+ <div className="w-[212px]">
1962
+ <ColorPanel
1963
+ pal={pal}
1964
+ onPick={setFill}
1965
+ onNone={clearFill}
1966
+ noneLabel="塗りつぶしなし"
1967
+ />
1968
+ <div className="mx-2 my-1 border-t" style={{ borderColor: pal.border }} />
1969
+ <GradientPanel pal={pal} onPick={setGradientFill} />
1970
+ </div>
1971
+ )}
1972
+ />
1973
+ <Dropdown
1974
+ pal={pal}
1975
+ trigger={<SmallButton icon={PenLine} title="図形の枠線" label="枠線" chevron pal={pal} />}
1976
+ content={() => (
1977
+ // 色・太さ・種類を続けて試せるよう、選んでも閉じない
1978
+ <div>
1979
+ <ColorPanel
1980
+ pal={pal}
1981
+ onPick={(v) => ensureBorder({ borderColor: v })}
1982
+ onNone={() => apply({ borderStyle: 'none' })}
1983
+ noneLabel="枠線なし"
1984
+ />
1985
+ <div className="mx-2 my-1 border-t" style={{ borderColor: pal.border }} />
1986
+ <div className="flex items-center gap-1 px-2 pb-1 text-[11px]" style={{ color: pal.sub }}>
1987
+ 太さ
1988
+ {[1, 2, 3, 4, 6].map((w) => (
1989
+ <button key={w}
1990
+ onMouseDown={(e) => e.preventDefault()}
1991
+ onClick={() => ensureBorder({ borderWidth: `${w}px` })}
1992
+ className="rounded border px-1.5 py-0.5"
1993
+ style={{ borderColor: pal.border, color: pal.text }}>
1994
+ {w}
1995
+ </button>
1996
+ ))}
1997
+ </div>
1998
+ <div className="flex items-center gap-1 px-2 pb-1.5 text-[11px]" style={{ color: pal.sub }}>
1999
+ 種類
2000
+ {([['実線', 'solid'], ['破線', 'dashed'], ['点線', 'dotted']] as const).map(([lb, v]) => (
2001
+ <button key={v}
2002
+ onMouseDown={(e) => e.preventDefault()}
2003
+ onClick={() => ensureBorder({ borderStyle: v })}
2004
+ className="rounded border px-1.5 py-0.5"
2005
+ style={{ borderColor: pal.border, color: pal.text }}>
2006
+ {lb}
2007
+ </button>
2008
+ ))}
2009
+ </div>
2010
+ </div>
2011
+ )}
2012
+ />
2013
+ <Dropdown
2014
+ pal={pal}
2015
+ trigger={<SmallButton icon={Sparkles} title="図形の効果(影)" label="効果" chevron pal={pal} />}
2016
+ items={[
2017
+ ...SHADOWS.map((sh) => ({
2018
+ label: `影: ${sh.label}`,
2019
+ // 切り抜いた図形は box-shadow が clip-path に切られるので drop-shadow を使う
2020
+ onClick: () => { for (const el of targets()) applyShadowPreset(el, sh.preset); notifyIframeChange(); setRev((n) => n + 1); },
2021
+ })),
2022
+ { label: '書式ウィンドウで細かく調整…', onClick: actions.toggleFormatPane },
2023
+ ]}
2024
+ />
2025
+ </div>
2026
+ <Sep pal={pal} />
2027
+ {/* 角丸・透明度 */}
2028
+ <div className="flex flex-col justify-center gap-1 px-1.5">
2029
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2030
+ 角丸
2031
+ <input type="number" min={0} value={borderRadiusPx}
2032
+ onChange={(e) => apply({ borderRadius: `${Math.max(0, Number(e.target.value) || 0)}px` })}
2033
+ className={`${inputCls} h-6 w-[56px]`} style={inputStyle} />
2034
+ </label>
2035
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2036
+ 透明度
2037
+ <input type="number" min={0} max={100} value={100 - opacityPct}
2038
+ onChange={(e) => apply({ opacity: String(Math.min(100, Math.max(0, 100 - (Number(e.target.value) || 0))) / 100) })}
2039
+ className={`${inputCls} h-6 w-[56px]`} style={inputStyle} />
2040
+ </label>
2041
+ </div>
2042
+ <Sep pal={pal} />
2043
+ {/* サイズ(実機の「サイズ」グループ) */}
2044
+ <div className="flex flex-col justify-center gap-1 px-1.5">
2045
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2046
+
2047
+ <input type="number" min={1} value={sizeWH.w}
2048
+ onChange={(e) => apply({ width: `${Math.max(1, Number(e.target.value) || 1)}px` })}
2049
+ className={`${inputCls} h-6 w-[64px]`} style={inputStyle} />
2050
+ </label>
2051
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2052
+ 高さ
2053
+ <input type="number" min={1} value={sizeWH.h}
2054
+ onChange={(e) => apply({ height: `${Math.max(1, Number(e.target.value) || 1)}px` })}
2055
+ className={`${inputCls} h-6 w-[64px]`} style={inputStyle} />
2056
+ </label>
2057
+ </div>
2058
+ <Sep pal={pal} />
2059
+ {/* 実機の図形の書式タブにも配置グループがある(グラデ適用→整列でホームへ戻らせない) */}
2060
+ <Dropdown
2061
+ pal={pal}
2062
+ trigger={<BigButton icon={LayoutGrid} label="整列" caret pal={pal} />}
2063
+ content={() => (
2064
+ <AlignSection
2065
+ pal={pal}
2066
+ count={targets().length}
2067
+ tailLabel="重ね順"
2068
+ onAlign={runAlign}
2069
+ onDistribute={runDistribute}
2070
+ />
2071
+ )}
2072
+ items={[
2073
+ { label: '最前面へ移動', onClick: actions.bringToFront, disabled: !actions.bringToFront },
2074
+ { label: '前面へ移動', onClick: actions.bringForward, disabled: !actions.bringForward },
2075
+ { label: '背面へ移動', onClick: actions.sendBackward, disabled: !actions.sendBackward },
2076
+ { label: '最背面へ移動', onClick: actions.sendToBack, disabled: !actions.sendToBack },
2077
+ ]}
2078
+ />
2079
+ </>
2080
+ )}
2081
+ {tab === 'pictureformat' && (
2082
+ <>
2083
+ {/* 修整・色・アート効果(CSS filter) */}
2084
+ <div className="flex items-center gap-0.5 px-1">
2085
+ {PICTURE_FILTERS.map((g) => (
2086
+ <Dropdown
2087
+ key={g.group}
2088
+ pal={pal}
2089
+ trigger={
2090
+ <BigButton
2091
+ icon={g.group === '修整' ? Sun : g.group === '色' ? Palette : Sparkles}
2092
+ label={g.group === 'アート効果' ? 'アート\n効果' : g.group}
2093
+ caret
2094
+ pal={pal}
2095
+ />
2096
+ }
2097
+ items={g.items.map((it) => ({
2098
+ label: it.label,
2099
+ onClick: () => applyToImage({ filter: it.value }),
2100
+ }))}
2101
+ />
2102
+ ))}
2103
+ <div className="flex flex-col justify-center gap-1 px-1.5">
2104
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2105
+ 透明度
2106
+ <input type="number" min={0} max={100} value={100 - opacityPct}
2107
+ onChange={(e) => applyToImage({ opacity: String(Math.min(100, Math.max(0, 100 - (Number(e.target.value) || 0))) / 100) })}
2108
+ className={`${inputCls} h-6 w-[56px]`} style={inputStyle} />
2109
+ </label>
2110
+ <button
2111
+ onClick={() => applyToImage({ filter: '', opacity: '', border: '', borderRadius: '', boxShadow: '', clipPath: '', padding: '', backgroundColor: '', transform: '', WebkitBoxReflect: '', WebkitMaskImage: '', maskImage: '' })}
2112
+ className="rounded border px-1.5 py-0.5 text-[11px]"
2113
+ style={{ borderColor: pal.border, color: pal.text }}
2114
+ >
2115
+ 図のリセット
2116
+ </button>
2117
+ </div>
2118
+ </div>
2119
+ <Sep pal={pal} />
2120
+ {/* 図のスタイル ギャラリー */}
2121
+ <div className="flex max-w-[420px] items-center gap-1 overflow-x-auto px-1">
2122
+ {PICTURE_STYLES.map((st) => (
2123
+ <button
2124
+ key={st.label}
2125
+ title={st.label}
2126
+ onClick={() => applyToImage(st.styles)}
2127
+ className="flex h-[58px] w-[62px] shrink-0 flex-col items-center justify-center gap-1 rounded"
2128
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = pal.hover; }}
2129
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ''; }}
2130
+ >
2131
+ {/* 実機と同じく、効果を当てたサムネイルで見せる */}
2132
+ <span
2133
+ className="block h-[26px] w-[38px]"
2134
+ style={{
2135
+ backgroundColor: st.styles.backgroundColor || '#9aa7b1',
2136
+ backgroundImage: 'linear-gradient(135deg,#8fa6b8 40%,#c3d0d9 40%)',
2137
+ border: st.styles.border ? '2px solid currentColor' : undefined,
2138
+ borderRadius: st.styles.borderRadius === '50%' ? '50%' : st.styles.borderRadius ? 6 : undefined,
2139
+ boxShadow: st.styles.boxShadow ? '0 3px 6px rgba(0,0,0,0.45)' : undefined,
2140
+ color: pal.text,
2141
+ }}
2142
+ />
2143
+ <span className="text-[9.5px]" style={{ color: pal.sub }}>{st.label}</span>
2144
+ </button>
2145
+ ))}
2146
+ </div>
2147
+ <Sep pal={pal} />
2148
+ {/* 図の枠線・図の効果・書式ウィンドウ */}
2149
+ <div className="flex items-center gap-0.5 px-1">
2150
+ <Dropdown
2151
+ pal={pal}
2152
+ trigger={<SmallButton icon={PenLine} title="図の枠線" label="図の枠線" chevron pal={pal} />}
2153
+ content={() => (
2154
+ <div>
2155
+ <ColorPanel
2156
+ pal={pal}
2157
+ onPick={(v) => applyToImage({ borderColor: v, borderStyle: 'solid', borderWidth: selectedImage?.style.borderWidth || '4px' })}
2158
+ onNone={() => applyToImage({ border: '' })}
2159
+ noneLabel="枠線なし"
2160
+ />
2161
+ <div className="flex items-center gap-1 px-2 pb-1.5 text-[11px]" style={{ color: pal.sub }}>
2162
+ 太さ
2163
+ {[1, 2, 4, 8, 12].map((w) => (
2164
+ <button key={w}
2165
+ onMouseDown={(e) => e.preventDefault()}
2166
+ onClick={() => applyToImage({ borderWidth: `${w}px`, borderStyle: 'solid', borderColor: selectedImage?.style.borderColor || 'var(--color-ink)' })}
2167
+ className="rounded border px-1.5 py-0.5" style={{ borderColor: pal.border, color: pal.text }}>
2168
+ {w}
2169
+ </button>
2170
+ ))}
2171
+ </div>
2172
+ </div>
2173
+ )}
2174
+ />
2175
+ <SmallButton
2176
+ icon={Sparkles}
2177
+ title="図の効果(影・反射・光彩・ぼかしを書式ウィンドウで調整)"
2178
+ label="図の効果"
2179
+ onClick={actions.toggleFormatPane}
2180
+ active={actions.formatPaneOpen}
2181
+ pal={pal}
2182
+ />
2183
+ </div>
2184
+ <Sep pal={pal} />
2185
+ {/* トリミング・回転・サイズ */}
2186
+ <div className="flex items-center gap-0.5 px-1">
2187
+ <BigButton
2188
+ icon={Crop}
2189
+ label={cropping ? 'トリミング\n確定' : 'トリミング'}
2190
+ active={cropping}
2191
+ onClick={toggleCrop}
2192
+ title={cropping ? 'Enterで確定 / Escで取り消し' : '表示範囲を切り抜く'}
2193
+ pal={pal}
2194
+ />
2195
+ <Dropdown
2196
+ pal={pal}
2197
+ trigger={<BigButton icon={RotateCw} label="回転" caret pal={pal} />}
2198
+ items={[
2199
+ { label: '右へ90°回転', onClick: () => transformImage('cw') },
2200
+ { label: '左へ90°回転', onClick: () => transformImage('ccw') },
2201
+ { label: '左右反転', onClick: () => transformImage('flipH') },
2202
+ { label: '上下反転', onClick: () => transformImage('flipV') },
2203
+ { label: '回転をリセット', onClick: () => transformImage('reset') },
2204
+ ]}
2205
+ />
2206
+ </div>
2207
+ <Sep pal={pal} />
2208
+ <div className="flex flex-col justify-center gap-1 px-1.5">
2209
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2210
+ 高さ
2211
+ <input type="number" min={1} value={sizeWH.h}
2212
+ onChange={(e) => applyToImage({ height: `${Math.max(1, Number(e.target.value) || 1)}px` })}
2213
+ className={`${inputCls} h-6 w-[64px]`} style={inputStyle} />
2214
+ </label>
2215
+ <label className="flex items-center justify-between gap-1.5 text-[11px]" style={{ color: pal.sub }}>
2216
+
2217
+ <input type="number" min={1} value={sizeWH.w}
2218
+ onChange={(e) => applyToImage({ width: `${Math.max(1, Number(e.target.value) || 1)}px` })}
2219
+ className={`${inputCls} h-6 w-[64px]`} style={inputStyle} />
2220
+ </label>
2221
+ </div>
2222
+ <Sep pal={pal} />
2223
+ <Dropdown
2224
+ pal={pal}
2225
+ trigger={<BigButton icon={LayoutGrid} label="整列" caret pal={pal} />}
2226
+ content={() => (
2227
+ <AlignSection
2228
+ pal={pal}
2229
+ count={targets().length}
2230
+ tailLabel="重ね順"
2231
+ onAlign={runAlign}
2232
+ onDistribute={runDistribute}
2233
+ />
2234
+ )}
2235
+ items={[
2236
+ { label: '最前面へ移動', onClick: actions.bringToFront, disabled: !actions.bringToFront },
2237
+ { label: '前面へ移動', onClick: actions.bringForward, disabled: !actions.bringForward },
2238
+ { label: '背面へ移動', onClick: actions.sendBackward, disabled: !actions.sendBackward },
2239
+ { label: '最背面へ移動', onClick: actions.sendToBack, disabled: !actions.sendToBack },
2240
+ ]}
2241
+ />
2242
+ </>
2243
+ )}
2244
+
2245
+ {tab === 'view' && (
2246
+ <>
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} />
2250
+ <Sep pal={pal} />
2251
+ <BigButton icon={theme === 'dark' ? Sun : Moon} label={theme === 'dark' ? 'ライト' : 'ダーク'} onClick={onToggleTheme} pal={pal} />
2252
+ <BigButton icon={PenTool} label={'Figma風\nUIへ'} onClick={onSwitchUi} pal={pal} />
2253
+ <Sep pal={pal} />
2254
+ <BigButton icon={Play} label={'スライド\nショー'} onClick={openSlideshow} title={`${deckTitle ?? ''} を表示モードで開く`} pal={pal} />
2255
+ </>
2256
+ )}
2257
+ </div>
2258
+ {designOpen && (
2259
+ <PptDesignProposals page={page} pal={pal} onClose={() => setDesignOpen(false)} />
2260
+ )}
2261
+ </div>
2262
+ );
2263
+ }
2264
+
2265
+ /* ============================ 左: サムネイル ============================ */
2266
+
2267
+ export function PptThumbnails({ page, theme, search }: { page: number; theme: PptTheme; search: string }) {
2268
+ const pal = PALETTES[theme];
2269
+ const deck = useDeck();
2270
+ const currentRef = useRef<HTMLButtonElement>(null);
2271
+ /** 右クリックメニュー(PowerPointと同じ操作面) */
2272
+ const [menu, setMenu] = useState<{ page: number; x: number; y: number } | null>(null);
2273
+ /** ドラッグ並び替えの状態 */
2274
+ const [dragFrom, setDragFrom] = useState<number | null>(null);
2275
+ const [dropAt, setDropAt] = useState<number | null>(null);
2276
+ 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]);
2288
+
2289
+ const goto = async (n: number) => {
2290
+ if (n === page) return;
2291
+ // 未保存があれば黙って保存してから移る。保存できなかったときだけ従来の確認に落とす
2292
+ if (!(await flushAutoSave())) {
2293
+ if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) return;
2294
+ }
2295
+ window.location.hash = `#/edit/${n}`;
2296
+ };
2297
+
2298
+ /** デッキ操作の共通処理。操作後にページ番号のずれを追随する */
2299
+ const run = async (fn: () => Promise<unknown>, nextPage?: number) => {
2300
+ if (busy) return;
2301
+ setBusy(true);
2302
+ try {
2303
+ await fn();
2304
+ 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
+ }
2312
+ } catch (e) {
2313
+ window.alert(`操作に失敗しました: ${String(e).slice(0, 120)}`);
2314
+ } finally {
2315
+ setBusy(false);
2316
+ }
2317
+ };
2318
+
2319
+ const doMove = (from: number, to: number) => {
2320
+ if (to < 1 || to > deck.slides.length || from === to) return;
2321
+ // 編集中のページ自身を動かしたら追随、他ページの移動で自分の番号がずれたら補正
2322
+ let next = page;
2323
+ if (from === page) next = to;
2324
+ else if (from < page && to >= page) next = page - 1;
2325
+ else if (from > page && to <= page) next = page + 1;
2326
+ void run(() => moveSlide(from, to), next);
2327
+ };
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;
2347
+
2348
+ 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>
2435
+ </span>
2436
+ )}
2437
+ </span>
2438
+ </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>
2472
+ );
2473
+ }
2474
+
2475
+ /* ============================ 下: ステータスバー ============================ */
2476
+
2477
+ export function PptStatusBar({ page, total, theme }: { page: number; total: number; theme: PptTheme }) {
2478
+ const pal = PALETTES[theme];
2479
+ const { zoom, setZoom, fitZoom } = useEditorContext();
2480
+ return (
2481
+ <div
2482
+ className="flex h-7 items-center gap-3 border-t px-3 text-[11px]"
2483
+ style={{ backgroundColor: pal.rail, borderColor: pal.border, color: pal.sub }}
2484
+ >
2485
+ <span>スライド {page} / {total}</span>
2486
+ <span className="ml-auto" />
2487
+ <button onClick={() => setZoom(Math.max(10, zoom - 10))} className="rounded p-0.5" title="縮小" style={{ color: pal.sub }}>
2488
+ <ZoomOut className="h-3.5 w-3.5" />
2489
+ </button>
2490
+ <input
2491
+ type="range"
2492
+ min={10}
2493
+ max={400}
2494
+ value={zoom}
2495
+ onChange={(e) => setZoom(parseInt(e.target.value, 10))}
2496
+ className="w-28"
2497
+ style={{ accentColor: PPT_ACCENT }}
2498
+ />
2499
+ <button onClick={() => setZoom(Math.min(300, zoom + 10))} className="rounded p-0.5" title="拡大" style={{ color: pal.sub }}>
2500
+ <ZoomIn className="h-3.5 w-3.5" />
2501
+ </button>
2502
+ <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 }}>
2504
+ <Maximize className="h-3.5 w-3.5" />
2505
+ </button>
2506
+ </div>
2507
+ );
2508
+ }