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