@growgroup/visual-editor 0.4.0 → 0.6.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 (41) hide show
  1. package/README.md +48 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +80 -17
  5. package/src/editor/collab/CollabPresence.tsx +172 -0
  6. package/src/editor/collab/binding.ts +269 -0
  7. package/src/editor/collab/color.ts +28 -0
  8. package/src/editor/collab/connection.ts +110 -0
  9. package/src/editor/collab/layer.ts +168 -0
  10. package/src/editor/collab/merge.ts +112 -0
  11. package/src/editor/collab/morph.ts +113 -0
  12. package/src/editor/collab/room.ts +82 -0
  13. package/src/editor/collab/runtime.ts +442 -0
  14. package/src/editor/collab/signals.ts +11 -0
  15. package/src/editor/collab/store.ts +192 -0
  16. package/src/editor/collab/useCollab.ts +266 -0
  17. package/src/editor/components/AiPromptPopover.tsx +3 -2
  18. package/src/editor/components/EditorCanvas.tsx +11 -0
  19. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +17 -8
  20. package/src/editor/components/multi-page/PageFramePreview.tsx +3 -1
  21. package/src/editor/components/ppt/PptComments.tsx +3 -8
  22. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  23. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  24. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  25. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  26. package/src/editor/contexts/MultiPageCanvasContext.tsx +187 -40
  27. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  28. package/src/editor/hooks/useIframeSetup.ts +2 -0
  29. package/src/editor/hooks/useResizablePanel.ts +3 -2
  30. package/src/editor/utils/debug.ts +2 -5
  31. package/src/editor/utils/dom-utils.ts +32 -1
  32. package/src/editor/utils/drop-target.ts +1 -1
  33. package/src/editor/utils/flex-reorder.ts +1 -1
  34. package/src/editor/utils/html-utils.ts +2 -0
  35. package/src/editor/utils/restore-flow.ts +1 -1
  36. package/src/editor/utils/smart-guides.ts +1 -1
  37. package/src/editor/utils/storage.ts +47 -0
  38. package/src/index.ts +15 -0
  39. package/src/io.ts +29 -0
  40. package/src/styles/skin.css +17 -0
  41. package/src/version.ts +1 -1
@@ -0,0 +1,266 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * エディタ本体(FrontendVisualEditorInner)と共同編集の実体をつなぐ。
5
+ *
6
+ * io.collab が無ければ何もしない(実体を読み込まず、リスナーも張らない)。
7
+ * あれば runtime.ts を動的に読み、
8
+ * - 編集中のページが変わったら部屋を付け替える
9
+ * - 初期化を終えた紙面をページの部屋の本文につなぐ(binding.ts)。履歴の present が変わるたびに送る
10
+ * - 選択(anchorValueOf の値)・カーソル(紙面の px)・打鍵中の要素を awareness に載せる
11
+ * - 他の人が送ったページの見るだけの紙面を、部屋の本文で読み直す(キャンバスのとき)
12
+ * - 他人の選択枠とカーソルを紙面に描く(layer.ts)
13
+ */
14
+
15
+ import { useEffect, useMemo, useRef, useState } from 'react';
16
+ import { io, type EditorCollab } from '../../io';
17
+ import { useEditorContext } from '../EditorContext';
18
+ import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
19
+ import { anchorValueOf } from '../components/ppt/PptComments';
20
+ import { getCleanHtml } from '../utils/html-utils';
21
+ import type { CollabDocBinding, CollabRuntime } from './runtime';
22
+ import { COLLAB_DOCUMENT_READY_ATTR, COLLAB_DOCUMENT_READY_EVENT, type CollabDocumentReadyDetail } from './signals';
23
+ import { COLLAB_DISABLED, collabStore, setCollabFlusher } from './store';
24
+ import { checkCollabRoom } from './room';
25
+ import { colorFor } from './color';
26
+ import { useCollabLayer } from './layer';
27
+
28
+ /** 他の人の rev が続けて届いたとき、読み直しを 1 回にまとめる(ms) */
29
+ const REV_DEBOUNCE_MS = 500;
30
+ /** 部屋に本文が無いとき(書き戻し役の書いたファイルから読み直す)、書き戻しの 1.5 秒を待つ(ms) */
31
+ const REV_FILE_DELAY_MS = 2000;
32
+ /** 見るだけの紙面の本文を部屋から読む同時数 */
33
+ const READ_PARALLEL = 2;
34
+
35
+ export type CollabController = {
36
+ config: EditorCollab;
37
+ /** まだ送っていない変更を今すぐ送る */
38
+ flush: () => void;
39
+ undo: () => void;
40
+ redo: () => void;
41
+ };
42
+
43
+ type ReadyDocument = CollabDocumentReadyDetail & { initialShared: string };
44
+
45
+ export function useCollab({ contentId, selectedIds }: { contentId: string | null; selectedIds: string[] }): CollabController | null {
46
+ // エディタを開いている間は io.collab を読み直さない(途中で渡し直されても部屋を作り直さない)
47
+ const [config] = useState<EditorCollab | null>(() => io().collab ?? null);
48
+ const { getIframeDoc, html, setHtml, contentList } = useEditorContext();
49
+ const multiPageCanvas = useMultiPageCanvasOptional();
50
+ const [runtime, setRuntime] = useState<CollabRuntime | null>(null);
51
+ const [ready, setReady] = useState<ReadyDocument | null>(null);
52
+ const bindingRef = useRef<CollabDocBinding | null>(null);
53
+ const setHtmlRef = useRef(setHtml);
54
+ setHtmlRef.current = setHtml;
55
+ const canvasRef = useRef(multiPageCanvas);
56
+ canvasRef.current = multiPageCanvas;
57
+ const contentListRef = useRef(contentList);
58
+ contentListRef.current = contentList;
59
+ const contentIdRef = useRef(contentId);
60
+ contentIdRef.current = contentId;
61
+
62
+ useEffect(() => {
63
+ if (!config) return;
64
+ // 中継が保存しない部屋名だと、繋がっているのに中身が消える。繋ぐ前に検査して、外れたら始めない
65
+ const room = checkCollabRoom(config.projectRoom);
66
+ if (!room.ok) {
67
+ console.warn(`[collab] 部屋名が中継に保存されない形なので共同編集を始めません: ${room.reason}`);
68
+ collabStore.set({
69
+ enabled: true,
70
+ self: { id: config.user.id, name: config.user.name || config.user.id, color: colorFor(config.user.id, config.user.color) },
71
+ requireBridge: !!config.requireBridge,
72
+ status: 'error',
73
+ roomError: room.reason,
74
+ });
75
+ return () => collabStore.set(COLLAB_DISABLED);
76
+ }
77
+ let alive = true;
78
+ let created: CollabRuntime | null = null;
79
+ import('./runtime').then(
80
+ (m) => {
81
+ if (!alive) return;
82
+ created = m.createCollabRuntime(config);
83
+ setRuntime(created);
84
+ },
85
+ (e) => console.warn('[collab] 共同編集を始められませんでした:', e),
86
+ );
87
+ return () => {
88
+ alive = false;
89
+ created?.destroy();
90
+ };
91
+ }, [config]);
92
+
93
+ // 初期化を終えた文書を受け取り、その時点の姿(種まきに使う)を控える。
94
+ // 先に済んでいた(実体の読み込みより前だった)ときは印から拾う
95
+ useEffect(() => {
96
+ if (!config) return;
97
+ const snapshot = (doc: Document) => {
98
+ try {
99
+ return config.encode ? config.encode(getCleanHtml(doc)) : getCleanHtml(doc);
100
+ } catch {
101
+ return '';
102
+ }
103
+ };
104
+ const onReady = (e: Event) => {
105
+ const detail = (e as CustomEvent<CollabDocumentReadyDetail>).detail;
106
+ if (detail?.doc) setReady({ doc: detail.doc, contentId: detail.contentId, initialShared: snapshot(detail.doc) });
107
+ };
108
+ window.addEventListener(COLLAB_DOCUMENT_READY_EVENT, onReady);
109
+ const doc = getIframeDoc();
110
+ if (doc?.documentElement?.hasAttribute(COLLAB_DOCUMENT_READY_ATTR)) {
111
+ const readyId = doc.documentElement.getAttribute(COLLAB_DOCUMENT_READY_ATTR) || null;
112
+ setReady((prev) => (prev?.doc === doc ? prev : { doc, contentId: readyId, initialShared: snapshot(doc) }));
113
+ }
114
+ return () => window.removeEventListener(COLLAB_DOCUMENT_READY_EVENT, onReady);
115
+ }, [config, getIframeDoc]);
116
+
117
+ useEffect(() => {
118
+ runtime?.setPage(contentId);
119
+ }, [runtime, contentId]);
120
+
121
+ // いま編集しているページの文書(ページ切替の途中は前の文書を使わない)
122
+ const doc = ready && ready.contentId === contentId ? ready.doc : null;
123
+
124
+ // 紙面 ⇄ ページの部屋の本文
125
+ useEffect(() => {
126
+ if (!runtime || !ready || !doc || !contentId) return;
127
+ const binding = runtime.bind({
128
+ doc,
129
+ contentId,
130
+ initialShared: ready.initialShared,
131
+ onApplied: (artboardHtml) => setHtmlRef.current(artboardHtml),
132
+ });
133
+ if (!binding) return;
134
+ bindingRef.current = binding;
135
+ setCollabFlusher(() => binding.flush());
136
+ return () => {
137
+ setCollabFlusher(null);
138
+ // 離れるページの見るだけの紙面を、部屋の本文の最新にしておく(共同編集中は保存しないので、保存後の追従が効かない)
139
+ const shared = binding.sharedHtml();
140
+ binding.destroy();
141
+ if (bindingRef.current === binding) bindingRef.current = null;
142
+ if (shared != null) canvasRef.current?.setPageHtml(contentId, shared);
143
+ };
144
+ }, [runtime, ready, doc, contentId]);
145
+
146
+ // 自分の変更: 履歴の present が変わるたび(notifyIframeChange / pushHistory / setHtml)。打鍵は binding が input で拾う
147
+ useEffect(() => {
148
+ bindingRef.current?.scheduleSend();
149
+ }, [html]);
150
+
151
+ // 他の人が本文を送ったページの、見るだけの紙面を読み直す(キャンバスのとき)
152
+ const hasCanvas = !!multiPageCanvas;
153
+ useEffect(() => {
154
+ if (!runtime || !config || !hasCanvas) return;
155
+ let alive = true;
156
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
157
+ const queue: string[] = [];
158
+ let running = 0;
159
+ const pump = () => {
160
+ while (alive && running < READ_PARALLEL && queue.length > 0) {
161
+ const id = queue.shift()!;
162
+ running += 1;
163
+ void runtime
164
+ .readShared(id)
165
+ .then((shared) => {
166
+ const canvas = canvasRef.current;
167
+ if (!alive || !canvas || id === contentIdRef.current) return;
168
+ if (shared != null) {
169
+ canvas.setPageHtml(id, shared);
170
+ } else if (io().loadContent) {
171
+ // 部屋に本文が無い = 書き戻し役がファイルに書いたものを読む。書き終わるのを待ってから
172
+ timers.set(`file:${id}`, setTimeout(() => canvasRef.current?.updatePageFrame(id, { html: null, stale: true }), REV_FILE_DELAY_MS));
173
+ }
174
+ })
175
+ .finally(() => {
176
+ running -= 1;
177
+ pump();
178
+ });
179
+ }
180
+ };
181
+ const off = runtime.onRemoteRev((route) => {
182
+ const id = contentListRef.current.find((c) => config.routeFor(c.id) === route)?.id;
183
+ if (!id || id === contentIdRef.current) return;
184
+ const prev = timers.get(id);
185
+ if (prev) clearTimeout(prev);
186
+ timers.set(
187
+ id,
188
+ setTimeout(() => {
189
+ timers.delete(id);
190
+ if (!queue.includes(id)) queue.push(id);
191
+ pump();
192
+ }, REV_DEBOUNCE_MS),
193
+ );
194
+ });
195
+ return () => {
196
+ alive = false;
197
+ off();
198
+ timers.forEach((timer) => clearTimeout(timer));
199
+ };
200
+ }, [runtime, config, hasCanvas]);
201
+
202
+ // 選択 → awareness
203
+ const selectionKey = selectedIds.join('\n');
204
+ useEffect(() => {
205
+ if (!runtime) return;
206
+ const artboard = doc?.getElementById('artboard');
207
+ if (!artboard) {
208
+ runtime.setSelection([]);
209
+ return;
210
+ }
211
+ const anchors: string[] = [];
212
+ for (const id of selectedIds) {
213
+ const el = artboard.querySelector<HTMLElement>(`[data-element-id="${CSS.escape(id)}"]`);
214
+ const anchor = el ? anchorValueOf(artboard, el) : null;
215
+ if (anchor && !anchors.includes(anchor)) anchors.push(anchor);
216
+ }
217
+ runtime.setSelection(anchors);
218
+ // eslint-disable-next-line react-hooks/exhaustive-deps
219
+ }, [runtime, doc, selectionKey]);
220
+
221
+ // カーソル(紙面の px)と打鍵中の要素 → awareness
222
+ useEffect(() => {
223
+ if (!runtime || !doc) return;
224
+ const artboard = doc.getElementById('artboard');
225
+ if (!artboard) return;
226
+ const onMove = (e: PointerEvent) => {
227
+ const r = artboard.getBoundingClientRect();
228
+ const scale = r.width / (artboard.offsetWidth || 1) || 1;
229
+ runtime.setCursor({ x: Math.round((e.clientX - r.left) / scale), y: Math.round((e.clientY - r.top) / scale) });
230
+ };
231
+ const onLeave = () => runtime.setCursor(null);
232
+ const updateEditing = () => {
233
+ const el = doc.querySelector<HTMLElement>('#artboard [contenteditable="true"]');
234
+ runtime.setEditing(el ? anchorValueOf(artboard, el) : null);
235
+ };
236
+ // focusout の時点ではまだ contenteditable が外れていないので、外れてから読む
237
+ const onFocusOut = () => setTimeout(updateEditing, 0);
238
+ doc.addEventListener('pointermove', onMove, { passive: true });
239
+ doc.documentElement.addEventListener('pointerleave', onLeave);
240
+ doc.addEventListener('focusin', updateEditing);
241
+ doc.addEventListener('focusout', onFocusOut);
242
+ return () => {
243
+ doc.removeEventListener('pointermove', onMove);
244
+ doc.documentElement.removeEventListener('pointerleave', onLeave);
245
+ doc.removeEventListener('focusin', updateEditing);
246
+ doc.removeEventListener('focusout', onFocusOut);
247
+ runtime.setCursor(null);
248
+ runtime.setEditing(null);
249
+ };
250
+ }, [runtime, doc]);
251
+
252
+ useCollabLayer(doc, !!runtime);
253
+
254
+ return useMemo<CollabController | null>(
255
+ () =>
256
+ config
257
+ ? {
258
+ config,
259
+ flush: () => bindingRef.current?.flush(),
260
+ undo: () => bindingRef.current?.undo(),
261
+ redo: () => bindingRef.current?.redo(),
262
+ }
263
+ : null,
264
+ [config],
265
+ );
266
+ }
@@ -11,6 +11,7 @@ import { Sparkles, X, Loader2, Send, Paperclip, FileImage, FileText, Trash2 } fr
11
11
  import { Button } from '../../components/ui/button';
12
12
  import { Textarea } from '../../components/ui/textarea';
13
13
  import type { AttachedFile } from '../../lib/agent/slide-agent/types';
14
+ import { readStorage, writeStorage } from '../utils/storage';
14
15
 
15
16
  // サポートするファイルタイプ
16
17
  const SUPPORTED_FILE_TYPES = {
@@ -44,7 +45,7 @@ export function AiPromptPopover({
44
45
  const [prompt, setPrompt] = useState('');
45
46
  // [移植時の追加] 生成に使うAIエンジン(ローカルCLI)を選択する
46
47
  const [engine, setEngine] = useState<'codex' | 'claude'>(() => {
47
- const saved = typeof window !== 'undefined' ? localStorage.getItem('gg-ai-engine') : null;
48
+ const saved = readStorage('gg-ai-engine');
48
49
  return saved === 'claude' ? 'claude' : 'codex';
49
50
  });
50
51
  const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
@@ -333,7 +334,7 @@ export function AiPromptPopover({
333
334
  type="button"
334
335
  onClick={() => {
335
336
  setEngine(e);
336
- localStorage.setItem('gg-ai-engine', e);
337
+ writeStorage('gg-ai-engine', e);
337
338
  }}
338
339
  className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
339
340
  engine === e
@@ -32,6 +32,8 @@ import { useMultiPageCanvasOptional } from "../contexts/MultiPageCanvasContext";
32
32
  import { applyDocumentAttributes } from "./multi-page/PageFramePreview";
33
33
  import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
34
34
  import { endContentSwitch } from "../autosave";
35
+ import { io } from "../../io";
36
+ import { COLLAB_DOCUMENT_READY_ATTR, COLLAB_DOCUMENT_READY_EVENT } from "../collab/signals";
35
37
  import { showPartDropIndicator, clearPartDropIndicator, findFlowInsertion } from "../utils/drop-target";
36
38
  import { debugLog } from '../utils/debug';
37
39
  import { useEditorComponents } from "../contexts/EditorComponentsContext";
@@ -937,6 +939,15 @@ export function EditorCanvas() {
937
939
  "[Canvas] Webpage mode: content height tracking initialized on artboard"
938
940
  );
939
941
  }
942
+ // 共同編集(io.collab)に「この文書は読み込みと初期化を終えた」と知らせる。
943
+ // 種まき・他人の変更の当て込みはここから(初期化の途中の姿で撒くと、人によって本文が食い違う)
944
+ if (io().collab) {
945
+ const readyContentId = currentContentIdRef.current ?? "";
946
+ iframeDoc.documentElement.setAttribute(COLLAB_DOCUMENT_READY_ATTR, readyContentId);
947
+ window.dispatchEvent(
948
+ new CustomEvent(COLLAB_DOCUMENT_READY_EVENT, { detail: { doc: iframeDoc, contentId: readyContentId || null } })
949
+ );
950
+ }
940
951
  });
941
952
 
942
953
  // 重要: クリーンアップ関数をrefに保存して、
@@ -38,8 +38,7 @@ import {
38
38
  useCanvasActivePageId,
39
39
  RULER_SIZE,
40
40
  FRAME_GAP,
41
- PREVIEW_IMAGE_ZOOM,
42
- PREVIEW_IMAGE_ZOOM_OUT,
41
+ PREVIEW_IMAGE_ZOOM_HYSTERESIS,
43
42
  type PageFrameLayout,
44
43
  } from '../../contexts/MultiPageCanvasContext';
45
44
  import { useEditorContext } from '../../EditorContext';
@@ -53,6 +52,7 @@ import { CanvasRulers } from './CanvasRulers';
53
52
  import { CanvasFrameDecor } from './CanvasFrameDecor';
54
53
  import { cn } from '../../../lib/utils';
55
54
  import { ArrowUpRight } from 'lucide-react';
55
+ import { CollabPageDots } from '../../collab/CollabPresence';
56
56
 
57
57
  /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
58
58
  const LABEL_HEIGHT = 20;
@@ -169,6 +169,8 @@ const FrameChrome = memo(function FrameChrome({ page, showLabel, showLink, isSel
169
169
  <span className="ed-frame-label-index">{page.index + 1}</span>
170
170
  <span className="ed-frame-label-title">{page.title || page.id}</span>
171
171
  {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
172
+ {/* 共同編集: このページに居る参加者の色の点(io.collab が無ければ何も出ない) */}
173
+ <CollabPageDots contentId={page.id} />
172
174
  {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
173
175
  </button>
174
176
  {page.href && showLink && (
@@ -218,6 +220,8 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
218
220
  rulersVisible,
219
221
  isInteracting,
220
222
  initialViewRestored,
223
+ getPreviewImageZoomCap,
224
+ previewImageCapVersion,
221
225
  } = canvas;
222
226
  // 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
223
227
  // 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
@@ -280,7 +284,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
280
284
  // (CSS が拾う。属性 1 つの書き換えで 26 枚がいっぺんに切り替わり、React は通らない)。
281
285
  // iframe を外してメモリを返すのは操作が止まってから(下の imageMode)。
282
286
  // 外す向き(画像 → iframe)もここではやらない ── 止まってから 0.5 以上で判定する
283
- if (el && z < PREVIEW_IMAGE_ZOOM_OUT && el.getAttribute('data-canvas-preview-image') !== '1') {
287
+ if (el && z < getPreviewImageZoomCap() * PREVIEW_IMAGE_ZOOM_HYSTERESIS && el.getAttribute('data-canvas-preview-image') !== '1') {
284
288
  el.setAttribute('data-canvas-preview-image', '1');
285
289
  }
286
290
  // 粗い倍率: 150ms に 1 回まで。末尾のタイマーで操作の終わりに必ず追いつく
@@ -295,7 +299,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
295
299
  unsubscribe();
296
300
  if (coarseTimer) clearTimeout(coarseTimer);
297
301
  };
298
- }, [viewStore]);
302
+ }, [viewStore, getPreviewImageZoomCap]);
299
303
 
300
304
  // エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
301
305
  useEffect(() => {
@@ -366,21 +370,26 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
366
370
  // ---- 紙面を画像に落とすか(大きく縮小している間だけ)
367
371
  // 判定は操作が止まってからしか動かさない。縮小の途中で 26 枚の iframe を差し替えると、
368
372
  // 軽くするための切替そのものが重いフレームを作る
369
- const [imageMode, setImageMode] = useState(() => viewStore.get().canvasZoom < PREVIEW_IMAGE_ZOOM_OUT);
373
+ const [imageMode, setImageMode] = useState(
374
+ () => viewStore.get().canvasZoom < getPreviewImageZoomCap() * PREVIEW_IMAGE_ZOOM_HYSTERESIS,
375
+ );
370
376
  const imageModeRef = useRef(imageMode);
371
377
  imageModeRef.current = imageMode;
378
+ // previewImageCapVersion は「画像の解像度が分かって上限が動いた」きっかけ。
379
+ // 止まったまま画像が読み終わった場合でも、ここで判定をやり直す
372
380
  useEffect(() => {
373
381
  if (isInteracting) return;
374
382
  const timer = setTimeout(() => {
375
383
  const z = viewStore.get().canvasZoom;
376
- // ヒステリシス: 画像 → iframe は 0.5 以上、iframe → 画像は 0.4 未満(境目で行き来しない)
377
- const next = imageModeRef.current ? z < PREVIEW_IMAGE_ZOOM : z < PREVIEW_IMAGE_ZOOM_OUT;
384
+ const cap = getPreviewImageZoomCap();
385
+ // ヒステリシス: 画像 → iframe は上限以上、iframe → 画像は上限 × 0.8 未満(境目で行き来しない)
386
+ const next = imageModeRef.current ? z < cap : z < cap * PREVIEW_IMAGE_ZOOM_HYSTERESIS;
378
387
  if (next !== imageModeRef.current) setImageMode(next);
379
388
  // iframe に戻す向きに決まったら、縮小の途中で立てた掛け金も外す(iframe がまた見える)
380
389
  if (!next) containerRef.current?.removeAttribute('data-canvas-preview-image');
381
390
  }, PREVIEW_MODE_SETTLE_MS);
382
391
  return () => clearTimeout(timer);
383
- }, [isInteracting, coarseZoom, viewStore]);
392
+ }, [isInteracting, coarseZoom, viewStore, getPreviewImageZoomCap, previewImageCapVersion]);
384
393
  // 画像を裏で先に読んでおくか(粗い倍率なので、段ごとには変わらない真偽値)
385
394
  const preloadImage = coarseZoom < PREVIEW_IMAGE_PRELOAD_ZOOM;
386
395
 
@@ -52,7 +52,7 @@ interface PageFramePreviewProps {
52
52
  }
53
53
 
54
54
  export const PageFramePreview = memo(function PageFramePreview({ page, rootRef, imageMode = false, preloadImage = false }: PageFramePreviewProps) {
55
- const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
55
+ const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes, reportThumbnailWidth } = useMultiPageCanvas();
56
56
  const documentAttributesRef = useRef(documentAttributes);
57
57
  documentAttributesRef.current = documentAttributes;
58
58
  const hostRef = useRef<HTMLDivElement>(null);
@@ -228,6 +228,8 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef,
228
228
  const img = e.currentTarget;
229
229
  if (!img.naturalWidth || !img.naturalHeight) return;
230
230
  setPageHeight(page.id, (page.size.width * img.naturalHeight) / img.naturalWidth, 'preview');
231
+ // 画像を出してよい倍率の上限は、いちばん粗い画像に合わせて決まる(にじませないため)
232
+ reportThumbnailWidth(page.id, img.naturalWidth);
231
233
  }}
232
234
  onError={() => setImageFailed(true)}
233
235
  />
@@ -23,6 +23,7 @@ import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
23
23
  import { commentAction, type SlideComment } from '../../../lib/deck';
24
24
  import { can, io, type CommentRect, type EditorDeck } from '../../../io';
25
25
  import { useResizablePanel } from '../../hooks/useResizablePanel';
26
+ import { readStorage, writeStorage } from '../../utils/storage';
26
27
  import { ConfirmDialog } from '../shell/ConfirmDialog';
27
28
  import { PPT_PALETTES, type PptTheme } from './PptChrome';
28
29
 
@@ -155,18 +156,12 @@ export function findAnchored(root: HTMLElement, value: string, label?: string):
155
156
  }
156
157
 
157
158
  export function loadAuthor(): string {
158
- try {
159
- return localStorage.getItem(AUTHOR_KEY) || '';
160
- } catch {
161
- return '';
162
- }
159
+ return readStorage(AUTHOR_KEY) || '';
163
160
  }
164
161
 
165
162
  /** 名前を記憶する。右パネルとコメントボードで同じ置き場を使う */
166
163
  export function storeAuthor(value: string): void {
167
- try {
168
- localStorage.setItem(AUTHOR_KEY, value);
169
- } catch { /* 記憶できなくても続行 */ }
164
+ writeStorage(AUTHOR_KEY, value);
170
165
  }
171
166
 
172
167
  /** 未解決コメント数(サムネイルのバッジ用) */
@@ -27,6 +27,8 @@ import { useEditorContext } from '../../EditorContext';
27
27
  import { getCleanHtml } from '../../utils/html-utils';
28
28
  import { startExport, waitForExport, downloadExport, type ExportFormat } from '../../../lib/export';
29
29
  import { can } from '../../../io';
30
+ import { CollabPresence, CollabStatusPill } from '../../collab/CollabPresence';
31
+ import { useCollabSelector, selectCollabPageActive, flushCollab } from '../../collab/store';
30
32
 
31
33
  export type EditorSaveStatus = 'saved' | 'dirty' | 'saving' | 'error';
32
34
 
@@ -108,6 +110,7 @@ export function EditorTopBar({
108
110
  const pal = palette ?? FIGMA_PALETTE;
109
111
  const share = SHARE_COLOR[variant];
110
112
  const { getIframeDoc, html, saving, hasChanges } = useEditorContext();
113
+ const collabPageActive = useCollabSelector(selectCollabPageActive);
111
114
  const [exporting, setExporting] = useState<string | null>(null);
112
115
 
113
116
  /**
@@ -116,6 +119,13 @@ export function EditorTopBar({
116
119
  */
117
120
  const handleSave = async () => {
118
121
  try {
122
+ // 共同編集でそのページの部屋に入っている間は、ファイルへ書くのは書き戻し役。
123
+ // ここでは「まだ送っていない変更を今すぐ送る」だけにして、二重に書かせない
124
+ if (collabPageActive) {
125
+ flushCollab();
126
+ toast.success('同期済み', { description: '共同編集中の変更は、書き戻し役がファイルへ保存します' });
127
+ return;
128
+ }
119
129
  const doc = getIframeDoc();
120
130
  // 保存してもエディタは閉じない。保存は作業の中断ではない
121
131
  await onSave(doc ? getCleanHtml(doc) : html);
@@ -216,7 +226,9 @@ export function EditorTopBar({
216
226
  {/* 右: UI固有の操作 / 自動保存 / 保存 / プレビュー / 共有 / …/ 閉じる */}
217
227
  <div className="flex shrink-0 items-center gap-1.5">
218
228
  {rightExtra}
219
- <SaveStatusPill variant={variant} status={saveStatus} pal={pal} />
229
+ <CollabPresence />
230
+ {/* 共同編集でページの部屋に入っている間は、保存の状態の代わりに接続状態を出す(ファイルに書くのは書き戻し役) */}
231
+ {collabPageActive ? <CollabStatusPill /> : <SaveStatusPill variant={variant} status={saveStatus} pal={pal} />}
220
232
  <button
221
233
  data-topbar="save"
222
234
  onClick={() => void handleSave()}
@@ -17,6 +17,7 @@ import { useDeck } from '../../../components/viewer/useDeck';
17
17
  import { DeckSlideRender } from '../../../components/DeckSlideRender';
18
18
  import { flushAutoSave } from '../../autosave';
19
19
  import { useMultiPageCanvasOptional } from '../../contexts/MultiPageCanvasContext';
20
+ import { CollabPageDots } from '../../collab/CollabPresence';
20
21
 
21
22
  /**
22
23
  * スライド描画はメモ化する。パネル幅の変更や他ページの選択で親が再レンダーしても、
@@ -120,6 +121,8 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
120
121
  )}
121
122
  </span>
122
123
  <span className="min-w-0 flex-1 self-center text-xs leading-relaxed">{s.title || "無題"}</span>
124
+ {/* 共同編集: このページに居る参加者の色の点(io.collab が無ければ何も出ない) */}
125
+ <span className="self-center"><CollabPageDots contentId={canvas?.pages[i]?.id ?? String(n)} /></span>
123
126
  </button>
124
127
  );
125
128
  })}
@@ -2,15 +2,15 @@
2
2
 
3
3
  import { createContext, useContext, useEffect, useState } from 'react';
4
4
  import type { PptTheme } from '../components/ppt/PptChrome';
5
+ import { readStorage, writeStorage } from '../utils/storage';
5
6
 
6
7
  const THEME_KEY = 'gg-editor:ppt-theme';
7
8
  export const EditorAppearanceContext = createContext<PptTheme | undefined>(undefined);
8
9
 
9
10
  export function initialEditorTheme(): PptTheme {
10
- try {
11
- const saved = localStorage.getItem(THEME_KEY);
12
- if (saved === 'light' || saved === 'dark') return saved;
13
- } catch { /* ストレージが使えなくてもOS設定で開く */ }
11
+ // ストレージが使えなくても(共有ドロップの opaque origin など)OS 設定で開く
12
+ const saved = readStorage(THEME_KEY);
13
+ if (saved === 'light' || saved === 'dark') return saved;
14
14
  return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
15
15
  }
16
16
 
@@ -28,7 +28,7 @@ export function useEditorTheme() {
28
28
  }, []);
29
29
  const toggleTheme = () => {
30
30
  const next = theme === 'dark' ? 'light' : 'dark';
31
- try { localStorage.setItem(THEME_KEY, next); } catch { /* 今回の表示には適用する */ }
31
+ writeStorage(THEME_KEY, next);
32
32
  setTheme(next);
33
33
  };
34
34
  return { theme, toggleTheme };
@@ -91,6 +91,8 @@ const EDITOR_CHROME_SELECTOR = [
91
91
  '.flex-drop-indicator',
92
92
  '.nesting-drop-indicator',
93
93
  '.auto-layout-drop-indicator',
94
+ // 共同編集の他人の選択枠・カーソル(io.collab があるときだけ描かれる)
95
+ '.gg-collab-layer',
94
96
  ].join(',');
95
97
 
96
98
  /**