@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * フレックス内ドラッグ = 同一親の中での並べ替え。
2
+ * フロー内ドラッグ = 同一親の中での並べ替え。
3
3
  *
4
4
  * オートレイアウト(display:flex)の子を掴んだとき、従来は問答無用で絶対配置へ
5
5
  * 倒していたため、フレックスの設定が触った瞬間に無意味になっていた。
@@ -7,6 +7,14 @@
7
7
  * 以前あった「ゴースト並べ替え」は任意の親へ再挿入できたため、離した場所に
8
8
  * よっては要素が画面の外へ消えた。同一親に限ることでその事故を構造的に防ぐ。
9
9
  *
10
+ * 【slide と webpage で入口が違う】
11
+ * - slide : `shouldReorder` = 明示的にオートレイアウトONにした flex の器だけ。
12
+ * テンプレート由来の flex が山ほどあり、全部を器にすると既定の
13
+ * ドラッグ(絶対配置化して自由移動)が勝手に変わってしまうため。
14
+ * - webpage: `resolveWebpageDrag` = フロー内の要素は**常に**並べ替え。
15
+ * webpage は版面を流し込みのまま保つ約束なので、絶対配置へ倒すと
16
+ * ページ高さが潰れて紙面が飛ぶ(これが「動かすと版面が崩れる」の正体)。
17
+ *
10
18
  * 座標はクライアント座標(getBoundingClientRect)のまま扱う。ここで必要なのは
11
19
  * 「カーソルが兄弟の前半か後半か」だけで、アートボード座標へ割り戻す必要がない。
12
20
  */
@@ -22,22 +30,69 @@ const OVERLAY_SELECTOR =
22
30
  '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
23
31
  '.flex-drop-indicator,#gg-measure-layer,#gg-smart-guides,#' + INDICATOR_ID;
24
32
 
33
+ /**
34
+ * 並びの性格。挿入位置の決め方と、青い線の引き方がこれで変わる。
35
+ * - block : 縦一列の流し込み。前後は y の中点で決まる
36
+ * - flex : 主軸が1本(row/column)。前後は主軸の中点で決まる
37
+ * - grid2d: grid と flex-wrap。1本の軸では決まらないので、
38
+ * 「カーソルに一番近い兄弟」を選んでから、ずれの大きい軸で前後を決める
39
+ */
40
+ export type ReorderLayout = 'block' | 'flex' | 'grid2d';
41
+
42
+ /** 親が並べ替えの器になり得る display か。webpage の入口で使う */
43
+ function classifyDisplay(display: string): ReorderLayout | null {
44
+ if (display === 'flex' || display === 'inline-flex') return 'flex';
45
+ if (display === 'grid' || display === 'inline-grid') return 'grid2d';
46
+ if (display === 'block' || display === 'flow-root' || display === 'list-item') return 'block';
47
+ // inline / table 系 / contents などは並びの前後が素直に決まらないので触らない
48
+ return null;
49
+ }
50
+
51
+ /** ドラッグを並べ替えとして扱うための材料。これが作れたら並べ替えできる */
52
+ export interface ReorderPlan {
53
+ /** 動かす要素(DOM順の連続した塊)。単一選択なら1個 */
54
+ elements: HTMLElement[];
55
+ parent: HTMLElement;
56
+ layout: ReorderLayout;
57
+ }
58
+
59
+ /** webpage モードでドラッグをどう扱うかの判定結果 */
60
+ export type WebpageDragPlan =
61
+ /** 同一親の中で並べ替える */
62
+ | { kind: 'reorder'; plan: ReorderPlan }
63
+ /** 兄弟がいない(ひとりっ子)。動かす先がないので何もしない */
64
+ | { kind: 'no-siblings' }
65
+ /** 複数選択がバラバラ(親が違う/連続していない/フロー内と絶対配置の混在) */
66
+ | { kind: 'not-contiguous' }
67
+ /** 従来どおり座標で自由に動かす(すでに絶対配置の要素など) */
68
+ | { kind: 'free' };
69
+
25
70
  /** 並べ替え中の状態。ドラッグ開始時に1回だけ作る */
26
71
  export interface ReorderSession {
27
- element: HTMLElement;
72
+ /** 動かしている要素(DOM順の連続した塊) */
73
+ elements: HTMLElement[];
28
74
  parent: HTMLElement;
29
75
  /** 動かしている要素を除いた兄弟(DOM順) */
30
76
  siblings: HTMLElement[];
31
- /** 主軸が縦か */
77
+ layout: ReorderLayout;
78
+ /** 主軸が縦か(grid2d では使わない) */
32
79
  isColumn: boolean;
33
- /** 開始時の位置(取り消し・変化判定用) */
80
+ /** flex-direction が *-reverse か(画面上の並びとDOM順が逆になる) */
81
+ isReverse: boolean;
82
+ /** 開始時の挿入位置。ここと同じなら「順番は変わっていない」 */
34
83
  originalIndex: number;
35
84
  /** 直前に描いた挿入位置。同じなら描き直さない */
36
85
  lastIndex: number;
86
+ /** grid2d で直近に使った軸。青い線をこの軸で引く */
87
+ lastAxis: 'x' | 'y';
88
+ /** grid2d で線を引く相手(カーソルに一番近い兄弟) */
89
+ lastNeighbor: HTMLElement | null;
90
+ /** lastNeighbor の手前に入れるのか(false なら後ろ) */
91
+ lastBefore: boolean;
37
92
  }
38
93
 
39
94
  /** 並べ替えの相手になる子だけを取り出す */
40
- function realChildren(parent: HTMLElement): HTMLElement[] {
95
+ export function realChildren(parent: HTMLElement): HTMLElement[] {
41
96
  const out: HTMLElement[] = [];
42
97
  for (const node of Array.from(parent.children)) {
43
98
  // [注意] `node instanceof HTMLElement` は使えない。この関数は親ウィンドウで
@@ -51,8 +106,14 @@ function realChildren(parent: HTMLElement): HTMLElement[] {
51
106
  return out;
52
107
  }
53
108
 
109
+ /** フロー(static/relative)の中にいるか */
110
+ function isInFlow(element: HTMLElement, win: Window): boolean {
111
+ const position = win.getComputedStyle(element).position;
112
+ return position !== 'absolute' && position !== 'fixed';
113
+ }
114
+
54
115
  /**
55
- * この要素を「同一親の並べ替え」で動かすべきか判定する。
116
+ * この要素を「同一親の並べ替え」で動かすべきか判定する。【slide 専用・変更禁止】
56
117
  *
57
118
  * 【既定は現状維持】computed が flex というだけでは並べ替えにしない。
58
119
  * テンプレート由来の flex はスライド中に山ほどあり、それを全部器として扱うと
@@ -88,35 +149,132 @@ export function shouldReorder(
88
149
  return realChildren(parent).length >= 2;
89
150
  }
90
151
 
91
- /** 並べ替えの下ごしらえ(ドラッグが動き出した最初の1回だけ呼ぶ) */
92
- export function startReorder(element: HTMLElement, doc: Document): ReorderSession | null {
93
- const win = doc.defaultView;
152
+ /** slide の並べ替え(shouldReorder が true のとき)の材料を作る */
153
+ export function planSlideReorder(
154
+ element: HTMLElement,
155
+ doc: Document,
156
+ ): ReorderPlan | null {
94
157
  const parent = element.parentElement;
95
- if (!win || !parent) return null;
158
+ if (!parent) return null;
159
+ // shouldReorder が display:flex/inline-flex を保証しているので layout は 'flex' 固定。
160
+ // ここで flex-wrap を見て grid2d へ振ると slide の既存挙動が変わるので、あえて見ない
161
+ return { elements: [element], parent, layout: 'flex' };
162
+ }
163
+
164
+ /**
165
+ * webpage モードでこのドラッグをどう扱うか決める。
166
+ *
167
+ * webpage は「絶対配置へ倒さない」が設計原則(README)。倒すと版面が固定pxに固まり、
168
+ * さらにカラムが流れから抜けてページ高さが潰れ、紙面が中央寄せで飛ぶ。
169
+ * そこでフロー内の要素は必ず並べ替えに回し、倒すのは Cmd/Ctrl の逃げ道だけにする。
170
+ *
171
+ * 判定の順番:
172
+ * 1. すでに絶対配置(全部) → free。倒す必要がないので従来どおり座標移動
173
+ * 2. フロー内と絶対配置の混ざった複数選択 → not-contiguous(何もしない)。
174
+ * 片方を倒すと版面が崩れ、倒さないと群がバラけるため、どちらも選ばない
175
+ * 3. 親が並べ替えの器になり得ない display → free
176
+ * 4. 複数選択が別の親 / 連続していない → not-contiguous
177
+ * 5. 兄弟がいない → no-siblings
178
+ * 6. それ以外 → reorder
179
+ */
180
+ export function resolveWebpageDrag(
181
+ elements: HTMLElement[],
182
+ doc: Document,
183
+ ): WebpageDragPlan {
184
+ const win = doc.defaultView;
185
+ if (!win || elements.length === 0) return { kind: 'free' };
186
+
187
+ const inFlow = elements.filter((el) => isInFlow(el, win));
188
+ // 1. 全部フロー外 → 触っても版面は動かない。従来どおりの自由移動
189
+ if (inFlow.length === 0) return { kind: 'free' };
190
+ // 2. 混在 → 群としてまとまった扱いができない
191
+ if (inFlow.length !== elements.length) return { kind: 'not-contiguous' };
192
+
193
+ const parent = elements[0].parentElement;
194
+ if (!parent) return { kind: 'free' };
195
+ if (elements.some((el) => el.parentElement !== parent)) {
196
+ return { kind: 'not-contiguous' };
197
+ }
198
+
199
+ // 3. 器になり得る display か
200
+ const layout = classifyDisplay(win.getComputedStyle(parent).display);
201
+ if (!layout) return { kind: 'free' };
202
+
203
+ const children = realChildren(parent);
204
+ const indices = elements
205
+ .map((el) => children.indexOf(el))
206
+ .filter((i) => i >= 0)
207
+ .sort((a, b) => a - b);
208
+ if (indices.length !== elements.length) return { kind: 'free' };
209
+
210
+ // 4. 連続した塊か(飛び飛びの選択を1か所へ寄せると、選んでいない要素の順番まで変わる)
211
+ const contiguous = indices.every((v, i) => i === 0 || v === indices[i - 1] + 1);
212
+ if (!contiguous) return { kind: 'not-contiguous' };
213
+
214
+ // 5. 並べ替える相手がいない
215
+ if (children.length - elements.length < 1) return { kind: 'no-siblings' };
216
+
217
+ // DOM順に揃えてから渡す(選択の順番ではなく並びの順番で動かす)
218
+ const ordered = indices.map((i) => children[i]);
219
+ return { kind: 'reorder', plan: { elements: ordered, parent, layout } };
220
+ }
221
+
222
+ /**
223
+ * 並べ替えの下ごしらえ(ドラッグが動き出した最初の1回だけ呼ぶ)
224
+ *
225
+ * @param options.restoreFlow
226
+ * 子の絶対配置を解いてフローへ戻すか。
227
+ * slide の明示ONの器では true(ONにした後に別経路で絶対配置が焼き込まれた子を直す)。
228
+ * **webpage では必ず false**。webpage の普通の親には「Cmd+ドラッグで自由配置にした子」が
229
+ * 混ざり得て、隣の要素を普通にドラッグしただけでそれが勝手にフローへ戻ってしまうため。
230
+ */
231
+ export function startReorder(
232
+ plan: ReorderPlan,
233
+ doc: Document,
234
+ options: { restoreFlow: boolean },
235
+ ): ReorderSession | null {
236
+ const win = doc.defaultView;
237
+ const { parent, elements, layout } = plan;
238
+ if (!win || elements.length === 0) return null;
96
239
 
97
- // 子はONにした時点でフロー化済み。ただし後から追加された子や、
98
- // 別経路で絶対配置が焼き込まれた子が混じると並びが崩れるので保険で通す
99
- restoreChildrenToFlow(parent);
240
+ if (options.restoreFlow) {
241
+ // 子はONにした時点でフロー化済み。ただし後から追加された子や、
242
+ // 別経路で絶対配置が焼き込まれた子が混じると並びが崩れるので保険で通す
243
+ restoreChildrenToFlow(parent);
244
+ }
100
245
 
101
246
  const children = realChildren(parent);
102
- const originalIndex = children.indexOf(element);
103
- if (originalIndex < 0) return null;
247
+ const moving = new Set(elements);
248
+ const siblings = children.filter((c) => !moving.has(c));
249
+ const firstIndex = children.indexOf(elements[0]);
250
+ if (firstIndex < 0) return null;
251
+
252
+ // 連続した塊が children の firstIndex から始まるなら、
253
+ // 塊を抜いた siblings の中では firstIndex 番目の手前が「元の位置」になる
254
+ const originalIndex = firstIndex;
104
255
 
105
- const dir = win.getComputedStyle(parent).flexDirection || 'row';
256
+ const parentStyle = win.getComputedStyle(parent);
257
+ const dir = parentStyle.flexDirection || 'row';
106
258
 
107
259
  // 掴んでいる要素は「持ち上がっている」ことを見せる。
108
260
  // 既存の .dragging(opacity .7 + grabbing)をそのまま使う。インラインstyleで
109
261
  // 当てると保存HTMLに漏れうるが、このクラスは getCleanHtml が必ず剥がす
110
-
111
- element.classList.add('dragging');
262
+ for (const el of elements) el.classList.add('dragging');
112
263
 
113
264
  return {
114
- element,
265
+ elements,
115
266
  parent,
116
- siblings: children.filter((c) => c !== element),
117
- isColumn: dir.startsWith('column'),
267
+ siblings,
268
+ layout,
269
+ // block は縦一列。flex の computed は block でも 'row' を返すので、
270
+ // flexDirection をそのまま信じると横軸で判定してしまう
271
+ isColumn: layout === 'block' ? true : dir.startsWith('column'),
272
+ isReverse: layout === 'flex' && dir.endsWith('-reverse'),
118
273
  originalIndex,
119
274
  lastIndex: -1,
275
+ lastAxis: 'y',
276
+ lastNeighbor: null,
277
+ lastBefore: true,
120
278
  };
121
279
  }
122
280
 
@@ -124,8 +282,14 @@ export function startReorder(element: HTMLElement, doc: Document): ReorderSessio
124
282
  * カーソル位置から「兄弟の何番目の前に入るか」を決める
125
283
  *
126
284
  * 返すのは siblings(動かしている要素を除いた配列)に対する挿入位置。
127
- * 各兄弟の中点と比べるだけなので、要素が可変幅でも破綻しない。
128
- * flex-direction:*-reverse は画面上の並びとDOM順が逆なので、最後に反転する。
285
+ *
286
+ * block / flex は1本の軸しかないので、各兄弟の中点と比べるだけでよい。
287
+ * 要素が可変幅でも破綻しない。flex-direction:*-reverse は画面上の並びと
288
+ * DOM順が逆なので、最後に反転する。
289
+ *
290
+ * grid / flex-wrap は1本の軸では決まらない(右隣も下隣もいる)。
291
+ * カーソルに一番近い兄弟を選び、その中心からのずれを要素の幅・高さで正規化して、
292
+ * **ずれが支配的なほうの軸**で前後を決める。これ以上凝らない。
129
293
  */
130
294
  export function computeInsertIndex(
131
295
  session: ReorderSession,
@@ -133,9 +297,40 @@ export function computeInsertIndex(
133
297
  clientY: number,
134
298
  doc: Document,
135
299
  ): number {
136
- const { siblings, isColumn } = session;
137
- const dir = doc.defaultView?.getComputedStyle(session.parent).flexDirection || 'row';
138
- const isReverse = dir.endsWith('-reverse');
300
+ const { siblings, isColumn, isReverse, layout } = session;
301
+ if (siblings.length === 0) return 0;
302
+
303
+ if (layout === 'grid2d') {
304
+ let nearest = 0;
305
+ let best = Infinity;
306
+ let bestRect: DOMRect | null = null;
307
+ for (let i = 0; i < siblings.length; i++) {
308
+ const r = siblings[i].getBoundingClientRect();
309
+ const cx = r.left + r.width / 2;
310
+ const cy = r.top + r.height / 2;
311
+ const d = (clientX - cx) ** 2 + (clientY - cy) ** 2;
312
+ if (d < best) {
313
+ best = d;
314
+ nearest = i;
315
+ bestRect = r;
316
+ }
317
+ }
318
+ if (!bestRect) return siblings.length;
319
+
320
+ const cx = bestRect.left + bestRect.width / 2;
321
+ const cy = bestRect.top + bestRect.height / 2;
322
+ // 幅・高さで割ってから比べる。割らないと、横長のカードで
323
+ // 少し上下にずれただけで「縦の入れ替え」に化ける
324
+ const nx = bestRect.width > 0 ? (clientX - cx) / bestRect.width : 0;
325
+ const ny = bestRect.height > 0 ? (clientY - cy) / bestRect.height : 0;
326
+ const useY = Math.abs(ny) > Math.abs(nx);
327
+ const before = useY ? clientY < cy : clientX < cx;
328
+
329
+ session.lastAxis = useY ? 'y' : 'x';
330
+ session.lastNeighbor = siblings[nearest];
331
+ session.lastBefore = before;
332
+ return before ? nearest : nearest + 1;
333
+ }
139
334
 
140
335
  let index = siblings.length;
141
336
  for (let i = 0; i < siblings.length; i++) {
@@ -148,6 +343,8 @@ export function computeInsertIndex(
148
343
  break;
149
344
  }
150
345
  }
346
+ session.lastAxis = isColumn ? 'y' : 'x';
347
+ session.lastNeighbor = null;
151
348
  return index;
152
349
  }
153
350
 
@@ -158,6 +355,9 @@ export function computeInsertIndex(
158
355
  * position が無いと座標の基準にできない。親の style を書き換えると版面が動くので、
159
356
  * body直下に fixed で置く(画面座標そのまま)。ドラッグ中しか出ないので
160
357
  * スクロール追従は不要。
358
+ *
359
+ * grid2d だけは親いっぱいに引くと意味が伝わらない(どの行に入るか分からない)ので、
360
+ * 「入る先の隣人」の辺に、その隣人の幅・高さで引く。
161
361
  */
162
362
  export function drawInsertIndicator(
163
363
  session: ReorderSession,
@@ -166,10 +366,26 @@ export function drawInsertIndicator(
166
366
  ): void {
167
367
  clearInsertIndicator(doc);
168
368
 
169
- const { siblings, isColumn, parent } = session;
369
+ const { siblings, isColumn, isReverse, parent, layout } = session;
370
+ const base = 'position:fixed;z-index:10001;pointer-events:none;' +
371
+ `background:${BLUE};border-radius:1.5px;box-shadow:0 0 6px rgba(13,153,255,0.5);`;
372
+ const line = doc.createElement('div');
373
+ line.id = INDICATOR_ID;
374
+
375
+ if (layout === 'grid2d' && session.lastNeighbor) {
376
+ const r = session.lastNeighbor.getBoundingClientRect();
377
+ if (session.lastAxis === 'y') {
378
+ const y = session.lastBefore ? r.top : r.bottom;
379
+ line.style.cssText = base + `left:${r.left}px;width:${r.width}px;top:${y - 1.5}px;height:3px;`;
380
+ } else {
381
+ const x = session.lastBefore ? r.left : r.right;
382
+ line.style.cssText = base + `top:${r.top}px;height:${r.height}px;left:${x - 1.5}px;width:3px;`;
383
+ }
384
+ doc.body.appendChild(line);
385
+ return;
386
+ }
387
+
170
388
  const parentRect = parent.getBoundingClientRect();
171
- const dir = doc.defaultView?.getComputedStyle(parent).flexDirection || 'row';
172
- const isReverse = dir.endsWith('-reverse');
173
389
 
174
390
  // 挿入位置の座標。末尾なら最後の兄弟の後ろ、それ以外は index 番目の手前
175
391
  let pos: number;
@@ -183,10 +399,6 @@ export function drawInsertIndicator(
183
399
  pos = isColumn ? (isReverse ? r.bottom : r.top) : isReverse ? r.right : r.left;
184
400
  }
185
401
 
186
- const line = doc.createElement('div');
187
- line.id = INDICATOR_ID;
188
- const base = 'position:fixed;z-index:10001;pointer-events:none;' +
189
- `background:${BLUE};border-radius:1.5px;box-shadow:0 0 6px rgba(13,153,255,0.5);`;
190
402
  line.style.cssText = isColumn
191
403
  ? base + `left:${parentRect.left}px;width:${parentRect.width}px;top:${pos - 1.5}px;height:3px;`
192
404
  : base + `top:${parentRect.top}px;height:${parentRect.height}px;left:${pos - 1.5}px;width:3px;`;
@@ -200,32 +412,45 @@ export function clearInsertIndicator(doc: Document): void {
200
412
  }
201
413
 
202
414
  /**
203
- * 並べ替えを確定する。順番が変わったら true
415
+ * 塊を siblings の index 番目の手前へ入れる。順番が変わったら true
204
416
  *
205
417
  * insertBefore しか使わないので、要素は必ず同じ親の中に留まる。
206
418
  */
419
+ export function applyReorder(
420
+ parent: HTMLElement,
421
+ elements: HTMLElement[],
422
+ siblings: HTMLElement[],
423
+ originalIndex: number,
424
+ index: number,
425
+ ): boolean {
426
+ // すでにその位置にいるなら DOM を触らない(履歴を無駄に汚さない)
427
+ if (index === originalIndex) return false;
428
+
429
+ const target = index >= siblings.length ? null : siblings[index];
430
+ // 塊はDOM順のまま順に差し込む。同じ参照点の前に入れれば元の順番が保たれる
431
+ for (const el of elements) parent.insertBefore(el, target);
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * 並べ替えを確定する。順番が変わったら true
437
+ */
207
438
  export function commitReorder(
208
439
  session: ReorderSession,
209
440
  index: number,
210
441
  doc: Document,
211
442
  ): boolean {
212
- const { element, parent, siblings } = session;
443
+ const { elements, parent, siblings, originalIndex } = session;
213
444
 
214
445
  // 見た目の持ち上げを戻してから並べ替える(保存HTMLに opacity を残さない)
215
446
  endReorder(session, doc);
216
447
 
217
- const target = index >= siblings.length ? null : siblings[index];
218
- // すでにその位置にいるなら DOM を触らない(履歴を無駄に汚さない)
219
- if (target === element.nextElementSibling) return false;
220
- if (target === null && element === parent.lastElementChild) return false;
221
-
222
- parent.insertBefore(element, target);
223
- return true;
448
+ return applyReorder(parent, elements, siblings, originalIndex, index);
224
449
  }
225
450
 
226
451
  /** 見た目の持ち上げと線を片付ける(取り消し・中断からも呼ぶ) */
227
452
  export function endReorder(session: ReorderSession | null, doc: Document): void {
228
453
  clearInsertIndicator(doc);
229
454
  if (!session) return;
230
- session.element.classList.remove('dragging');
455
+ for (const el of session.elements) el.classList.remove('dragging');
231
456
  }
@@ -191,10 +191,11 @@ export function measureStyleSize(
191
191
  doc: Document
192
192
  ): { width: number; height: number } {
193
193
  const cs = doc.defaultView?.getComputedStyle(element);
194
- let width = element.offsetWidth;
195
- let height = element.offsetHeight;
194
+ // SVG は offsetWidth を持たない(undefined)。0 扱いにして下の矩形フォールバックへ流す
195
+ let width = typeof element.offsetWidth === 'number' ? element.offsetWidth : 0;
196
+ let height = typeof element.offsetHeight === 'number' ? element.offsetHeight : 0;
196
197
 
197
- // インライン要素などで offsetWidth が 0 のときは矩形から拾う(保険)
198
+ // インライン要素や SVG で offsetWidth が取れないときは矩形から拾う(保険)
198
199
  if (width === 0 && height === 0) {
199
200
  const rect = element.getBoundingClientRect();
200
201
  const scale = getArtboardRenderScale(doc);
@@ -179,18 +179,23 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
179
179
  background-color: ${CANVAS_BG_COLOR};
180
180
  }
181
181
 
182
- /* パディング用ラッパー(スクロール領域確保) */
182
+ /* パディング用ラッパー(スクロール領域確保)
183
+ webpage は上端固定にする。中央寄せだと、テキスト入力や並べ替えでページの
184
+ 高さが変わるたびに紙面が上下へ動き、見ていた場所が飛ぶ(実測 84〜729px)。
185
+ ブラウザのページと同じで、高さが変わっても上端は動かないのが正しい */
183
186
  #canvas-scroll-area {
184
187
  display: flex;
185
- align-items: center;
188
+ align-items: ${editorMode === 'webpage' ? 'flex-start' : 'center'};
186
189
  justify-content: center;
187
190
  /* サイズはJSで動的に設定 */
188
191
  }
189
192
 
190
- /* ズーム/パン適用対象 */
193
+ /* ズーム/パン適用対象。
194
+ webpage は上端固定なので、縮小も上端を基準にする(center だと縮んだ分だけ
195
+ 紙面が下へずれ、上端固定の意味が無くなる) */
191
196
  #artboard-wrapper {
192
197
  flex-shrink: 0;
193
- transform-origin: center center;
198
+ transform-origin: ${editorMode === 'webpage' ? 'top center' : 'center center'};
194
199
  /* transform は JS で設定 */
195
200
  /* 読み込み直後は非表示。倍率適用前の等倍(巨大)な一瞬を見せない。
196
201
  EditorCanvas がロード時に倍率を当ててから可視化する */
@@ -230,7 +235,7 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
230
235
  }
231
236
  </style>
232
237
  </head>
233
- <body tabindex="-1">
238
+ <body tabindex="-1" data-editor-mode="${editorMode}">
234
239
  <div id="canvas-container">
235
240
  <div id="canvas-scroll-area">
236
241
  <div id="artboard-wrapper">
@@ -99,14 +99,46 @@ function isEditableTarget(target: EventTarget | null): boolean {
99
99
  *
100
100
  * [移植時の修正] 以前は copy / cut / paste / selectAll もここに入っていたため、
101
101
  * テキスト編集中の Cmd+C が「要素のコピー」に化け、Cmd+A が兄弟要素の全選択になっていた。
102
- * 編集中は Undo/Redo と Escape(編集終了)以外はブラウザ既定に通す。
102
+ *
103
+ * [修正] Undo/Redo もここから外した。
104
+ * 編集中の Cmd+Z を横取りすると、ブラウザ既定の「文字入力の取り消し」が止まる一方で、
105
+ * エディタの履歴には打鍵ごとの段が無い(テキスト編集は確定時に1件積まれる)ため、
106
+ * **押しても何も起きない**状態だった(実測: 文字を打ってCmd+Z→入力は戻らない)。
107
+ * 選択したテキストを打ち間違えて上書きすると、確定するまで取り戻せなかった。
108
+ * 編集中の取り消しは文字単位=ブラウザ既定に任せ、
109
+ * 要素単位の取り消し(エディタの履歴)は編集を抜けてから効かせる。
110
+ * PowerPoint / Figma も編集中の Cmd+Z は文字単位で動く。
103
111
  */
104
- const ALLOWED_IN_EDITABLE: ShortcutAction[] = ['undo', 'redo', 'deselect'];
112
+ const ALLOWED_IN_EDITABLE: ShortcutAction[] = ['deselect'];
105
113
 
106
114
  function isAllowedInEditableContext(action: ShortcutAction | undefined): boolean {
107
115
  return action ? ALLOWED_IN_EDITABLE.includes(action) : false;
108
116
  }
109
117
 
118
+ /**
119
+ * キャンバスにフォーカスがあるときだけ横取りするアクション。
120
+ *
121
+ * Tab はブラウザ既定の「次のUIへフォーカス移動」でもある。無条件に奪うと
122
+ * ヘッダーやパネルのボタンをキーボードだけで辿れなくなるため、
123
+ * 紙面を触っている間だけ「兄弟要素の巡回」に使う。
124
+ */
125
+ const CANVAS_ONLY: ShortcutAction[] = ['selectNextSibling', 'selectPrevSibling'];
126
+
127
+ /**
128
+ * このイベントがキャンバス(iframe)側の操作か。
129
+ * - iframe の document から来たイベント → キャンバス
130
+ * - 親側から来た場合は、body か iframe 自身にフォーカスがあるときだけキャンバス扱い
131
+ * (ボタンや入力にフォーカスがあるなら、それはUIの操作中)
132
+ */
133
+ function isCanvasEvent(event: KeyboardEvent): boolean {
134
+ const doc = (event.target as HTMLElement | null)?.ownerDocument ?? null;
135
+ if (doc && typeof document !== 'undefined' && doc !== document) return true;
136
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
137
+ if (!active) return true;
138
+ const tag = active.tagName?.toUpperCase();
139
+ return tag === 'BODY' || tag === 'IFRAME';
140
+ }
141
+
110
142
  /**
111
143
  * エディタのキーボードショートカット・ディスパッチャ(唯一の経路)
112
144
  *
@@ -153,6 +185,11 @@ export function useEditorShortcuts(
153
185
  return;
154
186
  }
155
187
 
188
+ // キャンバス限定のキー(Tab)は、UI側にフォーカスがあるときは既定動作に通す
189
+ if (CANVAS_ONLY.includes(action) && !isCanvasEvent(keyboardEvent)) {
190
+ return;
191
+ }
192
+
156
193
  const callback = callbacksRef.current[action];
157
194
  if (!callback) return;
158
195
 
@@ -20,6 +20,14 @@
20
20
  /* エディタUIが参照するトークン(shadcn/ui 由来)。
21
21
  利用側の設定に依存しないよう、ここで閉じる */
22
22
  @theme {
23
+ /* Tailwind既定テーマの --font-sans / --font-serif を出力から外す。
24
+ dist/editor.css が :root へこれらを吐くと、利用側アプリが @theme で定義した
25
+ フォント(例: 游ゴシック)を読み込み順で上書きしてしまい、エディタを開いた
26
+ 画面だけスライドの書体が変わる(編集キャンバスのiframeはホストの :root から
27
+ --font-* を収集するため、汚染がそのまま持ち込まれる)。
28
+ --font-mono はエディタUIの kbd 表示で使うため残す。 */
29
+ --font-sans: initial;
30
+ --font-serif: initial;
23
31
  --color-background: #ffffff;
24
32
  --color-foreground: #0a0a0a;
25
33
  --color-card: #ffffff;