@growgroup/visual-editor 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +41 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +15 -3
  5. package/src/editor/FrontendVisualEditor.tsx +168 -55
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +107 -8
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorHeader.tsx +47 -12
  10. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  11. package/src/editor/components/EditorPropertyPanel.tsx +56 -25
  12. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  13. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
  14. package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
  15. package/src/editor/components/multi-page/index.ts +2 -6
  16. package/src/editor/components/ppt/PptComments.tsx +8 -4
  17. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  19. package/src/editor/constants.ts +28 -0
  20. package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
  21. package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
  23. package/src/editor/hooks/useCanvasControls.ts +1 -1
  24. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  25. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  26. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  27. package/src/editor/hooks/useDrawingMode.ts +1 -0
  28. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  29. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  30. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  31. package/src/editor/utils/dom-utils.ts +23 -1
  32. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  33. package/src/editor/utils/html-utils.ts +102 -14
  34. package/src/index.ts +5 -0
  35. package/src/io.ts +20 -0
  36. package/src/styles/skin.css +122 -36
  37. package/src/version.ts +9 -0
  38. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  39. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  40. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  41. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  42. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  43. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -21,6 +21,7 @@ import {
21
21
  } from './hooks/useKeyboardShortcuts';
22
22
  import type { MoveElementResult } from './hooks/useElementActions';
23
23
  import { EditorProvider, useEditorContext, type ContentListItem, type EditorMode } from './EditorContext';
24
+ import type { DocumentAttributes } from './contexts/EditorArtboardContext';
24
25
  import { PptTitleBar, PptRibbon, PptThumbnails, PptStatusBar, initialPptTheme, PPT_PALETTES, type PptTheme } from './components/ppt/PptChrome';
25
26
  import { LeftPanel } from './components/shell/LeftPanel';
26
27
  import { useAltMeasure } from './hooks/useAltMeasure';
@@ -30,7 +31,7 @@ import { PptFormatPane } from './components/ppt/PptFormatPane';
30
31
  import { createAuthApi } from '../lib/api/auth-fetch';
31
32
  import { useAuth } from '../components/auth/AuthProvider';
32
33
  import { findSlideRoot, findInsertionParent } from './utils/slide-root';
33
- import { registerAutoSaveFlush } from './autosave';
34
+ import { registerAutoSaveFlush, flushAutoSave, beginContentSwitch, endContentSwitch, isContentSwitching } from './autosave';
34
35
  import { getCleanHtml } from './utils/html-utils';
35
36
  import { EditorAppearanceContext, useEditorTheme } from './contexts/EditorAppearanceContext';
36
37
  import { CanvasAppearance } from './components/shell/CanvasAppearance';
@@ -69,6 +70,7 @@ import { BreakpointGuides } from './components/BreakpointGuides';
69
70
  import type { ContextMenuPosition } from './components';
70
71
  import { EditorToolbar } from './EditorToolbar';
71
72
  import { MultiPageCanvasView } from './components/multi-page';
73
+ import { useMultiPageCanvasOptional } from './contexts/MultiPageCanvasContext';
72
74
  import { convertToAbsolutePositioning, buildDomTree, getArtboardContent, canUngroup, updateSelectionBox } from './utils/dom-utils';
73
75
  import { extractElementInfo } from './utils/style-utils';
74
76
  import { findTableCell, insertRow, insertColumn, deleteRow, deleteColumn } from './utils/table-edit';
@@ -138,12 +140,34 @@ export interface FrontendVisualEditorProps {
138
140
  * 保存。options.auto は自動保存(ユーザーが押したのではない)を示す。
139
141
  * 保存結果のトーストは手動保存のときだけ出したいので、呼び出し側で見分けられるようにする
140
142
  */
141
- onSave: (html: string, options?: { auto?: boolean }) => Promise<void>;
143
+ onSave: (html: string, options?: { auto?: boolean; contentId?: string }) => Promise<void>;
142
144
  onClose: () => void;
143
- /** マルチページ無限キャンバスモードを有効にする (default: false) */
145
+ /**
146
+ * Figma 風のマルチフレームキャンバス(全ページを 1 枚のキャンバスに並べ、クリックしたページを編集する)。
147
+ * ページの本文は contentList[].thumbnailHtml か io.loadContent(id) から読む。
148
+ * 保存は onSave(html, { contentId }) で「どのページか」が付いて呼ばれる (default: false)
149
+ */
144
150
  enableMultiPageCanvas?: boolean;
145
- /** 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ) */
151
+ /**
152
+ * 編集するページが変わったときの通知(キャンバスのクリック・左パネルのページ一覧)。
153
+ * 利用側が URL や「いま編集中のページ」を追うために使う。逆に contentId プロップを
154
+ * 変えると、エディタ側がそのページへ移る
155
+ */
156
+ onContentChange?: (contentId: string) => void;
157
+ /** キャンバスの表示位置(倍率・スクロール)を記憶するキー。省略時は parentId */
158
+ canvasStorageKey?: string;
159
+ /**
160
+ * 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ)。
161
+ * parentId を渡すとキャンバスが階層のツリーで並び、href を渡すと「別タブで開く」が付き、
162
+ * revision を進めるとそのページの紙面が読み直される(ContentListItem を参照)
163
+ */
146
164
  contentList?: ContentListItem[];
165
+ /**
166
+ * 文書(`<html>`)に付ける属性。生きているエディタと見るだけの紙面の両方に、読み直さずに反映する。
167
+ * 利用側の CSS が `html[data-…]` で切り替える表示(構成ラフの注釈カラムの表示・非表示など)に使う。
168
+ * 値が null / undefined の属性は外す
169
+ */
170
+ documentAttributes?: DocumentAttributes;
147
171
  /**
148
172
  * 左パネルの「ページ」タブに差し込む中身。
149
173
  *
@@ -186,6 +210,7 @@ function FrontendVisualEditorInner({
186
210
  activeTool,
187
211
  setActiveTool,
188
212
  originalHtml,
213
+ sourceHtml,
189
214
  // [自動保存] 変更検知と、保存できた分の基準の付け替えに使う
190
215
  html,
191
216
  hasChanges,
@@ -716,6 +741,9 @@ function FrontendVisualEditorInner({
716
741
  * 特に onSave はページ番号を閉じ込めているため、保存の開始時点の値で
717
742
  * 内容とページ番号を揃える必要がある(古い内容を新しいページへ書かないため)
718
743
  */
744
+ const multiPageCanvas = useMultiPageCanvasOptional();
745
+ const multiPageCanvasRef = useRef(multiPageCanvas);
746
+ multiPageCanvasRef.current = multiPageCanvas;
719
747
  const saveStateRef = useRef({ html, hasChanges, canUndo, contentId });
720
748
  useEffect(() => {
721
749
  saveStateRef.current = { html, hasChanges, canUndo, contentId };
@@ -731,17 +759,10 @@ function FrontendVisualEditorInner({
731
759
  const baseline = saveStateRef.current.html;
732
760
  const savingContentId = saveStateRef.current.contentId;
733
761
 
762
+ // 保存先は常に利用側の onSave。マルチフレームのキャンバスでは
763
+ // 「どのページの本文か」を contentId で付けて渡す(利用側が保存先を選べる)
734
764
  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);
765
+ await onSave(htmlToSave, { ...options, contentId: savingContentId ?? undefined });
745
766
  })();
746
767
  saveInFlightRef.current = task.then(
747
768
  () => undefined,
@@ -755,6 +776,8 @@ function FrontendVisualEditorInner({
755
776
  savedPayloadRef.current = htmlToSave;
756
777
  setOriginalHtml(baseline);
757
778
  }
779
+ // キャンバスの「見るだけの紙面」も保存後の姿にする(離れたあとも最新が見える)
780
+ if (savingContentId) multiPageCanvasRef.current?.setPageHtml(savingContentId, htmlToSave);
758
781
  setAutoSaveFailed(false);
759
782
  } catch (e) {
760
783
  setAutoSaveFailed(true);
@@ -763,7 +786,7 @@ function FrontendVisualEditorInner({
763
786
  saveInFlightRef.current = null;
764
787
  setSaving(false);
765
788
  }
766
- }, [isMultiPageCanvas, parentId, contentId, getIdToken, layoutMode, onSave, setOriginalHtml, setSaving]);
789
+ }, [onSave, setOriginalHtml, setSaving]);
767
790
 
768
791
  /**
769
792
  * 未保存の変更があれば保存する。戻り値は「保存できたか」。
@@ -775,6 +798,9 @@ function FrontendVisualEditorInner({
775
798
  */
776
799
  const saveIfDirty = useCallback(async (): Promise<boolean> => {
777
800
  const state = saveStateRef.current;
801
+ // ページ切替の途中(履歴の本文がまだ前のページ)は保存しない。
802
+ // ここで送ると前のページの本文を次のページへ書いてしまう
803
+ if (isContentSwitching()) return true;
778
804
  if (!state.hasChanges || !state.canUndo) return true;
779
805
  const iframeDoc = getIframeDoc();
780
806
  const payload = iframeDoc ? getCleanHtml(iframeDoc) : state.html;
@@ -841,6 +867,18 @@ function FrontendVisualEditorInner({
841
867
  savedPayloadRef.current = null;
842
868
  }, [contentId]);
843
869
 
870
+ // マルチフレームのキャンバス: フレーム名の「未保存」印と、見るだけの紙面の本文を同期する
871
+ const isDirtyForCanvas = hasChanges && canUndo;
872
+ useEffect(() => {
873
+ if (!multiPageCanvas || !contentId) return;
874
+ multiPageCanvas.updatePageFrame(contentId, { isDirty: isDirtyForCanvas });
875
+ }, [multiPageCanvas, contentId, isDirtyForCanvas]);
876
+ useEffect(() => {
877
+ if (!multiPageCanvas || !contentId) return;
878
+ multiPageCanvas.setPageHtml(contentId, sourceHtml);
879
+ // eslint-disable-next-line react-hooks/exhaustive-deps
880
+ }, [sourceHtml]);
881
+
844
882
  /** 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す */
845
883
  useEffect(() => {
846
884
  if (!hasChanges) return;
@@ -915,11 +953,13 @@ function FrontendVisualEditorInner({
915
953
  if (element) {
916
954
  const rect = element.getBoundingClientRect();
917
955
  const iframeRect = iframe.getBoundingClientRect();
918
-
956
+ // iframe が外側の transform で縮んでいる(マルチフレーム)ときは実測の倍率で掛ける
957
+ const outerScale = iframeRect.width / (iframe.clientWidth || iframeRect.width) || 1;
958
+
919
959
  // ポップオーバーを要素の右側に表示
920
960
  setAiPromptPosition({
921
- x: iframeRect.left + rect.right + 10,
922
- y: iframeRect.top + rect.top,
961
+ x: iframeRect.left + rect.right * outerScale + 10,
962
+ y: iframeRect.top + rect.top * outerScale,
923
963
  });
924
964
  return;
925
965
  }
@@ -1243,18 +1283,23 @@ function FrontendVisualEditorInner({
1243
1283
  // 縮小表示されている。画面座標をそのまま使うとスライド左上を原点にできないので、
1244
1284
  // スライドのルート要素の実測矩形を基準に、スライド内座標へ換算する。
1245
1285
  const iframeRect = iframeRef.current.getBoundingClientRect();
1286
+ // 親の座標 → iframe の座標。iframe が外側の transform で縮んでいる
1287
+ // (マルチフレーム)ときは実測の倍率で割る。単独表示では 1
1288
+ const outerScale = iframeRect.width / (iframeRef.current.clientWidth || iframeRect.width) || 1;
1289
+ const frameX = (e.clientX - iframeRect.left) / outerScale;
1290
+ const frameY = (e.clientY - iframeRect.top) / outerScale;
1246
1291
  const slideRoot = findSlideRoot(iframeDoc);
1247
1292
  let x: number;
1248
1293
  let y: number;
1249
1294
  if (slideRoot) {
1250
1295
  const rootRect = slideRoot.getBoundingClientRect();
1251
1296
  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;
1297
+ x = (frameX - rootRect.left) / rootScale;
1298
+ y = (frameY - rootRect.top) / rootScale;
1254
1299
  } else {
1255
- const dropScale = zoom || 1;
1256
- x = (e.clientX - iframeRect.left) / dropScale;
1257
- y = (e.clientY - iframeRect.top) / dropScale;
1300
+ const dropScale = (zoom || 100) / 100;
1301
+ x = frameX / dropScale;
1302
+ y = frameY / dropScale;
1258
1303
  }
1259
1304
 
1260
1305
  // 部品モード: 定義を実体化してそのまま置く(JSON のインスタンスは作らない)。
@@ -1266,7 +1311,7 @@ function FrontendVisualEditorInner({
1266
1311
  return;
1267
1312
  }
1268
1313
  if (editorMode === 'webpage') {
1269
- insertIntoFlow(iframeDoc, element, e.clientX - iframeRect.left, e.clientY - iframeRect.top);
1314
+ insertIntoFlow(iframeDoc, element, frameX, frameY);
1270
1315
  } else {
1271
1316
  element.style.position = 'absolute';
1272
1317
  element.style.left = `${x}px`;
@@ -2000,7 +2045,8 @@ function FrontendVisualEditorInner({
2000
2045
  }),
2001
2046
  page: Number(currentContentId ?? contentId) || 1,
2002
2047
  }}
2003
- onSwitchUi={() => switchUi('ppt')}
2048
+ /* マルチフレームのキャンバスは Figma 風の殻だけ(PowerPoint 風には切り替えない) */
2049
+ onSwitchUi={isMultiPageCanvas ? undefined : () => switchUi('ppt')}
2004
2050
  onSave={effectiveSave}
2005
2051
  onSaveSettings={handleSaveCurrentSettings}
2006
2052
  onClose={handleClose}
@@ -2026,8 +2072,6 @@ function FrontendVisualEditorInner({
2026
2072
  上下2段=ページ切替+レイヤー(Figma風。キャンバス編集中はページの並びがキャンバス側にあるので出さない) */}
2027
2073
  {isPpt ? (
2028
2074
  <PptThumbnails page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} search={pptSearch} />
2029
- ) : isMultiPageCanvas ? (
2030
- <EditorLayerPanel />
2031
2075
  ) : (
2032
2076
  <div className={layersOpen && !isComponentPanelOpen ? "flex min-h-0" : "hidden"}>
2033
2077
  <LeftPanel page={Number(currentContentId ?? contentId) || 1} pagesSlot={pagesPanel} />
@@ -2170,7 +2214,7 @@ function FrontendVisualEditorInner({
2170
2214
  コメントを開いている間はプロパティ(詳細編集)を引っ込め、閉じると戻る。
2171
2215
  2つ並ぶと「いまどちらを操作しているのか」が分からなくなるため */}
2172
2216
  {!isPpt && !pptCommentsOpen && (editorMode === 'slide' || selectedElement || selectedElementIds.length > 0 || activeTool === 'scale') && <EditorPropertyPanel />}
2173
- {!isMultiPageCanvas && can('commentAction') && (
2217
+ {can('commentAction') && (
2174
2218
  <PptCommentMarkers
2175
2219
  page={Number(currentContentId ?? contentId) || 1}
2176
2220
  onOpenThread={(id) => {
@@ -2182,7 +2226,7 @@ function FrontendVisualEditorInner({
2182
2226
  {isPpt && pptFormatPaneOpen && (
2183
2227
  <PptFormatPane theme={pptTheme} onClose={() => setPptFormatPaneOpen(false)} />
2184
2228
  )}
2185
- {!isMultiPageCanvas && can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2229
+ {can('commentAction') && (pptCommentsOpen || commentsMounted) && (
2186
2230
  <div className={pptCommentsOpen ? 'flex min-h-0' : 'hidden'}>
2187
2231
  <PptCommentsPanel
2188
2232
  page={Number(currentContentId ?? contentId) || 1}
@@ -2199,7 +2243,7 @@ function FrontendVisualEditorInner({
2199
2243
  {/* ノート欄(トークスクリプト)。PowerPoint風・Figma風の両方に出す。
2200
2244
  Figma風はダーク配色で固定(スキンと馴染む) */}
2201
2245
  {/* ノート欄は発表原稿。Webページには無い概念なので出さない */}
2202
- {!isMultiPageCanvas && editorMode !== 'webpage' && can('apiFetch') && (
2246
+ {editorMode !== 'webpage' && can('apiFetch') && (
2203
2247
  <PptNotes page={Number(currentContentId ?? contentId) || 1} theme={pptTheme} />
2204
2248
  )}
2205
2249
 
@@ -2542,6 +2586,9 @@ export function FrontendVisualEditor({
2542
2586
  contentList: externalContentList,
2543
2587
  pagesPanel,
2544
2588
  headerExtra,
2589
+ onContentChange: onContentChangeProp,
2590
+ canvasStorageKey,
2591
+ documentAttributes,
2545
2592
  }: FrontendVisualEditorProps) {
2546
2593
  const { getIdToken } = useAuth();
2547
2594
  const [contentList, setContentList] = useState<ContentListItem[]>(externalContentList || []);
@@ -2680,45 +2727,109 @@ export function FrontendVisualEditor({
2680
2727
  fetchContentList();
2681
2728
  }, [effectiveParentId, effectiveContentId, getIdToken, editorMode]);
2682
2729
 
2683
- // コンテンツ変更ハンドラ(editorModeに応じてAPIを切り替え)
2684
- const handleContentChange = useCallback(async (newContentId: string) => {
2685
- if (!effectiveParentId || newContentId === currentContentId) return;
2730
+ // 利用側への通知は最新の閉包で(切替の非同期処理の途中で props が変わっても取り違えない)
2731
+ const onContentChangeRef = useRef(onContentChangeProp);
2732
+ onContentChangeRef.current = onContentChangeProp;
2733
+ const currentContentIdRef = useRef(currentContentId);
2734
+ currentContentIdRef.current = currentContentId;
2735
+
2736
+ /**
2737
+ * 編集するページを移す。本文の取り方は順に:
2738
+ * 1. io.loadContent(id) … 利用側が持つ読み込み(最新が取れる)
2739
+ * 2. contentList[].thumbnailHtml … 一覧に本文を同梱している場合
2740
+ * 3. parentId の API(gg-manager) … 従来の経路
2741
+ * どれも無ければ移れない(黙って何もしない)。
2742
+ *
2743
+ * 入口はフレームのクリック(activatePage)と、利用側の contentId プロップの変更の 2 つ。
2744
+ * どちらから来ても **ここで** 未保存の変更を保存してから移る(入口ごとに保存を
2745
+ * 書くと、片方に漏れて 2 秒以内の編集が消える)。
2746
+ * マルチフレームのキャンバスでは isLoading にしない(EditorProvider を外すと
2747
+ * 生きているエディタが消えて、キャンバスごと作り直しになる)。
2748
+ * 戻り値は移れたか(キャンバスの activatePage がリングの戻しに使う)
2749
+ */
2750
+ const switchSeqRef = useRef(0);
2751
+ const handleContentChange = useCallback(async (newContentId: string): Promise<boolean> => {
2752
+ if (newContentId === currentContentIdRef.current) return true;
2753
+ const listItem = contentList.find((c) => c.id === newContentId);
2754
+ const loader = io().loadContent;
2755
+ const canUseApi = !!effectiveParentId && !loader && listItem?.thumbnailHtml == null;
2756
+ if (!loader && listItem?.thumbnailHtml == null && !effectiveParentId) {
2757
+ console.warn('[FrontendVisualEditor] ページを移れません: io.loadContent か contentList[].thumbnailHtml か parentId が必要です');
2758
+ return false;
2759
+ }
2760
+ // 連続して呼ばれたら最後の 1 つだけを通す(前の読み込み結果で上書きしない)
2761
+ const seq = ++switchSeqRef.current;
2762
+ const superseded = () => seq !== switchSeqRef.current;
2763
+
2764
+ // 未保存の変更は黙って保存してから移る(失敗したときだけ確認する)。
2765
+ // 保存の可否を見るのは切替を印す前(印した後は保存が見送られる)
2766
+ if (!(await flushAutoSave())) {
2767
+ if (superseded()) return false;
2768
+ if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) {
2769
+ // 利用側の「編集中のページ」を今のページへ戻す(プロップ経由で来たとき用)
2770
+ const stay = currentContentIdRef.current;
2771
+ if (stay) onContentChangeRef.current?.(stay);
2772
+ return false;
2773
+ }
2774
+ }
2775
+ if (superseded()) return false;
2686
2776
 
2687
- // キャンバスモード時はisLoadingをスキップ(EditorProviderのアンマウントを防止)
2688
2777
  if (!enableMultiPageCanvas) {
2689
2778
  setIsLoading(true);
2690
2779
  }
2780
+ // ここから iframe の初期化が終わるまで自動保存を止める(前ページの本文を次ページへ書かない)
2781
+ beginContentSwitch();
2782
+ let handedOver = false;
2691
2783
  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);
2784
+ let newHtml = '';
2785
+ let newLayoutMode: 'absolute' | 'auto' = 'auto';
2786
+ if (loader) {
2787
+ newHtml = (await loader(newContentId)) ?? '';
2788
+ } else if (!canUseApi) {
2789
+ newHtml = listItem?.thumbnailHtml ?? '';
2704
2790
  } 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);
2791
+ const api = await createAuthApi(getIdToken);
2792
+ if (editorMode === 'webpage') {
2793
+ const data = await api.get(`/api/websites/${effectiveParentId}/pages/${newContentId}`);
2794
+ newHtml = data.page?.content?.html || '';
2795
+ newLayoutMode = data.page?.layoutMode || 'auto';
2796
+ } else {
2797
+ const data = await api.get(`/api/presentations/${effectiveParentId}/slides/${newContentId}`);
2798
+ newHtml = data.slide?.generatedHtml || data.slide?.content?.html || '';
2799
+ newLayoutMode = data.slide?.layoutMode || 'auto';
2800
+ }
2713
2801
  }
2802
+ if (superseded()) return false;
2803
+
2804
+ setCurrentHtml(newHtml);
2805
+ setCurrentLayoutMode(newLayoutMode);
2806
+ setCurrentContentId(newContentId);
2807
+ currentContentIdRef.current = newContentId;
2808
+ // 以後の endContentSwitch は EditorCanvas(iframe の初期化後)が呼ぶ
2809
+ handedOver = true;
2810
+ onContentChangeRef.current?.(newContentId);
2811
+ return true;
2714
2812
  } catch (error) {
2715
2813
  console.error('Failed to fetch content:', error);
2814
+ toast.error('ページを読み込めませんでした');
2815
+ return false;
2716
2816
  } finally {
2817
+ // 本文を差し替えなかった(失敗・追い越された)なら、自分で印を外す
2818
+ if (!handedOver) endContentSwitch();
2717
2819
  if (!enableMultiPageCanvas) {
2718
2820
  setIsLoading(false);
2719
2821
  }
2720
2822
  }
2721
- }, [effectiveParentId, currentContentId, getIdToken, editorMode, enableMultiPageCanvas]);
2823
+ }, [effectiveParentId, getIdToken, editorMode, enableMultiPageCanvas, contentList]);
2824
+
2825
+ // 利用側が contentId プロップを変えたら、そのページへ移る(キャンバスのときだけ。
2826
+ // 単独表示は従来どおり利用側が key で作り直す)
2827
+ useEffect(() => {
2828
+ if (!enableMultiPageCanvas || !effectiveContentId) return;
2829
+ if (effectiveContentId === currentContentIdRef.current) return;
2830
+ void handleContentChange(effectiveContentId);
2831
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2832
+ }, [enableMultiPageCanvas, effectiveContentId]);
2722
2833
 
2723
2834
  useEffect(() => {
2724
2835
  if (html) {
@@ -2757,12 +2868,14 @@ export function FrontendVisualEditor({
2757
2868
  contentList={contentList}
2758
2869
  currentContentId={currentContentId}
2759
2870
  onContentChange={handleContentChange}
2871
+ documentAttributes={documentAttributes}
2760
2872
  initialLayoutMode={currentLayoutMode}
2761
2873
  initialVariables={initialVariables}
2762
2874
  onSaveVariables={handleSaveVariables}
2763
2875
  onLoadVariables={handleLoadVariables}
2764
2876
  websiteId={effectiveParentId}
2765
2877
  enableMultiPageCanvas={enableMultiPageCanvas}
2878
+ canvasStorageKey={canvasStorageKey ?? effectiveParentId ?? null}
2766
2879
  >
2767
2880
  <FrontendVisualEditorInner
2768
2881
  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,9 @@ 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 { applyDocumentAttributes } from "./multi-page/PageFramePreview";
33
+ import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
34
+ import { endContentSwitch } from "../autosave";
32
35
  import type {
33
36
  DOMTreeNode,
34
37
  DragState,
@@ -122,11 +125,33 @@ export function EditorCanvas() {
122
125
  setShowLayoutHint,
123
126
  viewportWidth,
124
127
  setIframeReady,
128
+ documentAttributes,
129
+ currentContentId,
125
130
  } = useEditorContext();
131
+ // 高さの報告先は「本文が載っているページ」(利用側の currentContentId)。
132
+ // クリックしただけでまだ本文が来ていないページへ、前のページの高さを書かない
133
+ const currentContentIdRef = useRef(currentContentId);
134
+ currentContentIdRef.current = currentContentId;
126
135
 
127
136
  // マルチページモード判定
128
137
  const multiPageCanvas = useMultiPageCanvasOptional();
138
+ // 文書(<html>)に付ける属性(利用側の documentAttributes)。読み込み時と、変わったときに付け替える
139
+ const documentAttributesRef = useRef(documentAttributes);
140
+ documentAttributesRef.current = documentAttributes;
141
+ useEffect(() => {
142
+ try {
143
+ applyDocumentAttributes(iframeRef.current?.contentDocument, documentAttributes);
144
+ } catch {
145
+ /* まだ読めなければ読み込み時に付く */
146
+ }
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [documentAttributes]);
129
149
  const isInMultiPageMode = !!multiPageCanvas?.isEnabled;
150
+ // ロードハンドラ(deps=[])から最新の値を読むための ref
151
+ const multiPageCanvasRef = useRef(multiPageCanvas);
152
+ multiPageCanvasRef.current = multiPageCanvas;
153
+ const isInMultiPageModeRef = useRef(isInMultiPageMode);
154
+ isInMultiPageModeRef.current = isInMultiPageMode;
130
155
 
131
156
  // Figmaライクなキャンバス操作
132
157
  useCanvasControls();
@@ -301,6 +326,17 @@ export function EditorCanvas() {
301
326
 
302
327
  const scrollHeight = targetElement.scrollHeight;
303
328
  const computedHeight = Math.max(scrollHeight, maxBottom);
329
+ // 埋め込み(マルチフレーム)ではフレーム = 紙面そのもの。下の余白は付けず、
330
+ // 実測をキャンバス側へ渡してフレームの高さにする
331
+ if (isInMultiPageModeRef.current) {
332
+ const frameHeight = Math.max(200, Math.ceil(computedHeight));
333
+ setContentHeight(frameHeight);
334
+ contentHeightRef.current = frameHeight;
335
+ const mpc = multiPageCanvasRef.current;
336
+ const activeId = currentContentIdRef.current;
337
+ if (mpc && activeId) mpc.setPageHeight(activeId, frameHeight, "editor");
338
+ return;
339
+ }
304
340
  const newHeight = Math.max(WEBPAGE_MIN_HEIGHT, computedHeight + 100);
305
341
 
306
342
  setContentHeight(newHeight);
@@ -409,11 +445,19 @@ export function EditorCanvas() {
409
445
  zoomRef.current = zoom;
410
446
  }, [zoom]);
411
447
 
448
+ // 埋め込みでは版面の幅を最初から入れて組む(後から書き換えると一瞬 1440px で描かれる)。
449
+ // 単独表示では幅は effect が書くので、ここでは依存に入れない(幅変更で iframe を作り直さない)
450
+ const embeddedWidth = isInMultiPageMode ? viewportWidth : 0;
412
451
  const iframeHtml = useMemo(
413
452
  // sourceHtml(ページ切替でのみ変わる)から組む。originalHtml は
414
453
  // initLayout が変更判定の基準として書き換えるため、ここに使うとループする
415
- () => generateEditableHtml(sourceHtml, editorMode),
416
- [sourceHtml, editorMode]
454
+ () =>
455
+ generateEditableHtml(
456
+ sourceHtml,
457
+ editorMode,
458
+ embeddedWidth ? { embedded: true, artboardWidth: embeddedWidth } : undefined,
459
+ ),
460
+ [sourceHtml, editorMode, embeddedWidth]
417
461
  );
418
462
 
419
463
  // ========== iframe Load Handler ==========
@@ -429,6 +473,7 @@ export function EditorCanvas() {
429
473
  // initLayout はフォント待ち等で数百msかかるため、その後に適用すると
430
474
  // 「等倍の巨大な一瞬 → 縮む」のフラッシュがノイズになる
431
475
  applyCanvasZoomDom(iframeDoc, zoomRef.current);
476
+ applyDocumentAttributes(iframeDoc, documentAttributesRef.current);
432
477
  const wrapperEl = iframeDoc.getElementById("artboard-wrapper");
433
478
  if (wrapperEl) wrapperEl.style.visibility = "visible";
434
479
 
@@ -460,8 +505,13 @@ export function EditorCanvas() {
460
505
  // 3. イベントリスナーのセットアップ(ローカル配列に収集後、refに保存)
461
506
  const cleanupFunctions: (() => void)[] = [];
462
507
 
463
- // タッチジェスチャー
464
- cleanupFunctions.push(setupTouchGestureListeners(iframeDoc));
508
+ // タッチジェスチャー。埋め込み(マルチフレーム)では倍率も位置も外側が持つので、
509
+ // ホイール・ピンチ・Space・中ボタンをそのまま親へ転送する
510
+ if (isInMultiPageModeRef.current) {
511
+ cleanupFunctions.push(setupEmbeddedCanvasBridge(iframeDoc, iframe));
512
+ } else {
513
+ cleanupFunctions.push(setupTouchGestureListeners(iframeDoc));
514
+ }
465
515
 
466
516
  // キーボードショートカット
467
517
  cleanupFunctions.push(setupKeyboardShortcuts(iframeDoc));
@@ -521,13 +571,16 @@ export function EditorCanvas() {
521
571
  // 「iframe ビューポート基準」なので、iframe の矩形分だけ平行移動して渡す。
522
572
  // iframe 自体は等倍(scale は iframe 内の #artboard-wrapper に掛かる)なので
523
573
  // 平行移動だけで正しく一致する。
574
+ // 埋め込み(マルチフレーム)では iframe 要素自体が外側の transform で縮んでいるので、
575
+ // 実測の矩形と iframe の内寸の比(= 外側の倍率)で割ってから渡す。単独表示では比は 1
524
576
  const toIframeCoords = (e: MouseEvent): MouseEvent => {
525
577
  const frameEl = iframeRef.current;
526
578
  if (!frameEl) return e;
527
579
  const r = frameEl.getBoundingClientRect();
580
+ const outerScale = r.width / (frameEl.clientWidth || r.width) || 1;
528
581
  return new MouseEvent(e.type, {
529
- clientX: e.clientX - r.left,
530
- clientY: e.clientY - r.top,
582
+ clientX: (e.clientX - r.left) / outerScale,
583
+ clientY: (e.clientY - r.top) / outerScale,
531
584
  screenX: e.screenX,
532
585
  screenY: e.screenY,
533
586
  button: e.button,
@@ -805,6 +858,14 @@ export function EditorCanvas() {
805
858
  initLayout().then(() => {
806
859
  // ページ切替直後の文書へ現在の倍率を適用(上記 zoomRef のコメント参照)
807
860
  applyCanvasZoomDom(iframeDoc, zoomRef.current);
861
+ // 履歴の本文がこの文書の姿になった = ページ切替が終わった(自動保存を再開してよい)
862
+ endContentSwitch();
863
+ if (isInMultiPageModeRef.current) {
864
+ const mpc = multiPageCanvasRef.current;
865
+ if (mpc) {
866
+ iframeDoc.documentElement.dataset.outerZoom = String(mpc.viewStore.get().canvasZoom);
867
+ }
868
+ }
808
869
  const artboardEl = iframeDoc.getElementById("artboard");
809
870
  if (editorModeRef.current === "webpage" && artboardEl) {
810
871
  updateContentHeightRef.current();
@@ -840,11 +901,17 @@ export function EditorCanvas() {
840
901
  // 依存に iframeHtml(=originalHtml由来)を含めることで、ページ切替(サムネイル/URL)で
841
902
  // コンテンツが差し替わったときに**iframeだけ**を作り直す。殻(ヘッダー・パネル・
842
903
  // サムネイル)は残るので、切替のたびに画面全体がリロードされたようには見えない
843
- useEffect(() => {
904
+ // useLayoutEffect なのは、setIframeReady(false)(エディタを隠す)を、枠が新しいページへ移る
905
+ // 描画と同じフレームに載せるため。useEffect だと 1 フレームだけ前のページの姿が新しい枠に見える
906
+ useLayoutEffect(() => {
844
907
  const iframe = iframeRef.current;
845
908
  if (!iframe) return;
846
909
 
847
910
  iframe.addEventListener("load", handleIframeLoad);
911
+ // 新しい本文を読み込む間は「準備できていない」に戻す。
912
+ // マルチフレームのキャンバスはこれを見て、前のページの姿が新しい枠に
913
+ // 見えないようエディタを隠す(読み込みが済むまで見るだけの紙面が透ける)
914
+ setIframeReady(false);
848
915
  const blob = new Blob([iframeHtml], { type: "text/html" });
849
916
  iframe.src = URL.createObjectURL(blob);
850
917
 
@@ -939,6 +1006,36 @@ export function EditorCanvas() {
939
1006
  );
940
1007
  }, [zoom, editorMode, contentHeight, viewportWidth, getIframeDoc]);
941
1008
 
1009
+ // 埋め込み(マルチフレーム): 外側の倍率を iframe に知らせ、選択枠・ハンドルの太さを
1010
+ // 画面上で一定に保つ(applyOverlayScale が data-outer-zoom を読む)
1011
+ // ズーム・パンは毎目盛り起きるので、ここでは描き直さず(再レンダーせず)ストアを購読して
1012
+ // 直接 DOM に書く。選択枠の描き直しは 1 フレームに 1 回にまとめる
1013
+ const viewStore = multiPageCanvas?.viewStore ?? null;
1014
+ useEffect(() => {
1015
+ if (!isInMultiPageMode || !viewStore) return;
1016
+ let raf: number | null = null;
1017
+ let lastZoom: number | null = null;
1018
+ const apply = () => {
1019
+ const zoom = viewStore.get().canvasZoom;
1020
+ if (zoom === lastZoom) return;
1021
+ lastZoom = zoom;
1022
+ const iframeDoc = getIframeDoc();
1023
+ if (!iframeDoc?.documentElement) return;
1024
+ iframeDoc.documentElement.dataset.outerZoom = String(zoom);
1025
+ if (raf != null) cancelAnimationFrame(raf);
1026
+ raf = requestAnimationFrame(() => {
1027
+ raf = null;
1028
+ refreshSelectionOverlay(iframeDoc);
1029
+ });
1030
+ };
1031
+ apply();
1032
+ const unsubscribe = viewStore.subscribe(apply);
1033
+ return () => {
1034
+ unsubscribe();
1035
+ if (raf != null) cancelAnimationFrame(raf);
1036
+ };
1037
+ }, [isInMultiPageMode, viewStore, getIframeDoc]);
1038
+
942
1039
  // ビューポート幅変更時にiframe内のartboard幅を更新
943
1040
  useEffect(() => {
944
1041
  if (editorMode !== "webpage") return;
@@ -977,6 +1074,7 @@ export function EditorCanvas() {
977
1074
  <div
978
1075
  ref={containerRef}
979
1076
  className="h-full w-full"
1077
+ data-editor-canvas={isInMultiPageMode ? "embedded" : "single"}
980
1078
  style={{
981
1079
  backgroundColor: isInMultiPageMode ? "transparent" : "var(--ed-bg)",
982
1080
  }}
@@ -986,6 +1084,7 @@ export function EditorCanvas() {
986
1084
  className="w-full h-full border-0"
987
1085
  title={editorMode === "webpage" ? "Webpage Editor" : "Slide Editor"}
988
1086
  sandbox="allow-same-origin allow-scripts"
1087
+ style={isInMultiPageMode ? { display: "block", background: "#fff" } : undefined}
989
1088
  />
990
1089
  </div>
991
1090
  );