@growgroup/visual-editor 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +189 -43
  13. package/src/editor/components/EditorCanvas.tsx +8 -3
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useCanvasControls.ts +3 -2
  34. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  35. package/src/editor/hooks/useDragResize.ts +86 -6
  36. package/src/editor/hooks/useDrawingMode.ts +4 -2
  37. package/src/editor/hooks/useElementActions.ts +60 -1
  38. package/src/editor/hooks/useElementSelection.ts +35 -8
  39. package/src/editor/hooks/useIframeSetup.ts +10 -0
  40. package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
  41. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  42. package/src/editor/hooks/useResizablePanel.ts +40 -11
  43. package/src/editor/parts.ts +321 -0
  44. package/src/editor/utils/dom-utils.ts +135 -15
  45. package/src/editor/utils/flex-reorder.ts +268 -43
  46. package/src/editor/utils/geometry.ts +4 -3
  47. package/src/editor/utils/html-utils.ts +10 -5
  48. package/src/hooks/useEditorShortcuts.ts +39 -2
  49. package/src/index.ts +25 -0
  50. package/src/io.ts +54 -0
  51. package/src/styles/skin.css +159 -0
  52. package/src/types/editor-components.ts +23 -1
@@ -11,7 +11,7 @@
11
11
  * 汎用的なHTML編集エディタとして、スライド、ページ、コンポーネント等で利用可能
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
+ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
15
15
  import { useEditorShortcuts } from '../hooks/useEditorShortcuts';
16
16
  import {
17
17
  editorCancelDragRef,
@@ -32,7 +32,10 @@ import { useAuth } from '../components/auth/AuthProvider';
32
32
  import { findSlideRoot, findInsertionParent } from './utils/slide-root';
33
33
  import { registerAutoSaveFlush } from './autosave';
34
34
  import { getCleanHtml } from './utils/html-utils';
35
- import './editor-skin.css';
35
+ import { EditorAppearanceContext, useEditorTheme } from './contexts/EditorAppearanceContext';
36
+ import { CanvasAppearance } from './components/shell/CanvasAppearance';
37
+ import { can, io } from '../io';
38
+ import { insertIntoFlow } from './parts';
36
39
  import type { Slide } from '../types/slide';
37
40
  import {
38
41
  useEditorMessages,
@@ -55,16 +58,9 @@ import {
55
58
  EditorFooter,
56
59
  EditorContextMenu,
57
60
  AiPromptPopover,
58
- HtmlEditorDialog,
59
- HtmlImportDialog,
60
- CssEditorDialog,
61
- JsEditorDialog,
62
- PageSettingsDialog,
63
- MediaLibraryDialog,
64
61
  EditorCanvas,
65
62
  VariablesPanel,
66
63
  ComponentPanel,
67
- MasterComponentEditor,
68
64
  } from './components';
69
65
  import { useEditorVariables, useEditorComponents } from './EditorContext';
70
66
  import type { CSSVariableDefinition } from '../types/css-variables';
@@ -102,6 +98,13 @@ import { cn } from '../lib/utils';
102
98
  * 打鍵やドラッグのたびに保存(=原本TSXへの書き戻しジョブ)が走ることはない。
103
99
  */
104
100
  const AUTO_SAVE_DELAY_MS = 2000;
101
+ const MasterComponentEditor = lazy(() => import('./components/MasterComponentEditor').then((m) => ({ default: m.MasterComponentEditor })));
102
+ const MediaLibraryDialog = lazy(() => import('./components/MediaLibraryDialog').then((m) => ({ default: m.MediaLibraryDialog })));
103
+ const PageSettingsDialog = lazy(() => import('./components/PageSettingsDialog').then((m) => ({ default: m.PageSettingsDialog })));
104
+ const JsEditorDialog = lazy(() => import('./components/JsEditorDialog').then((m) => ({ default: m.JsEditorDialog })));
105
+ const CssEditorDialog = lazy(() => import('./components/CssEditorDialog').then((m) => ({ default: m.CssEditorDialog })));
106
+ const HtmlImportDialog = lazy(() => import('./components/HtmlImportDialog').then((m) => ({ default: m.HtmlImportDialog })));
107
+ const HtmlEditorDialog = lazy(() => import('./components/HtmlEditorDialog').then((m) => ({ default: m.HtmlEditorDialog })));
105
108
 
106
109
  /**
107
110
  * 保存の状態表示。エディタの殻(Figma風ヘッダー / PowerPoint風タイトルバー)で共通に使う。
@@ -285,6 +288,8 @@ function FrontendVisualEditorInner({
285
288
  componentLibrary,
286
289
  createInstance,
287
290
  getMasterComponent,
291
+ partsMode,
292
+ materializePartInstance,
288
293
  } = useEditorComponents();
289
294
 
290
295
  const { getIdToken } = useAuth();
@@ -332,15 +337,14 @@ function FrontendVisualEditorInner({
332
337
  try { localStorage.setItem('gg-editor:ui-mode', mode); } catch { /* 記憶できなくても動作は継続 */ }
333
338
  };
334
339
  // PowerPoint風UIのテーマ(OS設定に追従・切替は記憶)とサムネイル検索
335
- const [pptTheme, setPptTheme] = useState<PptTheme>(() => initialPptTheme());
336
- const togglePptTheme = () => {
337
- const next: PptTheme = pptTheme === 'dark' ? 'light' : 'dark';
338
- setPptTheme(next);
339
- try { localStorage.setItem('gg-editor:ppt-theme', next); } catch { /* 続行 */ }
340
- };
340
+ const { theme: pptTheme, toggleTheme: togglePptTheme } = useEditorTheme();
341
+ const [layersOpen, setLayersOpen] = useState(editorMode !== 'webpage' || !!pagesPanel);
342
+ const [guidesOpen, setGuidesOpen] = useState(false);
341
343
  const [pptSearch, setPptSearch] = useState('');
342
344
  // コメントパネル(PowerPoint風UIのみ)。フォーカス合図はカウンタで送る
343
345
  const [pptCommentsOpen, setPptCommentsOpen] = useState(false);
346
+ const [commentsMounted, setCommentsMounted] = useState(false);
347
+ useEffect(() => { if (pptCommentsOpen) setCommentsMounted(true); }, [pptCommentsOpen]);
344
348
  // PowerPoint風UIの右ペイン「図の書式設定」(影・反射・光彩・ぼかし)
345
349
  const [pptFormatPaneOpen, setPptFormatPaneOpen] = useState(false);
346
350
  const [pptCommentFocus, setPptCommentFocus] = useState(0);
@@ -534,6 +538,10 @@ function FrontendVisualEditorInner({
534
538
  selectedElementInstance,
535
539
  isComponentInstance,
536
540
  hasOverrides,
541
+ isPartInstance,
542
+ partLabel,
543
+ handleDetachPart,
544
+ handleUpdatePart,
537
545
  handleCreateComponent,
538
546
  handleConfirmCreateComponent,
539
547
  handleEditMasterComponent,
@@ -817,6 +825,17 @@ function FrontendVisualEditorInner({
817
825
  /** ページ切替(サムネイル・新しいスライド)の直前に、殻から保存を呼べるようにする */
818
826
  useEffect(() => registerAutoSaveFlush(() => saveIfDirtyRef.current()), []);
819
827
 
828
+ /**
829
+ * この編集画面が消えるとき(ページ切替で key が変わる・閉じる)、保存待ちの変更を捨てない。
830
+ *
831
+ * 自動保存は2秒のデバウンス。編集して2秒以内にページを切り替えると、タイマーごと
832
+ * 捨てられて編集が消えていた(構成ラフで実測: 見出しを直して隣のページへ移ると失われる)。
833
+ * useLayoutEffect の後始末は iframe がまだ DOM にある間に走るので、ここで
834
+ * 本文を読み取って保存を始める。onSave はこの画面のもの(古い閉包)なので、
835
+ * 切替先ではなく編集していたページへ正しく保存される。
836
+ */
837
+ useLayoutEffect(() => () => { void saveIfDirtyRef.current(); }, []);
838
+
820
839
  /** ページが変わったら「保存済みの本文」の記憶も切り替える */
821
840
  useEffect(() => {
822
841
  savedPayloadRef.current = null;
@@ -1080,7 +1099,35 @@ function FrontendVisualEditorInner({
1080
1099
  try {
1081
1100
  const { componentId } = JSON.parse(componentData);
1082
1101
  console.log('[FrontendVisualEditor] Parsed componentId:', componentId);
1083
- if (componentId) {
1102
+ if (componentId && partsMode) {
1103
+ // 部品モード: 実体化してフローへ(iframe 内の座標がそのまま来る)
1104
+ const iframeDoc = iframeRef.current?.contentDocument;
1105
+ const element = iframeDoc ? materializePartInstance(componentId, iframeDoc) : null;
1106
+ if (iframeDoc && element) {
1107
+ if (editorMode === 'webpage') {
1108
+ insertIntoFlow(iframeDoc, element, x || 0, y || 0);
1109
+ } else {
1110
+ element.style.position = 'absolute';
1111
+ element.style.left = `${x || 100}px`;
1112
+ element.style.top = `${y || 100}px`;
1113
+ findInsertionParent(iframeDoc).appendChild(element);
1114
+ }
1115
+ if (!element.getAttribute('data-element-id')) {
1116
+ element.setAttribute('data-editable', 'true');
1117
+ element.setAttribute('data-element-id', `el-${Date.now()}-part`);
1118
+ }
1119
+ notifyIframeChange(true);
1120
+ const { extractElementInfo } = await import('./utils/style-utils');
1121
+ const elementInfo = extractElementInfo(element, iframeDoc);
1122
+ if (elementInfo) {
1123
+ setSelectedElement(elementInfo);
1124
+ setSelectedElementIds([element.getAttribute('data-element-id')!]);
1125
+ element.classList.add('selected');
1126
+ }
1127
+ } else {
1128
+ console.error('[FrontendVisualEditor] 部品が見つかりません:', componentId);
1129
+ }
1130
+ } else if (componentId) {
1084
1131
  // インスタンスを作成
1085
1132
  const instance = createInstance(componentId, undefined, contentId);
1086
1133
  console.log('[FrontendVisualEditor] Created instance:', instance);
@@ -1133,7 +1180,7 @@ function FrontendVisualEditorInner({
1133
1180
 
1134
1181
  window.addEventListener('message', handleMessage);
1135
1182
  return () => window.removeEventListener('message', handleMessage);
1136
- }, [uploadFromFile, contentId, createInstance, getMasterComponent, iframeRef, notifyIframeChange, setSelectedElement, setSelectedElementIds]);
1183
+ }, [uploadFromFile, contentId, createInstance, getMasterComponent, iframeRef, notifyIframeChange, setSelectedElement, setSelectedElementIds, partsMode, materializePartInstance, editorMode]);
1137
1184
 
1138
1185
  // ドラッグ&ドロップハンドラ
1139
1186
  const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -1210,6 +1257,38 @@ function FrontendVisualEditorInner({
1210
1257
  y = (e.clientY - iframeRect.top) / dropScale;
1211
1258
  }
1212
1259
 
1260
+ // 部品モード: 定義を実体化してそのまま置く(JSON のインスタンスは作らない)。
1261
+ // webpage はフローに差し込み、slide は従来どおり絶対配置
1262
+ if (partsMode) {
1263
+ const element = materializePartInstance(componentId, iframeDoc);
1264
+ if (!element) {
1265
+ console.error('[FrontendVisualEditor] 部品が見つかりません:', componentId);
1266
+ return;
1267
+ }
1268
+ if (editorMode === 'webpage') {
1269
+ insertIntoFlow(iframeDoc, element, e.clientX - iframeRect.left, e.clientY - iframeRect.top);
1270
+ } else {
1271
+ element.style.position = 'absolute';
1272
+ element.style.left = `${x}px`;
1273
+ element.style.top = `${y}px`;
1274
+ (slideRoot ?? iframeDoc.body).appendChild(element);
1275
+ }
1276
+ // MutationObserver が id を付けるのは非同期なので、選択できるよう先に付ける
1277
+ if (!element.getAttribute('data-element-id')) {
1278
+ element.setAttribute('data-editable', 'true');
1279
+ element.setAttribute('data-element-id', `el-${Date.now()}-part`);
1280
+ }
1281
+ notifyIframeChange(true);
1282
+ const { extractElementInfo } = await import('./utils/style-utils');
1283
+ const elementInfo = extractElementInfo(element, iframeDoc);
1284
+ if (elementInfo) {
1285
+ setSelectedElement(elementInfo);
1286
+ setSelectedElementIds([element.getAttribute('data-element-id')!]);
1287
+ element.classList.add('selected');
1288
+ }
1289
+ return;
1290
+ }
1291
+
1213
1292
  // インスタンスを作成(バリアントIDが指定されていれば使用)
1214
1293
  // Pass position directly to createInstance to avoid React state batching race condition
1215
1294
  console.log('[FrontendVisualEditor] Creating instance for component:', componentId, 'variant:', variantId);
@@ -1287,7 +1366,7 @@ function FrontendVisualEditorInner({
1287
1366
  }
1288
1367
  }
1289
1368
  }
1290
- }, [containerRef, uploadFromFiles, createInstance, getMasterComponent, setSelectedElement, contentId, pushHistory, iframeRef, zoom]);
1369
+ }, [containerRef, uploadFromFiles, createInstance, getMasterComponent, setSelectedElement, contentId, pushHistory, iframeRef, zoom, partsMode, materializePartInstance, editorMode, notifyIframeChange, setSelectedElementIds]);
1291
1370
 
1292
1371
  // クリップボードからのペースト処理(画像、HTML、Excel、Word、SVG)
1293
1372
  // 同じpasteイベントを二度処理しないための記録。
@@ -1814,18 +1893,22 @@ function FrontendVisualEditorInner({
1814
1893
  // ================= UIモード(Figma風 / PowerPoint風) =================
1815
1894
  // ベース(キャンバス・選択・保存・書き戻し)は完全共通で、切り替わるのは殻だけ。
1816
1895
  // 選択はlocalStorageに記憶する
1817
- const isPpt = uiMode === 'ppt' && !isMultiPageCanvas;
1896
+ const isPpt = uiMode === 'ppt' && editorMode === 'slide' && !isMultiPageCanvas;
1818
1897
 
1819
1898
  return (
1899
+ <EditorAppearanceContext.Provider value={pptTheme}>
1820
1900
  <div
1901
+ data-editor-theme={pptTheme}
1902
+ data-editor-mode={editorMode}
1821
1903
  className={cn(
1822
1904
  // [移植時の修正] gg-editor-skin でビューアと同じ配色に揃える(editor-skin.css)
1823
- "gg-editor-skin flex flex-col",
1905
+ "gg-editor-skin gg-editor-ui flex flex-col",
1824
1906
  isMultiPageCanvas ? "absolute inset-0 z-40" : "fixed inset-0 z-50"
1825
1907
  )}
1826
1908
  data-frontend-visual-editor="true"
1827
- style={{ touchAction: 'none', backgroundColor: isPpt ? PPT_PALETTES[pptTheme].chrome : '#1e1e1e' }}
1909
+ style={{ backgroundColor: 'var(--ed-bg)' }}
1828
1910
  >
1911
+ <CanvasAppearance theme={pptTheme} />
1829
1912
  {/* ヘッダー(UIモードで切り替え) */}
1830
1913
  {isPpt ? (
1831
1914
  <>
@@ -1902,6 +1985,12 @@ function FrontendVisualEditorInner({
1902
1985
  </>
1903
1986
  ) : (
1904
1987
  <EditorHeader
1988
+ theme={pptTheme}
1989
+ onToggleTheme={togglePptTheme}
1990
+ layersOpen={layersOpen}
1991
+ onToggleLayers={() => setLayersOpen((v) => !v)}
1992
+ guidesOpen={guidesOpen}
1993
+ onToggleGuides={() => setGuidesOpen((v) => !v)}
1905
1994
  comments={{
1906
1995
  open: pptCommentsOpen,
1907
1996
  toggle: () =>
@@ -1918,13 +2007,13 @@ function FrontendVisualEditorInner({
1918
2007
  saveStatus={saveStatus}
1919
2008
  isCanvasEditing={isMultiPageCanvas}
1920
2009
  onImport={() => setIsImportDialogOpen(true)}
1921
- onCssEdit={handleOpenCssEditor}
2010
+ onCssEdit={can('apiFetch') ? handleOpenCssEditor : undefined}
1922
2011
  hasCss={!!importedCss}
1923
- onJsEdit={handleOpenJsEditor}
2012
+ onJsEdit={can('apiFetch') ? handleOpenJsEditor : undefined}
1924
2013
  hasJs={!!importedJs}
1925
- onPageSettings={handleOpenPageSettings}
2014
+ onPageSettings={can('apiFetch') ? handleOpenPageSettings : undefined}
1926
2015
  hasPageSettings={!!(pageSettings.title || pageSettings.description || pageSettings.ogp?.image)}
1927
- onExport={parentId && contentId ? handleExport : undefined}
2016
+ onExport={can('apiFetch') && parentId && contentId ? handleExport : undefined}
1928
2017
  contextNumber={Number(currentContentId ?? contentId) || undefined}
1929
2018
  contextTitle={contentList.find((c) => c.id === (currentContentId ?? contentId))?.title}
1930
2019
  headerExtra={headerExtra}
@@ -1940,7 +2029,9 @@ function FrontendVisualEditorInner({
1940
2029
  ) : isMultiPageCanvas ? (
1941
2030
  <EditorLayerPanel />
1942
2031
  ) : (
1943
- <LeftPanel page={Number(currentContentId ?? contentId) || 1} pagesSlot={pagesPanel} />
2032
+ <div className={layersOpen && !isComponentPanelOpen ? "flex min-h-0" : "hidden"}>
2033
+ <LeftPanel page={Number(currentContentId ?? contentId) || 1} pagesSlot={pagesPanel} />
2034
+ </div>
1944
2035
  )}
1945
2036
 
1946
2037
  {/* コンポーネントパネル(左側、レイヤーパネルの隣) */}
@@ -1978,10 +2069,10 @@ function FrontendVisualEditorInner({
1978
2069
  <>
1979
2070
  <EditorCanvas />
1980
2071
  {/* ブレイクポイントガイド(webpageモード用) */}
1981
- <BreakpointGuides
2072
+ {guidesOpen && <BreakpointGuides
1982
2073
  containerWidth={canvasAreaSize.width}
1983
2074
  containerHeight={canvasAreaSize.height}
1984
- />
2075
+ />}
1985
2076
  </>
1986
2077
  )}
1987
2078
 
@@ -2035,9 +2126,9 @@ function FrontendVisualEditorInner({
2035
2126
  onBringToFront={selectedElement ? bringToFront : undefined}
2036
2127
  onSendToBack={selectedElement ? sendToBack : undefined}
2037
2128
  onImageUpload={() => openFilePicker({ x: 100, y: 100 })}
2038
- onOpenMediaLibrary={() => setIsMediaLibraryOpen(true)}
2129
+ onOpenMediaLibrary={can('apiFetch') ? () => setIsMediaLibraryOpen(true) : undefined}
2039
2130
  isMediaReplaceMode={isImageSelected}
2040
- onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2131
+ onAiRegenerate={can('apiFetch') && selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2041
2132
  onOpenVariables={() => setIsVariablesPanelOpen(true)}
2042
2133
  hasVariables={hasVariables}
2043
2134
  onOpenComponents={() => setIsComponentPanelOpen(true)}
@@ -2078,8 +2169,8 @@ function FrontendVisualEditorInner({
2078
2169
  {/* 右パネルは同時に1つだけ出す。
2079
2170
  コメントを開いている間はプロパティ(詳細編集)を引っ込め、閉じると戻る。
2080
2171
  2つ並ぶと「いまどちらを操作しているのか」が分からなくなるため */}
2081
- {!isPpt && !pptCommentsOpen && <EditorPropertyPanel />}
2082
- {!isMultiPageCanvas && (
2172
+ {!isPpt && !pptCommentsOpen && (editorMode === 'slide' || selectedElement || selectedElementIds.length > 0 || activeTool === 'scale') && <EditorPropertyPanel />}
2173
+ {!isMultiPageCanvas && can('commentAction') && (
2083
2174
  <PptCommentMarkers
2084
2175
  page={Number(currentContentId ?? contentId) || 1}
2085
2176
  onOpenThread={(id) => {
@@ -2091,23 +2182,25 @@ function FrontendVisualEditorInner({
2091
2182
  {isPpt && pptFormatPaneOpen && (
2092
2183
  <PptFormatPane theme={pptTheme} onClose={() => setPptFormatPaneOpen(false)} />
2093
2184
  )}
2094
- {!isMultiPageCanvas && pptCommentsOpen && (
2185
+ {!isMultiPageCanvas && can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2186
+ <div className={pptCommentsOpen ? 'flex min-h-0' : 'hidden'}>
2095
2187
  <PptCommentsPanel
2096
2188
  page={Number(currentContentId ?? contentId) || 1}
2097
- theme={isPpt ? pptTheme : 'dark'}
2189
+ theme={pptTheme}
2098
2190
  onClose={() => setPptCommentsOpen(false)}
2099
2191
  focusSignal={pptCommentFocus}
2100
2192
  activeThreadId={pptActiveThread}
2101
2193
  onActiveThread={setPptActiveThread}
2102
2194
  />
2195
+ </div>
2103
2196
  )}
2104
2197
  </div>
2105
2198
 
2106
2199
  {/* ノート欄(トークスクリプト)。PowerPoint風・Figma風の両方に出す。
2107
2200
  Figma風はダーク配色で固定(スキンと馴染む) */}
2108
2201
  {/* ノート欄は発表原稿。Webページには無い概念なので出さない */}
2109
- {!isMultiPageCanvas && editorMode !== 'webpage' && (
2110
- <PptNotes page={Number(currentContentId ?? contentId) || 1} theme={isPpt ? pptTheme : 'dark'} />
2202
+ {!isMultiPageCanvas && editorMode !== 'webpage' && can('apiFetch') && (
2203
+ <PptNotes page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} />
2111
2204
  )}
2112
2205
 
2113
2206
  {/* フッター(Figma風) / ステータスバー(PowerPoint風) */}
@@ -2127,13 +2220,18 @@ function FrontendVisualEditorInner({
2127
2220
  hasStyleInClipboard={hasStyleInClipboard()}
2128
2221
  isComponentInstance={isComponentInstance}
2129
2222
  hasOverrides={hasOverrides}
2223
+ partsMode={partsMode}
2224
+ isPartInstance={isPartInstance}
2225
+ partLabel={partLabel}
2226
+ onDetachPart={handleDetachPart}
2227
+ onUpdatePart={can('savePart') ? handleUpdatePart : undefined}
2130
2228
  onCopy={selectedElement ? copyElements : undefined}
2131
2229
  onCut={selectedElement ? cutElements : undefined}
2132
2230
  onPaste={pasteElements}
2133
2231
  onDelete={selectedElement ? deleteElement : undefined}
2134
2232
  onDuplicate={selectedElement ? duplicateElement : undefined}
2135
2233
  onReplaceImageFromFile={isImageSelected ? replaceImageFromFile : undefined}
2136
- onReplaceImageFromLibrary={isImageSelected ? () => setIsMediaLibraryOpen(true) : undefined}
2234
+ onReplaceImageFromLibrary={can('apiFetch') && isImageSelected ? () => setIsMediaLibraryOpen(true) : undefined}
2137
2235
  onReplaceImageFromClipboard={isImageSelected ? () => void replaceImageFromClipboard() : undefined}
2138
2236
  onCopyStyle={selectedElement ? copyStyle : undefined}
2139
2237
  onPasteStyle={selectedElement ? pasteStyle : undefined}
@@ -2150,7 +2248,7 @@ function FrontendVisualEditorInner({
2150
2248
  onDeleteRow={tableActions?.deleteRow}
2151
2249
  onDeleteColumn={tableActions?.deleteColumn}
2152
2250
  onEditLink={selectedElement ? handleEditLink : undefined}
2153
- onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2251
+ onAiRegenerate={can('apiFetch') && selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2154
2252
  onEditHtml={selectedElement ? handleEditHtml : undefined}
2155
2253
  onCreateComponent={handleCreateComponent}
2156
2254
  onGoToMainComponent={handleGoToMainComponent}
@@ -2170,6 +2268,7 @@ function FrontendVisualEditorInner({
2170
2268
  />
2171
2269
 
2172
2270
  {/* HTML編集ダイアログ */}
2271
+ {htmlEditorState.isOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2173
2272
  <HtmlEditorDialog
2174
2273
  isOpen={htmlEditorState.isOpen}
2175
2274
  onClose={() => {
@@ -2182,8 +2281,10 @@ function FrontendVisualEditorInner({
2182
2281
  onSave={handleSaveHtml}
2183
2282
  initialHtml={htmlEditorState.initialHtml}
2184
2283
  />
2284
+ </Suspense>}
2185
2285
 
2186
2286
  {/* HTMLインポートダイアログ */}
2287
+ {isImportDialogOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2187
2288
  <HtmlImportDialog
2188
2289
  isOpen={isImportDialogOpen}
2189
2290
  onClose={() => {
@@ -2197,8 +2298,10 @@ function FrontendVisualEditorInner({
2197
2298
  presentationId={parentId}
2198
2299
  slideId={contentId}
2199
2300
  />
2301
+ </Suspense>}
2200
2302
 
2201
2303
  {/* CSS編集ダイアログ */}
2304
+ {isCssEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2202
2305
  <CssEditorDialog
2203
2306
  isOpen={isCssEditorOpen}
2204
2307
  onClose={() => {
@@ -2212,8 +2315,10 @@ function FrontendVisualEditorInner({
2212
2315
  onSave={handleSaveCss}
2213
2316
  onClear={handleClearCss}
2214
2317
  />
2318
+ </Suspense>}
2215
2319
 
2216
2320
  {/* JS編集ダイアログ */}
2321
+ {isJsEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2217
2322
  <JsEditorDialog
2218
2323
  isOpen={isJsEditorOpen}
2219
2324
  onClose={() => {
@@ -2227,8 +2332,10 @@ function FrontendVisualEditorInner({
2227
2332
  onSave={handleSaveJs}
2228
2333
  onClear={handleClearJs}
2229
2334
  />
2335
+ </Suspense>}
2230
2336
 
2231
2337
  {/* ページ設定ダイアログ */}
2338
+ {isPageSettingsOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2232
2339
  <PageSettingsDialog
2233
2340
  isOpen={isPageSettingsOpen}
2234
2341
  onClose={() => {
@@ -2243,8 +2350,10 @@ function FrontendVisualEditorInner({
2243
2350
  onSave={handleSavePageSettings}
2244
2351
  onUploadImage={handleUploadOgpImage}
2245
2352
  />
2353
+ </Suspense>}
2246
2354
 
2247
2355
  {/* メディアライブラリ */}
2356
+ {isMediaLibraryOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2248
2357
  <MediaLibraryDialog
2249
2358
  isOpen={isMediaLibraryOpen}
2250
2359
  onClose={() => {
@@ -2258,6 +2367,7 @@ function FrontendVisualEditorInner({
2258
2367
  mode={isImageSelected ? 'replace' : 'insert'}
2259
2368
  currentSrc={selectedImageSrc}
2260
2369
  />
2370
+ </Suspense>}
2261
2371
 
2262
2372
  {/* CSS変数パネル */}
2263
2373
  <VariablesPanel
@@ -2282,7 +2392,7 @@ function FrontendVisualEditorInner({
2282
2392
  <Dialog open={createComponentDialogOpen} onOpenChange={setCreateComponentDialogOpen}>
2283
2393
  <DialogContent className="bg-[#2c2c2c] border-[#444444] text-white">
2284
2394
  <DialogHeader>
2285
- <DialogTitle>コンポーネントを作成</DialogTitle>
2395
+ <DialogTitle>{partsMode ? '部品として保存' : 'コンポーネントを作成'}</DialogTitle>
2286
2396
  </DialogHeader>
2287
2397
  <div className="space-y-4 py-4">
2288
2398
  <div className="space-y-2">
@@ -2323,6 +2433,7 @@ function FrontendVisualEditorInner({
2323
2433
  </Dialog>
2324
2434
 
2325
2435
  {/* マスターコンポーネントエディタ */}
2436
+ {isMasterEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2326
2437
  <MasterComponentEditor
2327
2438
  master={editingMasterComponent}
2328
2439
  open={isMasterEditorOpen}
@@ -2340,6 +2451,7 @@ function FrontendVisualEditorInner({
2340
2451
  name: c.name,
2341
2452
  }))}
2342
2453
  />
2454
+ </Suspense>}
2343
2455
 
2344
2456
  {/* レイアウトモード切替ヒントトースト。
2345
2457
  Webページでは絶対配置に倒さないので、それを勧めるこの導線も出さない */}
@@ -2384,7 +2496,29 @@ function FrontendVisualEditorInner({
2384
2496
  </div>
2385
2497
  </div>
2386
2498
  )}
2499
+
2500
+ {/* Webページ用の案内。
2501
+ Webページでは「絶対配置モードに切り替える」を勧めない(版面が固定pxに固まる)ので、
2502
+ 代わりに逃げ道だけを伝える。sonner の Toaster はアプリ側に無く toast() が
2503
+ 表示されないため、既存のこの帯を流用している */}
2504
+ {showLayoutHint && editorMode === 'webpage' && (
2505
+ <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] animate-in fade-in slide-in-from-bottom-2 duration-300">
2506
+ <div className="bg-black/90 text-white px-4 py-3 rounded-lg shadow-xl flex items-center gap-4">
2507
+ <span className="text-sm">
2508
+ 並べ替える相手がありません。Cmd/Ctrl+ドラッグで自由配置、ダブルクリックで中に入れます
2509
+ </span>
2510
+ <button
2511
+ onClick={() => setShowLayoutHint(false)}
2512
+ className="text-gray-400 hover:text-white text-lg leading-none ml-1"
2513
+ aria-label="閉じる"
2514
+ >
2515
+ ×
2516
+ </button>
2517
+ </div>
2518
+ </div>
2519
+ )}
2387
2520
  </div>
2521
+ </EditorAppearanceContext.Provider>
2388
2522
  );
2389
2523
  }
2390
2524
 
@@ -2430,9 +2564,11 @@ export function FrontendVisualEditor({
2430
2564
  const [initialVariables, setInitialVariables] = useState<CSSVariableDefinition[]>([]);
2431
2565
  const [isVariablesLoaded, setIsVariablesLoaded] = useState(false);
2432
2566
 
2433
- // CSS変数をロード(webpageモード・slideモード両対応)
2567
+ // CSS変数をロード(webpageモード・slideモード両対応)。
2568
+ // 利用側が io.loadVariables を渡していればそれ(ファイル等)から、無ければブラウザ内(localStorage)から
2434
2569
  useEffect(() => {
2435
- if (!effectiveParentId) {
2570
+ const fromIo = io().loadVariables;
2571
+ if (!effectiveParentId && !fromIo) {
2436
2572
  setIsVariablesLoaded(true);
2437
2573
  return;
2438
2574
  }
@@ -2441,9 +2577,11 @@ export function FrontendVisualEditor({
2441
2577
  try {
2442
2578
  // editorModeに応じてスコープを決定
2443
2579
  const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2444
- const variables = await getCSSVariablesList(effectiveParentId, scope);
2580
+ const variables = fromIo
2581
+ ? await fromIo()
2582
+ : await getCSSVariablesList(effectiveParentId!, scope);
2445
2583
  setInitialVariables(variables);
2446
- console.log(`[FrontendVisualEditor] Loaded CSS variables (${scope}):`, variables.length);
2584
+ console.log(`[FrontendVisualEditor] Loaded CSS variables (${fromIo ? 'io' : scope}):`, variables.length);
2447
2585
  } catch (error) {
2448
2586
  console.error('[FrontendVisualEditor] Failed to load CSS variables:', error);
2449
2587
  } finally {
@@ -2456,6 +2594,12 @@ export function FrontendVisualEditor({
2456
2594
 
2457
2595
  // CSS変数を保存するコールバック
2458
2596
  const handleSaveVariables = useCallback(async (variables: CSSVariableDefinition[]) => {
2597
+ const toIo = io().saveVariables;
2598
+ if (toIo) {
2599
+ await toIo(variables);
2600
+ console.log('[FrontendVisualEditor] Saved CSS variables (io):', variables.length);
2601
+ return;
2602
+ }
2459
2603
  if (!effectiveParentId) {
2460
2604
  throw new Error('Parent ID is required to save CSS variables');
2461
2605
  }
@@ -2467,6 +2611,8 @@ export function FrontendVisualEditor({
2467
2611
 
2468
2612
  // CSS変数をロードするコールバック
2469
2613
  const handleLoadVariables = useCallback(async (resourceId: string) => {
2614
+ const fromIo = io().loadVariables;
2615
+ if (fromIo) return fromIo();
2470
2616
  // editorModeに応じてスコープを決定
2471
2617
  const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2472
2618
  return getCSSVariablesList(resourceId, scope);
@@ -318,14 +318,19 @@ export function EditorCanvas() {
318
318
  if (!container) return;
319
319
 
320
320
  const scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
321
- const scrollTop = (container.scrollHeight - container.clientHeight) / 2;
321
+ // webpage は上端固定(紙面は #canvas-scroll-area で flex-start)。
322
+ // 初期表示もページの先頭から見えるべきで、縦の中央へ飛ばさない
323
+ const scrollTop =
324
+ editorMode === "webpage"
325
+ ? 0
326
+ : (container.scrollHeight - container.clientHeight) / 2;
322
327
 
323
328
  container.scrollTo({
324
329
  left: scrollLeft,
325
330
  top: scrollTop,
326
331
  behavior: "instant",
327
332
  });
328
- }, [containerRef]);
333
+ }, [containerRef, editorMode]);
329
334
 
330
335
  useEffect(() => {
331
336
  if (isInMultiPageMode) return; // マルチページモードではキャンバスがスクロールを管理
@@ -973,7 +978,7 @@ export function EditorCanvas() {
973
978
  ref={containerRef}
974
979
  className="h-full w-full"
975
980
  style={{
976
- backgroundColor: isInMultiPageMode ? "transparent" : "#1a1a1a",
981
+ backgroundColor: isInMultiPageMode ? "transparent" : "var(--ed-bg)",
977
982
  }}
978
983
  >
979
984
  <iframe