@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.
- package/README.md +24 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +18 -12
- package/src/editor/components/EditorContextMenu.tsx +52 -2
- package/src/editor/components/EditorFooter.tsx +29 -6
- package/src/editor/components/EditorLayerPanel.tsx +86 -0
- package/src/editor/components/LayerTreeItem.tsx +107 -3
- package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
- package/src/editor/components/ppt/PptChrome.tsx +8 -4
- package/src/editor/components/shell/LeftPanel.tsx +8 -1
- package/src/editor/components/shell/PagesPanel.tsx +7 -1
- package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
- package/src/editor/constants.ts +16 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +49 -11
- package/src/editor/hooks/useCanvasControls.ts +41 -4
- package/src/editor/hooks/useDragResize.ts +205 -17
- package/src/editor/hooks/useElementActions.ts +119 -34
- package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
- package/src/editor/hooks/useMarqueeSelection.ts +5 -1
- package/src/editor/utils/align-elements.ts +49 -0
- package/src/editor/utils/canvas-guides.ts +46 -0
- package/src/editor/utils/dom-utils.ts +12 -2
- package/src/editor/utils/history-timing.ts +9 -0
- package/src/editor/utils/html-utils.ts +4 -0
- package/src/editor/utils/lock.ts +24 -0
- package/src/editor/utils/smart-duplicate.ts +51 -0
- package/src/editor/utils/smart-guides.ts +232 -2
- package/src/hooks/useEditorShortcuts.ts +46 -2
- package/src/styles/skin.css +5 -0
- package/src/types/editor.ts +120 -1
- 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 ?
|
|
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}
|
|
23
|
-
['
|
|
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
|
-
}
|
|
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
|
-
{
|
|
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 &&
|