@growgroup/visual-editor 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +7 -3
- package/src/editor/FrontendVisualEditor.tsx +154 -54
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +93 -8
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorPropertyPanel.tsx +2 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
- package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/parts.ts +38 -12
- package/src/editor/utils/dom-utils.ts +7 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +86 -11
- package/src/io.ts +20 -0
- package/src/styles/skin.css +66 -34
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
|
@@ -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
|
);
|
package/src/editor/parts.ts
CHANGED
|
@@ -148,15 +148,21 @@ export function stripEditorAttrs(root: Element): void {
|
|
|
148
148
|
/** スロットを推定して付ける対象(文字や画像を持ちうる要素) */
|
|
149
149
|
const SLOT_TAGS: Record<string, string> = {
|
|
150
150
|
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
|
|
151
|
-
p: 'body',
|
|
152
|
-
figcaption: 'caption', blockquote: 'quote',
|
|
153
|
-
td: 'cell', th: 'cell', label: 'label', span: 'text', time: 'time', small: 'note',
|
|
151
|
+
p: 'body', a: 'link', button: 'button', img: 'image',
|
|
152
|
+
figcaption: 'caption', blockquote: 'quote', label: 'label', span: 'text', time: 'time', small: 'note',
|
|
154
153
|
};
|
|
155
154
|
|
|
155
|
+
/** タグだけの骨格(「同じ形の兄弟」の判定用。文字と属性は見ない) */
|
|
156
|
+
const tagSkeleton = (el: Element): string =>
|
|
157
|
+
`${el.tagName.toLowerCase()}${el.children.length ? `(${Array.from(el.children).map(tagSkeleton).join(',')})` : ''}`;
|
|
158
|
+
|
|
156
159
|
/**
|
|
157
|
-
* スロットの推定。既に `data-slot`
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
+
* スロットの推定。既に `data-slot` があるものはそのまま。祖先が既にスロットなら付けない(入れ子のスロットを作らない)。
|
|
161
|
+
*
|
|
162
|
+
* 1. 繰り返し(ul/ol・dl・table、同じ形の子が 2 つ以上並ぶ器)は**器ごと 1 つのスロット**(list / items)。
|
|
163
|
+
* カードが 3 枚か 4 枚かはページの内容であって部品の構造ではないので、項目ごとにスロットを切らない
|
|
164
|
+
* (切ると、定義より多い項目が sync で捨てられる)。ルート自身が繰り返しの器ならルートがスロットになる
|
|
165
|
+
* 2. 残りはタグで(見出し→heading、段落→body、リンク→link …)。span は文字だけを直接持つときだけ。svg の中は見ない
|
|
160
166
|
* 戻り値は付けた数。
|
|
161
167
|
*/
|
|
162
168
|
export function inferSlots(root: Element): number {
|
|
@@ -172,12 +178,32 @@ export function inferSlots(root: Element): number {
|
|
|
172
178
|
const base = v.replace(/-\d+$/, '');
|
|
173
179
|
if (base) used.set(base, Math.max(used.get(base) ?? 0, 1));
|
|
174
180
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
const inSlot = (el: Element) => {
|
|
182
|
+
const a = el.parentElement?.closest(`[${SLOT_ATTR}]`);
|
|
183
|
+
return !!(a && root.contains(a));
|
|
184
|
+
};
|
|
185
|
+
const skip = (el: Element) => !!el.closest('svg') || el.hasAttribute(SLOT_ATTR) || inSlot(el);
|
|
186
|
+
const isRepeatContainer = (el: Element) => {
|
|
187
|
+
const kids = Array.from(el.children);
|
|
188
|
+
const tag = el.tagName.toLowerCase();
|
|
189
|
+
if ((tag === 'ul' || tag === 'ol') && kids.some((k) => k.tagName.toLowerCase() === 'li')) return 'list';
|
|
190
|
+
if (tag === 'dl' || tag === 'table') return 'list';
|
|
191
|
+
if (kids.length >= 2 && kids.every((k) => tagSkeleton(k) === tagSkeleton(kids[0]))) return 'items';
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
// 1. 繰り返しの器(ルート自身も含む。文書順なので外側が先に決まり、中の器は祖先がスロットになって飛ばされる)
|
|
195
|
+
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
|
|
196
|
+
if (el !== root && skip(el)) continue;
|
|
197
|
+
if (el === root && el.hasAttribute(SLOT_ATTR)) continue;
|
|
198
|
+
const kind = isRepeatContainer(el);
|
|
199
|
+
if (!kind) continue;
|
|
200
|
+
el.setAttribute(SLOT_ATTR, nameFor(kind));
|
|
201
|
+
count++;
|
|
202
|
+
}
|
|
203
|
+
if (root.hasAttribute(SLOT_ATTR)) return count;
|
|
204
|
+
// 2. 残りはタグで
|
|
205
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
206
|
+
if (skip(el)) continue;
|
|
181
207
|
const tag = el.tagName.toLowerCase();
|
|
182
208
|
const base = SLOT_TAGS[tag];
|
|
183
209
|
if (!base) continue;
|
|
@@ -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;
|