@growgroup/visual-editor 0.2.1 → 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.
- package/README.md +37 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +7 -3
- package/src/editor/FrontendVisualEditor.tsx +154 -54
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +93 -8
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorPropertyPanel.tsx +2 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
- package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/parts.ts +38 -12
- package/src/editor/utils/dom-utils.ts +7 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +86 -11
- package/src/io.ts +20 -0
- package/src/styles/skin.css +66 -34
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 = (
|
|
1253
|
-
y = (
|
|
1286
|
+
x = (frameX - rootRect.left) / rootScale;
|
|
1287
|
+
y = (frameY - rootRect.top) / rootScale;
|
|
1254
1288
|
} else {
|
|
1255
|
-
const dropScale = zoom ||
|
|
1256
|
-
x =
|
|
1257
|
-
y =
|
|
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,
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
//
|
|
2684
|
-
const
|
|
2685
|
-
|
|
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
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
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,
|
|
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}
|
package/src/editor/autosave.ts
CHANGED
|
@@ -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
|
-
() =>
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|