@growgroup/visual-editor 0.9.0 → 0.10.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.
@@ -42,6 +42,7 @@ import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
42
42
  import type { EditorMode } from './EditorToolContext';
43
43
  import { io, type PreviewStyle } from '../../io';
44
44
  import { readStorage, writeStorage } from '../utils/storage';
45
+ import { nextZoomLevel, type CanvasZoomOptions, type ResolvedCanvasZoomOptions } from '../utils/canvas-gestures';
45
46
 
46
47
  // ============================================================
47
48
  // 定数
@@ -51,10 +52,13 @@ import { readStorage, writeStorage } from '../utils/storage';
51
52
  export { WEBPAGE_WIDTH };
52
53
  /** 高さが未測定の webpage フレームに仮に与える高さ */
53
54
  export const WEBPAGE_MIN_HEIGHT = 1200;
54
- /** ズーム範囲(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲) */
55
+ /**
56
+ * ズーム範囲の既定(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲)。
57
+ * 利用側は `canvasZoomOptions` で範囲・刻み・⌘2 の合わせ先を変えられる(0.10.0)
58
+ */
55
59
  export const MIN_ZOOM = 0.02;
56
60
  export const MAX_ZOOM = 4;
57
- /** ホイール 1 段のズーム倍率(ボタン・キー操作) */
61
+ /** ボタン・キー操作の 1 段の倍率(`steps: 'ratio'`、既定) */
58
62
  const ZOOM_STEP = 1.25;
59
63
  /** 全体表示・ページ表示のときの余白(px、画面上) */
60
64
  const FIT_PADDING = 72;
@@ -225,6 +229,10 @@ export interface MultiPageCanvasContextValue {
225
229
  zoomToFit: (options?: { animate?: boolean }) => void;
226
230
  zoomToPage: (id: string, options?: { animate?: boolean }) => void;
227
231
  zoomToActual: () => void;
232
+ /** キャンバスの座標の矩形を、余白を取って画面に収める(100% を超えてもよい。選択範囲に合わせる等) — 0.10.0 */
233
+ zoomToRect: (rect: CanvasBounds, options?: { animate?: boolean }) => void;
234
+ /** 拡大縮小の設定(`canvasZoomOptions` を省略時の値で埋めたもの) — 0.10.0 */
235
+ zoomOptions: ResolvedCanvasZoomOptions;
228
236
  /** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
229
237
  revealPage: (id: string) => void;
230
238
  /**
@@ -484,7 +492,17 @@ function fitView(
484
492
  };
485
493
  }
486
494
 
487
- const clampZoom = (z: number) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
495
+ /** 利用側が渡した設定を省略時の値で埋める(範囲は片方だけ・逆転・0 以下を既定へ) */
496
+ export function resolveCanvasZoomOptions(options?: CanvasZoomOptions | null): ResolvedCanvasZoomOptions {
497
+ const min = Number.isFinite(options?.min) && (options?.min as number) > 0 ? (options?.min as number) : MIN_ZOOM;
498
+ const max = Number.isFinite(options?.max) && (options?.max as number) > 0 ? (options?.max as number) : MAX_ZOOM;
499
+ const range = min <= max ? { min, max } : { min: MIN_ZOOM, max: MAX_ZOOM };
500
+ return {
501
+ ...range,
502
+ steps: options?.steps === 'powers-of-two' ? 'powers-of-two' : 'ratio',
503
+ fitShortcut: options?.fitShortcut === 'selection' ? 'selection' : 'page',
504
+ };
505
+ }
488
506
  const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
489
507
  const prefersReducedMotion = () =>
490
508
  typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -518,6 +536,8 @@ interface MultiPageCanvasProviderProps {
518
536
  enabled: boolean;
519
537
  /** 見えている範囲を保存するキー(利用側の parentId 等)。無ければ保存しない */
520
538
  storageKey?: string | null;
539
+ /** 拡大縮小の設定(範囲・刻み・⌘2 の合わせ先)。省略時は従来の挙動(0.10.0) */
540
+ zoomOptions?: CanvasZoomOptions;
521
541
  }
522
542
 
523
543
  type PersistedView = {
@@ -546,7 +566,15 @@ function readPersistedView(key: string | null | undefined): PersistedView | null
546
566
  }
547
567
  }
548
568
 
549
- export function MultiPageCanvasProvider({ children, enabled, storageKey }: MultiPageCanvasProviderProps) {
569
+ export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOptions: zoomOptionsProp }: MultiPageCanvasProviderProps) {
570
+ // 拡大縮小の設定。倍率はどの経路(ホイール・ピンチ・キー・ボタン・全体表示)も clampZoom で範囲に収める
571
+ const zoomOptions = useMemo<ResolvedCanvasZoomOptions>(
572
+ () => resolveCanvasZoomOptions(zoomOptionsProp),
573
+ [zoomOptionsProp?.min, zoomOptionsProp?.max, zoomOptionsProp?.steps, zoomOptionsProp?.fitShortcut],
574
+ );
575
+ const zoomOptionsRef = useRef(zoomOptions);
576
+ zoomOptionsRef.current = zoomOptions;
577
+ const clampZoom = useCallback((z: number) => Math.max(zoomOptionsRef.current.min, Math.min(zoomOptionsRef.current.max, z)), []);
550
578
  const artboard = useEditorArtboard();
551
579
  const { editorMode } = useEditorTool();
552
580
  const { viewportWidth } = useEditorView();
@@ -864,8 +892,17 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
864
892
  [setView, viewStore],
865
893
  );
866
894
 
867
- const zoomIn = useCallback(() => zoomTo(viewStore.get().canvasZoom * ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
868
- const zoomOut = useCallback(() => zoomTo(viewStore.get().canvasZoom / ZOOM_STEP, { animate: true }), [zoomTo, viewStore]);
895
+ // 刻みは既定で 1.25 倍ずつ。`steps: 'powers-of-two'` なら 2 の累乗の段(Figma。…50% → 100% → 200%…。段の間からは向きの側の段へ)
896
+ const stepZoom = useCallback((direction: 1 | -1) => {
897
+ const current = viewStore.get().canvasZoom;
898
+ const options = zoomOptionsRef.current;
899
+ const next = options.steps === 'powers-of-two'
900
+ ? nextZoomLevel(current, direction, options)
901
+ : current * (direction > 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
902
+ zoomTo(next, { animate: true });
903
+ }, [zoomTo, viewStore]);
904
+ const zoomIn = useCallback(() => stepZoom(1), [stepZoom]);
905
+ const zoomOut = useCallback(() => stepZoom(-1), [stepZoom]);
869
906
  const zoomToActual = useCallback(() => zoomTo(1, { animate: true }), [zoomTo]);
870
907
 
871
908
  const zoomToFit = useCallback<MultiPageCanvasContextValue['zoomToFit']>(
@@ -876,6 +913,14 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
876
913
  [setView],
877
914
  );
878
915
 
916
+ // 選択範囲に合わせる等。全体表示と違って 100% で止めない(小さい要素は大きく見せる。Figma と同じ)
917
+ const zoomToRect = useCallback<MultiPageCanvasContextValue['zoomToRect']>(
918
+ (rect, options) => {
919
+ setView(fitView(rect, containerSize(), { padding: FIT_PADDING, maxZoom: zoomOptionsRef.current.max, rulers: rulersRef.current }), options);
920
+ },
921
+ [setView],
922
+ );
923
+
879
924
  const zoomToPage = useCallback<MultiPageCanvasContextValue['zoomToPage']>(
880
925
  (id, options) => {
881
926
  const page = pagesRef.current.find((p) => p.id === id);
@@ -1223,6 +1268,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1223
1268
  zoomToFit,
1224
1269
  zoomToPage,
1225
1270
  zoomToActual,
1271
+ zoomToRect,
1272
+ zoomOptions,
1226
1273
  revealPage,
1227
1274
  focusPage,
1228
1275
  activatePage,
@@ -1244,7 +1291,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1244
1291
  [
1245
1292
  enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
1246
1293
  setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, shiftView, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
1247
- zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1294
+ zoomToActual, zoomToRect, zoomOptions, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1248
1295
  persisted, applyInitialView, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1249
1296
  getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
1250
1297
  ],
@@ -1259,17 +1306,19 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1259
1306
  export function ConditionalMultiPageProvider({
1260
1307
  enabled,
1261
1308
  storageKey,
1309
+ zoomOptions,
1262
1310
  children,
1263
1311
  }: {
1264
1312
  enabled: boolean;
1265
1313
  storageKey?: string | null;
1314
+ zoomOptions?: CanvasZoomOptions;
1266
1315
  children: React.ReactNode;
1267
1316
  }) {
1268
1317
  if (!enabled) {
1269
1318
  return <>{children}</>;
1270
1319
  }
1271
1320
  return (
1272
- <MultiPageCanvasProvider enabled={enabled} storageKey={storageKey}>
1321
+ <MultiPageCanvasProvider enabled={enabled} storageKey={storageKey} zoomOptions={zoomOptions}>
1273
1322
  {children}
1274
1323
  </MultiPageCanvasProvider>
1275
1324
  );
@@ -31,11 +31,11 @@ export function useBrowserZoomPrevention(): void {
31
31
  }
32
32
  };
33
33
 
34
- // Safari/WebKitのジェスチャーイベントを防止
34
+ // Safari/WebKitのジェスチャーイベント(ピンチ)でのブラウザ拡大を防止
35
+ // 注: 伝播は止めない(0.10.0)。キャンバス(useInfiniteCanvas)がピンチを拡大縮小に使う。
36
+ // 以前は stopImmediatePropagation で握りつぶしていて、Safari・WKWebView ではピンチしても何も起きなかった
35
37
  const preventGesture = (e: Event) => {
36
38
  e.preventDefault();
37
- e.stopPropagation();
38
- e.stopImmediatePropagation();
39
39
  return false;
40
40
  };
41
41
 
@@ -610,12 +610,9 @@ export function useCanvasControls() {
610
610
  window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
611
611
  document.addEventListener('wheel', handleWheel, { passive: false, capture: true });
612
612
 
613
+ // ブラウザの拡大だけを止める。伝播は止めない(0.10.0。キャンバスがピンチを拡大縮小に使う)
613
614
  const handleGesture = (e: Event) => {
614
- if (isEditorOpen()) {
615
- e.preventDefault();
616
- e.stopPropagation();
617
- e.stopImmediatePropagation();
618
- }
615
+ if (isEditorOpen()) e.preventDefault();
619
616
  };
620
617
 
621
618
  window.addEventListener('gesturestart', handleGesture, { passive: false, capture: true });
@@ -9,6 +9,7 @@ import { useCallback, useRef, useEffect, MutableRefObject } from 'react';
9
9
  import { useEditorContext } from '../EditorContext';
10
10
  import { updateSelectionBox } from '../utils/dom-utils';
11
11
  import { extractElementInfo } from '../utils/style-utils';
12
+ import { passThroughSvg } from '../utils/svg-hit';
12
13
 
13
14
  interface UseContextMenuHandlerOptions {
14
15
  /** 編集可能要素を取得する関数 */
@@ -86,7 +87,8 @@ export function useContextMenuHandler(
86
87
  const y = iframeRect.top + e.clientY * outerScale;
87
88
 
88
89
  // 要素がクリックされた場合は選択
89
- const element = getEditableElement(e.target, iframeDoc);
90
+ // SVG の箱の空きは素通りさせる(クリックの判別と同じ。utils/svg-hit.ts)
91
+ const element = getEditableElement(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc);
90
92
  if (element) {
91
93
  const elementId = element.getAttribute('data-element-id') || '';
92
94
  const currentIds = selectedElementIdsRef.current;
@@ -26,6 +26,7 @@ import { extractElementInfo } from "../utils/style-utils";
26
26
  import { MARQUEE_DRAG_THRESHOLD } from "../constants";
27
27
  import type { DragState, ResizeState, MarqueeState } from "../types";
28
28
  import { debugLog } from '../utils/debug';
29
+ import { passThroughSvg } from '../utils/svg-hit';
29
30
 
30
31
  /**
31
32
  * mousedown で保留し、mouseup で意味を確定するクリック情報
@@ -1181,6 +1182,9 @@ export function useElementSelection(
1181
1182
  ),
1182
1183
  ) ?? null;
1183
1184
  }
1185
+ // SVG の箱の中で何も描かれていない所を押したら、SVG を素通りして下の要素を押したことにする。
1186
+ // 装飾の SVG が文字やボタンに重なっていると、素のクリックも Cmd+クリックも SVG しか選べなかった(utils/svg-hit.ts)
1187
+ hitSource = passThroughSvg(iframeDoc, hitSource, e.clientX, e.clientY);
1184
1188
  const hit = getEditableElement(hitSource, iframeDoc);
1185
1189
  // 空白=押した点の祖先に編集可能要素がまったく無い状態。
1186
1190
  // #artboard / 紙面と同じ大きさの器 / #artboard-wrapper / #canvas-container は
@@ -1432,7 +1436,7 @@ export function useElementSelection(
1432
1436
 
1433
1437
 
1434
1438
  const isCtrlCmd = e.ctrlKey || e.metaKey;
1435
- const deepElement = getEditableElement(e.target, iframeDoc);
1439
+ const deepElement = getEditableElement(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc);
1436
1440
 
1437
1441
  if (!deepElement) return;
1438
1442
 
@@ -12,6 +12,7 @@ import { buildDomTree, isInlineElement, isNonEditableTag } from '../utils/dom-ut
12
12
  import { EDITOR_IFRAME_STYLES } from '../constants';
13
13
  import { isLockedInsidePart } from '../parts';
14
14
  import { debugLog } from '../utils/debug';
15
+ import { isInsideSvg, passThroughSvg } from '../utils/svg-hit';
15
16
 
16
17
  interface UseIframeSetupReturn {
17
18
  /**
@@ -107,7 +108,14 @@ export function useIframeSetup(): UseIframeSetupReturn {
107
108
  element.removeAttribute('data-inline');
108
109
  return;
109
110
  }
110
- if (element.closest('script, style, svg path, svg g')) return;
111
+ if (element.closest('script, style')) return;
112
+ // SVG の中(path / circle / g など)は SVG ごと 1 つの図として扱う(utils/svg-hit.ts)。
113
+ // 以前は path と g だけを外していたので、circle や rect が単独で選ばれていた
114
+ if (isInsideSvg(element)) {
115
+ element.removeAttribute('data-editable');
116
+ element.removeAttribute('data-element-id');
117
+ return;
118
+ }
111
119
  if (element.classList.contains('material-icons')) return;
112
120
  if (element.classList.contains('material-icons-outlined')) return;
113
121
  // selection-box およびその子要素はスキップ
@@ -274,7 +282,8 @@ export function useIframeSetup(): UseIframeSetupReturn {
274
282
  clearPreview();
275
283
  return;
276
284
  }
277
- const next = resolveHoverTarget(e.target, iframeDoc, {
285
+ // SVG の箱の空きは素通りさせる(クリックの判別と同じ。utils/svg-hit.ts)
286
+ const next = resolveHoverTarget(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc, {
278
287
  meta: e.metaKey || e.ctrlKey,
279
288
  });
280
289
  if (next === currentPreviewElement) return;
@@ -7,9 +7,13 @@
7
7
  * - ホイール: パン(2 本指スクロール)。Shift で横
8
8
  * - Ctrl/Cmd + ホイール、トラックパッドのピンチ: カーソル位置を固定してズーム
9
9
  * - Space + ドラッグ / 中ボタンドラッグ: パン
10
- * - Cmd/Ctrl + / - : 段階ズーム。Shift+0 = 100%、Shift+1 = 全体、Shift+2 = 編集中のページ、Shift+R = 定規
10
+ * - Safari・WKWebView のトラックパッドのピンチ(gesturestart / gesturechange): カーソル位置を固定してズーム(0.10.0)
11
+ * - Cmd/Ctrl + / - : 段階ズーム(既定 1.25 倍。canvasZoomOptions.steps で 2 の累乗の段)。Shift+0 = 100%、Shift+1 = 全体、
12
+ * Shift+2 = 編集中のページ(canvasZoomOptions.fitShortcut: 'selection' なら選んでいる要素)、Shift+R = 定規
11
13
  * (Cmd+0 / 1 / 2 は既存のディスパッチャ(editorZoomApiRef)が受ける)
12
14
  * - 2 本指タッチ: ピンチズーム / 1 本指: パン
15
+ * - 拡大縮小はどの経路も zoomAt(カーソル位置が中心)に集める。キーとボタンは画面の中心
16
+ * - IME の変換中のキーは奪わない
13
17
  *
14
18
  * [iframe から来る操作]
15
19
  * 生きているエディタ(EditorCanvas)と見るだけの紙面は iframe なので、その上で起きた
@@ -21,7 +25,8 @@
21
25
 
22
26
  import { useEffect, useRef, useState, type RefObject } from 'react';
23
27
  import { useMultiPageCanvasOptional, MIN_ZOOM, MAX_ZOOM } from '../contexts/MultiPageCanvasContext';
24
- import { isSpaceActivatedControl } from './useCanvasControls';
28
+ import { isSpaceActivatedControl, editorZoomApiRef } from './useCanvasControls';
29
+ import { listenCanvasGestures, isImeKeyEvent } from '../utils/canvas-gestures';
25
30
 
26
31
  /** iframe 側が転送してくるメッセージ */
27
32
  export type EmbeddedCanvasMessage =
@@ -29,12 +34,12 @@ export type EmbeddedCanvasMessage =
29
34
  | { type: 'EMBEDDED_PINCH'; coords?: 'parent'; previousDistance: number; currentDistance: number; centerX: number; centerY: number }
30
35
  | { type: 'EMBEDDED_PAN_START'; coords?: 'parent'; clientX: number; clientY: number }
31
36
  | { type: 'EMBEDDED_KEY'; kind: 'down' | 'up'; key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean };
37
+ // ピンチ(GestureEvent)の転送 EMBEDDED_GESTURE は listenCanvasGestures が受ける(utils/canvas-gestures.ts)
32
38
 
33
39
  /** ホイール 1 目盛りのズーム係数。トラックパッド(小さい delta が連続)と、マウスホイール(大きい delta)で分ける */
34
40
  const PINCH_K = 0.01;
35
41
  const WHEEL_K = 0.0025;
36
42
  const MAX_STEP = 1.25;
37
- const KEY_STEP = 1.25;
38
43
 
39
44
  function wheelFactor(deltaY: number): number {
40
45
  const k = Math.abs(deltaY) < 50 ? PINCH_K : WHEEL_K;
@@ -87,10 +92,23 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
87
92
  return { clientX: rect.left + x * scale, clientY: rect.top + y * scale };
88
93
  };
89
94
 
95
+ // ---- Safari・WKWebView のピンチ(容器の上で起きたものと、紙面の iframe から転送されたもの)
96
+ const gestures = listenCanvasGestures({
97
+ win: window,
98
+ container,
99
+ target: () => ({
100
+ getZoom: () => c().viewStore.get().canvasZoom,
101
+ zoomAt: (zoom, clientX, clientY) => c().zoomAt(zoom, toContainerPoint(clientX, clientY)),
102
+ markInteracting: () => c().markInteracting(),
103
+ }),
104
+ });
105
+
90
106
  // ---- ズーム/パンの実処理(ホイールと転送メッセージで共通)
91
107
  const applyWheel = (deltaX: number, deltaY: number, clientX: number, clientY: number, zoomGesture: boolean, shiftKey: boolean) => {
92
108
  c().markInteracting();
93
109
  if (zoomGesture) {
110
+ // ピンチ(GestureEvent)の最中に ctrl 付き wheel も届く環境で二重に拡大しない
111
+ if (gestures.active) return;
94
112
  const { canvasZoom } = c().viewStore.get();
95
113
  c().zoomAt(canvasZoom * wheelFactor(deltaY), toContainerPoint(clientX, clientY));
96
114
  return;
@@ -129,7 +147,8 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
129
147
  };
130
148
 
131
149
  // ---- キー
132
- const stepZoom = (dir: 1 | -1) => c().zoomTo(c().viewStore.get().canvasZoom * (dir > 0 ? KEY_STEP : 1 / KEY_STEP), { animate: true });
150
+ // ヘッダーの +/− と同じ zoomIn / zoomOut(刻みは canvasZoomOptions.steps)
151
+ const stepZoom = (dir: 1 | -1) => (dir > 0 ? c().zoomIn() : c().zoomOut());
133
152
  const handleKey = (kind: 'down' | 'up', key: string, code: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }, preventDefault: () => void, typing: boolean) => {
134
153
  if (code === 'Space') {
135
154
  if (typing) return;
@@ -155,8 +174,11 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
155
174
  if (key === '0' || code === 'Digit0') { preventDefault(); c().zoomToActual(); return; }
156
175
  if (key === '1' || code === 'Digit1') { preventDefault(); c().zoomToFit({ animate: true }); return; }
157
176
  if (key === '2' || code === 'Digit2') {
177
+ // ⌘2 と同じ合わせ先(既定は編集中のページ。fitShortcut: 'selection' なら選んでいる要素)
178
+ preventDefault();
179
+ if (editorZoomApiRef.current) { editorZoomApiRef.current.selection(); return; }
158
180
  const id = c().viewStore.get().activePageId;
159
- if (id) { preventDefault(); c().zoomToPage(id, { animate: true }); }
181
+ if (id) c().zoomToPage(id, { animate: true });
160
182
  return;
161
183
  }
162
184
  if (key.toLowerCase() === 'r' || code === 'KeyR') { preventDefault(); c().toggleRulers(); return; }
@@ -167,6 +189,7 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
167
189
  const spaceBelongsToControl = (e: KeyboardEvent) =>
168
190
  e.code === 'Space' && (isSpaceActivatedControl(e.target) || isSpaceActivatedControl(document.activeElement));
169
191
  const handleKeyDown = (e: KeyboardEvent) => {
192
+ if (isImeKeyEvent(e)) return; // IME の変換中のキーは奪わない
170
193
  if (spaceBelongsToControl(e)) return;
171
194
  if (e.repeat && e.code === 'Space') { if (!isTypingTarget(e.target)) e.preventDefault(); return; }
172
195
  handleKey('down', e.key, e.code, e, () => e.preventDefault(), isTypingTarget(e.target));
@@ -284,6 +307,7 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
284
307
  container.addEventListener('touchcancel', handleTouchEnd);
285
308
 
286
309
  return () => {
310
+ gestures.dispose();
287
311
  window.removeEventListener('wheel', preventNativeZoom, { capture: true });
288
312
  document.removeEventListener('wheel', preventNativeZoom, { capture: true });
289
313
  container.removeEventListener('wheel', handleWheel);
@@ -17,6 +17,7 @@
17
17
  import type { EditorPartDef } from '../io';
18
18
  import { findFlowInsertion } from './utils/drop-target';
19
19
  import { isNonEditableTag } from './utils/dom-utils';
20
+ import { isInsideSvg } from './utils/svg-hit';
20
21
 
21
22
  export const PART_ATTR = 'data-part';
22
23
  export const PART_VERSION_ATTR = 'data-part-v';
@@ -317,7 +318,7 @@ export function unlockPartDescendants(root: HTMLElement): number {
317
318
  let i = 0;
318
319
  const stamp = Date.now();
319
320
  for (const el of Array.from(root.querySelectorAll('*'))) {
320
- if (el.closest('script, style, svg path, svg g')) continue;
321
+ if (el.closest('script, style') || isInsideSvg(el)) continue;
321
322
  if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue;
322
323
  if (isNonEditableTag(el)) continue;
323
324
  if (!el.getAttribute('data-element-id')) {
@@ -0,0 +1,210 @@
1
+ /**
2
+ * マルチフレームのキャンバスの拡大縮小を Figma にそろえる部品(0.10.0)。
3
+ *
4
+ * - Safari・WKWebView のトラックパッドのピンチは gesturestart / gesturechange / gestureend で届く
5
+ * (Chrome・Edge・Electron は ctrl 付きの wheel で届く)。以前は止めるだけで拡大縮小に使っていなかった。
6
+ * scale は gesturestart からの累積なので、始まりの倍率に掛ける。紙面(iframe)の中で起きたものは親へ転送する
7
+ * - ⌘+ / ⌘− とヘッダーの +/− は 2 の累乗の段(…50% → 100% → 200%…。段の間からは向きの側の段へ)
8
+ * - IME の変換中のキーは奪わない
9
+ * - 選んでいる要素に合わせる(⌘2 / ⇧2)ための矩形
10
+ *
11
+ * DOM と数値だけを扱い、React やキャンバスの Context には触らない(受け手が zoomAt 等を渡す)。
12
+ */
13
+
14
+ export interface ZoomRange {
15
+ min: number;
16
+ max: number;
17
+ }
18
+
19
+ /**
20
+ * マルチフレームのキャンバスの拡大縮小の設定(`canvasZoomOptions`)。どれも省略時は従来の挙動。
21
+ * Figma にそろえるなら `{ min: 0.02, max: 256, steps: 'powers-of-two', fitShortcut: 'selection' }`
22
+ */
23
+ export interface CanvasZoomOptions {
24
+ /** 倍率の下限(省略時 0.02 = 2%) */
25
+ min?: number;
26
+ /** 倍率の上限(省略時 4 = 400%。Figma は 256 = 25600%) */
27
+ max?: number;
28
+ /** ⌘+ / ⌘− とヘッダーの +/− の刻み。'ratio' = 1.25 倍ずつ(省略時)、'powers-of-two' = 2 の累乗の段(Figma) */
29
+ steps?: 'ratio' | 'powers-of-two';
30
+ /** ⌘2 / ⇧2 の合わせ先。'page' = 編集中のページ(省略時)、'selection' = 選んでいる要素(無ければページ。Figma) */
31
+ fitShortcut?: 'page' | 'selection';
32
+ }
33
+ export type ResolvedCanvasZoomOptions = ZoomRange & Required<Pick<CanvasZoomOptions, 'steps' | 'fitShortcut'>>;
34
+
35
+ const clamp = (zoom: number, range: ZoomRange) => Math.max(range.min, Math.min(range.max, zoom));
36
+
37
+ /** ⌘+ / ⌘− の次の倍率。2 の累乗の段へ寄せる(今が段の上なら 1 段、段の間なら向きの側の段へ) */
38
+ export function nextZoomLevel(current: number, direction: 1 | -1, range: ZoomRange): number {
39
+ if (!Number.isFinite(current) || current <= 0) return clamp(1, range);
40
+ const exponent = Math.log2(current);
41
+ const EPSILON = 1e-6;
42
+ const next = direction > 0 ? Math.floor(exponent + EPSILON) + 1 : Math.ceil(exponent - EPSILON) - 1;
43
+ return clamp(2 ** next, range);
44
+ }
45
+
46
+ /** IME の変換中のキーか(Safari・Chrome は変換中の keydown に keyCode 229 を出す) */
47
+ export function isImeKeyEvent(event: KeyboardEvent): boolean {
48
+ return event.isComposing === true || event.keyCode === 229;
49
+ }
50
+
51
+ // ------------------------------------------------------------------ ピンチ(GestureEvent)
52
+
53
+ /** WebKit の GestureEvent(型定義は標準の lib に無い) */
54
+ export type CanvasGestureEvent = Event & { scale?: number; clientX?: number; clientY?: number };
55
+ export type CanvasGesturePhase = 'start' | 'change' | 'end';
56
+ export interface EmbeddedGestureMessage {
57
+ type: 'EMBEDDED_GESTURE';
58
+ coords: 'parent';
59
+ phase: CanvasGesturePhase;
60
+ scale: number;
61
+ clientX: number;
62
+ clientY: number;
63
+ }
64
+
65
+ export interface GestureZoomTarget {
66
+ getZoom: () => number;
67
+ /** client 座標の点を動かさずに倍率を変える(倍率の範囲は受け手が収める) */
68
+ zoomAt: (zoom: number, clientX: number, clientY: number) => void;
69
+ markInteracting?: () => void;
70
+ }
71
+
72
+ /** これだけピンチの続きが来なければ、終わったものとみなす(ms) */
73
+ const GESTURE_IDLE_MS = 1000;
74
+ const PHASES: Array<[CanvasGesturePhase, string]> = [['start', 'gesturestart'], ['change', 'gesturechange'], ['end', 'gestureend']];
75
+ const LISTEN = { passive: false, capture: true } as const;
76
+
77
+ /**
78
+ * ピンチの状態。始まりの倍率を覚え、scale(累積)を掛ける。
79
+ * `active` の間は ctrl 付き wheel での拡大をしない(両方届く環境で二重に拡大しない)。
80
+ * end を取りこぼしたまま止まったら GESTURE_IDLE_MS で解く(ctrl 付き wheel の拡大が戻らなくならない)
81
+ */
82
+ export function createGestureZoom(target: () => GestureZoomTarget | null, now: () => number = () => Date.now()) {
83
+ let base: number | null = null;
84
+ let lastAt = 0;
85
+ const isLive = () => base !== null && now() - lastAt < GESTURE_IDLE_MS;
86
+ return {
87
+ get active() { return isLive(); },
88
+ handle(phase: CanvasGesturePhase, scale: number, clientX: number, clientY: number) {
89
+ const canvas = target();
90
+ if (!canvas) return;
91
+ if (phase === 'start') { base = canvas.getZoom(); lastAt = now(); return; }
92
+ // start を取りこぼしても(iframe の読み込み直し等)、今の倍率を始まりにして続ける
93
+ const start = isLive() && base !== null ? base : canvas.getZoom();
94
+ base = start;
95
+ lastAt = now();
96
+ if (Number.isFinite(scale) && scale > 0) {
97
+ canvas.markInteracting?.();
98
+ canvas.zoomAt(start * scale, clientX, clientY);
99
+ }
100
+ if (phase === 'end') base = null;
101
+ },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 親(キャンバスの容器がある文書)でピンチを受ける。容器の上で起きたものと、紙面の iframe から転送されてきたもの。
107
+ * 容器の外(ヘッダー・パネル)のピンチは拡大しない(ブラウザ自体の拡大は useBrowserZoomPrevention が止める)。
108
+ * GestureEvent に座標が無い環境では、最後にポインタがあった所を中心にする。
109
+ */
110
+ export function listenCanvasGestures(input: {
111
+ win: Window;
112
+ container: HTMLElement;
113
+ target: () => GestureZoomTarget | null;
114
+ }): { readonly active: boolean; dispose: () => void } {
115
+ const { win, container } = input;
116
+ const zoom = createGestureZoom(input.target);
117
+ let pointer: { x: number; y: number } | null = null;
118
+ const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
119
+ const pointOf = (event: CanvasGestureEvent) => {
120
+ if (Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) return { x: event.clientX as number, y: event.clientY as number };
121
+ if (pointer) return pointer;
122
+ const rect = container.getBoundingClientRect();
123
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
124
+ };
125
+ const handlers = PHASES.map(([phase, type]) => {
126
+ const handler = (event: Event) => {
127
+ const target = event.target as Node | null;
128
+ if (!target || typeof target.nodeType !== 'number' || !container.contains(target)) return;
129
+ event.preventDefault();
130
+ const gesture = event as CanvasGestureEvent;
131
+ const point = pointOf(gesture);
132
+ zoom.handle(phase, Number(gesture.scale ?? 1), point.x, point.y);
133
+ };
134
+ return [type, handler] as const;
135
+ });
136
+ const onMessage = (event: MessageEvent) => {
137
+ const data = event.data as Partial<EmbeddedGestureMessage> | null;
138
+ if (!data || typeof data !== 'object' || data.type !== 'EMBEDDED_GESTURE' || data.coords !== 'parent') return;
139
+ if (data.phase !== 'start' && data.phase !== 'change' && data.phase !== 'end') return;
140
+ zoom.handle(data.phase, Number(data.scale), Number(data.clientX), Number(data.clientY));
141
+ };
142
+ for (const [type, handler] of handlers) win.addEventListener(type, handler, LISTEN);
143
+ win.addEventListener('mousemove', onPointer, { passive: true });
144
+ win.addEventListener('message', onMessage);
145
+ return {
146
+ get active() { return zoom.active; },
147
+ dispose() {
148
+ for (const [type, handler] of handlers) win.removeEventListener(type, handler, LISTEN);
149
+ win.removeEventListener('mousemove', onPointer);
150
+ win.removeEventListener('message', onMessage);
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * 紙面(生きているエディタの iframe)で起きたピンチを親へ転送する。座標は送る前に親の座標へ直す
157
+ * (親の realm で作った関数なので、受け手からは送り元の iframe が分からない。ホイールの転送と同じ)。
158
+ */
159
+ export function forwardEmbeddedGestures(input: {
160
+ doc: Document;
161
+ toParent: (x: number, y: number) => { clientX: number; clientY: number };
162
+ post: (message: EmbeddedGestureMessage) => void;
163
+ }): () => void {
164
+ const { doc } = input;
165
+ let pointer: { x: number; y: number } | null = null;
166
+ const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
167
+ const handlers = PHASES.map(([phase, type]) => {
168
+ const handler = (event: Event) => {
169
+ const gesture = event as CanvasGestureEvent;
170
+ event.preventDefault();
171
+ event.stopPropagation();
172
+ const hasPoint = Number.isFinite(gesture.clientX) && Number.isFinite(gesture.clientY);
173
+ const local = hasPoint
174
+ ? { x: gesture.clientX as number, y: gesture.clientY as number }
175
+ : pointer ?? { x: (doc.defaultView?.innerWidth ?? 0) / 2, y: (doc.defaultView?.innerHeight ?? 0) / 2 };
176
+ input.post({ type: 'EMBEDDED_GESTURE', coords: 'parent', phase, scale: Number(gesture.scale ?? 1), ...input.toParent(local.x, local.y) });
177
+ };
178
+ return [type, handler] as const;
179
+ });
180
+ for (const [type, handler] of handlers) doc.addEventListener(type, handler, LISTEN);
181
+ doc.addEventListener('mousemove', onPointer, { passive: true, capture: true });
182
+ return () => {
183
+ for (const [type, handler] of handlers) doc.removeEventListener(type, handler, LISTEN);
184
+ doc.removeEventListener('mousemove', onPointer, { capture: true });
185
+ };
186
+ }
187
+
188
+ // ------------------------------------------------------------------ 選択範囲に合わせる
189
+
190
+ /**
191
+ * 紙面で選んでいる要素(#artboard の .selected)を囲む矩形を、キャンバスの座標で返す。無ければ null。
192
+ * 埋め込みの紙面は iframe の中では等倍なので、iframe の中の矩形にフレームの位置を足せばよい。
193
+ */
194
+ export function selectionCanvasRect(
195
+ iframe: HTMLIFrameElement | null | undefined,
196
+ framePosition: { x: number; y: number } | null | undefined,
197
+ ): { minX: number; minY: number; maxX: number; maxY: number } | null {
198
+ const doc = iframe?.contentDocument;
199
+ if (!doc || !framePosition) return null;
200
+ const rects = Array.from(doc.querySelectorAll<HTMLElement>('#artboard .selected'))
201
+ .map((element) => element.getBoundingClientRect())
202
+ .filter((rect) => rect.width > 0 || rect.height > 0);
203
+ if (!rects.length) return null;
204
+ return {
205
+ minX: framePosition.x + Math.min(...rects.map((rect) => rect.left)),
206
+ minY: framePosition.y + Math.min(...rects.map((rect) => rect.top)),
207
+ maxX: framePosition.x + Math.max(...rects.map((rect) => rect.right)),
208
+ maxY: framePosition.y + Math.max(...rects.map((rect) => rect.bottom)),
209
+ };
210
+ }
@@ -8,6 +8,8 @@
8
8
  * 座標は iframe の座標のまま送る(受け手が送り元 iframe の実測矩形で親の座標へ直す)
9
9
  */
10
10
 
11
+ import { forwardEmbeddedGestures, isImeKeyEvent } from './canvas-gestures';
12
+
11
13
  const ZOOM_KEYS = new Set(['=', '+', '-', '_', ';']);
12
14
 
13
15
  function isTypingTarget(target: EventTarget | null): boolean {
@@ -50,11 +52,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
50
52
  );
51
53
  };
52
54
 
53
- // ---- Safari の gesture(ピンチ)はブラウザ拡大を止めるだけ
54
- const handleGesture = (e: Event) => {
55
- e.preventDefault();
56
- e.stopPropagation();
57
- };
55
+ // ---- Safari・WKWebView の gesture(ピンチ)も外へ(親が拡大縮小する。ブラウザの拡大は止める)
56
+ const disposeGestures = forwardEmbeddedGestures({ doc: iframeDoc, toParent, post: (message) => parent.postMessage(message, '*') });
58
57
 
59
58
  // ---- 2 本指タッチ = ピンチ
60
59
  let lastDistance: number | null = null;
@@ -90,6 +89,7 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
90
89
 
91
90
  // ---- キー: Space(パン)、Cmd+/- (ズーム)、Shift+0/1/2(100%・全体・このページ)、Shift+R(定規)
92
91
  const forwardKey = (kind: 'down' | 'up') => (e: KeyboardEvent) => {
92
+ if (isImeKeyEvent(e)) return; // IME の変換中のキーは奪わない
93
93
  const cmd = e.metaKey || e.ctrlKey;
94
94
  const typing = isTypingTarget(e.target);
95
95
  const isSpace = e.code === 'Space';
@@ -128,9 +128,6 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
128
128
 
129
129
  const options: AddEventListenerOptions = { passive: false, capture: true };
130
130
  iframeDoc.addEventListener('wheel', handleWheel, options);
131
- iframeDoc.addEventListener('gesturestart', handleGesture, options);
132
- iframeDoc.addEventListener('gesturechange', handleGesture, options);
133
- iframeDoc.addEventListener('gestureend', handleGesture, options);
134
131
  iframeDoc.addEventListener('touchstart', handleTouchStart, options);
135
132
  iframeDoc.addEventListener('touchmove', handleTouchMove, options);
136
133
  iframeDoc.addEventListener('touchend', handleTouchEnd, options);
@@ -140,10 +137,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
140
137
  iframeDoc.addEventListener('mousedown', handleMouseDown, options);
141
138
 
142
139
  return () => {
140
+ disposeGestures();
143
141
  iframeDoc.removeEventListener('wheel', handleWheel, options);
144
- iframeDoc.removeEventListener('gesturestart', handleGesture, options);
145
- iframeDoc.removeEventListener('gesturechange', handleGesture, options);
146
- iframeDoc.removeEventListener('gestureend', handleGesture, options);
147
142
  iframeDoc.removeEventListener('touchstart', handleTouchStart, options);
148
143
  iframeDoc.removeEventListener('touchmove', handleTouchMove, options);
149
144
  iframeDoc.removeEventListener('touchend', handleTouchEnd, options);