@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.
Files changed (45) hide show
  1. package/README.md +90 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +5 -1
  5. package/src/editor/FrontendVisualEditor.tsx +26 -12
  6. package/src/editor/components/EditorCanvas.tsx +11 -0
  7. package/src/editor/components/EditorContextMenu.tsx +52 -2
  8. package/src/editor/components/EditorFooter.tsx +29 -6
  9. package/src/editor/components/EditorHeader.tsx +15 -2
  10. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  11. package/src/editor/components/LayerTreeItem.tsx +107 -3
  12. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  13. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  14. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
  15. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  16. package/src/editor/components/ppt/PptComments.tsx +98 -1
  17. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  19. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  20. package/src/editor/constants.ts +25 -0
  21. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
  23. package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
  24. package/src/editor/hooks/useCanvasControls.ts +43 -9
  25. package/src/editor/hooks/useDragResize.ts +205 -17
  26. package/src/editor/hooks/useElementActions.ts +119 -34
  27. package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
  28. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  29. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  30. package/src/editor/utils/align-elements.ts +49 -0
  31. package/src/editor/utils/canvas-gestures.ts +210 -0
  32. package/src/editor/utils/canvas-guides.ts +46 -0
  33. package/src/editor/utils/dom-utils.ts +12 -2
  34. package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
  35. package/src/editor/utils/history-timing.ts +9 -0
  36. package/src/editor/utils/html-utils.ts +4 -0
  37. package/src/editor/utils/lock.ts +24 -0
  38. package/src/editor/utils/smart-duplicate.ts +51 -0
  39. package/src/editor/utils/smart-guides.ts +232 -2
  40. package/src/hooks/useEditorShortcuts.ts +46 -2
  41. package/src/index.ts +5 -1
  42. package/src/io.ts +54 -0
  43. package/src/styles/skin.css +5 -0
  44. package/src/types/editor.ts +120 -1
  45. 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 next = computeResizeGeometry(
802
- {
803
- left: snapshot.left,
804
- top: snapshot.top,
805
- width: snapshot.width,
806
- height: snapshot.height,
807
- },
808
- handle,
809
- deltaX,
810
- deltaY,
811
- { shiftKey: mods.shiftKey, altKey: mods.altKey, limits: snapshot.limits }
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
- v !== null
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
- const threshold = SNAP_THRESHOLD_SCREEN_PX / (scale || 1);
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, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
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, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
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: 10, y: 10 },
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: 10, y: 10 },
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[], offset?: { x: number; y: number }) => {
953
+ const pasteSerializedElements = useCallback((list: SerializedElement[]) => {
924
954
  const iframeDoc = getIframeDoc();
925
955
  if (!iframeDoc || list.length === 0) return;
926
- const off = offset ?? { x: 10, y: 10 };
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
- const left = parseInt(el.style.left || '0') || 0;
948
- const top = parseInt(el.style.top || '0') || 0;
949
- el.style.left = `${left + off.x}px`;
950
- el.style.top = `${top + off.y}px`;
951
-
952
- // 挿入先に応じて挿入
953
- if (insertAfterElement && insertAfterElement.parentNode) {
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
- pasteSerializedElements(clip.elements, clip.offset);
993
- clip.offset.x += 10;
994
- clip.offset.y += 10;
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 elements = ids
1475
+ const found = ids
1398
1476
  .map(id => getIframeElement(iframeDoc, id))
1399
1477
  .filter((el): el is HTMLElement => el !== null);
1400
- if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
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 elements = ids
1542
+ const found = ids
1462
1543
  .map(id => getIframeElement(iframeDoc, id))
1463
1544
  .filter((el): el is HTMLElement => el !== null);
1464
- if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
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,