@growgroup/visual-editor 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +41 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +15 -3
  5. package/src/editor/FrontendVisualEditor.tsx +168 -55
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +107 -8
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorHeader.tsx +47 -12
  10. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  11. package/src/editor/components/EditorPropertyPanel.tsx +56 -25
  12. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  13. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
  14. package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
  15. package/src/editor/components/multi-page/index.ts +2 -6
  16. package/src/editor/components/ppt/PptComments.tsx +8 -4
  17. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  19. package/src/editor/constants.ts +28 -0
  20. package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
  21. package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
  23. package/src/editor/hooks/useCanvasControls.ts +1 -1
  24. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  25. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  26. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  27. package/src/editor/hooks/useDrawingMode.ts +1 -0
  28. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  29. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  30. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  31. package/src/editor/utils/dom-utils.ts +23 -1
  32. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  33. package/src/editor/utils/html-utils.ts +102 -14
  34. package/src/index.ts +5 -0
  35. package/src/io.ts +20 -0
  36. package/src/styles/skin.css +122 -36
  37. package/src/version.ts +9 -0
  38. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  39. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  40. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  41. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  42. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  43. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -3,1402 +3,402 @@
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
+ import { ArrowUpRight } from 'lucide-react';
43
+
44
+ /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
45
+ const LABEL_HEIGHT = 20;
46
+
47
+ // ------------------------------------------------------------
48
+ // フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
49
+ // ------------------------------------------------------------
50
+
51
+ interface FrameShellProps {
52
+ page: PageFrameLayout;
53
+ zoom: number;
54
+ isActive: boolean;
55
+ passThrough: boolean;
56
+ rootRef: React.RefObject<HTMLDivElement | null>;
57
+ onMouseDown: (e: React.MouseEvent, id: string) => void;
58
+ onHover: (id: string | null) => void;
59
+ }
60
+
61
+ const FrameShell = memo(function FrameShell({ page, zoom, isActive, passThrough, rootRef, onMouseDown, onHover }: FrameShellProps) {
62
+ return (
63
+ <div
64
+ data-page-frame={page.id}
65
+ data-page-active={isActive ? 'true' : undefined}
66
+ className="absolute"
67
+ style={{
68
+ left: page.position.x,
69
+ top: page.position.y,
70
+ width: page.size.width,
71
+ height: page.size.height,
72
+ background: '#fff',
73
+ boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
74
+ cursor: isActive || passThrough ? undefined : 'pointer',
75
+ }}
76
+ onMouseDown={(e) => onMouseDown(e, page.id)}
77
+ onMouseEnter={() => onHover(page.id)}
78
+ onMouseLeave={() => onHover(null)}
79
+ >
80
+ <PageFramePreview page={page} rootRef={rootRef} />
81
+ </div>
82
+ );
83
+ });
84
+
85
+ // ------------------------------------------------------------
86
+ // フレーム名・リング(オーバーレイの中。画面の座標 = 紙面の位置 × 倍率)
87
+ // ------------------------------------------------------------
88
+
89
+ interface FrameChromeProps {
90
+ page: PageFrameLayout;
91
+ zoom: number;
92
+ /** 名前を出せる倍率か(行の隙間に名前が収まる)。全フレーム共通なので親で判定する */
93
+ labelsVisible: boolean;
94
+ /** リングと名前の強調(切替中はクリックしたページへ先に移る) */
95
+ isSelected: boolean;
96
+ isHover: boolean;
97
+ /** 本文を読んでいる・エディタを組み直している */
98
+ isBusy: boolean;
99
+ onActivate: (id: string) => void;
100
+ onZoomTo: (id: string) => void;
101
+ }
102
+
103
+ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
104
+ const sx = page.position.x * zoom;
105
+ const sy = page.position.y * zoom;
106
+ const sw = page.size.width * zoom;
107
+ const sh = page.size.height * zoom;
108
+ // 縮小してフレームが細くなったら名前は幅に収める(重ならない)。極端に小さければ出さない
109
+ const showLabel = labelsVisible && sw >= 28;
110
+ return (
111
+ <div className="absolute left-0 top-0" data-frame-chrome={page.id} style={{ transform: `translate(${sx}px, ${sy}px)` }}>
112
+ {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
113
+ {showLabel && (
114
+ <div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}>
115
+ <button
116
+ type="button"
117
+ data-frame-label={page.id}
118
+ className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
119
+ title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
120
+ onMouseDown={(e) => e.stopPropagation()}
121
+ onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
122
+ onDoubleClick={() => onZoomTo(page.id)}
123
+ >
124
+ <span className="ed-frame-label-index">{page.index + 1}</span>
125
+ <span className="ed-frame-label-title">{page.title || page.id}</span>
126
+ {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
127
+ {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
128
+ </button>
129
+ {page.href && sw >= 96 && (
130
+ <a
131
+ className="ed-frame-label-link"
132
+ data-frame-link={page.id}
133
+ href={page.href}
134
+ target="_blank"
135
+ rel="noreferrer"
136
+ title="別タブで開く(編集しない表示)"
137
+ aria-label={`${page.title || page.id} を別タブで開く`}
138
+ onMouseDown={(e) => e.stopPropagation()}
139
+ onClick={(e) => e.stopPropagation()}
140
+ >
141
+ <ArrowUpRight aria-hidden="true" />
142
+ </a>
143
+ )}
144
+ </div>
145
+ )}
146
+ {/* リング(選択中 = 実線、ホバー = 淡い実線) */}
147
+ {(isSelected || isHover) && (
148
+ <div
149
+ className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
150
+ style={{ width: sw, height: sh }}
151
+ />
152
+ )}
153
+ </div>
154
+ );
155
+ });
156
+
157
+ // ------------------------------------------------------------
158
+ // 階層の線(親の下端の中央 → 子の上端の中央。転写層の中、紙面の座標)
159
+ // ------------------------------------------------------------
160
+
161
+ const TreeConnectors = memo(function TreeConnectors({ pages, gapY }: { pages: PageFrameLayout[]; gapY: number }) {
162
+ const byId = useMemo(() => new Map(pages.map((p) => [p.id, p] as const)), [pages]);
163
+ const d = useMemo(() => {
164
+ const parts: string[] = [];
165
+ pages.forEach((child) => {
166
+ const parent = child.parentId ? byId.get(child.parentId) : undefined;
167
+ if (!parent) return;
168
+ const x1 = parent.position.x + parent.size.width / 2;
169
+ const y1 = parent.position.y + parent.size.height;
170
+ const x2 = child.position.x + child.size.width / 2;
171
+ const y2 = child.position.y;
172
+ // 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
173
+ const ym = y2 - gapY / 2;
174
+ parts.push(`M${x1} ${y1} V${ym} H${x2} V${y2}`);
175
+ });
176
+ return parts.join(' ');
177
+ }, [pages, byId, gapY]);
178
+ if (!d) return null;
179
+ return (
180
+ <svg className="ed-frame-connectors" data-canvas-connectors aria-hidden="true" width={1} height={1} style={{ overflow: 'visible' }}>
181
+ <path d={d} fill="none" vectorEffect="non-scaling-stroke" />
182
+ </svg>
183
+ );
184
+ });
38
185
 
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
- `;
186
+ // ------------------------------------------------------------
187
+ // キャンバス
188
+ // ------------------------------------------------------------
77
189
 
78
190
  export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
191
+ const canvas = useMultiPageCanvas();
79
192
  const {
80
- viewState,
81
193
  pages,
82
- updatePageFrame,
83
- focusPage,
194
+ bounds,
195
+ layout,
196
+ registerContainer,
197
+ activatePage,
198
+ activatingPageId,
84
199
  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;
200
+ zoomToPage,
201
+ zoomToActual,
202
+ rulersVisible,
203
+ isInteracting,
204
+ initialViewRestored,
205
+ } = canvas;
206
+ const { canvasOffset, canvasZoom, activePageId } = useCanvasViewState();
207
+ const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
208
+ const theme = useContext(EditorAppearanceContext) ?? 'light';
101
209
  const containerRef = useRef<HTMLDivElement>(null);
210
+ const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
211
+ // リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
212
+ const selectedId = activatingPageId ?? activePageId;
102
213
 
103
- // 各ページのiframe参照を保持
104
- const iframeMapRef = useRef(new Map<string, HTMLIFrameElement>());
214
+ // 無限キャンバスのズーム/パン操作(容器と、iframe から転送される操作の両方)
215
+ const { isSpaceHeld, isPanning } = useInfiniteCanvas(containerRef);
216
+ // キャンバスの余白から始めるマーキー選択(選択ツールのときだけ。Space・手のひら中は動かさない)
217
+ const { marqueeRect, marqueeArmed, onMouseDown: onMarqueeMouseDown } = useCanvasMarquee(containerRef, {
218
+ enabled: activeTool === 'select' && !isSpaceHeld && !isPanning,
219
+ });
105
220
 
106
- // 無限キャンバスのズーム/パン操作
107
- useInfiniteCanvas(containerRef);
221
+ useEffect(() => {
222
+ registerContainer(containerRef.current);
223
+ return () => registerContainer(null);
224
+ }, [registerContainer]);
108
225
 
109
- // エディタズームを100%に固定(キャンバスズームが全体スケーリングを担う)
226
+ // エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
110
227
  useEffect(() => {
111
228
  setZoom(100);
112
229
  setFitZoom(100);
113
230
  }, [setZoom, setFitZoom]);
114
231
 
115
- const [isInteracting, setIsInteracting] = useState(false);
116
- const interactingTimeoutRef = useRef<number | null>(null);
117
-
232
+ // Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける
118
233
  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);
234
+ editorZoomApiRef.current = {
235
+ fit: () => zoomToFit({ animate: true }),
236
+ actual: () => zoomToActual(),
237
+ selection: () => {
238
+ if (activePageId) zoomToPage(activePageId, { animate: true });
239
+ else zoomToFit({ animate: true });
240
+ },
135
241
  };
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
242
  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);
243
+ editorZoomApiRef.current = null;
148
244
  };
149
- }, []);
245
+ }, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
150
246
 
151
- // 初回フィットズーム
152
- const hasInitialFitRef = useRef(false);
247
+ // 初回の表示: 前回の場所が保存されていて内容と重なるならそこへ。無ければ編集中のページを大きく
248
+ const initialViewDoneRef = useRef(false);
153
249
  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;
210
- }
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);
232
- }, []);
233
-
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;
250
+ if (initialViewDoneRef.current || pages.length === 0) return;
251
+ const el = containerRef.current;
252
+ if (!el || el.clientWidth === 0) return;
253
+ initialViewDoneRef.current = true;
254
+ if (initialViewRestored) {
255
+ const { canvasOffset: o, canvasZoom: z } = canvas.viewStore.get();
256
+ const left = o.x + bounds.minX * z;
257
+ const top = o.y + bounds.minY * z;
258
+ const right = o.x + bounds.maxX * z;
259
+ const bottom = o.y + bounds.maxY * z;
260
+ const intersects = right > 0 && left < el.clientWidth && bottom > 0 && top < el.clientHeight;
261
+ if (intersects) return;
246
262
  }
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);
263
+ if (activePageId) zoomToPage(activePageId);
264
+ else zoomToFit();
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
266
+ }, [pages.length, activePageId]);
267
+
268
+ const [hoverId, setHoverId] = useState<string | null>(null);
269
+ const handleHover = useCallback((id: string | null) => {
270
+ setHoverId((prev) => (id === null ? null : id === prev ? prev : id));
265
271
  }, []);
266
272
 
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) => {
1023
- 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
-
273
+ // Space・中ボタン・手のひらツールの間は、iframe に触らせず容器がパンを受ける
274
+ const passThrough = isSpaceHeld || isPanning || activeTool === 'move';
275
+ const passThroughRef = useRef(passThrough);
276
+ passThroughRef.current = passThrough;
277
+ const activePageIdRef = useRef(activePageId);
278
+ activePageIdRef.current = activePageId;
279
+
280
+ const handleFrameMouseDown = useCallback(
281
+ (e: React.MouseEvent, id: string) => {
282
+ if (e.button !== 0 || passThroughRef.current) return;
283
+ if (id === activePageIdRef.current) return;
1081
284
  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
- };
1309
-
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
- };
285
+ void activatePage(id);
286
+ },
287
+ [activatePage],
288
+ );
289
+ const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
290
+ const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
1319
291
 
1320
- window.addEventListener('keydown', handleKeyDown);
1321
- window.addEventListener('keyup', handleKeyUp);
1322
- return () => {
1323
- window.removeEventListener('keydown', handleKeyDown);
1324
- window.removeEventListener('keyup', handleKeyUp);
1325
- };
1326
- }, []);
292
+ const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
293
+ // 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
294
+ // 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
295
+ const labelsVisible = FRAME_GAP[canvas.editorMode].y * canvasZoom >= LABEL_HEIGHT + 6;
296
+ // data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
297
+ const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
298
+ const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
1327
299
 
1328
300
  return (
1329
301
  <div
1330
302
  ref={containerRef}
1331
303
  data-infinite-canvas="true"
1332
- className="absolute inset-0 overflow-hidden select-none overscroll-none"
1333
- style={{ backgroundColor: '#1a1a1a', touchAction: 'none', overscrollBehavior: 'none' }}
304
+ data-canvas-zoom={Math.round(canvasZoom * 100)}
305
+ data-interacting={isInteracting ? 'true' : undefined}
306
+ className="absolute inset-0 overflow-hidden select-none"
307
+ style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
308
+ onMouseDown={onMarqueeMouseDown}
1334
309
  >
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 無限キャンバス */}
310
+ {/* 転写層 */}
1346
311
  <div
1347
- className="canvas-transform-layer"
312
+ data-canvas-transform-layer
313
+ className="absolute left-0 top-0"
1348
314
  style={{
1349
- transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`,
315
+ transform: layerTransform,
1350
316
  transformOrigin: '0 0',
1351
- willChange: 'transform',
317
+ willChange: isInteracting ? 'transform' : undefined,
1352
318
  }}
1353
319
  >
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
- />
320
+ {/* 階層の線(フレームの下に描く。線の太さは倍率に関係なく一定 = non-scaling-stroke) */}
321
+ {layout === 'tree' && <TreeConnectors pages={pages} gapY={FRAME_GAP[canvas.editorMode].y} />}
1366
322
 
1367
- {/* 全ページ: 同時編集可能 */}
1368
- {pages.map(page => (
1369
- <div
323
+ {pages.map((page) => (
324
+ <FrameShell
1370
325
  key={page.id}
326
+ page={page}
327
+ zoom={canvasZoom}
328
+ isActive={page.id === activePageId}
329
+ passThrough={passThrough}
330
+ rootRef={containerRef}
331
+ onMouseDown={handleFrameMouseDown}
332
+ onHover={handleHover}
333
+ />
334
+ ))}
335
+
336
+ {/* 生きているエディタ(1 つだけ)。編集中のフレームの上に重ねる。
337
+ 新しい本文を読み込んでいる間は隠す(前のページの姿が新しい枠に見えない。
338
+ 下の見るだけの紙面が透けて、読み込みが済んだところで差し替わる) */}
339
+ {active && (
340
+ <div
341
+ data-editor-frame={active.id}
342
+ data-editor-ready={iframeReady ? 'true' : 'false'}
1371
343
  className="absolute"
1372
344
  style={{
1373
- left: page.position.x,
1374
- top: page.position.y,
1375
- width: page.size.width,
1376
- height: page.size.height,
345
+ left: active.position.x,
346
+ top: active.position.y,
347
+ width: active.size.width,
348
+ height: active.size.height,
349
+ // 余白からのマーキー中は親が最後まで受ける(iframe に入ると mouseup が親に来ない)
350
+ pointerEvents: passThrough || marqueeArmed ? 'none' : 'auto',
351
+ visibility: iframeReady ? 'visible' : 'hidden',
1377
352
  }}
1378
353
  >
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>
354
+ <EditorCanvas />
1391
355
  </div>
1392
- ))}
356
+ )}
1393
357
  </div>
1394
358
 
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
359
+ {/* 画面座標のオーバーレイ: フレーム名・リング(層ごと平行移動。倍率だけ各要素に渡す) */}
360
+ <div
361
+ className="pointer-events-none absolute left-0 top-0 z-10"
362
+ data-canvas-overlay
363
+ style={{ transform: overlayTransform, willChange: isInteracting ? 'transform' : undefined }}
364
+ >
365
+ {pages.map((page) => {
366
+ const isSelected = page.id === selectedId;
367
+ return (
368
+ <FrameChrome
369
+ key={page.id}
370
+ page={page}
371
+ zoom={canvasZoom}
372
+ labelsVisible={labelsVisible}
373
+ isSelected={isSelected}
374
+ isHover={hoverId === page.id && !isSelected && !passThrough}
375
+ isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}
376
+ onActivate={handleActivate}
377
+ onZoomTo={handleZoomTo}
378
+ />
379
+ );
380
+ })}
1401
381
  </div>
382
+
383
+ {/* 余白から引いたマーキー(画面座標) */}
384
+ {marqueeRect && (
385
+ <div
386
+ className="ed-canvas-marquee"
387
+ data-canvas-marquee
388
+ style={{ left: marqueeRect.left, top: marqueeRect.top, width: marqueeRect.width, height: marqueeRect.height }}
389
+ />
390
+ )}
391
+
392
+ {rulersVisible && <CanvasRulers theme={theme} />}
393
+
394
+ {pages.length === 0 && (
395
+ <div className="absolute inset-0 flex items-center justify-center" style={{ paddingTop: rulersVisible ? RULER_SIZE : 0 }}>
396
+ <div className="ed-empty">
397
+ <strong>ページがありません</strong>
398
+ <span>contentList にページを渡すと、ここに並びます。</span>
399
+ </div>
400
+ </div>
401
+ )}
1402
402
  </div>
1403
403
  );
1404
404
  });