@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.
Files changed (38) hide show
  1. package/README.md +37 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +7 -3
  5. package/src/editor/FrontendVisualEditor.tsx +154 -54
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +93 -8
  8. package/src/editor/components/EditorHeader.tsx +47 -12
  9. package/src/editor/components/EditorPropertyPanel.tsx +2 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  11. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
  12. package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
  13. package/src/editor/components/multi-page/index.ts +2 -6
  14. package/src/editor/components/ppt/PptComments.tsx +8 -4
  15. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  16. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  17. package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
  18. package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
  19. package/src/editor/hooks/useCanvasControls.ts +1 -1
  20. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  21. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  22. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  23. package/src/editor/hooks/useDrawingMode.ts +1 -0
  24. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  25. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  26. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  27. package/src/editor/parts.ts +38 -12
  28. package/src/editor/utils/dom-utils.ts +7 -1
  29. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  30. package/src/editor/utils/html-utils.ts +86 -11
  31. package/src/io.ts +20 -0
  32. package/src/styles/skin.css +66 -34
  33. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  34. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  35. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  36. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  37. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  38. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -3,299 +3,307 @@
3
3
  /**
4
4
  * useInfiniteCanvas
5
5
  *
6
- * 無限キャンバスのズーム/パン操作を管理するhook
7
- * - ホイールズーム (Ctrl/Cmd + wheel)
8
- * - ホイールパン (wheel without modifier)
9
- * - ピンチジェスチャー (trackpad pinch)
10
- * - スペース+ドラッグパン
11
- * - Ctrl/Cmd+0 でフィットズーム
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
- const MIN_ZOOM = 0.02;
18
- const MAX_ZOOM = 2.0;
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 isSpaceHeldRef = useRef(false);
25
- const isPanningRef = useRef(false);
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 (!canvas?.isEnabled) return;
65
+ if (!enabled) return;
35
66
  const container = containerRef.current;
36
67
  if (!container) return;
37
68
 
38
- const handleWheel = (e: WheelEvent) => {
39
- e.preventDefault();
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
- const c = canvasRef.current;
42
- if (!c) return;
43
- const { canvasOffset, canvasZoom } = c.viewState;
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
- // ピンチジェスチャー(ctrlKey=trueになる)またはCtrl+Wheelでズーム
46
- if (e.ctrlKey || e.metaKey) {
47
- const rect = container.getBoundingClientRect();
48
- const mouseX = e.clientX - rect.left;
49
- const mouseY = e.clientY - rect.top;
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
- const delta = -e.deltaY;
53
- const factor = delta > 0 ? 1.05 : 0.95;
54
- const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, canvasZoom * factor));
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
- const newOffset = {
58
- x: mouseX - (mouseX - canvasOffset.x) * (newZoom / canvasZoom),
59
- y: mouseY - (mouseY - canvasOffset.y) * (newZoom / canvasZoom),
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
- c.setCanvasZoom(newZoom);
63
- c.setCanvasOffset(newOffset);
64
- } else {
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
- useEffect(() => {
94
- if (!canvas?.isEnabled) return;
95
-
96
- const handleKeyDown = (e: KeyboardEvent) => {
97
- if (e.code === 'Space' && !e.repeat) {
98
- e.preventDefault();
99
- isSpaceHeldRef.current = true;
100
- const container = containerRef.current;
101
- if (container) {
102
- container.style.cursor = 'grab';
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 c = canvasRef.current;
107
- if (!c) return;
108
-
109
- // Ctrl/Cmd + 0: フィットズーム
110
- if ((e.ctrlKey || e.metaKey) && e.key === '0') {
111
- e.preventDefault();
112
- const container = containerRef.current;
113
- if (container) {
114
- const rect = container.getBoundingClientRect();
115
- c.zoomToFit(rect.width, rect.height);
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.code === 'Space') {
134
- isSpaceHeldRef.current = false;
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
- window.addEventListener('keydown', handleKeyDown);
144
- window.addEventListener('keyup', handleKeyUp);
145
- return () => {
146
- window.removeEventListener('keydown', handleKeyDown);
147
- window.removeEventListener('keyup', handleKeyUp);
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
- // スペース押下中 or 中クリック
160
- if (isSpaceHeldRef.current || e.button === 1) {
189
+ if (spaceRef.current || e.button === 1) {
161
190
  e.preventDefault();
162
- isPanningRef.current = true;
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 (!isPanningRef.current) return;
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
- const c = canvasRef.current;
177
- if (!c) return;
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 (isPanningRef.current) {
187
- isPanningRef.current = false;
188
- container.style.cursor = isSpaceHeldRef.current ? 'grab' : '';
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
- container.addEventListener('mousedown', handleMouseDown);
193
- window.addEventListener('mousemove', handleMouseMove);
194
- window.addEventListener('mouseup', handleMouseUp);
195
- return () => {
196
- container.removeEventListener('mousedown', handleMouseDown);
197
- window.removeEventListener('mousemove', handleMouseMove);
198
- window.removeEventListener('mouseup', handleMouseUp);
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
- lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
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
- const c = canvasRef.current;
227
- initialPinchDistance = getDistance(e.touches[0], e.touches[1]);
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
- const c = canvasRef.current;
235
- if (!c) return;
236
-
237
- if (e.touches.length === 1 && lastTouchPos) {
238
- // 1本指パン
239
- const dx = e.touches[0].clientX - lastTouchPos.x;
240
- const dy = e.touches[0].clientY - lastTouchPos.y;
241
- lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
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 currentDistance = getDistance(e.touches[0], e.touches[1]);
252
- const scale = currentDistance / initialPinchDistance;
253
- const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, initialPinchZoom * scale));
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
- lastTouchPos = null;
273
- initialPinchDistance = null;
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
- }, [canvas?.isEnabled, containerRef]);
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
  );
@@ -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', li: 'item', a: 'link', button: 'button', img: 'image',
152
- figcaption: 'caption', blockquote: 'quote', dt: 'term', dd: 'desc',
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
- * span は文字だけを直接持つときだけ。svg の中は見ない。
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 candidates = Array.from(root.querySelectorAll('*'));
176
- for (const el of candidates) {
177
- if (el.closest('svg')) continue;
178
- if (el.hasAttribute(SLOT_ATTR)) continue;
179
- const ancestorSlot = el.parentElement?.closest(`[${SLOT_ATTR}]`);
180
- if (ancestorSlot && root.contains(ancestorSlot)) continue;
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
- const scale = getArtboardScale(iframeDoc) || 1;
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;