@growgroup/visual-editor 0.10.0 → 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 (34) hide show
  1. package/README.md +24 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +18 -12
  5. package/src/editor/components/EditorContextMenu.tsx +52 -2
  6. package/src/editor/components/EditorFooter.tsx +29 -6
  7. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  8. package/src/editor/components/LayerTreeItem.tsx +107 -3
  9. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  11. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  12. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  13. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  14. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  15. package/src/editor/constants.ts +16 -0
  16. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  17. package/src/editor/contexts/MultiPageCanvasContext.tsx +49 -11
  18. package/src/editor/hooks/useCanvasControls.ts +41 -4
  19. package/src/editor/hooks/useDragResize.ts +205 -17
  20. package/src/editor/hooks/useElementActions.ts +119 -34
  21. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  22. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  23. package/src/editor/utils/align-elements.ts +49 -0
  24. package/src/editor/utils/canvas-guides.ts +46 -0
  25. package/src/editor/utils/dom-utils.ts +12 -2
  26. package/src/editor/utils/history-timing.ts +9 -0
  27. package/src/editor/utils/html-utils.ts +4 -0
  28. package/src/editor/utils/lock.ts +24 -0
  29. package/src/editor/utils/smart-duplicate.ts +51 -0
  30. package/src/editor/utils/smart-guides.ts +232 -2
  31. package/src/hooks/useEditorShortcuts.ts +46 -2
  32. package/src/styles/skin.css +5 -0
  33. package/src/types/editor.ts +120 -1
  34. package/src/version.ts +1 -1
@@ -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">
@@ -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 &&