@growgroup/visual-editor 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +189 -43
  13. package/src/editor/components/EditorCanvas.tsx +8 -3
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useCanvasControls.ts +3 -2
  34. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  35. package/src/editor/hooks/useDragResize.ts +86 -6
  36. package/src/editor/hooks/useDrawingMode.ts +4 -2
  37. package/src/editor/hooks/useElementActions.ts +60 -1
  38. package/src/editor/hooks/useElementSelection.ts +35 -8
  39. package/src/editor/hooks/useIframeSetup.ts +10 -0
  40. package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
  41. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  42. package/src/editor/hooks/useResizablePanel.ts +40 -11
  43. package/src/editor/parts.ts +321 -0
  44. package/src/editor/utils/dom-utils.ts +135 -15
  45. package/src/editor/utils/flex-reorder.ts +268 -43
  46. package/src/editor/utils/geometry.ts +4 -3
  47. package/src/editor/utils/html-utils.ts +10 -5
  48. package/src/hooks/useEditorShortcuts.ts +39 -2
  49. package/src/index.ts +25 -0
  50. package/src/io.ts +54 -0
  51. package/src/styles/skin.css +159 -0
  52. package/src/types/editor-components.ts +23 -1
@@ -13,12 +13,13 @@
13
13
  */
14
14
 
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
- import { Check, CornerUpLeft, Loader2, MapPin, RotateCcw, Send, Sparkles, Trash2, X } from 'lucide-react';
17
- import { readApiJson } from '../../utils/api-json';
16
+ import { Check, CornerUpLeft, Loader2, MapPin, RotateCcw, Send, MessageSquare, Sparkles, Trash2, X } from 'lucide-react';
18
17
  import { useEditorContext } from '../../EditorContext';
19
- import { useDeck, refreshDeck } from '../../../components/viewer/useDeck';
18
+ import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
20
19
  import { commentAction, type SlideComment } from '../../../lib/deck';
21
- import { can } from '../../../io';
20
+ import { can, io, type EditorDeck } from '../../../io';
21
+ import { useResizablePanel } from '../../hooks/useResizablePanel';
22
+ import { ConfirmDialog } from '../shell/ConfirmDialog';
22
23
  import { PPT_PALETTES, type PptTheme } from './PptChrome';
23
24
 
24
25
  const AUTHOR_KEY = 'gg-editor:comment-author';
@@ -131,7 +132,7 @@ function Avatar({ name }: { name: string }) {
131
132
  return (
132
133
  <span
133
134
  className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
134
- style={{ backgroundColor: colors[h % colors.length] }}
135
+ style={{ backgroundColor: colors[h % colors.length], color: "#ffffff" }}
135
136
  >
136
137
  {(name || '?').slice(0, 1)}
137
138
  </span>
@@ -176,31 +177,57 @@ export function useCommentMarkers({
176
177
  layer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9000;';
177
178
  artboard.appendChild(layer);
178
179
  }
179
- layer.innerHTML = '';
180
180
  const anchored = comments.filter((c) => c.anchorSrc && !c.resolved);
181
+ const wanted = new Set(anchored.map((c) => c.id));
182
+ layer.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
183
+ if (!wanted.has(button.dataset.commentId!)) button.remove();
184
+ });
185
+ const rootRect = artboard.getBoundingClientRect();
186
+ const scale = rootRect.width / artboard.offsetWidth || 1;
187
+ const size = 32 / scale;
188
+ const stacks = new Map<string, number>();
181
189
  for (const c of anchored) {
182
190
  const target = findAnchored(artboard, c.anchorSrc!);
183
- const bubble = doc.createElement('button');
184
- bubble.setAttribute('data-comment-id', c.id);
185
- bubble.title = `${c.author}: ${c.text.slice(0, 60)}`;
186
- bubble.textContent = (c.author || '?').slice(0, 1);
187
- const x = target ? target.offsetLeft + target.offsetWidth - 6 : 1860;
188
- const y = target ? Math.max(0, target.offsetTop - 10) : 20;
189
- bubble.style.cssText =
190
- `position:absolute;left:${x}px;top:${y}px;width:34px;height:34px;` +
191
- 'border-radius:50% 50% 50% 4px;background:#0F6CBD;color:#fff;font-size:15px;' +
192
- 'font-weight:700;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.35);' +
193
- 'cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;';
194
- bubble.addEventListener('mousedown', (e) => {
195
- e.preventDefault();
196
- e.stopPropagation();
197
- });
198
- bubble.addEventListener('click', (e) => {
199
- e.preventDefault();
200
- e.stopPropagation();
201
- onOpenRef.current(c.id);
202
- });
203
- layer.appendChild(bubble);
191
+ // 要素が削除されたコメントは一覧から参照できる。別の場所に誤って付けない。
192
+ if (!target) {
193
+ layer.querySelector(`[data-comment-id="${CSS.escape(c.id)}"]`)?.remove();
194
+ continue;
195
+ }
196
+ let bubble = layer.querySelector<HTMLButtonElement>(`[data-comment-id="${CSS.escape(c.id)}"]`);
197
+ if (!bubble) {
198
+ bubble = doc.createElement('button');
199
+ bubble.type = 'button';
200
+ bubble.setAttribute('data-comment-id', c.id);
201
+ bubble.addEventListener('mousedown', (e) => { e.stopPropagation(); });
202
+ bubble.addEventListener('click', (e) => {
203
+ e.preventDefault(); e.stopPropagation(); onOpenRef.current(c.id);
204
+ });
205
+ bubble.addEventListener('focus', () => { bubble!.style.outline = '3px solid #2459c4'; });
206
+ bubble.addEventListener('blur', () => { bubble!.style.outline = ''; });
207
+ layer.appendChild(bubble);
208
+ }
209
+ const label = `${c.author}のコメント:${c.text.slice(0, 80)}`;
210
+ bubble.title = label;
211
+ bubble.setAttribute('aria-label', label);
212
+ const letter = (c.author || '?').slice(0, 1);
213
+ if (bubble.textContent !== letter) bubble.textContent = letter;
214
+ const rect = target.getBoundingClientRect();
215
+ const stack = stacks.get(c.anchorSrc!) ?? 0;
216
+ stacks.set(c.anchorSrc!, stack + 1);
217
+ const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) / scale - size / 2));
218
+ const y = Math.max(0, (rect.top - rootRect.top) / scale - size / 2) + stack * (size + 4 / scale);
219
+ const styles: Partial<CSSStyleDeclaration> = {
220
+ position: 'absolute', left: `${x}px`, top: `${y}px`, width: `${size}px`, height: `${size}px`,
221
+ borderRadius: '50% 50% 50% 4px', background: '#2459c4', color: '#fff', fontSize: `${13 / scale}px`,
222
+ fontFamily: 'system-ui, sans-serif', fontWeight: '700', border: `${2 / scale}px solid #fff`,
223
+ boxShadow: '0 2px 8px #0003', cursor: 'pointer', pointerEvents: 'auto', display: 'flex',
224
+ alignItems: 'center', justifyContent: 'center', padding: '0px',
225
+ };
226
+ // 同じ値を再代入してMutationObserverを起こさない。フォーカス中のDOMも維持する。
227
+ const style = bubble.style as unknown as Record<string, string>;
228
+ for (const [key, value] of Object.entries(styles)) {
229
+ if (style[key] !== value) style[key] = value as string;
230
+ }
204
231
  }
205
232
  };
206
233
 
@@ -213,7 +240,7 @@ export function useCommentMarkers({
213
240
  const layer = getIframeDoc()?.querySelector('.gg-comment-layer');
214
241
  layer?.remove();
215
242
  };
216
- }, [active, page, getIframeDoc, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author]))]);
243
+ }, [active, page, getIframeDoc, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
217
244
  }
218
245
 
219
246
  /* ============================ 右パネル ============================ */
@@ -245,6 +272,10 @@ export function PptCommentsPanel({
245
272
  const [replyDraft, setReplyDraft] = useState('');
246
273
  const [showResolved, setShowResolved] = useState(false);
247
274
  const [busy, setBusy] = useState(false);
275
+ const busyRef = useRef(false);
276
+ const [error, setError] = useState('');
277
+ const [deleteId, setDeleteId] = useState<string | null>(null);
278
+ const { width, isDragging, resizeHandleProps } = useResizablePanel({ initialWidth: 336, minWidth: 304, maxWidth: 480, direction: 'left', storageKey: 'gg-editor:comments-width' });
248
279
  const composerRef = useRef<HTMLTextAreaElement>(null);
249
280
  const threadRefs = useRef<Record<string, HTMLDivElement | null>>({});
250
281
 
@@ -254,7 +285,7 @@ export function PptCommentsPanel({
254
285
 
255
286
  useEffect(() => {
256
287
  if (activeThreadId) {
257
- threadRefs.current[activeThreadId]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
288
+ threadRefs.current[activeThreadId]?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
258
289
  }
259
290
  }, [activeThreadId]);
260
291
 
@@ -276,20 +307,18 @@ export function PptCommentsPanel({
276
307
  async (commentId: string) => {
277
308
  setFixJobs((prev) => ({ ...prev, [commentId]: { message: '開始中…' } }));
278
309
  try {
279
- const res = await fetch('/__comment-fix', {
310
+ const data = await io().apiFetch!('/__comment-fix', {
280
311
  method: 'POST',
281
312
  headers: { 'content-type': 'application/json' },
282
313
  body: JSON.stringify({ page, commentId }),
283
314
  });
284
- const { jobId, error } = await readApiJson<{ jobId?: string; error?: string }>(res);
315
+ const { jobId, error } = data as { jobId?: string; error?: string };
285
316
  if (!jobId) throw new Error(error ?? '開始できませんでした');
286
317
 
287
318
  const startedAt = Date.now();
288
319
  while (Date.now() - startedAt < 8 * 60 * 1000) {
289
320
  await new Promise((r) => setTimeout(r, 1500));
290
- const st = await readApiJson<{ state: string; message?: string; error?: string }>(
291
- await fetch(`/__comment-fix/status/${jobId}`),
292
- );
321
+ const st = await io().apiFetch!(`/__comment-fix/status/${jobId}`) as { state: string; message?: string; error?: string };
293
322
  if (st.state === 'running') {
294
323
  setFixJobs((prev) => ({ ...prev, [commentId]: { message: st.message || '実行中…' } }));
295
324
  continue;
@@ -328,18 +357,16 @@ export function PptCommentsPanel({
328
357
  )) return;
329
358
  setFixAll({ running: true, message: '開始中…' });
330
359
  try {
331
- const res = await fetch('/__comment-fix', {
360
+ const data = await io().apiFetch!('/__comment-fix', {
332
361
  method: 'POST',
333
362
  headers: { 'content-type': 'application/json' },
334
363
  body: JSON.stringify({ all: true }),
335
364
  });
336
- const { jobId, error } = await readApiJson<{ jobId?: string; error?: string }>(res);
365
+ const { jobId, error } = data as { jobId?: string; error?: string };
337
366
  if (!jobId) throw new Error(error ?? '開始できませんでした');
338
367
  for (;;) {
339
368
  await new Promise((r) => setTimeout(r, 2000));
340
- const st = await readApiJson<{ state: string; message?: string; error?: string }>(
341
- await fetch(`/__comment-fix/status/${jobId}`),
342
- );
369
+ const st = await io().apiFetch!(`/__comment-fix/status/${jobId}`) as { state: string; message?: string; error?: string };
343
370
  if (st.state === 'running') {
344
371
  setFixAll({ running: true, message: st.message || '実行中…' });
345
372
  continue;
@@ -356,18 +383,21 @@ export function PptCommentsPanel({
356
383
  }
357
384
  }, [fixAll?.running, unresolvedTotal]);
358
385
 
359
- const run = useCallback(async (fn: () => Promise<unknown>) => {
360
- if (busy) return;
361
- setBusy(true);
386
+ const run = useCallback(async (fn: () => Promise<EditorDeck>): Promise<boolean> => {
387
+ if (busyRef.current) return false;
388
+ busyRef.current = true;
389
+ setBusy(true); setError('');
362
390
  try {
363
- await fn();
364
- await refreshDeck();
391
+ applyDeck(await fn());
392
+ return true;
365
393
  } catch (e) {
366
- window.alert(`コメント操作に失敗しました: ${String(e).slice(0, 120)}`);
394
+ setError(`操作できませんでした。${e instanceof Error ? e.message : String(e)}`);
395
+ return false;
367
396
  } finally {
397
+ busyRef.current = false;
368
398
  setBusy(false);
369
399
  }
370
- }, [busy]);
400
+ }, []);
371
401
 
372
402
  /** 選択中の要素からアンカーを拾う(選択なしならページ全体へのコメント) */
373
403
  const currentAnchor = useMemo(() => {
@@ -393,7 +423,7 @@ export function PptCommentsPanel({
393
423
  anchorSrc: currentAnchor?.src,
394
424
  anchorLabel: currentAnchor?.label,
395
425
  }),
396
- ).then(() => setDraft(''));
426
+ ).then((ok) => { if (ok) setDraft(''); });
397
427
  };
398
428
 
399
429
  const open = comments.filter((c) => !c.resolved);
@@ -414,11 +444,13 @@ export function PptCommentsPanel({
414
444
  <div
415
445
  key={c.id}
416
446
  ref={(el) => { threadRefs.current[c.id] = el; }}
417
- className="rounded-lg border p-2.5"
447
+ className="ed-comment-thread rounded-lg border p-4"
448
+ data-comment-thread={c.id}
449
+ aria-label={`${c.author}のコメント`}
418
450
  style={{
419
- borderColor: activeThreadId === c.id ? '#0F6CBD' : pal.border,
451
+ borderColor: activeThreadId === c.id ? 'var(--ed-accent)' : pal.border,
420
452
  backgroundColor: pal.control,
421
- opacity: c.resolved ? 0.72 : 1,
453
+
422
454
  }}
423
455
  onClick={() => onActiveThread(c.id)}
424
456
  >
@@ -430,7 +462,7 @@ export function PptCommentsPanel({
430
462
  </div>
431
463
  {c.resolved ? (
432
464
  <button
433
- title="スレッドを再開"
465
+ disabled={busy} aria-label="スレッドを再開" title="スレッドを再開"
434
466
  onClick={(e) => { e.stopPropagation(); void run(() => commentAction(page, { action: 'resolve', commentId: c.id, resolved: false })); }}
435
467
  className="rounded p-1" style={{ color: pal.sub }}
436
468
  >
@@ -438,7 +470,7 @@ export function PptCommentsPanel({
438
470
  </button>
439
471
  ) : (
440
472
  <button
441
- title="解決済みにする"
473
+ disabled={busy} aria-label="解決済みにする" title="解決済みにする"
442
474
  onClick={(e) => { e.stopPropagation(); void run(() => commentAction(page, { action: 'resolve', commentId: c.id, resolved: true })); }}
443
475
  className="rounded p-1" style={{ color: pal.sub }}
444
476
  >
@@ -446,12 +478,10 @@ export function PptCommentsPanel({
446
478
  </button>
447
479
  )}
448
480
  <button
449
- title="スレッドを削除"
481
+ disabled={busy} aria-label="スレッドを削除" title="スレッドを削除"
450
482
  onClick={(e) => {
451
483
  e.stopPropagation();
452
- if (window.confirm('このコメントスレッドを削除しますか?')) {
453
- void run(() => commentAction(page, { action: 'delete', commentId: c.id }));
454
- }
484
+ setDeleteId(c.id);
455
485
  }}
456
486
  className="rounded p-1" style={{ color: pal.sub }}
457
487
  >
@@ -460,12 +490,13 @@ export function PptCommentsPanel({
460
490
  </div>
461
491
 
462
492
  {c.anchorSrc && (
463
- <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: '#0F6CBD' }}>
493
+ <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }}>
464
494
  <MapPin className="h-3 w-3" />
465
495
  {c.anchorLabel ? `「${c.anchorLabel}」` : '要素に添付'}
466
496
  </div>
467
497
  )}
468
498
 
499
+ {c.resolved && <p className="mt-2 text-xs text-gray-400">解決済み</p>}
469
500
  <p className="mt-1.5 whitespace-pre-wrap text-[12.5px] leading-relaxed" style={{ color: pal.text }}>
470
501
  {c.text}
471
502
  </p>
@@ -498,7 +529,7 @@ export function PptCommentsPanel({
498
529
  }}
499
530
  title="この指摘のとおりにAIが原本(TSX)を修正します"
500
531
  className="flex items-center gap-1 rounded border px-1.5 py-1 text-[11px] transition-colors"
501
- style={{ borderColor: pal.border, color: '#8B5CF6' }}
532
+ style={{ borderColor: pal.border, color: 'var(--ed-accent)' }}
502
533
  >
503
534
  <Sparkles className="h-3 w-3" />
504
535
  AIで修正
@@ -517,15 +548,17 @@ export function PptCommentsPanel({
517
548
  <div className="mt-2 flex items-end gap-1.5">
518
549
  <textarea
519
550
  autoFocus
551
+ aria-label="返信内容"
552
+ disabled={busy}
520
553
  value={replyDraft}
521
554
  onChange={(e) => setReplyDraft(e.target.value)}
522
555
  onKeyDown={(e) => {
523
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
556
+ if (e.key === 'Enter' && !e.nativeEvent.isComposing && (e.metaKey || e.ctrlKey)) {
524
557
  e.preventDefault();
525
558
  const t = replyDraft.trim();
526
559
  if (t) {
527
560
  void run(() => commentAction(page, { action: 'reply', commentId: c.id, author: author.trim() || 'ゲスト', text: t }))
528
- .then(() => { setReplyDraft(''); setReplyFor(null); });
561
+ .then((ok) => { if (ok) { setReplyDraft(''); setReplyFor(null); } });
529
562
  }
530
563
  }
531
564
  }}
@@ -535,15 +568,15 @@ export function PptCommentsPanel({
535
568
  style={{ backgroundColor: pal.chrome, borderColor: pal.border, color: pal.text }}
536
569
  />
537
570
  <button
538
- title="返信を送信"
571
+ disabled={busy || !replyDraft.trim()} aria-label="返信を送信" title="返信を送信"
539
572
  onClick={() => {
540
573
  const t = replyDraft.trim();
541
574
  if (t) {
542
575
  void run(() => commentAction(page, { action: 'reply', commentId: c.id, author: author.trim() || 'ゲスト', text: t }))
543
- .then(() => { setReplyDraft(''); setReplyFor(null); });
576
+ .then((ok) => { if (ok) { setReplyDraft(''); setReplyFor(null); } });
544
577
  }
545
578
  }}
546
- className="rounded p-1.5" style={{ color: '#0F6CBD' }}
579
+ className="rounded p-1.5" style={{ color: 'var(--ed-accent)' }}
547
580
  >
548
581
  <Send className="h-4 w-4" />
549
582
  </button>
@@ -553,7 +586,7 @@ export function PptCommentsPanel({
553
586
  <button
554
587
  onClick={(e) => { e.stopPropagation(); setReplyFor(c.id); setReplyDraft(''); }}
555
588
  className="mt-1.5 flex items-center gap-1 text-[11px]"
556
- style={{ color: '#0F6CBD' }}
589
+ style={{ color: 'var(--ed-accent)' }}
557
590
  >
558
591
  <CornerUpLeft className="h-3 w-3" />
559
592
  返信
@@ -565,28 +598,33 @@ export function PptCommentsPanel({
565
598
 
566
599
  return (
567
600
  <div
568
- className="flex w-[292px] shrink-0 flex-col border-l"
569
- style={{ backgroundColor: PPT_PALETTES[theme].rail, borderColor: pal.border }}
601
+ className="relative flex shrink-0 flex-col border-l ed-comments-panel"
602
+ aria-label="コメント"
603
+ data-comments-panel
604
+ style={{ width, backgroundColor: "var(--ed-surface)", borderColor: pal.border }}
570
605
  >
571
- <div className="flex items-center gap-2 border-b px-3 py-2" style={{ borderColor: pal.border }}>
572
- <span className="text-[13px] font-medium" style={{ color: pal.text }}>コメント</span>
573
- <span className="truncate text-[11px]" style={{ color: pal.sub }}>
574
- {deckAll.slides[page - 1]?.title ?? `ページ ${page}`}
575
- </span>
606
+ <div {...resizeHandleProps} />
607
+ {isDragging && <div className="fixed inset-0 z-50 cursor-col-resize" />}
608
+ <div className="ed-panel-heading" style={{ borderColor: pal.border }}>
609
+ <MessageSquare className="h-4 w-4" /><span style={{ color: pal.text }}>コメント</span>
610
+ <span className="text-xs font-normal text-gray-400">未解決 {comments.filter((c) => !c.resolved).length}</span>
611
+
612
+ <button onClick={onClose} className="ed-icon-button ml-auto" style={{ color: pal.sub }} title="コメントを閉じる" aria-label="コメントを閉じる">
613
+ <X className="h-4 w-4" />
614
+ </button>
615
+ </div>
616
+ <div className="px-4">
576
617
  {unresolvedTotal > 0 && !fixAll?.running && can('apiFetch') && (
577
618
  <button
578
619
  onClick={() => void startFixAll()}
579
620
  title="全スライドの未解決コメントをAIが順に修正し、各スレッドへ対応報告を返信します"
580
- className="ml-2 flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px]"
621
+ className="my-2 flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px]"
581
622
  style={{ borderColor: pal.border, color: pal.text }}
582
623
  >
583
624
  <Sparkles className="h-3 w-3" />
584
625
  すべてAIで修正({unresolvedTotal})
585
626
  </button>
586
627
  )}
587
- <button onClick={onClose} className="ml-auto rounded p-1" style={{ color: pal.sub }} title="閉じる">
588
- <X className="h-4 w-4" />
589
- </button>
590
628
  </div>
591
629
  {(fixAll?.running || fixAll?.error) && (
592
630
  <div className="border-b px-3 py-1.5 text-[11px]" style={{ borderColor: pal.border, color: fixAll.error ? '#f66' : pal.sub }}>
@@ -595,15 +633,18 @@ export function PptCommentsPanel({
595
633
  )}
596
634
 
597
635
 
636
+ {error && <p role="alert" className="px-4 py-3 text-xs" style={{ color: "var(--ed-danger)" }}>{error} 入力内容は残っています。</p>}
637
+ <p className="border-b px-4 py-2 text-xs text-gray-400">{deck.slides[page - 1]?.title ?? `ページ ${page}`}</p>
598
638
  {/* 新規コメント */}
599
- <div className="border-b p-2.5" style={{ borderColor: pal.border }}>
639
+ <div className="border-b p-4" style={{ borderColor: pal.border }}>
600
640
  <div className="flex items-center gap-1.5">
601
641
  <Avatar name={author || 'ゲ'} />
602
642
  <input
643
+ aria-label="投稿者の名前"
603
644
  value={author}
604
645
  onChange={(e) => saveAuthor(e.target.value)}
605
646
  placeholder="名前(記憶されます)"
606
- className="h-6 flex-1 rounded border px-1.5 text-[11px] outline-none"
647
+ className="h-8 flex-1 rounded border px-2 text-[11px] outline-none"
607
648
  style={{ backgroundColor: pal.control, borderColor: pal.border, color: pal.text }}
608
649
  />
609
650
  </div>
@@ -612,22 +653,21 @@ export function PptCommentsPanel({
612
653
  <div
613
654
  className="mt-1.5 flex items-start gap-1.5 rounded border px-1.5 py-1 text-[10px]"
614
655
  style={{
615
- color: '#0F6CBD',
616
- borderColor: '#0F6CBD',
656
+ color: 'var(--ed-accent)',
657
+ borderColor: 'var(--ed-accent)',
617
658
  backgroundColor: 'rgba(15,108,189,.08)',
618
659
  }}
619
- title={currentAnchor.src}
620
660
  >
621
661
  <MapPin className="mt-px h-3 w-3 shrink-0" />
622
662
  <span className="min-w-0">
623
663
  {currentAnchor.kind === 'range' ? (
624
664
  <>
625
- 選択中の<span className="font-bold">{currentAnchor.tag}</span>(「
665
+ 「
626
666
  <span className="font-bold">{currentAnchor.label}</span>」を含む範囲)へのコメント
627
667
  </>
628
668
  ) : (
629
669
  <>
630
- 選択中の<span className="font-bold">{currentAnchor.tag}</span>「
670
+ 選択中の「
631
671
  <span className="font-bold">{currentAnchor.label}</span>」へのコメント
632
672
  </>
633
673
  )}
@@ -647,10 +687,12 @@ export function PptCommentsPanel({
647
687
  <div className="mt-1.5 flex items-end gap-1.5">
648
688
  <textarea
649
689
  ref={composerRef}
690
+ aria-label="コメント内容"
691
+ disabled={busy}
650
692
  value={draft}
651
693
  onChange={(e) => setDraft(e.target.value)}
652
694
  onKeyDown={(e) => {
653
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
695
+ if (e.key === 'Enter' && !e.nativeEvent.isComposing && (e.metaKey || e.ctrlKey)) {
654
696
  e.preventDefault();
655
697
  post();
656
698
  }
@@ -664,27 +706,30 @@ export function PptCommentsPanel({
664
706
  onClick={post}
665
707
  disabled={busy || !draft.trim()}
666
708
  title="コメントを投稿"
667
- className="rounded p-1.5 disabled:opacity-40"
668
- style={{ color: '#0F6CBD' }}
709
+ className="ed-button ed-button-primary"
669
710
  >
670
711
  <Send className="h-4 w-4" />
712
+ {busy ? "送信中" : "送信"}
671
713
  </button>
672
714
  </div>
715
+ <p className="mt-2 text-xs text-gray-400">⌘ / Ctrl+Enter で送信</p>
673
716
  </div>
674
717
 
675
718
  {/* スレッド一覧 */}
676
- <div className="flex-1 space-y-2 overflow-y-auto p-2.5">
719
+ <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
677
720
  {open.length === 0 && resolved.length === 0 && (
678
721
  <p className="px-1 pt-2 text-[12px]" style={{ color: pal.sub }}>
679
722
  まだコメントはありません。要素を選択して投稿すると、その要素に吹き出しが付きます。
680
723
  </p>
681
724
  )}
725
+ {open.length === 0 && resolved.length > 0 && <p className="py-2 text-xs text-gray-400">未解決のコメントはありません。</p>}
682
726
  {open.map((c) => renderThread(c))}
683
727
 
684
728
  {resolved.length > 0 && (
685
729
  <button
686
730
  onClick={() => setShowResolved((v) => !v)}
687
- className="w-full pt-1 text-left text-[11px]"
731
+ aria-expanded={showResolved}
732
+ className="w-full rounded py-2 text-left text-xs"
688
733
  style={{ color: pal.sub }}
689
734
  >
690
735
  {showResolved ? '▾' : '▸'} 解決済み ({resolved.length})
@@ -692,6 +737,9 @@ export function PptCommentsPanel({
692
737
  )}
693
738
  {showResolved && resolved.map((c) => renderThread(c))}
694
739
  </div>
740
+ <ConfirmDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}
741
+ title="コメントを削除しますか?" description="このスレッドと返信を削除します。この操作は取り消せません。"
742
+ onConfirm={() => { if (deleteId) void run(() => commentAction(page, { action: 'delete', commentId: deleteId })); }} />
695
743
  </div>
696
744
  );
697
745
  }
@@ -12,7 +12,14 @@
12
12
 
13
13
  import { useCallback, useEffect, useRef, useState } from 'react';
14
14
  import { readApiJson } from '../../utils/api-json';
15
- import { createPortal } from 'react-dom';
15
+ import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../../../components/ui/dialog';
16
+ import { io } from '../../../io';
17
+
18
+ /** 既存のジョブ応答処理へ、利用側IOのJSONを渡す。HTTPの実装は利用側だけが持つ。 */
19
+ async function proposalRequest(path: string, init?: RequestInit): Promise<Response> {
20
+ const data = await io().apiFetch!(path, init);
21
+ return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
22
+ }
16
23
  import { X, Sparkles, Loader2, Check, AlertTriangle } from 'lucide-react';
17
24
  import { useEditorContext } from '../../EditorContext';
18
25
 
@@ -61,19 +68,13 @@ export function PptDesignProposals({
61
68
  return () => { aliveRef.current = false; };
62
69
  }, []);
63
70
 
64
- useEffect(() => {
65
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
66
- document.addEventListener('keydown', onKey);
67
- return () => document.removeEventListener('keydown', onKey);
68
- }, [onClose]);
69
-
70
71
  /** ジョブが片付くまで status を叩き続ける */
71
72
  const poll = useCallback(
72
73
  async <T,>(url: string, onTick: (v: T & { state: string; message?: string }) => void) => {
73
74
  for (;;) {
74
75
  if (!aliveRef.current) return null;
75
76
  await new Promise((r) => setTimeout(r, 2500));
76
- const res = await fetch(url).catch(() => null);
77
+ const res = await proposalRequest(url).catch(() => null);
77
78
  if (!res || !res.ok) continue;
78
79
  const v = (await readApiJson<T & { state: string; message?: string; error?: string }>(res));
79
80
  onTick(v);
@@ -88,7 +89,7 @@ export function PptDesignProposals({
88
89
  setPatterns([]);
89
90
  setGenState('running');
90
91
  setGenMessage('ジョブを開始しています…');
91
- const res = await fetch('/__design-gen', {
92
+ const res = await proposalRequest('/__design-gen', {
92
93
  method: 'POST',
93
94
  headers: { 'Content-Type': 'application/json' },
94
95
  body: JSON.stringify({ page, instruction, count }),
@@ -119,7 +120,7 @@ export function PptDesignProposals({
119
120
  setError('');
120
121
  setApplyingUrl(url);
121
122
  setApplyMessage('ジョブを開始しています…');
122
- const res = await fetch('/__design-apply', {
123
+ const res = await proposalRequest('/__design-apply', {
123
124
  method: 'POST',
124
125
  headers: { 'Content-Type': 'application/json' },
125
126
  body: JSON.stringify({ page, image: url, note: instruction }),
@@ -161,21 +162,16 @@ export function PptDesignProposals({
161
162
 
162
163
  const busy = genState === 'running' || !!applyingUrl;
163
164
 
164
- return createPortal(
165
- <div
166
- className="fixed inset-0 z-[10001] flex items-center justify-center p-6"
167
- style={{ backgroundColor: 'rgba(0,0,0,0.45)' }}
168
- onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onClose(); }}
169
- >
170
- <div
171
- data-design-proposals="1"
172
- className="flex max-h-full w-[min(1100px,95vw)] flex-col overflow-hidden rounded-lg border shadow-2xl"
173
- style={{ backgroundColor: pal.chrome, borderColor: pal.border, color: pal.text }}
174
- >
165
+ return (
166
+ <Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
167
+ <DialogContent data-design-proposals="1" className="flex w-[min(1100px,95vw)] max-w-none flex-col p-0 [&>button:last-child]:hidden"
168
+ onEscapeKeyDown={(e) => { if (busy) e.preventDefault(); }}
169
+ onInteractOutside={(e) => { if (busy) e.preventDefault(); }}>
170
+ <DialogDescription className="sr-only">スライドのデザイン案を生成して選択します。</DialogDescription>
175
171
  {/* 見出し */}
176
172
  <div className="flex items-center gap-2 border-b px-4 py-3" style={{ borderColor: pal.border }}>
177
173
  <Sparkles className="h-4 w-4" />
178
- <span className="text-[13px] font-semibold">デザイン提案</span>
174
+ <DialogTitle className="text-base">デザイン提案</DialogTitle>
179
175
  <span className="text-[11px]" style={{ color: pal.sub }}>
180
176
  {page}枚目のスライドを、内容はそのままに別の見せ方で提案します
181
177
  </span>
@@ -218,7 +214,7 @@ export function PptDesignProposals({
218
214
  onClick={() => void generate()}
219
215
  disabled={busy}
220
216
  className="flex h-[30px] items-center gap-1.5 rounded px-3 text-[12px] font-medium"
221
- style={{ backgroundColor: busy ? pal.disabled : '#ED6C47', color: '#fff' }}
217
+ style={{ backgroundColor: busy ? pal.disabled : 'var(--ed-accent-strong)', color: '#fff' }}
222
218
  >
223
219
  {genState === 'running' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
224
220
  生成
@@ -268,8 +264,7 @@ export function PptDesignProposals({
268
264
  <div className="border-t px-4 py-2 text-[11px]" style={{ borderColor: pal.border, color: pal.sub }}>
269
265
  適用するとスライドの実装(TSX)が書き直されます。描画できない結果になった場合は自動で元に戻します。
270
266
  </div>
271
- </div>
272
- </div>,
273
- document.body,
267
+ </DialogContent>
268
+ </Dialog>
274
269
  );
275
270
  }