@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,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クラスに変換する
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
import type { SerializedElement, BoundingBox, StyleClipboard, ClipboardData } from '../types';
|
|
35
35
|
import type { globalLastUsedStylesRef } from '../contexts/EditorRefsContext';
|
|
36
36
|
import { debugLog } from '../utils/debug';
|
|
37
|
+
import { readPosition, rememberDuplicate, repeatOffsetFor } from '../utils/smart-duplicate';
|
|
38
|
+
import { withoutLocked } from '../utils/lock';
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* 要素の種類を判定(shape / text / line)
|
|
@@ -111,8 +113,6 @@ function translateOutOfFlowElement(view: Window, el: HTMLElement, dx: number, dy
|
|
|
111
113
|
return true;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
/** 複製時にずらす量(px) */
|
|
115
|
-
const DUPLICATE_OFFSET = 20;
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* 複製した要素を「元要素の見た目の位置 + offset」に置く。
|
|
@@ -153,12 +153,32 @@ function offsetDuplicate(
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** 器として扱わない要素(文字・画像・図形など、子を持たせても意味をなさない) */
|
|
157
|
+
const NON_CONTAINER_TAGS = new Set([
|
|
158
|
+
'P', 'SPAN', 'A', 'BUTTON', 'IMG', 'SVG', 'VIDEO', 'CANVAS', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
|
|
159
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LABEL', 'STRONG', 'EM', 'B', 'I', 'U', 'BR', 'HR', 'TABLE', 'TR', 'TD', 'TH',
|
|
160
|
+
'UL', 'OL', 'LI',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 貼り付けの器になれるか(Figma のフレーム / グループにあたるか)。
|
|
165
|
+
* 位置指定のある箱で、文字を直に持たない要素。position:static の箱に絶対配置の子を入れると、
|
|
166
|
+
* 子の座標が別の祖先基準になって飛ぶので器にしない
|
|
167
|
+
*/
|
|
168
|
+
function isPasteContainer(el: HTMLElement): boolean {
|
|
169
|
+
if (NON_CONTAINER_TAGS.has(el.tagName.toUpperCase())) return false;
|
|
170
|
+
if (el.hasAttribute('data-component-instance')) return false;
|
|
171
|
+
const view = el.ownerDocument.defaultView;
|
|
172
|
+
if (!view || view.getComputedStyle(el).position === 'static') return false;
|
|
173
|
+
return !Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
/** 矢印キー移動の結果。呼び出し側が「なぜ動かないか」を提示できるようにする */
|
|
157
177
|
export interface MoveElementResult {
|
|
158
178
|
/** 実際に動かした要素数 */
|
|
159
179
|
moved: number;
|
|
160
180
|
/** 動かせなかった理由。null なら成功 */
|
|
161
|
-
blocked: null | 'no-selection' | 'flow';
|
|
181
|
+
blocked: null | 'no-selection' | 'flow' | 'locked';
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/**
|
|
@@ -754,17 +774,22 @@ export function useElementActions() {
|
|
|
754
774
|
if (selectedElementIds.length > 0) {
|
|
755
775
|
const newIds: string[] = [];
|
|
756
776
|
const newElements: HTMLElement[] = [];
|
|
777
|
+
// Figma と同じく真上に複製。直前の複製を動かしてから押したときは同じずれを繰り返す
|
|
778
|
+
const firstSource = getIframeElement(iframeDoc, selectedElementIds[0]);
|
|
779
|
+
const { dx, dy } = repeatOffsetFor(selectedElementIds, firstSource);
|
|
780
|
+
const firstFrom = firstSource ? readPosition(firstSource) : null;
|
|
757
781
|
selectedElementIds.forEach(id => {
|
|
758
782
|
const el = getIframeElement(iframeDoc, id);
|
|
759
783
|
if (el) {
|
|
760
784
|
const { clone, newId } = cloneElementWithInstance(el);
|
|
761
785
|
// 位置指定は「DOMに入れてから」元要素の実測位置を基準に行う(後述の理由)
|
|
762
786
|
el.parentNode?.insertBefore(clone, el.nextSibling);
|
|
763
|
-
offsetDuplicate(iframeDoc, el, clone,
|
|
787
|
+
offsetDuplicate(iframeDoc, el, clone, dx, dy);
|
|
764
788
|
newIds.push(newId);
|
|
765
789
|
newElements.push(clone);
|
|
766
790
|
}
|
|
767
791
|
});
|
|
792
|
+
rememberDuplicate(newIds, firstFrom);
|
|
768
793
|
|
|
769
794
|
// 新しい要素を選択
|
|
770
795
|
setSelectedElementIds(newIds);
|
|
@@ -785,9 +810,12 @@ export function useElementActions() {
|
|
|
785
810
|
if (!selectedElement) return;
|
|
786
811
|
const el = getIframeElement(iframeDoc, selectedElement.id);
|
|
787
812
|
if (el) {
|
|
813
|
+
const { dx, dy } = repeatOffsetFor([selectedElement.id], el);
|
|
814
|
+
const from = readPosition(el);
|
|
788
815
|
const { clone, newId } = cloneElementWithInstance(el);
|
|
789
816
|
el.parentNode?.insertBefore(clone, el.nextSibling);
|
|
790
|
-
offsetDuplicate(iframeDoc, el, clone,
|
|
817
|
+
offsetDuplicate(iframeDoc, el, clone, dx, dy);
|
|
818
|
+
rememberDuplicate([newId], from);
|
|
791
819
|
|
|
792
820
|
// 新しい要素を選択
|
|
793
821
|
clone.classList.add('selected');
|
|
@@ -808,6 +836,7 @@ export function useElementActions() {
|
|
|
808
836
|
attributes[attr.name] = attr.value;
|
|
809
837
|
}
|
|
810
838
|
}
|
|
839
|
+
const pos = readPosition(el);
|
|
811
840
|
return {
|
|
812
841
|
tagName: el.tagName.toLowerCase(),
|
|
813
842
|
id: el.getAttribute('data-element-id') || '',
|
|
@@ -815,6 +844,7 @@ export function useElementActions() {
|
|
|
815
844
|
style: el.getAttribute('style') || '',
|
|
816
845
|
innerHTML: el.innerHTML,
|
|
817
846
|
attributes,
|
|
847
|
+
...(pos ? { box: { ...pos, width: el.offsetWidth, height: el.offsetHeight } } : {}),
|
|
818
848
|
};
|
|
819
849
|
}, []);
|
|
820
850
|
|
|
@@ -890,7 +920,7 @@ export function useElementActions() {
|
|
|
890
920
|
if (serialized.length > 0) {
|
|
891
921
|
clipboardRef.current = {
|
|
892
922
|
elements: serialized,
|
|
893
|
-
offset: { x:
|
|
923
|
+
offset: { x: 0, y: 0 },
|
|
894
924
|
};
|
|
895
925
|
writeToOsClipboard(clipboardRef.current);
|
|
896
926
|
}
|
|
@@ -904,7 +934,7 @@ export function useElementActions() {
|
|
|
904
934
|
|
|
905
935
|
clipboardRef.current = {
|
|
906
936
|
elements: [serializeElement(el)],
|
|
907
|
-
offset: { x:
|
|
937
|
+
offset: { x: 0, y: 0 },
|
|
908
938
|
};
|
|
909
939
|
writeToOsClipboard(clipboardRef.current);
|
|
910
940
|
}, [selectedElement, selectedElementIds, getIframeDoc, serializeElement, clipboardRef, writeToOsClipboard]);
|
|
@@ -920,38 +950,58 @@ export function useElementActions() {
|
|
|
920
950
|
* 内部クリップボード(同一エディタ内)と、OSクリップボード経由
|
|
921
951
|
* (スライド跨ぎ。pasteイベントがペイロードを取り出して渡す)の両方から使う。
|
|
922
952
|
*/
|
|
923
|
-
const pasteSerializedElements = useCallback((list: SerializedElement[]
|
|
953
|
+
const pasteSerializedElements = useCallback((list: SerializedElement[]) => {
|
|
924
954
|
const iframeDoc = getIframeDoc();
|
|
925
955
|
if (!iframeDoc || list.length === 0) return;
|
|
926
|
-
|
|
956
|
+
|
|
957
|
+
// 貼り付け先(Figma「Copy and paste objects」の Paste placement と同じ考え方):
|
|
958
|
+
// - 器(子を持てる要素)を選んでいれば、その中へ。元の器の中と同じ x / y に置き、入りきらない軸は中央
|
|
959
|
+
// - 器でない要素を選んでいれば、その要素の親へ(その要素のすぐ手前=1つ前面)
|
|
960
|
+
// - 何も選んでいなければスライドのルートへ
|
|
961
|
+
// どの場合も「ずらさない」。同じスライドで元が見えていても、元の真上に重なる(Figma と同じ)
|
|
962
|
+
const selected = selectedElement ? getIframeElement(iframeDoc, selectedElement.id) : null;
|
|
963
|
+
const sourceIds = new Set(list.map((item) => item.id));
|
|
964
|
+
const isSelfPaste = !!selected && sourceIds.has(selected.getAttribute('data-element-id') || '');
|
|
965
|
+
// 選んだ器の中へ入れるのは、スライド(絶対配置)で、貼る要素がすべて位置(box)を持つときだけ。
|
|
966
|
+
// webpage は流し込みを保つ約束なので従来どおり選択の直後に入れる。box の無いフローの要素も同じ
|
|
967
|
+
const canNest = editorMode !== 'webpage' && list.every((item) => !!item.box);
|
|
968
|
+
const container = canNest && selected && !isSelfPaste && isPasteContainer(selected) ? selected : null;
|
|
969
|
+
let insertAfterElement: HTMLElement | null = container ? null : selected;
|
|
927
970
|
|
|
928
971
|
// 既存の選択ボックスをクリア
|
|
929
972
|
iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
|
|
930
973
|
iframeDoc.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
|
|
931
974
|
|
|
932
|
-
// 挿入先を決定: 選択要素がある場合はその直後、なければbodyの末尾
|
|
933
|
-
let insertAfterElement: HTMLElement | null = null;
|
|
934
|
-
if (selectedElement) {
|
|
935
|
-
insertAfterElement = getIframeElement(iframeDoc, selectedElement.id);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
975
|
const pastedIds: string[] = [];
|
|
939
976
|
const pastedElements: HTMLElement[] = [];
|
|
940
977
|
|
|
941
978
|
list.forEach(serialized => {
|
|
942
979
|
const el = deserializeElement(serialized);
|
|
943
980
|
if (!el) return;
|
|
944
|
-
|
|
981
|
+
|
|
982
|
+
// ルートだけでなく中の要素の ID も作り直す(元と同じ ID が2つできると選択・履歴が混線する)
|
|
945
983
|
const newId = generateElementId('paste');
|
|
946
984
|
el.setAttribute('data-element-id', newId);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
el.
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
985
|
+
el.querySelectorAll('[data-element-id]').forEach((child) => {
|
|
986
|
+
child.setAttribute('data-element-id', generateElementId('paste'));
|
|
987
|
+
});
|
|
988
|
+
el.classList.remove('selected', 'hover-preview', 'text-editable-hover', 'marquee-hover', 'dragging');
|
|
989
|
+
el.removeAttribute('data-layer-hover');
|
|
990
|
+
|
|
991
|
+
if (container) {
|
|
992
|
+
container.appendChild(el);
|
|
993
|
+
// 元の器の中と同じ x / y。入りきらない軸は器の中央(Figma と同じ)
|
|
994
|
+
const box = serialized.box;
|
|
995
|
+
if (box) {
|
|
996
|
+
const cw = container.clientWidth;
|
|
997
|
+
const ch = container.clientHeight;
|
|
998
|
+
const fitsX = box.left >= 0 && box.left + box.width <= cw;
|
|
999
|
+
const fitsY = box.top >= 0 && box.top + box.height <= ch;
|
|
1000
|
+
el.style.left = `${Math.round(fitsX ? box.left : (cw - box.width) / 2)}px`;
|
|
1001
|
+
el.style.top = `${Math.round(fitsY ? box.top : (ch - box.height) / 2)}px`;
|
|
1002
|
+
}
|
|
1003
|
+
} else if (insertAfterElement && insertAfterElement.parentNode) {
|
|
1004
|
+
// 選択要素の直後(1つ前面)に。位置はコピーしたときのまま
|
|
955
1005
|
insertAfterElement.parentNode.insertBefore(el, insertAfterElement.nextSibling);
|
|
956
1006
|
// 次のペースト要素のために更新
|
|
957
1007
|
insertAfterElement = el;
|
|
@@ -963,13 +1013,13 @@ export function useElementActions() {
|
|
|
963
1013
|
iframeDoc.body;
|
|
964
1014
|
root.appendChild(el);
|
|
965
1015
|
}
|
|
966
|
-
|
|
1016
|
+
|
|
967
1017
|
pastedIds.push(newId);
|
|
968
1018
|
pastedElements.push(el);
|
|
969
1019
|
});
|
|
970
1020
|
|
|
971
1021
|
notifyIframeChange();
|
|
972
|
-
|
|
1022
|
+
|
|
973
1023
|
// ペーストした要素を選択し、選択ボックスを更新
|
|
974
1024
|
if (pastedIds.length > 0) {
|
|
975
1025
|
setSelectedElementIds(pastedIds);
|
|
@@ -983,15 +1033,43 @@ export function useElementActions() {
|
|
|
983
1033
|
if (info) setSelectedElement(info);
|
|
984
1034
|
}
|
|
985
1035
|
}
|
|
986
|
-
}, [getIframeDoc, deserializeElement, notifyIframeChange, selectedElement, setSelectedElementIds, setSelectedElement]);
|
|
1036
|
+
}, [getIframeDoc, deserializeElement, notifyIframeChange, selectedElement, setSelectedElementIds, setSelectedElement, editorMode]);
|
|
987
1037
|
|
|
988
1038
|
// 内部クリップボードからのペースト(pasteイベント側のフォールバック用)
|
|
989
1039
|
const pasteElements = useCallback(() => {
|
|
990
1040
|
const clip = clipboardRef.current;
|
|
991
1041
|
if (!clip) return;
|
|
992
|
-
|
|
993
|
-
clip.
|
|
994
|
-
|
|
1042
|
+
// Figma と同じく何度貼ってもずらさない
|
|
1043
|
+
pasteSerializedElements(clip.elements);
|
|
1044
|
+
}, [clipboardRef, pasteSerializedElements]);
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* ⌘⇧V(Figma の Paste over selection / その場に貼る)。
|
|
1048
|
+
* ⌘V も同じ位置に貼るので動きは同じ。⌘⇧V はブラウザが paste イベントを出さないことがあるため、
|
|
1049
|
+
* OS のクリップボードを直接読み、読めなければ(権限が無い等)このエディタの内部クリップボードを使う
|
|
1050
|
+
*/
|
|
1051
|
+
const pasteInPlace = useCallback(async () => {
|
|
1052
|
+
try {
|
|
1053
|
+
const items = await navigator.clipboard.read();
|
|
1054
|
+
for (const item of items) {
|
|
1055
|
+
if (!item.types.includes('text/html')) continue;
|
|
1056
|
+
const html = await (await item.getType('text/html')).text();
|
|
1057
|
+
const payload = html.match(/data-gg-payload="([^"]+)"/);
|
|
1058
|
+
if (payload) {
|
|
1059
|
+
pasteSerializedElements(JSON.parse(decodeURIComponent(escape(atob(payload[1])))));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// 読めたのに要素の印が無い = 後から別のもの(文字・画像など)をコピーした。
|
|
1064
|
+
// 内部に残っている古い要素は貼らない(何もしない)
|
|
1065
|
+
return;
|
|
1066
|
+
} catch {
|
|
1067
|
+
// 権限が無い・未対応のブラウザ。下で内部クリップボードを見る
|
|
1068
|
+
}
|
|
1069
|
+
// 読めなかったときだけ内部クリップボードへ。それも通常の ⌘V(pasteFromInternalIfFresh)と同じく、
|
|
1070
|
+
// OS への書き込みが成功していた(= OS 側に新しいものがあるかもしれない)なら貼らない
|
|
1071
|
+
const clip = clipboardRef.current;
|
|
1072
|
+
if (clip && !clip.osWritten) pasteSerializedElements(clip.elements);
|
|
995
1073
|
}, [clipboardRef, pasteSerializedElements]);
|
|
996
1074
|
|
|
997
1075
|
/**
|
|
@@ -1394,10 +1472,13 @@ export function useElementActions() {
|
|
|
1394
1472
|
: [];
|
|
1395
1473
|
if (ids.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1396
1474
|
|
|
1397
|
-
const
|
|
1475
|
+
const found = ids
|
|
1398
1476
|
.map(id => getIframeElement(iframeDoc, id))
|
|
1399
1477
|
.filter((el): el is HTMLElement => el !== null);
|
|
1400
|
-
if (
|
|
1478
|
+
if (found.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1479
|
+
// ロック中(本人か祖先)は矢印キーでも動かさない・大きさも変えない(Figma と同じ。記憶)
|
|
1480
|
+
const elements = withoutLocked(found);
|
|
1481
|
+
if (elements.length === 0) return { moved: 0, blocked: 'locked' };
|
|
1401
1482
|
|
|
1402
1483
|
// webpage はフロー内の要素を倒さない。矢印キーは並べ替えを1段動かす
|
|
1403
1484
|
if (editorMode === 'webpage') {
|
|
@@ -1458,10 +1539,13 @@ export function useElementActions() {
|
|
|
1458
1539
|
: selectedElement
|
|
1459
1540
|
? [selectedElement.id]
|
|
1460
1541
|
: [];
|
|
1461
|
-
const
|
|
1542
|
+
const found = ids
|
|
1462
1543
|
.map(id => getIframeElement(iframeDoc, id))
|
|
1463
1544
|
.filter((el): el is HTMLElement => el !== null);
|
|
1464
|
-
if (
|
|
1545
|
+
if (found.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1546
|
+
// ロック中(本人か祖先)は矢印キーでも動かさない・大きさも変えない(Figma と同じ。記憶)
|
|
1547
|
+
const elements = withoutLocked(found);
|
|
1548
|
+
if (elements.length === 0) return { moved: 0, blocked: 'locked' };
|
|
1465
1549
|
|
|
1466
1550
|
// フロー内の要素は移動と同じ遅延変換で絶対配置へ倒してから触る
|
|
1467
1551
|
elements.forEach(el => {
|
|
@@ -1824,6 +1908,7 @@ export function useElementActions() {
|
|
|
1824
1908
|
pasteElements,
|
|
1825
1909
|
pasteSerializedElements,
|
|
1826
1910
|
pasteFromInternalIfFresh,
|
|
1911
|
+
pasteInPlace,
|
|
1827
1912
|
copyStyle,
|
|
1828
1913
|
pasteStyle,
|
|
1829
1914
|
hasStyleInClipboard,
|