@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
@@ -18,10 +18,13 @@ import {
18
18
  selectAllSiblingsIn,
19
19
  selectSiblingIn,
20
20
  exitTextEditingIn,
21
+ useCanvasShortcutActions,
22
+ editableParentOf,
21
23
  } from './hooks/useKeyboardShortcuts';
22
24
  import type { MoveElementResult } from './hooks/useElementActions';
23
25
  import { EditorProvider, useEditorContext, type ContentListItem, type EditorMode } from './EditorContext';
24
26
  import type { DocumentAttributes } from './contexts/EditorArtboardContext';
27
+ import type { CanvasZoomOptions } from './utils/canvas-gestures';
25
28
  import { PptTitleBar, PptRibbon, PptThumbnails, PptStatusBar, initialPptTheme, PPT_PALETTES, type PptTheme } from './components/ppt/PptChrome';
26
29
  import { LeftPanel } from './components/shell/LeftPanel';
27
30
  import { useAltMeasure } from './hooks/useAltMeasure';
@@ -187,6 +190,11 @@ export interface FrontendVisualEditorProps {
187
190
  onContentChange?: (contentId: string) => void;
188
191
  /** キャンバスの表示位置(倍率・スクロール)を記憶するキー。省略時は parentId */
189
192
  canvasStorageKey?: string;
193
+ /**
194
+ * マルチフレームのキャンバスの拡大縮小の設定(0.10.0)。省略時は従来の挙動(2%〜400%・1.25 倍ずつ・⌘2 は編集中のページ)。
195
+ * Figma にそろえるなら `{ min: 0.02, max: 256, steps: 'powers-of-two', fitShortcut: 'selection' }`
196
+ */
197
+ canvasZoomOptions?: CanvasZoomOptions;
190
198
  /**
191
199
  * 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ)。
192
200
  * parentId を渡すとキャンバスが階層のツリーで並び、href を渡すと「別タブで開く」が付き、
@@ -287,6 +295,7 @@ function FrontendVisualEditorInner({
287
295
  copyElements,
288
296
  pasteSerializedElements,
289
297
  pasteFromInternalIfFresh,
298
+ pasteInPlace,
290
299
  resizeElements,
291
300
  cutElements,
292
301
  pasteElements,
@@ -2111,19 +2120,13 @@ function FrontendVisualEditorInner({
2111
2120
  return;
2112
2121
  }
2113
2122
 
2114
- // 3. 1階層上へ
2123
+ // 3. 1階層上へ。複数選択でも親が1つにそろうなら親へ(Enter で子を全選択→Esc で戻れる)。
2124
+ // 親は「編集できる最も近い祖先」。素の parentElement だと ID の無い包みを選んでしまう
2115
2125
  const selectedEls = iframeDoc.querySelectorAll<HTMLElement>('.selected');
2116
- const selected = selectedEls.length === 1 ? selectedEls[0] : null;
2117
- const artboard = iframeDoc.getElementById('artboard');
2118
- const parent = selected?.parentElement ?? null;
2119
- const canGoUp =
2120
- !!selected &&
2121
- !!parent &&
2122
- parent !== iframeDoc.body &&
2123
- parent !== artboard &&
2124
- parent.parentElement !== artboard; // スライドの面の直下(トップレベル)で止める
2125
-
2126
- if (canGoUp && parent) {
2126
+ const parents = new Set(Array.from(selectedEls).map(editableParentOf));
2127
+ const parent = parents.size === 1 ? [...parents][0] : null;
2128
+
2129
+ if (parent) {
2127
2130
  selectedEls.forEach((el) => el.classList.remove('selected'));
2128
2131
  parent.classList.add('selected');
2129
2132
  setSelectedElementIds([parent.getAttribute('data-element-id') || '']);
@@ -2268,6 +2271,9 @@ function FrontendVisualEditorInner({
2268
2271
  });
2269
2272
  }, [setZoom, fitZoom, getIframeDoc, selectedElement, selectedElementIds]);
2270
2273
 
2274
+ // Figma 準拠のキャンバス操作(Enter/Shift+Enter・整列・表示/ロック・不透明度・書式)
2275
+ const canvasShortcutActions = useCanvasShortcutActions();
2276
+
2271
2277
  // キーボードショートカット用のコールバックマップ
2272
2278
  // [重要] キー割り当ては KEYBOARD_SHORTCUTS(src/types/editor.ts) が唯一の定義。
2273
2279
  // ここには「アクション名 → 実処理」だけを書く。
@@ -2305,6 +2311,7 @@ function FrontendVisualEditorInner({
2305
2311
  copy: hasSelection ? copyElements : undefined,
2306
2312
  cut: hasSelection ? cutElements : undefined,
2307
2313
  // paste は意図的に未定義のまま(上部のカスタムハンドラで処理し、画像ペーストを許可)
2314
+ pasteInPlace: () => { void pasteInPlace(); },
2308
2315
  // スタイル操作
2309
2316
  copyStyle: hasSelection ? copyStyle : undefined,
2310
2317
  pasteStyle: hasSelection ? pasteStyle : undefined,
@@ -2346,6 +2353,7 @@ function FrontendVisualEditorInner({
2346
2353
  zoomFit: () => handleZoom('fit'),
2347
2354
  zoomActual: () => handleZoom('actual'),
2348
2355
  zoomSelection: () => handleZoom('selection'),
2356
+ ...canvasShortcutActions,
2349
2357
  };
2350
2358
 
2351
2359
  // キーボード処理の唯一の入口。
@@ -2729,6 +2737,10 @@ function FrontendVisualEditorInner({
2729
2737
  onSendBackward={selectedElement ? sendBackward : undefined}
2730
2738
  onBringToFront={selectedElement ? bringToFront : undefined}
2731
2739
  onSendToBack={selectedElement ? sendToBack : undefined}
2740
+ onToggleHidden={canvasShortcutActions.toggleHidden}
2741
+ onToggleLocked={canvasShortcutActions.toggleLocked}
2742
+ onFlipH={canvasShortcutActions.flipH}
2743
+ onFlipV={canvasShortcutActions.flipV}
2732
2744
  onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
2733
2745
  onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
2734
2746
  onInsertRowAbove={tableActions?.insertRowAbove}
@@ -3071,6 +3083,7 @@ export function FrontendVisualEditor({
3071
3083
  headerExtra,
3072
3084
  onContentChange: onContentChangeProp,
3073
3085
  canvasStorageKey,
3086
+ canvasZoomOptions,
3074
3087
  documentAttributes,
3075
3088
  }: FrontendVisualEditorProps) {
3076
3089
  const { getIdToken } = useAuth();
@@ -3363,6 +3376,7 @@ export function FrontendVisualEditor({
3363
3376
  websiteId={effectiveParentId}
3364
3377
  enableMultiPageCanvas={enableMultiPageCanvas}
3365
3378
  canvasStorageKey={canvasStorageKey ?? effectiveParentId ?? null}
3379
+ canvasZoomOptions={canvasZoomOptions}
3366
3380
  >
3367
3381
  <FrontendVisualEditorInner
3368
3382
  onSave={onSave}
@@ -17,6 +17,7 @@ import {
17
17
  SLIDE_WIDTH,
18
18
  SLIDE_HEIGHT,
19
19
  WEBPAGE_MIN_HEIGHT,
20
+ FRAME_REGION_ATTRIBUTE,
20
21
  } from "../constants";
21
22
  import {
22
23
  applyCanvasZoomDom,
@@ -344,6 +345,13 @@ export function EditorCanvas() {
344
345
  }
345
346
  });
346
347
 
348
+ // 利用側が紙面(#artboard)の外、上下に並べた表示だけの領域(サイトの共通のヘッダー・フッター等)も
349
+ // フレームの高さに入れる。目印は FRAME_REGION_ATTRIBUTE(0.10.0。docs/frame-regions-2026-09.md)
350
+ iframeDoc.querySelectorAll(`[${FRAME_REGION_ATTRIBUTE}]`).forEach((el) => {
351
+ const bottom = (el as HTMLElement).getBoundingClientRect().bottom;
352
+ if (bottom > maxBottom) maxBottom = bottom;
353
+ });
354
+
347
355
  const scrollHeight = targetElement.scrollHeight;
348
356
  const computedHeight = Math.max(scrollHeight, maxBottom);
349
357
  // 埋め込み(マルチフレーム)ではフレーム = 紙面そのもの。下の余白は付けず、
@@ -949,6 +957,9 @@ export function EditorCanvas() {
949
957
  updateContentHeightRef.current();
950
958
  });
951
959
  resizeObserver.observe(artboardEl);
960
+ // 紙面の外の領域の大きさが変わったときも測り直す(#artboard-wrapper はそれらを含む)
961
+ const artboardWrapperEl = iframeDoc.getElementById("artboard-wrapper");
962
+ if (artboardWrapperEl) resizeObserver.observe(artboardWrapperEl);
952
963
  cleanupFunctions.push(() => resizeObserver.disconnect());
953
964
 
954
965
  debugLog(
@@ -22,6 +22,9 @@ import {
22
22
  Ungroup,
23
23
  Lock,
24
24
  Unlock,
25
+ EyeOff,
26
+ FlipHorizontal2,
27
+ FlipVertical2,
25
28
  Sparkles,
26
29
  Code,
27
30
  Paintbrush,
@@ -88,6 +91,12 @@ export interface ContextMenuProps {
88
91
  onSendToBack?: () => void;
89
92
  onGroup?: () => void;
90
93
  onUngroup?: () => void;
94
+ /** 表示/非表示(⌘⇧H)とロック/解除(⌘⇧L)。Figma の右クリックの Show/Hide・Lock/Unlock */
95
+ onToggleHidden?: () => void;
96
+ onToggleLocked?: () => void;
97
+ /** 左右反転(⇧H)・上下反転(⇧V)。Figma の右クリックの Flip horizontal / vertical */
98
+ onFlipH?: () => void;
99
+ onFlipV?: () => void;
91
100
  /** 表の編集(右クリックしたセルが表の中のときだけ渡される) */
92
101
  onInsertRowAbove?: () => void;
93
102
  onInsertRowBelow?: () => void;
@@ -198,6 +207,10 @@ export function EditorContextMenu({
198
207
  onSendToBack,
199
208
  onGroup,
200
209
  onUngroup,
210
+ onToggleHidden,
211
+ onToggleLocked,
212
+ onFlipH,
213
+ onFlipV,
201
214
  onInsertRowAbove,
202
215
  onInsertRowBelow,
203
216
  onInsertColumnLeft,
@@ -480,17 +493,54 @@ export function EditorContextMenu({
480
493
  <MenuItem
481
494
  icon={<ChevronsUp className="w-3.5 h-3.5" />}
482
495
  label="最前面へ"
483
- shortcut="⌘⇧]"
496
+ shortcut="⌥⌘]"
484
497
  onClick={() => handleAction(onBringToFront)}
485
498
  disabled={!hasSelection}
486
499
  />
487
500
  <MenuItem
488
501
  icon={<ChevronsDown className="w-3.5 h-3.5" />}
489
502
  label="最背面へ"
490
- shortcut="⌘⇧["
503
+ shortcut="⌥⌘["
491
504
  onClick={() => handleAction(onSendToBack)}
492
505
  disabled={!hasSelection}
493
506
  />
507
+ {(onToggleHidden || onToggleLocked) && (
508
+ <>
509
+ <MenuDivider />
510
+ <MenuItem
511
+ icon={<EyeOff className="w-3.5 h-3.5" />}
512
+ label="表示 / 非表示"
513
+ shortcut="⌘⇧H"
514
+ onClick={() => handleAction(onToggleHidden)}
515
+ disabled={!hasSelection || !onToggleHidden}
516
+ />
517
+ <MenuItem
518
+ icon={<Lock className="w-3.5 h-3.5" />}
519
+ label="ロック / 解除"
520
+ shortcut="⌘⇧L"
521
+ onClick={() => handleAction(onToggleLocked)}
522
+ disabled={!hasSelection || !onToggleLocked}
523
+ />
524
+ {(onFlipH || onFlipV) && (
525
+ <>
526
+ <MenuItem
527
+ icon={<FlipHorizontal2 className="w-3.5 h-3.5" />}
528
+ label="左右反転"
529
+ shortcut="⇧H"
530
+ onClick={() => handleAction(onFlipH)}
531
+ disabled={!hasSelection || !onFlipH}
532
+ />
533
+ <MenuItem
534
+ icon={<FlipVertical2 className="w-3.5 h-3.5" />}
535
+ label="上下反転"
536
+ shortcut="⇧V"
537
+ onClick={() => handleAction(onFlipV)}
538
+ disabled={!hasSelection || !onFlipV}
539
+ />
540
+ </>
541
+ )}
542
+ </>
543
+ )}
494
544
 
495
545
  {/* グループ操作(複数選択時のみ表示) */}
496
546
  {(selectionCount > 1 || isGroup) && (
@@ -14,19 +14,42 @@ export function EditorFooter() {
14
14
  : activeTool === 'scale' ? 'ドラッグで拡大・縮小 · V で選択に戻る'
15
15
  : activeTool === 'comment' ? '紙面をドラッグして範囲を指定 · クリックで点 · Esc で選択に戻る'
16
16
  : activeTool !== 'select' ? 'ドラッグで追加 · Esc でキャンセル'
17
- : selectedElement || selectedElementIds.length ? 'ダブルクリックで文字を編集 · Esc で選択を解除'
17
+ : selectedElement || selectedElementIds.length ? `ダブルクリック・Enter で内側へ · Esc で外側へ · ${alt}ドラッグで複製`
18
18
  : 'クリックで選択 · Space+ドラッグで紙面を移動';
19
+ const shift = isMac ? '⇧' : 'Shift+';
20
+ const ctrl = isMac ? '⌃' : 'Ctrl+';
21
+ // Figma の割り当てに合わせている(出典と差分は docs/figma-parity-2026-11.md)
19
22
  const shortcuts = [
20
23
  ['選択 / 移動 / 拡大・縮小', 'V / H / K'],
21
24
  ['コメント(範囲を指定)', 'C'],
22
- ['元に戻す / やり直し', `${cmd}Z / ${cmd}⇧Z`],
23
- ['保存', `${cmd}S`], ['複製', `${cmd}D`],
25
+ ['元に戻す / やり直し', `${cmd}Z / ${cmd}${shift}Z`],
26
+ ['貼り付け(元と同じ位置。枠を選んでいれば中へ)', `${cmd}V / ${cmd}${shift}V`],
27
+ ['保存', `${cmd}S`], ['複製(動かしてから押すと同じずれで続ける)', `${cmd}D / ${alt}ドラッグ`],
24
28
  ['全体表示 / 100% / 選択範囲', `${cmd}0 / ${cmd}1 / ${cmd}2`],
25
29
  ['複数選択', 'Shift+クリック'], ['最下層の要素を選択', `${cmd}クリック`],
30
+ ['子を選択 / 親を選択', `Enter / ${shift}Enter`],
31
+ ['兄弟を順に選択', `Tab / ${shift}Tab`],
32
+ ['同じ階層をすべて選択', `${cmd}A`],
33
+ ['移動(1px / 10px)', `矢印 / ${shift}矢印`],
34
+ ['サイズ変更(1px / 10px)', `${cmd}矢印 / ${cmd}${shift}矢印`],
35
+ ['前面へ / 背面へ', `${cmd}] / ${cmd}[`],
36
+ ['最前面へ / 最背面へ', isMac ? '⌥⌘] / ⌥⌘[' : 'Ctrl+Shift+] / ['],
37
+ ['整列(左・右・上・下)', `${alt}A / ${alt}D / ${alt}W / ${alt}S`],
38
+ ['整列(左右中央・上下中央)', `${alt}H / ${alt}V`],
39
+ ['等間隔(横 / 縦)', `${shift}${alt}H / ${shift}${alt}V`],
40
+ ['グループ化 / 解除', `${cmd}G / ${cmd}${shift}G`],
41
+ ['隠す / ロック', `${cmd}${shift}H / ${cmd}${shift}L`],
42
+ ['左右反転 / 上下反転', `${shift}H / ${shift}V`],
43
+ ['レイヤー名を変える(紙面にフォーカスがあるときの ⌘R は、何も選んでいなくてもページを再読み込みしない)', `${cmd}R / 名前をダブルクリック`],
44
+ ['UI を隠す / 出す', `${cmd}\\`],
45
+ ['アウトライン表示', `${cmd}${shift}O`],
46
+ ['書式のコピー / 貼り付け', `${alt}${cmd}C / ${alt}${cmd}V`],
47
+ ['不透明度 10〜100%(2つ続けて 45% など)', '1〜9 / 0'],
48
+ ['太字 / 斜体 / 下線(要素全体)', `${cmd}B / ${cmd}I / ${cmd}U`],
49
+ ['文字を大きく / 小さく(1px)', `${cmd}${shift}> / ${cmd}${shift}<`],
26
50
  ['リンク先へ移動', `${alt}クリック`],
27
51
  ['選択・入力を終了', 'Esc'],
28
- ];
29
- return (
52
+ ]; return (
30
53
  <div className="ed-footer">
31
54
  <span className="ed-footer-hint">{hint}</span>
32
55
  <div className="flex shrink-0 items-center gap-4">
@@ -37,7 +60,7 @@ export function EditorFooter() {
37
60
  <Keyboard className="h-4 w-4" />ショートカット
38
61
  </button>
39
62
  </PopoverTrigger>
40
- <PopoverContent side="top" align="end" className="w-[400px]">
63
+ <PopoverContent side="top" align="end" className="max-h-[70vh] w-[400px] overflow-y-auto">
41
64
  <h2 className="mb-4 text-sm font-semibold">キーボードショートカット</h2>
42
65
  <dl className="space-y-3">
43
66
  {shortcuts.map(([label, key]) => <div key={label} className="flex items-center justify-between gap-4 text-xs">
@@ -124,8 +124,11 @@ export function EditorHeader({
124
124
  const canvas = useMultiPageCanvasOptional();
125
125
  const canvasView = useCanvasViewStateOptional();
126
126
  const zoom = canvas && canvasView ? canvasView.canvasZoom * 100 : singleZoom;
127
- const minZoom = canvas ? CANVAS_MIN_ZOOM * 100 : MIN_ZOOM;
128
- const maxZoom = canvas ? CANVAS_MAX_ZOOM * 100 : MAX_ZOOM;
127
+ // キャンバスの範囲は利用側が変えられる(canvasZoomOptions)。表示と +/− の可否も同じ範囲で
128
+ const minZoom = canvas ? (canvas.zoomOptions?.min ?? CANVAS_MIN_ZOOM) * 100 : MIN_ZOOM;
129
+ const maxZoom = canvas ? (canvas.zoomOptions?.max ?? CANVAS_MAX_ZOOM) * 100 : MAX_ZOOM;
130
+ // ⇧2 の合わせ先(canvasZoomOptions.fitShortcut)。'selection' のときだけ「選択範囲に合わせる」を出す
131
+ const fitsSelection = canvas?.zoomOptions?.fitShortcut === 'selection';
129
132
  const setZoom = (value: number) => {
130
133
  if (canvas) canvas.zoomTo(value / 100, { animate: true });
131
134
  else setSingleZoom(value);
@@ -420,6 +423,16 @@ export function EditorHeader({
420
423
  className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
421
424
  >
422
425
  編集中のページに合わせる
426
+ {!fitsSelection && <span className="text-[10px] text-gray-500">⇧2</span>}
427
+ </DropdownMenuItem>
428
+ )}
429
+ {canvas && fitsSelection && (
430
+ <DropdownMenuItem
431
+ data-zoom-preset="selection"
432
+ onClick={() => editorZoomApiRef.current?.selection()}
433
+ className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
434
+ >
435
+ 選択範囲に合わせる
423
436
  <span className="text-[10px] text-gray-500">⇧2</span>
424
437
  </DropdownMenuItem>
425
438
  )}
@@ -27,6 +27,8 @@ import { refreshSelectionOverlay, buildDomTree } from '../utils/dom-utils';
27
27
  import { extractElementInfo } from '../utils/style-utils';
28
28
  import type { DOMTreeNode } from '../types';
29
29
  import { LayerTreeItem, type LayerKind } from './LayerTreeItem';
30
+ import { RENAME_LAYER_EVENT } from '../hooks/useKeyboardShortcuts';
31
+ import { LOCK_ATTR, LOCK_SELECTOR } from '../utils/lock';
30
32
 
31
33
  // ========================================
32
34
  // レイヤー名の生成
@@ -843,6 +845,76 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
843
845
  notifyIframeChange();
844
846
  }, [getIframeDoc, notifyIframeChange]);
845
847
 
848
+ // レイヤー名の変更(名前のダブルクリック・⌘R。Figma の Rename)。名前は data-layer-name に保存する
849
+ const [renamingId, setRenamingId] = useState<string | null>(null);
850
+ useEffect(() => {
851
+ const onRename = (e: Event) => {
852
+ const id = (e as CustomEvent<{ id: string }>).detail?.id;
853
+ if (id) setRenamingId(id);
854
+ };
855
+ window.addEventListener(RENAME_LAYER_EVENT, onRename);
856
+ return () => window.removeEventListener(RENAME_LAYER_EVENT, onRename);
857
+ }, []);
858
+ const handleRenameStart = useCallback((nodeId: string) => setRenamingId(nodeId), []);
859
+ const handleRenameCommit = useCallback((nodeId: string, name: string | null) => {
860
+ setRenamingId((current) => (current === nodeId ? null : current));
861
+ if (name === null) return;
862
+ const iframeDoc = getIframeDoc();
863
+ const el = iframeDoc?.querySelector(`[data-element-id="${nodeId}"]`) as HTMLElement | null;
864
+ if (!el) return;
865
+ const next = name.trim();
866
+ const before = el.getAttribute('data-layer-name') ?? '';
867
+ if (next === before) return;
868
+ // 空にしたら自動の名前(見出しの文言など)に戻す
869
+ if (next) el.setAttribute('data-layer-name', next);
870
+ else el.removeAttribute('data-layer-name');
871
+ notifyIframeChange();
872
+ }, [getIframeDoc, notifyIframeChange]);
873
+
874
+ // ロック/解除(⌘⇧L と同じ data-gg-locked="true")。紙面ではロック中の要素を選べないので、ここが解除の入口
875
+ const handleToggleLock = useCallback((nodeId: string) => {
876
+ const iframeDoc = getIframeDoc();
877
+ if (!iframeDoc) return;
878
+
879
+ const el = iframeDoc.querySelector(`[data-element-id="${nodeId}"]`) as HTMLElement;
880
+ if (!el) return;
881
+
882
+ if (el.getAttribute(LOCK_ATTR) === 'true') {
883
+ el.removeAttribute(LOCK_ATTR);
884
+ } else {
885
+ el.setAttribute(LOCK_ATTR, 'true');
886
+ }
887
+ notifyIframeChange();
888
+ // 選択枠のハンドルの出し分け(selection-locked)を描き直す
889
+ requestAnimationFrame(() => refreshSelectionOverlay(iframeDoc));
890
+ }, [getIframeDoc, notifyIframeChange]);
891
+
892
+ const lockedNodes = useMemo(() => {
893
+ const iframeDoc = getIframeDoc();
894
+ // domTree が作り直されたとき(属性の変更を含む)に数え直す
895
+ if (!iframeDoc || domTree.length === 0) return new Set<string>();
896
+ const locked = new Set<string>();
897
+ iframeDoc.querySelectorAll<HTMLElement>(`${LOCK_SELECTOR}[data-element-id]`).forEach((el) => {
898
+ const id = el.getAttribute('data-element-id');
899
+ if (id) locked.add(id);
900
+ });
901
+ return locked;
902
+ }, [domTree, getIframeDoc]);
903
+
904
+ // 祖先がロック中の要素(自分はロックしていない)。行に薄い鍵を出す(Figma と同じ見せ方)
905
+ const lockedByParentNodes = useMemo(() => {
906
+ const iframeDoc = getIframeDoc();
907
+ if (!iframeDoc || lockedNodes.size === 0) return new Set<string>();
908
+ const inherited = new Set<string>();
909
+ iframeDoc
910
+ .querySelectorAll<HTMLElement>(`${LOCK_SELECTOR} [data-element-id]`)
911
+ .forEach((el) => {
912
+ const id = el.getAttribute('data-element-id');
913
+ if (id && !lockedNodes.has(id) && !el.closest('.selection-box')) inherited.add(id);
914
+ });
915
+ return inherited;
916
+ }, [lockedNodes, getIframeDoc]);
917
+
846
918
  // hiddenNodesの計算(メモ化)
847
919
  const hiddenNodes = useMemo(() => {
848
920
  const iframeDoc = getIframeDoc();
@@ -991,6 +1063,8 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
991
1063
  const isDragging = draggedNodeId === node.id;
992
1064
  const isDragOver = dragOverNodeId === node.id;
993
1065
  const isHidden = hiddenNodes.has(node.id);
1066
+ const isLocked = lockedNodes.has(node.id);
1067
+ const isLockedByParent = lockedByParentNodes.has(node.id);
994
1068
  const label = labelMap.get(node.id) ?? fallbackLabel(node);
995
1069
 
996
1070
  return (
@@ -1004,6 +1078,11 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1004
1078
  isDragOver={isDragOver}
1005
1079
  dragOverPosition={isDragOver ? dragOverPosition : null}
1006
1080
  isHidden={isHidden}
1081
+ isLocked={isLocked}
1082
+ isLockedByParent={isLockedByParent}
1083
+ isRenaming={renamingId === node.id}
1084
+ onRenameStart={handleRenameStart}
1085
+ onRenameCommit={handleRenameCommit}
1007
1086
  hasStyleInClipboard={styleInClipboard}
1008
1087
  label={label.name}
1009
1088
  kind={label.kind}
@@ -1014,6 +1093,7 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1014
1093
  labelKey={labelKey}
1015
1094
  onToggleExpand={handleToggleExpand}
1016
1095
  onToggleVisibility={handleToggleVisibility}
1096
+ onToggleLock={handleToggleLock}
1017
1097
  onNodeClick={handleNodeClick}
1018
1098
  onRowMouseEnter={handleRowMouseEnter}
1019
1099
  onRowMouseLeave={handleRowMouseLeave}
@@ -1050,6 +1130,12 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1050
1130
  dragOverNodeId,
1051
1131
  dragOverPosition,
1052
1132
  hiddenNodes,
1133
+ lockedNodes,
1134
+ lockedByParentNodes,
1135
+ handleToggleLock,
1136
+ renamingId,
1137
+ handleRenameStart,
1138
+ handleRenameCommit,
1053
1139
  styleInClipboard,
1054
1140
  labelMap,
1055
1141
  labelKey,
@@ -8,7 +8,7 @@
8
8
  * - カスタム比較関数で必要な変更のみ検知
9
9
  */
10
10
 
11
- import React, { memo } from 'react';
11
+ import React, { memo, useRef } from 'react';
12
12
  import {
13
13
  ContextMenu,
14
14
  ContextMenuContent,
@@ -27,6 +27,8 @@ import {
27
27
  ChevronsDown,
28
28
  Eye,
29
29
  EyeOff,
30
+ Lock,
31
+ Unlock,
30
32
  GripVertical,
31
33
  Paintbrush,
32
34
  ClipboardPaste,
@@ -90,6 +92,12 @@ export interface LayerTreeItemProps {
90
92
  isDragOver: boolean;
91
93
  dragOverPosition: 'before' | 'after' | 'inside' | null;
92
94
  isHidden: boolean;
95
+ /** ロック中(data-gg-locked="true")。紙面では選べないので、ここが解除の入口になる */
96
+ isLocked: boolean;
97
+ /** 祖先がロック中(自分はロックしていない)。薄い鍵を出すだけで、押せない */
98
+ isLockedByParent: boolean;
99
+ /** 名前を入力中(ダブルクリック・⌘R) */
100
+ isRenaming: boolean;
93
101
  hasStyleInClipboard: boolean;
94
102
  /** 行に出す人が読める名前(実 DOM から生成) */
95
103
  label: string;
@@ -113,6 +121,11 @@ export interface LayerTreeItemProps {
113
121
  // Event handlers
114
122
  onToggleExpand: (nodeId: string) => void;
115
123
  onToggleVisibility: (nodeId: string) => void;
124
+ onToggleLock: (nodeId: string) => void;
125
+ /** 名前の入力を始める(名前のダブルクリック) */
126
+ onRenameStart: (nodeId: string) => void;
127
+ /** 名前を確定する。null は取り消し、空文字は自動の名前に戻す */
128
+ onRenameCommit: (nodeId: string, name: string | null) => void;
116
129
  onNodeClick: (e: React.MouseEvent | React.KeyboardEvent, nodeId: string) => void;
117
130
  /** 行ホバー開始(キャンバス側をハイライトする) */
118
131
  onRowMouseEnter: (nodeId: string) => void;
@@ -156,6 +169,9 @@ export const LayerTreeItem = memo(function LayerTreeItem({
156
169
  isDragOver,
157
170
  dragOverPosition,
158
171
  isHidden,
172
+ isLocked,
173
+ isLockedByParent,
174
+ isRenaming,
159
175
  hasStyleInClipboard,
160
176
  label,
161
177
  kind,
@@ -166,6 +182,9 @@ export const LayerTreeItem = memo(function LayerTreeItem({
166
182
  labelKey: _labelKey, // 比較用のみ、レンダリングには使用しない
167
183
  onToggleExpand,
168
184
  onToggleVisibility,
185
+ onToggleLock,
186
+ onRenameStart,
187
+ onRenameCommit,
169
188
  onNodeClick,
170
189
  onRowMouseEnter,
171
190
  onRowMouseLeave,
@@ -189,6 +208,11 @@ export const LayerTreeItem = memo(function LayerTreeItem({
189
208
  const hasChildren = node.children.length > 0;
190
209
  const KindIcon = KIND_ICONS[kind] ?? Frame;
191
210
 
211
+ // 表示中の名前のまま確定したら何もしない(自動の名前を固定の名前として書き込まない)
212
+ const commitRename = (value: string) => onRenameCommit(node.id, value === label ? null : value);
213
+ // Esc で取り消したあと、入力欄が消えるときの blur で確定しないための印
214
+ const renameCancelledRef = useRef(false);
215
+
192
216
  return (
193
217
  <div className={isDragging ? 'opacity-50' : ''}>
194
218
  <ContextMenu>
@@ -294,7 +318,10 @@ export const LayerTreeItem = memo(function LayerTreeItem({
294
318
  : isSelected
295
319
  ? 'text-white'
296
320
  : 'text-gray-400'
297
- } opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 ${
321
+ } ${
322
+ // 隠しているあいだは常に出す(Figma と同じく、どれを隠したか一覧で分かる)
323
+ isHidden ? 'opacity-100' : 'opacity-0'
324
+ } group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 ${
298
325
  isSelected ? 'hover:bg-[#0d99ff]' : 'hover:bg-gray-600'
299
326
  }`}
300
327
  >
@@ -305,6 +332,42 @@ export const LayerTreeItem = memo(function LayerTreeItem({
305
332
  )}
306
333
  </button>
307
334
 
335
+ {/* ロック/解除。ロック中は常に出す。紙面ではロック中の要素を選べないので、ここから外す。
336
+ 祖先がロック中なら薄い鍵だけ(外すのは親の行で) */}
337
+ {isLockedByParent && !isLocked ? (
338
+ <span
339
+ className={`p-0.5 ${isSelected ? 'text-white/50' : 'text-gray-500/60'}`}
340
+ title="親の要素がロックされています"
341
+ aria-label="親の要素がロックされています"
342
+ >
343
+ <Lock className="w-3 h-3" />
344
+ </span>
345
+ ) : (
346
+ <button
347
+ aria-label={isLocked ? 'ロックを解除' : 'ロック'}
348
+ title={isLocked ? 'ロックを解除(⌘⇧L)' : 'ロック(⌘⇧L)'}
349
+ onClick={(e) => {
350
+ e.stopPropagation();
351
+ onToggleLock(node.id);
352
+ }}
353
+ className={`p-0.5 rounded ${
354
+ isLocked
355
+ ? isSelected
356
+ ? 'text-white'
357
+ : 'text-gray-300'
358
+ : isSelected
359
+ ? 'text-white'
360
+ : 'text-gray-400'
361
+ } ${
362
+ isLocked ? 'opacity-100' : 'opacity-0'
363
+ } group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 ${
364
+ isSelected ? 'hover:bg-[#0d99ff]' : 'hover:bg-gray-600'
365
+ }`}
366
+ >
367
+ {isLocked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
368
+ </button>
369
+ )}
370
+
308
371
  {/* コンポーネントインスタンスアイコン */}
309
372
  {node.componentInstanceId && (
310
373
  <Component
@@ -340,8 +403,46 @@ export const LayerTreeItem = memo(function LayerTreeItem({
340
403
  : 'text-gray-200'
341
404
  }`}
342
405
  title={`${label} (${node.tagName})`}
406
+ onDoubleClick={(e) => {
407
+ e.stopPropagation();
408
+ onRenameStart(node.id);
409
+ }}
343
410
  >
344
- {label}
411
+ {isRenaming ? (
412
+ <input
413
+ // autoFocus だとパネルが横へスクロールして行の頭が切れるので、スクロールさせずに当てる
414
+ ref={(input) => input?.focus({ preventScroll: true })}
415
+ defaultValue={label}
416
+ aria-label="レイヤー名"
417
+ draggable={false}
418
+ onClick={(e) => e.stopPropagation()}
419
+ onMouseDown={(e) => e.stopPropagation()}
420
+ onDragStart={(e) => {
421
+ e.preventDefault();
422
+ e.stopPropagation();
423
+ }}
424
+ onKeyDown={(e) => {
425
+ e.stopPropagation();
426
+ if (e.nativeEvent.isComposing) return; // 日本語の変換の確定 Enter では閉じない
427
+ if (e.key === 'Enter') commitRename(e.currentTarget.value);
428
+ if (e.key === 'Escape') {
429
+ renameCancelledRef.current = true;
430
+ onRenameCommit(node.id, null);
431
+ }
432
+ }}
433
+ onFocus={(e) => {
434
+ renameCancelledRef.current = false;
435
+ e.currentTarget.select();
436
+ }}
437
+ onBlur={(e) => {
438
+ if (renameCancelledRef.current) return;
439
+ commitRename(e.currentTarget.value);
440
+ }}
441
+ className="w-full min-w-0 rounded-sm border border-[#0d99ff] bg-[#1e1e1e] px-1 text-[11px] text-white outline-none"
442
+ />
443
+ ) : (
444
+ label
445
+ )}
345
446
  </span>
346
447
  <span
347
448
  className={`text-[10px] flex-shrink-0 tabular-nums ${
@@ -503,6 +604,9 @@ export const LayerTreeItem = memo(function LayerTreeItem({
503
604
  prevProps.isDragOver === nextProps.isDragOver &&
504
605
  prevProps.dragOverPosition === nextProps.dragOverPosition &&
505
606
  prevProps.isHidden === nextProps.isHidden &&
607
+ prevProps.isLocked === nextProps.isLocked &&
608
+ prevProps.isLockedByParent === nextProps.isLockedByParent &&
609
+ prevProps.isRenaming === nextProps.isRenaming &&
506
610
  prevProps.hasStyleInClipboard === nextProps.hasStyleInClipboard &&
507
611
  // selectionKeyが変わると子要素も再レンダリングが必要
508
612
  prevProps.selectionKey === nextProps.selectionKey &&