@growgroup/visual-editor 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +90 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +5 -1
  5. package/src/editor/FrontendVisualEditor.tsx +26 -12
  6. package/src/editor/components/EditorCanvas.tsx +11 -0
  7. package/src/editor/components/EditorContextMenu.tsx +52 -2
  8. package/src/editor/components/EditorFooter.tsx +29 -6
  9. package/src/editor/components/EditorHeader.tsx +15 -2
  10. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  11. package/src/editor/components/LayerTreeItem.tsx +107 -3
  12. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  13. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  14. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
  15. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  16. package/src/editor/components/ppt/PptComments.tsx +98 -1
  17. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  19. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  20. package/src/editor/constants.ts +25 -0
  21. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
  23. package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
  24. package/src/editor/hooks/useCanvasControls.ts +43 -9
  25. package/src/editor/hooks/useDragResize.ts +205 -17
  26. package/src/editor/hooks/useElementActions.ts +119 -34
  27. package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
  28. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  29. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  30. package/src/editor/utils/align-elements.ts +49 -0
  31. package/src/editor/utils/canvas-gestures.ts +210 -0
  32. package/src/editor/utils/canvas-guides.ts +46 -0
  33. package/src/editor/utils/dom-utils.ts +12 -2
  34. package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
  35. package/src/editor/utils/history-timing.ts +9 -0
  36. package/src/editor/utils/html-utils.ts +4 -0
  37. package/src/editor/utils/lock.ts +24 -0
  38. package/src/editor/utils/smart-duplicate.ts +51 -0
  39. package/src/editor/utils/smart-guides.ts +232 -2
  40. package/src/hooks/useEditorShortcuts.ts +46 -2
  41. package/src/index.ts +5 -1
  42. package/src/io.ts +54 -0
  43. package/src/styles/skin.css +5 -0
  44. package/src/types/editor.ts +120 -1
  45. package/src/version.ts +1 -1
@@ -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
  });
@@ -45,6 +45,7 @@ import { useEditorContext } from '../../EditorContext';
45
45
  import { useInfiniteCanvas } from '../../hooks/useInfiniteCanvas';
46
46
  import { useCanvasMarquee } from '../../hooks/useCanvasMarquee';
47
47
  import { editorZoomApiRef } from '../../hooks/useCanvasControls';
48
+ import { selectionCanvasRect } from '../../utils/canvas-gestures';
48
49
  import { EditorAppearanceContext } from '../../contexts/EditorAppearanceContext';
49
50
  import { EditorCanvas } from '../EditorCanvas';
50
51
  import { PageFramePreview } from './PageFramePreview';
@@ -226,7 +227,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
226
227
  // 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
227
228
  // 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
228
229
  const activePageId = useCanvasActivePageId();
229
- const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
230
+ const { setZoom, setFitZoom, activeTool, iframeReady, iframeRef } = useEditorContext();
230
231
  const theme = useContext(EditorAppearanceContext) ?? 'light';
231
232
  const containerRef = useRef<HTMLDivElement>(null);
232
233
  const layerRef = useRef<HTMLDivElement>(null);
@@ -339,20 +340,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
339
340
  setFitZoom(100);
340
341
  }, [setZoom, setFitZoom]);
341
342
 
342
- // Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける
343
+ // Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける。
344
+ // ⌘2 / ⇧2 は既定で編集中のページ。`canvasZoomOptions.fitShortcut: 'selection'` なら Figma と同じく
345
+ // 選んでいる要素があればそこへ、無ければ編集中のページへ(0.10.0)
346
+ const { zoomToRect, zoomOptions } = canvas;
347
+ const fitsSelection = zoomOptions.fitShortcut === 'selection';
343
348
  useEffect(() => {
344
349
  editorZoomApiRef.current = {
345
350
  fit: () => zoomToFit({ animate: true }),
346
351
  actual: () => zoomToActual(),
347
352
  selection: () => {
348
- if (activePageId) zoomToPage(activePageId, { animate: true });
353
+ const frame = pages.find((page) => page.id === activePageId);
354
+ const rect = fitsSelection ? selectionCanvasRect(iframeRef.current, frame?.position) : null;
355
+ if (rect) zoomToRect(rect, { animate: true });
356
+ else if (activePageId) zoomToPage(activePageId, { animate: true });
349
357
  else zoomToFit({ animate: true });
350
358
  },
351
359
  };
352
360
  return () => {
353
361
  editorZoomApiRef.current = null;
354
362
  };
355
- }, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
363
+ }, [zoomToFit, zoomToActual, zoomToPage, zoomToRect, fitsSelection, activePageId, pages, iframeRef]);
356
364
 
357
365
  // 初回の表示: 容器の大きさとフレームの並びが揃ったところで 1 回(決め方は applyInitialView)
358
366
  useEffect(() => {
@@ -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>}
@@ -21,7 +21,7 @@ import { useEditorContext } from '../../EditorContext';
21
21
  import { useCanvasViewStateOptional } from '../../contexts/MultiPageCanvasContext';
22
22
  import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
23
23
  import { commentAction, type SlideComment } from '../../../lib/deck';
24
- import { can, io, type CommentRect, type EditorDeck } from '../../../io';
24
+ import { can, io, type CommentRect, type EditorCommentActionResult, type EditorDeck } from '../../../io';
25
25
  import { useResizablePanel } from '../../hooks/useResizablePanel';
26
26
  import { readStorage, writeStorage } from '../../utils/storage';
27
27
  import { ConfirmDialog } from '../shell/ConfirmDialog';
@@ -696,6 +696,76 @@ export function PptCommentsPanel({
696
696
  }
697
697
  }, []);
698
698
 
699
+ /**
700
+ * 利用側が足した操作(io.commentActions)の進行状況。キーは `スレッド操作id:コメントid` /
701
+ * `パネル操作id:@page`。
702
+ *
703
+ * 共通の run() には載せない(「AIで修正」と同じ理由): 利用側の操作は外部に依頼して長くかかりうるので、
704
+ * 走らせている間ほかのコメント操作まで止めない。結果と失敗は押した場所の下に出す。
705
+ */
706
+ const [hostActions, setHostActions] = useState<Record<string, { running: boolean; message?: string; error?: string }>>({});
707
+ const hostRunning = useRef(new Set<string>());
708
+ const runHostAction = useCallback(async (key: string, fn: () => Promise<EditorCommentActionResult | void>) => {
709
+ if (hostRunning.current.has(key)) return;
710
+ hostRunning.current.add(key);
711
+ setHostActions((prev) => ({ ...prev, [key]: { running: true } }));
712
+ try {
713
+ const result = await fn();
714
+ if (result?.deck) applyDeck(result.deck);
715
+ setHostActions((prev) => ({ ...prev, [key]: { running: false, message: result?.message } }));
716
+ } catch (e) {
717
+ setHostActions((prev) => ({ ...prev, [key]: { running: false, error: String(e instanceof Error ? e.message : e).slice(0, 200) } }));
718
+ } finally {
719
+ hostRunning.current.delete(key);
720
+ }
721
+ }, []);
722
+
723
+ /** 押した場所の下に出す知らせ・失敗(利用側の操作) */
724
+ const renderHostActionStatus = (key: string) => {
725
+ const state = hostActions[key];
726
+ if (!state || state.running || (!state.message && !state.error)) return null;
727
+ return (
728
+ <p key={`${key}:status`} role={state.error ? 'alert' : 'status'} className="w-full text-[11px] leading-snug" style={{ color: state.error ? '#d13438' : pal.sub }}>
729
+ {state.error ?? state.message}
730
+ </p>
731
+ );
732
+ };
733
+
734
+ /** 利用側がスレッドに足した操作。渡されていなければ何も出さない */
735
+ const renderThreadActions = (c: SlideComment) => {
736
+ const shown = (io().commentActions?.thread ?? []).filter((action) => (action.when ? action.when(c, { page }) : !c.resolved));
737
+ if (shown.length === 0) return null;
738
+ return (
739
+ <div className="mt-2 flex flex-wrap items-center gap-1.5" data-comment-actions>
740
+ {shown.map((action) => {
741
+ const key = `${action.id}:${c.id}`;
742
+ const running = hostActions[key]?.running === true;
743
+ return (
744
+ <button
745
+ key={action.id}
746
+ type="button"
747
+ data-comment-action={action.id}
748
+ disabled={running}
749
+ aria-busy={running}
750
+ onClick={(e) => { e.stopPropagation(); void runHostAction(key, () => action.run({ page, comment: c })); }}
751
+ title={action.title}
752
+ className="flex items-center gap-1 rounded border px-1.5 py-1 text-[11px] transition-colors"
753
+ style={{ borderColor: pal.border, color: 'var(--ed-accent)' }}
754
+ >
755
+ {running ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
756
+ {action.label}
757
+ </button>
758
+ );
759
+ })}
760
+ {shown.map((action) => renderHostActionStatus(`${action.id}:${c.id}`))}
761
+ </div>
762
+ );
763
+ };
764
+
765
+ /** 利用側がパネル上部に足した、いまのページ全体への操作 */
766
+ const panelActions = (io().commentActions?.panel ?? []).filter((action) =>
767
+ action.when ? action.when({ page, comments }) : unresolvedCount(comments) > 0);
768
+
699
769
  /** 選択中の要素からアンカーを拾う(選択なしならページ全体へのコメント) */
700
770
  const currentAnchor = useMemo(() => {
701
771
  const doc = getIframeDoc();
@@ -851,6 +921,8 @@ export function PptCommentsPanel({
851
921
  </div>
852
922
  )}
853
923
 
924
+ {renderThreadActions(c)}
925
+
854
926
  {replyFor === c.id ? (
855
927
  <div className="mt-2 flex items-end gap-1.5">
856
928
  <textarea
@@ -932,6 +1004,31 @@ export function PptCommentsPanel({
932
1004
  すべてAIで修正({unresolvedTotal})
933
1005
  </button>
934
1006
  )}
1007
+ {panelActions.length > 0 && (
1008
+ <div className="my-2 flex flex-wrap items-center gap-1.5" data-comment-panel-actions>
1009
+ {panelActions.map((action) => {
1010
+ const key = `${action.id}:@${page}`;
1011
+ const running = hostActions[key]?.running === true;
1012
+ return (
1013
+ <button
1014
+ key={action.id}
1015
+ type="button"
1016
+ data-comment-panel-action={action.id}
1017
+ disabled={running}
1018
+ aria-busy={running}
1019
+ onClick={() => void runHostAction(key, () => action.run({ page, comments }))}
1020
+ title={action.title}
1021
+ className="flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px]"
1022
+ style={{ borderColor: pal.border, color: pal.text }}
1023
+ >
1024
+ {running ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
1025
+ {action.label}
1026
+ </button>
1027
+ );
1028
+ })}
1029
+ {panelActions.map((action) => renderHostActionStatus(`${action.id}:@${page}`))}
1030
+ </div>
1031
+ )}
935
1032
  </div>
936
1033
  {(fixAll?.running || fixAll?.error) && (
937
1034
  <div className="border-b px-3 py-1.5 text-[11px]" style={{ borderColor: pal.border, color: fixAll.error ? '#f66' : pal.sub }}>
@@ -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
+ }
@@ -10,6 +10,15 @@ export const SLIDE_HEIGHT = 1080;
10
10
  export const WEBPAGE_WIDTH = 1440;
11
11
  export const WEBPAGE_MIN_HEIGHT = 900; // 最小高さ
12
12
 
13
+ /**
14
+ * 紙面(#artboard)の外、上下に利用側が並べる「表示だけの領域」の目印(0.10.0)。
15
+ * 生きているエディタの文書で #artboard の兄弟(#artboard-wrapper の中)に置いた要素にこの属性を付けると、
16
+ * マルチフレームのキャンバスのフレームの高さに入る(付けないと紙面の高さで切れる)。
17
+ * 保存・共同編集が送るのは #artboard の中身だけなので、ここに置いたものは本文に混ざらない。
18
+ * 使い方は docs/frame-regions-2026-09.md
19
+ */
20
+ export const FRAME_REGION_ATTRIBUTE = 'data-editor-frame-region';
21
+
13
22
  // レスポンシブブレイクポイントプリセット(webpageモード用)
14
23
  export const BREAKPOINT_PRESETS = [
15
24
  { id: 'default', name: 'デフォルト', width: 1400, icon: 'monitor', minRange: 1190, maxRange: 1920 },
@@ -207,6 +216,16 @@ export const EDITOR_IFRAME_STYLES = `
207
216
  pointer-events: auto !important;
208
217
  cursor: pointer;
209
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
+ }
210
229
  .drawing-preview {
211
230
  position: absolute;
212
231
  pointer-events: none;
@@ -400,6 +419,12 @@ export const EDITOR_IFRAME_STYLES = `
400
419
  * ルートを選んだら枠・ハンドル・サイズラベルが紫、中のスロットを選んだら枠は青のまま
401
420
  * 「部品の中」の札だけ出す。札はパンくず(上 -22px)の左上に重ならないよう、その上に置く
402
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
+ }
403
428
  .selection-box.selection-part .selection-outline { box-shadow: 0 0 0 var(--outline-w) #7b61ff; }
404
429
  .selection-box.selection-part .resize-handle::after { border-color: #7b61ff; }
405
430
  .selection-box.selection-part .size-label { background: #7b61ff; }