@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.
- package/README.md +90 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +5 -1
- package/src/editor/FrontendVisualEditor.tsx +26 -12
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/EditorContextMenu.tsx +52 -2
- package/src/editor/components/EditorFooter.tsx +29 -6
- package/src/editor/components/EditorHeader.tsx +15 -2
- 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/multi-page/MultiPageCanvasView.tsx +12 -4
- package/src/editor/components/ppt/PptChrome.tsx +8 -4
- package/src/editor/components/ppt/PptComments.tsx +98 -1
- 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 +25 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
- package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
- package/src/editor/hooks/useCanvasControls.ts +43 -9
- package/src/editor/hooks/useDragResize.ts +205 -17
- package/src/editor/hooks/useElementActions.ts +119 -34
- package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
- 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-gestures.ts +210 -0
- package/src/editor/utils/canvas-guides.ts +46 -0
- package/src/editor/utils/dom-utils.ts +12 -2
- package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
- 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/index.ts +5 -1
- package/src/io.ts +54 -0
- package/src/styles/skin.css +5 -0
- package/src/types/editor.ts +120 -1
- 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
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
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 ?
|
|
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">
|
|
@@ -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
|
-
|
|
128
|
-
const
|
|
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
|
-
}
|
|
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 &&
|