@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.
- package/README.md +37 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +7 -3
- package/src/editor/FrontendVisualEditor.tsx +154 -54
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +93 -8
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorPropertyPanel.tsx +2 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
- package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/utils/dom-utils.ts +7 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +86 -11
- package/src/io.ts +20 -0
- package/src/styles/skin.css +66 -34
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
|
@@ -3,1402 +3,352 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* MultiPageCanvasView
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
import {
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from '
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
83
|
-
|
|
148
|
+
bounds,
|
|
149
|
+
registerContainer,
|
|
150
|
+
activatePage,
|
|
151
|
+
activatingPageId,
|
|
84
152
|
zoomToFit,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
104
|
-
const
|
|
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
|
-
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
registerContainer(containerRef.current);
|
|
176
|
+
return () => registerContainer(null);
|
|
177
|
+
}, [registerContainer]);
|
|
108
178
|
|
|
109
|
-
//
|
|
179
|
+
// エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
|
|
110
180
|
useEffect(() => {
|
|
111
181
|
setZoom(100);
|
|
112
182
|
setFitZoom(100);
|
|
113
183
|
}, [setZoom, setFitZoom]);
|
|
114
184
|
|
|
115
|
-
|
|
116
|
-
const interactingTimeoutRef = useRef<number | null>(null);
|
|
117
|
-
|
|
185
|
+
// Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける
|
|
118
186
|
useEffect(() => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
200
|
+
// 初回の表示: 前回の場所が保存されていて内容と重なるならそこへ。無ければ編集中のページを大きく
|
|
201
|
+
const initialViewDoneRef = useRef(false);
|
|
153
202
|
useEffect(() => {
|
|
154
|
-
if (
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
265
|
+
data-canvas-transform-layer
|
|
266
|
+
className="absolute left-0 top-0"
|
|
1348
267
|
style={{
|
|
1349
|
-
transform:
|
|
268
|
+
transform: layerTransform,
|
|
1350
269
|
transformOrigin: '0 0',
|
|
1351
|
-
willChange: 'transform',
|
|
270
|
+
willChange: isInteracting ? 'transform' : undefined,
|
|
1352
271
|
}}
|
|
1353
272
|
>
|
|
1354
|
-
{
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
286
|
+
{/* 生きているエディタ(1 つだけ)。編集中のフレームの上に重ねる。
|
|
287
|
+
新しい本文を読み込んでいる間は隠す(前のページの姿が新しい枠に見えない。
|
|
288
|
+
下の見るだけの紙面が透けて、読み込みが済んだところで差し替わる) */}
|
|
289
|
+
{active && (
|
|
1369
290
|
<div
|
|
1370
|
-
|
|
291
|
+
data-editor-frame={active.id}
|
|
292
|
+
data-editor-ready={iframeReady ? 'true' : 'false'}
|
|
1371
293
|
className="absolute"
|
|
1372
294
|
style={{
|
|
1373
|
-
left:
|
|
1374
|
-
top:
|
|
1375
|
-
width:
|
|
1376
|
-
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
|
-
<
|
|
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
|
-
<
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
});
|