@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.
- package/README.md +41 -1
- package/dist/editor.css +1 -1
- package/package.json +3 -2
- package/src/editor/EditorContext.tsx +15 -3
- package/src/editor/FrontendVisualEditor.tsx +168 -55
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +107 -8
- package/src/editor/components/EditorContextMenu.tsx +12 -3
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorLayerPanel.tsx +28 -2
- package/src/editor/components/EditorPropertyPanel.tsx +56 -25
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +197 -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/constants.ts +28 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
- package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
- 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 +23 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +102 -14
- package/src/index.ts +5 -0
- package/src/io.ts +20 -0
- package/src/styles/skin.css +122 -36
- package/src/version.ts +9 -0
- 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,299 +3,307 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* useInfiniteCanvas
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
6
|
+
* マルチフレームキャンバスのズーム/パン操作。
|
|
7
|
+
* - ホイール: パン(2 本指スクロール)。Shift で横
|
|
8
|
+
* - Ctrl/Cmd + ホイール、トラックパッドのピンチ: カーソル位置を固定してズーム
|
|
9
|
+
* - Space + ドラッグ / 中ボタンドラッグ: パン
|
|
10
|
+
* - Cmd/Ctrl + / - : 段階ズーム。Shift+0 = 100%、Shift+1 = 全体、Shift+2 = 編集中のページ、Shift+R = 定規
|
|
11
|
+
* (Cmd+0 / 1 / 2 は既存のディスパッチャ(editorZoomApiRef)が受ける)
|
|
12
|
+
* - 2 本指タッチ: ピンチズーム / 1 本指: パン
|
|
13
|
+
*
|
|
14
|
+
* [iframe から来る操作]
|
|
15
|
+
* 生きているエディタ(EditorCanvas)と見るだけの紙面は iframe なので、その上で起きた
|
|
16
|
+
* ホイール・キー・中ボタンは親へ届かない。iframe 側が postMessage で転送し
|
|
17
|
+
* (EMBEDDED_WHEEL / EMBEDDED_PINCH / EMBEDDED_KEY / EMBEDDED_PAN_START)、
|
|
18
|
+
* ここで **同じ処理** に流す。座標は iframe の座標で届くので、
|
|
19
|
+
* 送り元の iframe 要素の実測矩形と倍率で親の座標へ直す
|
|
12
20
|
*/
|
|
13
21
|
|
|
14
|
-
import { useEffect, useRef, type RefObject } from 'react';
|
|
15
|
-
import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
|
|
22
|
+
import { useEffect, useRef, useState, type RefObject } from 'react';
|
|
23
|
+
import { useMultiPageCanvasOptional, MIN_ZOOM, MAX_ZOOM } from '../contexts/MultiPageCanvasContext';
|
|
24
|
+
import { isSpaceActivatedControl } from './useCanvasControls';
|
|
25
|
+
|
|
26
|
+
/** iframe 側が転送してくるメッセージ */
|
|
27
|
+
export type EmbeddedCanvasMessage =
|
|
28
|
+
| { type: 'EMBEDDED_WHEEL'; coords?: 'parent'; deltaX: number; deltaY: number; clientX: number; clientY: number; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }
|
|
29
|
+
| { type: 'EMBEDDED_PINCH'; coords?: 'parent'; previousDistance: number; currentDistance: number; centerX: number; centerY: number }
|
|
30
|
+
| { type: 'EMBEDDED_PAN_START'; coords?: 'parent'; clientX: number; clientY: number }
|
|
31
|
+
| { type: 'EMBEDDED_KEY'; kind: 'down' | 'up'; key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean };
|
|
32
|
+
|
|
33
|
+
/** ホイール 1 目盛りのズーム係数。トラックパッド(小さい delta が連続)と、マウスホイール(大きい delta)で分ける */
|
|
34
|
+
const PINCH_K = 0.01;
|
|
35
|
+
const WHEEL_K = 0.0025;
|
|
36
|
+
const MAX_STEP = 1.25;
|
|
37
|
+
const KEY_STEP = 1.25;
|
|
38
|
+
|
|
39
|
+
function wheelFactor(deltaY: number): number {
|
|
40
|
+
const k = Math.abs(deltaY) < 50 ? PINCH_K : WHEEL_K;
|
|
41
|
+
return Math.max(1 / MAX_STEP, Math.min(MAX_STEP, Math.exp(-deltaY * k)));
|
|
42
|
+
}
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
const
|
|
44
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
45
|
+
const el = target as HTMLElement | null;
|
|
46
|
+
if (!el || typeof el.closest !== 'function') return false;
|
|
47
|
+
return !!el.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]');
|
|
48
|
+
}
|
|
19
49
|
|
|
20
|
-
export function useInfiniteCanvas(
|
|
21
|
-
containerRef: RefObject<HTMLDivElement | null>
|
|
22
|
-
) {
|
|
50
|
+
export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>) {
|
|
23
51
|
const canvas = useMultiPageCanvasOptional();
|
|
24
|
-
const
|
|
25
|
-
|
|
52
|
+
const enabled = !!canvas?.isEnabled;
|
|
53
|
+
|
|
54
|
+
const [isSpaceHeld, setIsSpaceHeld] = useState(false);
|
|
55
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
56
|
+
const spaceRef = useRef(false);
|
|
57
|
+
const panningRef = useRef(false);
|
|
26
58
|
const lastPanPosRef = useRef({ x: 0, y: 0 });
|
|
27
59
|
|
|
28
60
|
// Stale closure回避: 頻繁に変わる値はrefで保持
|
|
29
61
|
const canvasRef = useRef(canvas);
|
|
30
62
|
canvasRef.current = canvas;
|
|
31
63
|
|
|
32
|
-
// ホイールイベント: ズームとパン(refベースで effect churn を回避)
|
|
33
64
|
useEffect(() => {
|
|
34
|
-
if (!
|
|
65
|
+
if (!enabled) return;
|
|
35
66
|
const container = containerRef.current;
|
|
36
67
|
if (!container) return;
|
|
37
68
|
|
|
38
|
-
const
|
|
39
|
-
|
|
69
|
+
const c = () => canvasRef.current!;
|
|
70
|
+
const toContainerPoint = (clientX: number, clientY: number) => {
|
|
71
|
+
const rect = container.getBoundingClientRect();
|
|
72
|
+
return { x: clientX - rect.left, y: clientY - rect.top };
|
|
73
|
+
};
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 送り元 iframe の座標を親の client 座標へ(外側の transform の倍率を実測で掛ける)。
|
|
77
|
+
* 送り手が親の座標に直して送ってきた(coords: 'parent')ならそのまま
|
|
78
|
+
*/
|
|
79
|
+
const fromIframe = (source: MessageEventSource | null, x: number, y: number, coords?: 'parent') => {
|
|
80
|
+
if (coords === 'parent') return { clientX: x, clientY: y };
|
|
81
|
+
if (!source) return null;
|
|
82
|
+
const iframes = Array.from(container.querySelectorAll('iframe'));
|
|
83
|
+
const iframe = iframes.find((f) => f.contentWindow === source);
|
|
84
|
+
if (!iframe) return null;
|
|
85
|
+
const rect = iframe.getBoundingClientRect();
|
|
86
|
+
const scale = rect.width / (iframe.clientWidth || rect.width) || 1;
|
|
87
|
+
return { clientX: rect.left + x * scale, clientY: rect.top + y * scale };
|
|
88
|
+
};
|
|
44
89
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
90
|
+
// ---- ズーム/パンの実処理(ホイールと転送メッセージで共通)
|
|
91
|
+
const applyWheel = (deltaX: number, deltaY: number, clientX: number, clientY: number, zoomGesture: boolean, shiftKey: boolean) => {
|
|
92
|
+
c().markInteracting();
|
|
93
|
+
if (zoomGesture) {
|
|
94
|
+
const { canvasZoom } = c().viewStore.get();
|
|
95
|
+
c().zoomAt(canvasZoom * wheelFactor(deltaY), toContainerPoint(clientX, clientY));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { canvasOffset } = c().viewStore.get();
|
|
99
|
+
// Shift + 縦ホイール = 横パン(マウス向け。トラックパッドは deltaX が来る)
|
|
100
|
+
const dx = shiftKey && deltaX === 0 ? deltaY : deltaX;
|
|
101
|
+
const dy = shiftKey && deltaX === 0 ? 0 : deltaY;
|
|
102
|
+
c().setCanvasOffset({ x: canvasOffset.x - dx, y: canvasOffset.y - dy });
|
|
103
|
+
};
|
|
50
104
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
105
|
+
const applyPinch = (previousDistance: number, currentDistance: number, clientX: number, clientY: number) => {
|
|
106
|
+
if (!previousDistance || !currentDistance) return;
|
|
107
|
+
c().markInteracting();
|
|
108
|
+
const factor = Math.max(1 / MAX_STEP, Math.min(MAX_STEP, currentDistance / previousDistance));
|
|
109
|
+
c().zoomAt(c().viewStore.get().canvasZoom * factor, toContainerPoint(clientX, clientY));
|
|
110
|
+
};
|
|
55
111
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
112
|
+
const startPan = (clientX: number, clientY: number) => {
|
|
113
|
+
panningRef.current = true;
|
|
114
|
+
setIsPanning(true);
|
|
115
|
+
lastPanPosRef.current = { x: clientX, y: clientY };
|
|
116
|
+
container.style.cursor = 'grabbing';
|
|
117
|
+
c().markInteracting();
|
|
118
|
+
};
|
|
61
119
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
c.setCanvasOffset({
|
|
67
|
-
x: canvasOffset.x - e.deltaX,
|
|
68
|
-
y: canvasOffset.y - e.deltaY,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
120
|
+
// ---- ホイール(容器の上で起きたもの)
|
|
121
|
+
const handleWheel = (e: WheelEvent) => {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
applyWheel(e.deltaX, e.deltaY, e.clientX, e.clientY, e.ctrlKey || e.metaKey, e.shiftKey);
|
|
71
124
|
};
|
|
72
125
|
|
|
126
|
+
// ブラウザ自体の拡大を殺す(どこから来ても)
|
|
73
127
|
const preventNativeZoom = (e: WheelEvent) => {
|
|
74
|
-
if (e.ctrlKey || e.metaKey)
|
|
75
|
-
e.preventDefault();
|
|
76
|
-
}
|
|
128
|
+
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
|
77
129
|
};
|
|
78
|
-
|
|
79
|
-
// イベントがどこから来てもブラウザズームをルートで殺す
|
|
80
|
-
window.addEventListener('wheel', preventNativeZoom, { passive: false, capture: true });
|
|
81
|
-
document.addEventListener('wheel', preventNativeZoom, { passive: false, capture: true });
|
|
82
|
-
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
83
|
-
|
|
84
|
-
return () => {
|
|
85
|
-
window.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
86
|
-
document.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
87
|
-
container.removeEventListener('wheel', handleWheel);
|
|
88
|
-
};
|
|
89
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
-
}, [canvas?.isEnabled, containerRef]);
|
|
91
130
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
131
|
+
// ---- キー
|
|
132
|
+
const stepZoom = (dir: 1 | -1) => c().zoomTo(c().viewStore.get().canvasZoom * (dir > 0 ? KEY_STEP : 1 / KEY_STEP), { animate: true });
|
|
133
|
+
const handleKey = (kind: 'down' | 'up', key: string, code: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }, preventDefault: () => void, typing: boolean) => {
|
|
134
|
+
if (code === 'Space') {
|
|
135
|
+
if (typing) return;
|
|
136
|
+
if (kind === 'down') {
|
|
137
|
+
if (!spaceRef.current) {
|
|
138
|
+
spaceRef.current = true;
|
|
139
|
+
setIsSpaceHeld(true);
|
|
140
|
+
container.style.cursor = 'grab';
|
|
141
|
+
}
|
|
142
|
+
preventDefault();
|
|
143
|
+
} else {
|
|
144
|
+
spaceRef.current = false;
|
|
145
|
+
setIsSpaceHeld(false);
|
|
146
|
+
if (!panningRef.current) container.style.cursor = '';
|
|
103
147
|
}
|
|
148
|
+
return;
|
|
104
149
|
}
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
150
|
+
if (kind !== 'down' || typing) return;
|
|
151
|
+
const cmd = mods.metaKey || mods.ctrlKey;
|
|
152
|
+
if (cmd && (key === '=' || key === '+' || key === ';' )) { preventDefault(); stepZoom(1); return; }
|
|
153
|
+
if (cmd && (key === '-' || key === '_')) { preventDefault(); stepZoom(-1); return; }
|
|
154
|
+
if (!cmd && mods.shiftKey && !mods.altKey) {
|
|
155
|
+
if (key === '0' || code === 'Digit0') { preventDefault(); c().zoomToActual(); return; }
|
|
156
|
+
if (key === '1' || code === 'Digit1') { preventDefault(); c().zoomToFit({ animate: true }); return; }
|
|
157
|
+
if (key === '2' || code === 'Digit2') {
|
|
158
|
+
const id = c().viewStore.get().activePageId;
|
|
159
|
+
if (id) { preventDefault(); c().zoomToPage(id, { animate: true }); }
|
|
160
|
+
return;
|
|
116
161
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// Ctrl/Cmd + +: ズームイン
|
|
120
|
-
if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) {
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
c.zoomIn();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Ctrl/Cmd + -: ズームアウト
|
|
126
|
-
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
|
127
|
-
e.preventDefault();
|
|
128
|
-
c.zoomOut();
|
|
162
|
+
if (key.toLowerCase() === 'r' || code === 'KeyR') { preventDefault(); c().toggleRulers(); return; }
|
|
129
163
|
}
|
|
130
164
|
};
|
|
131
165
|
|
|
166
|
+
// ボタンにフォーカスがあるときの Space はそのボタンのもの(キーボードだけで操作している人が押せなくなる)
|
|
167
|
+
const spaceBelongsToControl = (e: KeyboardEvent) =>
|
|
168
|
+
e.code === 'Space' && (isSpaceActivatedControl(e.target) || isSpaceActivatedControl(document.activeElement));
|
|
169
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
170
|
+
if (spaceBelongsToControl(e)) return;
|
|
171
|
+
if (e.repeat && e.code === 'Space') { if (!isTypingTarget(e.target)) e.preventDefault(); return; }
|
|
172
|
+
handleKey('down', e.key, e.code, e, () => e.preventDefault(), isTypingTarget(e.target));
|
|
173
|
+
};
|
|
132
174
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
133
|
-
if (e
|
|
134
|
-
|
|
135
|
-
isPanningRef.current = false;
|
|
136
|
-
const container = containerRef.current;
|
|
137
|
-
if (container) {
|
|
138
|
-
container.style.cursor = '';
|
|
139
|
-
}
|
|
140
|
-
}
|
|
175
|
+
if (spaceBelongsToControl(e) && !spaceRef.current) return;
|
|
176
|
+
handleKey('up', e.key, e.code, e, () => e.preventDefault(), isTypingTarget(e.target));
|
|
141
177
|
};
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
// ウィンドウのフォーカスが外れたら Space を離したことにする(押しっぱなし状態が残らない)
|
|
179
|
+
const handleBlur = () => {
|
|
180
|
+
spaceRef.current = false;
|
|
181
|
+
setIsSpaceHeld(false);
|
|
182
|
+
panningRef.current = false;
|
|
183
|
+
setIsPanning(false);
|
|
184
|
+
container.style.cursor = '';
|
|
148
185
|
};
|
|
149
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
|
-
}, [canvas?.isEnabled, containerRef]);
|
|
151
|
-
|
|
152
|
-
// スペース+マウスドラッグでパン
|
|
153
|
-
useEffect(() => {
|
|
154
|
-
if (!canvas?.isEnabled) return;
|
|
155
|
-
const container = containerRef.current;
|
|
156
|
-
if (!container) return;
|
|
157
186
|
|
|
187
|
+
// ---- マウス(Space または中ボタンでパン)
|
|
158
188
|
const handleMouseDown = (e: MouseEvent) => {
|
|
159
|
-
|
|
160
|
-
if (isSpaceHeldRef.current || e.button === 1) {
|
|
189
|
+
if (spaceRef.current || e.button === 1) {
|
|
161
190
|
e.preventDefault();
|
|
162
|
-
|
|
163
|
-
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
|
164
|
-
container.style.cursor = 'grabbing';
|
|
191
|
+
startPan(e.clientX, e.clientY);
|
|
165
192
|
}
|
|
166
193
|
};
|
|
167
|
-
|
|
168
194
|
const handleMouseMove = (e: MouseEvent) => {
|
|
169
|
-
if (!
|
|
195
|
+
if (!panningRef.current) return;
|
|
170
196
|
e.preventDefault();
|
|
171
|
-
|
|
172
197
|
const dx = e.clientX - lastPanPosRef.current.x;
|
|
173
198
|
const dy = e.clientY - lastPanPosRef.current.y;
|
|
174
199
|
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const { canvasOffset } = c.viewState;
|
|
179
|
-
c.setCanvasOffset({
|
|
180
|
-
x: canvasOffset.x + dx,
|
|
181
|
-
y: canvasOffset.y + dy,
|
|
182
|
-
});
|
|
200
|
+
const { canvasOffset } = c().viewStore.get();
|
|
201
|
+
c().setCanvasOffset({ x: canvasOffset.x + dx, y: canvasOffset.y + dy });
|
|
202
|
+
c().markInteracting();
|
|
183
203
|
};
|
|
184
|
-
|
|
185
204
|
const handleMouseUp = () => {
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
if (!panningRef.current) return;
|
|
206
|
+
panningRef.current = false;
|
|
207
|
+
setIsPanning(false);
|
|
208
|
+
container.style.cursor = spaceRef.current ? 'grab' : '';
|
|
190
209
|
};
|
|
191
210
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
211
|
+
// ---- iframe からの転送
|
|
212
|
+
const handleMessage = (e: MessageEvent) => {
|
|
213
|
+
const data = e.data as EmbeddedCanvasMessage | undefined;
|
|
214
|
+
if (!data || typeof data !== 'object' || typeof data.type !== 'string' || !data.type.startsWith('EMBEDDED_')) return;
|
|
215
|
+
if (data.type === 'EMBEDDED_WHEEL') {
|
|
216
|
+
const p = fromIframe(e.source, data.clientX, data.clientY, data.coords);
|
|
217
|
+
if (!p) return;
|
|
218
|
+
applyWheel(data.deltaX, data.deltaY, p.clientX, p.clientY, data.ctrlKey || data.metaKey, data.shiftKey);
|
|
219
|
+
} else if (data.type === 'EMBEDDED_PINCH') {
|
|
220
|
+
const p = fromIframe(e.source, data.centerX, data.centerY, data.coords);
|
|
221
|
+
if (!p) return;
|
|
222
|
+
applyPinch(data.previousDistance, data.currentDistance, p.clientX, p.clientY);
|
|
223
|
+
} else if (data.type === 'EMBEDDED_PAN_START') {
|
|
224
|
+
const p = fromIframe(e.source, data.clientX, data.clientY, data.coords);
|
|
225
|
+
if (!p) return;
|
|
226
|
+
startPan(p.clientX, p.clientY);
|
|
227
|
+
} else if (data.type === 'EMBEDDED_KEY') {
|
|
228
|
+
handleKey(data.kind, data.key, data.code, data, () => undefined, false);
|
|
229
|
+
}
|
|
199
230
|
};
|
|
200
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
-
}, [canvas?.isEnabled, containerRef]);
|
|
202
|
-
|
|
203
|
-
// タッチパン(モバイル対応)
|
|
204
|
-
useEffect(() => {
|
|
205
|
-
if (!canvas?.isEnabled) return;
|
|
206
|
-
const container = containerRef.current;
|
|
207
|
-
if (!container) return;
|
|
208
|
-
|
|
209
|
-
let lastTouchPos: { x: number; y: number } | null = null;
|
|
210
|
-
let initialPinchDistance: number | null = null;
|
|
211
|
-
let initialPinchZoom: number | null = null;
|
|
212
|
-
|
|
213
|
-
const getCenter = (t1: Touch, t2: Touch) => ({
|
|
214
|
-
x: (t1.clientX + t2.clientX) / 2,
|
|
215
|
-
y: (t1.clientY + t2.clientY) / 2,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const getDistance = (t1: Touch, t2: Touch) =>
|
|
219
|
-
Math.sqrt((t2.clientX - t1.clientX) ** 2 + (t2.clientY - t1.clientY) ** 2);
|
|
220
231
|
|
|
232
|
+
// ---- タッチ
|
|
233
|
+
let lastTouch: { x: number; y: number } | null = null;
|
|
234
|
+
let pinchStart: { distance: number; zoom: number } | null = null;
|
|
235
|
+
const distance = (t1: Touch, t2: Touch) => Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
|
|
236
|
+
const center = (t1: Touch, t2: Touch) => ({ x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 });
|
|
221
237
|
const handleTouchStart = (e: TouchEvent) => {
|
|
222
|
-
if (e.touches.length === 1) {
|
|
223
|
-
|
|
224
|
-
} else if (e.touches.length === 2) {
|
|
238
|
+
if (e.touches.length === 1) lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
239
|
+
else if (e.touches.length === 2) {
|
|
225
240
|
e.preventDefault();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
initialPinchZoom = c?.viewState.canvasZoom ?? 0.15;
|
|
229
|
-
lastTouchPos = null;
|
|
241
|
+
pinchStart = { distance: distance(e.touches[0], e.touches[1]), zoom: c().viewStore.get().canvasZoom };
|
|
242
|
+
lastTouch = null;
|
|
230
243
|
}
|
|
231
244
|
};
|
|
232
|
-
|
|
233
245
|
const handleTouchMove = (e: TouchEvent) => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const { canvasOffset } = c.viewState;
|
|
244
|
-
c.setCanvasOffset({
|
|
245
|
-
x: canvasOffset.x + dx,
|
|
246
|
-
y: canvasOffset.y + dy,
|
|
247
|
-
});
|
|
248
|
-
} else if (e.touches.length === 2 && initialPinchDistance != null && initialPinchZoom != null) {
|
|
246
|
+
if (e.touches.length === 1 && lastTouch) {
|
|
247
|
+
const dx = e.touches[0].clientX - lastTouch.x;
|
|
248
|
+
const dy = e.touches[0].clientY - lastTouch.y;
|
|
249
|
+
lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
250
|
+
const { canvasOffset } = c().viewStore.get();
|
|
251
|
+
c().setCanvasOffset({ x: canvasOffset.x + dx, y: canvasOffset.y + dy });
|
|
252
|
+
c().markInteracting();
|
|
253
|
+
} else if (e.touches.length === 2 && pinchStart) {
|
|
249
254
|
e.preventDefault();
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const rect = container.getBoundingClientRect();
|
|
256
|
-
const center = getCenter(e.touches[0], e.touches[1]);
|
|
257
|
-
const mouseX = center.x - rect.left;
|
|
258
|
-
const mouseY = center.y - rect.top;
|
|
259
|
-
|
|
260
|
-
const { canvasOffset, canvasZoom } = c.viewState;
|
|
261
|
-
const newOffset = {
|
|
262
|
-
x: mouseX - (mouseX - canvasOffset.x) * (newZoom / canvasZoom),
|
|
263
|
-
y: mouseY - (mouseY - canvasOffset.y) * (newZoom / canvasZoom),
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
c.setCanvasZoom(newZoom);
|
|
267
|
-
c.setCanvasOffset(newOffset);
|
|
255
|
+
const scale = distance(e.touches[0], e.touches[1]) / pinchStart.distance;
|
|
256
|
+
const p = center(e.touches[0], e.touches[1]);
|
|
257
|
+
c().zoomAt(pinchStart.zoom * scale, toContainerPoint(p.x, p.y));
|
|
258
|
+
c().markInteracting();
|
|
268
259
|
}
|
|
269
260
|
};
|
|
270
|
-
|
|
271
261
|
const handleTouchEnd = () => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
initialPinchZoom = null;
|
|
262
|
+
lastTouch = null;
|
|
263
|
+
pinchStart = null;
|
|
275
264
|
};
|
|
276
|
-
|
|
277
265
|
const preventNativeTouchZoom = (e: TouchEvent) => {
|
|
278
|
-
if (e.touches && e.touches.length >= 2)
|
|
279
|
-
e.preventDefault();
|
|
280
|
-
}
|
|
266
|
+
if (e.touches && e.touches.length >= 2) e.preventDefault();
|
|
281
267
|
};
|
|
268
|
+
|
|
269
|
+
window.addEventListener('wheel', preventNativeZoom, { passive: false, capture: true });
|
|
270
|
+
document.addEventListener('wheel', preventNativeZoom, { passive: false, capture: true });
|
|
271
|
+
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
272
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
273
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
274
|
+
window.addEventListener('blur', handleBlur);
|
|
275
|
+
container.addEventListener('mousedown', handleMouseDown);
|
|
276
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
277
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
278
|
+
window.addEventListener('message', handleMessage);
|
|
282
279
|
window.addEventListener('touchstart', preventNativeTouchZoom, { passive: false, capture: true });
|
|
283
280
|
window.addEventListener('touchmove', preventNativeTouchZoom, { passive: false, capture: true });
|
|
284
|
-
document.addEventListener('touchstart', preventNativeTouchZoom, { passive: false, capture: true });
|
|
285
|
-
document.addEventListener('touchmove', preventNativeTouchZoom, { passive: false, capture: true });
|
|
286
|
-
|
|
287
281
|
container.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
288
282
|
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
289
283
|
container.addEventListener('touchend', handleTouchEnd);
|
|
284
|
+
container.addEventListener('touchcancel', handleTouchEnd);
|
|
285
|
+
|
|
290
286
|
return () => {
|
|
287
|
+
window.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
288
|
+
document.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
289
|
+
container.removeEventListener('wheel', handleWheel);
|
|
290
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
291
|
+
window.removeEventListener('keyup', handleKeyUp);
|
|
292
|
+
window.removeEventListener('blur', handleBlur);
|
|
293
|
+
container.removeEventListener('mousedown', handleMouseDown);
|
|
294
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
295
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
296
|
+
window.removeEventListener('message', handleMessage);
|
|
291
297
|
window.removeEventListener('touchstart', preventNativeTouchZoom, { capture: true });
|
|
292
298
|
window.removeEventListener('touchmove', preventNativeTouchZoom, { capture: true });
|
|
293
|
-
document.removeEventListener('touchstart', preventNativeTouchZoom, { capture: true });
|
|
294
|
-
document.removeEventListener('touchmove', preventNativeTouchZoom, { capture: true });
|
|
295
299
|
container.removeEventListener('touchstart', handleTouchStart);
|
|
296
300
|
container.removeEventListener('touchmove', handleTouchMove);
|
|
297
301
|
container.removeEventListener('touchend', handleTouchEnd);
|
|
302
|
+
container.removeEventListener('touchcancel', handleTouchEnd);
|
|
303
|
+
container.style.cursor = '';
|
|
298
304
|
};
|
|
299
305
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
300
|
-
}, [
|
|
306
|
+
}, [enabled, containerRef]);
|
|
307
|
+
|
|
308
|
+
return { isSpaceHeld, isPanning, MIN_ZOOM, MAX_ZOOM };
|
|
301
309
|
}
|
|
@@ -43,7 +43,7 @@ interface UseMarqueeSelectionOptions {
|
|
|
43
43
|
* Figma と同じく交差判定にする。
|
|
44
44
|
* ※ dom-utils は他の修正項目と共有しているため、ここにローカル実装を置く。
|
|
45
45
|
*/
|
|
46
|
-
function findElementsIntersectingMarquee(
|
|
46
|
+
export function findElementsIntersectingMarquee(
|
|
47
47
|
iframeDoc: Document,
|
|
48
48
|
marquee: { left: number; top: number; right: number; bottom: number }
|
|
49
49
|
): HTMLElement[] {
|
|
@@ -80,7 +80,7 @@ function findElementsIntersectingMarquee(
|
|
|
80
80
|
* ※ dom-utils の collapseToParentIfAllChildrenSelected は
|
|
81
81
|
* 「全ての子が選択されたときだけ親に畳む」別のルールなので使わない。
|
|
82
82
|
*/
|
|
83
|
-
function collapseToTopMost(elements: HTMLElement[]): HTMLElement[] {
|
|
83
|
+
export function collapseToTopMost(elements: HTMLElement[]): HTMLElement[] {
|
|
84
84
|
return elements.filter(
|
|
85
85
|
(el) => !elements.some((other) => other !== el && other.contains(el))
|
|
86
86
|
);
|
|
@@ -229,6 +229,9 @@ export function isInlineElement(element: HTMLElement, computedStyle?: CSSStyleDe
|
|
|
229
229
|
* 適用ロジックの二重実装を作らない。
|
|
230
230
|
*/
|
|
231
231
|
export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
|
|
232
|
+
// 埋め込み(マルチフレームのキャンバス内)では倍率は外側の CSS transform が持つ。
|
|
233
|
+
// iframe の中は常に等倍で、スクロール領域も作らない
|
|
234
|
+
if (iframeDoc.body?.dataset.embedded === '1') return;
|
|
232
235
|
const wrapper = iframeDoc.getElementById('artboard-wrapper');
|
|
233
236
|
const scrollArea = iframeDoc.getElementById('canvas-scroll-area');
|
|
234
237
|
const container = iframeDoc.getElementById('canvas-container');
|
|
@@ -1105,7 +1108,10 @@ const RADIUS_MIN_INSET_SCREEN = 14;
|
|
|
1105
1108
|
* refreshSelectionOverlay を呼んでいる)。
|
|
1106
1109
|
*/
|
|
1107
1110
|
export function applyOverlayScale(iframeDoc: Document): number {
|
|
1108
|
-
|
|
1111
|
+
// 埋め込み(マルチフレームのキャンバス)では iframe の外側にも倍率が掛かる。
|
|
1112
|
+
// 画面上で 1px にするには内側 × 外側の倍率で割る(EditorCanvas が data-outer-zoom に書く)
|
|
1113
|
+
const outerZoom = Number(iframeDoc.documentElement?.dataset.outerZoom) || 1;
|
|
1114
|
+
const scale = (getArtboardScale(iframeDoc) || 1) * outerZoom;
|
|
1109
1115
|
const overlayScale = 1 / scale;
|
|
1110
1116
|
const root = iframeDoc.documentElement;
|
|
1111
1117
|
if (!root) return overlayScale;
|
|
@@ -1295,6 +1301,22 @@ function drawSelectionBox(
|
|
|
1295
1301
|
selectionBox.className =
|
|
1296
1302
|
mode === 'member' ? 'selection-box selection-member' : 'selection-box';
|
|
1297
1303
|
selectionBox.setAttribute('data-for-element', elementId);
|
|
1304
|
+
// 部品(data-part)のインスタンスは枠を紫にして、名前と版を枠の上に出す(部品だと一目で分かる)。
|
|
1305
|
+
// 部品の中のスロットを選んでいるときも、どの部品の中かを出す
|
|
1306
|
+
const partRoot = element.hasAttribute('data-part') ? element : (element.parentElement?.closest('[data-part]') as HTMLElement | null) ?? null;
|
|
1307
|
+
if (partRoot) {
|
|
1308
|
+
const isRoot = partRoot === element;
|
|
1309
|
+
selectionBox.classList.add(isRoot ? 'selection-part' : 'selection-in-part');
|
|
1310
|
+
if (mode !== 'member') {
|
|
1311
|
+
const badge = iframeDoc.createElement('div');
|
|
1312
|
+
badge.className = 'part-badge';
|
|
1313
|
+
const slot = isRoot ? null : (element.closest('[data-slot]') as HTMLElement | null);
|
|
1314
|
+
const slotName = slot && partRoot.contains(slot) && slot !== partRoot ? slot.getAttribute('data-slot') : null;
|
|
1315
|
+
const version = partRoot.getAttribute('data-part-v');
|
|
1316
|
+
badge.textContent = `${isRoot ? '部品' : '部品の中'} ${partRoot.getAttribute('data-part') ?? ''}${version ? ` v${version}` : ''}${slotName ? ` › ${slotName}` : ''}`;
|
|
1317
|
+
selectionBox.appendChild(badge);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1298
1320
|
selectionBox.style.cssText = `
|
|
1299
1321
|
left: ${relativeLeft}px;
|
|
1300
1322
|
top: ${relativeTop}px;
|