@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,247 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * 定規から引くガイド(Figma の「定規からドラッグしてガイドを作る」)。
5
+ *
6
+ * - 上の定規から下へドラッグ → 横のガイド、左の定規から右へドラッグ → 縦のガイド
7
+ * - ガイドはドラッグで動かせる。定規の上へ戻して離すと消える
8
+ * - クリックで選ぶ(青くなる)。選んだまま Delete / Backspace で消える。Esc か外をクリックで選択を外す
9
+ * - 位置は編集中のページの紙面の座標(px)で持ち、要素の移動・リサイズの吸着の相手になる
10
+ * - 保存しない(ページごとにメモリで持つ。読み込み直すと消える)
11
+ *
12
+ * CanvasRulers の中(定規と同じ重ね合わせの層)に置く。座標の換算は定規と同じ:
13
+ * 画面 = canvasOffset + (ページの位置 + 紙面の px) × canvasZoom
14
+ */
15
+
16
+ import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react';
17
+ import { RULER_SIZE, useCanvasViewState, useMultiPageCanvas } from '../../contexts/MultiPageCanvasContext';
18
+ import {
19
+ getPageGuides,
20
+ setActiveGuidePage,
21
+ setPageGuides,
22
+ subscribeGuides,
23
+ type PageGuides,
24
+ } from '../../utils/canvas-guides';
25
+
26
+ /** Figma のガイドに近い赤 */
27
+ const GUIDE_COLOR = '#f24822';
28
+ /** 選んでいるガイドの色 */
29
+ const GUIDE_SELECTED_COLOR = '#0d99ff';
30
+ /** これより動かなければクリック(選ぶだけ)とみなす(画面 px) */
31
+ const CLICK_SLOP = 2;
32
+ /** ガイドをつかめる幅(画面 px) */
33
+ const HIT = 7;
34
+
35
+ type Axis = 'x' | 'y';
36
+
37
+ interface Drag {
38
+ axis: Axis;
39
+ /** 動かしている既存のガイドの番号(新しく引いているときは null) */
40
+ index: number | null;
41
+ /** いまの位置(紙面の px) */
42
+ value: number;
43
+ /** ポインタが定規の上にある(離すと消える) */
44
+ overRuler: boolean;
45
+ }
46
+
47
+ export const CanvasGuides = memo(function CanvasGuides() {
48
+ const { pages } = useMultiPageCanvas();
49
+ const { canvasOffset, canvasZoom, activePageId } = useCanvasViewState();
50
+ const active = pages.find((p) => p.id === activePageId) ?? null;
51
+ const rootRef = useRef<HTMLDivElement>(null);
52
+ const [drag, setDrag] = useState<Drag | null>(null);
53
+ const [selected, setSelected] = useState<{ axis: Axis; index: number } | null>(null);
54
+ // ドラッグ中に window へ張ったリスナーを外す関数。アンマウント・ページ切り替えで残さない
55
+ const detachRef = useRef<(() => void) | null>(null);
56
+ useEffect(() => () => detachRef.current?.(), []);
57
+
58
+ const guides = useSyncExternalStore(
59
+ subscribeGuides,
60
+ () => getPageGuides(activePageId),
61
+ () => getPageGuides(null),
62
+ );
63
+
64
+ // 吸着が見るページ。定規(= この部品)が消えたら外す
65
+ useEffect(() => {
66
+ setActiveGuidePage(activePageId);
67
+ setSelected(null);
68
+ // ページが替わったら引いている途中のドラッグも捨てる(前のページへ書き込まない)
69
+ detachRef.current?.();
70
+ setDrag(null);
71
+ return () => setActiveGuidePage(null);
72
+ }, [activePageId]);
73
+
74
+ if (!active) return null;
75
+
76
+ const originX = canvasOffset.x + active.position.x * canvasZoom;
77
+ const originY = canvasOffset.y + active.position.y * canvasZoom;
78
+ const toScreen = (axis: Axis, v: number) => (axis === 'x' ? originX : originY) + v * canvasZoom;
79
+
80
+ /** ドラッグを始める。終わり(離したとき)にガイドを足す・動かす・消す */
81
+ const begin = (e: React.PointerEvent, axis: Axis, index: number | null) => {
82
+ if (e.button !== 0) return;
83
+ e.preventDefault();
84
+ e.stopPropagation();
85
+ const root = rootRef.current;
86
+ if (!root) return;
87
+ const box = root.getBoundingClientRect();
88
+ const read = (ev: PointerEvent | React.PointerEvent): Drag => {
89
+ const sx = ev.clientX - box.left;
90
+ const sy = ev.clientY - box.top;
91
+ const screen = axis === 'x' ? sx : sy;
92
+ const value = Math.round((screen - (axis === 'x' ? originX : originY)) / canvasZoom);
93
+ // 縦のガイドは左の定規、横のガイドは上の定規へ戻すと消える
94
+ const overRuler = axis === 'x' ? sx < RULER_SIZE : sy < RULER_SIZE;
95
+ return { axis, index, value, overRuler };
96
+ };
97
+ const startX = e.clientX;
98
+ const startY = e.clientY;
99
+ let moved = false;
100
+ let current = read(e);
101
+ // 既存のガイドは、動かし始めるまでは線を出したまま(クリックで選ぶだけのときにちらつかない)
102
+ if (index === null) setDrag(current);
103
+ const onMove = (ev: PointerEvent) => {
104
+ if (!moved && Math.abs(ev.clientX - startX) <= CLICK_SLOP && Math.abs(ev.clientY - startY) <= CLICK_SLOP) return;
105
+ moved = true;
106
+ current = read(ev);
107
+ setDrag(current);
108
+ };
109
+ const detach = () => {
110
+ window.removeEventListener('pointermove', onMove);
111
+ window.removeEventListener('pointerup', onUp);
112
+ window.removeEventListener('pointercancel', onUp);
113
+ if (detachRef.current === detach) detachRef.current = null;
114
+ };
115
+ const onUp = () => {
116
+ detach();
117
+ setDrag(null);
118
+ // 既存のガイドをクリックしただけ → 選ぶ(Delete で消せるように、キー入力をこの線に向ける)
119
+ if (index !== null && !moved) {
120
+ setSelected({ axis, index });
121
+ return;
122
+ }
123
+ const now = getPageGuides(active.id);
124
+ const list = [...(axis === 'x' ? now.xs : now.ys)];
125
+ let nextIndex: number | null = null;
126
+ if (index !== null && current.overRuler) list.splice(index, 1);
127
+ else if (index !== null) {
128
+ list[index] = current.value;
129
+ nextIndex = index;
130
+ } else if (!current.overRuler) {
131
+ list.push(current.value);
132
+ nextIndex = list.length - 1;
133
+ }
134
+ const next: PageGuides = axis === 'x' ? { xs: list, ys: now.ys } : { xs: now.xs, ys: list };
135
+ setPageGuides(active.id, next);
136
+ setSelected(nextIndex === null ? null : { axis, index: nextIndex });
137
+ };
138
+ detachRef.current?.();
139
+ window.addEventListener('pointermove', onMove);
140
+ window.addEventListener('pointerup', onUp);
141
+ window.addEventListener('pointercancel', onUp);
142
+ detachRef.current = detach;
143
+ };
144
+
145
+ /** 選んでいるガイドを消す(Delete / Backspace) */
146
+ const removeSelected = () => {
147
+ if (!selected) return;
148
+ const now = getPageGuides(active.id);
149
+ const list = [...(selected.axis === 'x' ? now.xs : now.ys)];
150
+ list.splice(selected.index, 1);
151
+ setPageGuides(active.id, selected.axis === 'x' ? { xs: list, ys: now.ys } : { xs: now.xs, ys: list });
152
+ setSelected(null);
153
+ };
154
+
155
+ const pageW = active.size.width * canvasZoom;
156
+ const pageH = active.size.height * canvasZoom;
157
+
158
+ const renderLine = (axis: Axis, v: number, key: string, index: number | null, ghost: boolean) => {
159
+ const pos = toScreen(axis, v);
160
+ const vertical = axis === 'x';
161
+ // 置いたガイドは編集中のページの範囲に引く(ページごとのガイドなので)。引いている最中は画面いっぱい
162
+ const style: React.CSSProperties = vertical
163
+ ? { left: pos - HIT / 2, width: HIT, top: ghost ? RULER_SIZE : originY, height: ghost ? undefined : pageH, bottom: ghost ? 0 : undefined, cursor: 'ew-resize' }
164
+ : { top: pos - HIT / 2, height: HIT, left: ghost ? RULER_SIZE : originX, width: ghost ? undefined : pageW, right: ghost ? 0 : undefined, cursor: 'ns-resize' };
165
+ const isSelected = !ghost && index !== null && selected?.axis === axis && selected.index === index;
166
+ const color = isSelected ? GUIDE_SELECTED_COLOR : GUIDE_COLOR;
167
+ return (
168
+ <div
169
+ key={key}
170
+ data-canvas-guide={axis}
171
+ data-selected={isSelected || undefined}
172
+ className="absolute"
173
+ style={{ ...style, pointerEvents: ghost ? 'none' : 'auto', outline: 'none' }}
174
+ // 選んだガイドにフォーカスを当て、Delete を紙面(要素の削除)ではなくこの線で受ける
175
+ tabIndex={isSelected ? -1 : undefined}
176
+ ref={isSelected ? (el) => el?.focus({ preventScroll: true }) : undefined}
177
+ onPointerDown={ghost || index === null ? undefined : (e) => begin(e, axis, index)}
178
+ onKeyDown={
179
+ isSelected
180
+ ? (e) => {
181
+ if (e.key === 'Delete' || e.key === 'Backspace') {
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ removeSelected();
185
+ } else if (e.key === 'Escape') {
186
+ e.stopPropagation();
187
+ setSelected(null);
188
+ }
189
+ }
190
+ : undefined
191
+ }
192
+ onBlur={isSelected ? () => setSelected(null) : undefined}
193
+ >
194
+ <div
195
+ className="absolute"
196
+ style={
197
+ vertical
198
+ ? { left: HIT / 2 - 0.5, top: 0, bottom: 0, width: 1, background: color }
199
+ : { top: HIT / 2 - 0.5, left: 0, right: 0, height: 1, background: color }
200
+ }
201
+ />
202
+ </div>
203
+ );
204
+ };
205
+
206
+ return (
207
+ <div ref={rootRef} className="absolute inset-0" data-canvas-guides>
208
+ {guides.xs.map((v, i) => (drag && drag.axis === 'x' && drag.index === i ? null : renderLine('x', v, `x${i}`, i, false)))}
209
+ {guides.ys.map((v, i) => (drag && drag.axis === 'y' && drag.index === i ? null : renderLine('y', v, `y${i}`, i, false)))}
210
+
211
+ {/* 定規の上の、ガイドを引き出す当たり(上 = 横のガイド、左 = 縦のガイド) */}
212
+ <div
213
+ className="absolute"
214
+ data-ruler-hit="top"
215
+ style={{ left: RULER_SIZE, right: 0, top: 0, height: RULER_SIZE, pointerEvents: 'auto', cursor: 'ns-resize' }}
216
+ onPointerDown={(e) => begin(e, 'y', null)}
217
+ />
218
+ <div
219
+ className="absolute"
220
+ data-ruler-hit="left"
221
+ style={{ top: RULER_SIZE, bottom: 0, left: 0, width: RULER_SIZE, pointerEvents: 'auto', cursor: 'ew-resize' }}
222
+ onPointerDown={(e) => begin(e, 'x', null)}
223
+ />
224
+
225
+ {drag && (
226
+ <>
227
+ {/* 引いている間は紙面(iframe)にポインタを取られないよう全面を覆う */}
228
+ <div className="fixed inset-0" style={{ pointerEvents: 'auto', cursor: drag.axis === 'x' ? 'ew-resize' : 'ns-resize', zIndex: 1 }} />
229
+ {!drag.overRuler && renderLine(drag.axis, drag.value, 'ghost', null, true)}
230
+ {!drag.overRuler && (
231
+ <div
232
+ className="absolute rounded px-1 text-[10px] tabular-nums text-white"
233
+ style={{
234
+ background: GUIDE_COLOR,
235
+ left: drag.axis === 'x' ? toScreen('x', drag.value) + 4 : RULER_SIZE + 4,
236
+ top: drag.axis === 'y' ? toScreen('y', drag.value) + 4 : RULER_SIZE + 4,
237
+ pointerEvents: 'none',
238
+ }}
239
+ >
240
+ {drag.value}
241
+ </div>
242
+ )}
243
+ </>
244
+ )}
245
+ </div>
246
+ );
247
+ });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { memo, useEffect, useRef } from 'react';
10
10
  import { RULER_SIZE, useMultiPageCanvas, useCanvasViewState } from '../../contexts/MultiPageCanvasContext';
11
+ import { CanvasGuides } from '../canvas-guides/CanvasGuides';
11
12
 
12
13
  /** 目盛りの候補(紙面の px)。画面上で 60px 以上離れる最小のものを選ぶ */
13
14
  const STEPS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
@@ -142,6 +143,8 @@ export const CanvasRulers = memo(function CanvasRulers({ theme }: { theme: 'ligh
142
143
  className="absolute left-0 top-0"
143
144
  style={{ width: RULER_SIZE, height: RULER_SIZE, background: 'var(--ed-panel)', borderRight: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)' }}
144
145
  />
146
+ {/* 定規から引くガイド(定規の当たりとガイドの線だけが押せる。ほかは下のキャンバスへ素通し) */}
147
+ <CanvasGuides />
145
148
  </div>
146
149
  );
147
150
  });
@@ -55,6 +55,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '../../../components/ui/
55
55
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '../../../components/ui/dropdown-menu';
56
56
  import { useResizablePanel } from '../../hooks/useResizablePanel';
57
57
  import { ConfirmDialog } from '../shell/ConfirmDialog';
58
+ import { useRowsNearViewport } from '../shell/useRowsNearViewport';
58
59
  import { can, io } from '../../../io';
59
60
  import { MIN_ZOOM, MAX_ZOOM, editorZoomApiRef } from '../../hooks/useCanvasControls';
60
61
  import { toast } from 'sonner';
@@ -2301,14 +2302,17 @@ export function PptThumbnails({ page, theme, search }: { page: number; theme: Pp
2301
2302
  const q = search.trim().toLocaleLowerCase();
2302
2303
  const slides = deck.slides.map((slide, i) => ({ slide, n: i + 1 })).filter(({ slide, n }) => !q || slide.title.toLocaleLowerCase().includes(q) || String(n) === q);
2303
2304
  const hasMenu = !!(ops?.move || ops?.duplicate || ops?.remove || ops?.updateMeta);
2305
+ // 画面に近づいた行だけ紙面を描く(一度描いた行は残す)。描かない行も同じ大きさの白い枠・番号・題名は出す
2306
+ const listRef = useRef<HTMLDivElement>(null);
2307
+ const nearRows = useRowsNearViewport(listRef, slides.map(({ slide }) => slide.id), deck.slides.slice(page - 1, page).map((s) => s.id));
2304
2308
 
2305
2309
  return (
2306
2310
  <aside data-ppt-thumbnails aria-label="スライド一覧" className="relative flex shrink-0 flex-col border-r" style={{ width, backgroundColor: pal.rail, borderColor: pal.border }}>
2307
2311
  <div className="ed-panel-heading">スライド<span className="ml-auto text-xs font-normal text-gray-400">{page} / {deck.slides.length}</span></div>
2308
- <div className="min-h-0 flex-1 overflow-y-auto py-2">
2312
+ <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto py-2">
2309
2313
  {slides.length === 0 && <p className="ed-empty">{q ? '一致するスライドがありません。' : 'スライドがありません。'}</p>}
2310
2314
  {slides.map(({ slide: s, n }) => <div key={s.id} className="relative px-2 py-2" style={{ opacity: dragFrom === n ? .5 : 1 }}>
2311
- <button ref={n === page ? currentRef : undefined} data-page-thumb={n} aria-current={n === page ? 'page' : undefined}
2315
+ <button ref={n === page ? currentRef : undefined} data-page-thumb={n} data-row-key={s.id} aria-current={n === page ? 'page' : undefined}
2312
2316
  disabled={busy} draggable={!!ops?.move && !busy}
2313
2317
  onDragStart={(e) => { setDragFrom(n); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(n)); }}
2314
2318
  onDragOver={(e) => { if (dragFrom === null || !ops?.move) return; e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropAt(e.clientY < r.top + r.height / 2 ? n : n + 1); }}
@@ -2319,9 +2323,9 @@ export function PptThumbnails({ page, theme, search }: { page: number; theme: Pp
2319
2323
  <span className="min-w-0 flex-1">
2320
2324
  {can('renderContent') && <span className="relative block aspect-video overflow-hidden rounded bg-white"
2321
2325
  style={{ width: thumbWidth, boxShadow: n === page ? `0 0 0 2px ${PPT_ACCENT}` : `0 0 0 1px ${pal.border}`, opacity: s.hidden ? .5 : 1 }}>
2322
- <span data-editor-preview className="pointer-events-none absolute left-0 top-0 origin-top-left" style={{ width: 1920, height: 1080, transform: `scale(${thumbWidth / 1920})` }}>
2326
+ {nearRows.has(s.id) && <span data-editor-preview className="pointer-events-none absolute left-0 top-0 origin-top-left" style={{ width: 1920, height: 1080, transform: `scale(${thumbWidth / 1920})` }}>
2323
2327
  <MemoSlideRender page={n} template={s.template} edited={s.edited} />
2324
- </span>
2328
+ </span>}
2325
2329
  </span>}
2326
2330
  <span className="mt-2 block truncate pr-6 text-xs" style={{ color: pal.text }}>{s.title || '無題'}{s.hidden ? '(非表示)' : ''}</span>
2327
2331
  {can('commentAction') && unresolvedCount(s.comments) > 0 && <span className="mt-1 block text-xs" style={{ color: pal.sub }}>未解決 {unresolvedCount(s.comments)}件</span>}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useState, type ReactNode } from 'react';
3
+ import { useEffect, useState, type ReactNode } from 'react';
4
+ import { RENAME_LAYER_EVENT } from '../../hooks/useKeyboardShortcuts';
4
5
  import { Layers, Files } from 'lucide-react';
5
6
  import { useEditorContext } from '../../EditorContext';
6
7
  import { useResizablePanel } from '../../hooks/useResizablePanel';
@@ -16,6 +17,12 @@ export function LeftPanel({ page, pagesSlot }: { page: number; pagesSlot?: React
16
17
  initialWidth: 264, minWidth: 224, maxWidth: 440, direction: 'right', storageKey: 'gg-editor:left-panel-width',
17
18
  });
18
19
  const [tab, setTab] = useState(showPages ? 'pages' : 'layers');
20
+ // ⌘R(レイヤー名の変更)はレイヤーのタブで名前を入力させるので、ページのタブを開いていたら切り替える
21
+ useEffect(() => {
22
+ const toLayers = () => setTab('layers');
23
+ window.addEventListener(RENAME_LAYER_EVENT, toLayers);
24
+ return () => window.removeEventListener(RENAME_LAYER_EVENT, toLayers);
25
+ }, []);
19
26
  return (
20
27
  <aside data-left-panel aria-label="ページとレイヤー" className="relative flex shrink-0 flex-col overflow-hidden border-r border-[#444444] bg-[#2c2c2c]" style={{ width }}>
21
28
  {showPages ? (
@@ -18,6 +18,7 @@ import { DeckSlideRender } from '../../../components/DeckSlideRender';
18
18
  import { flushAutoSave } from '../../autosave';
19
19
  import { useMultiPageCanvasOptional } from '../../contexts/MultiPageCanvasContext';
20
20
  import { CollabPageDots } from '../../collab/CollabPresence';
21
+ import { useRowsNearViewport } from './useRowsNearViewport';
21
22
 
22
23
  /**
23
24
  * スライド描画はメモ化する。パネル幅の変更や他ページの選択で親が再レンダーしても、
@@ -50,6 +51,10 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
50
51
  return () => observer.disconnect();
51
52
  }, []);
52
53
 
54
+ // 画面に近づいた行だけ紙面を描く(一度描いた行は残す)。行の大きさは幅が決まってから決まるので、それまでは見ない
55
+ const rowKeys = deck.slides.map((s) => s.id);
56
+ const nearRows = useRowsNearViewport(rootRef, rowKeys, rowKeys.slice(page - 1, page), thumbWidth > 0);
57
+
53
58
  // 編集中のページが視界の外にあると「今どこか」が分からないので追う
54
59
  useEffect(() => {
55
60
  currentRef.current?.scrollIntoView({ block: 'nearest' });
@@ -91,6 +96,7 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
91
96
  key={s.id}
92
97
  ref={current ? currentRef : undefined}
93
98
  data-page-thumb={n}
99
+ data-row-key={s.id}
94
100
  aria-current={current ? "page" : undefined}
95
101
  onClick={() => void goto(n)}
96
102
  className="flex w-full shrink-0 items-start gap-1.5 px-2 py-1 text-left"
@@ -111,7 +117,7 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
111
117
  opacity: s.hidden ? 0.45 : 1,
112
118
  }}
113
119
  >
114
- {thumbWidth > 0 && (
120
+ {thumbWidth > 0 && nearRows.has(s.id) && (
115
121
  <span
116
122
  data-editor-preview className="pointer-events-none absolute left-0 top-0 origin-top-left"
117
123
  style={{ width: 1920, height: 1080, transform: `scale(${thumbWidth / 1920})` }}
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type RefObject } from 'react';
4
+
5
+ /** 先読みの幅。上下 1.5 画面分(速くスクロールしても白い枠が見えにくい) */
6
+ const NEAR_MARGIN = '150% 0px';
7
+
8
+ /**
9
+ * スクロールする一覧で、画面に近づいた行のキーを集める(一度入ったキーは外さない)。
10
+ *
11
+ * サムネイルの一覧(PagesPanel・PptThumbnails)は 150 枚すべてを DeckSlideRender で描くと、
12
+ * 読み込み直後の DOM 13,124 のうち 10,224・img 554 枚が一覧になり、利用側の io も 150 枚分の
13
+ * 本文とテンプレートを取りに行く(ヘッドレス実測)。近い行だけ描けば済む。
14
+ * 描かない行も同じ大きさの枠は呼び出し側で置くので、スクロールの高さと位置は変わらない。
15
+ *
16
+ * 行は `[data-row-key]` の要素で見分ける。並べ替えでも描いた行が描き直されないよう、キーはページ番号でなく slide の id にする。
17
+ *
18
+ * @param rootRef スクロールする箱
19
+ * @param rowKeys 今並んでいる行のキー(増減・並べ替えで見直す)
20
+ * @param initialKeys 最初から描く行(編集中のページ)
21
+ * @param enabled false の間は見ない(行の大きさが決まる前に見ると遠くの行まで「近い」と出る)
22
+ */
23
+ export function useRowsNearViewport(
24
+ rootRef: RefObject<HTMLElement | null>,
25
+ rowKeys: readonly string[],
26
+ initialKeys: readonly string[],
27
+ enabled = true,
28
+ ): ReadonlySet<string> {
29
+ const [near, setNear] = useState<ReadonlySet<string>>(() => new Set(initialKeys));
30
+ const keysSignature = rowKeys.join('\n');
31
+
32
+ useEffect(() => {
33
+ const root = rootRef.current;
34
+ if (!enabled) return;
35
+ if (!root || typeof IntersectionObserver === 'undefined') {
36
+ setNear(new Set(keysSignature.split('\n')));
37
+ return;
38
+ }
39
+ const observer = new IntersectionObserver(
40
+ (entries) => {
41
+ const keys = entries.filter((e) => e.isIntersecting).map((e) => (e.target as HTMLElement).dataset.rowKey ?? '');
42
+ if (keys.length === 0) return;
43
+ setNear((prev) => {
44
+ if (keys.every((k) => prev.has(k))) return prev;
45
+ const next = new Set(prev);
46
+ keys.forEach((k) => next.add(k));
47
+ return next;
48
+ });
49
+ },
50
+ { root, rootMargin: NEAR_MARGIN },
51
+ );
52
+ // 見始めるのはレイアウトが落ち着いてから(読み込み直後は箱の高さが決まっておらず、すぐ見ると
53
+ // 150 行中 70〜79 行が「近い」と出た。2 フレーム後なら 26 行)
54
+ let raf = requestAnimationFrame(() => {
55
+ raf = requestAnimationFrame(() => root.querySelectorAll<HTMLElement>('[data-row-key]').forEach((el) => observer.observe(el)));
56
+ });
57
+ return () => {
58
+ cancelAnimationFrame(raf);
59
+ observer.disconnect();
60
+ };
61
+ }, [rootRef, keysSignature, enabled]);
62
+
63
+ return near;
64
+ }
@@ -216,6 +216,16 @@ export const EDITOR_IFRAME_STYLES = `
216
216
  pointer-events: auto !important;
217
217
  cursor: pointer;
218
218
  }
219
+ /*
220
+ * ロックした要素(⌘⇧L で data-gg-locked="true")は紙面のクリック・ドラッグ・ホバーを素通しする。
221
+ * Figma と同じく「下の要素を掴める」状態にし、選択はレイヤーパネルから行う。
222
+ * 親をロックすると中の要素もまとめてロックされる(Figma と同じ)。
223
+ * 上の pointer-events:auto !important より詳細度を高くするため、同じ :not() を重ねている
224
+ */
225
+ body:not(.draw-mode):not(.text-mode):not(.move-mode):not(.comment-mode) [data-editable="true"][data-gg-locked="true"],
226
+ body:not(.draw-mode):not(.text-mode):not(.move-mode):not(.comment-mode) [data-gg-locked="true"] [data-editable="true"] {
227
+ pointer-events: none !important;
228
+ }
219
229
  .drawing-preview {
220
230
  position: absolute;
221
231
  pointer-events: none;
@@ -409,6 +419,12 @@ export const EDITOR_IFRAME_STYLES = `
409
419
  * ルートを選んだら枠・ハンドル・サイズラベルが紫、中のスロットを選んだら枠は青のまま
410
420
  * 「部品の中」の札だけ出す。札はパンくず(上 -22px)の左上に重ならないよう、その上に置く
411
421
  */
422
+ /* ロック中(⌘⇧L)の要素: 枠は出すが、大きさ・回転・角丸のハンドルは出さない */
423
+ .selection-box.selection-locked .resize-handle,
424
+ .selection-box.selection-locked .rotation-handle,
425
+ .selection-box.selection-locked .border-radius-handle {
426
+ display: none !important;
427
+ }
412
428
  .selection-box.selection-part .selection-outline { box-shadow: 0 0 0 var(--outline-w) #7b61ff; }
413
429
  .selection-box.selection-part .resize-handle::after { border-color: #7b61ff; }
414
430
  .selection-box.selection-part .size-label { background: #7b61ff; }
@@ -38,6 +38,7 @@ import React, {
38
38
  useState,
39
39
  } from 'react';
40
40
  import { MAX_HISTORY_SIZE } from '../../types/editor';
41
+ import { COALESCE_WINDOW_MS } from '../utils/history-timing';
41
42
  import { useEditorRefs } from './EditorRefsContext';
42
43
 
43
44
  export interface EditorHistoryContextValue {
@@ -77,8 +78,8 @@ export function useEditorHistory(): EditorHistoryContextValue {
77
78
  * - 矢印キーのキーリピート(約30ms間隔)・テキスト入力・プロパティ入力が1エントリにまとまる
78
79
  * - 意図的に間を空けた操作(400ms超)は別エントリのまま残る
79
80
  * ドラッグ/リサイズはmouseupで1回しかpushされないため、この窓に依存せず元から1エントリ。
81
+ * 値は utils/history-timing.ts の COALESCE_WINDOW_MS(数字キーの2桁入力と共有)。
80
82
  */
81
- const COALESCE_WINDOW_MS = 400;
82
83
 
83
84
  /**
84
85
  * 履歴に残してはいけないエディタUIのノード。
@@ -156,6 +157,10 @@ function normalizeSnapshot(rawHtml: string): { html: string; signature: string }
156
157
  node.classList.remove(cls);
157
158
  });
158
159
  });
160
+ // レイヤーパネルの行のホバー強調(属性)も一時状態。焼き付けると ⌘Z で強調が戻ってくる
161
+ template.content.querySelectorAll('[data-layer-hover]').forEach((node) => {
162
+ node.removeAttribute('data-layer-hover');
163
+ });
159
164
  // 空になったclass属性は落とす。
160
165
  // 「元から空」と「剥がして空になった」が同じ表記になるようにして、
161
166
  // 見た目が同じなのに別状態と判定される(=何も起きないUndoが増える)のを防ぐ。
@@ -76,6 +76,14 @@ export const RULER_SIZE = 20;
76
76
  const VIEW_ANIMATION_MS = 240;
77
77
  /** 見るだけの紙面を同時に読む枚数 */
78
78
  const PREVIEW_PARALLEL = 4;
79
+
80
+ /** 主スレッドが空いたときに 1 回呼ぶ(requestIdleCallback が無い環境は次のタスク)。待つのは最大 200ms */
81
+ function scheduleIdle(cb: () => void): void {
82
+ const w = typeof window !== 'undefined' ? (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }) : null;
83
+ if (w?.requestIdleCallback) w.requestIdleCallback(cb, { timeout: 200 });
84
+ else setTimeout(cb, 16);
85
+ }
86
+
79
87
  /**
80
88
  * 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
81
89
  * どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
@@ -1131,6 +1139,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOpt
1131
1139
  // 保存と読み込みは利用側(FrontendVisualEditor.handleContentChange)が 1 か所で行う。
1132
1140
  // ここでは「どこへ移ろうとしているか」を持ち、リングを先に動かして押した手応えを返す
1133
1141
  const [activatingPageId, setActivatingPageId] = useState<string | null>(null);
1142
+ // 移っている最中の数。activatePage が重なっても、全部が終わるまで 0 に戻らない(boolean だと先に終わった側が戻してしまう)
1143
+ const activatingCountRef = useRef(0);
1134
1144
  const onContentChangeRef = useRef(artboard.onContentChange);
1135
1145
  onContentChangeRef.current = artboard.onContentChange;
1136
1146
  const activatePage = useCallback<MultiPageCanvasContextValue['activatePage']>(
@@ -1146,14 +1156,19 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOpt
1146
1156
  // この切替は終わっているので、state ではなく ref で残す
1147
1157
  editorInitiatedPageIdRef.current = id;
1148
1158
  setActivatingPageId(id);
1149
- if (options?.reveal) revealPage(id);
1150
1159
  let moved = false;
1160
+ // 移る先の本文を読むあいだは、見るだけの紙面を新しく載せない(編集するページを先に出す)。
1161
+ // 数を上げるのは try の直前。ここから先で例外が出ても finally で必ず下がる(下がらないと紙面が二度と載らない)
1162
+ activatingCountRef.current += 1;
1151
1163
  try {
1164
+ if (options?.reveal) revealPage(id);
1152
1165
  const result = await (notify as (contentId: string) => unknown)(id);
1153
1166
  moved = result !== false;
1154
1167
  return moved;
1155
1168
  } finally {
1156
1169
  setActivatingPageId((v) => (v === id ? null : v));
1170
+ activatingCountRef.current -= 1;
1171
+ pumpAfterInteractionRef.current();
1157
1172
  // 移れなかったら印を消す(残すと、あとで同じページへホスト発で移るときに視点が動かない)。
1158
1173
  // 別のページへ移ったあとなら消さない ── その印はそちらの切替のもの
1159
1174
  if (!moved && editorInitiatedPageIdRef.current === id) editorInitiatedPageIdRef.current = null;
@@ -1165,10 +1180,22 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOpt
1165
1180
  // ---- 操作状態
1166
1181
  const [isInteracting, setIsInteracting] = useState(false);
1167
1182
  const interactingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
1183
+ // パン・ズームの最中は見るだけの紙面を新しく載せない(下の pumpPreviewSlots)。
1184
+ // iframe を載せると本文の解析・スタイル・レイアウトがその段のフレームに乗り、
1185
+ // ズームアウトの途中で 50〜200ms のフレームが混じる(ヘッドレス 150 枚で実測)。
1186
+ // 止まったら(160ms 後)編集中のページに近い順に、空いた時間に 1 枚ずつ載せる。
1187
+ // それまでは骨組み(または画像)が見える。止まらずにパンし続けると、その間は 1 枚も増えない
1188
+ const interactingRef = useRef(false);
1189
+ const pumpAfterInteractionRef = useRef<() => void>(() => undefined);
1168
1190
  const markInteracting = useCallback(() => {
1191
+ interactingRef.current = true;
1169
1192
  setIsInteracting(true);
1170
1193
  if (interactingTimer.current) clearTimeout(interactingTimer.current);
1171
- interactingTimer.current = setTimeout(() => setIsInteracting(false), 160);
1194
+ interactingTimer.current = setTimeout(() => {
1195
+ interactingRef.current = false;
1196
+ setIsInteracting(false);
1197
+ pumpAfterInteractionRef.current();
1198
+ }, 160);
1172
1199
  }, []);
1173
1200
  useEffect(() => () => { if (interactingTimer.current) clearTimeout(interactingTimer.current); }, []);
1174
1201
 
@@ -1177,21 +1204,32 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOpt
1177
1204
  // ---- 見るだけの紙面の読み込み枠(同時に PREVIEW_PARALLEL 枚まで。編集中のページに近い順)
1178
1205
  const slotQueueRef = useRef<{ id: string; grant: () => void }[]>([]);
1179
1206
  const slotHoldersRef = useRef(new Set<string>());
1207
+ // 枠は 1 回に 1 枚だけ、主スレッドが空いたとき(requestIdleCallback)に渡す。
1208
+ // iframe を載せると本文の解析・スタイル・レイアウト・初回のラスタが同じ主スレッドに乗る。
1209
+ // 4 枚を同じフレームで渡すと 1 フレームに 4 枚分が積まれ、編集中のページの読み込みや
1210
+ // 直後のクリック・ページ移動の描画を押し出す(ヘッドレス 150 枚のページ移動で 30〜60ms のフレームが続いた)
1211
+ const pumpScheduledRef = useRef(false);
1180
1212
  const pumpPreviewSlots = useCallback(() => {
1181
1213
  const holders = slotHoldersRef.current;
1182
- const queue = slotQueueRef.current;
1183
- if (holders.size >= PREVIEW_PARALLEL || queue.length === 0) return;
1184
- const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
1185
- const indexOf = (id: string) => indexById.get(id) ?? 0;
1186
- const activeId = viewStore.get().activePageId;
1187
- const activeIndex = activeId ? indexOf(activeId) : 0;
1188
- queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
1189
- while (holders.size < PREVIEW_PARALLEL && queue.length > 0) {
1214
+ if (pumpScheduledRef.current) return;
1215
+ if (holders.size >= PREVIEW_PARALLEL || slotQueueRef.current.length === 0 || interactingRef.current || activatingCountRef.current > 0) return;
1216
+ pumpScheduledRef.current = true;
1217
+ scheduleIdle(() => {
1218
+ pumpScheduledRef.current = false;
1219
+ const queue = slotQueueRef.current;
1220
+ if (holders.size >= PREVIEW_PARALLEL || queue.length === 0 || interactingRef.current || activatingCountRef.current > 0) return;
1221
+ const indexById = new Map(pagesRef.current.map((p) => [p.id, p.index] as const));
1222
+ const indexOf = (id: string) => indexById.get(id) ?? 0;
1223
+ const activeId = viewStore.get().activePageId;
1224
+ const activeIndex = activeId ? indexOf(activeId) : 0;
1225
+ queue.sort((a, b) => Math.abs(indexOf(a.id) - activeIndex) - Math.abs(indexOf(b.id) - activeIndex));
1190
1226
  const next = queue.shift()!;
1191
1227
  holders.add(next.id);
1192
1228
  next.grant();
1193
- }
1229
+ pumpAfterInteractionRef.current();
1230
+ });
1194
1231
  }, [viewStore]);
1232
+ pumpAfterInteractionRef.current = pumpPreviewSlots;
1195
1233
  const requestPreviewSlot = useCallback<MultiPageCanvasContextValue['requestPreviewSlot']>(
1196
1234
  (id, grant) => {
1197
1235
  if (slotHoldersRef.current.has(id)) {