@growgroup/visual-editor 0.9.1 → 0.11.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 (45) hide show
  1. package/README.md +90 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +5 -1
  5. package/src/editor/FrontendVisualEditor.tsx +26 -12
  6. package/src/editor/components/EditorCanvas.tsx +11 -0
  7. package/src/editor/components/EditorContextMenu.tsx +52 -2
  8. package/src/editor/components/EditorFooter.tsx +29 -6
  9. package/src/editor/components/EditorHeader.tsx +15 -2
  10. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  11. package/src/editor/components/LayerTreeItem.tsx +107 -3
  12. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  13. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  14. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
  15. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  16. package/src/editor/components/ppt/PptComments.tsx +98 -1
  17. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  19. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  20. package/src/editor/constants.ts +25 -0
  21. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
  23. package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
  24. package/src/editor/hooks/useCanvasControls.ts +43 -9
  25. package/src/editor/hooks/useDragResize.ts +205 -17
  26. package/src/editor/hooks/useElementActions.ts +119 -34
  27. package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
  28. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  29. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  30. package/src/editor/utils/align-elements.ts +49 -0
  31. package/src/editor/utils/canvas-gestures.ts +210 -0
  32. package/src/editor/utils/canvas-guides.ts +46 -0
  33. package/src/editor/utils/dom-utils.ts +12 -2
  34. package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
  35. package/src/editor/utils/history-timing.ts +9 -0
  36. package/src/editor/utils/html-utils.ts +4 -0
  37. package/src/editor/utils/lock.ts +24 -0
  38. package/src/editor/utils/smart-duplicate.ts +51 -0
  39. package/src/editor/utils/smart-guides.ts +232 -2
  40. package/src/hooks/useEditorShortcuts.ts +46 -2
  41. package/src/index.ts +5 -1
  42. package/src/io.ts +54 -0
  43. package/src/styles/skin.css +5 -0
  44. package/src/types/editor.ts +120 -1
  45. package/src/version.ts +1 -1
@@ -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,13 @@
17
17
 
18
18
  import { useCallback } from 'react';
19
19
  import { refreshSelectionOverlay, getArtboardContent } from '../utils/dom-utils';
20
+ import { useEditorContext } from '../EditorContext';
21
+ import { COALESCE_WINDOW_MS } from '../utils/history-timing';
22
+ import { alignElements, distributeElements, mirrorPositions, type AlignMode, type DistributeAxis } from '../utils/align-elements';
23
+ import { applyTailwindStyles } from '../utils/tailwind-utils';
24
+ import { extractElementInfo } from '../utils/style-utils';
25
+ import type { ShortcutCallbacks } from '../../types/editor';
26
+ import { LOCK_ATTR, isLockedEl, withoutLocked } from '../utils/lock';
20
27
 
21
28
  interface UseKeyboardShortcutsOptions {
22
29
  /** グループ化関数 */
@@ -91,6 +98,8 @@ export function selectAllSiblingsIn(
91
98
  return !ancestor || !artboard.contains(ancestor);
92
99
  });
93
100
  }
101
+ // ロック中の要素は紙面からは選ばない(⌘A。Figma の Select all もロック中を飛ばす。記憶)
102
+ siblings = siblings.filter((el) => !isLockedEl(el));
94
103
  if (siblings.length === 0) return { ids: [], elements: [] };
95
104
 
96
105
  iframeDoc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
@@ -152,6 +161,8 @@ export function selectSiblingIn(
152
161
  return !ancestor || !artboard.contains(ancestor);
153
162
  });
154
163
  }
164
+ // Tab の巡回もロック中の要素を飛ばす
165
+ siblings = siblings.filter((el) => !isLockedEl(el));
155
166
  if (siblings.length === 0) return null;
156
167
 
157
168
  const index = current ? siblings.indexOf(current) : -1;
@@ -214,3 +225,479 @@ export function useKeyboardShortcuts(
214
225
 
215
226
  return { setupKeyboardShortcuts };
216
227
  }
228
+
229
+ // ============================================================
230
+ // Figma の作法に合わせたキャンバス操作(Enter / Shift+Enter / 整列 / 表示・ロック / 不透明度 / 書式)
231
+ // ============================================================
232
+
233
+ /** 選択枠などオーバーレイの中の要素は「紙面の要素」として数えない */
234
+ function isOverlayNode(el: Element): boolean {
235
+ return !!el.closest('.selection-box, #gg-smart-guides, #gg-measure-layer');
236
+ }
237
+
238
+ /**
239
+ * el の「1段内側」の要素。編集できない包み(レイアウト用の div 等)は透過して、
240
+ * 枝ごとに最初に当たる編集可能な要素を拾う(Figma のレイヤーパネルの1段下と同じ見え方)
241
+ */
242
+ export function editableChildrenOf(el: HTMLElement): HTMLElement[] {
243
+ const out: HTMLElement[] = [];
244
+ const walk = (node: Element) => {
245
+ for (const child of Array.from(node.children)) {
246
+ if (isOverlayNode(child)) continue;
247
+ if (child.getAttribute('data-editable') === 'true' && child.getAttribute('data-element-id')) {
248
+ out.push(child as HTMLElement);
249
+ } else {
250
+ walk(child);
251
+ }
252
+ }
253
+ };
254
+ walk(el);
255
+ return out;
256
+ }
257
+
258
+ /** el の1段外側の編集可能な要素。紙面(#artboard)の外へは出ない */
259
+ export function editableParentOf(el: HTMLElement): HTMLElement | null {
260
+ const artboard = el.ownerDocument.getElementById('artboard');
261
+ const parent = el.parentElement?.closest<HTMLElement>('[data-editable="true"][data-element-id]') ?? null;
262
+ if (!parent || !artboard || parent === artboard || !artboard.contains(parent)) return null;
263
+ // 紙面いっぱいのルート(スライドの面)は「親」として選ばない。
264
+ // 選べても全体が青くなるだけで、最上位の要素から Esc で抜けられなくなる
265
+ if (
266
+ parent.parentElement === artboard &&
267
+ parent.offsetWidth >= artboard.offsetWidth &&
268
+ parent.offsetHeight >= artboard.offsetHeight
269
+ ) {
270
+ return null;
271
+ }
272
+ return parent;
273
+ }
274
+
275
+ /**
276
+ * 文字を直に持つ要素か(Enter で「子を選ぶ」ではなく「文字の編集に入る」対象)。
277
+ * <p>本文<span>強調</span></p> のような段落は、中の span を子として選ぶより
278
+ * 文字の編集に入るほうが Figma のテキストレイヤーの感覚に近い
279
+ */
280
+ function hasOwnText(el: HTMLElement): boolean {
281
+ return Array.from(el.childNodes).some(
282
+ (n) => n.nodeType === 3 && (n.textContent ?? '').trim().length > 0,
283
+ );
284
+ }
285
+
286
+ function selectedEls(doc: Document, ids: string[]): HTMLElement[] {
287
+ return ids
288
+ .map((id) => doc.querySelector<HTMLElement>(`[data-element-id="${id}"]`))
289
+ .filter((el): el is HTMLElement => !!el && !isOverlayNode(el));
290
+ }
291
+
292
+ /** DOM の選択表示を els に揃える(クラスと枠)。React 側の状態は呼び出し側で合わせる */
293
+ function markSelected(doc: Document, els: HTMLElement[]): void {
294
+ doc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
295
+ els.forEach((el) => el.classList.add('selected'));
296
+ refreshSelectionOverlay(doc);
297
+ }
298
+
299
+ /** 文字の編集に入る。既存のダブルクリック処理(選択済み+文字要素→編集)をそのまま通す */
300
+ function startTextEditingViaDblClick(el: HTMLElement): void {
301
+ const view = el.ownerDocument.defaultView;
302
+ if (!view) return;
303
+ const r = el.getBoundingClientRect();
304
+ el.dispatchEvent(
305
+ new view.MouseEvent('dblclick', {
306
+ bubbles: true,
307
+ cancelable: true,
308
+ clientX: r.left + Math.min(8, r.width / 2),
309
+ clientY: r.top + r.height / 2,
310
+ detail: 2,
311
+ }),
312
+ );
313
+ }
314
+
315
+ /**
316
+ * 数字を2つ続けて押したとみなす間隔(ms)。Figma の「4→5 で 45%」(記憶にもとづく・未確認)。
317
+ * 履歴が同じ要素への続けての変更を1つにまとめる間隔と揃える。こうすると2打目が1打目の履歴を
318
+ * 置き換え、⌘Z 1回で「押す前」に戻る(間隔がずれると1打目だけが別の履歴として残る)
319
+ */
320
+ const OPACITY_TWO_DIGIT_MS = COALESCE_WINDOW_MS;
321
+ let lastOpacityDigit: { digit: number; at: number } | null = null;
322
+
323
+ /**
324
+ * transform の中の scaleX(-1) / scaleY(-1) を付け外しする。rotate など他の関数は順序ごと残す。
325
+ * 回転のドラッグ(useDragResize の setTransformFn)は scaleX / scaleY を消さないので両立する
326
+ */
327
+ export function toggleFlipIn(transform: string, axis: 'x' | 'y'): string {
328
+ const fn = axis === 'x' ? 'scaleX(-1)' : 'scaleY(-1)';
329
+ const pattern = axis === 'x' ? /\s*scaleX\(-1\)/g : /\s*scaleY\(-1\)/g;
330
+ // 回転している要素は、画面の軸で鏡写しにする(Figma と同じ見え方)。
331
+ // 鏡写し × rotate(a) = rotate(-a) × 鏡写し なので、角度の符号を反転してから反転を付け外しする
332
+ const mirrored = transform.replace(/rotate\((-?[\d.]+)deg\)/, (_, deg: string) => {
333
+ const next = -Number(deg);
334
+ return `rotate(${Object.is(next, -0) ? 0 : next}deg)`;
335
+ });
336
+ const cleaned = mirrored.replace(pattern, '').trim();
337
+ if (cleaned !== mirrored.trim()) return cleaned;
338
+ return cleaned ? `${cleaned} ${fn}` : fn;
339
+ }
340
+
341
+ /** ⌘R でレイヤー名の入力を開く合図。レイヤーパネル(と左パネルのタブ)が受け取る */
342
+ export const RENAME_LAYER_EVENT = 'gg:rename-layer';
343
+
344
+ /** ⌘\ で UI を隠すときにエディタの外枠に付けるクラス(skin.css が見る) */
345
+ const UI_HIDDEN_CLASS = 'gg-ui-hidden';
346
+
347
+ /** 文字を直に持つ要素(テキストノードの子がある要素)。⌘⇧> / ⌘⇧< の対象 */
348
+ function textBearers(root: HTMLElement): HTMLElement[] {
349
+ const all = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
350
+ return all.filter(
351
+ (el) =>
352
+ !el.closest('.selection-box') &&
353
+ Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim()),
354
+ );
355
+ }
356
+
357
+ /**
358
+ * ⌘\: エディタの外枠(.gg-editor-ui)にクラスを付け外しして、上の帯・左右のパネル・下の帯・道具箱を隠す。
359
+ * 紙面(キャンバス)だけが残る。もう一度 ⌘\ で戻る(Figma の Hide/Show UI)
360
+ */
361
+ function toggleEditorUi(): void {
362
+ const shell = document.querySelector('.gg-editor-ui');
363
+ if (!shell) return;
364
+ shell.classList.toggle(UI_HIDDEN_CLASS);
365
+ }
366
+
367
+ /** ⌘⇧O のアウトライン表示。iframe の <html> にクラスを付ける(保存は #artboard の中身だけなので本文には残らない) */
368
+ const OUTLINE_CLASS = 'gg-outline-mode';
369
+ const OUTLINE_STYLE_ID = 'gg-outline-mode-style';
370
+ /**
371
+ * 輪郭表示でも消さないエディタの飾り(選択枠・ハンドル・寸法・吸着ガイド・計測など)。
372
+ * #artboard の中に描かれているので、除外しないと一緒に透明になって選択枠や寸法が見えなくなる
373
+ */
374
+ const OUTLINE_KEEP =
375
+ '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,.size-label,.element-breadcrumb,' +
376
+ '#gg-smart-guides,#gg-measure-layer,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui';
377
+ const OUTLINE_TARGET = `html.${OUTLINE_CLASS} #artboard *:not(:is(${OUTLINE_KEEP})):not(:is(${OUTLINE_KEEP}) *)`;
378
+ const OUTLINE_CSS = `
379
+ html.${OUTLINE_CLASS} #artboard {
380
+ background: #fff !important;
381
+ }
382
+ ${OUTLINE_TARGET} {
383
+ background: transparent !important;
384
+ box-shadow: none !important;
385
+ border-color: transparent !important;
386
+ outline: 1px solid rgba(0, 0, 0, 0.55) !important;
387
+ outline-offset: -1px;
388
+ -webkit-text-fill-color: transparent !important;
389
+ -webkit-text-stroke: 0.8px #222 !important;
390
+ }
391
+ ${OUTLINE_TARGET}:is(img, video), ${OUTLINE_TARGET}:is(svg *) {
392
+ opacity: 0 !important;
393
+ }
394
+ `;
395
+
396
+ /**
397
+ * ⌘⇧O: 紙面の塗り・画像を消して、各要素の枠と文字の輪郭だけを見せる(Figma の Show outlines)。
398
+ * 重なって見えない要素や、透明な器の大きさを確かめるのに使う。もう一度押すと戻る
399
+ */
400
+ function toggleOutlineIn(doc: Document): void {
401
+ if (!doc.getElementById(OUTLINE_STYLE_ID)) {
402
+ const style = doc.createElement('style');
403
+ style.id = OUTLINE_STYLE_ID;
404
+ style.textContent = OUTLINE_CSS;
405
+ doc.head.appendChild(style);
406
+ }
407
+ doc.documentElement.classList.toggle(OUTLINE_CLASS);
408
+ }
409
+
410
+ const OPACITY_KEYS = ['opacity1', 'opacity2', 'opacity3', 'opacity4', 'opacity5', 'opacity6', 'opacity7', 'opacity8', 'opacity9', 'opacity0'] as const;
411
+
412
+ /**
413
+ * Figma 準拠のキャンバス操作をショートカットのコールバック表として返す。
414
+ * FrontendVisualEditor の shortcutCallbacks に展開して使う。
415
+ * 選択が無いときは undefined を返し、キーをブラウザ既定に通す(何も奪わない)。
416
+ */
417
+ export function useCanvasShortcutActions(): ShortcutCallbacks {
418
+ const {
419
+ getIframeDoc,
420
+ selectedElement,
421
+ selectedElementIds,
422
+ setSelectedElement,
423
+ setSelectedElementIds,
424
+ notifyIframeChange,
425
+ restoreFocus,
426
+ } = useEditorContext();
427
+
428
+ const ids = selectedElementIds.length ? selectedElementIds : selectedElement ? [selectedElement.id] : [];
429
+ const hasSelection = ids.length > 0;
430
+
431
+ /** DOM の選択を React 側へ反映する(右パネル・レイヤーパネルが追従する) */
432
+ const commit = useCallback(
433
+ (doc: Document, els: HTMLElement[]) => {
434
+ markSelected(doc, els);
435
+ setSelectedElementIds(els.map((el) => el.getAttribute('data-element-id') || '').filter(Boolean));
436
+ const info = els[0] ? extractElementInfo(els[0], doc) : null;
437
+ setSelectedElement(info ?? null);
438
+ restoreFocus();
439
+ },
440
+ [setSelectedElementIds, setSelectedElement, restoreFocus],
441
+ );
442
+
443
+ /** 書き換えたあとの後始末(履歴へ積み、枠と右パネルを描き直す) */
444
+ const afterChange = useCallback(
445
+ (doc: Document, changed: HTMLElement[]) => {
446
+ if (!changed.length) return;
447
+ notifyIframeChange();
448
+ requestAnimationFrame(() => refreshSelectionOverlay(doc));
449
+ const first = selectedEls(doc, ids)[0];
450
+ const info = first ? extractElementInfo(first, doc) : null;
451
+ if (info) setSelectedElement(info);
452
+ },
453
+ // ids は毎レンダー作り直すので文字列で比べる
454
+ // eslint-disable-next-line react-hooks/exhaustive-deps
455
+ [notifyIframeChange, setSelectedElement, ids.join(',')],
456
+ );
457
+
458
+ /** Enter: 子を選ぶ。文字を直に持つ要素なら文字の編集に入る */
459
+ const selectChildren = useCallback(() => {
460
+ const doc = getIframeDoc();
461
+ if (!doc) return;
462
+ const els = selectedEls(doc, ids);
463
+ if (els.length === 1 && hasOwnText(els[0])) {
464
+ startTextEditingViaDblClick(els[0]);
465
+ return;
466
+ }
467
+ const children = els.flatMap(editableChildrenOf);
468
+ if (children.length === 0) {
469
+ // 子が無い葉(画像・図形・文字だけの要素)。文字を持つなら編集に入る
470
+ if (els.length === 1 && (els[0].textContent ?? '').trim()) startTextEditingViaDblClick(els[0]);
471
+ return;
472
+ }
473
+ commit(doc, children);
474
+ // eslint-disable-next-line react-hooks/exhaustive-deps
475
+ }, [getIframeDoc, commit, ids.join(',')]);
476
+
477
+ /** Shift+Enter: 親を選ぶ(複数選択なら各要素の親をまとめて)。最上位では何もしない */
478
+ const selectParent = useCallback(() => {
479
+ const doc = getIframeDoc();
480
+ if (!doc) return;
481
+ const parents = Array.from(
482
+ new Set(selectedEls(doc, ids).map(editableParentOf).filter((p): p is HTMLElement => !!p)),
483
+ );
484
+ if (parents.length === 0) return;
485
+ commit(doc, parents);
486
+ // eslint-disable-next-line react-hooks/exhaustive-deps
487
+ }, [getIframeDoc, commit, ids.join(',')]);
488
+
489
+ const runAlign = useCallback(
490
+ (mode: AlignMode) => {
491
+ const doc = getIframeDoc();
492
+ if (!doc) return;
493
+ afterChange(doc, alignElements(withoutLocked(selectedEls(doc, ids)), mode));
494
+ },
495
+ // eslint-disable-next-line react-hooks/exhaustive-deps
496
+ [getIframeDoc, afterChange, ids.join(',')],
497
+ );
498
+
499
+ const runDistribute = useCallback(
500
+ (axis: DistributeAxis) => {
501
+ const doc = getIframeDoc();
502
+ if (!doc) return;
503
+ afterChange(doc, distributeElements(withoutLocked(selectedEls(doc, ids)), axis));
504
+ },
505
+ // eslint-disable-next-line react-hooks/exhaustive-deps
506
+ [getIframeDoc, afterChange, ids.join(',')],
507
+ );
508
+
509
+ /**
510
+ * ⌘⇧H: 隠す/表示する。書き方はレイヤーパネルの目のアイコンと同じ
511
+ * (visibility:hidden + opacity:0)にそろえる。違う書き方にすると、
512
+ * 片方で隠したものがもう片方で「表示中」に見える
513
+ */
514
+ const toggleHidden = useCallback(() => {
515
+ const doc = getIframeDoc();
516
+ if (!doc) return;
517
+ const els = selectedEls(doc, ids);
518
+ const allHidden = els.length > 0 && els.every((el) => el.style.visibility === 'hidden');
519
+ els.forEach((el) => {
520
+ el.style.visibility = allHidden ? '' : 'hidden';
521
+ el.style.opacity = allHidden ? '' : '0';
522
+ });
523
+ afterChange(doc, els);
524
+ // eslint-disable-next-line react-hooks/exhaustive-deps
525
+ }, [getIframeDoc, afterChange, ids.join(',')]);
526
+
527
+ /**
528
+ * ⌘⇧L: ロック/解除。data-gg-locked="true" を付けると、紙面のクリックが下の要素へ
529
+ * 素通りする(constants.ts の EDITOR_STYLES)。選択はレイヤーパネルから行える(Figma と同じ)
530
+ */
531
+ const toggleLocked = useCallback(() => {
532
+ const doc = getIframeDoc();
533
+ if (!doc) return;
534
+ const els = selectedEls(doc, ids);
535
+ const allLocked = els.length > 0 && els.every((el) => el.getAttribute(LOCK_ATTR) === 'true');
536
+ els.forEach((el) => {
537
+ if (allLocked) el.removeAttribute(LOCK_ATTR);
538
+ else el.setAttribute(LOCK_ATTR, 'true');
539
+ });
540
+ afterChange(doc, els);
541
+ // eslint-disable-next-line react-hooks/exhaustive-deps
542
+ }, [getIframeDoc, afterChange, ids.join(',')]);
543
+
544
+ /** 数字キー: 不透明度(1=10% … 9=90%, 0=100%) */
545
+ const setOpacity = useCallback(
546
+ (digit: number) => {
547
+ const doc = getIframeDoc();
548
+ if (!doc) return;
549
+ const els = withoutLocked(selectedEls(doc, ids));
550
+ // 素早く2つ続けたら2桁(4→5 で 45%、0→0 で 0%)。1つだけなら 1=10% … 9=90%、0=100%
551
+ const now = Date.now();
552
+ const prev = lastOpacityDigit && now - lastOpacityDigit.at < OPACITY_TWO_DIGIT_MS ? lastOpacityDigit : null;
553
+ let value: string;
554
+ if (prev) {
555
+ value = String((prev.digit * 10 + digit) / 100);
556
+ lastOpacityDigit = null;
557
+ } else {
558
+ value = digit === 0 ? '1' : String(digit / 10);
559
+ lastOpacityDigit = { digit, at: now };
560
+ }
561
+ els.forEach((el) => applyTailwindStyles(el, { opacity: value }));
562
+ afterChange(doc, els);
563
+ },
564
+ // eslint-disable-next-line react-hooks/exhaustive-deps
565
+ [getIframeDoc, afterChange, ids.join(',')],
566
+ );
567
+
568
+ /**
569
+ * ⇧H / ⇧V: 左右反転・上下反転(help.figma.com「Adjust alignment, rotation, position, and dimensions」の
570
+ * Flip layers と同じキー)。複数選択は Figma と同じく選択全体を 1 つの塊として反転する
571
+ * (各要素の位置を選択群の中心で鏡写しにし、各要素にも反転を付ける)。履歴は 1 回分
572
+ */
573
+ const flip = useCallback(
574
+ (axis: 'x' | 'y') => {
575
+ const doc = getIframeDoc();
576
+ if (!doc) return;
577
+ const els = withoutLocked(selectedEls(doc, ids));
578
+ // 複数選択は塊として鏡写し: 位置を選択群の中心で反転してから、各要素にも反転を付ける
579
+ if (els.length > 1) mirrorPositions(els, axis);
580
+ els.forEach((el) => {
581
+ const next = toggleFlipIn(el.style.transform || '', axis);
582
+ if (next) el.style.transform = next;
583
+ else el.style.removeProperty('transform');
584
+ });
585
+ afterChange(doc, els);
586
+ },
587
+ // eslint-disable-next-line react-hooks/exhaustive-deps
588
+ [getIframeDoc, afterChange, ids.join(',')],
589
+ );
590
+
591
+ /**
592
+ * ⌘⇧> / ⌘⇧<: 文字の大きさを 1px 上げる/下げる。
593
+ * 刻みは記憶にもとづく(help.figma.com「Explore text properties」はキーだけで刻みを書いていない)。
594
+ * 選んだ要素の中で文字を直に持つ要素それぞれを、今の大きさから同じ量だけ動かす
595
+ * (見出しと本文の大きさの差は保つ)。複数選択でも1回の変更 = ⌘Z 1回で戻る。
596
+ */
597
+ const stepFontSize = useCallback(
598
+ (delta: number) => {
599
+ const doc = getIframeDoc();
600
+ const view = doc?.defaultView;
601
+ if (!doc || !view) return;
602
+ const els = withoutLocked(selectedEls(doc, ids));
603
+ const targets = Array.from(new Set(els.flatMap(textBearers)));
604
+ if (!targets.length) return;
605
+ // 先に全部の今の大きさを読む(親を書き換えてから子を読むと継承で値が変わる)
606
+ const sizes = targets.map((el) => parseFloat(view.getComputedStyle(el).fontSize));
607
+ targets.forEach((el, i) => {
608
+ if (!Number.isFinite(sizes[i])) return;
609
+ const next = Math.max(1, Math.round(sizes[i]) + delta);
610
+ // tailwind-merge は text-[Npx] を入れると leading-* を消す(文字の大きさが行の高さも決めるため)。
611
+ // 行の高さは変えたくないので、付いていた leading-* を戻す
612
+ const leading = Array.from(el.classList).filter((c) => /^leading-/.test(c));
613
+ applyTailwindStyles(el, { fontSize: `${next}px` });
614
+ leading.forEach((c) => el.classList.add(c));
615
+ });
616
+ afterChange(doc, els);
617
+ },
618
+ // eslint-disable-next-line react-hooks/exhaustive-deps
619
+ [getIframeDoc, afterChange, ids.join(',')],
620
+ );
621
+
622
+ /**
623
+ * ⌘B / ⌘I / ⌘U: 要素の文字全体の書式を切り替える。
624
+ * 複数選択なら「全部が ON なら OFF、それ以外は全部 ON」(Figma と同じ揃え方)
625
+ */
626
+ const toggleTextStyle = useCallback(
627
+ (kind: 'bold' | 'italic' | 'underline') => {
628
+ const doc = getIframeDoc();
629
+ const view = doc?.defaultView;
630
+ if (!doc || !view) return;
631
+ const els = withoutLocked(selectedEls(doc, ids)).filter((el) => (el.textContent ?? '').trim());
632
+ if (!els.length) return;
633
+ // ホバー中の文字要素にはエディタが青い下線(.text-editable-hover)を引いている。
634
+ // そのまま測ると「下線あり」と読んで ⌘U が毎回 OFF 側に倒れるので、外して測る
635
+ const HOVER_CLASSES = ['text-editable-hover', 'hover-preview'];
636
+ const isOn = (el: HTMLElement) => {
637
+ const had = HOVER_CLASSES.filter((c) => el.classList.contains(c));
638
+ el.classList.remove(...had);
639
+ const cs = view.getComputedStyle(el);
640
+ const on =
641
+ kind === 'bold'
642
+ ? (parseInt(cs.fontWeight, 10) || 400) >= 600
643
+ : kind === 'italic'
644
+ ? cs.fontStyle === 'italic'
645
+ : cs.textDecorationLine.includes('underline');
646
+ el.classList.add(...had);
647
+ return on;
648
+ };
649
+ const turnOn = !els.every(isOn);
650
+ const styles: Record<string, string> =
651
+ kind === 'bold'
652
+ ? { fontWeight: turnOn ? '700' : '400' }
653
+ : kind === 'italic'
654
+ ? { fontStyle: turnOn ? 'italic' : 'normal' }
655
+ : { textDecoration: turnOn ? 'underline' : 'none' };
656
+ els.forEach((el) => applyTailwindStyles(el, styles));
657
+ afterChange(doc, els);
658
+ },
659
+ // eslint-disable-next-line react-hooks/exhaustive-deps
660
+ [getIframeDoc, afterChange, ids.join(',')],
661
+ );
662
+
663
+ // ⌘\ は選択が無くても効く(UI を隠したまま選択を外しても戻せるように)
664
+ const always: ShortcutCallbacks = {
665
+ toggleUi: toggleEditorUi,
666
+ toggleOutline: () => {
667
+ const doc = getIframeDoc();
668
+ if (doc) toggleOutlineIn(doc);
669
+ },
670
+ };
671
+ if (!hasSelection) return always;
672
+
673
+ const callbacks: ShortcutCallbacks = {
674
+ ...always,
675
+ flipH: () => flip('x'),
676
+ flipV: () => flip('y'),
677
+ renameLayer: () => {
678
+ const id = ids[0] ?? selectedElement?.id;
679
+ if (id) window.dispatchEvent(new CustomEvent(RENAME_LAYER_EVENT, { detail: { id } }));
680
+ },
681
+ selectChildren,
682
+ selectParent,
683
+ alignLeft: () => runAlign('left'),
684
+ alignRight: () => runAlign('right'),
685
+ alignTop: () => runAlign('top'),
686
+ alignBottom: () => runAlign('bottom'),
687
+ alignHCenter: () => runAlign('hcenter'),
688
+ alignVCenter: () => runAlign('vcenter'),
689
+ distributeH: () => runDistribute('h'),
690
+ distributeV: () => runDistribute('v'),
691
+ toggleHidden,
692
+ toggleLocked,
693
+ toggleBold: () => toggleTextStyle('bold'),
694
+ toggleItalic: () => toggleTextStyle('italic'),
695
+ toggleUnderline: () => toggleTextStyle('underline'),
696
+ fontSizeUp: () => stepFontSize(1),
697
+ fontSizeDown: () => stepFontSize(-1),
698
+ };
699
+ OPACITY_KEYS.forEach((key, i) => {
700
+ callbacks[key] = () => setOpacity(i === 9 ? 0 : i + 1);
701
+ });
702
+ return callbacks;
703
+ }
@@ -19,6 +19,7 @@ import { extractElementInfo } from '../utils/style-utils';
19
19
  import { MARQUEE_DRAG_THRESHOLD } from '../constants';
20
20
  import type { MarqueeState } from '../types';
21
21
  import { debugLog } from '../utils/debug';
22
+ import { isLockedEl } from '../utils/lock';
22
23
 
23
24
  interface UseMarqueeSelectionOptions {
24
25
  /** マーキー選択の開始保留フラグ */
@@ -54,7 +55,10 @@ export function findElementsIntersectingMarquee(
54
55
  el.classList.contains('selection-box') ||
55
56
  el.classList.contains('marquee-selection-box') ||
56
57
  el.classList.contains('drawing-preview') ||
57
- el.closest('.selection-box')
58
+ el.closest('.selection-box') ||
59
+ // ロック中(⌘⇧L)・非表示(⌘⇧H / 目のアイコン)は範囲選択でも拾わない(Figma と同じ)
60
+ isLockedEl(el) ||
61
+ el.style.visibility === 'hidden'
58
62
  ) {
59
63
  return;
60
64
  }