@growgroup/visual-editor 0.1.5 → 0.1.6
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/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +33 -1
- package/src/editor/components/EditorCanvas.tsx +7 -2
- package/src/editor/hooks/useCanvasControls.ts +3 -2
- package/src/editor/hooks/useDragResize.ts +86 -6
- package/src/editor/hooks/useDrawingMode.ts +4 -2
- package/src/editor/hooks/useElementActions.ts +60 -1
- package/src/editor/hooks/useElementSelection.ts +35 -8
- package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
- package/src/editor/utils/dom-utils.ts +131 -12
- package/src/editor/utils/flex-reorder.ts +268 -43
- package/src/editor/utils/geometry.ts +4 -3
- package/src/editor/utils/html-utils.ts +10 -5
- package/src/hooks/useEditorShortcuts.ts +39 -2
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 汎用的なHTML編集エディタとして、スライド、ページ、コンポーネント等で利用可能
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
15
15
|
import { useEditorShortcuts } from '../hooks/useEditorShortcuts';
|
|
16
16
|
import {
|
|
17
17
|
editorCancelDragRef,
|
|
@@ -817,6 +817,17 @@ function FrontendVisualEditorInner({
|
|
|
817
817
|
/** ページ切替(サムネイル・新しいスライド)の直前に、殻から保存を呼べるようにする */
|
|
818
818
|
useEffect(() => registerAutoSaveFlush(() => saveIfDirtyRef.current()), []);
|
|
819
819
|
|
|
820
|
+
/**
|
|
821
|
+
* この編集画面が消えるとき(ページ切替で key が変わる・閉じる)、保存待ちの変更を捨てない。
|
|
822
|
+
*
|
|
823
|
+
* 自動保存は2秒のデバウンス。編集して2秒以内にページを切り替えると、タイマーごと
|
|
824
|
+
* 捨てられて編集が消えていた(構成ラフで実測: 見出しを直して隣のページへ移ると失われる)。
|
|
825
|
+
* useLayoutEffect の後始末は iframe がまだ DOM にある間に走るので、ここで
|
|
826
|
+
* 本文を読み取って保存を始める。onSave はこの画面のもの(古い閉包)なので、
|
|
827
|
+
* 切替先ではなく編集していたページへ正しく保存される。
|
|
828
|
+
*/
|
|
829
|
+
useLayoutEffect(() => () => { void saveIfDirtyRef.current(); }, []);
|
|
830
|
+
|
|
820
831
|
/** ページが変わったら「保存済みの本文」の記憶も切り替える */
|
|
821
832
|
useEffect(() => {
|
|
822
833
|
savedPayloadRef.current = null;
|
|
@@ -2384,6 +2395,27 @@ function FrontendVisualEditorInner({
|
|
|
2384
2395
|
</div>
|
|
2385
2396
|
</div>
|
|
2386
2397
|
)}
|
|
2398
|
+
|
|
2399
|
+
{/* Webページ用の案内。
|
|
2400
|
+
Webページでは「絶対配置モードに切り替える」を勧めない(版面が固定pxに固まる)ので、
|
|
2401
|
+
代わりに逃げ道だけを伝える。sonner の Toaster はアプリ側に無く toast() が
|
|
2402
|
+
表示されないため、既存のこの帯を流用している */}
|
|
2403
|
+
{showLayoutHint && editorMode === 'webpage' && (
|
|
2404
|
+
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] animate-in fade-in slide-in-from-bottom-2 duration-300">
|
|
2405
|
+
<div className="bg-black/90 text-white px-4 py-3 rounded-lg shadow-xl flex items-center gap-4">
|
|
2406
|
+
<span className="text-sm">
|
|
2407
|
+
並べ替える相手がありません。Cmd/Ctrl+ドラッグで自由配置、ダブルクリックで中に入れます
|
|
2408
|
+
</span>
|
|
2409
|
+
<button
|
|
2410
|
+
onClick={() => setShowLayoutHint(false)}
|
|
2411
|
+
className="text-gray-400 hover:text-white text-lg leading-none ml-1"
|
|
2412
|
+
aria-label="閉じる"
|
|
2413
|
+
>
|
|
2414
|
+
×
|
|
2415
|
+
</button>
|
|
2416
|
+
</div>
|
|
2417
|
+
</div>
|
|
2418
|
+
)}
|
|
2387
2419
|
</div>
|
|
2388
2420
|
);
|
|
2389
2421
|
}
|
|
@@ -318,14 +318,19 @@ export function EditorCanvas() {
|
|
|
318
318
|
if (!container) return;
|
|
319
319
|
|
|
320
320
|
const scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
|
|
321
|
-
|
|
321
|
+
// webpage は上端固定(紙面は #canvas-scroll-area で flex-start)。
|
|
322
|
+
// 初期表示もページの先頭から見えるべきで、縦の中央へ飛ばさない
|
|
323
|
+
const scrollTop =
|
|
324
|
+
editorMode === "webpage"
|
|
325
|
+
? 0
|
|
326
|
+
: (container.scrollHeight - container.clientHeight) / 2;
|
|
322
327
|
|
|
323
328
|
container.scrollTo({
|
|
324
329
|
left: scrollLeft,
|
|
325
330
|
top: scrollTop,
|
|
326
331
|
behavior: "instant",
|
|
327
332
|
});
|
|
328
|
-
}, [containerRef]);
|
|
333
|
+
}, [containerRef, editorMode]);
|
|
329
334
|
|
|
330
335
|
useEffect(() => {
|
|
331
336
|
if (isInMultiPageMode) return; // マルチページモードではキャンバスがスクロールを管理
|
|
@@ -527,10 +527,11 @@ export function useCanvasControls() {
|
|
|
527
527
|
if (!container) return;
|
|
528
528
|
container.scrollTo({
|
|
529
529
|
left: (container.scrollWidth - container.clientWidth) / 2,
|
|
530
|
-
|
|
530
|
+
// webpage は上端固定(#canvas-scroll-area が flex-start)。先頭から見せる
|
|
531
|
+
top: editorMode === 'webpage' ? 0 : (container.scrollHeight - container.clientHeight) / 2,
|
|
531
532
|
behavior: 'instant',
|
|
532
533
|
});
|
|
533
|
-
}, [containerRef]);
|
|
534
|
+
}, [containerRef, editorMode]);
|
|
534
535
|
|
|
535
536
|
// ズームフィット計算
|
|
536
537
|
const calculateFitZoom = useCallback(() => {
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from '../utils/geometry';
|
|
32
32
|
import {
|
|
33
33
|
shouldReorder,
|
|
34
|
+
planSlideReorder,
|
|
35
|
+
resolveWebpageDrag,
|
|
34
36
|
startReorder,
|
|
35
37
|
computeInsertIndex,
|
|
36
38
|
drawInsertIndicator,
|
|
@@ -290,7 +292,7 @@ const ROTATION_SNAP_DEG = 15;
|
|
|
290
292
|
export function useDragResize(
|
|
291
293
|
options: UseDragResizeOptions
|
|
292
294
|
): UseDragResizeReturn {
|
|
293
|
-
const { setShowLayoutHint, zoom } = useEditorContext();
|
|
295
|
+
const { setShowLayoutHint, zoom, editorMode } = useEditorContext();
|
|
294
296
|
|
|
295
297
|
const {
|
|
296
298
|
dragStateRef,
|
|
@@ -326,11 +328,26 @@ export function useDragResize(
|
|
|
326
328
|
const reorderRef = useRef<ReorderSession | null>(null);
|
|
327
329
|
const reorderIndexRef = useRef<number>(-1);
|
|
328
330
|
|
|
331
|
+
// 「並べ替えたいが相手がいない」ドラッグ。座標も DOM も一切書かずに空振りさせる。
|
|
332
|
+
// ここを立てずに素通りさせると convertDragTargetsToAbsolute が走って版面が崩れ、
|
|
333
|
+
// さらに mouseup が「動いた」と見なして履歴を1段積んでしまう
|
|
334
|
+
const dragBlockedRef = useRef(false);
|
|
335
|
+
// 案内はセッション中1回だけ。ドラッグのたびに帯が出ると操作の邪魔になる
|
|
336
|
+
const reorderHintShownRef = useRef(false);
|
|
337
|
+
|
|
338
|
+
// editorMode も zoom と同じ理由で ref に写す。
|
|
339
|
+
// iframe に張ったリスナーのクロージャは古い値を掴んだままになるため
|
|
340
|
+
const editorModeRef = useRef(editorMode);
|
|
341
|
+
|
|
329
342
|
// zoomが変更されたらrefを更新
|
|
330
343
|
useEffect(() => {
|
|
331
344
|
zoomRef.current = zoom;
|
|
332
345
|
}, [zoom]);
|
|
333
346
|
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
editorModeRef.current = editorMode;
|
|
349
|
+
}, [editorMode]);
|
|
350
|
+
|
|
334
351
|
// 最新のsetShowLayoutHintをrefに同期
|
|
335
352
|
useEffect(() => {
|
|
336
353
|
setShowLayoutHintRef.current = setShowLayoutHint;
|
|
@@ -360,6 +377,7 @@ export function useDragResize(
|
|
|
360
377
|
dragBaseRectRef.current = null;
|
|
361
378
|
reorderRef.current = null;
|
|
362
379
|
reorderIndexRef.current = -1;
|
|
380
|
+
dragBlockedRef.current = false;
|
|
363
381
|
}, [dragStateRef]);
|
|
364
382
|
|
|
365
383
|
/**
|
|
@@ -556,9 +574,15 @@ export function useDragResize(
|
|
|
556
574
|
|
|
557
575
|
// 位置を書けるのは「フローから外れている要素」だけ。
|
|
558
576
|
// 絶対配置モードなら従来通り static → absolute へ倒して位置を持たせる。
|
|
577
|
+
//
|
|
578
|
+
// [webpage は倒さない] webpage は流し込みを保つ約束(README)。ここで倒すと
|
|
579
|
+
// 要素が流れから抜けて後続が詰め上がり、ページ高さが変わって紙面が飛ぶ。
|
|
580
|
+
// 大きさを変えるのに position は要らないので width/height だけ書く。
|
|
559
581
|
const position = win?.getComputedStyle(el).position;
|
|
560
582
|
const outOfFlow = isOutOfFlowPosition(position);
|
|
561
|
-
const canWritePosition =
|
|
583
|
+
const canWritePosition =
|
|
584
|
+
outOfFlow ||
|
|
585
|
+
(layoutModeRef.current === 'absolute' && editorModeRef.current !== 'webpage');
|
|
562
586
|
|
|
563
587
|
if (canWritePosition) {
|
|
564
588
|
// すでにフロー外なら position は触らない(fixed を absolute に落とさない)
|
|
@@ -908,24 +932,50 @@ export function useDragResize(
|
|
|
908
932
|
const targets = getDragTargets(dragState);
|
|
909
933
|
const movingElements = targets.map((t) => t.element);
|
|
910
934
|
const componentEdit = iframeDoc.body.classList.contains('component-edit-mode');
|
|
935
|
+
const isWebpage = editorModeRef.current === 'webpage';
|
|
911
936
|
|
|
912
937
|
// Cmd/Ctrl は「並べ替えから抜けて自由に動かす」逃げ道。
|
|
913
938
|
// スナップの無効化と同じキーに揃える(押している間は座標がそのまま通る)
|
|
914
939
|
const wantsFreeMove = e.metaKey || e.ctrlKey;
|
|
915
940
|
|
|
916
|
-
if (
|
|
941
|
+
if (isWebpage) {
|
|
942
|
+
// ── webpage ──
|
|
943
|
+
// フロー内の要素を絶対配置へ倒すと、カラムが流れから抜けてページ高さが
|
|
944
|
+
// 潰れ、artboard が縮んで紙面が中央寄せで飛ぶ(実測 2424→679px)。
|
|
945
|
+
// README の設計原則どおり倒さず、同一親の並べ替えだけで動かす。
|
|
946
|
+
if (!componentEdit && !wantsFreeMove) {
|
|
947
|
+
const decision = resolveWebpageDrag(movingElements, iframeDoc);
|
|
948
|
+
if (decision.kind === 'reorder') {
|
|
949
|
+
reorderRef.current = startReorder(decision.plan, iframeDoc, {
|
|
950
|
+
restoreFlow: false,
|
|
951
|
+
});
|
|
952
|
+
reorderIndexRef.current = -1;
|
|
953
|
+
} else if (decision.kind !== 'free') {
|
|
954
|
+
// 並べ替える相手がいない/群がバラバラ。倒さず、動かさず、案内だけ出す
|
|
955
|
+
dragBlockedRef.current = true;
|
|
956
|
+
if (!reorderHintShownRef.current) {
|
|
957
|
+
reorderHintShownRef.current = true;
|
|
958
|
+
setShowLayoutHintRef.current(true);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} else if (
|
|
917
963
|
!componentEdit &&
|
|
918
964
|
!wantsFreeMove &&
|
|
919
965
|
dragState.element &&
|
|
920
966
|
shouldReorder(dragState.element, iframeDoc, movingElements.length)
|
|
921
967
|
) {
|
|
968
|
+
// ── slide ──
|
|
922
969
|
// 並べ替え: 絶対配置へ倒さない。倒した瞬間にフレックスの設定が
|
|
923
970
|
// 効かなくなり、右パネルのレイアウト指定が無意味になる
|
|
924
|
-
|
|
925
|
-
|
|
971
|
+
const plan = planSlideReorder(dragState.element, iframeDoc);
|
|
972
|
+
if (plan) {
|
|
973
|
+
reorderRef.current = startReorder(plan, iframeDoc, { restoreFlow: true });
|
|
974
|
+
reorderIndexRef.current = -1;
|
|
975
|
+
}
|
|
926
976
|
}
|
|
927
977
|
|
|
928
|
-
if (!reorderRef.current) {
|
|
978
|
+
if (!reorderRef.current && !dragBlockedRef.current) {
|
|
929
979
|
// in-flow の要素はここで初めて絶対配置へ変換する。
|
|
930
980
|
// mousedown 時に変換すると、選んだだけで後続の兄弟が詰め上がって版面が動く。
|
|
931
981
|
// コンポーネント編集中は変換しない(部品本来の並びを保つため)
|
|
@@ -941,6 +991,14 @@ export function useDragResize(
|
|
|
941
991
|
}
|
|
942
992
|
}
|
|
943
993
|
|
|
994
|
+
// ── 空振りモード ──
|
|
995
|
+
// 「並べ替えたいが相手がいない」ドラッグ。座標も DOM も書かない。
|
|
996
|
+
// hasMoved を立てないことが肝で、立てると mouseup が Tailwind 変換と
|
|
997
|
+
// SLIDE_CONTENT_CHANGED を走らせ、何も変えていないのに履歴が1段積まれる
|
|
998
|
+
if (dragBlockedRef.current) {
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
944
1002
|
dragState.hasMoved = true;
|
|
945
1003
|
|
|
946
1004
|
// ── 並べ替えモード ──
|
|
@@ -1051,6 +1109,16 @@ export function useDragResize(
|
|
|
1051
1109
|
return true;
|
|
1052
1110
|
}
|
|
1053
1111
|
|
|
1112
|
+
// 空振り中の取り消し: 座標も DOM も書いていないので戻すものが無い
|
|
1113
|
+
if (dragBlockedRef.current) {
|
|
1114
|
+
getDragTargets(dragState).forEach(({ element }) =>
|
|
1115
|
+
element.classList.remove('dragging'),
|
|
1116
|
+
);
|
|
1117
|
+
resetDragState();
|
|
1118
|
+
refreshSelectionOverlay(doc);
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1054
1122
|
if (autoLayoutDragStateRef.current) {
|
|
1055
1123
|
// ゴースト並べ替え中:DOMの並びは変えずにゴーストだけ畳む
|
|
1056
1124
|
cancelAutoLayoutDrag(autoLayoutDragStateRef.current, doc);
|
|
@@ -1222,6 +1290,18 @@ export function useDragResize(
|
|
|
1222
1290
|
clearSmartGuides(iframeDoc);
|
|
1223
1291
|
setOverlayLabel(null);
|
|
1224
1292
|
|
|
1293
|
+
// ── 空振りの後始末 ──
|
|
1294
|
+
// 何も書いていないので履歴も送らない。掴んだ見た目だけ戻す
|
|
1295
|
+
if (dragBlockedRef.current) {
|
|
1296
|
+
getDragTargets(dragState).forEach(({ element }) =>
|
|
1297
|
+
element.classList.remove('dragging'),
|
|
1298
|
+
);
|
|
1299
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1300
|
+
sendElementInfo(dragState.element, iframeDoc);
|
|
1301
|
+
resetDragState();
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1225
1305
|
// ── 並べ替えの確定 ──
|
|
1226
1306
|
// 座標(left/top)は書いていないので Tailwind への変換は通さない。
|
|
1227
1307
|
// 変わったのは DOM の順番だけで、それは innerHTML にそのまま出る
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useEffect } from 'react';
|
|
8
8
|
import { useEditorContext } from '../EditorContext';
|
|
9
|
-
import { generateElementId } from '../utils/dom-utils';
|
|
9
|
+
import { generateElementId, lockCaretScroll } from '../utils/dom-utils';
|
|
10
10
|
import { inkStyle } from '../utils/ink-style';
|
|
11
11
|
import { pendingShape, shapeStyles as shapeLibStyles } from '../utils/shape-library';
|
|
12
12
|
|
|
@@ -205,7 +205,9 @@ export function useDrawingMode() {
|
|
|
205
205
|
artboard.appendChild(textEl);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
// 置いた場所は見えているので、フォーカスやキャレット追従で紙面を動かさない
|
|
209
|
+
lockCaretScroll(textEl, iframeDoc);
|
|
210
|
+
textEl.focus({ preventScroll: true });
|
|
209
211
|
textEl.classList.add('selected', 'editing');
|
|
210
212
|
|
|
211
213
|
const range = iframeDoc.createRange();
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
hasEditableChildren,
|
|
16
16
|
prepareElementDragOrigin,
|
|
17
17
|
} from '../utils/dom-utils';
|
|
18
|
+
import {
|
|
19
|
+
resolveWebpageDrag,
|
|
20
|
+
realChildren,
|
|
21
|
+
applyReorder,
|
|
22
|
+
} from '../utils/flex-reorder';
|
|
18
23
|
import { extractElementInfo } from '../utils/style-utils';
|
|
19
24
|
import { applyTailwindStyles, convertInlineStylesToTailwind } from '../utils/tailwind-utils';
|
|
20
25
|
import { copyElementsToFigma, isFigmaExportAvailable } from '../utils/figma-export';
|
|
@@ -152,6 +157,42 @@ export interface MoveElementResult {
|
|
|
152
157
|
blocked: null | 'no-selection' | 'flow';
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
/**
|
|
161
|
+
* webpage モードの矢印キー = 「並べ替えを1段動かす」
|
|
162
|
+
*
|
|
163
|
+
* webpage はフロー内の要素を絶対配置へ倒さない(README の設計原則)。
|
|
164
|
+
* 倒すと版面が固定pxに固まり、後続の要素が詰め上がってページ高さが変わる。
|
|
165
|
+
* そこで矢印キーは座標ではなく **並び順** を1つ動かす。
|
|
166
|
+
* ↑/← = ひとつ前へ / ↓/→ = ひとつ後ろへ(Shift 付きでも1段。並びに10段の意味は無い)。
|
|
167
|
+
*
|
|
168
|
+
* @returns null なら「並べ替えの対象ではない」= 呼び出し側の座標移動へ落とす
|
|
169
|
+
*/
|
|
170
|
+
function reorderSelectionByStep(
|
|
171
|
+
elements: HTMLElement[],
|
|
172
|
+
step: -1 | 1,
|
|
173
|
+
iframeDoc: Document,
|
|
174
|
+
): MoveElementResult | null {
|
|
175
|
+
const decision = resolveWebpageDrag(elements, iframeDoc);
|
|
176
|
+
// すでに絶対配置(Cmd+ドラッグで自由配置にした要素など)は従来どおり座標で動かす
|
|
177
|
+
if (decision.kind === 'free') return null;
|
|
178
|
+
// 相手がいない / 群がバラバラ。ドラッグと同じ理由で何もしない
|
|
179
|
+
if (decision.kind !== 'reorder') return { moved: 0, blocked: 'flow' };
|
|
180
|
+
|
|
181
|
+
const { elements: block, parent } = decision.plan;
|
|
182
|
+
const children = realChildren(parent);
|
|
183
|
+
const moving = new Set(block);
|
|
184
|
+
const siblings = children.filter((c) => !moving.has(c));
|
|
185
|
+
const originalIndex = children.indexOf(block[0]);
|
|
186
|
+
if (originalIndex < 0) return { moved: 0, blocked: 'flow' };
|
|
187
|
+
|
|
188
|
+
const next = originalIndex + step;
|
|
189
|
+
// 端に着いている。黙って止まるのが正しい(案内は出さない)
|
|
190
|
+
if (next < 0 || next > siblings.length) return { moved: 0, blocked: null };
|
|
191
|
+
|
|
192
|
+
const changed = applyReorder(parent, block, siblings, originalIndex, next);
|
|
193
|
+
return { moved: changed ? block.length : 0, blocked: null };
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
/**
|
|
156
197
|
* 最後に使用したスタイルをキャプチャ
|
|
157
198
|
*/
|
|
@@ -1326,6 +1367,24 @@ export function useElementActions() {
|
|
|
1326
1367
|
.filter((el): el is HTMLElement => el !== null);
|
|
1327
1368
|
if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1328
1369
|
|
|
1370
|
+
// webpage はフロー内の要素を倒さない。矢印キーは並べ替えを1段動かす
|
|
1371
|
+
if (editorMode === 'webpage') {
|
|
1372
|
+
const step: -1 | 1 = dx < 0 || dy < 0 ? -1 : 1;
|
|
1373
|
+
const reordered = reorderSelectionByStep(elements, step, iframeDoc);
|
|
1374
|
+
if (reordered) {
|
|
1375
|
+
if (reordered.moved > 0) {
|
|
1376
|
+
notifyIframeChange();
|
|
1377
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1378
|
+
const anchor = selectedElement
|
|
1379
|
+
? getIframeElement(iframeDoc, selectedElement.id) ?? elements[0]
|
|
1380
|
+
: elements[0];
|
|
1381
|
+
const info = extractElementInfo(anchor, iframeDoc);
|
|
1382
|
+
if (info) setSelectedElement(info);
|
|
1383
|
+
}
|
|
1384
|
+
return reordered;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1329
1388
|
// [モード廃止] フロー内の要素は「動かせない」で終わらせず、その要素だけを
|
|
1330
1389
|
// 絶対配置へ変換してから動かす(ドラッグと同じ遅延変換)。
|
|
1331
1390
|
// 以前はここで弾いていたため、矢印キーが無反応になっていた。
|
|
@@ -1350,7 +1409,7 @@ export function useElementActions() {
|
|
|
1350
1409
|
if (info) setSelectedElement(info);
|
|
1351
1410
|
|
|
1352
1411
|
return { moved: elements.length, blocked: null };
|
|
1353
|
-
}, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement]);
|
|
1412
|
+
}, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, editorMode]);
|
|
1354
1413
|
|
|
1355
1414
|
/**
|
|
1356
1415
|
* 選択中の全要素の寸法を変える(キーボードリサイズ用)。
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getArtboardScale,
|
|
20
20
|
refreshSelectionOverlay,
|
|
21
21
|
syncSelectionOverlayRects,
|
|
22
|
+
lockCaretScroll,
|
|
22
23
|
} from "../utils/dom-utils";
|
|
23
24
|
import { extractElementInfo } from "../utils/style-utils";
|
|
24
25
|
import { MARQUEE_DRAG_THRESHOLD } from "../constants";
|
|
@@ -1093,11 +1094,14 @@ export function useElementSelection(
|
|
|
1093
1094
|
const rawTarget = e.target as HTMLElement;
|
|
1094
1095
|
const existingIds = selectedElementIdsRef.current;
|
|
1095
1096
|
|
|
1096
|
-
// selection-box
|
|
1097
|
+
// selection-boxをクリックした場合、対象要素のドラッグを開始。
|
|
1098
|
+
// Cmd/Ctrl(最下層を選ぶ)と Shift(追加/解除)は下の判別器に任せる。
|
|
1099
|
+
// 以前は Cmd でもここで掴んでいたため、選択中の器の上で Cmd+クリックしても
|
|
1100
|
+
// 中の要素を選べなかった(Figmaでは選べる)
|
|
1097
1101
|
const selectionBox = rawTarget.closest(
|
|
1098
1102
|
".selection-box",
|
|
1099
1103
|
) as HTMLElement | null;
|
|
1100
|
-
if (selectionBox && !e.shiftKey) {
|
|
1104
|
+
if (selectionBox && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
|
1101
1105
|
const forElementId = selectionBox.getAttribute("data-for-element");
|
|
1102
1106
|
if (forElementId) {
|
|
1103
1107
|
const targetEl = iframeDoc.querySelector(
|
|
@@ -1158,15 +1162,30 @@ export function useElementSelection(
|
|
|
1158
1162
|
// ============================================================
|
|
1159
1163
|
|
|
1160
1164
|
// ---- ① ヒットテスト ----
|
|
1161
|
-
|
|
1165
|
+
// 選択枠(オーバーレイ)の上で押した場合、e.target は枠なので紙面の要素が分からない。
|
|
1166
|
+
// 枠の下にある要素を elementsFromPoint で拾い、それを押した点として扱う
|
|
1167
|
+
// (Cmd+クリックの最下層選択、Shift+クリックの追加/解除がここを通る)
|
|
1168
|
+
let hitSource: Element | null = rawTarget;
|
|
1169
|
+
if (selectionBox && typeof iframeDoc.elementsFromPoint === "function") {
|
|
1170
|
+
hitSource =
|
|
1171
|
+
iframeDoc
|
|
1172
|
+
.elementsFromPoint(e.clientX, e.clientY)
|
|
1173
|
+
.find(
|
|
1174
|
+
(el) =>
|
|
1175
|
+
!el.closest(
|
|
1176
|
+
".selection-box,.marquee-selection-box,[data-editor-overlay],#gg-smart-guides,#gg-measure-layer",
|
|
1177
|
+
),
|
|
1178
|
+
) ?? null;
|
|
1179
|
+
}
|
|
1180
|
+
const hit = getEditableElement(hitSource, iframeDoc);
|
|
1162
1181
|
// 空白=押した点の祖先に編集可能要素がまったく無い状態。
|
|
1163
1182
|
// #artboard / 紙面と同じ大きさの器 / #artboard-wrapper / #canvas-container は
|
|
1164
1183
|
// initializeEditableElements が意図的に data-editable を付けないので
|
|
1165
1184
|
// ここで自然に「空白」と判定される(IDの白リストは不要)。
|
|
1166
1185
|
const isBlank =
|
|
1167
1186
|
!hit &&
|
|
1168
|
-
!(typeof
|
|
1169
|
-
?
|
|
1187
|
+
!(hitSource && typeof hitSource.closest === "function"
|
|
1188
|
+
? hitSource.closest('[data-editable="true"]')
|
|
1170
1189
|
: null);
|
|
1171
1190
|
|
|
1172
1191
|
// ---- ② 役割決定 ----
|
|
@@ -1211,7 +1230,10 @@ export function useElementSelection(
|
|
|
1211
1230
|
// 押した点を含む「選択済み要素」。あればそれを掴んだものとして扱う。
|
|
1212
1231
|
// (コンテキスト解決で祖先へ引き上げると、掴んだ要素と選択済み要素が
|
|
1213
1232
|
// 食い違って群が壊れる)
|
|
1214
|
-
const selectedHit = findSelectedHit(
|
|
1233
|
+
const selectedHit = findSelectedHit(
|
|
1234
|
+
(hitSource as HTMLElement | null) ?? rawTarget,
|
|
1235
|
+
iframeDoc,
|
|
1236
|
+
);
|
|
1215
1237
|
|
|
1216
1238
|
let target: HTMLElement;
|
|
1217
1239
|
if (meta) {
|
|
@@ -1370,8 +1392,13 @@ export function useElementSelection(
|
|
|
1370
1392
|
.querySelectorAll(".selection-box")
|
|
1371
1393
|
.forEach((box) => box.remove());
|
|
1372
1394
|
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1395
|
+
// 紙面が飛ぶのを防ぐ(キャレット追従のスクロールを打ち消す)。
|
|
1396
|
+
// 祖先のスクロール位置は focus の前に覚える必要があるので、フォーカスより先に張る
|
|
1397
|
+
lockCaretScroll(element, iframeDoc);
|
|
1398
|
+
|
|
1399
|
+
// preventScroll: ダブルクリックした場所は既に見えているので、フォーカスでの
|
|
1400
|
+
// スクロールは要らない(あると編集に入った瞬間に紙面が動く)
|
|
1401
|
+
element.focus({ preventScroll: true });
|
|
1375
1402
|
|
|
1376
1403
|
// テキスト全体を選択(オプション)
|
|
1377
1404
|
const selection = iframeDoc.getSelection();
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { useCallback } from 'react';
|
|
19
|
-
import { refreshSelectionOverlay } from '../utils/dom-utils';
|
|
19
|
+
import { refreshSelectionOverlay, getArtboardContent } from '../utils/dom-utils';
|
|
20
20
|
|
|
21
21
|
interface UseKeyboardShortcutsOptions {
|
|
22
22
|
/** グループ化関数 */
|
|
@@ -186,6 +186,17 @@ export function exitTextEditingIn(iframeDoc: Document): boolean {
|
|
|
186
186
|
|
|
187
187
|
// 選択ボックスを再表示(1要素ずつ作り直すと他の枠を巻き込んで壊すため全体を再構築)
|
|
188
188
|
refreshSelectionOverlay(iframeDoc);
|
|
189
|
+
|
|
190
|
+
// [確定を履歴に積む]
|
|
191
|
+
// フォーカスが外れて確定する経路(EditorCanvas の focusout)は、contenteditable が
|
|
192
|
+
// まだ付いている要素からのイベントでだけ内容変更を通知する。ここは先に属性を
|
|
193
|
+
// 外すので focusout 経路が黙り、Escape で確定した編集が履歴にも自動保存にも
|
|
194
|
+
// 乗らなかった(Cmd+Z で戻らない・保存されないまま閉じられる)。
|
|
195
|
+
// 同じ内容なら履歴側で重複扱いになるので、変わっていなくても送ってよい
|
|
196
|
+
window.postMessage(
|
|
197
|
+
{ type: 'SLIDE_CONTENT_CHANGED', html: getArtboardContent(iframeDoc) },
|
|
198
|
+
'*',
|
|
199
|
+
);
|
|
189
200
|
return true;
|
|
190
201
|
}
|
|
191
202
|
|
|
@@ -249,9 +249,15 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
|
|
|
249
249
|
scrollArea.style.minWidth = `${cw}px`;
|
|
250
250
|
scrollArea.style.minHeight = `${ch}px`;
|
|
251
251
|
|
|
252
|
+
// webpage は上端固定(#canvas-scroll-area が flex-start、変形の基準も top)。
|
|
253
|
+
// 上の余白は中央寄せでは自然に生まれたが、上端固定では自分で確保する。
|
|
254
|
+
// 縦方向の中央寄せもしない(ページの高さが変わるたびに紙面が動く原因だった)
|
|
255
|
+
const topAnchored = iframeDoc.body.dataset.editorMode === 'webpage';
|
|
256
|
+
scrollArea.style.paddingTop = topAnchored ? `${padding}px` : '';
|
|
257
|
+
|
|
252
258
|
// 紙面が容器に収まる軸はスクロールを中央へ(拡大時のパンには干渉しない)
|
|
253
259
|
if (w * scale <= cw) container.scrollLeft = Math.max(0, (scrollArea.offsetWidth - cw) / 2);
|
|
254
|
-
if (h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
|
|
260
|
+
if (!topAnchored && h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
|
|
255
261
|
}
|
|
256
262
|
|
|
257
263
|
/* ============================ 原本への書き戻し支援 ============================
|
|
@@ -1784,11 +1790,12 @@ export function convertDragTargetsToAbsolute(
|
|
|
1784
1790
|
.map(({ element, origin }) => {
|
|
1785
1791
|
const cs = win.getComputedStyle(element);
|
|
1786
1792
|
if (cs.position === 'absolute' || cs.position === 'fixed') return null;
|
|
1793
|
+
const box = readOffsetBox(element, iframeDoc);
|
|
1787
1794
|
return {
|
|
1788
1795
|
element,
|
|
1789
1796
|
origin,
|
|
1790
|
-
width:
|
|
1791
|
-
height:
|
|
1797
|
+
width: box.width,
|
|
1798
|
+
height: box.height,
|
|
1792
1799
|
parent: element.parentElement,
|
|
1793
1800
|
};
|
|
1794
1801
|
})
|
|
@@ -1838,6 +1845,19 @@ export function fitElementToContent(
|
|
|
1838
1845
|
const fitH = handle.includes('n') || handle.includes('s');
|
|
1839
1846
|
if (!fitW && !fitH) return { changed: false };
|
|
1840
1847
|
|
|
1848
|
+
// 流し込みの子(カードやリスト項目)を並べている器は対象外。
|
|
1849
|
+
// 「最長行に幅を合わせる」はテキスト箱のための採寸で、器に掛けると
|
|
1850
|
+
// 中の文の長さまで幅が縮み、カードが折れてページ高さまで変わる
|
|
1851
|
+
// (Webページの商品一覧で、ハンドルを2回押しただけで 490→337px に潰れた)。
|
|
1852
|
+
// 絶対配置の子だけを持つ器は下の childExtent で正しく扱えるので通す
|
|
1853
|
+
const INLINE_TEXT_TAGS = new Set(['SPAN', 'A', 'B', 'I', 'EM', 'STRONG', 'BR', 'SMALL', 'SUP', 'SUB', 'MARK', 'CODE']);
|
|
1854
|
+
const hasFlowChildren = [...element.children].some((c) => {
|
|
1855
|
+
if (INLINE_TEXT_TAGS.has(c.tagName)) return false;
|
|
1856
|
+
const pos = win.getComputedStyle(c).position;
|
|
1857
|
+
return pos !== 'absolute' && pos !== 'fixed';
|
|
1858
|
+
});
|
|
1859
|
+
if (hasFlowChildren) return { changed: false };
|
|
1860
|
+
|
|
1841
1861
|
const before = { w: element.offsetWidth, h: element.offsetHeight,
|
|
1842
1862
|
left: element.offsetLeft, top: element.offsetTop };
|
|
1843
1863
|
|
|
@@ -1915,6 +1935,105 @@ export function fitElementToContent(
|
|
|
1915
1935
|
return { changed: true };
|
|
1916
1936
|
}
|
|
1917
1937
|
|
|
1938
|
+
/**
|
|
1939
|
+
* テキスト編集中、ブラウザのキャレット追従で紙面が飛ぶのを防ぐ。
|
|
1940
|
+
*
|
|
1941
|
+
* 編集に入る・文字を打つ・End/Home でキャレットが動くたびに、ブラウザは
|
|
1942
|
+
* キャレットを「見える位置」へスクロールさせる。紙面は #artboard-wrapper の
|
|
1943
|
+
* transform で縮小されているため、その計算が狂い、見えているのに大きく
|
|
1944
|
+
* スクロールしてしまう(実測: スライドで 53px、Webページで 1900px 飛んだ)。
|
|
1945
|
+
* ダブルクリックした場所は既に見えているので、キャレット起因のスクロールは要らない。
|
|
1946
|
+
*
|
|
1947
|
+
* 追従は打鍵の後、次の描画更新の中で走る(rAF より後)ので、scroll イベントで
|
|
1948
|
+
* 捕まえて描画される前に戻す。overflow:hidden の html/body/#artboard も
|
|
1949
|
+
* プログラム的にはスクロールされ得るので、要素からルートまでの祖先を全部見る。
|
|
1950
|
+
* ユーザー自身のスクロール(ホイール・パン・スクロールバー)は直前の入力で見分け、
|
|
1951
|
+
* その直後の scroll は「意図した移動」として覚え直す。
|
|
1952
|
+
* 編集を抜けたら(contenteditable が外れたら)自分で外れる。
|
|
1953
|
+
*/
|
|
1954
|
+
export function lockCaretScroll(element: HTMLElement, iframeDoc: Document): void {
|
|
1955
|
+
const scrollers: HTMLElement[] = [];
|
|
1956
|
+
for (let a = element.parentElement; a; a = a.parentElement) scrollers.push(a);
|
|
1957
|
+
const remembered = scrollers.map((s) => ({ left: s.scrollLeft, top: s.scrollTop }));
|
|
1958
|
+
const remember = () => {
|
|
1959
|
+
scrollers.forEach((s, i) => {
|
|
1960
|
+
remembered[i].left = s.scrollLeft;
|
|
1961
|
+
remembered[i].top = s.scrollTop;
|
|
1962
|
+
});
|
|
1963
|
+
};
|
|
1964
|
+
const restore = () => {
|
|
1965
|
+
scrollers.forEach((s, i) => {
|
|
1966
|
+
if (s.scrollLeft !== remembered[i].left) s.scrollLeft = remembered[i].left;
|
|
1967
|
+
if (s.scrollTop !== remembered[i].top) s.scrollTop = remembered[i].top;
|
|
1968
|
+
});
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
let userScrollUntil = 0;
|
|
1972
|
+
const markUserScroll = () => {
|
|
1973
|
+
userScrollUntil = performance.now() + 400;
|
|
1974
|
+
};
|
|
1975
|
+
const onScroll = () => {
|
|
1976
|
+
if (element.getAttribute('contenteditable') !== 'true') return detach();
|
|
1977
|
+
if (performance.now() < userScrollUntil) remember();
|
|
1978
|
+
else restore();
|
|
1979
|
+
};
|
|
1980
|
+
const detach = () => {
|
|
1981
|
+
iframeDoc.removeEventListener('wheel', markUserScroll, true);
|
|
1982
|
+
iframeDoc.removeEventListener('mousedown', markUserScroll, true);
|
|
1983
|
+
iframeDoc.removeEventListener('touchstart', markUserScroll, true);
|
|
1984
|
+
iframeDoc.removeEventListener('scroll', onScroll, true);
|
|
1985
|
+
};
|
|
1986
|
+
iframeDoc.addEventListener('wheel', markUserScroll, true);
|
|
1987
|
+
iframeDoc.addEventListener('mousedown', markUserScroll, true);
|
|
1988
|
+
iframeDoc.addEventListener('touchstart', markUserScroll, true);
|
|
1989
|
+
iframeDoc.addEventListener('scroll', onScroll, true);
|
|
1990
|
+
// フォーカスや全選択で既に動いていたら、その場で戻す
|
|
1991
|
+
restore();
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* offsetLeft/offsetTop/offsetWidth/offsetHeight 相当を、SVG でも取れる形で読む。
|
|
1996
|
+
*
|
|
1997
|
+
* [なぜ必要か] `<svg>` と その中身は SVGElement で、offsetLeft 等を**持たない**
|
|
1998
|
+
* (undefined)。そのまま origin にすると `left: NaNpx` が書かれて無視され、
|
|
1999
|
+
* 掴んでも1pxも動かない(スライドのロゴ・図形で実測)。
|
|
2000
|
+
* HTMLElement は従来どおり offset 系(スケール前の CSS px、transform の影響なし)を
|
|
2001
|
+
* 使い、持たない要素だけ矩形から割り戻す。基準は offsetParent と同じ
|
|
2002
|
+
* 「最も近い position が static でない祖先」(無ければ body)。
|
|
2003
|
+
*/
|
|
2004
|
+
export function readOffsetBox(
|
|
2005
|
+
element: HTMLElement,
|
|
2006
|
+
iframeDoc: Document,
|
|
2007
|
+
): { left: number; top: number; width: number; height: number } {
|
|
2008
|
+
if (typeof element.offsetLeft === 'number') {
|
|
2009
|
+
return {
|
|
2010
|
+
left: element.offsetLeft,
|
|
2011
|
+
top: element.offsetTop,
|
|
2012
|
+
width: element.offsetWidth,
|
|
2013
|
+
height: element.offsetHeight,
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
const win = iframeDoc.defaultView;
|
|
2017
|
+
const artboard = iframeDoc.getElementById('artboard');
|
|
2018
|
+
const scale = artboard ? artboard.getBoundingClientRect().width / (artboard.offsetWidth || 1) || 1 : 1;
|
|
2019
|
+
let parent: HTMLElement | null = element.parentElement;
|
|
2020
|
+
while (parent && parent !== iframeDoc.body && win?.getComputedStyle(parent).position === 'static') {
|
|
2021
|
+
parent = parent.parentElement;
|
|
2022
|
+
}
|
|
2023
|
+
const base = parent ?? iframeDoc.body;
|
|
2024
|
+
const baseRect = base.getBoundingClientRect();
|
|
2025
|
+
const baseStyle = win?.getComputedStyle(base);
|
|
2026
|
+
const borderLeft = parseFloat(baseStyle?.borderLeftWidth || '0') || 0;
|
|
2027
|
+
const borderTop = parseFloat(baseStyle?.borderTopWidth || '0') || 0;
|
|
2028
|
+
const rect = element.getBoundingClientRect();
|
|
2029
|
+
return {
|
|
2030
|
+
left: (rect.left - baseRect.left) / scale - borderLeft,
|
|
2031
|
+
top: (rect.top - baseRect.top) / scale - borderTop,
|
|
2032
|
+
width: rect.width / scale,
|
|
2033
|
+
height: rect.height / scale,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1918
2037
|
export function prepareElementDragOrigin(
|
|
1919
2038
|
element: HTMLElement,
|
|
1920
2039
|
iframeDoc: Document,
|
|
@@ -1929,7 +2048,8 @@ export function prepareElementDragOrigin(
|
|
|
1929
2048
|
if (!options.convertToAbsolute) {
|
|
1930
2049
|
// 変換が許されていない(オートレイアウト等)。
|
|
1931
2050
|
// 値だけ返して要素は一切触らない。
|
|
1932
|
-
|
|
2051
|
+
const box = readOffsetBox(element, iframeDoc);
|
|
2052
|
+
return { left: box.left, top: box.top };
|
|
1933
2053
|
}
|
|
1934
2054
|
|
|
1935
2055
|
// 親を offsetParent に確定させてから採寸する。
|
|
@@ -1944,10 +2064,7 @@ export function prepareElementDragOrigin(
|
|
|
1944
2064
|
}
|
|
1945
2065
|
|
|
1946
2066
|
// 採寸はスタイルを書き換える前に行う(margin:0 を入れると値が変わるため)
|
|
1947
|
-
const left = element
|
|
1948
|
-
const top = element.offsetTop;
|
|
1949
|
-
const width = element.offsetWidth;
|
|
1950
|
-
const height = element.offsetHeight;
|
|
2067
|
+
const { left, top, width, height } = readOffsetBox(element, iframeDoc);
|
|
1951
2068
|
|
|
1952
2069
|
capturePrestyle(element);
|
|
1953
2070
|
element.style.width = `${width}px`;
|
|
@@ -1960,8 +2077,10 @@ export function prepareElementDragOrigin(
|
|
|
1960
2077
|
}
|
|
1961
2078
|
|
|
1962
2079
|
// すでに絶対配置。offsetLeft/offsetTop がそのまま left/top の基準になる
|
|
1963
|
-
|
|
1964
|
-
const
|
|
2080
|
+
// (SVG は offset 系を持たないので矩形から割り戻す)
|
|
2081
|
+
const box = readOffsetBox(element, iframeDoc);
|
|
2082
|
+
const left = box.left;
|
|
2083
|
+
const top = box.top;
|
|
1965
2084
|
|
|
1966
2085
|
// right / bottom で位置決めされている要素(例: `absolute bottom-[24px] left-[96px]`)に
|
|
1967
2086
|
// そのまま top を書くと、top と bottom が同時に効いて「移動」ではなく「伸長」になる。
|
|
@@ -1978,8 +2097,8 @@ export function prepareElementDragOrigin(
|
|
|
1978
2097
|
(element.style.bottom !== '' && element.style.bottom !== 'auto') ||
|
|
1979
2098
|
/(^|\s)-?bottom-/.test(element.className);
|
|
1980
2099
|
if (hasRight || hasBottom) {
|
|
1981
|
-
element.style.width = `${
|
|
1982
|
-
element.style.height = `${
|
|
2100
|
+
element.style.width = `${box.width}px`;
|
|
2101
|
+
element.style.height = `${box.height}px`;
|
|
1983
2102
|
if (hasRight) {
|
|
1984
2103
|
element.style.right = 'auto';
|
|
1985
2104
|
// インラインの auto だけでは `right-[24px]` クラスが保存HTMLに残り、
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* フロー内ドラッグ = 同一親の中での並べ替え。
|
|
3
3
|
*
|
|
4
4
|
* オートレイアウト(display:flex)の子を掴んだとき、従来は問答無用で絶対配置へ
|
|
5
5
|
* 倒していたため、フレックスの設定が触った瞬間に無意味になっていた。
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* 以前あった「ゴースト並べ替え」は任意の親へ再挿入できたため、離した場所に
|
|
8
8
|
* よっては要素が画面の外へ消えた。同一親に限ることでその事故を構造的に防ぐ。
|
|
9
9
|
*
|
|
10
|
+
* 【slide と webpage で入口が違う】
|
|
11
|
+
* - slide : `shouldReorder` = 明示的にオートレイアウトONにした flex の器だけ。
|
|
12
|
+
* テンプレート由来の flex が山ほどあり、全部を器にすると既定の
|
|
13
|
+
* ドラッグ(絶対配置化して自由移動)が勝手に変わってしまうため。
|
|
14
|
+
* - webpage: `resolveWebpageDrag` = フロー内の要素は**常に**並べ替え。
|
|
15
|
+
* webpage は版面を流し込みのまま保つ約束なので、絶対配置へ倒すと
|
|
16
|
+
* ページ高さが潰れて紙面が飛ぶ(これが「動かすと版面が崩れる」の正体)。
|
|
17
|
+
*
|
|
10
18
|
* 座標はクライアント座標(getBoundingClientRect)のまま扱う。ここで必要なのは
|
|
11
19
|
* 「カーソルが兄弟の前半か後半か」だけで、アートボード座標へ割り戻す必要がない。
|
|
12
20
|
*/
|
|
@@ -22,22 +30,69 @@ const OVERLAY_SELECTOR =
|
|
|
22
30
|
'.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
|
|
23
31
|
'.flex-drop-indicator,#gg-measure-layer,#gg-smart-guides,#' + INDICATOR_ID;
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 並びの性格。挿入位置の決め方と、青い線の引き方がこれで変わる。
|
|
35
|
+
* - block : 縦一列の流し込み。前後は y の中点で決まる
|
|
36
|
+
* - flex : 主軸が1本(row/column)。前後は主軸の中点で決まる
|
|
37
|
+
* - grid2d: grid と flex-wrap。1本の軸では決まらないので、
|
|
38
|
+
* 「カーソルに一番近い兄弟」を選んでから、ずれの大きい軸で前後を決める
|
|
39
|
+
*/
|
|
40
|
+
export type ReorderLayout = 'block' | 'flex' | 'grid2d';
|
|
41
|
+
|
|
42
|
+
/** 親が並べ替えの器になり得る display か。webpage の入口で使う */
|
|
43
|
+
function classifyDisplay(display: string): ReorderLayout | null {
|
|
44
|
+
if (display === 'flex' || display === 'inline-flex') return 'flex';
|
|
45
|
+
if (display === 'grid' || display === 'inline-grid') return 'grid2d';
|
|
46
|
+
if (display === 'block' || display === 'flow-root' || display === 'list-item') return 'block';
|
|
47
|
+
// inline / table 系 / contents などは並びの前後が素直に決まらないので触らない
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ドラッグを並べ替えとして扱うための材料。これが作れたら並べ替えできる */
|
|
52
|
+
export interface ReorderPlan {
|
|
53
|
+
/** 動かす要素(DOM順の連続した塊)。単一選択なら1個 */
|
|
54
|
+
elements: HTMLElement[];
|
|
55
|
+
parent: HTMLElement;
|
|
56
|
+
layout: ReorderLayout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** webpage モードでドラッグをどう扱うかの判定結果 */
|
|
60
|
+
export type WebpageDragPlan =
|
|
61
|
+
/** 同一親の中で並べ替える */
|
|
62
|
+
| { kind: 'reorder'; plan: ReorderPlan }
|
|
63
|
+
/** 兄弟がいない(ひとりっ子)。動かす先がないので何もしない */
|
|
64
|
+
| { kind: 'no-siblings' }
|
|
65
|
+
/** 複数選択がバラバラ(親が違う/連続していない/フロー内と絶対配置の混在) */
|
|
66
|
+
| { kind: 'not-contiguous' }
|
|
67
|
+
/** 従来どおり座標で自由に動かす(すでに絶対配置の要素など) */
|
|
68
|
+
| { kind: 'free' };
|
|
69
|
+
|
|
25
70
|
/** 並べ替え中の状態。ドラッグ開始時に1回だけ作る */
|
|
26
71
|
export interface ReorderSession {
|
|
27
|
-
|
|
72
|
+
/** 動かしている要素(DOM順の連続した塊) */
|
|
73
|
+
elements: HTMLElement[];
|
|
28
74
|
parent: HTMLElement;
|
|
29
75
|
/** 動かしている要素を除いた兄弟(DOM順) */
|
|
30
76
|
siblings: HTMLElement[];
|
|
31
|
-
|
|
77
|
+
layout: ReorderLayout;
|
|
78
|
+
/** 主軸が縦か(grid2d では使わない) */
|
|
32
79
|
isColumn: boolean;
|
|
33
|
-
/**
|
|
80
|
+
/** flex-direction が *-reverse か(画面上の並びとDOM順が逆になる) */
|
|
81
|
+
isReverse: boolean;
|
|
82
|
+
/** 開始時の挿入位置。ここと同じなら「順番は変わっていない」 */
|
|
34
83
|
originalIndex: number;
|
|
35
84
|
/** 直前に描いた挿入位置。同じなら描き直さない */
|
|
36
85
|
lastIndex: number;
|
|
86
|
+
/** grid2d で直近に使った軸。青い線をこの軸で引く */
|
|
87
|
+
lastAxis: 'x' | 'y';
|
|
88
|
+
/** grid2d で線を引く相手(カーソルに一番近い兄弟) */
|
|
89
|
+
lastNeighbor: HTMLElement | null;
|
|
90
|
+
/** lastNeighbor の手前に入れるのか(false なら後ろ) */
|
|
91
|
+
lastBefore: boolean;
|
|
37
92
|
}
|
|
38
93
|
|
|
39
94
|
/** 並べ替えの相手になる子だけを取り出す */
|
|
40
|
-
function realChildren(parent: HTMLElement): HTMLElement[] {
|
|
95
|
+
export function realChildren(parent: HTMLElement): HTMLElement[] {
|
|
41
96
|
const out: HTMLElement[] = [];
|
|
42
97
|
for (const node of Array.from(parent.children)) {
|
|
43
98
|
// [注意] `node instanceof HTMLElement` は使えない。この関数は親ウィンドウで
|
|
@@ -51,8 +106,14 @@ function realChildren(parent: HTMLElement): HTMLElement[] {
|
|
|
51
106
|
return out;
|
|
52
107
|
}
|
|
53
108
|
|
|
109
|
+
/** フロー(static/relative)の中にいるか */
|
|
110
|
+
function isInFlow(element: HTMLElement, win: Window): boolean {
|
|
111
|
+
const position = win.getComputedStyle(element).position;
|
|
112
|
+
return position !== 'absolute' && position !== 'fixed';
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
/**
|
|
55
|
-
*
|
|
116
|
+
* この要素を「同一親の並べ替え」で動かすべきか判定する。【slide 専用・変更禁止】
|
|
56
117
|
*
|
|
57
118
|
* 【既定は現状維持】computed が flex というだけでは並べ替えにしない。
|
|
58
119
|
* テンプレート由来の flex はスライド中に山ほどあり、それを全部器として扱うと
|
|
@@ -88,35 +149,132 @@ export function shouldReorder(
|
|
|
88
149
|
return realChildren(parent).length >= 2;
|
|
89
150
|
}
|
|
90
151
|
|
|
91
|
-
/**
|
|
92
|
-
export function
|
|
93
|
-
|
|
152
|
+
/** slide の並べ替え(shouldReorder が true のとき)の材料を作る */
|
|
153
|
+
export function planSlideReorder(
|
|
154
|
+
element: HTMLElement,
|
|
155
|
+
doc: Document,
|
|
156
|
+
): ReorderPlan | null {
|
|
94
157
|
const parent = element.parentElement;
|
|
95
|
-
if (!
|
|
158
|
+
if (!parent) return null;
|
|
159
|
+
// shouldReorder が display:flex/inline-flex を保証しているので layout は 'flex' 固定。
|
|
160
|
+
// ここで flex-wrap を見て grid2d へ振ると slide の既存挙動が変わるので、あえて見ない
|
|
161
|
+
return { elements: [element], parent, layout: 'flex' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* webpage モードでこのドラッグをどう扱うか決める。
|
|
166
|
+
*
|
|
167
|
+
* webpage は「絶対配置へ倒さない」が設計原則(README)。倒すと版面が固定pxに固まり、
|
|
168
|
+
* さらにカラムが流れから抜けてページ高さが潰れ、紙面が中央寄せで飛ぶ。
|
|
169
|
+
* そこでフロー内の要素は必ず並べ替えに回し、倒すのは Cmd/Ctrl の逃げ道だけにする。
|
|
170
|
+
*
|
|
171
|
+
* 判定の順番:
|
|
172
|
+
* 1. すでに絶対配置(全部) → free。倒す必要がないので従来どおり座標移動
|
|
173
|
+
* 2. フロー内と絶対配置の混ざった複数選択 → not-contiguous(何もしない)。
|
|
174
|
+
* 片方を倒すと版面が崩れ、倒さないと群がバラけるため、どちらも選ばない
|
|
175
|
+
* 3. 親が並べ替えの器になり得ない display → free
|
|
176
|
+
* 4. 複数選択が別の親 / 連続していない → not-contiguous
|
|
177
|
+
* 5. 兄弟がいない → no-siblings
|
|
178
|
+
* 6. それ以外 → reorder
|
|
179
|
+
*/
|
|
180
|
+
export function resolveWebpageDrag(
|
|
181
|
+
elements: HTMLElement[],
|
|
182
|
+
doc: Document,
|
|
183
|
+
): WebpageDragPlan {
|
|
184
|
+
const win = doc.defaultView;
|
|
185
|
+
if (!win || elements.length === 0) return { kind: 'free' };
|
|
186
|
+
|
|
187
|
+
const inFlow = elements.filter((el) => isInFlow(el, win));
|
|
188
|
+
// 1. 全部フロー外 → 触っても版面は動かない。従来どおりの自由移動
|
|
189
|
+
if (inFlow.length === 0) return { kind: 'free' };
|
|
190
|
+
// 2. 混在 → 群としてまとまった扱いができない
|
|
191
|
+
if (inFlow.length !== elements.length) return { kind: 'not-contiguous' };
|
|
192
|
+
|
|
193
|
+
const parent = elements[0].parentElement;
|
|
194
|
+
if (!parent) return { kind: 'free' };
|
|
195
|
+
if (elements.some((el) => el.parentElement !== parent)) {
|
|
196
|
+
return { kind: 'not-contiguous' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3. 器になり得る display か
|
|
200
|
+
const layout = classifyDisplay(win.getComputedStyle(parent).display);
|
|
201
|
+
if (!layout) return { kind: 'free' };
|
|
202
|
+
|
|
203
|
+
const children = realChildren(parent);
|
|
204
|
+
const indices = elements
|
|
205
|
+
.map((el) => children.indexOf(el))
|
|
206
|
+
.filter((i) => i >= 0)
|
|
207
|
+
.sort((a, b) => a - b);
|
|
208
|
+
if (indices.length !== elements.length) return { kind: 'free' };
|
|
209
|
+
|
|
210
|
+
// 4. 連続した塊か(飛び飛びの選択を1か所へ寄せると、選んでいない要素の順番まで変わる)
|
|
211
|
+
const contiguous = indices.every((v, i) => i === 0 || v === indices[i - 1] + 1);
|
|
212
|
+
if (!contiguous) return { kind: 'not-contiguous' };
|
|
213
|
+
|
|
214
|
+
// 5. 並べ替える相手がいない
|
|
215
|
+
if (children.length - elements.length < 1) return { kind: 'no-siblings' };
|
|
216
|
+
|
|
217
|
+
// DOM順に揃えてから渡す(選択の順番ではなく並びの順番で動かす)
|
|
218
|
+
const ordered = indices.map((i) => children[i]);
|
|
219
|
+
return { kind: 'reorder', plan: { elements: ordered, parent, layout } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 並べ替えの下ごしらえ(ドラッグが動き出した最初の1回だけ呼ぶ)
|
|
224
|
+
*
|
|
225
|
+
* @param options.restoreFlow
|
|
226
|
+
* 子の絶対配置を解いてフローへ戻すか。
|
|
227
|
+
* slide の明示ONの器では true(ONにした後に別経路で絶対配置が焼き込まれた子を直す)。
|
|
228
|
+
* **webpage では必ず false**。webpage の普通の親には「Cmd+ドラッグで自由配置にした子」が
|
|
229
|
+
* 混ざり得て、隣の要素を普通にドラッグしただけでそれが勝手にフローへ戻ってしまうため。
|
|
230
|
+
*/
|
|
231
|
+
export function startReorder(
|
|
232
|
+
plan: ReorderPlan,
|
|
233
|
+
doc: Document,
|
|
234
|
+
options: { restoreFlow: boolean },
|
|
235
|
+
): ReorderSession | null {
|
|
236
|
+
const win = doc.defaultView;
|
|
237
|
+
const { parent, elements, layout } = plan;
|
|
238
|
+
if (!win || elements.length === 0) return null;
|
|
96
239
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
240
|
+
if (options.restoreFlow) {
|
|
241
|
+
// 子はONにした時点でフロー化済み。ただし後から追加された子や、
|
|
242
|
+
// 別経路で絶対配置が焼き込まれた子が混じると並びが崩れるので保険で通す
|
|
243
|
+
restoreChildrenToFlow(parent);
|
|
244
|
+
}
|
|
100
245
|
|
|
101
246
|
const children = realChildren(parent);
|
|
102
|
-
const
|
|
103
|
-
|
|
247
|
+
const moving = new Set(elements);
|
|
248
|
+
const siblings = children.filter((c) => !moving.has(c));
|
|
249
|
+
const firstIndex = children.indexOf(elements[0]);
|
|
250
|
+
if (firstIndex < 0) return null;
|
|
251
|
+
|
|
252
|
+
// 連続した塊が children の firstIndex から始まるなら、
|
|
253
|
+
// 塊を抜いた siblings の中では firstIndex 番目の手前が「元の位置」になる
|
|
254
|
+
const originalIndex = firstIndex;
|
|
104
255
|
|
|
105
|
-
const
|
|
256
|
+
const parentStyle = win.getComputedStyle(parent);
|
|
257
|
+
const dir = parentStyle.flexDirection || 'row';
|
|
106
258
|
|
|
107
259
|
// 掴んでいる要素は「持ち上がっている」ことを見せる。
|
|
108
260
|
// 既存の .dragging(opacity .7 + grabbing)をそのまま使う。インラインstyleで
|
|
109
261
|
// 当てると保存HTMLに漏れうるが、このクラスは getCleanHtml が必ず剥がす
|
|
110
|
-
|
|
111
|
-
element.classList.add('dragging');
|
|
262
|
+
for (const el of elements) el.classList.add('dragging');
|
|
112
263
|
|
|
113
264
|
return {
|
|
114
|
-
|
|
265
|
+
elements,
|
|
115
266
|
parent,
|
|
116
|
-
siblings
|
|
117
|
-
|
|
267
|
+
siblings,
|
|
268
|
+
layout,
|
|
269
|
+
// block は縦一列。flex の computed は block でも 'row' を返すので、
|
|
270
|
+
// flexDirection をそのまま信じると横軸で判定してしまう
|
|
271
|
+
isColumn: layout === 'block' ? true : dir.startsWith('column'),
|
|
272
|
+
isReverse: layout === 'flex' && dir.endsWith('-reverse'),
|
|
118
273
|
originalIndex,
|
|
119
274
|
lastIndex: -1,
|
|
275
|
+
lastAxis: 'y',
|
|
276
|
+
lastNeighbor: null,
|
|
277
|
+
lastBefore: true,
|
|
120
278
|
};
|
|
121
279
|
}
|
|
122
280
|
|
|
@@ -124,8 +282,14 @@ export function startReorder(element: HTMLElement, doc: Document): ReorderSessio
|
|
|
124
282
|
* カーソル位置から「兄弟の何番目の前に入るか」を決める
|
|
125
283
|
*
|
|
126
284
|
* 返すのは siblings(動かしている要素を除いた配列)に対する挿入位置。
|
|
127
|
-
*
|
|
128
|
-
* flex
|
|
285
|
+
*
|
|
286
|
+
* block / flex は1本の軸しかないので、各兄弟の中点と比べるだけでよい。
|
|
287
|
+
* 要素が可変幅でも破綻しない。flex-direction:*-reverse は画面上の並びと
|
|
288
|
+
* DOM順が逆なので、最後に反転する。
|
|
289
|
+
*
|
|
290
|
+
* grid / flex-wrap は1本の軸では決まらない(右隣も下隣もいる)。
|
|
291
|
+
* カーソルに一番近い兄弟を選び、その中心からのずれを要素の幅・高さで正規化して、
|
|
292
|
+
* **ずれが支配的なほうの軸**で前後を決める。これ以上凝らない。
|
|
129
293
|
*/
|
|
130
294
|
export function computeInsertIndex(
|
|
131
295
|
session: ReorderSession,
|
|
@@ -133,9 +297,40 @@ export function computeInsertIndex(
|
|
|
133
297
|
clientY: number,
|
|
134
298
|
doc: Document,
|
|
135
299
|
): number {
|
|
136
|
-
const { siblings, isColumn } = session;
|
|
137
|
-
|
|
138
|
-
|
|
300
|
+
const { siblings, isColumn, isReverse, layout } = session;
|
|
301
|
+
if (siblings.length === 0) return 0;
|
|
302
|
+
|
|
303
|
+
if (layout === 'grid2d') {
|
|
304
|
+
let nearest = 0;
|
|
305
|
+
let best = Infinity;
|
|
306
|
+
let bestRect: DOMRect | null = null;
|
|
307
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
308
|
+
const r = siblings[i].getBoundingClientRect();
|
|
309
|
+
const cx = r.left + r.width / 2;
|
|
310
|
+
const cy = r.top + r.height / 2;
|
|
311
|
+
const d = (clientX - cx) ** 2 + (clientY - cy) ** 2;
|
|
312
|
+
if (d < best) {
|
|
313
|
+
best = d;
|
|
314
|
+
nearest = i;
|
|
315
|
+
bestRect = r;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!bestRect) return siblings.length;
|
|
319
|
+
|
|
320
|
+
const cx = bestRect.left + bestRect.width / 2;
|
|
321
|
+
const cy = bestRect.top + bestRect.height / 2;
|
|
322
|
+
// 幅・高さで割ってから比べる。割らないと、横長のカードで
|
|
323
|
+
// 少し上下にずれただけで「縦の入れ替え」に化ける
|
|
324
|
+
const nx = bestRect.width > 0 ? (clientX - cx) / bestRect.width : 0;
|
|
325
|
+
const ny = bestRect.height > 0 ? (clientY - cy) / bestRect.height : 0;
|
|
326
|
+
const useY = Math.abs(ny) > Math.abs(nx);
|
|
327
|
+
const before = useY ? clientY < cy : clientX < cx;
|
|
328
|
+
|
|
329
|
+
session.lastAxis = useY ? 'y' : 'x';
|
|
330
|
+
session.lastNeighbor = siblings[nearest];
|
|
331
|
+
session.lastBefore = before;
|
|
332
|
+
return before ? nearest : nearest + 1;
|
|
333
|
+
}
|
|
139
334
|
|
|
140
335
|
let index = siblings.length;
|
|
141
336
|
for (let i = 0; i < siblings.length; i++) {
|
|
@@ -148,6 +343,8 @@ export function computeInsertIndex(
|
|
|
148
343
|
break;
|
|
149
344
|
}
|
|
150
345
|
}
|
|
346
|
+
session.lastAxis = isColumn ? 'y' : 'x';
|
|
347
|
+
session.lastNeighbor = null;
|
|
151
348
|
return index;
|
|
152
349
|
}
|
|
153
350
|
|
|
@@ -158,6 +355,9 @@ export function computeInsertIndex(
|
|
|
158
355
|
* position が無いと座標の基準にできない。親の style を書き換えると版面が動くので、
|
|
159
356
|
* body直下に fixed で置く(画面座標そのまま)。ドラッグ中しか出ないので
|
|
160
357
|
* スクロール追従は不要。
|
|
358
|
+
*
|
|
359
|
+
* grid2d だけは親いっぱいに引くと意味が伝わらない(どの行に入るか分からない)ので、
|
|
360
|
+
* 「入る先の隣人」の辺に、その隣人の幅・高さで引く。
|
|
161
361
|
*/
|
|
162
362
|
export function drawInsertIndicator(
|
|
163
363
|
session: ReorderSession,
|
|
@@ -166,10 +366,26 @@ export function drawInsertIndicator(
|
|
|
166
366
|
): void {
|
|
167
367
|
clearInsertIndicator(doc);
|
|
168
368
|
|
|
169
|
-
const { siblings, isColumn, parent } = session;
|
|
369
|
+
const { siblings, isColumn, isReverse, parent, layout } = session;
|
|
370
|
+
const base = 'position:fixed;z-index:10001;pointer-events:none;' +
|
|
371
|
+
`background:${BLUE};border-radius:1.5px;box-shadow:0 0 6px rgba(13,153,255,0.5);`;
|
|
372
|
+
const line = doc.createElement('div');
|
|
373
|
+
line.id = INDICATOR_ID;
|
|
374
|
+
|
|
375
|
+
if (layout === 'grid2d' && session.lastNeighbor) {
|
|
376
|
+
const r = session.lastNeighbor.getBoundingClientRect();
|
|
377
|
+
if (session.lastAxis === 'y') {
|
|
378
|
+
const y = session.lastBefore ? r.top : r.bottom;
|
|
379
|
+
line.style.cssText = base + `left:${r.left}px;width:${r.width}px;top:${y - 1.5}px;height:3px;`;
|
|
380
|
+
} else {
|
|
381
|
+
const x = session.lastBefore ? r.left : r.right;
|
|
382
|
+
line.style.cssText = base + `top:${r.top}px;height:${r.height}px;left:${x - 1.5}px;width:3px;`;
|
|
383
|
+
}
|
|
384
|
+
doc.body.appendChild(line);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
170
388
|
const parentRect = parent.getBoundingClientRect();
|
|
171
|
-
const dir = doc.defaultView?.getComputedStyle(parent).flexDirection || 'row';
|
|
172
|
-
const isReverse = dir.endsWith('-reverse');
|
|
173
389
|
|
|
174
390
|
// 挿入位置の座標。末尾なら最後の兄弟の後ろ、それ以外は index 番目の手前
|
|
175
391
|
let pos: number;
|
|
@@ -183,10 +399,6 @@ export function drawInsertIndicator(
|
|
|
183
399
|
pos = isColumn ? (isReverse ? r.bottom : r.top) : isReverse ? r.right : r.left;
|
|
184
400
|
}
|
|
185
401
|
|
|
186
|
-
const line = doc.createElement('div');
|
|
187
|
-
line.id = INDICATOR_ID;
|
|
188
|
-
const base = 'position:fixed;z-index:10001;pointer-events:none;' +
|
|
189
|
-
`background:${BLUE};border-radius:1.5px;box-shadow:0 0 6px rgba(13,153,255,0.5);`;
|
|
190
402
|
line.style.cssText = isColumn
|
|
191
403
|
? base + `left:${parentRect.left}px;width:${parentRect.width}px;top:${pos - 1.5}px;height:3px;`
|
|
192
404
|
: base + `top:${parentRect.top}px;height:${parentRect.height}px;left:${pos - 1.5}px;width:3px;`;
|
|
@@ -200,32 +412,45 @@ export function clearInsertIndicator(doc: Document): void {
|
|
|
200
412
|
}
|
|
201
413
|
|
|
202
414
|
/**
|
|
203
|
-
*
|
|
415
|
+
* 塊を siblings の index 番目の手前へ入れる。順番が変わったら true
|
|
204
416
|
*
|
|
205
417
|
* insertBefore しか使わないので、要素は必ず同じ親の中に留まる。
|
|
206
418
|
*/
|
|
419
|
+
export function applyReorder(
|
|
420
|
+
parent: HTMLElement,
|
|
421
|
+
elements: HTMLElement[],
|
|
422
|
+
siblings: HTMLElement[],
|
|
423
|
+
originalIndex: number,
|
|
424
|
+
index: number,
|
|
425
|
+
): boolean {
|
|
426
|
+
// すでにその位置にいるなら DOM を触らない(履歴を無駄に汚さない)
|
|
427
|
+
if (index === originalIndex) return false;
|
|
428
|
+
|
|
429
|
+
const target = index >= siblings.length ? null : siblings[index];
|
|
430
|
+
// 塊はDOM順のまま順に差し込む。同じ参照点の前に入れれば元の順番が保たれる
|
|
431
|
+
for (const el of elements) parent.insertBefore(el, target);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 並べ替えを確定する。順番が変わったら true
|
|
437
|
+
*/
|
|
207
438
|
export function commitReorder(
|
|
208
439
|
session: ReorderSession,
|
|
209
440
|
index: number,
|
|
210
441
|
doc: Document,
|
|
211
442
|
): boolean {
|
|
212
|
-
const {
|
|
443
|
+
const { elements, parent, siblings, originalIndex } = session;
|
|
213
444
|
|
|
214
445
|
// 見た目の持ち上げを戻してから並べ替える(保存HTMLに opacity を残さない)
|
|
215
446
|
endReorder(session, doc);
|
|
216
447
|
|
|
217
|
-
|
|
218
|
-
// すでにその位置にいるなら DOM を触らない(履歴を無駄に汚さない)
|
|
219
|
-
if (target === element.nextElementSibling) return false;
|
|
220
|
-
if (target === null && element === parent.lastElementChild) return false;
|
|
221
|
-
|
|
222
|
-
parent.insertBefore(element, target);
|
|
223
|
-
return true;
|
|
448
|
+
return applyReorder(parent, elements, siblings, originalIndex, index);
|
|
224
449
|
}
|
|
225
450
|
|
|
226
451
|
/** 見た目の持ち上げと線を片付ける(取り消し・中断からも呼ぶ) */
|
|
227
452
|
export function endReorder(session: ReorderSession | null, doc: Document): void {
|
|
228
453
|
clearInsertIndicator(doc);
|
|
229
454
|
if (!session) return;
|
|
230
|
-
session.
|
|
455
|
+
for (const el of session.elements) el.classList.remove('dragging');
|
|
231
456
|
}
|
|
@@ -191,10 +191,11 @@ export function measureStyleSize(
|
|
|
191
191
|
doc: Document
|
|
192
192
|
): { width: number; height: number } {
|
|
193
193
|
const cs = doc.defaultView?.getComputedStyle(element);
|
|
194
|
-
|
|
195
|
-
let
|
|
194
|
+
// SVG は offsetWidth を持たない(undefined)。0 扱いにして下の矩形フォールバックへ流す
|
|
195
|
+
let width = typeof element.offsetWidth === 'number' ? element.offsetWidth : 0;
|
|
196
|
+
let height = typeof element.offsetHeight === 'number' ? element.offsetHeight : 0;
|
|
196
197
|
|
|
197
|
-
//
|
|
198
|
+
// インライン要素や SVG で offsetWidth が取れないときは矩形から拾う(保険)
|
|
198
199
|
if (width === 0 && height === 0) {
|
|
199
200
|
const rect = element.getBoundingClientRect();
|
|
200
201
|
const scale = getArtboardRenderScale(doc);
|
|
@@ -179,18 +179,23 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
|
|
|
179
179
|
background-color: ${CANVAS_BG_COLOR};
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/* パディング用ラッパー(スクロール領域確保)
|
|
182
|
+
/* パディング用ラッパー(スクロール領域確保)
|
|
183
|
+
webpage は上端固定にする。中央寄せだと、テキスト入力や並べ替えでページの
|
|
184
|
+
高さが変わるたびに紙面が上下へ動き、見ていた場所が飛ぶ(実測 84〜729px)。
|
|
185
|
+
ブラウザのページと同じで、高さが変わっても上端は動かないのが正しい */
|
|
183
186
|
#canvas-scroll-area {
|
|
184
187
|
display: flex;
|
|
185
|
-
align-items: center;
|
|
188
|
+
align-items: ${editorMode === 'webpage' ? 'flex-start' : 'center'};
|
|
186
189
|
justify-content: center;
|
|
187
190
|
/* サイズはJSで動的に設定 */
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
/*
|
|
193
|
+
/* ズーム/パン適用対象。
|
|
194
|
+
webpage は上端固定なので、縮小も上端を基準にする(center だと縮んだ分だけ
|
|
195
|
+
紙面が下へずれ、上端固定の意味が無くなる) */
|
|
191
196
|
#artboard-wrapper {
|
|
192
197
|
flex-shrink: 0;
|
|
193
|
-
transform-origin: center center;
|
|
198
|
+
transform-origin: ${editorMode === 'webpage' ? 'top center' : 'center center'};
|
|
194
199
|
/* transform は JS で設定 */
|
|
195
200
|
/* 読み込み直後は非表示。倍率適用前の等倍(巨大)な一瞬を見せない。
|
|
196
201
|
EditorCanvas がロード時に倍率を当ててから可視化する */
|
|
@@ -230,7 +235,7 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
|
|
|
230
235
|
}
|
|
231
236
|
</style>
|
|
232
237
|
</head>
|
|
233
|
-
<body tabindex="-1">
|
|
238
|
+
<body tabindex="-1" data-editor-mode="${editorMode}">
|
|
234
239
|
<div id="canvas-container">
|
|
235
240
|
<div id="canvas-scroll-area">
|
|
236
241
|
<div id="artboard-wrapper">
|
|
@@ -99,14 +99,46 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|
|
99
99
|
*
|
|
100
100
|
* [移植時の修正] 以前は copy / cut / paste / selectAll もここに入っていたため、
|
|
101
101
|
* テキスト編集中の Cmd+C が「要素のコピー」に化け、Cmd+A が兄弟要素の全選択になっていた。
|
|
102
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* [修正] Undo/Redo もここから外した。
|
|
104
|
+
* 編集中の Cmd+Z を横取りすると、ブラウザ既定の「文字入力の取り消し」が止まる一方で、
|
|
105
|
+
* エディタの履歴には打鍵ごとの段が無い(テキスト編集は確定時に1件積まれる)ため、
|
|
106
|
+
* **押しても何も起きない**状態だった(実測: 文字を打ってCmd+Z→入力は戻らない)。
|
|
107
|
+
* 選択したテキストを打ち間違えて上書きすると、確定するまで取り戻せなかった。
|
|
108
|
+
* 編集中の取り消しは文字単位=ブラウザ既定に任せ、
|
|
109
|
+
* 要素単位の取り消し(エディタの履歴)は編集を抜けてから効かせる。
|
|
110
|
+
* PowerPoint / Figma も編集中の Cmd+Z は文字単位で動く。
|
|
103
111
|
*/
|
|
104
|
-
const ALLOWED_IN_EDITABLE: ShortcutAction[] = ['
|
|
112
|
+
const ALLOWED_IN_EDITABLE: ShortcutAction[] = ['deselect'];
|
|
105
113
|
|
|
106
114
|
function isAllowedInEditableContext(action: ShortcutAction | undefined): boolean {
|
|
107
115
|
return action ? ALLOWED_IN_EDITABLE.includes(action) : false;
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
/**
|
|
119
|
+
* キャンバスにフォーカスがあるときだけ横取りするアクション。
|
|
120
|
+
*
|
|
121
|
+
* Tab はブラウザ既定の「次のUIへフォーカス移動」でもある。無条件に奪うと
|
|
122
|
+
* ヘッダーやパネルのボタンをキーボードだけで辿れなくなるため、
|
|
123
|
+
* 紙面を触っている間だけ「兄弟要素の巡回」に使う。
|
|
124
|
+
*/
|
|
125
|
+
const CANVAS_ONLY: ShortcutAction[] = ['selectNextSibling', 'selectPrevSibling'];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* このイベントがキャンバス(iframe)側の操作か。
|
|
129
|
+
* - iframe の document から来たイベント → キャンバス
|
|
130
|
+
* - 親側から来た場合は、body か iframe 自身にフォーカスがあるときだけキャンバス扱い
|
|
131
|
+
* (ボタンや入力にフォーカスがあるなら、それはUIの操作中)
|
|
132
|
+
*/
|
|
133
|
+
function isCanvasEvent(event: KeyboardEvent): boolean {
|
|
134
|
+
const doc = (event.target as HTMLElement | null)?.ownerDocument ?? null;
|
|
135
|
+
if (doc && typeof document !== 'undefined' && doc !== document) return true;
|
|
136
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
137
|
+
if (!active) return true;
|
|
138
|
+
const tag = active.tagName?.toUpperCase();
|
|
139
|
+
return tag === 'BODY' || tag === 'IFRAME';
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
/**
|
|
111
143
|
* エディタのキーボードショートカット・ディスパッチャ(唯一の経路)
|
|
112
144
|
*
|
|
@@ -153,6 +185,11 @@ export function useEditorShortcuts(
|
|
|
153
185
|
return;
|
|
154
186
|
}
|
|
155
187
|
|
|
188
|
+
// キャンバス限定のキー(Tab)は、UI側にフォーカスがあるときは既定動作に通す
|
|
189
|
+
if (CANVAS_ONLY.includes(action) && !isCanvasEvent(keyboardEvent)) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
156
193
|
const callback = callbacksRef.current[action];
|
|
157
194
|
if (!callback) return;
|
|
158
195
|
|