@growgroup/visual-editor 0.1.4 → 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.
@@ -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 = outOfFlow || layoutModeRef.current === 'absolute';
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
- reorderRef.current = startReorder(dragState.element, iframeDoc);
925
- reorderIndexRef.current = -1;
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
- textEl.focus();
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
- const hit = getEditableElement(e.target, iframeDoc);
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 rawTarget?.closest === "function"
1169
- ? rawTarget.closest('[data-editable="true"]')
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(rawTarget, iframeDoc);
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
- element.focus();
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: element.offsetWidth,
1791
- height: element.offsetHeight,
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
- return { left: element.offsetLeft, top: element.offsetTop };
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.offsetLeft;
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
- const left = element.offsetLeft;
1964
- const top = element.offsetTop;
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 = `${element.offsetWidth}px`;
1982
- element.style.height = `${element.offsetHeight}px`;
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に残り、