@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
@@ -3,1402 +3,352 @@
3
3
  /**
4
4
  * MultiPageCanvasView
5
5
  *
6
- * 全ページ同時編集可能な無限キャンバス
7
- * - 全ページのiframeに親側から編集ハンドラをアタッチ
8
- * - updateSelectionBox / extractElementInfo を直接使用
9
- * - キーボードショートカット、リサイズハンドル、ドラッグ完備
6
+ * Figma 風のマルチフレームキャンバス。全ページをフレームとして並べ、
7
+ * 1 つのフレームだけが生きているエディタ(EditorCanvas)、残りは見るだけの紙面。
8
+ *
9
+ * 層の構成(下から):
10
+ * 1. 転写層(translate + scale)… フレームの白地 + 見るだけの紙面(PageFramePreview)
11
+ * 2. 転写層の中、編集中のフレームの位置に EditorCanvas(1 つだけ。ページを移っても作り直さない)
12
+ * 3. 画面座標のオーバーレイ … フレーム名・選択リング(倍率に関係なく同じ太さ・同じ文字サイズ)
13
+ * 4. 定規
14
+ *
15
+ * EditorCanvas を pages.map の中に置くとページを移るたびに React が作り直して
16
+ * iframe が読み直される。map の外に 1 つだけ置き、位置だけ動かす
17
+ *
18
+ * [パン・ズームの軽さ]
19
+ * パンは 2 つの層の transform 文字列を変えるだけにする。フレーム 1 枚ずつの要素
20
+ * (FrameShell / FrameChrome)は memo で、位置は倍率だけに依存させる(画面上の位置 =
21
+ * 層の translate + 紙面の位置 × 倍率)。150 枚あってもパンで描き直る要素は 2 つ。
22
+ * ズームは倍率に依存する要素が描き直る(それでも小さな要素が 300 個ほど)
10
23
  */
11
24
 
12
- import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
- import { useMultiPageCanvas } from '../../contexts/MultiPageCanvasContext';
25
+ import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
26
+ import {
27
+ useMultiPageCanvas,
28
+ useCanvasViewState,
29
+ RULER_SIZE,
30
+ FRAME_GAP,
31
+ type PageFrameLayout,
32
+ } from '../../contexts/MultiPageCanvasContext';
14
33
  import { useEditorContext } from '../../EditorContext';
15
- import { useEditorRefs } from '../../contexts/EditorRefsContext';
16
34
  import { useInfiniteCanvas } from '../../hooks/useInfiniteCanvas';
17
- import { extractElementInfo } from '../../utils/style-utils';
18
- import {
19
- buildDomTree,
20
- updateSelectionBox,
21
- removeSelectionBox,
22
- } from '../../utils/dom-utils';
23
- import { EDITOR_IFRAME_STYLES } from '../../constants';
24
- import {
25
- startAutoLayoutDrag,
26
- updateDragPosition,
27
- endAutoLayoutDrag,
28
- cancelAutoLayoutDrag,
29
- findDropTarget,
30
- showDropIndicator,
31
- hideFlexDropIndicator,
32
- type AutoLayoutDragState,
33
- type DropTargetInfo,
34
- } from '../../utils/flex-utils';
35
- import { PageLivePreview } from './PageLivePreview';
36
- import { PageLabel } from './PageLabel';
37
- import { CanvasZoomControls } from './CanvasZoomControls';
35
+ import { useCanvasMarquee } from '../../hooks/useCanvasMarquee';
36
+ import { editorZoomApiRef } from '../../hooks/useCanvasControls';
37
+ import { EditorAppearanceContext } from '../../contexts/EditorAppearanceContext';
38
+ import { EditorCanvas } from '../EditorCanvas';
39
+ import { PageFramePreview } from './PageFramePreview';
40
+ import { CanvasRulers } from './CanvasRulers';
41
+ import { cn } from '../../../lib/utils';
42
+
43
+ /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
44
+ const LABEL_HEIGHT = 20;
45
+
46
+ // ------------------------------------------------------------
47
+ // フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
48
+ // ------------------------------------------------------------
49
+
50
+ interface FrameShellProps {
51
+ page: PageFrameLayout;
52
+ zoom: number;
53
+ isActive: boolean;
54
+ passThrough: boolean;
55
+ rootRef: React.RefObject<HTMLDivElement | null>;
56
+ onMouseDown: (e: React.MouseEvent, id: string) => void;
57
+ onHover: (id: string | null) => void;
58
+ }
59
+
60
+ const FrameShell = memo(function FrameShell({ page, zoom, isActive, passThrough, rootRef, onMouseDown, onHover }: FrameShellProps) {
61
+ return (
62
+ <div
63
+ data-page-frame={page.id}
64
+ data-page-active={isActive ? 'true' : undefined}
65
+ className="absolute"
66
+ style={{
67
+ left: page.position.x,
68
+ top: page.position.y,
69
+ width: page.size.width,
70
+ height: page.size.height,
71
+ background: '#fff',
72
+ boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
73
+ cursor: isActive || passThrough ? undefined : 'pointer',
74
+ }}
75
+ onMouseDown={(e) => onMouseDown(e, page.id)}
76
+ onMouseEnter={() => onHover(page.id)}
77
+ onMouseLeave={() => onHover(null)}
78
+ >
79
+ <PageFramePreview page={page} rootRef={rootRef} />
80
+ </div>
81
+ );
82
+ });
83
+
84
+ // ------------------------------------------------------------
85
+ // フレーム名・リング(オーバーレイの中。画面の座標 = 紙面の位置 × 倍率)
86
+ // ------------------------------------------------------------
87
+
88
+ interface FrameChromeProps {
89
+ page: PageFrameLayout;
90
+ zoom: number;
91
+ /** 名前を出せる倍率か(行の隙間に名前が収まる)。全フレーム共通なので親で判定する */
92
+ labelsVisible: boolean;
93
+ /** リングと名前の強調(切替中はクリックしたページへ先に移る) */
94
+ isSelected: boolean;
95
+ isHover: boolean;
96
+ /** 本文を読んでいる・エディタを組み直している */
97
+ isBusy: boolean;
98
+ onActivate: (id: string) => void;
99
+ onZoomTo: (id: string) => void;
100
+ }
101
+
102
+ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
103
+ const sx = page.position.x * zoom;
104
+ const sy = page.position.y * zoom;
105
+ const sw = page.size.width * zoom;
106
+ const sh = page.size.height * zoom;
107
+ // 縮小してフレームが細くなったら名前は幅に収める(重ならない)。極端に小さければ出さない
108
+ const showLabel = labelsVisible && sw >= 28;
109
+ return (
110
+ <div className="absolute left-0 top-0" data-frame-chrome={page.id} style={{ transform: `translate(${sx}px, ${sy}px)` }}>
111
+ {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ) */}
112
+ {showLabel && (
113
+ <button
114
+ type="button"
115
+ data-frame-label={page.id}
116
+ className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
117
+ style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}
118
+ title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
119
+ onMouseDown={(e) => e.stopPropagation()}
120
+ onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
121
+ onDoubleClick={() => onZoomTo(page.id)}
122
+ >
123
+ <span className="ed-frame-label-index">{page.index + 1}</span>
124
+ <span className="ed-frame-label-title">{page.title || page.id}</span>
125
+ {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
126
+ {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
127
+ </button>
128
+ )}
129
+ {/* リング(選択中 = 実線、ホバー = 淡い実線) */}
130
+ {(isSelected || isHover) && (
131
+ <div
132
+ className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
133
+ style={{ width: sw, height: sh }}
134
+ />
135
+ )}
136
+ </div>
137
+ );
138
+ });
38
139
 
39
- /** キャンバスモード用の追加CSS(data-element-idベース) */
40
- const CANVAS_EDITING_STYLES = `
41
- /* トップレベル要素のホバー */
42
- #artboard > [data-element-id]:hover:not(.selected):not(.editing) {
43
- outline: 2px solid rgba(13, 153, 255, 0.3);
44
- outline-offset: -1px;
45
- }
46
- /* 全要素のホバー(Ctrl/Cmd押下で子要素選択可) */
47
- [data-element-id]:hover:not(.selected):not(.editing) {
48
- outline: 1px solid rgba(13, 153, 255, 0.15);
49
- outline-offset: -1px;
50
- }
51
- [data-element-id].selected {
52
- outline: none !important;
53
- cursor: move;
54
- }
55
- [data-element-id].dragging {
56
- opacity: 0.7;
57
- cursor: grabbing !important;
58
- }
59
- [data-element-id].editing {
60
- outline: 2px solid #0d99ff !important;
61
- outline-offset: 2px;
62
- cursor: text !important;
63
- min-height: 1em;
64
- }
65
- .marquee-hover {
66
- outline: 2px solid rgba(13, 153, 255, 0.6) !important;
67
- outline-offset: 1px;
68
- }
69
- /* 描画ツール: 既存要素のpointer-events無効化 */
70
- body.draw-mode [data-element-id] {
71
- pointer-events: none;
72
- }
73
- body.text-mode [data-element-id] {
74
- pointer-events: none;
75
- }
76
- `;
140
+ // ------------------------------------------------------------
141
+ // キャンバス
142
+ // ------------------------------------------------------------
77
143
 
78
144
  export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
145
+ const canvas = useMultiPageCanvas();
79
146
  const {
80
- viewState,
81
147
  pages,
82
- updatePageFrame,
83
- focusPage,
148
+ bounds,
149
+ registerContainer,
150
+ activatePage,
151
+ activatingPageId,
84
152
  zoomToFit,
85
- setCanvasZoom,
86
- setCanvasOffset,
87
- registerUndoHandlers,
88
- } = useMultiPageCanvas();
89
- const {
90
- setZoom,
91
- setFitZoom,
92
- setSelectedElement,
93
- setSelectedElementIds,
94
- setDomTree,
95
- setExpandedNodes,
96
- activeTool,
97
- layoutMode,
98
- } = useEditorContext();
99
- const refs = useEditorRefs();
100
- const { canvasOffset, canvasZoom } = viewState;
153
+ zoomToPage,
154
+ zoomToActual,
155
+ rulersVisible,
156
+ isInteracting,
157
+ initialViewRestored,
158
+ } = canvas;
159
+ const { canvasOffset, canvasZoom, activePageId } = useCanvasViewState();
160
+ const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
161
+ const theme = useContext(EditorAppearanceContext) ?? 'light';
101
162
  const containerRef = useRef<HTMLDivElement>(null);
163
+ const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
164
+ // リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
165
+ const selectedId = activatingPageId ?? activePageId;
102
166
 
103
- // 各ページのiframe参照を保持
104
- const iframeMapRef = useRef(new Map<string, HTMLIFrameElement>());
167
+ // 無限キャンバスのズーム/パン操作(容器と、iframe から転送される操作の両方)
168
+ const { isSpaceHeld, isPanning } = useInfiniteCanvas(containerRef);
169
+ // キャンバスの余白から始めるマーキー選択(選択ツールのときだけ。Space・手のひら中は動かさない)
170
+ const { marqueeRect, marqueeArmed, onMouseDown: onMarqueeMouseDown } = useCanvasMarquee(containerRef, {
171
+ enabled: activeTool === 'select' && !isSpaceHeld && !isPanning,
172
+ });
105
173
 
106
- // 無限キャンバスのズーム/パン操作
107
- useInfiniteCanvas(containerRef);
174
+ useEffect(() => {
175
+ registerContainer(containerRef.current);
176
+ return () => registerContainer(null);
177
+ }, [registerContainer]);
108
178
 
109
- // エディタズームを100%に固定(キャンバスズームが全体スケーリングを担う)
179
+ // エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
110
180
  useEffect(() => {
111
181
  setZoom(100);
112
182
  setFitZoom(100);
113
183
  }, [setZoom, setFitZoom]);
114
184
 
115
- const [isInteracting, setIsInteracting] = useState(false);
116
- const interactingTimeoutRef = useRef<number | null>(null);
117
-
185
+ // Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける
118
186
  useEffect(() => {
119
- const handleInteractData = (e?: Event) => {
120
- // For message events, only trigger if it's a relevant one
121
- if (e && e.type === 'message') {
122
- const msg = (e as MessageEvent).data;
123
- if (!['PAGE_PREVIEW_WHEEL', 'IFRAME_WHEEL_EVENT', 'IFRAME_PINCH_EVENT'].includes(msg?.type)) {
124
- return;
125
- }
126
- }
127
-
128
- setIsInteracting(true);
129
- if (interactingTimeoutRef.current) {
130
- window.clearTimeout(interactingTimeoutRef.current);
131
- }
132
- interactingTimeoutRef.current = window.setTimeout(() => {
133
- setIsInteracting(false);
134
- }, 150);
187
+ editorZoomApiRef.current = {
188
+ fit: () => zoomToFit({ animate: true }),
189
+ actual: () => zoomToActual(),
190
+ selection: () => {
191
+ if (activePageId) zoomToPage(activePageId, { animate: true });
192
+ else zoomToFit({ animate: true });
193
+ },
135
194
  };
136
-
137
- window.addEventListener('wheel', handleInteractData, { passive: true, capture: true });
138
- window.addEventListener('touchstart', handleInteractData, { passive: true, capture: true });
139
- window.addEventListener('touchmove', handleInteractData, { passive: true, capture: true });
140
- window.addEventListener('message', handleInteractData);
141
-
142
195
  return () => {
143
- window.removeEventListener('wheel', handleInteractData, { capture: true });
144
- window.removeEventListener('touchstart', handleInteractData, { capture: true });
145
- window.removeEventListener('touchmove', handleInteractData, { capture: true });
146
- window.removeEventListener('message', handleInteractData);
147
- if (interactingTimeoutRef.current) window.clearTimeout(interactingTimeoutRef.current);
196
+ editorZoomApiRef.current = null;
148
197
  };
149
- }, []);
198
+ }, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
150
199
 
151
- // 初回フィットズーム
152
- const hasInitialFitRef = useRef(false);
200
+ // 初回の表示: 前回の場所が保存されていて内容と重なるならそこへ。無ければ編集中のページを大きく
201
+ const initialViewDoneRef = useRef(false);
153
202
  useEffect(() => {
154
- if (hasInitialFitRef.current || pages.length === 0) return;
155
- const container = containerRef.current;
156
- if (!container) return;
157
-
158
- const timer = setTimeout(() => {
159
- const rect = container.getBoundingClientRect();
160
- if (rect.width > 0 && rect.height > 0) {
161
- zoomToFit(rect.width, rect.height);
162
- hasInitialFitRef.current = true;
163
- }
164
- }, 100);
165
- return () => clearTimeout(timer);
166
- }, [pages.length, zoomToFit]);
167
-
168
- // Stale closure回避用ref
169
- const viewStateRef = useRef(viewState);
170
- viewStateRef.current = viewState;
171
- const pagesRef = useRef(pages);
172
- pagesRef.current = pages;
173
- const setSelectedElementRef = useRef(setSelectedElement);
174
- setSelectedElementRef.current = setSelectedElement;
175
- const setSelectedElementIdsRef = useRef(setSelectedElementIds);
176
- setSelectedElementIdsRef.current = setSelectedElementIds;
177
- const setDomTreeRef = useRef(setDomTree);
178
- setDomTreeRef.current = setDomTree;
179
- const setExpandedNodesRef = useRef(setExpandedNodes);
180
- setExpandedNodesRef.current = setExpandedNodes;
181
- const updatePageFrameRef = useRef(updatePageFrame);
182
- updatePageFrameRef.current = updatePageFrame;
183
- const activeToolRef = useRef(activeTool);
184
- activeToolRef.current = activeTool;
185
- const focusPageRef = useRef(focusPage);
186
- focusPageRef.current = focusPage;
187
- const layoutModeRef = useRef(layoutMode);
188
- layoutModeRef.current = layoutMode;
189
-
190
- // --- Global Undo/Redo Stack for Multi-Page ---
191
- // A single sequential timeline across all iframes
192
- const globalUndoStackRef = useRef<{ pageId: string; html: string }[]>([]);
193
- const globalRedoStackRef = useRef<{ pageId: string; html: string }[]>([]);
194
-
195
- const canUndo = useCallback(() => globalUndoStackRef.current.length > 0, []);
196
- const canRedo = useCallback(() => globalRedoStackRef.current.length > 0, []);
197
-
198
- const performGlobalUndo = useCallback(() => {
199
- const stack = globalUndoStackRef.current;
200
- if (stack.length === 0) return;
201
-
202
- const lastAction = stack.pop()!;
203
- const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${lastAction.pageId}"]`) as HTMLIFrameElement;
204
- const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
205
- const targetArtboard = targetDoc?.getElementById('artboard');
206
-
207
- if (!targetArtboard || !targetDoc) {
208
- stack.push(lastAction);
209
- return;
203
+ if (initialViewDoneRef.current || pages.length === 0) return;
204
+ const el = containerRef.current;
205
+ if (!el || el.clientWidth === 0) return;
206
+ initialViewDoneRef.current = true;
207
+ if (initialViewRestored) {
208
+ const { canvasOffset: o, canvasZoom: z } = canvas.viewStore.get();
209
+ const left = o.x + bounds.minX * z;
210
+ const top = o.y + bounds.minY * z;
211
+ const right = o.x + bounds.maxX * z;
212
+ const bottom = o.y + bounds.maxY * z;
213
+ const intersects = right > 0 && left < el.clientWidth && bottom > 0 && top < el.clientHeight;
214
+ if (intersects) return;
210
215
  }
211
-
212
- globalRedoStackRef.current.push({
213
- pageId: lastAction.pageId,
214
- html: targetArtboard.innerHTML
215
- });
216
-
217
- targetArtboard.innerHTML = lastAction.html;
218
-
219
- // Clear selections explicitly at context level
220
- setSelectedElementRef.current(null);
221
- setSelectedElementIdsRef.current([]);
222
-
223
- // Trigger update
224
- updatePageFrameRef.current(lastAction.pageId, {
225
- thumbnailHtml: lastAction.html,
226
- isDirty: true,
227
- });
228
-
229
- // Fix Tree
230
- const tree = buildDomTree(targetDoc);
231
- setDomTreeRef.current(tree);
216
+ if (activePageId) zoomToPage(activePageId);
217
+ else zoomToFit();
218
+ // eslint-disable-next-line react-hooks/exhaustive-deps
219
+ }, [pages.length, activePageId]);
220
+
221
+ const [hoverId, setHoverId] = useState<string | null>(null);
222
+ const handleHover = useCallback((id: string | null) => {
223
+ setHoverId((prev) => (id === null ? null : id === prev ? prev : id));
232
224
  }, []);
233
225
 
234
- const performGlobalRedo = useCallback(() => {
235
- const redoStack = globalRedoStackRef.current;
236
- if (redoStack.length === 0) return;
237
-
238
- const nextAction = redoStack.pop()!;
239
- const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${nextAction.pageId}"]`) as HTMLIFrameElement;
240
- const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
241
- const targetArtboard = targetDoc?.getElementById('artboard');
242
-
243
- if (!targetArtboard || !targetDoc) {
244
- redoStack.push(nextAction);
245
- return;
246
- }
247
-
248
- globalUndoStackRef.current.push({
249
- pageId: nextAction.pageId,
250
- html: targetArtboard.innerHTML
251
- });
252
-
253
- targetArtboard.innerHTML = nextAction.html;
254
-
255
- setSelectedElementRef.current(null);
256
- setSelectedElementIdsRef.current([]);
257
-
258
- updatePageFrameRef.current(nextAction.pageId, {
259
- thumbnailHtml: nextAction.html,
260
- isDirty: true,
261
- });
262
-
263
- const tree = buildDomTree(targetDoc);
264
- setDomTreeRef.current(tree);
265
- }, []);
266
-
267
- // Register these globally to the Toolbar Context
268
- useEffect(() => {
269
- if (registerUndoHandlers) {
270
- registerUndoHandlers({
271
- undo: performGlobalUndo,
272
- redo: performGlobalRedo,
273
- canUndo,
274
- canRedo,
275
- });
276
- }
277
- }, [performGlobalUndo, performGlobalRedo, canUndo, canRedo, registerUndoHandlers]);
278
-
279
- /**
280
- * 各ページのiframeに完全な編集機能を初期化
281
- * 親コンテキストから直接 updateSelectionBox / extractElementInfo を使用
282
- */
283
- const handlePageIframeLoad = useCallback((pageId: string, iframe: HTMLIFrameElement) => {
284
- iframeMapRef.current.set(pageId, iframe);
285
-
286
- const iframeDoc = iframe.contentDocument;
287
- if (!iframeDoc) return;
288
-
289
- // 既に初期化済みなら何もしない
290
- if (iframeDoc.querySelector('[data-canvas-editor-css]')) return;
291
-
292
- // ===== 1. CSS注入 =====
293
- const style = iframeDoc.createElement('style');
294
- style.setAttribute('data-canvas-editor-css', 'true');
295
- style.textContent = EDITOR_IFRAME_STYLES + CANVAS_EDITING_STYLES;
296
- iframeDoc.head.appendChild(style);
297
-
298
- // ===== 1.1 data-editable属性の補完 =====
299
- // レイアウトモード変換で必要な data-editable="true" を #artboard 直下の子要素に付与
300
- const artboard = iframeDoc.getElementById('artboard');
301
- if (artboard) {
302
- Array.from(artboard.children).forEach(child => {
303
- if (child.nodeType !== 1) return;
304
- if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') return;
305
- if (!child.hasAttribute('data-editable')) {
306
- child.setAttribute('data-editable', 'true');
307
- }
308
- });
309
- }
310
-
311
- // ===== 1.5 iframe内ブラウザズーム防止 =====
312
- let meta = iframeDoc.querySelector('meta[name="viewport"]');
313
- if (!meta) {
314
- meta = iframeDoc.createElement('meta');
315
- meta.setAttribute('name', 'viewport');
316
- iframeDoc.head.appendChild(meta);
317
- }
318
- meta.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0');
319
-
320
- iframeDoc.body.style.touchAction = 'none';
321
- iframeDoc.documentElement.style.touchAction = 'none';
322
-
323
- // === ブラウザネイティブズーム防止徹底 ===
324
- const preventZoom = (e: WheelEvent) => {
325
- if (e.ctrlKey || e.metaKey) {
326
- e.preventDefault();
327
- }
328
- };
329
- iframeDoc.addEventListener('wheel', preventZoom, { passive: false, capture: true });
330
- iframe.contentWindow?.addEventListener('wheel', preventZoom, { passive: false, capture: true });
331
-
332
- const preventGesture = (e: Event) => { e.preventDefault(); e.stopPropagation(); };
333
- iframeDoc.addEventListener('gesturestart', preventGesture, { passive: false, capture: true } as EventListenerOptions);
334
- iframeDoc.addEventListener('gesturechange', preventGesture, { passive: false, capture: true } as EventListenerOptions);
335
- iframeDoc.addEventListener('gestureend', preventGesture, { passive: false, capture: true } as EventListenerOptions);
336
- iframe.contentWindow?.addEventListener('gesturestart', preventGesture, { passive: false, capture: true } as EventListenerOptions);
337
- iframe.contentWindow?.addEventListener('gesturechange', preventGesture, { passive: false, capture: true } as EventListenerOptions);
338
- iframe.contentWindow?.addEventListener('gestureend', preventGesture, { passive: false, capture: true } as EventListenerOptions);
339
-
340
- const preventTouch = (e: TouchEvent) => {
341
- if (e.touches.length > 1) { e.preventDefault(); e.stopPropagation(); }
342
- };
343
- iframeDoc.addEventListener('touchstart', preventTouch, { passive: false, capture: true });
344
- iframeDoc.addEventListener('touchmove', preventTouch, { passive: false, capture: true });
345
- iframe.contentWindow?.addEventListener('touchstart', preventTouch, { passive: false, capture: true });
346
- iframe.contentWindow?.addEventListener('touchmove', preventTouch, { passive: false, capture: true });
347
-
348
- const preventShortcuts = (e: KeyboardEvent) => {
349
- if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) {
350
- e.preventDefault();
351
- }
352
- };
353
- iframeDoc.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
354
- iframe.contentWindow?.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
355
-
356
- // ===== 2. 編集状態 =====
357
- let selectedEl: HTMLElement | null = null;
358
-
359
- let dragState = {
360
- element: null as HTMLElement | null,
361
- startX: 0,
362
- startY: 0,
363
- origLeft: 0,
364
- origTop: 0,
365
- isDragging: false,
366
- hasMoved: false,
367
- // Auto-layout drag fields
368
- autoLayoutDragMode: false,
369
- autoLayoutState: null as AutoLayoutDragState | null,
370
- originalParent: null as HTMLElement | null,
371
- currentDropTarget: null as HTMLElement | null,
372
- dropPosition: null as string | null,
373
- dropIndex: -1,
374
- };
375
-
376
- let resizeState = {
377
- isResizing: false,
378
- isRotating: false,
379
- element: null as HTMLElement | null,
380
- handle: '',
381
- startX: 0,
382
- startY: 0,
383
- origLeft: 0,
384
- origTop: 0,
385
- origWidth: 0,
386
- origHeight: 0,
387
- origRadius: 0,
388
- rotation: 0,
389
- rotationStartAngle: 0,
390
- centerX: 0,
391
- centerY: 0,
392
- };
393
-
394
- // ===== 3. ヘルパー関数 =====
395
- const wireIframeToRefs = () => {
396
- const prevIframe = (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current;
397
- (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
398
- if (prevIframe !== iframe) {
399
- // iframe切替: useDrawingMode等のiframe依存hookを再アタッチさせる
400
- refs.setIframeReady(false);
401
- setTimeout(() => refs.setIframeReady(true), 0);
402
- } else {
403
- refs.setIframeReady(true);
404
- }
405
- };
406
-
407
- const selectElement = (el: HTMLElement | null) => {
408
- // 前の選択を解除
409
- if (selectedEl) {
410
- selectedEl.classList.remove('selected');
411
- }
412
- removeSelectionBox(iframeDoc);
413
-
414
- selectedEl = el;
415
-
416
- if (el) {
417
- el.classList.add('selected');
418
- updateSelectionBox(iframeDoc, el);
419
- wireIframeToRefs();
420
-
421
- // プロパティパネル更新
422
- const info = extractElementInfo(el, iframeDoc);
423
- if (info) {
424
- setSelectedElementRef.current(info);
425
- setSelectedElementIdsRef.current([info.id]);
426
- }
427
- } else {
428
- setSelectedElementRef.current(null);
429
- setSelectedElementIdsRef.current([]);
430
- }
431
- };
432
-
433
- // iframe単位の `saveSnapshot` 関数内でグローバルスタックに積ませる
434
-
435
- // React更新デバウンス用
436
- let notifyTimer: ReturnType<typeof setTimeout> | null = null;
437
- const notifyContentChanged = () => {
438
- if (notifyTimer) clearTimeout(notifyTimer);
439
- notifyTimer = setTimeout(() => {
440
- const artboard = iframeDoc.getElementById('artboard');
441
- if (artboard) {
442
- updatePageFrameRef.current(pageId, {
443
- thumbnailHtml: artboard.innerHTML,
444
- isDirty: true,
445
- });
446
- }
447
- }, 300);
448
- };
449
-
450
- // --- Undo/Redo (Global DOM スナップショット) ---
451
- const saveSnapshot = () => {
452
- const artboard = iframeDoc.getElementById('artboard');
453
- if (!artboard) return;
454
-
455
- const stack = globalUndoStackRef.current;
456
- stack.push({ pageId, html: artboard.innerHTML });
457
-
458
- // Limit global history depth to 100
459
- if (stack.length > 100) stack.shift();
460
-
461
- // 操作が行われたらRedoスタックは破棄
462
- globalRedoStackRef.current = [];
463
- };
464
-
465
- const performUndo = () => {
466
- const stack = globalUndoStackRef.current;
467
- if (stack.length === 0) return;
468
-
469
- // Pop the most recent action across ALL pages
470
- const lastAction = stack.pop()!;
471
-
472
- // Find the specific iframe that this action belongs to
473
- const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${lastAction.pageId}"]`) as HTMLIFrameElement;
474
- const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
475
- const targetArtboard = targetDoc?.getElementById('artboard');
476
-
477
- if (!targetArtboard || !targetDoc) {
478
- // Fallback: IF frame not found, push back and abort
479
- stack.push(lastAction);
480
- return;
481
- }
482
-
483
- // Save current state to Redo stack before applying Undo
484
- globalRedoStackRef.current.push({
485
- pageId: lastAction.pageId,
486
- html: targetArtboard.innerHTML
487
- });
488
-
489
- // Apply the historical HTML
490
- targetArtboard.innerHTML = lastAction.html;
491
-
492
- // Clear selection gracefully
493
- selectElement(null);
494
- // Trigger update
495
- if (lastAction.pageId === pageId) {
496
- notifyContentChanged();
497
- } else {
498
- // Directly call the update on the target page if different
499
- updatePageFrameRef.current(lastAction.pageId, {
500
- thumbnailHtml: lastAction.html,
501
- isDirty: true,
502
- });
503
- }
504
-
505
- // Fix Tree
506
- const tree = buildDomTree(targetDoc);
507
- setDomTreeRef.current(tree);
508
- };
509
-
510
- const performRedo = () => {
511
- const redoStack = globalRedoStackRef.current;
512
- if (redoStack.length === 0) return;
513
-
514
- const nextAction = redoStack.pop()!;
515
-
516
- // Find the specific iframe
517
- const targetIframe = containerRef.current?.querySelector(`iframe[data-page-id="${nextAction.pageId}"]`) as HTMLIFrameElement;
518
- const targetDoc = targetIframe?.contentDocument || targetIframe?.contentWindow?.document;
519
- const targetArtboard = targetDoc?.getElementById('artboard');
520
-
521
- if (!targetArtboard || !targetDoc) {
522
- redoStack.push(nextAction);
523
- return;
524
- }
525
-
526
- // Save current state to Undo stack before applying Redo
527
- globalUndoStackRef.current.push({
528
- pageId: nextAction.pageId,
529
- html: targetArtboard.innerHTML
530
- });
531
-
532
- // Apply the redo HTML
533
- targetArtboard.innerHTML = nextAction.html;
534
-
535
- selectElement(null);
536
- if (nextAction.pageId === pageId) {
537
- notifyContentChanged();
538
- } else {
539
- updatePageFrameRef.current(nextAction.pageId, {
540
- thumbnailHtml: nextAction.html,
541
- isDirty: true,
542
- });
543
- }
544
-
545
- const tree = buildDomTree(targetDoc);
546
- setDomTreeRef.current(tree);
547
- };
548
-
549
- // --- テキスト編集状態 ---
550
- let isEditing = false;
551
-
552
- // ===== 4. マウスダウン(選択 + ドラッグ開始 + リサイズハンドル) =====
553
- iframeDoc.addEventListener('mousedown', (e) => {
554
- const target = e.target as HTMLElement;
555
-
556
- // --- テキスト編集中は通常のマウスイベントを許可 ---
557
- if (isEditing) {
558
- // 編集中要素の外側クリックで編集終了
559
- if (selectedEl && !selectedEl.contains(target)) {
560
- selectedEl.contentEditable = 'false';
561
- selectedEl.classList.remove('editing');
562
- isEditing = false;
563
- saveSnapshot();
564
- notifyContentChanged();
565
- const info = extractElementInfo(selectedEl, iframeDoc);
566
- if (info) setSelectedElementRef.current(info);
567
- }
568
- return;
569
- }
570
-
571
- // --- selection-box内(breadcrumb等)のクリックはスキップ ---
572
- if (target.closest('.selection-box') && !target.getAttribute('data-handle')) {
573
- return;
574
- }
575
-
576
- // --- 描画ツールが有効な場合はselection/dragをスキップ ---
577
- const drawingTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame', 'text'];
578
- if (drawingTools.includes(activeToolRef.current)) {
579
- wireIframeToRefs();
580
- // useDrawingModeにイベント処理を委譲(preventDefault/stopPropagationしない)
581
- return;
582
- }
583
-
584
- // --- リサイズ/回転ハンドル ---
585
- const handle = target.getAttribute('data-handle');
586
- if (handle && selectedEl) {
587
- e.preventDefault();
588
- e.stopPropagation();
589
-
590
- const rect = selectedEl.getBoundingClientRect();
591
- const computedLeft = parseFloat(selectedEl.style.left) || 0;
592
- const computedTop = parseFloat(selectedEl.style.top) || 0;
593
- const computedStyle = iframeDoc.defaultView?.getComputedStyle(selectedEl);
594
-
595
- if (handle.startsWith('rotate-')) {
596
- const transform = selectedEl.style.transform || '';
597
- const rotateMatch = transform.match(/rotate\(([^)]+)deg\)/);
598
- const currentRotation = rotateMatch ? parseFloat(rotateMatch[1]) : 0;
599
- const centerX = rect.left + rect.width / 2;
600
- const centerY = rect.top + rect.height / 2;
601
- const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
602
-
603
- resizeState = {
604
- isResizing: false, isRotating: true, element: selectedEl, handle,
605
- startX: e.clientX, startY: e.clientY,
606
- origLeft: computedLeft, origTop: computedTop,
607
- origWidth: rect.width, origHeight: rect.height,
608
- origRadius: 0, rotation: currentRotation, rotationStartAngle: startAngle,
609
- centerX, centerY,
610
- };
611
- iframeDoc.body.classList.add('rotating');
612
- } else {
613
- resizeState = {
614
- isResizing: true, isRotating: false, element: selectedEl, handle,
615
- startX: e.clientX, startY: e.clientY,
616
- origLeft: computedLeft, origTop: computedTop,
617
- origWidth: rect.width, origHeight: rect.height,
618
- origRadius: parseInt(computedStyle?.borderRadius || '0') || 0,
619
- rotation: 0, rotationStartAngle: 0, centerX: 0, centerY: 0,
620
- };
621
- }
622
- return;
623
- }
624
-
625
- // --- 要素選択 + ドラッグ開始 ---
626
- // ページフォーカス切替(レイヤーパネル・CSS/JS・設定を正しいページに同期)
627
- if (viewStateRef.current.activePageId !== pageId) {
628
- focusPageRef.current(pageId);
629
- }
630
- // PAGE_FOCUSED: レイヤーパネル用DOMツリー構築
631
- wireIframeToRefs();
632
- const tree = buildDomTree(iframeDoc);
633
- setDomTreeRef.current(tree);
634
- const firstLevelIds = new Set<string>(tree.map(n => n.id));
635
- setExpandedNodesRef.current(firstLevelIds);
636
-
637
- const isMeta = e.ctrlKey || e.metaKey;
638
-
639
- // 既に選択済みの要素内をクリック(Ctrl/Cmd未押下時)→ 再選択せずドラッグ開始
640
- // Ctrl/Cmd押下時は子要素選択を優先
641
- let dragTarget: HTMLElement;
642
- if (!isMeta && selectedEl && (selectedEl === target || selectedEl.contains(target))) {
643
- e.preventDefault();
644
- e.stopPropagation();
645
- dragTarget = selectedEl;
646
- } else {
647
- // Ctrl/Cmd+クリック: 子要素を直接選択
648
- // 通常クリック: トップレベル要素(#artboard直下)を選択
649
- let selectable: HTMLElement | null;
650
- if (isMeta) {
651
- selectable = target.closest('[data-element-id]') as HTMLElement | null;
652
- if (selectable && selectable.id === 'artboard') selectable = null;
653
- } else {
654
- selectable = target.closest('#artboard > [data-element-id]') as HTMLElement | null;
655
- }
656
- if (!selectable) {
657
- selectElement(null);
658
- return;
659
- }
660
-
661
- e.preventDefault();
662
- e.stopPropagation();
663
- selectElement(selectable);
664
- dragTarget = selectable;
665
- }
666
-
667
- // DOM変更前のスナップショット保存(undo用)
668
- saveSnapshot();
669
-
670
- if (layoutModeRef.current === 'auto') {
671
- // Auto-layout: ドラッグ状態のみ設定(ghost は mousemove で作成)
672
- dragState = {
673
- element: dragTarget,
674
- startX: e.clientX,
675
- startY: e.clientY,
676
- origLeft: 0,
677
- origTop: 0,
678
- isDragging: true,
679
- hasMoved: false,
680
- autoLayoutDragMode: true,
681
- autoLayoutState: null,
682
- originalParent: dragTarget.parentElement,
683
- currentDropTarget: null,
684
- dropPosition: null,
685
- dropIndex: -1,
686
- };
687
- } else {
688
- // Absolute: ドラッグ準備(絶対配置モード時)
689
- const cs = iframeDoc.defaultView?.getComputedStyle(dragTarget);
690
- let computedLeft = parseFloat(dragTarget.style.left) || 0;
691
- let computedTop = parseFloat(dragTarget.style.top) || 0;
692
- if (cs?.position === 'static') {
693
- dragTarget.style.position = 'relative';
694
- computedLeft = 0;
695
- computedTop = 0;
696
- }
697
-
698
- dragState = {
699
- element: dragTarget,
700
- startX: e.clientX,
701
- startY: e.clientY,
702
- origLeft: computedLeft,
703
- origTop: computedTop,
704
- isDragging: true,
705
- hasMoved: false,
706
- autoLayoutDragMode: false,
707
- autoLayoutState: null,
708
- originalParent: null,
709
- currentDropTarget: null,
710
- dropPosition: null,
711
- dropIndex: -1,
712
- };
713
- }
714
- });
715
-
716
- // ===== 5. マウスムーブ(ドラッグ移動 + リサイズ + 回転) =====
717
- iframeDoc.addEventListener('mousemove', (e) => {
718
- // --- 回転 ---
719
- if (resizeState.isRotating && resizeState.element) {
720
- const currentAngle = Math.atan2(
721
- e.clientY - resizeState.centerY,
722
- e.clientX - resizeState.centerX
723
- ) * (180 / Math.PI);
724
- const angleDelta = currentAngle - resizeState.rotationStartAngle;
725
- const newRotation = resizeState.rotation + angleDelta;
726
- const existingTransform = resizeState.element.style.transform || '';
727
- const scaleMatch = existingTransform.match(/scale\(([^)]+)\)/);
728
- const scaleValue = scaleMatch ? scaleMatch[0] : '';
729
- resizeState.element.style.transform = `rotate(${newRotation}deg) ${scaleValue}`.trim();
730
- updateSelectionBox(iframeDoc, resizeState.element);
731
- return;
732
- }
733
-
734
- // --- リサイズ ---
735
- if (resizeState.isResizing && resizeState.element) {
736
- const deltaX = e.clientX - resizeState.startX;
737
- const deltaY = e.clientY - resizeState.startY;
738
- const el = resizeState.element;
739
- const h = resizeState.handle;
740
- let newLeft = resizeState.origLeft;
741
- let newTop = resizeState.origTop;
742
- let newWidth = resizeState.origWidth;
743
- let newHeight = resizeState.origHeight;
744
- const minSize = 10;
745
-
746
- if (h === 'radius') {
747
- const maxRadius = Math.min(resizeState.origWidth, resizeState.origHeight) / 2;
748
- const newRadius = Math.max(0, Math.min(maxRadius, resizeState.origRadius - deltaX - deltaY));
749
- el.style.borderRadius = `${newRadius}px`;
750
- updateSelectionBox(iframeDoc, el);
751
- return;
752
- }
753
-
754
- switch (h) {
755
- case 'nw': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
756
- case 'n': newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
757
- case 'ne': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); newHeight = Math.max(minSize, resizeState.origHeight - deltaY); newTop = resizeState.origTop + (resizeState.origHeight - newHeight); break;
758
- case 'e': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); break;
759
- case 'se': newWidth = Math.max(minSize, resizeState.origWidth + deltaX); newHeight = Math.max(minSize, resizeState.origHeight + deltaY); break;
760
- case 's': newHeight = Math.max(minSize, resizeState.origHeight + deltaY); break;
761
- case 'sw': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newHeight = Math.max(minSize, resizeState.origHeight + deltaY); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); break;
762
- case 'w': newWidth = Math.max(minSize, resizeState.origWidth - deltaX); newLeft = resizeState.origLeft + (resizeState.origWidth - newWidth); break;
763
- }
764
-
765
- const cs = iframeDoc.defaultView?.getComputedStyle(el);
766
- if (cs?.position !== 'absolute' && cs?.position !== 'fixed') {
767
- el.style.position = 'absolute';
768
- }
769
- el.style.left = `${newLeft}px`;
770
- el.style.top = `${newTop}px`;
771
- el.style.width = `${newWidth}px`;
772
- el.style.height = `${newHeight}px`;
773
- updateSelectionBox(iframeDoc, el);
774
- return;
775
- }
776
-
777
- // --- ドラッグ移動 ---
778
- if (dragState.isDragging && dragState.element) {
779
- const dx = e.clientX - dragState.startX;
780
- const dy = e.clientY - dragState.startY;
781
- if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
782
- if (dragState.autoLayoutDragMode) {
783
- // Auto-layout: Ghost clone ドラッグ
784
- if (!dragState.hasMoved) {
785
- dragState.hasMoved = true;
786
- // scale=1: iframe内は1:1、外側CSSトランスフォームがズーム担当
787
- dragState.autoLayoutState = startAutoLayoutDrag(dragState.element, iframeDoc, 1);
788
- removeSelectionBox(iframeDoc);
789
- }
790
- if (dragState.autoLayoutState) {
791
- updateDragPosition(dragState.autoLayoutState, e.clientX, e.clientY);
792
- const dropInfo = findDropTarget(iframeDoc, e.clientX, e.clientY, dragState.element);
793
- if (dropInfo) {
794
- showDropIndicator(iframeDoc, dropInfo, dragState.element);
795
- dragState.currentDropTarget = dropInfo.container;
796
- dragState.dropPosition = dropInfo.position;
797
- dragState.dropIndex = dropInfo.index;
798
- } else {
799
- hideFlexDropIndicator(iframeDoc);
800
- dragState.currentDropTarget = null;
801
- dragState.dropPosition = null;
802
- dragState.dropIndex = -1;
803
- }
804
- }
805
- } else {
806
- // Absolute: 既存の left/top ドラッグ
807
- dragState.hasMoved = true;
808
- dragState.element.classList.add('dragging');
809
- dragState.element.style.left = `${dragState.origLeft + dx}px`;
810
- dragState.element.style.top = `${dragState.origTop + dy}px`;
811
- updateSelectionBox(iframeDoc, dragState.element);
812
- }
813
- }
814
- }
815
- });
816
-
817
- // ===== 6. マウスアップ =====
818
- iframeDoc.addEventListener('mouseup', () => {
819
- // 回転終了
820
- if (resizeState.isRotating && resizeState.element) {
821
- iframeDoc.body.classList.remove('rotating');
822
- notifyContentChanged();
823
- const info = extractElementInfo(resizeState.element, iframeDoc);
824
- if (info) setSelectedElementRef.current(info);
825
- }
826
- // リサイズ終了
827
- else if (resizeState.isResizing && resizeState.element) {
828
- notifyContentChanged();
829
- const info = extractElementInfo(resizeState.element, iframeDoc);
830
- if (info) setSelectedElementRef.current(info);
831
- }
832
-
833
- resizeState = {
834
- isResizing: false, isRotating: false, element: null, handle: '',
835
- startX: 0, startY: 0, origLeft: 0, origTop: 0,
836
- origWidth: 0, origHeight: 0, origRadius: 0,
837
- rotation: 0, rotationStartAngle: 0, centerX: 0, centerY: 0,
838
- };
839
-
840
- // ドラッグ終了
841
- if (dragState.isDragging && dragState.element) {
842
- if (dragState.autoLayoutDragMode && dragState.autoLayoutState) {
843
- // Auto-layout ドラッグ終了
844
- const autoState = dragState.autoLayoutState;
845
-
846
- // ドロップ先を構築(artboard内のみ許可)
847
- let dropInfo: DropTargetInfo | null = null;
848
- if (dragState.currentDropTarget && dragState.dropPosition !== null) {
849
- const artboard = iframeDoc.getElementById('artboard');
850
- const isValidDrop = dragState.currentDropTarget === artboard ||
851
- (artboard && artboard.contains(dragState.currentDropTarget));
852
- if (isValidDrop) {
853
- dropInfo = {
854
- container: dragState.currentDropTarget,
855
- position: dragState.dropPosition as 'before' | 'after' | 'inside',
856
- index: dragState.dropIndex,
857
- referenceElement: null,
858
- };
859
- }
860
- }
861
-
862
- const domChanged = endAutoLayoutDrag(autoState, dropInfo, iframeDoc);
863
- if (domChanged) {
864
- notifyContentChanged();
865
- const tree = buildDomTree(iframeDoc);
866
- setDomTreeRef.current(tree);
867
- }
868
-
869
- // 選択ボックス更新(DOM安定後)
870
- const droppedElement = dragState.element;
871
- setTimeout(() => {
872
- if (droppedElement) {
873
- updateSelectionBox(iframeDoc, droppedElement);
874
- const info = extractElementInfo(droppedElement, iframeDoc);
875
- if (info) setSelectedElementRef.current(info);
876
- }
877
- }, 0);
878
- } else {
879
- // Absolute: 既存のドラッグ終了
880
- dragState.element.classList.remove('dragging');
881
- if (dragState.hasMoved) {
882
- notifyContentChanged();
883
- // プロパティパネル更新
884
- const info = extractElementInfo(dragState.element, iframeDoc);
885
- if (info) setSelectedElementRef.current(info);
886
- }
887
- }
888
- }
889
- dragState = {
890
- element: null, startX: 0, startY: 0,
891
- origLeft: 0, origTop: 0, isDragging: false, hasMoved: false,
892
- autoLayoutDragMode: false, autoLayoutState: null,
893
- originalParent: null, currentDropTarget: null,
894
- dropPosition: null, dropIndex: -1,
895
- };
896
- });
897
-
898
- // ===== 7. キーボードショートカット =====
899
- iframeDoc.addEventListener('keydown', (e) => {
900
- // テキスト編集中はEscape以外のショートカットを無効化
901
- if (isEditing) {
902
- if (e.key === 'Escape') {
903
- e.preventDefault();
904
- if (selectedEl) {
905
- selectedEl.contentEditable = 'false';
906
- selectedEl.classList.remove('editing');
907
- isEditing = false;
908
- saveSnapshot();
909
- notifyContentChanged();
910
- updateSelectionBox(iframeDoc, selectedEl);
911
- const info = extractElementInfo(selectedEl, iframeDoc);
912
- if (info) setSelectedElementRef.current(info);
913
- }
914
- }
915
- return;
916
- }
917
-
918
- const isMeta = e.ctrlKey || e.metaKey;
919
-
920
- // Undo (per-iframe DOM snapshot)
921
- if (isMeta && e.key === 'z' && !e.shiftKey) {
922
- e.preventDefault();
923
- performUndo();
924
- return;
925
- }
926
- // Redo (per-iframe DOM snapshot)
927
- if (isMeta && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
928
- e.preventDefault();
929
- performRedo();
930
- return;
931
- }
932
-
933
- if (!selectedEl) return;
934
-
935
- // Delete / Backspace
936
- if (e.key === 'Delete' || e.key === 'Backspace') {
937
- e.preventDefault();
938
- saveSnapshot();
939
- const el = selectedEl;
940
- selectElement(null);
941
- el.remove();
942
- notifyContentChanged();
943
- // DOMツリー再構築
944
- const tree = buildDomTree(iframeDoc);
945
- setDomTreeRef.current(tree);
946
- return;
947
- }
948
-
949
- // Arrow keys (nudge)
950
- if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
951
- e.preventDefault();
952
- const n = e.shiftKey ? 10 : 1;
953
- const cs = iframeDoc.defaultView?.getComputedStyle(selectedEl);
954
- if (cs?.position === 'static') selectedEl.style.position = 'relative';
955
- const cl = parseFloat(selectedEl.style.left) || 0;
956
- const ct = parseFloat(selectedEl.style.top) || 0;
957
- if (e.key === 'ArrowLeft') selectedEl.style.left = `${cl - n}px`;
958
- if (e.key === 'ArrowRight') selectedEl.style.left = `${cl + n}px`;
959
- if (e.key === 'ArrowUp') selectedEl.style.top = `${ct - n}px`;
960
- if (e.key === 'ArrowDown') selectedEl.style.top = `${ct + n}px`;
961
- updateSelectionBox(iframeDoc, selectedEl);
962
- notifyContentChanged();
963
- return;
964
- }
965
-
966
- // Escape
967
- if (e.key === 'Escape') {
968
- // 進行中のオートレイアウトドラッグをキャンセル
969
- if (dragState.autoLayoutDragMode && dragState.autoLayoutState) {
970
- cancelAutoLayoutDrag(dragState.autoLayoutState, iframeDoc);
971
- dragState = {
972
- element: null, startX: 0, startY: 0,
973
- origLeft: 0, origTop: 0, isDragging: false, hasMoved: false,
974
- autoLayoutDragMode: false, autoLayoutState: null,
975
- originalParent: null, currentDropTarget: null,
976
- dropPosition: null, dropIndex: -1,
977
- };
978
- }
979
- selectElement(null);
980
- return;
981
- }
982
-
983
- // Duplicate (Ctrl+D)
984
- if (isMeta && e.key === 'd') {
985
- e.preventDefault();
986
- saveSnapshot();
987
- const clone = selectedEl.cloneNode(true) as HTMLElement;
988
- // 新しいIDを付与
989
- let idCounter = iframeDoc.querySelectorAll('[data-element-id]').length;
990
- clone.setAttribute('data-element-id', 'el-' + (idCounter++));
991
- const assignNewIds = (parent: Element) => {
992
- Array.from(parent.children).forEach(child => {
993
- if (child.nodeType === 1 && child.tagName !== 'SCRIPT' && child.tagName !== 'STYLE') {
994
- (child as HTMLElement).setAttribute('data-element-id', 'el-' + (idCounter++));
995
- assignNewIds(child);
996
- }
997
- });
998
- };
999
- assignNewIds(clone);
1000
- // オフセット配置
1001
- const left = parseFloat(clone.style.left) || 0;
1002
- const top = parseFloat(clone.style.top) || 0;
1003
- clone.style.left = `${left + 20}px`;
1004
- clone.style.top = `${top + 20}px`;
1005
- clone.classList.remove('selected');
1006
- selectedEl.parentElement?.appendChild(clone);
1007
- selectElement(clone);
1008
- notifyContentChanged();
1009
- const tree = buildDomTree(iframeDoc);
1010
- setDomTreeRef.current(tree);
1011
- return;
1012
- }
1013
- });
1014
-
1015
- // ===== 8. リンクナビゲーション防止 =====
1016
- iframeDoc.addEventListener('click', (e) => {
1017
- const anchor = (e.target as HTMLElement).closest('a');
1018
- if (anchor) e.preventDefault();
1019
- }, true);
1020
-
1021
- // ===== 8.5 コンテキストメニュー(右クリック) =====
1022
- iframeDoc.addEventListener('contextmenu', (e) => {
226
+ // Space・中ボタン・手のひらツールの間は、iframe に触らせず容器がパンを受ける
227
+ const passThrough = isSpaceHeld || isPanning || activeTool === 'move';
228
+ const passThroughRef = useRef(passThrough);
229
+ passThroughRef.current = passThrough;
230
+ const activePageIdRef = useRef(activePageId);
231
+ activePageIdRef.current = activePageId;
232
+
233
+ const handleFrameMouseDown = useCallback(
234
+ (e: React.MouseEvent, id: string) => {
235
+ if (e.button !== 0 || passThroughRef.current) return;
236
+ if (id === activePageIdRef.current) return;
1023
237
  e.preventDefault();
1024
- e.stopPropagation();
1025
-
1026
- // テキスト編集中はスキップ
1027
- if (isEditing) return;
1028
-
1029
- // ページフォーカス切替
1030
- if (viewStateRef.current.activePageId !== pageId) {
1031
- focusPageRef.current(pageId);
1032
- wireIframeToRefs();
1033
- }
1034
-
1035
- const target = e.target as HTMLElement;
1036
-
1037
- // 右クリックされた要素を選択(選択済み要素の内部なら維持)
1038
- if (!target.closest('.selection-box')) {
1039
- const isInsideSelected = selectedEl && (selectedEl === target || selectedEl.contains(target) || target.contains(selectedEl));
1040
- if (!isInsideSelected) {
1041
- const selectable = target.closest('#artboard > [data-element-id]') as HTMLElement | null;
1042
- if (selectable) {
1043
- wireIframeToRefs();
1044
- selectElement(selectable);
1045
- }
1046
- }
1047
- }
1048
-
1049
- // スクリーン座標を計算(iframe内座標 → 親ウィンドウ座標)
1050
- const iframeRect = iframe.getBoundingClientRect();
1051
- const scaleX = iframeRect.width / (iframe.clientWidth || 1);
1052
- const scaleY = iframeRect.height / (iframe.clientHeight || 1);
1053
-
1054
- window.parent.postMessage({
1055
- type: 'IFRAME_CONTEXT_MENU',
1056
- clientX: iframeRect.left + e.clientX * scaleX,
1057
- clientY: iframeRect.top + e.clientY * scaleY,
1058
- }, '*');
1059
- });
1060
-
1061
- // ===== 9. ダブルクリックでテキスト編集(選択済み要素のみ) =====
1062
- iframeDoc.addEventListener('dblclick', (e) => {
1063
- const target = e.target as HTMLElement;
1064
- if (target.closest('.selection-box')) return;
1065
-
1066
- // ページフォーカス切替
1067
- if (viewStateRef.current.activePageId !== pageId) {
1068
- focusPageRef.current(pageId);
1069
- wireIframeToRefs();
1070
- }
1071
-
1072
- // data-element-idを持つ最も近い要素を検索
1073
- const editable = target.closest('[data-element-id]') as HTMLElement | null;
1074
- if (!editable || editable.id === 'artboard') return;
1075
-
1076
- // 選択済み要素、またはその親要素が選択済みの場合のみ編集モードに入る
1077
- if (!selectedEl || (editable !== selectedEl && !selectedEl.contains(editable) && !editable.contains(selectedEl))) {
1078
- return;
1079
- }
1080
-
1081
- e.preventDefault();
1082
- e.stopPropagation();
1083
-
1084
- // スナップショット保存(編集前状態)
1085
- saveSnapshot();
1086
-
1087
- // 編集対象を決定(selectedElの子孫をダブルクリックした場合はその要素を編集)
1088
- const editTarget = editable.contains(selectedEl) ? selectedEl : editable;
1089
- selectElement(editTarget);
1090
- selectedEl = editTarget;
1091
-
1092
- // contentEditable有効化
1093
- editTarget.contentEditable = 'true';
1094
- editTarget.classList.add('editing');
1095
- editTarget.classList.remove('selected');
1096
- isEditing = true;
1097
-
1098
- // selection boxを非表示(テキスト選択と干渉するため)
1099
- removeSelectionBox(iframeDoc);
1100
-
1101
- // フォーカスしてカーソル配置
1102
- editTarget.focus();
1103
-
1104
- // ダブルクリック位置にカーソルを配置
1105
- const selection = iframeDoc.defaultView?.getSelection();
1106
- if (selection) {
1107
- const range = iframeDoc.caretRangeFromPoint?.(e.clientX, e.clientY);
1108
- if (range) {
1109
- selection.removeAllRanges();
1110
- selection.addRange(range);
1111
- }
1112
- }
1113
- });
1114
-
1115
- // ===== 10. 描画ツール用: マウスが入った時にiframeを事前ワイヤリング =====
1116
- iframeDoc.addEventListener('pointerenter', () => {
1117
- const dTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame', 'text'];
1118
- if (dTools.includes(activeToolRef.current)) {
1119
- if (viewStateRef.current.activePageId !== pageId) {
1120
- focusPageRef.current(pageId);
1121
- }
1122
- wireIframeToRefs();
1123
- }
1124
- });
1125
-
1126
- // ===== 11. 初期DOMツリー構築 =====
1127
- setTimeout(() => {
1128
- wireIframeToRefs();
1129
- const tree = buildDomTree(iframeDoc);
1130
- setDomTreeRef.current(tree);
1131
- const firstLevelIds = new Set<string>(tree.map(n => n.id));
1132
- setExpandedNodesRef.current(firstLevelIds);
1133
- }, 600);
1134
- }, [refs]);
1135
-
1136
- // ===== 描画ツール: 全iframeのbody classを同期 =====
1137
- useEffect(() => {
1138
- const drawingTools = ['rectangle', 'ellipse', 'line', 'arrow', 'pen', 'pencil', 'frame'];
1139
- const isDrawing = drawingTools.includes(activeTool);
1140
- const isText = activeTool === 'text';
1141
-
1142
- for (const [, iframe] of iframeMapRef.current) {
1143
- const doc = iframe.contentDocument;
1144
- if (!doc?.body) continue;
1145
-
1146
- doc.body.classList.remove(
1147
- 'draw-mode', 'text-mode', 'move-mode',
1148
- 'tool-rectangle', 'tool-ellipse', 'tool-line', 'tool-arrow',
1149
- 'tool-pen', 'tool-pencil', 'tool-text', 'tool-frame'
1150
- );
1151
-
1152
- if (isDrawing) {
1153
- doc.body.classList.add('draw-mode', `tool-${activeTool}`);
1154
- } else if (isText) {
1155
- doc.body.classList.add('text-mode', 'tool-text');
1156
- }
1157
- }
1158
- }, [activeTool]);
1159
-
1160
- // ===== ホイール転送メッセージ処理 =====
1161
- useEffect(() => {
1162
- const handleMessage = (e: MessageEvent) => {
1163
- // ホイール転送 → キャンバスズーム/パン
1164
- if (e.data?.type === 'PAGE_PREVIEW_WHEEL') {
1165
- const container = containerRef.current;
1166
- if (!container) return;
1167
-
1168
- const vs = viewStateRef.current;
1169
- const co = vs.canvasOffset;
1170
- const cz = vs.canvasZoom;
1171
-
1172
- if (e.data.ctrlKey || e.data.metaKey) {
1173
- const rect = container.getBoundingClientRect();
1174
- const mouseX = rect.width / 2;
1175
- const mouseY = rect.height / 2;
1176
-
1177
- const delta = -(e.data.deltaY || 0);
1178
- const factor = delta > 0 ? 1.05 : 0.95;
1179
- const newZoom = Math.max(0.02, Math.min(2.0, cz * factor));
1180
-
1181
- setCanvasZoom(newZoom);
1182
- setCanvasOffset({
1183
- x: mouseX - (mouseX - co.x) * (newZoom / cz),
1184
- y: mouseY - (mouseY - co.y) * (newZoom / cz),
1185
- });
1186
- } else {
1187
- setCanvasOffset({
1188
- x: co.x - (e.data.deltaX || 0),
1189
- y: co.y - (e.data.deltaY || 0),
1190
- });
1191
- }
1192
- }
1193
-
1194
- // タッチ転送 → ピンチズーム
1195
- if (e.data?.type === 'PAGE_PREVIEW_TOUCH' && e.data.touchType === 'move') {
1196
- const container = containerRef.current;
1197
- if (!container) return;
1198
- const touches = e.data.touches;
1199
- const prevTouches = e.data.prevTouches;
1200
- if (!touches || touches.length < 2 || !prevTouches || prevTouches.length < 2) return;
1201
-
1202
- const vs = viewStateRef.current;
1203
- const co = vs.canvasOffset;
1204
- const cz = vs.canvasZoom;
1205
-
1206
- // ピンチ距離計算
1207
- const prevDist = Math.hypot(
1208
- prevTouches[0].clientX - prevTouches[1].clientX,
1209
- prevTouches[0].clientY - prevTouches[1].clientY
1210
- );
1211
- const currDist = Math.hypot(
1212
- touches[0].clientX - touches[1].clientX,
1213
- touches[0].clientY - touches[1].clientY
1214
- );
1215
- if (prevDist === 0) return;
1216
-
1217
- const scale = currDist / prevDist;
1218
- const newZoom = Math.max(0.02, Math.min(2.0, cz * scale));
1219
-
1220
- const rect = container.getBoundingClientRect();
1221
- const centerX = (touches[0].clientX + touches[1].clientX) / 2 - rect.left;
1222
- const centerY = (touches[0].clientY + touches[1].clientY) / 2 - rect.top;
1223
-
1224
- setCanvasZoom(newZoom);
1225
- setCanvasOffset({
1226
- x: centerX - (centerX - co.x) * (newZoom / cz),
1227
- y: centerY - (centerY - co.y) * (newZoom / cz),
1228
- });
1229
- }
1230
-
1231
- // breadcrumb-select: パンくずクリックで要素選択
1232
- if (e.data?.type === 'breadcrumb-select' && e.data.elementId) {
1233
- for (const [, iframe] of iframeMapRef.current) {
1234
- if (e.source === iframe.contentWindow) {
1235
- const iframeDoc = iframe.contentDocument;
1236
- if (!iframeDoc) break;
1237
- const targetEl = iframeDoc.querySelector(`[data-element-id="${e.data.elementId}"]`) as HTMLElement;
1238
- if (targetEl) {
1239
- // 既存の選択を解除
1240
- const existingSelected = iframeDoc.querySelector('.selected');
1241
- if (existingSelected) existingSelected.classList.remove('selected');
1242
- removeSelectionBox(iframeDoc);
1243
- // 新しい要素を選択
1244
- targetEl.classList.add('selected');
1245
- updateSelectionBox(iframeDoc, targetEl);
1246
- // refs更新 + パネル更新
1247
- (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
1248
- refs.setIframeReady(true);
1249
- const info = extractElementInfo(targetEl, iframeDoc);
1250
- if (info) {
1251
- setSelectedElementRef.current(info);
1252
- setSelectedElementIdsRef.current([info.id]);
1253
- }
1254
- }
1255
- break;
1256
- }
1257
- }
1258
- }
1259
-
1260
- // 描画ツール/画像アップロード後のページフレーム同期
1261
- if (e.data?.type === 'DOM_TREE_UPDATED') {
1262
- const currentIframe = (refs.iframeRef as React.MutableRefObject<HTMLIFrameElement | null>).current;
1263
- if (currentIframe) {
1264
- for (const [pid, ifr] of iframeMapRef.current) {
1265
- if (ifr === currentIframe) {
1266
- const doc = ifr.contentDocument;
1267
- if (doc) {
1268
- const artboard = doc.getElementById('artboard');
1269
- if (artboard) {
1270
- updatePageFrameRef.current(pid, {
1271
- thumbnailHtml: artboard.innerHTML,
1272
- isDirty: true,
1273
- });
1274
- }
1275
- }
1276
- break;
1277
- }
1278
- }
1279
- }
1280
- }
1281
-
1282
- // コンテンツ高さ通知
1283
- if (e.data?.type === 'PAGE_CONTENT_HEIGHT') {
1284
- const { pageId, height } = e.data;
1285
- if (pageId && typeof height === 'number' && height > 0) {
1286
- const existingPage = pagesRef.current.find(p => p.id === pageId);
1287
- if (existingPage && Math.abs(existingPage.size.height - height) > 10) {
1288
- updatePageFrame(pageId, { size: { width: existingPage.size.width, height } });
1289
- }
1290
- }
1291
- }
1292
- };
1293
-
1294
- window.addEventListener('message', handleMessage);
1295
- return () => window.removeEventListener('message', handleMessage);
1296
- }, [setCanvasZoom, setCanvasOffset, updatePageFrame, refs]);
1297
-
1298
- // スペースキーでiframeのpointer-eventsを一時無効化(パンモード)
1299
- useEffect(() => {
1300
- const handleKeyDown = (e: KeyboardEvent) => {
1301
- if (e.code === 'Space' && !e.repeat) {
1302
- const container = containerRef.current;
1303
- if (!container) return;
1304
- container.querySelectorAll('iframe').forEach(iframe => {
1305
- (iframe as HTMLElement).style.pointerEvents = 'none';
1306
- });
1307
- }
1308
- };
238
+ void activatePage(id);
239
+ },
240
+ [activatePage],
241
+ );
242
+ const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
243
+ const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
1309
244
 
1310
- const handleKeyUp = (e: KeyboardEvent) => {
1311
- if (e.code === 'Space') {
1312
- const container = containerRef.current;
1313
- if (!container) return;
1314
- container.querySelectorAll('iframe').forEach(iframe => {
1315
- (iframe as HTMLElement).style.pointerEvents = '';
1316
- });
1317
- }
1318
- };
1319
-
1320
- window.addEventListener('keydown', handleKeyDown);
1321
- window.addEventListener('keyup', handleKeyUp);
1322
- return () => {
1323
- window.removeEventListener('keydown', handleKeyDown);
1324
- window.removeEventListener('keyup', handleKeyUp);
1325
- };
1326
- }, []);
245
+ const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
246
+ // 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
247
+ // 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
248
+ const labelsVisible = FRAME_GAP[canvas.editorMode].y * canvasZoom >= LABEL_HEIGHT + 6;
249
+ // data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
250
+ const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
251
+ const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
1327
252
 
1328
253
  return (
1329
254
  <div
1330
255
  ref={containerRef}
1331
256
  data-infinite-canvas="true"
1332
- className="absolute inset-0 overflow-hidden select-none overscroll-none"
1333
- style={{ backgroundColor: '#1a1a1a', touchAction: 'none', overscrollBehavior: 'none' }}
257
+ data-canvas-zoom={Math.round(canvasZoom * 100)}
258
+ data-interacting={isInteracting ? 'true' : undefined}
259
+ className="absolute inset-0 overflow-hidden select-none"
260
+ style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
261
+ onMouseDown={onMarqueeMouseDown}
1334
262
  >
1335
- {/* ドットグリッド背景 */}
1336
- <div
1337
- className="absolute inset-0 pointer-events-none"
1338
- style={{
1339
- backgroundImage: `radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px)`,
1340
- backgroundSize: `${24 * canvasZoom}px ${24 * canvasZoom}px`,
1341
- backgroundPosition: `${canvasOffset.x % (24 * canvasZoom)}px ${canvasOffset.y % (24 * canvasZoom)}px`,
1342
- }}
1343
- />
1344
-
1345
- {/* CSS transform 無限キャンバス */}
263
+ {/* 転写層 */}
1346
264
  <div
1347
- className="canvas-transform-layer"
265
+ data-canvas-transform-layer
266
+ className="absolute left-0 top-0"
1348
267
  style={{
1349
- transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`,
268
+ transform: layerTransform,
1350
269
  transformOrigin: '0 0',
1351
- willChange: 'transform',
270
+ willChange: isInteracting ? 'transform' : undefined,
1352
271
  }}
1353
272
  >
1354
- {/*
1355
- * イベントキャプチャ用オーバーレイ
1356
- * ズームやスクロール中にiframeをまたぐとイベントが途切れる問題を防ぐため、
1357
- * ホイールやタッチ操作中のみポインターイベントを横取りする
1358
- */}
1359
- <div
1360
- className="absolute inset-x-0 inset-y-0 z-50 transition-opacity duration-150"
1361
- style={{
1362
- pointerEvents: isInteracting ? 'auto' : 'none',
1363
- opacity: 0
1364
- }}
1365
- />
273
+ {pages.map((page) => (
274
+ <FrameShell
275
+ key={page.id}
276
+ page={page}
277
+ zoom={canvasZoom}
278
+ isActive={page.id === activePageId}
279
+ passThrough={passThrough}
280
+ rootRef={containerRef}
281
+ onMouseDown={handleFrameMouseDown}
282
+ onHover={handleHover}
283
+ />
284
+ ))}
1366
285
 
1367
- {/* 全ページ: 同時編集可能 */}
1368
- {pages.map(page => (
286
+ {/* 生きているエディタ(1 つだけ)。編集中のフレームの上に重ねる。
287
+ 新しい本文を読み込んでいる間は隠す(前のページの姿が新しい枠に見えない。
288
+ 下の見るだけの紙面が透けて、読み込みが済んだところで差し替わる) */}
289
+ {active && (
1369
290
  <div
1370
- key={page.id}
291
+ data-editor-frame={active.id}
292
+ data-editor-ready={iframeReady ? 'true' : 'false'}
1371
293
  className="absolute"
1372
294
  style={{
1373
- left: page.position.x,
1374
- top: page.position.y,
1375
- width: page.size.width,
1376
- height: page.size.height,
295
+ left: active.position.x,
296
+ top: active.position.y,
297
+ width: active.size.width,
298
+ height: active.size.height,
299
+ // 余白からのマーキー中は親が最後まで受ける(iframe に入ると mouseup が親に来ない)
300
+ pointerEvents: passThrough || marqueeArmed ? 'none' : 'auto',
301
+ visibility: iframeReady ? 'visible' : 'hidden',
1377
302
  }}
1378
303
  >
1379
- <PageLabel
1380
- title={page.title}
1381
- isDirty={page.isDirty}
1382
- />
1383
-
1384
- <div className="w-full h-full bg-white rounded-sm shadow-lg overflow-hidden">
1385
- <PageLivePreview
1386
- html={page.thumbnailHtml}
1387
- pageId={page.id}
1388
- onIframeLoad={handlePageIframeLoad}
1389
- />
1390
- </div>
304
+ <EditorCanvas />
1391
305
  </div>
1392
- ))}
306
+ )}
1393
307
  </div>
1394
308
 
1395
- {/* ズームコントロール */}
1396
- <CanvasZoomControls />
1397
-
1398
- {/* ページ数表示 */}
1399
- <div className="absolute top-4 left-4 z-10 text-xs text-gray-500 select-none">
1400
- {pages.length} pages
309
+ {/* 画面座標のオーバーレイ: フレーム名・リング(層ごと平行移動。倍率だけ各要素に渡す) */}
310
+ <div
311
+ className="pointer-events-none absolute left-0 top-0 z-10"
312
+ data-canvas-overlay
313
+ style={{ transform: overlayTransform, willChange: isInteracting ? 'transform' : undefined }}
314
+ >
315
+ {pages.map((page) => {
316
+ const isSelected = page.id === selectedId;
317
+ return (
318
+ <FrameChrome
319
+ key={page.id}
320
+ page={page}
321
+ zoom={canvasZoom}
322
+ labelsVisible={labelsVisible}
323
+ isSelected={isSelected}
324
+ isHover={hoverId === page.id && !isSelected && !passThrough}
325
+ isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}
326
+ onActivate={handleActivate}
327
+ onZoomTo={handleZoomTo}
328
+ />
329
+ );
330
+ })}
1401
331
  </div>
332
+
333
+ {/* 余白から引いたマーキー(画面座標) */}
334
+ {marqueeRect && (
335
+ <div
336
+ className="ed-canvas-marquee"
337
+ data-canvas-marquee
338
+ style={{ left: marqueeRect.left, top: marqueeRect.top, width: marqueeRect.width, height: marqueeRect.height }}
339
+ />
340
+ )}
341
+
342
+ {rulersVisible && <CanvasRulers theme={theme} />}
343
+
344
+ {pages.length === 0 && (
345
+ <div className="absolute inset-0 flex items-center justify-center" style={{ paddingTop: rulersVisible ? RULER_SIZE : 0 }}>
346
+ <div className="ed-empty">
347
+ <strong>ページがありません</strong>
348
+ <span>contentList にページを渡すと、ここに並びます。</span>
349
+ </div>
350
+ </div>
351
+ )}
1402
352
  </div>
1403
353
  );
1404
354
  });