@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.
- package/README.md +48 -1
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +80 -17
- package/src/editor/collab/CollabPresence.tsx +172 -0
- package/src/editor/collab/binding.ts +269 -0
- package/src/editor/collab/color.ts +28 -0
- package/src/editor/collab/connection.ts +110 -0
- package/src/editor/collab/layer.ts +168 -0
- package/src/editor/collab/merge.ts +112 -0
- package/src/editor/collab/morph.ts +113 -0
- package/src/editor/collab/room.ts +82 -0
- package/src/editor/collab/runtime.ts +442 -0
- package/src/editor/collab/signals.ts +11 -0
- package/src/editor/collab/store.ts +192 -0
- package/src/editor/collab/useCollab.ts +266 -0
- package/src/editor/components/AiPromptPopover.tsx +3 -2
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +17 -8
- package/src/editor/components/multi-page/PageFramePreview.tsx +3 -1
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +187 -40
- package/src/editor/hooks/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +2 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +32 -1
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- package/src/editor/utils/html-utils.ts +2 -0
- package/src/editor/utils/restore-flow.ts +1 -1
- package/src/editor/utils/smart-guides.ts +1 -1
- package/src/editor/utils/storage.ts +47 -0
- package/src/index.ts +15 -0
- package/src/io.ts +29 -0
- package/src/styles/skin.css +17 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
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(
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
31
|
+
writeStorage(THEME_KEY, next);
|
|
32
32
|
setTheme(next);
|
|
33
33
|
};
|
|
34
34
|
return { theme, toggleTheme };
|