@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
|
@@ -45,6 +45,8 @@ import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '..
|
|
|
45
45
|
|
|
46
46
|
// ズーム設定
|
|
47
47
|
export const MIN_ZOOM = 10; // 10%
|
|
48
|
+
/** この大きさ(px)に満たない容器はまだ組み上がっていないとみなし、フィットの計算に使わない */
|
|
49
|
+
const FIT_MIN_CONTAINER_PX = 120;
|
|
48
50
|
export const MAX_ZOOM = 400; // 400%
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -169,6 +171,13 @@ export function useCanvasControls() {
|
|
|
169
171
|
// 初期ズーム設定済みフラグ
|
|
170
172
|
const initialZoomSetRef = useRef(false);
|
|
171
173
|
|
|
174
|
+
// 人が倍率を動かしたか。true の間は、容器の大きさが変わってもフィットに合わせ直さない(下の updateFitZoomValue)。
|
|
175
|
+
// ホイール・ピンチ・100%・選択範囲へのズーム・倍率の直接入力で true、全体表示(⌘0 など zoomToFit)で false。
|
|
176
|
+
// 「いまの倍率がフィット値に近いか」だけで決めると、手でズームしてたまたまフィット値の近くで止めたとき、次のリサイズで追従に戻ってしまう
|
|
177
|
+
const userZoomedRef = useRef(false);
|
|
178
|
+
// このフックが自分で入れた倍率(フィット・全体表示)。これと違う倍率が入ってきたら、人(ヘッダーの倍率など外の setZoom)が動かした
|
|
179
|
+
const selfAppliedZoomRef = useRef<number | null>(null);
|
|
180
|
+
|
|
172
181
|
// パンモードが有効かどうか(スペースキーまたはハンドツール)
|
|
173
182
|
const isPanModeActive = isSpacePressed || activeTool === 'move';
|
|
174
183
|
|
|
@@ -307,6 +316,7 @@ export function useCanvasControls() {
|
|
|
307
316
|
const zoomRef = useRef(zoom);
|
|
308
317
|
useEffect(() => {
|
|
309
318
|
zoomRef.current = zoom;
|
|
319
|
+
if (selfAppliedZoomRef.current !== null && Math.abs(zoom - selfAppliedZoomRef.current) >= 0.5) userZoomedRef.current = true;
|
|
310
320
|
}, [zoom]);
|
|
311
321
|
|
|
312
322
|
/**
|
|
@@ -359,6 +369,7 @@ export function useCanvasControls() {
|
|
|
359
369
|
heldAnchorRef.current = { anchor, time: now };
|
|
360
370
|
}
|
|
361
371
|
|
|
372
|
+
userZoomedRef.current = true;
|
|
362
373
|
zoomRef.current = clamped;
|
|
363
374
|
|
|
364
375
|
if (options?.continuous) {
|
|
@@ -422,6 +433,7 @@ export function useCanvasControls() {
|
|
|
422
433
|
ly: rect.y + rect.h / 2,
|
|
423
434
|
};
|
|
424
435
|
heldAnchorRef.current = null;
|
|
436
|
+
userZoomedRef.current = true;
|
|
425
437
|
|
|
426
438
|
// すでに同じ倍率なら setZoom しても再レンダーが起きない=補正の useEffect も走らない。
|
|
427
439
|
// 「押しても何も起きない」を作らないため、その場合はここで直接寄せる。
|
|
@@ -444,6 +456,8 @@ export function useCanvasControls() {
|
|
|
444
456
|
const zoomToFit = useCallback(() => {
|
|
445
457
|
heldAnchorRef.current = null;
|
|
446
458
|
zoomAnchorRef.current = null;
|
|
459
|
+
userZoomedRef.current = false;
|
|
460
|
+
selfAppliedZoomRef.current = fitZoom;
|
|
447
461
|
zoomRef.current = fitZoom;
|
|
448
462
|
setZoom(fitZoom);
|
|
449
463
|
}, [fitZoom, setZoom]);
|
|
@@ -452,6 +466,7 @@ export function useCanvasControls() {
|
|
|
452
466
|
const zoomToActual = useCallback(() => {
|
|
453
467
|
heldAnchorRef.current = null;
|
|
454
468
|
zoomAnchorRef.current = null;
|
|
469
|
+
userZoomedRef.current = true;
|
|
455
470
|
zoomRef.current = 100;
|
|
456
471
|
setZoom(100);
|
|
457
472
|
}, [setZoom]);
|
|
@@ -545,26 +560,48 @@ export function useCanvasControls() {
|
|
|
545
560
|
return Math.floor(Math.min(scaleX, scaleY) * 100);
|
|
546
561
|
}, [containerRef, canvasWidth, canvasHeight]);
|
|
547
562
|
|
|
548
|
-
//
|
|
563
|
+
// 初期化時と容器の大きさが変わったときにフィットズームを計算する。
|
|
564
|
+
// 「全体表示のまま」(人がまだ倍率を動かしていない = userZoomedRef が false)なら
|
|
565
|
+
// 倍率もフィットに合わせ直す(PowerPoint の「スライドを画面に合わせる」と同じ)。
|
|
566
|
+
// 合わせ直すのはスライド(高さが決まっている)だけ。webpage は本文の高さ(canvasHeight)が変わるたびに
|
|
567
|
+
// フィット値が変わるので、追従させると編集中に倍率が勝手に動く。webpage は最初の 1 回だけ合わせる(以前と同じ)。
|
|
568
|
+
// 以前は 100ms 後に 1 回だけ合わせていたので、その時点で容器が組み上がっていない
|
|
569
|
+
// (キャンバス →「1 枚」の切替直後・パネルが後から開く)と 3〜19% の豆粒のまま残った。
|
|
570
|
+
// 容器の大きさの変化は window の resize では拾えない(パネルの開閉)ので ResizeObserver で見る
|
|
571
|
+
const fitZoomRef = useRef(fitZoom);
|
|
572
|
+
fitZoomRef.current = fitZoom;
|
|
549
573
|
useEffect(() => {
|
|
550
574
|
if (isInMultiPageMode) return;
|
|
551
575
|
|
|
552
576
|
const updateFitZoomValue = () => {
|
|
577
|
+
const container = containerRef.current;
|
|
578
|
+
// 組み上がる前(幅・高さがほぼ 0)の値で倍率を決めない
|
|
579
|
+
if (!container || container.clientWidth < FIT_MIN_CONTAINER_PX || container.clientHeight < FIT_MIN_CONTAINER_PX) return;
|
|
553
580
|
const newFitZoom = calculateFitZoom();
|
|
581
|
+
const followsFit = !initialZoomSetRef.current || (editorMode !== 'webpage' && !userZoomedRef.current);
|
|
582
|
+
fitZoomRef.current = newFitZoom;
|
|
554
583
|
setFitZoom(newFitZoom);
|
|
555
|
-
if (
|
|
584
|
+
if (followsFit && Math.abs(zoomRef.current - newFitZoom) >= 0.5) {
|
|
585
|
+
selfAppliedZoomRef.current = newFitZoom;
|
|
586
|
+
zoomRef.current = newFitZoom;
|
|
556
587
|
setZoom(newFitZoom);
|
|
557
|
-
|
|
588
|
+
} else if (followsFit) {
|
|
589
|
+
selfAppliedZoomRef.current = zoomRef.current;
|
|
558
590
|
}
|
|
591
|
+
initialZoomSetRef.current = true;
|
|
559
592
|
};
|
|
560
593
|
|
|
561
594
|
const timer = setTimeout(updateFitZoomValue, 100);
|
|
562
595
|
window.addEventListener('resize', updateFitZoomValue);
|
|
596
|
+
const container = containerRef.current;
|
|
597
|
+
const observer = container && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateFitZoomValue()) : null;
|
|
598
|
+
if (container) observer?.observe(container);
|
|
563
599
|
return () => {
|
|
564
600
|
clearTimeout(timer);
|
|
565
601
|
window.removeEventListener('resize', updateFitZoomValue);
|
|
602
|
+
observer?.disconnect();
|
|
566
603
|
};
|
|
567
|
-
}, [calculateFitZoom, setFitZoom, setZoom, isInMultiPageMode]);
|
|
604
|
+
}, [calculateFitZoom, setFitZoom, setZoom, isInMultiPageMode, containerRef, editorMode]);
|
|
568
605
|
|
|
569
606
|
// 初回マウント時に中央配置
|
|
570
607
|
useEffect(() => {
|
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
syncSelectionOverlayRects,
|
|
19
19
|
getOverlayRect,
|
|
20
20
|
setOverlayLabel,
|
|
21
|
+
generateElementId,
|
|
21
22
|
} from '../utils/dom-utils';
|
|
23
|
+
import { rememberDuplicate, forgetDuplicate } from '../utils/smart-duplicate';
|
|
22
24
|
import {
|
|
23
25
|
DRAG_START_THRESHOLD,
|
|
24
26
|
MIN_ELEMENT_SIZE,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
import {
|
|
46
48
|
collectGuideCandidates,
|
|
47
49
|
snapToGuides,
|
|
50
|
+
snapResizeEdges,
|
|
48
51
|
drawSmartGuides,
|
|
49
52
|
clearSmartGuides,
|
|
50
53
|
SNAP_THRESHOLD_SCREEN_PX,
|
|
@@ -55,6 +58,7 @@ import { computeFreeBounds, keepsInlineGeometry } from '../utils/free-layout';
|
|
|
55
58
|
import { extractElementInfo } from '../utils/style-utils';
|
|
56
59
|
import { convertInlineStylesToTailwind } from '../utils/tailwind-utils';
|
|
57
60
|
import { isAspectRatioLocked } from '../utils/aspect-lock';
|
|
61
|
+
import { isLockedEl } from '../utils/lock';
|
|
58
62
|
import {
|
|
59
63
|
getFlexContainerInfo,
|
|
60
64
|
calculateDropIndex,
|
|
@@ -224,6 +228,13 @@ interface ResizeSnapshot {
|
|
|
224
228
|
wroteHeight: boolean;
|
|
225
229
|
/** 要素の min / max-width・height。ハンドルでもここで止める(開始時に読む) */
|
|
226
230
|
limits: SizeLimits;
|
|
231
|
+
/**
|
|
232
|
+
* リサイズの吸着に使う。style の left/top/width/height(=snapshot の値)と
|
|
233
|
+
* アートボード座標の枠との差。回転・拡大縮小された要素は枠と style が対応しないので null(吸着しない)
|
|
234
|
+
*/
|
|
235
|
+
snapFrame: { dx: number; dy: number; dw: number; dh: number } | null;
|
|
236
|
+
/** 吸着の相手(兄弟の端・中央、紙面の端・中央、定規のガイド)。リサイズ開始時に 1 回だけ集める */
|
|
237
|
+
snapCandidates: GuideCandidates | null;
|
|
227
238
|
}
|
|
228
239
|
|
|
229
240
|
/**
|
|
@@ -237,6 +248,17 @@ interface ResizeSnapshot {
|
|
|
237
248
|
const RESIZE_INERT_CLASS = 'resize-inert';
|
|
238
249
|
const RESIZE_INERT_ATTR = 'data-resize-inert';
|
|
239
250
|
|
|
251
|
+
/**
|
|
252
|
+
* iframe が外側(キャンバス表示の拡縮)で何倍に表示されているか。1 枚表示では 1。
|
|
253
|
+
* 中のアートボードの倍率(getDragScale)とは別で、画面の px に直すには両方を掛ける
|
|
254
|
+
*/
|
|
255
|
+
function iframeOuterScale(iframeDoc: Document): number {
|
|
256
|
+
const frame = iframeDoc.defaultView?.frameElement as HTMLElement | null | undefined;
|
|
257
|
+
if (!frame || !frame.offsetWidth) return 1;
|
|
258
|
+
const shown = frame.getBoundingClientRect().width / frame.offsetWidth;
|
|
259
|
+
return shown > 0 && Number.isFinite(shown) ? shown : 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
/**
|
|
241
263
|
* transform の中の指定した関数だけを差し替える(他の関数は順序を保って残す)
|
|
242
264
|
*
|
|
@@ -291,6 +313,39 @@ function normalizeAngle(deg: number): number {
|
|
|
291
313
|
/** 回転のスナップ角(Shift 押下中) */
|
|
292
314
|
const ROTATION_SNAP_DEG = 15;
|
|
293
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Alt(⌥)ドラッグの複製(Figma と同じ)。
|
|
318
|
+
*
|
|
319
|
+
* 掴んでいる要素はそのまま動かし続け、**元の位置に複製を置いていく**。
|
|
320
|
+
* Figma は「複製が動いて元が残る」が、見た目と結果(元の位置と新しい位置に1つずつ)は同じで、
|
|
321
|
+
* こちらのほうが選択・ドラッグの状態(掴んでいる要素・選択枠・スナップの基準)を一切
|
|
322
|
+
* 差し替えずに済む。複製は掴んでいる要素の直前(1つ背面)に入れるので、動いている側が手前に見える。
|
|
323
|
+
*
|
|
324
|
+
* 部品のインスタンス(data-component-instance)を含む要素は複製しない。
|
|
325
|
+
* インスタンスの登録を伴う複製は ⌘D(duplicateElement)の経路にしか無く、
|
|
326
|
+
* DOM だけ写すと同じインスタンスIDを持つ要素が2つできてしまうため。
|
|
327
|
+
*/
|
|
328
|
+
function createAltDuplicates(
|
|
329
|
+
targets: { element: HTMLElement; origin: { left: number; top: number } }[],
|
|
330
|
+
): HTMLElement[] {
|
|
331
|
+
const clones: HTMLElement[] = [];
|
|
332
|
+
for (const { element, origin } of targets) {
|
|
333
|
+
if (!element.parentElement) continue;
|
|
334
|
+
if (element.matches('[data-component-instance]') || element.querySelector('[data-component-instance]')) continue;
|
|
335
|
+
const clone = element.cloneNode(true) as HTMLElement;
|
|
336
|
+
clone.querySelectorAll('.selection-box').forEach((box) => box.remove());
|
|
337
|
+
[clone, ...Array.from(clone.querySelectorAll<HTMLElement>('[data-element-id]'))].forEach((node) => {
|
|
338
|
+
if (node.hasAttribute('data-element-id')) node.setAttribute('data-element-id', generateElementId('dup'));
|
|
339
|
+
});
|
|
340
|
+
clone.classList.remove('selected', 'dragging', 'hover-preview', 'text-editable-hover', 'marquee-hover');
|
|
341
|
+
clone.style.left = `${roundPx(origin.left)}px`;
|
|
342
|
+
clone.style.top = `${roundPx(origin.top)}px`;
|
|
343
|
+
element.parentElement.insertBefore(clone, element);
|
|
344
|
+
clones.push(clone);
|
|
345
|
+
}
|
|
346
|
+
return clones;
|
|
347
|
+
}
|
|
348
|
+
|
|
294
349
|
/**
|
|
295
350
|
* ドラッグ・リサイズ処理フック
|
|
296
351
|
*/
|
|
@@ -337,6 +392,8 @@ export function useDragResize(
|
|
|
337
392
|
// ここを立てずに素通りさせると convertDragTargetsToAbsolute が走って版面が崩れ、
|
|
338
393
|
// さらに mouseup が「動いた」と見なして履歴を1段積んでしまう
|
|
339
394
|
const dragBlockedRef = useRef(false);
|
|
395
|
+
// Alt(⌥)ドラッグで元の位置に置いた複製。Alt を離すと消し、Esc の取り消しでも消す
|
|
396
|
+
const altClonesRef = useRef<HTMLElement[] | null>(null);
|
|
340
397
|
// 案内はセッション中1回だけ。ドラッグのたびに帯が出ると操作の邪魔になる
|
|
341
398
|
const reorderHintShownRef = useRef(false);
|
|
342
399
|
|
|
@@ -391,6 +448,8 @@ export function useDragResize(
|
|
|
391
448
|
reorderIndexRef.current = -1;
|
|
392
449
|
dragBlockedRef.current = false;
|
|
393
450
|
freeBoundsRef.current = null;
|
|
451
|
+
// 複製は確定済み(DOM に残す)。参照だけ手放す
|
|
452
|
+
altClonesRef.current = null;
|
|
394
453
|
}, [dragStateRef]);
|
|
395
454
|
|
|
396
455
|
/**
|
|
@@ -575,6 +634,8 @@ export function useDragResize(
|
|
|
575
634
|
resizeState.elements.forEach((el, index) => {
|
|
576
635
|
const orig = resizeState.origElementStates[index];
|
|
577
636
|
if (!orig) return;
|
|
637
|
+
// ロック中(本人か祖先)は一緒に選ばれていても大きさを変えない
|
|
638
|
+
if (isLockedEl(el)) return;
|
|
578
639
|
|
|
579
640
|
// 相対位置を維持して座標変換
|
|
580
641
|
const relativeLeft = orig.left - bounds.left;
|
|
@@ -639,8 +700,31 @@ export function useDragResize(
|
|
|
639
700
|
|
|
640
701
|
const size = measureStyleSize(element, iframeDoc);
|
|
641
702
|
|
|
703
|
+
// 吸着用: style の値とアートボード上の枠の差(padding・border の分と、親の位置)。
|
|
704
|
+
// 反転(scaleX(-1))は枠が変わらないので許し、回転・拡大縮小は対応が崩れるので吸着しない
|
|
705
|
+
let snapFrame: ResizeSnapshot['snapFrame'] = null;
|
|
706
|
+
const transform = win?.getComputedStyle(element).transform || 'none';
|
|
707
|
+
const m = transform.match(/^matrix\(([^)]+)\)$/);
|
|
708
|
+
const parts = m ? m[1].split(',').map(Number) : null;
|
|
709
|
+
const plainTransform =
|
|
710
|
+
transform === 'none' ||
|
|
711
|
+
(!!parts &&
|
|
712
|
+
Math.abs(Math.abs(parts[0]) - 1) < 1e-3 && Math.abs(parts[1]) < 1e-3 &&
|
|
713
|
+
Math.abs(parts[2]) < 1e-3 && Math.abs(Math.abs(parts[3]) - 1) < 1e-3);
|
|
714
|
+
if (plainTransform) {
|
|
715
|
+
const frame = getOverlayRect(iframeDoc, element);
|
|
716
|
+
snapFrame = {
|
|
717
|
+
dx: frame.left - origin.left,
|
|
718
|
+
dy: frame.top - origin.top,
|
|
719
|
+
dw: frame.width - size.width,
|
|
720
|
+
dh: frame.height - size.height,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
642
724
|
return {
|
|
643
725
|
element,
|
|
726
|
+
snapFrame,
|
|
727
|
+
snapCandidates: collectGuideCandidates(iframeDoc, [element]),
|
|
644
728
|
left: origin.left,
|
|
645
729
|
top: origin.top,
|
|
646
730
|
width: size.width,
|
|
@@ -756,6 +840,64 @@ export function useDragResize(
|
|
|
756
840
|
[markResizeInert]
|
|
757
841
|
);
|
|
758
842
|
|
|
843
|
+
/**
|
|
844
|
+
* リサイズの吸着。補正した移動量と、揃った線を返す(吸着しないときは null)
|
|
845
|
+
*
|
|
846
|
+
* Shift(縦横比固定)では主に動いている軸だけを吸着させ、もう片方は比から出す。
|
|
847
|
+
* 角のハンドルでは、従う軸の移動量を 0 にして computeResizeGeometry に渡し直すことで、
|
|
848
|
+
* 補正のあとも同じ軸が主になるようにする(補正で主の軸が入れ替わると線から外れるため)。
|
|
849
|
+
*/
|
|
850
|
+
const snapResize = useCallback(
|
|
851
|
+
(
|
|
852
|
+
iframeDoc: Document,
|
|
853
|
+
snapshot: ResizeSnapshot,
|
|
854
|
+
handle: string,
|
|
855
|
+
deltaX: number,
|
|
856
|
+
deltaY: number,
|
|
857
|
+
next: { left: number; top: number; width: number; height: number },
|
|
858
|
+
mods: { shiftKey: boolean; altKey: boolean; ctrlKey?: boolean; metaKey?: boolean }
|
|
859
|
+
): { deltaX: number; deltaY: number; guideX: number | null; guideY: number | null } | null => {
|
|
860
|
+
if (mods.ctrlKey || mods.metaKey) return null;
|
|
861
|
+
const frame = snapshot.snapFrame;
|
|
862
|
+
const candidates = snapshot.snapCandidates;
|
|
863
|
+
if (!frame || !candidates) return null;
|
|
864
|
+
const dir = handleDirection(handle);
|
|
865
|
+
if (dir.x === 0 && dir.y === 0) return null;
|
|
866
|
+
// 流れの中にいる要素は left/top を書けない。左・上の辺と中心からの拡大は動かないので吸着しない
|
|
867
|
+
const canMove = snapshot.canWritePosition;
|
|
868
|
+
let axes = {
|
|
869
|
+
x: dir.x !== 0 && (canMove || (dir.x > 0 && !mods.altKey)),
|
|
870
|
+
y: dir.y !== 0 && (canMove || (dir.y > 0 && !mods.altKey)),
|
|
871
|
+
};
|
|
872
|
+
let dX = deltaX;
|
|
873
|
+
let dY = deltaY;
|
|
874
|
+
if (mods.shiftKey && snapshot.width > 0 && snapshot.height > 0) {
|
|
875
|
+
if (dir.x !== 0 && dir.y !== 0) {
|
|
876
|
+
// computeResizeGeometry と同じ規則で主の軸を決める
|
|
877
|
+
const xMain = Math.abs(dir.x * deltaX) * snapshot.height >= Math.abs(dir.y * deltaY) * snapshot.width;
|
|
878
|
+
axes = { x: xMain && axes.x, y: !xMain && axes.y };
|
|
879
|
+
if (xMain) dY = 0;
|
|
880
|
+
else dX = 0;
|
|
881
|
+
} else {
|
|
882
|
+
axes = { x: dir.x !== 0 && axes.x, y: dir.y !== 0 && axes.y };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (!axes.x && !axes.y) return null;
|
|
886
|
+
const scale = getDragScale(iframeDoc);
|
|
887
|
+
const threshold = SNAP_THRESHOLD_SCREEN_PX / ((scale || 1) * iframeOuterScale(iframeDoc));
|
|
888
|
+
const rect = {
|
|
889
|
+
left: next.left + frame.dx,
|
|
890
|
+
top: next.top + frame.dy,
|
|
891
|
+
width: next.width + frame.dw,
|
|
892
|
+
height: next.height + frame.dh,
|
|
893
|
+
};
|
|
894
|
+
const s = snapResizeEdges(candidates, rect, dir, { alt: mods.altKey, axes }, threshold);
|
|
895
|
+
if (s.guideX === null && s.guideY === null) return null;
|
|
896
|
+
return { deltaX: dX + s.dx, deltaY: dY + s.dy, guideX: s.guideX, guideY: s.guideY };
|
|
897
|
+
},
|
|
898
|
+
[getDragScale]
|
|
899
|
+
);
|
|
900
|
+
|
|
759
901
|
/**
|
|
760
902
|
* 単一要素のリサイズ処理
|
|
761
903
|
*
|
|
@@ -770,7 +912,7 @@ export function useDragResize(
|
|
|
770
912
|
iframeDoc: Document,
|
|
771
913
|
deltaX: number,
|
|
772
914
|
deltaY: number,
|
|
773
|
-
mods: { shiftKey: boolean; altKey: boolean }
|
|
915
|
+
mods: { shiftKey: boolean; altKey: boolean; ctrlKey?: boolean; metaKey?: boolean }
|
|
774
916
|
): boolean => {
|
|
775
917
|
const resizeState = resizeStateRef.current;
|
|
776
918
|
if (!resizeState.element) return false;
|
|
@@ -798,18 +940,32 @@ export function useDragResize(
|
|
|
798
940
|
resizeSnapshotRef.current = snapshot;
|
|
799
941
|
}
|
|
800
942
|
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
)
|
|
943
|
+
const origRect = {
|
|
944
|
+
left: snapshot.left,
|
|
945
|
+
top: snapshot.top,
|
|
946
|
+
width: snapshot.width,
|
|
947
|
+
height: snapshot.height,
|
|
948
|
+
};
|
|
949
|
+
const geometryMods = { shiftKey: mods.shiftKey, altKey: mods.altKey, limits: snapshot.limits };
|
|
950
|
+
let next = computeResizeGeometry(origRect, handle, deltaX, deltaY, geometryMods);
|
|
951
|
+
|
|
952
|
+
// ── 吸着(移動と同じく画面上 4px。⌘/Ctrl を押している間は吸着しない) ──
|
|
953
|
+
const snapped = snapResize(iframeDoc, snapshot, handle, deltaX, deltaY, next, mods);
|
|
954
|
+
if (snapped) {
|
|
955
|
+
next = computeResizeGeometry(origRect, handle, snapped.deltaX, snapped.deltaY, geometryMods);
|
|
956
|
+
// 最小・最大サイズで止まったなどで辺が線に届かなかったら、線は出さない
|
|
957
|
+
const f = snapshot.snapFrame!;
|
|
958
|
+
const xs = [next.left + f.dx, next.left + f.dx + next.width + f.dw];
|
|
959
|
+
const ys = [next.top + f.dy, next.top + f.dy + next.height + f.dh];
|
|
960
|
+
const on = (list: number[], g: number | null) =>
|
|
961
|
+
g !== null && list.some((v) => Math.abs(v - g) <= 0.5) ? g : null;
|
|
962
|
+
const gx = on(xs, snapped.guideX);
|
|
963
|
+
const gy = on(ys, snapped.guideY);
|
|
964
|
+
if (gx !== null || gy !== null) drawSmartGuides(iframeDoc, gx, gy);
|
|
965
|
+
else clearSmartGuides(iframeDoc);
|
|
966
|
+
} else {
|
|
967
|
+
clearSmartGuides(iframeDoc);
|
|
968
|
+
}
|
|
813
969
|
|
|
814
970
|
// 掴んだハンドルが実際に動かす軸だけを書く。
|
|
815
971
|
// (例: e ハンドルで height まで書くと、触っていない高さが inline → Tailwind クラスへ
|
|
@@ -847,7 +1003,7 @@ export function useDragResize(
|
|
|
847
1003
|
|
|
848
1004
|
return true;
|
|
849
1005
|
},
|
|
850
|
-
[resizeStateRef, createResizeSnapshot, verifyResizeEffect]
|
|
1006
|
+
[resizeStateRef, createResizeSnapshot, verifyResizeEffect, snapResize]
|
|
851
1007
|
);
|
|
852
1008
|
|
|
853
1009
|
/**
|
|
@@ -876,7 +1032,8 @@ export function useDragResize(
|
|
|
876
1032
|
})
|
|
877
1033
|
.filter(
|
|
878
1034
|
(v): v is { element: HTMLElement; origin: { left: number; top: number } } =>
|
|
879
|
-
|
|
1035
|
+
// ロック中(本人か祖先)は一緒に選ばれていても動かさない
|
|
1036
|
+
v !== null && !isLockedEl(v.element)
|
|
880
1037
|
);
|
|
881
1038
|
},
|
|
882
1039
|
[]
|
|
@@ -1032,6 +1189,23 @@ export function useDragResize(
|
|
|
1032
1189
|
return true;
|
|
1033
1190
|
}
|
|
1034
1191
|
|
|
1192
|
+
// Alt(⌥)を押している間だけ、元の位置に複製を置く(Figma と同じく途中で押しても離してもよい)。
|
|
1193
|
+
// コンポーネント編集中は部品の構造を変えないので複製しない
|
|
1194
|
+
const wantsCopy = e.altKey && !iframeDoc.body.classList.contains('component-edit-mode');
|
|
1195
|
+
if (wantsCopy && !altClonesRef.current) {
|
|
1196
|
+
const targets = getDragTargets(dragState);
|
|
1197
|
+
altClonesRef.current = createAltDuplicates(targets);
|
|
1198
|
+
// このあとの ⌘D は「元の位置 → 離した位置」のずれで複製を続ける(Figma と同じ)
|
|
1199
|
+
rememberDuplicate(
|
|
1200
|
+
targets.map((t) => t.element.getAttribute('data-element-id') ?? '').filter(Boolean),
|
|
1201
|
+
targets[0]?.origin ?? null,
|
|
1202
|
+
);
|
|
1203
|
+
} else if (!wantsCopy && altClonesRef.current) {
|
|
1204
|
+
altClonesRef.current.forEach((clone) => clone.remove());
|
|
1205
|
+
altClonesRef.current = null;
|
|
1206
|
+
forgetDuplicate();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1035
1209
|
// スナップ補正。Cmd/Ctrl を押している間は吸着しない(Figmaと同じ逃げ道)
|
|
1036
1210
|
let snappedDx = dx;
|
|
1037
1211
|
let snappedDy = dy;
|
|
@@ -1039,7 +1213,8 @@ export function useDragResize(
|
|
|
1039
1213
|
const baseRect = dragBaseRectRef.current;
|
|
1040
1214
|
if (!e.metaKey && !e.ctrlKey && candidates && baseRect) {
|
|
1041
1215
|
// 吸着距離は「画面上で4px」。ズームで見た目の効きが変わらないよう割り戻す
|
|
1042
|
-
|
|
1216
|
+
// キャンバス表示では iframe ごと外から拡縮されるので、その倍率も掛けて「画面の 4px」にする
|
|
1217
|
+
const threshold = SNAP_THRESHOLD_SCREEN_PX / ((scale || 1) * iframeOuterScale(iframeDoc));
|
|
1043
1218
|
const snap = snapToGuides(
|
|
1044
1219
|
candidates,
|
|
1045
1220
|
{
|
|
@@ -1060,17 +1235,20 @@ export function useDragResize(
|
|
|
1060
1235
|
// 斜めへずれる(拘束が破れる)
|
|
1061
1236
|
let guideX = snap.guideX;
|
|
1062
1237
|
let guideY = snap.guideY;
|
|
1238
|
+
let spacing = snap.spacing ?? [];
|
|
1063
1239
|
if (e.shiftKey) {
|
|
1064
1240
|
if (dx === 0) {
|
|
1065
1241
|
snappedDx = 0;
|
|
1066
1242
|
guideX = null;
|
|
1243
|
+
spacing = spacing.filter((m) => m.axis !== 'x');
|
|
1067
1244
|
}
|
|
1068
1245
|
if (dy === 0) {
|
|
1069
1246
|
snappedDy = 0;
|
|
1070
1247
|
guideY = null;
|
|
1248
|
+
spacing = spacing.filter((m) => m.axis !== 'y');
|
|
1071
1249
|
}
|
|
1072
1250
|
}
|
|
1073
|
-
drawSmartGuides(iframeDoc, guideX, guideY);
|
|
1251
|
+
drawSmartGuides(iframeDoc, guideX, guideY, spacing);
|
|
1074
1252
|
} else {
|
|
1075
1253
|
clearSmartGuides(iframeDoc);
|
|
1076
1254
|
}
|
|
@@ -1124,6 +1302,11 @@ export function useDragResize(
|
|
|
1124
1302
|
const doc = iframeDoc ?? dragState.element.ownerDocument;
|
|
1125
1303
|
if (!doc) return false;
|
|
1126
1304
|
|
|
1305
|
+
// Alt ドラッグの複製は取り消しでは残さない
|
|
1306
|
+
if (altClonesRef.current) forgetDuplicate();
|
|
1307
|
+
altClonesRef.current?.forEach((clone) => clone.remove());
|
|
1308
|
+
altClonesRef.current = null;
|
|
1309
|
+
|
|
1127
1310
|
// 並べ替え中の取り消し: DOMの順番はまだ変えていないので、
|
|
1128
1311
|
// 持ち上げの見た目と線を戻すだけで元の状態に戻る
|
|
1129
1312
|
if (reorderRef.current) {
|
|
@@ -1194,6 +1377,9 @@ export function useDragResize(
|
|
|
1194
1377
|
const mods = {
|
|
1195
1378
|
shiftKey: e.shiftKey || isAspectRatioLocked(),
|
|
1196
1379
|
altKey: e.altKey,
|
|
1380
|
+
// 押している間は吸着しない(移動と同じ)
|
|
1381
|
+
ctrlKey: e.ctrlKey,
|
|
1382
|
+
metaKey: e.metaKey,
|
|
1197
1383
|
};
|
|
1198
1384
|
|
|
1199
1385
|
// Scale Toolによるリサイズ
|
|
@@ -1260,6 +1446,8 @@ export function useDragResize(
|
|
|
1260
1446
|
|
|
1261
1447
|
// リサイズ終了
|
|
1262
1448
|
if (resizeState.isResizing && resizeState.element) {
|
|
1449
|
+
// 吸着の赤い線は #artboard の中にある。保存する HTML に入れないよう先に消す
|
|
1450
|
+
clearSmartGuides(iframeDoc);
|
|
1263
1451
|
// インラインスタイルをTailwindクラスに変換
|
|
1264
1452
|
// リサイズ中はパフォーマンスのためインラインスタイルを使用し、
|
|
1265
1453
|
// 終了時にTailwindクラスに変換する
|