@growgroup/visual-editor 0.2.2 → 0.3.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 (37) hide show
  1. package/README.md +37 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +7 -3
  5. package/src/editor/FrontendVisualEditor.tsx +154 -54
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +93 -8
  8. package/src/editor/components/EditorHeader.tsx +47 -12
  9. package/src/editor/components/EditorPropertyPanel.tsx +2 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  11. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
  12. package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
  13. package/src/editor/components/multi-page/index.ts +2 -6
  14. package/src/editor/components/ppt/PptComments.tsx +8 -4
  15. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  16. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  17. package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
  18. package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
  19. package/src/editor/hooks/useCanvasControls.ts +1 -1
  20. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  21. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  22. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  23. package/src/editor/hooks/useDrawingMode.ts +1 -0
  24. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  25. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  26. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  27. package/src/editor/utils/dom-utils.ts +7 -1
  28. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  29. package/src/editor/utils/html-utils.ts +86 -11
  30. package/src/io.ts +20 -0
  31. package/src/styles/skin.css +66 -34
  32. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  33. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  34. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  35. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  36. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  37. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -30,7 +30,7 @@ import { PptFormatPane } from './components/ppt/PptFormatPane';
30
30
  import { createAuthApi } from '../lib/api/auth-fetch';
31
31
  import { useAuth } from '../components/auth/AuthProvider';
32
32
  import { findSlideRoot, findInsertionParent } from './utils/slide-root';
33
- import { registerAutoSaveFlush } from './autosave';
33
+ import { registerAutoSaveFlush, flushAutoSave, beginContentSwitch, endContentSwitch, isContentSwitching } from './autosave';
34
34
  import { getCleanHtml } from './utils/html-utils';
35
35
  import { EditorAppearanceContext, useEditorTheme } from './contexts/EditorAppearanceContext';
36
36
  import { CanvasAppearance } from './components/shell/CanvasAppearance';
@@ -69,6 +69,7 @@ import { BreakpointGuides } from './components/BreakpointGuides';
69
69
  import type { ContextMenuPosition } from './components';
70
70
  import { EditorToolbar } from './EditorToolbar';
71
71
  import { MultiPageCanvasView } from './components/multi-page';
72
+ import { useMultiPageCanvasOptional } from './contexts/MultiPageCanvasContext';
72
73
  import { convertToAbsolutePositioning, buildDomTree, getArtboardContent, canUngroup, updateSelectionBox } from './utils/dom-utils';
73
74
  import { extractElementInfo } from './utils/style-utils';
74
75
  import { findTableCell, insertRow, insertColumn, deleteRow, deleteColumn } from './utils/table-edit';
@@ -138,10 +139,22 @@ export interface FrontendVisualEditorProps {
138
139
  * 保存。options.auto は自動保存(ユーザーが押したのではない)を示す。
139
140
  * 保存結果のトーストは手動保存のときだけ出したいので、呼び出し側で見分けられるようにする
140
141
  */
141
- onSave: (html: string, options?: { auto?: boolean }) => Promise<void>;
142
+ onSave: (html: string, options?: { auto?: boolean; contentId?: string }) => Promise<void>;
142
143
  onClose: () => void;
143
- /** マルチページ無限キャンバスモードを有効にする (default: false) */
144
+ /**
145
+ * Figma 風のマルチフレームキャンバス(全ページを 1 枚のキャンバスに並べ、クリックしたページを編集する)。
146
+ * ページの本文は contentList[].thumbnailHtml か io.loadContent(id) から読む。
147
+ * 保存は onSave(html, { contentId }) で「どのページか」が付いて呼ばれる (default: false)
148
+ */
144
149
  enableMultiPageCanvas?: boolean;
150
+ /**
151
+ * 編集するページが変わったときの通知(キャンバスのクリック・左パネルのページ一覧)。
152
+ * 利用側が URL や「いま編集中のページ」を追うために使う。逆に contentId プロップを
153
+ * 変えると、エディタ側がそのページへ移る
154
+ */
155
+ onContentChange?: (contentId: string) => void;
156
+ /** キャンバスの表示位置(倍率・スクロール)を記憶するキー。省略時は parentId */
157
+ canvasStorageKey?: string;
145
158
  /** 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ) */
146
159
  contentList?: ContentListItem[];
147
160
  /**
@@ -186,6 +199,7 @@ function FrontendVisualEditorInner({
186
199
  activeTool,
187
200
  setActiveTool,
188
201
  originalHtml,
202
+ sourceHtml,
189
203
  // [自動保存] 変更検知と、保存できた分の基準の付け替えに使う
190
204
  html,
191
205
  hasChanges,
@@ -716,6 +730,9 @@ function FrontendVisualEditorInner({
716
730
  * 特に onSave はページ番号を閉じ込めているため、保存の開始時点の値で
717
731
  * 内容とページ番号を揃える必要がある(古い内容を新しいページへ書かないため)
718
732
  */
733
+ const multiPageCanvas = useMultiPageCanvasOptional();
734
+ const multiPageCanvasRef = useRef(multiPageCanvas);
735
+ multiPageCanvasRef.current = multiPageCanvas;
719
736
  const saveStateRef = useRef({ html, hasChanges, canUndo, contentId });
720
737
  useEffect(() => {
721
738
  saveStateRef.current = { html, hasChanges, canUndo, contentId };
@@ -731,17 +748,10 @@ function FrontendVisualEditorInner({
731
748
  const baseline = saveStateRef.current.html;
732
749
  const savingContentId = saveStateRef.current.contentId;
733
750
 
751
+ // 保存先は常に利用側の onSave。マルチフレームのキャンバスでは
752
+ // 「どのページの本文か」を contentId で付けて渡す(利用側が保存先を選べる)
734
753
  const task = (async () => {
735
- if (isMultiPageCanvas && parentId && contentId) {
736
- const api = await createAuthApi(getIdToken);
737
- await api.patch(`/api/websites/${parentId}/pages/${contentId}`, {
738
- html: htmlToSave,
739
- layoutMode,
740
- });
741
- toast.success('ページを保存しました');
742
- return;
743
- }
744
- await onSave(htmlToSave, options);
754
+ await onSave(htmlToSave, { ...options, contentId: savingContentId ?? undefined });
745
755
  })();
746
756
  saveInFlightRef.current = task.then(
747
757
  () => undefined,
@@ -755,6 +765,8 @@ function FrontendVisualEditorInner({
755
765
  savedPayloadRef.current = htmlToSave;
756
766
  setOriginalHtml(baseline);
757
767
  }
768
+ // キャンバスの「見るだけの紙面」も保存後の姿にする(離れたあとも最新が見える)
769
+ if (savingContentId) multiPageCanvasRef.current?.setPageHtml(savingContentId, htmlToSave);
758
770
  setAutoSaveFailed(false);
759
771
  } catch (e) {
760
772
  setAutoSaveFailed(true);
@@ -763,7 +775,7 @@ function FrontendVisualEditorInner({
763
775
  saveInFlightRef.current = null;
764
776
  setSaving(false);
765
777
  }
766
- }, [isMultiPageCanvas, parentId, contentId, getIdToken, layoutMode, onSave, setOriginalHtml, setSaving]);
778
+ }, [onSave, setOriginalHtml, setSaving]);
767
779
 
768
780
  /**
769
781
  * 未保存の変更があれば保存する。戻り値は「保存できたか」。
@@ -775,6 +787,9 @@ function FrontendVisualEditorInner({
775
787
  */
776
788
  const saveIfDirty = useCallback(async (): Promise<boolean> => {
777
789
  const state = saveStateRef.current;
790
+ // ページ切替の途中(履歴の本文がまだ前のページ)は保存しない。
791
+ // ここで送ると前のページの本文を次のページへ書いてしまう
792
+ if (isContentSwitching()) return true;
778
793
  if (!state.hasChanges || !state.canUndo) return true;
779
794
  const iframeDoc = getIframeDoc();
780
795
  const payload = iframeDoc ? getCleanHtml(iframeDoc) : state.html;
@@ -841,6 +856,18 @@ function FrontendVisualEditorInner({
841
856
  savedPayloadRef.current = null;
842
857
  }, [contentId]);
843
858
 
859
+ // マルチフレームのキャンバス: フレーム名の「未保存」印と、見るだけの紙面の本文を同期する
860
+ const isDirtyForCanvas = hasChanges && canUndo;
861
+ useEffect(() => {
862
+ if (!multiPageCanvas || !contentId) return;
863
+ multiPageCanvas.updatePageFrame(contentId, { isDirty: isDirtyForCanvas });
864
+ }, [multiPageCanvas, contentId, isDirtyForCanvas]);
865
+ useEffect(() => {
866
+ if (!multiPageCanvas || !contentId) return;
867
+ multiPageCanvas.setPageHtml(contentId, sourceHtml);
868
+ // eslint-disable-next-line react-hooks/exhaustive-deps
869
+ }, [sourceHtml]);
870
+
844
871
  /** 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す */
845
872
  useEffect(() => {
846
873
  if (!hasChanges) return;
@@ -915,11 +942,13 @@ function FrontendVisualEditorInner({
915
942
  if (element) {
916
943
  const rect = element.getBoundingClientRect();
917
944
  const iframeRect = iframe.getBoundingClientRect();
918
-
945
+ // iframe が外側の transform で縮んでいる(マルチフレーム)ときは実測の倍率で掛ける
946
+ const outerScale = iframeRect.width / (iframe.clientWidth || iframeRect.width) || 1;
947
+
919
948
  // ポップオーバーを要素の右側に表示
920
949
  setAiPromptPosition({
921
- x: iframeRect.left + rect.right + 10,
922
- y: iframeRect.top + rect.top,
950
+ x: iframeRect.left + rect.right * outerScale + 10,
951
+ y: iframeRect.top + rect.top * outerScale,
923
952
  });
924
953
  return;
925
954
  }
@@ -1243,18 +1272,23 @@ function FrontendVisualEditorInner({
1243
1272
  // 縮小表示されている。画面座標をそのまま使うとスライド左上を原点にできないので、
1244
1273
  // スライドのルート要素の実測矩形を基準に、スライド内座標へ換算する。
1245
1274
  const iframeRect = iframeRef.current.getBoundingClientRect();
1275
+ // 親の座標 → iframe の座標。iframe が外側の transform で縮んでいる
1276
+ // (マルチフレーム)ときは実測の倍率で割る。単独表示では 1
1277
+ const outerScale = iframeRect.width / (iframeRef.current.clientWidth || iframeRect.width) || 1;
1278
+ const frameX = (e.clientX - iframeRect.left) / outerScale;
1279
+ const frameY = (e.clientY - iframeRect.top) / outerScale;
1246
1280
  const slideRoot = findSlideRoot(iframeDoc);
1247
1281
  let x: number;
1248
1282
  let y: number;
1249
1283
  if (slideRoot) {
1250
1284
  const rootRect = slideRoot.getBoundingClientRect();
1251
1285
  const rootScale = rootRect.width / (slideRoot.offsetWidth || rootRect.width) || 1;
1252
- x = (e.clientX - iframeRect.left - rootRect.left) / rootScale;
1253
- y = (e.clientY - iframeRect.top - rootRect.top) / rootScale;
1286
+ x = (frameX - rootRect.left) / rootScale;
1287
+ y = (frameY - rootRect.top) / rootScale;
1254
1288
  } else {
1255
- const dropScale = zoom || 1;
1256
- x = (e.clientX - iframeRect.left) / dropScale;
1257
- y = (e.clientY - iframeRect.top) / dropScale;
1289
+ const dropScale = (zoom || 100) / 100;
1290
+ x = frameX / dropScale;
1291
+ y = frameY / dropScale;
1258
1292
  }
1259
1293
 
1260
1294
  // 部品モード: 定義を実体化してそのまま置く(JSON のインスタンスは作らない)。
@@ -1266,7 +1300,7 @@ function FrontendVisualEditorInner({
1266
1300
  return;
1267
1301
  }
1268
1302
  if (editorMode === 'webpage') {
1269
- insertIntoFlow(iframeDoc, element, e.clientX - iframeRect.left, e.clientY - iframeRect.top);
1303
+ insertIntoFlow(iframeDoc, element, frameX, frameY);
1270
1304
  } else {
1271
1305
  element.style.position = 'absolute';
1272
1306
  element.style.left = `${x}px`;
@@ -2000,7 +2034,8 @@ function FrontendVisualEditorInner({
2000
2034
  }),
2001
2035
  page: Number(currentContentId ?? contentId) || 1,
2002
2036
  }}
2003
- onSwitchUi={() => switchUi('ppt')}
2037
+ /* マルチフレームのキャンバスは Figma 風の殻だけ(PowerPoint 風には切り替えない) */
2038
+ onSwitchUi={isMultiPageCanvas ? undefined : () => switchUi('ppt')}
2004
2039
  onSave={effectiveSave}
2005
2040
  onSaveSettings={handleSaveCurrentSettings}
2006
2041
  onClose={handleClose}
@@ -2026,8 +2061,6 @@ function FrontendVisualEditorInner({
2026
2061
  上下2段=ページ切替+レイヤー(Figma風。キャンバス編集中はページの並びがキャンバス側にあるので出さない) */}
2027
2062
  {isPpt ? (
2028
2063
  <PptThumbnails page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} search={pptSearch} />
2029
- ) : isMultiPageCanvas ? (
2030
- <EditorLayerPanel />
2031
2064
  ) : (
2032
2065
  <div className={layersOpen && !isComponentPanelOpen ? "flex min-h-0" : "hidden"}>
2033
2066
  <LeftPanel page={Number(currentContentId ?? contentId) || 1} pagesSlot={pagesPanel} />
@@ -2170,7 +2203,7 @@ function FrontendVisualEditorInner({
2170
2203
  コメントを開いている間はプロパティ(詳細編集)を引っ込め、閉じると戻る。
2171
2204
  2つ並ぶと「いまどちらを操作しているのか」が分からなくなるため */}
2172
2205
  {!isPpt && !pptCommentsOpen && (editorMode === 'slide' || selectedElement || selectedElementIds.length > 0 || activeTool === 'scale') && <EditorPropertyPanel />}
2173
- {!isMultiPageCanvas && can('commentAction') && (
2206
+ {can('commentAction') && (
2174
2207
  <PptCommentMarkers
2175
2208
  page={Number(currentContentId ?? contentId) || 1}
2176
2209
  onOpenThread={(id) => {
@@ -2182,7 +2215,7 @@ function FrontendVisualEditorInner({
2182
2215
  {isPpt && pptFormatPaneOpen && (
2183
2216
  <PptFormatPane theme={pptTheme} onClose={() => setPptFormatPaneOpen(false)} />
2184
2217
  )}
2185
- {!isMultiPageCanvas && can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2218
+ {can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2186
2219
  <div className={pptCommentsOpen ? 'flex min-h-0' : 'hidden'}>
2187
2220
  <PptCommentsPanel
2188
2221
  page={Number(currentContentId ?? contentId) || 1}
@@ -2199,7 +2232,7 @@ function FrontendVisualEditorInner({
2199
2232
  {/* ノート欄(トークスクリプト)。PowerPoint風・Figma風の両方に出す。
2200
2233
  Figma風はダーク配色で固定(スキンと馴染む) */}
2201
2234
  {/* ノート欄は発表原稿。Webページには無い概念なので出さない */}
2202
- {!isMultiPageCanvas && editorMode !== 'webpage' && can('apiFetch') && (
2235
+ {editorMode !== 'webpage' && can('apiFetch') && (
2203
2236
  <PptNotes page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} />
2204
2237
  )}
2205
2238
 
@@ -2542,6 +2575,8 @@ export function FrontendVisualEditor({
2542
2575
  contentList: externalContentList,
2543
2576
  pagesPanel,
2544
2577
  headerExtra,
2578
+ onContentChange: onContentChangeProp,
2579
+ canvasStorageKey,
2545
2580
  }: FrontendVisualEditorProps) {
2546
2581
  const { getIdToken } = useAuth();
2547
2582
  const [contentList, setContentList] = useState<ContentListItem[]>(externalContentList || []);
@@ -2680,45 +2715,109 @@ export function FrontendVisualEditor({
2680
2715
  fetchContentList();
2681
2716
  }, [effectiveParentId, effectiveContentId, getIdToken, editorMode]);
2682
2717
 
2683
- // コンテンツ変更ハンドラ(editorModeに応じてAPIを切り替え)
2684
- const handleContentChange = useCallback(async (newContentId: string) => {
2685
- if (!effectiveParentId || newContentId === currentContentId) return;
2718
+ // 利用側への通知は最新の閉包で(切替の非同期処理の途中で props が変わっても取り違えない)
2719
+ const onContentChangeRef = useRef(onContentChangeProp);
2720
+ onContentChangeRef.current = onContentChangeProp;
2721
+ const currentContentIdRef = useRef(currentContentId);
2722
+ currentContentIdRef.current = currentContentId;
2723
+
2724
+ /**
2725
+ * 編集するページを移す。本文の取り方は順に:
2726
+ * 1. io.loadContent(id) … 利用側が持つ読み込み(最新が取れる)
2727
+ * 2. contentList[].thumbnailHtml … 一覧に本文を同梱している場合
2728
+ * 3. parentId の API(gg-manager) … 従来の経路
2729
+ * どれも無ければ移れない(黙って何もしない)。
2730
+ *
2731
+ * 入口はフレームのクリック(activatePage)と、利用側の contentId プロップの変更の 2 つ。
2732
+ * どちらから来ても **ここで** 未保存の変更を保存してから移る(入口ごとに保存を
2733
+ * 書くと、片方に漏れて 2 秒以内の編集が消える)。
2734
+ * マルチフレームのキャンバスでは isLoading にしない(EditorProvider を外すと
2735
+ * 生きているエディタが消えて、キャンバスごと作り直しになる)。
2736
+ * 戻り値は移れたか(キャンバスの activatePage がリングの戻しに使う)
2737
+ */
2738
+ const switchSeqRef = useRef(0);
2739
+ const handleContentChange = useCallback(async (newContentId: string): Promise<boolean> => {
2740
+ if (newContentId === currentContentIdRef.current) return true;
2741
+ const listItem = contentList.find((c) => c.id === newContentId);
2742
+ const loader = io().loadContent;
2743
+ const canUseApi = !!effectiveParentId && !loader && listItem?.thumbnailHtml == null;
2744
+ if (!loader && listItem?.thumbnailHtml == null && !effectiveParentId) {
2745
+ console.warn('[FrontendVisualEditor] ページを移れません: io.loadContent か contentList[].thumbnailHtml か parentId が必要です');
2746
+ return false;
2747
+ }
2748
+ // 連続して呼ばれたら最後の 1 つだけを通す(前の読み込み結果で上書きしない)
2749
+ const seq = ++switchSeqRef.current;
2750
+ const superseded = () => seq !== switchSeqRef.current;
2751
+
2752
+ // 未保存の変更は黙って保存してから移る(失敗したときだけ確認する)。
2753
+ // 保存の可否を見るのは切替を印す前(印した後は保存が見送られる)
2754
+ if (!(await flushAutoSave())) {
2755
+ if (superseded()) return false;
2756
+ if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) {
2757
+ // 利用側の「編集中のページ」を今のページへ戻す(プロップ経由で来たとき用)
2758
+ const stay = currentContentIdRef.current;
2759
+ if (stay) onContentChangeRef.current?.(stay);
2760
+ return false;
2761
+ }
2762
+ }
2763
+ if (superseded()) return false;
2686
2764
 
2687
- // キャンバスモード時はisLoadingをスキップ(EditorProviderのアンマウントを防止)
2688
2765
  if (!enableMultiPageCanvas) {
2689
2766
  setIsLoading(true);
2690
2767
  }
2768
+ // ここから iframe の初期化が終わるまで自動保存を止める(前ページの本文を次ページへ書かない)
2769
+ beginContentSwitch();
2770
+ let handedOver = false;
2691
2771
  try {
2692
- // 新しいコンテンツのHTMLを取得
2693
- const api = await createAuthApi(getIdToken);
2694
-
2695
- if (editorMode === 'webpage') {
2696
- // Webページモード
2697
- const data = await api.get(`/api/websites/${effectiveParentId}/pages/${newContentId}`);
2698
- const newHtml = data.page?.content?.html || '';
2699
- const newLayoutMode = data.page?.layoutMode || 'auto';
2700
-
2701
- setCurrentHtml(newHtml);
2702
- setCurrentLayoutMode(newLayoutMode);
2703
- setCurrentContentId(newContentId);
2772
+ let newHtml = '';
2773
+ let newLayoutMode: 'absolute' | 'auto' = 'auto';
2774
+ if (loader) {
2775
+ newHtml = (await loader(newContentId)) ?? '';
2776
+ } else if (!canUseApi) {
2777
+ newHtml = listItem?.thumbnailHtml ?? '';
2704
2778
  } else {
2705
- // スライドモード(デフォルト)
2706
- const data = await api.get(`/api/presentations/${effectiveParentId}/slides/${newContentId}`);
2707
- const newHtml = data.slide?.generatedHtml || data.slide?.content?.html || '';
2708
- const newLayoutMode = data.slide?.layoutMode || 'auto';
2709
-
2710
- setCurrentHtml(newHtml);
2711
- setCurrentLayoutMode(newLayoutMode);
2712
- setCurrentContentId(newContentId);
2779
+ const api = await createAuthApi(getIdToken);
2780
+ if (editorMode === 'webpage') {
2781
+ const data = await api.get(`/api/websites/${effectiveParentId}/pages/${newContentId}`);
2782
+ newHtml = data.page?.content?.html || '';
2783
+ newLayoutMode = data.page?.layoutMode || 'auto';
2784
+ } else {
2785
+ const data = await api.get(`/api/presentations/${effectiveParentId}/slides/${newContentId}`);
2786
+ newHtml = data.slide?.generatedHtml || data.slide?.content?.html || '';
2787
+ newLayoutMode = data.slide?.layoutMode || 'auto';
2788
+ }
2713
2789
  }
2790
+ if (superseded()) return false;
2791
+
2792
+ setCurrentHtml(newHtml);
2793
+ setCurrentLayoutMode(newLayoutMode);
2794
+ setCurrentContentId(newContentId);
2795
+ currentContentIdRef.current = newContentId;
2796
+ // 以後の endContentSwitch は EditorCanvas(iframe の初期化後)が呼ぶ
2797
+ handedOver = true;
2798
+ onContentChangeRef.current?.(newContentId);
2799
+ return true;
2714
2800
  } catch (error) {
2715
2801
  console.error('Failed to fetch content:', error);
2802
+ toast.error('ページを読み込めませんでした');
2803
+ return false;
2716
2804
  } finally {
2805
+ // 本文を差し替えなかった(失敗・追い越された)なら、自分で印を外す
2806
+ if (!handedOver) endContentSwitch();
2717
2807
  if (!enableMultiPageCanvas) {
2718
2808
  setIsLoading(false);
2719
2809
  }
2720
2810
  }
2721
- }, [effectiveParentId, currentContentId, getIdToken, editorMode, enableMultiPageCanvas]);
2811
+ }, [effectiveParentId, getIdToken, editorMode, enableMultiPageCanvas, contentList]);
2812
+
2813
+ // 利用側が contentId プロップを変えたら、そのページへ移る(キャンバスのときだけ。
2814
+ // 単独表示は従来どおり利用側が key で作り直す)
2815
+ useEffect(() => {
2816
+ if (!enableMultiPageCanvas || !effectiveContentId) return;
2817
+ if (effectiveContentId === currentContentIdRef.current) return;
2818
+ void handleContentChange(effectiveContentId);
2819
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2820
+ }, [enableMultiPageCanvas, effectiveContentId]);
2722
2821
 
2723
2822
  useEffect(() => {
2724
2823
  if (html) {
@@ -2763,6 +2862,7 @@ export function FrontendVisualEditor({
2763
2862
  onLoadVariables={handleLoadVariables}
2764
2863
  websiteId={effectiveParentId}
2765
2864
  enableMultiPageCanvas={enableMultiPageCanvas}
2865
+ canvasStorageKey={canvasStorageKey ?? effectiveParentId ?? null}
2766
2866
  >
2767
2867
  <FrontendVisualEditorInner
2768
2868
  onSave={onSave}
@@ -32,3 +32,35 @@ export async function flushAutoSave(): Promise<boolean> {
32
32
  return false
33
33
  }
34
34
  }
35
+
36
+ /**
37
+ * ページ切替の途中(次の本文を読み込んで iframe を組み直している間)は、
38
+ * 履歴の本文がまだ前のページのものなので、自動保存が走ると
39
+ * **前のページの内容を次のページへ書いてしまう**。切替の始まりと終わりを
40
+ * ここで印し、その間の自動保存を見送る(手動保存も同じ窓口を通る)。
41
+ */
42
+ let switching = 0
43
+ let switchTimer: ReturnType<typeof setTimeout> | null = null
44
+
45
+ /** 切替の開始。読み込みが失敗しても永久に止まらないよう 5 秒で自動解除 */
46
+ export function beginContentSwitch(): void {
47
+ switching++
48
+ if (switchTimer) clearTimeout(switchTimer)
49
+ switchTimer = setTimeout(() => {
50
+ switching = 0
51
+ switchTimer = null
52
+ }, 5000)
53
+ }
54
+
55
+ /** 切替の終了(iframe の初期化が済んだ時点で EditorCanvas が呼ぶ) */
56
+ export function endContentSwitch(): void {
57
+ if (switching > 0) switching--
58
+ if (switching === 0 && switchTimer) {
59
+ clearTimeout(switchTimer)
60
+ switchTimer = null
61
+ }
62
+ }
63
+
64
+ export function isContentSwitching(): boolean {
65
+ return switching > 0
66
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useCallback, useMemo, useRef, useState } from "react";
3
+ import { useEffect, useCallback, useMemo, useRef, useState, useLayoutEffect } from "react";
4
4
  import { useEditorContext } from "../EditorContext";
5
5
  import { useCanvasControls } from "../hooks/useCanvasControls";
6
6
  import { useElementActions } from "../hooks/useElementActions";
@@ -29,6 +29,8 @@ import { generateEditableHtml } from "../utils/html-utils";
29
29
  import { setupInlineFormatToolbar } from "../utils/inline-format";
30
30
  import { recalculateViewportUnits } from "../utils/viewport-utils";
31
31
  import { useMultiPageCanvasOptional } from "../contexts/MultiPageCanvasContext";
32
+ import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
33
+ import { endContentSwitch } from "../autosave";
32
34
  import type {
33
35
  DOMTreeNode,
34
36
  DragState,
@@ -122,11 +124,21 @@ export function EditorCanvas() {
122
124
  setShowLayoutHint,
123
125
  viewportWidth,
124
126
  setIframeReady,
127
+ currentContentId,
125
128
  } = useEditorContext();
129
+ // 高さの報告先は「本文が載っているページ」(利用側の currentContentId)。
130
+ // クリックしただけでまだ本文が来ていないページへ、前のページの高さを書かない
131
+ const currentContentIdRef = useRef(currentContentId);
132
+ currentContentIdRef.current = currentContentId;
126
133
 
127
134
  // マルチページモード判定
128
135
  const multiPageCanvas = useMultiPageCanvasOptional();
129
136
  const isInMultiPageMode = !!multiPageCanvas?.isEnabled;
137
+ // ロードハンドラ(deps=[])から最新の値を読むための ref
138
+ const multiPageCanvasRef = useRef(multiPageCanvas);
139
+ multiPageCanvasRef.current = multiPageCanvas;
140
+ const isInMultiPageModeRef = useRef(isInMultiPageMode);
141
+ isInMultiPageModeRef.current = isInMultiPageMode;
130
142
 
131
143
  // Figmaライクなキャンバス操作
132
144
  useCanvasControls();
@@ -301,6 +313,17 @@ export function EditorCanvas() {
301
313
 
302
314
  const scrollHeight = targetElement.scrollHeight;
303
315
  const computedHeight = Math.max(scrollHeight, maxBottom);
316
+ // 埋め込み(マルチフレーム)ではフレーム = 紙面そのもの。下の余白は付けず、
317
+ // 実測をキャンバス側へ渡してフレームの高さにする
318
+ if (isInMultiPageModeRef.current) {
319
+ const frameHeight = Math.max(200, Math.ceil(computedHeight));
320
+ setContentHeight(frameHeight);
321
+ contentHeightRef.current = frameHeight;
322
+ const mpc = multiPageCanvasRef.current;
323
+ const activeId = currentContentIdRef.current;
324
+ if (mpc && activeId) mpc.setPageHeight(activeId, frameHeight, "editor");
325
+ return;
326
+ }
304
327
  const newHeight = Math.max(WEBPAGE_MIN_HEIGHT, computedHeight + 100);
305
328
 
306
329
  setContentHeight(newHeight);
@@ -409,11 +432,19 @@ export function EditorCanvas() {
409
432
  zoomRef.current = zoom;
410
433
  }, [zoom]);
411
434
 
435
+ // 埋め込みでは版面の幅を最初から入れて組む(後から書き換えると一瞬 1440px で描かれる)。
436
+ // 単独表示では幅は effect が書くので、ここでは依存に入れない(幅変更で iframe を作り直さない)
437
+ const embeddedWidth = isInMultiPageMode ? viewportWidth : 0;
412
438
  const iframeHtml = useMemo(
413
439
  // sourceHtml(ページ切替でのみ変わる)から組む。originalHtml は
414
440
  // initLayout が変更判定の基準として書き換えるため、ここに使うとループする
415
- () => generateEditableHtml(sourceHtml, editorMode),
416
- [sourceHtml, editorMode]
441
+ () =>
442
+ generateEditableHtml(
443
+ sourceHtml,
444
+ editorMode,
445
+ embeddedWidth ? { embedded: true, artboardWidth: embeddedWidth } : undefined,
446
+ ),
447
+ [sourceHtml, editorMode, embeddedWidth]
417
448
  );
418
449
 
419
450
  // ========== iframe Load Handler ==========
@@ -460,8 +491,13 @@ export function EditorCanvas() {
460
491
  // 3. イベントリスナーのセットアップ(ローカル配列に収集後、refに保存)
461
492
  const cleanupFunctions: (() => void)[] = [];
462
493
 
463
- // タッチジェスチャー
464
- cleanupFunctions.push(setupTouchGestureListeners(iframeDoc));
494
+ // タッチジェスチャー。埋め込み(マルチフレーム)では倍率も位置も外側が持つので、
495
+ // ホイール・ピンチ・Space・中ボタンをそのまま親へ転送する
496
+ if (isInMultiPageModeRef.current) {
497
+ cleanupFunctions.push(setupEmbeddedCanvasBridge(iframeDoc, iframe));
498
+ } else {
499
+ cleanupFunctions.push(setupTouchGestureListeners(iframeDoc));
500
+ }
465
501
 
466
502
  // キーボードショートカット
467
503
  cleanupFunctions.push(setupKeyboardShortcuts(iframeDoc));
@@ -521,13 +557,16 @@ export function EditorCanvas() {
521
557
  // 「iframe ビューポート基準」なので、iframe の矩形分だけ平行移動して渡す。
522
558
  // iframe 自体は等倍(scale は iframe 内の #artboard-wrapper に掛かる)なので
523
559
  // 平行移動だけで正しく一致する。
560
+ // 埋め込み(マルチフレーム)では iframe 要素自体が外側の transform で縮んでいるので、
561
+ // 実測の矩形と iframe の内寸の比(= 外側の倍率)で割ってから渡す。単独表示では比は 1
524
562
  const toIframeCoords = (e: MouseEvent): MouseEvent => {
525
563
  const frameEl = iframeRef.current;
526
564
  if (!frameEl) return e;
527
565
  const r = frameEl.getBoundingClientRect();
566
+ const outerScale = r.width / (frameEl.clientWidth || r.width) || 1;
528
567
  return new MouseEvent(e.type, {
529
- clientX: e.clientX - r.left,
530
- clientY: e.clientY - r.top,
568
+ clientX: (e.clientX - r.left) / outerScale,
569
+ clientY: (e.clientY - r.top) / outerScale,
531
570
  screenX: e.screenX,
532
571
  screenY: e.screenY,
533
572
  button: e.button,
@@ -805,6 +844,14 @@ export function EditorCanvas() {
805
844
  initLayout().then(() => {
806
845
  // ページ切替直後の文書へ現在の倍率を適用(上記 zoomRef のコメント参照)
807
846
  applyCanvasZoomDom(iframeDoc, zoomRef.current);
847
+ // 履歴の本文がこの文書の姿になった = ページ切替が終わった(自動保存を再開してよい)
848
+ endContentSwitch();
849
+ if (isInMultiPageModeRef.current) {
850
+ const mpc = multiPageCanvasRef.current;
851
+ if (mpc) {
852
+ iframeDoc.documentElement.dataset.outerZoom = String(mpc.viewStore.get().canvasZoom);
853
+ }
854
+ }
808
855
  const artboardEl = iframeDoc.getElementById("artboard");
809
856
  if (editorModeRef.current === "webpage" && artboardEl) {
810
857
  updateContentHeightRef.current();
@@ -840,11 +887,17 @@ export function EditorCanvas() {
840
887
  // 依存に iframeHtml(=originalHtml由来)を含めることで、ページ切替(サムネイル/URL)で
841
888
  // コンテンツが差し替わったときに**iframeだけ**を作り直す。殻(ヘッダー・パネル・
842
889
  // サムネイル)は残るので、切替のたびに画面全体がリロードされたようには見えない
843
- useEffect(() => {
890
+ // useLayoutEffect なのは、setIframeReady(false)(エディタを隠す)を、枠が新しいページへ移る
891
+ // 描画と同じフレームに載せるため。useEffect だと 1 フレームだけ前のページの姿が新しい枠に見える
892
+ useLayoutEffect(() => {
844
893
  const iframe = iframeRef.current;
845
894
  if (!iframe) return;
846
895
 
847
896
  iframe.addEventListener("load", handleIframeLoad);
897
+ // 新しい本文を読み込む間は「準備できていない」に戻す。
898
+ // マルチフレームのキャンバスはこれを見て、前のページの姿が新しい枠に
899
+ // 見えないようエディタを隠す(読み込みが済むまで見るだけの紙面が透ける)
900
+ setIframeReady(false);
848
901
  const blob = new Blob([iframeHtml], { type: "text/html" });
849
902
  iframe.src = URL.createObjectURL(blob);
850
903
 
@@ -939,6 +992,36 @@ export function EditorCanvas() {
939
992
  );
940
993
  }, [zoom, editorMode, contentHeight, viewportWidth, getIframeDoc]);
941
994
 
995
+ // 埋め込み(マルチフレーム): 外側の倍率を iframe に知らせ、選択枠・ハンドルの太さを
996
+ // 画面上で一定に保つ(applyOverlayScale が data-outer-zoom を読む)
997
+ // ズーム・パンは毎目盛り起きるので、ここでは描き直さず(再レンダーせず)ストアを購読して
998
+ // 直接 DOM に書く。選択枠の描き直しは 1 フレームに 1 回にまとめる
999
+ const viewStore = multiPageCanvas?.viewStore ?? null;
1000
+ useEffect(() => {
1001
+ if (!isInMultiPageMode || !viewStore) return;
1002
+ let raf: number | null = null;
1003
+ let lastZoom: number | null = null;
1004
+ const apply = () => {
1005
+ const zoom = viewStore.get().canvasZoom;
1006
+ if (zoom === lastZoom) return;
1007
+ lastZoom = zoom;
1008
+ const iframeDoc = getIframeDoc();
1009
+ if (!iframeDoc?.documentElement) return;
1010
+ iframeDoc.documentElement.dataset.outerZoom = String(zoom);
1011
+ if (raf != null) cancelAnimationFrame(raf);
1012
+ raf = requestAnimationFrame(() => {
1013
+ raf = null;
1014
+ refreshSelectionOverlay(iframeDoc);
1015
+ });
1016
+ };
1017
+ apply();
1018
+ const unsubscribe = viewStore.subscribe(apply);
1019
+ return () => {
1020
+ unsubscribe();
1021
+ if (raf != null) cancelAnimationFrame(raf);
1022
+ };
1023
+ }, [isInMultiPageMode, viewStore, getIframeDoc]);
1024
+
942
1025
  // ビューポート幅変更時にiframe内のartboard幅を更新
943
1026
  useEffect(() => {
944
1027
  if (editorMode !== "webpage") return;
@@ -977,6 +1060,7 @@ export function EditorCanvas() {
977
1060
  <div
978
1061
  ref={containerRef}
979
1062
  className="h-full w-full"
1063
+ data-editor-canvas={isInMultiPageMode ? "embedded" : "single"}
980
1064
  style={{
981
1065
  backgroundColor: isInMultiPageMode ? "transparent" : "var(--ed-bg)",
982
1066
  }}
@@ -986,6 +1070,7 @@ export function EditorCanvas() {
986
1070
  className="w-full h-full border-0"
987
1071
  title={editorMode === "webpage" ? "Webpage Editor" : "Slide Editor"}
988
1072
  sandbox="allow-same-origin allow-scripts"
1073
+ style={isInMultiPageMode ? { display: "block", background: "#fff" } : undefined}
989
1074
  />
990
1075
  </div>
991
1076
  );