@growgroup/visual-editor 0.1.6 → 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 (42) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +157 -43
  13. package/src/editor/components/EditorCanvas.tsx +1 -1
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  34. package/src/editor/hooks/useIframeSetup.ts +10 -0
  35. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  36. package/src/editor/hooks/useResizablePanel.ts +40 -11
  37. package/src/editor/parts.ts +321 -0
  38. package/src/editor/utils/dom-utils.ts +5 -4
  39. package/src/index.ts +25 -0
  40. package/src/io.ts +54 -0
  41. package/src/styles/skin.css +159 -0
  42. package/src/types/editor-components.ts +23 -1
@@ -11,7 +11,7 @@
11
11
  * 汎用的なHTML編集エディタとして、スライド、ページ、コンポーネント等で利用可能
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useLayoutEffect, 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,
@@ -1091,7 +1099,35 @@ function FrontendVisualEditorInner({
1091
1099
  try {
1092
1100
  const { componentId } = JSON.parse(componentData);
1093
1101
  console.log('[FrontendVisualEditor] Parsed componentId:', componentId);
1094
- 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) {
1095
1131
  // インスタンスを作成
1096
1132
  const instance = createInstance(componentId, undefined, contentId);
1097
1133
  console.log('[FrontendVisualEditor] Created instance:', instance);
@@ -1144,7 +1180,7 @@ function FrontendVisualEditorInner({
1144
1180
 
1145
1181
  window.addEventListener('message', handleMessage);
1146
1182
  return () => window.removeEventListener('message', handleMessage);
1147
- }, [uploadFromFile, contentId, createInstance, getMasterComponent, iframeRef, notifyIframeChange, setSelectedElement, setSelectedElementIds]);
1183
+ }, [uploadFromFile, contentId, createInstance, getMasterComponent, iframeRef, notifyIframeChange, setSelectedElement, setSelectedElementIds, partsMode, materializePartInstance, editorMode]);
1148
1184
 
1149
1185
  // ドラッグ&ドロップハンドラ
1150
1186
  const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -1221,6 +1257,38 @@ function FrontendVisualEditorInner({
1221
1257
  y = (e.clientY - iframeRect.top) / dropScale;
1222
1258
  }
1223
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
+
1224
1292
  // インスタンスを作成(バリアントIDが指定されていれば使用)
1225
1293
  // Pass position directly to createInstance to avoid React state batching race condition
1226
1294
  console.log('[FrontendVisualEditor] Creating instance for component:', componentId, 'variant:', variantId);
@@ -1298,7 +1366,7 @@ function FrontendVisualEditorInner({
1298
1366
  }
1299
1367
  }
1300
1368
  }
1301
- }, [containerRef, uploadFromFiles, createInstance, getMasterComponent, setSelectedElement, contentId, pushHistory, iframeRef, zoom]);
1369
+ }, [containerRef, uploadFromFiles, createInstance, getMasterComponent, setSelectedElement, contentId, pushHistory, iframeRef, zoom, partsMode, materializePartInstance, editorMode, notifyIframeChange, setSelectedElementIds]);
1302
1370
 
1303
1371
  // クリップボードからのペースト処理(画像、HTML、Excel、Word、SVG)
1304
1372
  // 同じpasteイベントを二度処理しないための記録。
@@ -1825,18 +1893,22 @@ function FrontendVisualEditorInner({
1825
1893
  // ================= UIモード(Figma風 / PowerPoint風) =================
1826
1894
  // ベース(キャンバス・選択・保存・書き戻し)は完全共通で、切り替わるのは殻だけ。
1827
1895
  // 選択はlocalStorageに記憶する
1828
- const isPpt = uiMode === 'ppt' && !isMultiPageCanvas;
1896
+ const isPpt = uiMode === 'ppt' && editorMode === 'slide' && !isMultiPageCanvas;
1829
1897
 
1830
1898
  return (
1899
+ <EditorAppearanceContext.Provider value={pptTheme}>
1831
1900
  <div
1901
+ data-editor-theme={pptTheme}
1902
+ data-editor-mode={editorMode}
1832
1903
  className={cn(
1833
1904
  // [移植時の修正] gg-editor-skin でビューアと同じ配色に揃える(editor-skin.css)
1834
- "gg-editor-skin flex flex-col",
1905
+ "gg-editor-skin gg-editor-ui flex flex-col",
1835
1906
  isMultiPageCanvas ? "absolute inset-0 z-40" : "fixed inset-0 z-50"
1836
1907
  )}
1837
1908
  data-frontend-visual-editor="true"
1838
- style={{ touchAction: 'none', backgroundColor: isPpt ? PPT_PALETTES[pptTheme].chrome : '#1e1e1e' }}
1909
+ style={{ backgroundColor: 'var(--ed-bg)' }}
1839
1910
  >
1911
+ <CanvasAppearance theme={pptTheme} />
1840
1912
  {/* ヘッダー(UIモードで切り替え) */}
1841
1913
  {isPpt ? (
1842
1914
  <>
@@ -1913,6 +1985,12 @@ function FrontendVisualEditorInner({
1913
1985
  </>
1914
1986
  ) : (
1915
1987
  <EditorHeader
1988
+ theme={pptTheme}
1989
+ onToggleTheme={togglePptTheme}
1990
+ layersOpen={layersOpen}
1991
+ onToggleLayers={() => setLayersOpen((v) => !v)}
1992
+ guidesOpen={guidesOpen}
1993
+ onToggleGuides={() => setGuidesOpen((v) => !v)}
1916
1994
  comments={{
1917
1995
  open: pptCommentsOpen,
1918
1996
  toggle: () =>
@@ -1929,13 +2007,13 @@ function FrontendVisualEditorInner({
1929
2007
  saveStatus={saveStatus}
1930
2008
  isCanvasEditing={isMultiPageCanvas}
1931
2009
  onImport={() => setIsImportDialogOpen(true)}
1932
- onCssEdit={handleOpenCssEditor}
2010
+ onCssEdit={can('apiFetch') ? handleOpenCssEditor : undefined}
1933
2011
  hasCss={!!importedCss}
1934
- onJsEdit={handleOpenJsEditor}
2012
+ onJsEdit={can('apiFetch') ? handleOpenJsEditor : undefined}
1935
2013
  hasJs={!!importedJs}
1936
- onPageSettings={handleOpenPageSettings}
2014
+ onPageSettings={can('apiFetch') ? handleOpenPageSettings : undefined}
1937
2015
  hasPageSettings={!!(pageSettings.title || pageSettings.description || pageSettings.ogp?.image)}
1938
- onExport={parentId && contentId ? handleExport : undefined}
2016
+ onExport={can('apiFetch') && parentId && contentId ? handleExport : undefined}
1939
2017
  contextNumber={Number(currentContentId ?? contentId) || undefined}
1940
2018
  contextTitle={contentList.find((c) => c.id === (currentContentId ?? contentId))?.title}
1941
2019
  headerExtra={headerExtra}
@@ -1951,7 +2029,9 @@ function FrontendVisualEditorInner({
1951
2029
  ) : isMultiPageCanvas ? (
1952
2030
  <EditorLayerPanel />
1953
2031
  ) : (
1954
- <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>
1955
2035
  )}
1956
2036
 
1957
2037
  {/* コンポーネントパネル(左側、レイヤーパネルの隣) */}
@@ -1989,10 +2069,10 @@ function FrontendVisualEditorInner({
1989
2069
  <>
1990
2070
  <EditorCanvas />
1991
2071
  {/* ブレイクポイントガイド(webpageモード用) */}
1992
- <BreakpointGuides
2072
+ {guidesOpen && <BreakpointGuides
1993
2073
  containerWidth={canvasAreaSize.width}
1994
2074
  containerHeight={canvasAreaSize.height}
1995
- />
2075
+ />}
1996
2076
  </>
1997
2077
  )}
1998
2078
 
@@ -2046,9 +2126,9 @@ function FrontendVisualEditorInner({
2046
2126
  onBringToFront={selectedElement ? bringToFront : undefined}
2047
2127
  onSendToBack={selectedElement ? sendToBack : undefined}
2048
2128
  onImageUpload={() => openFilePicker({ x: 100, y: 100 })}
2049
- onOpenMediaLibrary={() => setIsMediaLibraryOpen(true)}
2129
+ onOpenMediaLibrary={can('apiFetch') ? () => setIsMediaLibraryOpen(true) : undefined}
2050
2130
  isMediaReplaceMode={isImageSelected}
2051
- onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2131
+ onAiRegenerate={can('apiFetch') && selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2052
2132
  onOpenVariables={() => setIsVariablesPanelOpen(true)}
2053
2133
  hasVariables={hasVariables}
2054
2134
  onOpenComponents={() => setIsComponentPanelOpen(true)}
@@ -2089,8 +2169,8 @@ function FrontendVisualEditorInner({
2089
2169
  {/* 右パネルは同時に1つだけ出す。
2090
2170
  コメントを開いている間はプロパティ(詳細編集)を引っ込め、閉じると戻る。
2091
2171
  2つ並ぶと「いまどちらを操作しているのか」が分からなくなるため */}
2092
- {!isPpt && !pptCommentsOpen && <EditorPropertyPanel />}
2093
- {!isMultiPageCanvas && (
2172
+ {!isPpt && !pptCommentsOpen && (editorMode === 'slide' || selectedElement || selectedElementIds.length > 0 || activeTool === 'scale') && <EditorPropertyPanel />}
2173
+ {!isMultiPageCanvas && can('commentAction') && (
2094
2174
  <PptCommentMarkers
2095
2175
  page={Number(currentContentId ?? contentId) || 1}
2096
2176
  onOpenThread={(id) => {
@@ -2102,23 +2182,25 @@ function FrontendVisualEditorInner({
2102
2182
  {isPpt && pptFormatPaneOpen && (
2103
2183
  <PptFormatPane theme={pptTheme} onClose={() => setPptFormatPaneOpen(false)} />
2104
2184
  )}
2105
- {!isMultiPageCanvas && pptCommentsOpen && (
2185
+ {!isMultiPageCanvas && can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2186
+ <div className={pptCommentsOpen ? 'flex min-h-0' : 'hidden'}>
2106
2187
  <PptCommentsPanel
2107
2188
  page={Number(currentContentId ?? contentId) || 1}
2108
- theme={isPpt ? pptTheme : 'dark'}
2189
+ theme={pptTheme}
2109
2190
  onClose={() => setPptCommentsOpen(false)}
2110
2191
  focusSignal={pptCommentFocus}
2111
2192
  activeThreadId={pptActiveThread}
2112
2193
  onActiveThread={setPptActiveThread}
2113
2194
  />
2195
+ </div>
2114
2196
  )}
2115
2197
  </div>
2116
2198
 
2117
2199
  {/* ノート欄(トークスクリプト)。PowerPoint風・Figma風の両方に出す。
2118
2200
  Figma風はダーク配色で固定(スキンと馴染む) */}
2119
2201
  {/* ノート欄は発表原稿。Webページには無い概念なので出さない */}
2120
- {!isMultiPageCanvas && editorMode !== 'webpage' && (
2121
- <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} />
2122
2204
  )}
2123
2205
 
2124
2206
  {/* フッター(Figma風) / ステータスバー(PowerPoint風) */}
@@ -2138,13 +2220,18 @@ function FrontendVisualEditorInner({
2138
2220
  hasStyleInClipboard={hasStyleInClipboard()}
2139
2221
  isComponentInstance={isComponentInstance}
2140
2222
  hasOverrides={hasOverrides}
2223
+ partsMode={partsMode}
2224
+ isPartInstance={isPartInstance}
2225
+ partLabel={partLabel}
2226
+ onDetachPart={handleDetachPart}
2227
+ onUpdatePart={can('savePart') ? handleUpdatePart : undefined}
2141
2228
  onCopy={selectedElement ? copyElements : undefined}
2142
2229
  onCut={selectedElement ? cutElements : undefined}
2143
2230
  onPaste={pasteElements}
2144
2231
  onDelete={selectedElement ? deleteElement : undefined}
2145
2232
  onDuplicate={selectedElement ? duplicateElement : undefined}
2146
2233
  onReplaceImageFromFile={isImageSelected ? replaceImageFromFile : undefined}
2147
- onReplaceImageFromLibrary={isImageSelected ? () => setIsMediaLibraryOpen(true) : undefined}
2234
+ onReplaceImageFromLibrary={can('apiFetch') && isImageSelected ? () => setIsMediaLibraryOpen(true) : undefined}
2148
2235
  onReplaceImageFromClipboard={isImageSelected ? () => void replaceImageFromClipboard() : undefined}
2149
2236
  onCopyStyle={selectedElement ? copyStyle : undefined}
2150
2237
  onPasteStyle={selectedElement ? pasteStyle : undefined}
@@ -2161,7 +2248,7 @@ function FrontendVisualEditorInner({
2161
2248
  onDeleteRow={tableActions?.deleteRow}
2162
2249
  onDeleteColumn={tableActions?.deleteColumn}
2163
2250
  onEditLink={selectedElement ? handleEditLink : undefined}
2164
- onAiRegenerate={selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2251
+ onAiRegenerate={can('apiFetch') && selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2165
2252
  onEditHtml={selectedElement ? handleEditHtml : undefined}
2166
2253
  onCreateComponent={handleCreateComponent}
2167
2254
  onGoToMainComponent={handleGoToMainComponent}
@@ -2181,6 +2268,7 @@ function FrontendVisualEditorInner({
2181
2268
  />
2182
2269
 
2183
2270
  {/* HTML編集ダイアログ */}
2271
+ {htmlEditorState.isOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2184
2272
  <HtmlEditorDialog
2185
2273
  isOpen={htmlEditorState.isOpen}
2186
2274
  onClose={() => {
@@ -2193,8 +2281,10 @@ function FrontendVisualEditorInner({
2193
2281
  onSave={handleSaveHtml}
2194
2282
  initialHtml={htmlEditorState.initialHtml}
2195
2283
  />
2284
+ </Suspense>}
2196
2285
 
2197
2286
  {/* HTMLインポートダイアログ */}
2287
+ {isImportDialogOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2198
2288
  <HtmlImportDialog
2199
2289
  isOpen={isImportDialogOpen}
2200
2290
  onClose={() => {
@@ -2208,8 +2298,10 @@ function FrontendVisualEditorInner({
2208
2298
  presentationId={parentId}
2209
2299
  slideId={contentId}
2210
2300
  />
2301
+ </Suspense>}
2211
2302
 
2212
2303
  {/* CSS編集ダイアログ */}
2304
+ {isCssEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2213
2305
  <CssEditorDialog
2214
2306
  isOpen={isCssEditorOpen}
2215
2307
  onClose={() => {
@@ -2223,8 +2315,10 @@ function FrontendVisualEditorInner({
2223
2315
  onSave={handleSaveCss}
2224
2316
  onClear={handleClearCss}
2225
2317
  />
2318
+ </Suspense>}
2226
2319
 
2227
2320
  {/* JS編集ダイアログ */}
2321
+ {isJsEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2228
2322
  <JsEditorDialog
2229
2323
  isOpen={isJsEditorOpen}
2230
2324
  onClose={() => {
@@ -2238,8 +2332,10 @@ function FrontendVisualEditorInner({
2238
2332
  onSave={handleSaveJs}
2239
2333
  onClear={handleClearJs}
2240
2334
  />
2335
+ </Suspense>}
2241
2336
 
2242
2337
  {/* ページ設定ダイアログ */}
2338
+ {isPageSettingsOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2243
2339
  <PageSettingsDialog
2244
2340
  isOpen={isPageSettingsOpen}
2245
2341
  onClose={() => {
@@ -2254,8 +2350,10 @@ function FrontendVisualEditorInner({
2254
2350
  onSave={handleSavePageSettings}
2255
2351
  onUploadImage={handleUploadOgpImage}
2256
2352
  />
2353
+ </Suspense>}
2257
2354
 
2258
2355
  {/* メディアライブラリ */}
2356
+ {isMediaLibraryOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2259
2357
  <MediaLibraryDialog
2260
2358
  isOpen={isMediaLibraryOpen}
2261
2359
  onClose={() => {
@@ -2269,6 +2367,7 @@ function FrontendVisualEditorInner({
2269
2367
  mode={isImageSelected ? 'replace' : 'insert'}
2270
2368
  currentSrc={selectedImageSrc}
2271
2369
  />
2370
+ </Suspense>}
2272
2371
 
2273
2372
  {/* CSS変数パネル */}
2274
2373
  <VariablesPanel
@@ -2293,7 +2392,7 @@ function FrontendVisualEditorInner({
2293
2392
  <Dialog open={createComponentDialogOpen} onOpenChange={setCreateComponentDialogOpen}>
2294
2393
  <DialogContent className="bg-[#2c2c2c] border-[#444444] text-white">
2295
2394
  <DialogHeader>
2296
- <DialogTitle>コンポーネントを作成</DialogTitle>
2395
+ <DialogTitle>{partsMode ? '部品として保存' : 'コンポーネントを作成'}</DialogTitle>
2297
2396
  </DialogHeader>
2298
2397
  <div className="space-y-4 py-4">
2299
2398
  <div className="space-y-2">
@@ -2334,6 +2433,7 @@ function FrontendVisualEditorInner({
2334
2433
  </Dialog>
2335
2434
 
2336
2435
  {/* マスターコンポーネントエディタ */}
2436
+ {isMasterEditorOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2337
2437
  <MasterComponentEditor
2338
2438
  master={editingMasterComponent}
2339
2439
  open={isMasterEditorOpen}
@@ -2351,6 +2451,7 @@ function FrontendVisualEditorInner({
2351
2451
  name: c.name,
2352
2452
  }))}
2353
2453
  />
2454
+ </Suspense>}
2354
2455
 
2355
2456
  {/* レイアウトモード切替ヒントトースト。
2356
2457
  Webページでは絶対配置に倒さないので、それを勧めるこの導線も出さない */}
@@ -2417,6 +2518,7 @@ function FrontendVisualEditorInner({
2417
2518
  </div>
2418
2519
  )}
2419
2520
  </div>
2521
+ </EditorAppearanceContext.Provider>
2420
2522
  );
2421
2523
  }
2422
2524
 
@@ -2462,9 +2564,11 @@ export function FrontendVisualEditor({
2462
2564
  const [initialVariables, setInitialVariables] = useState<CSSVariableDefinition[]>([]);
2463
2565
  const [isVariablesLoaded, setIsVariablesLoaded] = useState(false);
2464
2566
 
2465
- // CSS変数をロード(webpageモード・slideモード両対応)
2567
+ // CSS変数をロード(webpageモード・slideモード両対応)。
2568
+ // 利用側が io.loadVariables を渡していればそれ(ファイル等)から、無ければブラウザ内(localStorage)から
2466
2569
  useEffect(() => {
2467
- if (!effectiveParentId) {
2570
+ const fromIo = io().loadVariables;
2571
+ if (!effectiveParentId && !fromIo) {
2468
2572
  setIsVariablesLoaded(true);
2469
2573
  return;
2470
2574
  }
@@ -2473,9 +2577,11 @@ export function FrontendVisualEditor({
2473
2577
  try {
2474
2578
  // editorModeに応じてスコープを決定
2475
2579
  const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2476
- const variables = await getCSSVariablesList(effectiveParentId, scope);
2580
+ const variables = fromIo
2581
+ ? await fromIo()
2582
+ : await getCSSVariablesList(effectiveParentId!, scope);
2477
2583
  setInitialVariables(variables);
2478
- console.log(`[FrontendVisualEditor] Loaded CSS variables (${scope}):`, variables.length);
2584
+ console.log(`[FrontendVisualEditor] Loaded CSS variables (${fromIo ? 'io' : scope}):`, variables.length);
2479
2585
  } catch (error) {
2480
2586
  console.error('[FrontendVisualEditor] Failed to load CSS variables:', error);
2481
2587
  } finally {
@@ -2488,6 +2594,12 @@ export function FrontendVisualEditor({
2488
2594
 
2489
2595
  // CSS変数を保存するコールバック
2490
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
+ }
2491
2603
  if (!effectiveParentId) {
2492
2604
  throw new Error('Parent ID is required to save CSS variables');
2493
2605
  }
@@ -2499,6 +2611,8 @@ export function FrontendVisualEditor({
2499
2611
 
2500
2612
  // CSS変数をロードするコールバック
2501
2613
  const handleLoadVariables = useCallback(async (resourceId: string) => {
2614
+ const fromIo = io().loadVariables;
2615
+ if (fromIo) return fromIo();
2502
2616
  // editorModeに応じてスコープを決定
2503
2617
  const scope: CSSVariableScope = editorMode === 'webpage' ? 'website' : 'presentation';
2504
2618
  return getCSSVariablesList(resourceId, scope);
@@ -978,7 +978,7 @@ export function EditorCanvas() {
978
978
  ref={containerRef}
979
979
  className="h-full w-full"
980
980
  style={{
981
- backgroundColor: isInMultiPageMode ? "transparent" : "#1a1a1a",
981
+ backgroundColor: isInMultiPageMode ? "transparent" : "var(--ed-bg)",
982
982
  }}
983
983
  >
984
984
  <iframe