@growgroup/visual-editor 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +24 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +18 -12
  5. package/src/editor/components/EditorContextMenu.tsx +52 -2
  6. package/src/editor/components/EditorFooter.tsx +29 -6
  7. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  8. package/src/editor/components/LayerTreeItem.tsx +107 -3
  9. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  11. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  12. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  13. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  14. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  15. package/src/editor/constants.ts +16 -0
  16. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  17. package/src/editor/contexts/MultiPageCanvasContext.tsx +49 -11
  18. package/src/editor/hooks/useCanvasControls.ts +41 -4
  19. package/src/editor/hooks/useDragResize.ts +205 -17
  20. package/src/editor/hooks/useElementActions.ts +119 -34
  21. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  22. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  23. package/src/editor/utils/align-elements.ts +49 -0
  24. package/src/editor/utils/canvas-guides.ts +46 -0
  25. package/src/editor/utils/dom-utils.ts +12 -2
  26. package/src/editor/utils/history-timing.ts +9 -0
  27. package/src/editor/utils/html-utils.ts +4 -0
  28. package/src/editor/utils/lock.ts +24 -0
  29. package/src/editor/utils/smart-duplicate.ts +51 -0
  30. package/src/editor/utils/smart-guides.ts +232 -2
  31. package/src/hooks/useEditorShortcuts.ts +46 -2
  32. package/src/styles/skin.css +5 -0
  33. package/src/types/editor.ts +120 -1
  34. package/src/version.ts +1 -1
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 履歴が「同じ要素への続けての変更」を1つにまとめる間隔(ms)。
3
+ * EditorHistoryContext と、数字キーの不透明度(2桁入力)の両方が使う。
4
+ *
5
+ * EditorHistoryContext.tsx から直接 export しないのは、コンポーネント以外の値を export すると
6
+ * React Fast Refresh がそのファイルを差し替えられず、編集のたびにページ全体が読み込み直される
7
+ * (開いている編集の ⌘Z 履歴が消える)ため。
8
+ */
9
+ export const COALESCE_WINDOW_MS = 400;
@@ -371,6 +371,10 @@ export function getCleanHtml(doc: Document): string {
371
371
  // ドラッグ中の整列ガイド(表示専用)も保存しない
372
372
  tempContainer.querySelectorAll('#gg-smart-guides').forEach(el => el.remove());
373
373
 
374
+ // レイヤーパネルの行に乗せたときの紙面側の強調(表示専用)。
375
+ // 行の目・鍵のボタンを押すと、マウスが行に乗ったまま保存が走るので、ここで外さないと HTML に残る(実測)
376
+ tempContainer.querySelectorAll('[data-layer-hover]').forEach(el => el.removeAttribute('data-layer-hover'));
377
+
374
378
  // トリミングモードのUI(枠・ハンドル)。確定/中断で消えるが、保険で除去
375
379
  tempContainer.querySelectorAll('.gg-crop-ui').forEach(el => el.remove());
376
380
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ユーザーのロック(⌘⇧L・レイヤーパネルの鍵)。
3
+ *
4
+ * 属性名は `data-gg-locked`。部品定義の `element.locked` が以前から `data-locked="true"` を
5
+ * 付けている(component-renderer.ts)ので、同じ名前にするとロックの CSS(pointer-events:none)や
6
+ * 鍵の印が部品にまで効いてしまう。`data-gg-*` は部品の定義を作るとき(parts.ts の stripEditorAttrs)に
7
+ * 落ちるので、ロックが部品の定義に持ち込まれることもない。
8
+ *
9
+ * ロック中(本人か祖先がロック)の要素は、選ぶことはできるが(レイヤーパネル・Figma と同じ)、
10
+ * 位置・大きさ・見た目を変える操作の対象からは一律に外す(isLockedEl / withoutLocked)。
11
+ */
12
+
13
+ export const LOCK_ATTR = 'data-gg-locked';
14
+ export const LOCK_SELECTOR = `[${LOCK_ATTR}="true"]`;
15
+
16
+ /** 本人か祖先がロックされているか */
17
+ export function isLockedEl(el: Element): boolean {
18
+ return typeof el.closest === 'function' && !!el.closest(LOCK_SELECTOR);
19
+ }
20
+
21
+ /** 編集の対象からロック中の要素を外す */
22
+ export function withoutLocked<T extends Element>(els: T[]): T[] {
23
+ return els.filter((el) => !isLockedEl(el));
24
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ⌘D の「直前のずれを繰り返す」複製(Figma の Duplicate / PowerPoint の Ctrl+D と同じ作法)。
3
+ *
4
+ * Figma(help.figma.com「Copy and paste objects」): ⌘D は元の真上に複製する(最上位フレームを除く)。
5
+ * ⌥ドラッグで複製したあとに ⌘D を続けると、同じ距離で複製を続ける。
6
+ * ⌘D で複製 → 動かす → ⌘D でも、同じずれを繰り返す(PowerPoint の Ctrl+D と同じ)。
7
+ *
8
+ * 「どの要素を、どこから複製したか」を1組だけ覚え、次の ⌘D で選択がその要素のままなら
9
+ * 「今の位置 − 複製元の位置」をずれとして使う。選択が変わっていれば真上(ずれ 0)。
10
+ */
11
+
12
+ interface Point {
13
+ left: number;
14
+ top: number;
15
+ }
16
+
17
+ let memory: { key: string; from: Point } | null = null;
18
+
19
+ const keyOf = (ids: string[]) => [...ids].sort().join(',');
20
+
21
+ /** 位置指定の要素の left/top(使用値)。フロー要素など読めないときは null */
22
+ export function readPosition(el: HTMLElement): Point | null {
23
+ const view = el.ownerDocument.defaultView;
24
+ if (!view) return null;
25
+ const cs = view.getComputedStyle(el);
26
+ if (cs.position !== 'absolute' && cs.position !== 'fixed') return null;
27
+ const left = parseFloat(cs.left);
28
+ const top = parseFloat(cs.top);
29
+ return Number.isFinite(left) && Number.isFinite(top) ? { left, top } : null;
30
+ }
31
+
32
+ /** 複製した(または ⌥ドラッグで複製を置いた)要素と、その複製元の位置を覚える */
33
+ export function rememberDuplicate(ids: string[], from: Point | null): void {
34
+ memory = ids.length && from ? { key: keyOf(ids), from } : null;
35
+ }
36
+
37
+ export function forgetDuplicate(): void {
38
+ memory = null;
39
+ }
40
+
41
+ /**
42
+ * 次の ⌘D のずれ。覚えた要素が今も選ばれていて、動いていればそのずれ。それ以外は 0
43
+ * @param ids 今の選択
44
+ * @param first 今の選択の先頭の要素(位置を読む)
45
+ */
46
+ export function repeatOffsetFor(ids: string[], first: HTMLElement | null): { dx: number; dy: number } {
47
+ if (!memory || !first || memory.key !== keyOf(ids)) return { dx: 0, dy: 0 };
48
+ const now = readPosition(first);
49
+ if (!now) return { dx: 0, dy: 0 };
50
+ return { dx: now.left - memory.from.left, dy: now.top - memory.from.top };
51
+ }
@@ -13,10 +13,13 @@
13
13
  * ソート済み配列への二分探索3本(x)と3本(y)だけで、要素数が数百でも一定時間で終わる。
14
14
  */
15
15
 
16
+ import { getActiveCanvasGuides } from './canvas-guides';
16
17
  import { getOverlayRect } from './dom-utils';
17
18
 
18
19
  const LAYER_ID = 'gg-smart-guides';
19
20
  const RED = '#f24822'; // Figmaの計測色(measure-distance.ts と揃える)
21
+ /** 等間隔の表示色(Figma の等間隔ガイドのピンク。記憶にもとづく近似色) */
22
+ const PINK = '#ff3ebf';
20
23
 
21
24
  /** 吸着する距離。画面上のpxで定義し、使う側でズーム倍率を割り戻す */
22
25
  export const SNAP_THRESHOLD_SCREEN_PX = 4;
@@ -31,6 +34,8 @@ const OVERLAY_SELECTOR =
31
34
  export interface GuideCandidates {
32
35
  xs: number[];
33
36
  ys: number[];
37
+ /** 兄弟の矩形(等間隔の判定に使う)。アートボード座標 */
38
+ rects?: MovingRect[];
34
39
  }
35
40
 
36
41
  /** アートボード座標の矩形 */
@@ -49,6 +54,18 @@ export interface SnapResult {
49
54
  guideX: number | null;
50
55
  /** 揃った横線のy座標 */
51
56
  guideY: number | null;
57
+ /** 等間隔に吸着したときの間隔の表示(軸ごと)。吸着していない軸は入らない */
58
+ spacing?: SpacingMark[];
59
+ }
60
+
61
+ /**
62
+ * 等間隔の表示 1 本。axis='x' なら from〜to は x 座標で、at(y)の高さに横向きの線を引く
63
+ */
64
+ export interface SpacingMark {
65
+ axis: 'x' | 'y';
66
+ from: number;
67
+ to: number;
68
+ at: number;
52
69
  }
53
70
 
54
71
  /** 昇順ソート + 近い値の重複除去(0.5px以内は同じ線とみなす) */
@@ -96,9 +113,14 @@ export function collectGuideCandidates(
96
113
  // アートボードの端と中央(スライドの版面に対する整列)
97
114
  const xs: number[] = [0, width / 2, width];
98
115
  const ys: number[] = [0, height / 2, height];
116
+ // 定規から引いたガイド(キャンバス表示・編集中のページのもの)も吸着の相手
117
+ const ruled = getActiveCanvasGuides();
118
+ xs.push(...ruled.xs);
119
+ ys.push(...ruled.ys);
99
120
 
100
121
  const movingSet = new Set<HTMLElement>(moving);
101
122
  const seen = new Set<HTMLElement>();
123
+ const rects: MovingRect[] = [];
102
124
 
103
125
  for (const el of moving) {
104
126
  const parent = el.parentElement;
@@ -120,10 +142,105 @@ export function collectGuideCandidates(
120
142
 
121
143
  xs.push(r.left, r.left + r.width / 2, r.left + r.width);
122
144
  ys.push(r.top, r.top + r.height / 2, r.top + r.height);
145
+ rects.push({ left: r.left, top: r.top, width: r.width, height: r.height });
123
146
  }
124
147
  }
125
148
 
126
- return { xs: normalize(xs), ys: normalize(ys) };
149
+ return { xs: normalize(xs), ys: normalize(ys), rects };
150
+ }
151
+
152
+ /** 軸に沿って見た矩形。s〜e が軸方向、cs〜ce が直交方向 */
153
+ interface AxisSpan {
154
+ s: number;
155
+ e: number;
156
+ cs: number;
157
+ ce: number;
158
+ }
159
+
160
+ function toAxis(r: MovingRect, axis: 'x' | 'y'): AxisSpan {
161
+ return axis === 'x'
162
+ ? { s: r.left, e: r.left + r.width, cs: r.top, ce: r.top + r.height }
163
+ : { s: r.top, e: r.top + r.height, cs: r.left, ce: r.left + r.width };
164
+ }
165
+
166
+ /** 2つの矩形の直交方向の重なりの中央(間隔の線を引く高さ) */
167
+ function crossMid(a: AxisSpan, b: AxisSpan): number {
168
+ const lo = Math.max(a.cs, b.cs);
169
+ const hi = Math.min(a.ce, b.ce);
170
+ return lo <= hi ? (lo + hi) / 2 : (a.cs + a.ce) / 2;
171
+ }
172
+
173
+ /**
174
+ * 等間隔への吸着(Figma の等間隔ガイド)。
175
+ *
176
+ * 動かしている矩形と直交方向に重なる兄弟だけを「同じ列」とみなし、次の 3 通りを試す。
177
+ * 1. 前と後ろの兄弟のちょうど中間(両側の隙間が同じ)
178
+ * 2. 前の 2 つと同じ間隔で後ろに続ける(3 枚目のカードを並べるとき)
179
+ * 3. 後ろの 2 つと同じ間隔で前に置く
180
+ * いちばん近いものが threshold 以内なら、その補正量と表示する線を返す。
181
+ */
182
+ function snapToSpacing(
183
+ rects: MovingRect[],
184
+ rect: MovingRect,
185
+ axis: 'x' | 'y',
186
+ threshold: number,
187
+ ): { diff: number; marks: SpacingMark[] } | null {
188
+ const m = toAxis(rect, axis);
189
+ const size = m.e - m.s;
190
+ const mid = (m.s + m.e) / 2;
191
+ const row = rects.map((r) => toAxis(r, axis)).filter((o) => o.cs < m.ce && o.ce > m.cs);
192
+ // 前 = 終わりが自分の中央より手前、後ろ = 始まりが自分の中央より先。重なっているものは数えない
193
+ const before = row.filter((o) => o.e <= mid && o.s < m.s).sort((a, b) => b.e - a.e);
194
+ const after = row.filter((o) => o.s >= mid && o.e > m.e).sort((a, b) => a.s - b.s);
195
+ const prev = before[0];
196
+ const next = after[0];
197
+
198
+ type Option = { target: number; marks: SpacingMark[] };
199
+ const options: Option[] = [];
200
+ const mark = (from: number, to: number, at: number): SpacingMark => ({ axis, from, to, at });
201
+
202
+ if (prev && next) {
203
+ const gap = (next.s - prev.e - size) / 2;
204
+ if (gap >= 0) {
205
+ const t = prev.e + gap;
206
+ const at = (crossMid(prev, m) + crossMid(next, m)) / 2;
207
+ options.push({ target: t, marks: [mark(prev.e, t, at), mark(t + size, next.s, at)] });
208
+ }
209
+ }
210
+ if (prev) {
211
+ // prev の手前にある、prev と同じ列の兄弟
212
+ const prev2 = row
213
+ .filter((o) => o !== prev && o.e <= prev.s + 0.5 && o.cs < prev.ce && o.ce > prev.cs)
214
+ .sort((a, b) => b.e - a.e)[0];
215
+ if (prev2) {
216
+ const gap = prev.s - prev2.e;
217
+ const t = prev.e + gap;
218
+ options.push({
219
+ target: t,
220
+ marks: [mark(prev2.e, prev.s, crossMid(prev2, prev)), mark(prev.e, t, crossMid(prev, m))],
221
+ });
222
+ }
223
+ }
224
+ if (next) {
225
+ const next2 = row
226
+ .filter((o) => o !== next && o.s >= next.e - 0.5 && o.cs < next.ce && o.ce > next.cs)
227
+ .sort((a, b) => a.s - b.s)[0];
228
+ if (next2) {
229
+ const gap = next2.s - next.e;
230
+ const t = next.s - gap - size;
231
+ options.push({
232
+ target: t,
233
+ marks: [mark(t + size, next.s, crossMid(next, m)), mark(next.e, next2.s, crossMid(next, next2))],
234
+ });
235
+ }
236
+ }
237
+
238
+ let best: Option | null = null;
239
+ for (const o of options) {
240
+ const d = Math.abs(o.target - m.s);
241
+ if (d <= threshold && (!best || d < Math.abs(best.target - m.s))) best = o;
242
+ }
243
+ return best ? { diff: best.target - m.s, marks: best.marks } : null;
127
244
  }
128
245
 
129
246
  /**
@@ -166,6 +283,29 @@ export function snapToGuides(
166
283
  }
167
284
  }
168
285
 
286
+ // 等間隔。端・中央の吸着より近ければ、その軸は等間隔を優先する
287
+ if (candidates.rects?.length) {
288
+ const spacing: SpacingMark[] = [];
289
+ // 同じ位置で端の整列とも揃うなら(中央に置いた矢印など)、両方の線を出す
290
+ const SAME = 0.5;
291
+ const sx = snapToSpacing(candidates.rects, rect, 'x', threshold);
292
+ if (sx && (result.guideX === null || Math.abs(sx.diff) <= Math.abs(result.dx) + SAME)) {
293
+ if (result.guideX !== null && Math.abs(sx.diff - result.dx) > SAME) result.guideX = null;
294
+ result.dx = sx.diff;
295
+ spacing.push(...sx.marks);
296
+ }
297
+ const sy = snapToSpacing(candidates.rects, rect, 'y', threshold);
298
+ if (sy && (result.guideY === null || Math.abs(sy.diff) <= Math.abs(result.dy) + SAME)) {
299
+ if (result.guideY !== null && Math.abs(sy.diff - result.dy) > SAME) result.guideY = null;
300
+ result.dy = sy.diff;
301
+ spacing.push(...sy.marks);
302
+ }
303
+ if (spacing.length) {
304
+ // 等間隔の線は吸着後の位置で引く(x の線の高さは y の補正の影響を受けない近似で十分)
305
+ result.spacing = spacing;
306
+ }
307
+ }
308
+
169
309
  return result;
170
310
  }
171
311
 
@@ -182,9 +322,10 @@ export function drawSmartGuides(
182
322
  doc: Document,
183
323
  guideX: number | null,
184
324
  guideY: number | null,
325
+ spacing: SpacingMark[] = [],
185
326
  ): void {
186
327
  clearSmartGuides(doc);
187
- if (guideX === null && guideY === null) return;
328
+ if (guideX === null && guideY === null && spacing.length === 0) return;
188
329
 
189
330
  const artboard = doc.getElementById('artboard');
190
331
  if (!artboard) return;
@@ -214,5 +355,94 @@ export function drawSmartGuides(
214
355
  layer.appendChild(line);
215
356
  }
216
357
 
358
+ // 等間隔: 隙間ごとにピンクの線と間隔の数値(Figma の等間隔ガイド)
359
+ for (const sp of spacing) {
360
+ const len = sp.to - sp.from;
361
+ if (len <= 0) continue;
362
+ const seg = doc.createElement('div');
363
+ seg.dataset.guide = 'spacing';
364
+ seg.style.cssText =
365
+ sp.axis === 'x'
366
+ ? `position:absolute;left:${sp.from}px;width:${len}px;top:${sp.at}px;height:${w};margin-top:calc(${half} * -1);background:${PINK};`
367
+ : `position:absolute;top:${sp.from}px;height:${len}px;left:${sp.at}px;width:${w};margin-left:calc(${half} * -1);background:${PINK};`;
368
+ const label = doc.createElement('div');
369
+ label.textContent = String(Math.round(len));
370
+ label.style.cssText =
371
+ `position:absolute;${sp.axis === 'x' ? 'left:50%;top:0' : 'top:50%;left:0'};transform:translate(-50%,-50%);` +
372
+ `padding:calc(1px * var(--overlay-scale, 1)) calc(4px * var(--overlay-scale, 1));` +
373
+ `border-radius:calc(2px * var(--overlay-scale, 1));background:${PINK};color:#fff;` +
374
+ `font:600 calc(11px * var(--overlay-scale, 1))/1.2 system-ui,sans-serif;white-space:nowrap;`;
375
+ seg.appendChild(label);
376
+ layer.appendChild(seg);
377
+ }
378
+
217
379
  artboard.appendChild(layer);
218
380
  }
381
+
382
+ /** リサイズの吸着の結果。dx/dy は「マウスの移動量(アートボード px)に足す補正量」 */
383
+ export interface ResizeSnapResult {
384
+ dx: number;
385
+ dy: number;
386
+ guideX: number | null;
387
+ guideY: number | null;
388
+ }
389
+
390
+ /**
391
+ * リサイズ中の吸着(Figma と同じく、動いている辺だけが相手の端・中央・定規のガイドに吸い付く)
392
+ *
393
+ * rect は生の移動量で計算したリサイズ後の矩形(アートボード座標)。
394
+ * 掴んだ辺は、左右・上下どちらのハンドルでも移動量と同じだけ同じ向きに動く
395
+ * (Alt の中心固定でも同じ。そのとき対辺は逆向きに同じだけ動く)。
396
+ * なので「辺の位置の差」がそのまま移動量の補正になり、対辺で揃えるときだけ符号を反転する。
397
+ *
398
+ * axes で吸着させる軸を選ぶ。Shift(縦横比固定)のときは呼び出し側が主に動いている軸だけを渡し、
399
+ * もう片方は比から出す。
400
+ */
401
+ export function snapResizeEdges(
402
+ candidates: GuideCandidates,
403
+ rect: MovingRect,
404
+ dir: { x: -1 | 0 | 1; y: -1 | 0 | 1 },
405
+ opts: { alt: boolean; axes: { x: boolean; y: boolean } },
406
+ threshold: number,
407
+ ): ResizeSnapResult {
408
+ const result: ResizeSnapResult = { dx: 0, dy: 0, guideX: null, guideY: null };
409
+
410
+ const pick = (
411
+ list: number[],
412
+ start: number,
413
+ end: number,
414
+ d: -1 | 1,
415
+ ): { delta: number; guide: number } | null => {
416
+ // 掴んだ辺(+1)。Alt なら対辺も動くので候補に入れる(-1)
417
+ const edges: Array<[number, 1 | -1]> = [[d > 0 ? end : start, 1]];
418
+ if (opts.alt) edges.push([d > 0 ? start : end, -1]);
419
+ let best: { delta: number; guide: number } | null = null;
420
+ let bestDist = threshold;
421
+ for (const [edge, sign] of edges) {
422
+ const target = nearest(list, edge);
423
+ if (target === null) continue;
424
+ const diff = target - edge;
425
+ if (Math.abs(diff) <= bestDist) {
426
+ bestDist = Math.abs(diff);
427
+ best = { delta: diff * sign, guide: target };
428
+ }
429
+ }
430
+ return best;
431
+ };
432
+
433
+ if (opts.axes.x && dir.x !== 0) {
434
+ const s = pick(candidates.xs, rect.left, rect.left + rect.width, dir.x);
435
+ if (s) {
436
+ result.dx = s.delta;
437
+ result.guideX = s.guide;
438
+ }
439
+ }
440
+ if (opts.axes.y && dir.y !== 0) {
441
+ const s = pick(candidates.ys, rect.top, rect.top + rect.height, dir.y);
442
+ if (s) {
443
+ result.dy = s.delta;
444
+ result.guideY = s.guide;
445
+ }
446
+ }
447
+ return result;
448
+ }
@@ -51,6 +51,11 @@ function keyFromCode(code: string | undefined): string | undefined {
51
51
  if (digit) return digit[1];
52
52
  const bracket = code === 'BracketLeft' ? '[' : code === 'BracketRight' ? ']' : undefined;
53
53
  if (bracket) return bracket;
54
+ // ⌘⇧> / ⌘⇧<(文字の大きさ)。JIS 配列などで e.key が '>' '<' にならないときの引き直し
55
+ if (code === 'Period') return '.';
56
+ // ⌘\(UI を隠す)。JIS 配列の ¥ キー(IntlYen)も同じ位置づけ
57
+ if (code === 'Backslash' || code === 'IntlYen' || code === 'IntlRo') return '\\';
58
+ if (code === 'Comma') return ',';
54
59
  return undefined;
55
60
  }
56
61
 
@@ -122,7 +127,24 @@ function isAllowedInEditableContext(action: ShortcutAction | undefined): boolean
122
127
  * ヘッダーやパネルのボタンをキーボードだけで辿れなくなるため、
123
128
  * 紙面を触っている間だけ「兄弟要素の巡回」に使う。
124
129
  */
125
- const CANVAS_ONLY: ShortcutAction[] = ['selectNextSibling', 'selectPrevSibling'];
130
+ const CANVAS_ONLY: ShortcutAction[] = [
131
+ 'selectNextSibling',
132
+ 'selectPrevSibling',
133
+ // Enter はボタンの押下・ダイアログの確定でもある。UI にフォーカスがあるときは奪わない
134
+ 'selectChildren',
135
+ 'selectParent',
136
+ // 数字キーは右パネルの数値入力以外(スライダー・セレクト等)でも既定の意味を持つことがある
137
+ 'opacity1',
138
+ 'opacity2',
139
+ 'opacity3',
140
+ 'opacity4',
141
+ 'opacity5',
142
+ 'opacity6',
143
+ 'opacity7',
144
+ 'opacity8',
145
+ 'opacity9',
146
+ 'opacity0',
147
+ ];
126
148
 
127
149
  /**
128
150
  * このイベントがキャンバス(iframe)側の操作か。
@@ -130,6 +152,13 @@ const CANVAS_ONLY: ShortcutAction[] = ['selectNextSibling', 'selectPrevSibling']
130
152
  * - 親側から来た場合は、body か iframe 自身にフォーカスがあるときだけキャンバス扱い
131
153
  * (ボタンや入力にフォーカスがあるなら、それはUIの操作中)
132
154
  */
155
+ /**
156
+ * 押す相手が無い(選択が無い)ときでも、キャンバスにフォーカスがあればブラウザ既定に通さないキー。
157
+ * ⌘R(レイヤー名の変更)をそのまま通すとページが読み込み直され、⌘Z の履歴が消える。
158
+ * UI 側にフォーカスがあるときは既定どおり(読み込み直し)
159
+ */
160
+ const SWALLOW_ON_CANVAS: ShortcutAction[] = ['renameLayer'];
161
+
133
162
  function isCanvasEvent(event: KeyboardEvent): boolean {
134
163
  const doc = (event.target as HTMLElement | null)?.ownerDocument ?? null;
135
164
  if (doc && typeof document !== 'undefined' && doc !== document) return true;
@@ -173,9 +202,21 @@ export function useEditorShortcuts(
173
202
  if (!enabledRef.current) return;
174
203
  const keyboardEvent = event as KeyboardEvent;
175
204
 
205
+ // 日本語入力の変換中(Enter での確定・数字での候補選択など)は一切奪わない。
206
+ // keyCode 229 は Safari/Chrome が変換中のキーに付ける値で、isComposing が
207
+ // まだ false の最初の1打もこれで拾える
208
+ if (keyboardEvent.isComposing || keyboardEvent.keyCode === 229) return;
209
+
176
210
  const action = resolveAction(keyboardEvent);
177
211
  if (!action) return;
178
212
 
213
+ // 数字キー(不透明度)は押しっぱなしのキーリピートを捨てる。2 桁入力(5→5 で 55%)と
214
+ // 混ざって 50% と 55% を行き来してしまうため。既定の動作(数字の入力)も止める
215
+ if (keyboardEvent.repeat && action.startsWith('opacity')) {
216
+ if (callbacksRef.current[action]) keyboardEvent.preventDefault();
217
+ return;
218
+ }
219
+
179
220
  // イベント発生元 or そのドキュメントのフォーカスがテキスト編集中か
180
221
  const doc = (keyboardEvent.target as HTMLElement | null)?.ownerDocument ?? null;
181
222
  const inEditableContext =
@@ -191,7 +232,10 @@ export function useEditorShortcuts(
191
232
  }
192
233
 
193
234
  const callback = callbacksRef.current[action];
194
- if (!callback) return;
235
+ if (!callback) {
236
+ if (SWALLOW_ON_CANVAS.includes(action) && isCanvasEvent(keyboardEvent)) keyboardEvent.preventDefault();
237
+ return;
238
+ }
195
239
 
196
240
  keyboardEvent.preventDefault();
197
241
  keyboardEvent.stopPropagation();
@@ -387,3 +387,8 @@
387
387
  .gg-editor-ui .ed-frame-skeleton-note { font-size: 40px; color: #9aa3b2; }
388
388
  /* 定規の下に来る部分は透けないように */
389
389
  .gg-editor-ui [data-canvas-rulers] canvas { display: block; }
390
+
391
+ /* ⌘\ で UI を隠す(Figma の Hide/Show UI)。紙面の行だけ残し、上の帯・左右のパネル・発表者ノート・下の帯・道具箱を隠す */
392
+ .gg-editor-ui.gg-ui-hidden > :not(.flex-1),
393
+ .gg-editor-ui.gg-ui-hidden > .flex-1 > :not(.flex-1),
394
+ .gg-editor-ui.gg-ui-hidden .ed-toolbar { display: none !important; }
@@ -51,6 +51,11 @@ export interface SerializedElement {
51
51
  style: string;
52
52
  innerHTML: string;
53
53
  attributes: Record<string, string>;
54
+ /**
55
+ * コピーした時点の位置と大きさ(親の中の left/top。絶対配置の要素だけ)。
56
+ * 器を選んで貼るとき、同じ位置に入りきるか(入らなければ中央)の判定に使う
57
+ */
58
+ box?: { left: number; top: number; width: number; height: number };
54
59
  }
55
60
 
56
61
  // Clipboard data structure
@@ -151,7 +156,51 @@ export type ShortcutAction =
151
156
  | 'bringForward'
152
157
  | 'sendBackward'
153
158
  | 'bringToFront'
154
- | 'sendToBack';
159
+ | 'sendToBack'
160
+ // 選択の階層移動(Figma: Enter で子へ / Shift+Enter で親へ)
161
+ | 'selectChildren'
162
+ | 'selectParent'
163
+ // 整列(Figma: ⌥A/⌥D/⌥W/⌥S/⌥H/⌥V)と等間隔(⌃⌥H/⌃⌥V)
164
+ | 'alignLeft'
165
+ | 'alignRight'
166
+ | 'alignTop'
167
+ | 'alignBottom'
168
+ | 'alignHCenter'
169
+ | 'alignVCenter'
170
+ | 'distributeH'
171
+ | 'distributeV'
172
+ // 表示/ロックの切り替え(Figma: ⌘⇧H / ⌘⇧L)
173
+ | 'toggleHidden'
174
+ | 'toggleLocked'
175
+ // 数字キーで不透明度(Figma: 1=10% … 9=90%, 0=100%)
176
+ | 'opacity1'
177
+ | 'opacity2'
178
+ | 'opacity3'
179
+ | 'opacity4'
180
+ | 'opacity5'
181
+ | 'opacity6'
182
+ | 'opacity7'
183
+ | 'opacity8'
184
+ | 'opacity9'
185
+ | 'opacity0'
186
+ // 文字の太字/斜体/下線を要素ごと切り替える(文字編集中はブラウザ既定に任せる)
187
+ | 'toggleBold'
188
+ | 'toggleItalic'
189
+ | 'toggleUnderline'
190
+ // 文字の大きさを 1px 上げる/下げる(Figma・PowerPoint: ⌘⇧> / ⌘⇧<)
191
+ | 'fontSizeUp'
192
+ | 'fontSizeDown'
193
+ // ⌘⇧V: その場に貼る(Figma の Paste over selection)
194
+ | 'pasteInPlace'
195
+ // ⇧H / ⇧V: 左右反転・上下反転(Figma と同じキー)
196
+ | 'flipH'
197
+ | 'flipV'
198
+ // ⌘R: レイヤー名を変える(Figma の Rename)
199
+ | 'renameLayer'
200
+ // ⌘\: UI を隠す / 出す(Figma の Hide/Show UI)
201
+ | 'toggleUi'
202
+ // ⌘⇧O: アウトライン表示(Figma の Show outlines。塗りを消して枠線だけにする)
203
+ | 'toggleOutline';
155
204
 
156
205
  // Shortcut callbacks interface
157
206
  export type ShortcutCallbacks = Partial<Record<ShortcutAction, () => void>>;
@@ -324,6 +373,76 @@ export const KEYBOARD_SHORTCUTS: Record<string, ShortcutAction> = {
324
373
  'ctrl+shift+]': 'bringToFront',
325
374
  'meta+shift+[': 'sendToBack',
326
375
  'ctrl+shift+[': 'sendToBack',
376
+ // Figma の Mac 割り当ては ⌥⌘] / ⌥⌘[(Windows は Ctrl+Shift+] / [)。
377
+ // ⌘⇧] / ⌘⇧[ は PowerPoint(Mac) と同じなので両方残す
378
+ 'meta+alt+]': 'bringToFront',
379
+ 'meta+alt+[': 'sendToBack',
380
+
381
+ // 選択の階層移動(Figma: Enter=子を選ぶ / Shift+Enter=親を選ぶ)
382
+ 'enter': 'selectChildren',
383
+ 'shift+enter': 'selectParent',
384
+
385
+ // 整列(Figma: Alt+A/D/W/S/H/V)。Mac の Option 併用で e.key が特殊文字になっても
386
+ // useEditorShortcuts の keyFromCode が event.code から引き直す
387
+ 'alt+a': 'alignLeft',
388
+ 'alt+d': 'alignRight',
389
+ 'alt+w': 'alignTop',
390
+ 'alt+s': 'alignBottom',
391
+ 'alt+h': 'alignHCenter',
392
+ 'alt+v': 'alignVCenter',
393
+ // 等間隔。Figma は Mac ⌃⌥H / ⌃⌥V、Windows Alt+Shift+H / V(記憶にもとづく・未確認)。
394
+ // ⌃⌥V は Windows の「スタイルをペースト」(Ctrl+Alt+V)と同じキーになるので、縦は ⇧⌥V に寄せ、横も揃えて ⇧⌥H を持つ
395
+ 'ctrl+alt+h': 'distributeH',
396
+ 'shift+alt+h': 'distributeH',
397
+ 'shift+alt+v': 'distributeV',
398
+
399
+ // 表示/ロック(Figma: ⌘⇧H / ⌘⇧L)
400
+ 'meta+shift+h': 'toggleHidden',
401
+ 'ctrl+shift+h': 'toggleHidden',
402
+ 'meta+shift+l': 'toggleLocked',
403
+ 'ctrl+shift+l': 'toggleLocked',
404
+
405
+ // 不透明度(Figma: 1〜9 で 10〜90%、0 で 100%)
406
+ '1': 'opacity1',
407
+ '2': 'opacity2',
408
+ '3': 'opacity3',
409
+ '4': 'opacity4',
410
+ '5': 'opacity5',
411
+ '6': 'opacity6',
412
+ '7': 'opacity7',
413
+ '8': 'opacity8',
414
+ '9': 'opacity9',
415
+ '0': 'opacity0',
416
+
417
+ // 文字の書式(要素を選んだ状態で要素全体に効かせる。編集中は横取りしない)
418
+ 'meta+b': 'toggleBold',
419
+ 'ctrl+b': 'toggleBold',
420
+ 'meta+i': 'toggleItalic',
421
+ 'ctrl+i': 'toggleItalic',
422
+ 'meta+u': 'toggleUnderline',
423
+ 'ctrl+u': 'toggleUnderline',
424
+
425
+ // 文字の大きさ。US 配列の Shift+. は '>'、Shift+, は '<'。配列で e.key が違うときは code から引く('.' / ',')
426
+ 'meta+shift+>': 'fontSizeUp',
427
+ 'meta+shift+.': 'fontSizeUp',
428
+ 'ctrl+shift+>': 'fontSizeUp',
429
+ 'ctrl+shift+.': 'fontSizeUp',
430
+ 'meta+shift+<': 'fontSizeDown',
431
+ 'meta+shift+,': 'fontSizeDown',
432
+ 'ctrl+shift+<': 'fontSizeDown',
433
+ 'ctrl+shift+,': 'fontSizeDown',
434
+
435
+ 'meta+shift+v': 'pasteInPlace',
436
+ 'ctrl+shift+v': 'pasteInPlace',
437
+
438
+ 'shift+h': 'flipH',
439
+ 'shift+v': 'flipV',
440
+ 'meta+r': 'renameLayer',
441
+ 'ctrl+r': 'renameLayer',
442
+ 'meta+\\': 'toggleUi',
443
+ 'ctrl+\\': 'toggleUi',
444
+ 'meta+shift+o': 'toggleOutline',
445
+ 'ctrl+shift+o': 'toggleOutline',
327
446
  };
328
447
 
329
448
  // Movement amounts in pixels
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * 利用側はこの値と node_modules の package.json を見比べて、食い違っていれば
7
7
  * 「再起動してください」と出せる(構成ラフのテンプレートが行っている)
8
8
  */
9
- export const EDITOR_VERSION = '0.10.0';
9
+ export const EDITOR_VERSION = '0.11.0';