@growgroup/visual-editor 0.4.1 → 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 +47 -0
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +71 -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 +3 -0
- 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 +14 -11
- 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/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に保存して、
|
|
@@ -52,6 +52,7 @@ import { CanvasRulers } from './CanvasRulers';
|
|
|
52
52
|
import { CanvasFrameDecor } from './CanvasFrameDecor';
|
|
53
53
|
import { cn } from '../../../lib/utils';
|
|
54
54
|
import { ArrowUpRight } from 'lucide-react';
|
|
55
|
+
import { CollabPageDots } from '../../collab/CollabPresence';
|
|
55
56
|
|
|
56
57
|
/** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
|
|
57
58
|
const LABEL_HEIGHT = 20;
|
|
@@ -168,6 +169,8 @@ const FrameChrome = memo(function FrameChrome({ page, showLabel, showLink, isSel
|
|
|
168
169
|
<span className="ed-frame-label-index">{page.index + 1}</span>
|
|
169
170
|
<span className="ed-frame-label-title">{page.title || page.id}</span>
|
|
170
171
|
{page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
|
|
172
|
+
{/* 共同編集: このページに居る参加者の色の点(io.collab が無ければ何も出ない) */}
|
|
173
|
+
<CollabPageDots contentId={page.id} />
|
|
171
174
|
{isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
|
|
172
175
|
</button>
|
|
173
176
|
{page.href && showLink && (
|
|
@@ -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 };
|
|
@@ -40,6 +40,7 @@ import { useEditorView } from './EditorViewContext';
|
|
|
40
40
|
import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
|
|
41
41
|
import type { EditorMode } from './EditorToolContext';
|
|
42
42
|
import { io, type PreviewStyle } from '../../io';
|
|
43
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
43
44
|
|
|
44
45
|
// ============================================================
|
|
45
46
|
// 定数
|
|
@@ -220,6 +221,11 @@ export interface MultiPageCanvasContextValue {
|
|
|
220
221
|
zoomToActual: () => void;
|
|
221
222
|
/** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
|
|
222
223
|
revealPage: (id: string) => void;
|
|
224
|
+
/**
|
|
225
|
+
* そのページへ視点を寄せる。見えていなければ寄せ、点にしか見えない倍率ならページに合わせる
|
|
226
|
+
* (ホスト発のページ切替・共同編集のアバターのクリックで使う)
|
|
227
|
+
*/
|
|
228
|
+
focusPage: (id: string) => void;
|
|
223
229
|
|
|
224
230
|
// ---- ページ切替
|
|
225
231
|
/**
|
|
@@ -504,9 +510,9 @@ interface MultiPageCanvasProviderProps {
|
|
|
504
510
|
type PersistedView = { zoom: number; offset: { x: number; y: number }; rulers?: boolean };
|
|
505
511
|
|
|
506
512
|
function readPersistedView(key: string | null | undefined): PersistedView | null {
|
|
507
|
-
if (!key
|
|
513
|
+
if (!key) return null;
|
|
508
514
|
try {
|
|
509
|
-
const raw =
|
|
515
|
+
const raw = readStorage(`gg-editor:canvas-view:${key}`);
|
|
510
516
|
if (!raw) return null;
|
|
511
517
|
const v = JSON.parse(raw) as PersistedView;
|
|
512
518
|
if (!Number.isFinite(v.zoom) || !v.offset || !Number.isFinite(v.offset.x) || !Number.isFinite(v.offset.y)) return null;
|
|
@@ -673,19 +679,15 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
673
679
|
|
|
674
680
|
// 見えている範囲の保存(変化が止まってから)
|
|
675
681
|
useEffect(() => {
|
|
676
|
-
if (!storageKey
|
|
682
|
+
if (!storageKey) return;
|
|
677
683
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
678
684
|
const persist = () => {
|
|
679
685
|
if (timer) clearTimeout(timer);
|
|
680
686
|
timer = setTimeout(() => {
|
|
681
687
|
timer = null;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
localStorage.setItem(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
|
|
686
|
-
} catch {
|
|
687
|
-
/* 保存できなくても表示には影響しない */
|
|
688
|
-
}
|
|
688
|
+
const { canvasZoom, canvasOffset } = viewStore.get();
|
|
689
|
+
const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
|
|
690
|
+
writeStorage(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
|
|
689
691
|
}, 250);
|
|
690
692
|
};
|
|
691
693
|
const unsubscribe = viewStore.subscribe(persist);
|
|
@@ -1121,6 +1123,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1121
1123
|
zoomToPage,
|
|
1122
1124
|
zoomToActual,
|
|
1123
1125
|
revealPage,
|
|
1126
|
+
focusPage,
|
|
1124
1127
|
activatePage,
|
|
1125
1128
|
activatingPageId,
|
|
1126
1129
|
registerContainer,
|
|
@@ -1139,7 +1142,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1139
1142
|
[
|
|
1140
1143
|
enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
|
|
1141
1144
|
setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
|
|
1142
|
-
zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1145
|
+
zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1143
1146
|
persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
|
|
1144
1147
|
getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
|
|
1145
1148
|
],
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
4
|
import { debugLog } from '../utils/debug';
|
|
5
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Google Fontの情報
|
|
@@ -140,8 +141,8 @@ export function useGoogleFonts() {
|
|
|
140
141
|
const fetchGoogleFonts = useCallback(async () => {
|
|
141
142
|
// キャッシュをチェック
|
|
142
143
|
try {
|
|
143
|
-
const cachedFonts =
|
|
144
|
-
const cacheExpiry =
|
|
144
|
+
const cachedFonts = readStorage(FONTS_CACHE_KEY);
|
|
145
|
+
const cacheExpiry = readStorage(FONTS_CACHE_EXPIRY_KEY);
|
|
145
146
|
|
|
146
147
|
if (cachedFonts && cacheExpiry && Date.now() < parseInt(cacheExpiry, 10)) {
|
|
147
148
|
const parsed = JSON.parse(cachedFonts) as GoogleFont[];
|
|
@@ -197,12 +198,9 @@ export function useGoogleFonts() {
|
|
|
197
198
|
}));
|
|
198
199
|
|
|
199
200
|
// キャッシュに保存
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
} catch {
|
|
204
|
-
// ストレージが一杯の場合は無視
|
|
205
|
-
}
|
|
201
|
+
// ストレージが一杯・使えない場合は黙って諦める
|
|
202
|
+
writeStorage(FONTS_CACHE_KEY, JSON.stringify(googleFonts));
|
|
203
|
+
writeStorage(FONTS_CACHE_EXPIRY_KEY, String(Date.now() + CACHE_DURATION));
|
|
206
204
|
|
|
207
205
|
setFonts([...SYSTEM_FONTS, ...googleFonts]);
|
|
208
206
|
} catch (err) {
|
|
@@ -112,6 +112,8 @@ export function useIframeSetup(): UseIframeSetupReturn {
|
|
|
112
112
|
if (element.classList.contains('rotation-handle')) return;
|
|
113
113
|
// selection-boxの子孫要素をすべてスキップ(念のため)
|
|
114
114
|
if (element.closest('.selection-box')) return;
|
|
115
|
+
// 共同編集の他人の選択枠・カーソル(表示専用の層)
|
|
116
|
+
if (element.closest('.gg-collab-layer')) return;
|
|
115
117
|
|
|
116
118
|
// 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
|
|
117
119
|
// ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
4
5
|
|
|
5
6
|
export interface UseResizablePanelOptions {
|
|
6
7
|
/** 初期幅 */
|
|
@@ -54,7 +55,7 @@ export function useResizablePanel({
|
|
|
54
55
|
const getInitialWidth = () => {
|
|
55
56
|
if (storageKey && typeof window !== 'undefined') {
|
|
56
57
|
let stored: string | null = null;
|
|
57
|
-
|
|
58
|
+
stored = readStorage(storageKey);
|
|
58
59
|
if (stored) {
|
|
59
60
|
const parsed = parseInt(stored, 10);
|
|
60
61
|
if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
|
|
@@ -80,7 +81,7 @@ export function useResizablePanel({
|
|
|
80
81
|
// ドラッグ中の同期ストレージ書込みを避け、確定時だけ幅を記憶する。
|
|
81
82
|
useEffect(() => {
|
|
82
83
|
if (isDragging || !storageKey) return;
|
|
83
|
-
|
|
84
|
+
writeStorage(storageKey, String(width));
|
|
84
85
|
}, [width, isDragging, storageKey]);
|
|
85
86
|
|
|
86
87
|
// マウスダウン
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const DEBUG_KEY = 'gg-editor:debug';
|
|
19
|
+
import { readStorage } from './storage';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* デバッグログを出す設定か。
|
|
@@ -23,11 +24,7 @@ const DEBUG_KEY = 'gg-editor:debug';
|
|
|
23
24
|
* 参照そのものが例外を投げるので、必ず握りつぶして false に倒す。
|
|
24
25
|
*/
|
|
25
26
|
export function isDebugEnabled(): boolean {
|
|
26
|
-
|
|
27
|
-
return typeof localStorage !== 'undefined' && localStorage.getItem(DEBUG_KEY) === '1';
|
|
28
|
-
} catch {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
27
|
+
return readStorage(DEBUG_KEY) === '1';
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
/** 内部の様子を見るためのログ。既定では出ない */
|
|
@@ -22,7 +22,7 @@ const INDICATOR_CLASS = `selection-box ${PART_DROP_INDICATOR_CLASS}`;
|
|
|
22
22
|
|
|
23
23
|
/** 紙面の中身ではなく、エディタが差し込んでいる表示専用のノード */
|
|
24
24
|
const EDITOR_CHROME =
|
|
25
|
-
'.selection-box,.marquee-selection-box,.gg-comment-layer,.gg-crop-ui,' +
|
|
25
|
+
'.selection-box,.marquee-selection-box,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
|
|
26
26
|
'#gg-measure-layer,#gg-smart-guides,.flex-drop-indicator,.nesting-drop-indicator,' +
|
|
27
27
|
'.auto-layout-drop-indicator,.drag-ghost';
|
|
28
28
|
|
|
@@ -27,7 +27,7 @@ const BLUE = '#0d99ff';
|
|
|
27
27
|
/** 並べ替えの対象にしない、エディタが描いている飾り */
|
|
28
28
|
const OVERLAY_SELECTOR =
|
|
29
29
|
'.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
|
|
30
|
-
'.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
|
|
30
|
+
'.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
|
|
31
31
|
'.flex-drop-indicator,#gg-measure-layer,#gg-smart-guides,#' + INDICATOR_ID;
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -364,6 +364,8 @@ export function getCleanHtml(doc: Document): string {
|
|
|
364
364
|
|
|
365
365
|
// コメントの吹き出しレイヤー(表示専用)を削除
|
|
366
366
|
tempContainer.querySelectorAll('.gg-comment-layer').forEach(el => el.remove());
|
|
367
|
+
// 共同編集の他人の選択枠・カーソル(表示専用)も保存しない(共有する本文にも混ぜない)
|
|
368
|
+
tempContainer.querySelectorAll('.gg-collab-layer').forEach(el => el.remove());
|
|
367
369
|
// Alt計測の赤い線(表示専用)も保存しない
|
|
368
370
|
tempContainer.querySelectorAll('#gg-measure-layer').forEach(el => el.remove());
|
|
369
371
|
// ドラッグ中の整列ガイド(表示専用)も保存しない
|