@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,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共同編集の実体。io.collab があるときだけ動的に読まれる(yjs / Hocuspocus / idiomorph / fast-diff を束ねる側)。
|
|
3
|
+
*
|
|
4
|
+
* - 案件の部屋: 居場所(`{ user, kind: "editor", contentId, route }`)・書き戻し役の数・`Y.Map("pages")` の rev
|
|
5
|
+
* - ページの部屋: いま編集中のページの本文(binding.ts)と、選択・カーソル・打鍵中の要素
|
|
6
|
+
* (`{ user, kind: "editor", selection, cursor, editing }`)
|
|
7
|
+
* 表示は store.ts に書くだけ。ヘッダーや紙面の描画はそれを購読する
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { WebSocketStatus } from '@hocuspocus/provider';
|
|
11
|
+
import type * as Y from 'yjs';
|
|
12
|
+
import type { EditorCollab } from '../../io';
|
|
13
|
+
import { acquireRoom, acquireSocket, peekRoom, releaseRoom, releaseSocket, type Room } from './connection';
|
|
14
|
+
import { colorFor } from './color';
|
|
15
|
+
import { checkCollabRoom } from './room';
|
|
16
|
+
import { PageBinding } from './binding';
|
|
17
|
+
import {
|
|
18
|
+
collabStore,
|
|
19
|
+
setCollabSharedReader,
|
|
20
|
+
type CollabPagePeer,
|
|
21
|
+
type CollabPeer,
|
|
22
|
+
type CollabStatus,
|
|
23
|
+
type CollabUser,
|
|
24
|
+
} from './store';
|
|
25
|
+
|
|
26
|
+
/** カーソルを送る間隔(ms) */
|
|
27
|
+
const CURSOR_THROTTLE_MS = 50;
|
|
28
|
+
/** 案件の部屋の pages の rev を進める間隔(ms。取り決め §5 の「1 秒に 1 回まで」) */
|
|
29
|
+
const REV_INTERVAL_MS = 1000;
|
|
30
|
+
/** 見るだけの紙面を読み直すために入った部屋を、抜けるまでの猶予(ms) */
|
|
31
|
+
const PREVIEW_ROOM_GRACE_MS = 15000;
|
|
32
|
+
/** 見るだけの紙面の本文を待つ上限(ms) */
|
|
33
|
+
const READ_SHARED_TIMEOUT_MS = 4000;
|
|
34
|
+
|
|
35
|
+
type AwarenessState = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
function toUser(raw: unknown): CollabUser | null {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
39
|
+
const { id, name, color } = raw as { id?: unknown; name?: unknown; color?: unknown };
|
|
40
|
+
if (typeof id !== 'string' || !id) return null;
|
|
41
|
+
const label = typeof name === 'string' && name.trim() ? name : id;
|
|
42
|
+
return { id, name: label, color: colorFor(id, typeof color === 'string' ? color : undefined) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toCursor(raw: unknown): { x: number; y: number } | null {
|
|
46
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
47
|
+
const { x, y } = raw as { x?: unknown; y?: unknown };
|
|
48
|
+
return typeof x === 'number' && typeof y === 'number' && Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function waitSynced(room: Room, timeoutMs: number): Promise<boolean> {
|
|
52
|
+
if (room.provider.synced) return Promise.resolve(true);
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const done = () => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
room.provider.off('synced', done);
|
|
57
|
+
resolve(room.provider.synced);
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(done, timeoutMs);
|
|
60
|
+
room.provider.on('synced', done);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PageSession = {
|
|
65
|
+
room: Room;
|
|
66
|
+
contentId: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** 編集中の紙面と部屋の本文の束縛(binding.ts)のうち、エディタから呼ぶもの */
|
|
70
|
+
export type CollabDocBinding = {
|
|
71
|
+
scheduleSend(): void;
|
|
72
|
+
flush(): void;
|
|
73
|
+
undo(): void;
|
|
74
|
+
redo(): void;
|
|
75
|
+
sharedHtml(): string | null;
|
|
76
|
+
destroy(): void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type CollabRuntime = {
|
|
80
|
+
readonly config: EditorCollab;
|
|
81
|
+
readonly self: CollabUser;
|
|
82
|
+
/** 編集中のページが変わった(null = どのページも編集していない) */
|
|
83
|
+
setPage(contentId: string | null): void;
|
|
84
|
+
setSelection(anchors: string[]): void;
|
|
85
|
+
setCursor(cursor: { x: number; y: number } | null): void;
|
|
86
|
+
setEditing(anchor: string | null): void;
|
|
87
|
+
/** いまのページの部屋。入っていなければ null */
|
|
88
|
+
page(): PageSession | null;
|
|
89
|
+
/**
|
|
90
|
+
* 初期化を終えた紙面をページの部屋の本文につなぐ。そのページの部屋に入っていなければ null。
|
|
91
|
+
* initialShared は読み込み直後の姿(encode 済み。種まきに使う)
|
|
92
|
+
*/
|
|
93
|
+
bind(options: { doc: Document; contentId: string; initialShared: string; onApplied: (artboardHtml: string) => void }): CollabDocBinding | null;
|
|
94
|
+
/** 他の人がそのページ(route)の本文を送った。登録した時点で rev のあるページにも 1 回ずつ呼ぶ */
|
|
95
|
+
onRemoteRev(listener: (route: string) => void): () => void;
|
|
96
|
+
/** そのページの部屋の本文(decode 済み)。部屋に入って同期を待つ。空・同期できなければ null */
|
|
97
|
+
readShared(contentId: string): Promise<string | null>;
|
|
98
|
+
destroy(): void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function createCollabRuntime(config: EditorCollab): CollabRuntime {
|
|
102
|
+
const self: CollabUser = { id: config.user.id, name: config.user.name || config.user.id, color: colorFor(config.user.id, config.user.color) };
|
|
103
|
+
const encode = config.encode ?? ((clean: string) => clean);
|
|
104
|
+
const decodeFor = (contentId: string) => (shared: string) => (config.decode ? config.decode(shared, contentId) : shared);
|
|
105
|
+
const socket = acquireSocket(config.url);
|
|
106
|
+
const project = acquireRoom(config.url, config.projectRoom, config.token);
|
|
107
|
+
const revOrigin = { collab: 'rev' };
|
|
108
|
+
let destroyed = false;
|
|
109
|
+
let currentContentId: string | null = null;
|
|
110
|
+
let page: (PageSession & { off: () => void }) | null = null;
|
|
111
|
+
let localPending = false;
|
|
112
|
+
let roomError: string | null = null;
|
|
113
|
+
let selection: string[] = [];
|
|
114
|
+
let editing: string | null = null;
|
|
115
|
+
let cursorLatest: { x: number; y: number } | null = null;
|
|
116
|
+
let cursorSent: string | undefined;
|
|
117
|
+
let cursorTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
|
|
119
|
+
collabStore.set({
|
|
120
|
+
enabled: true,
|
|
121
|
+
ready: false,
|
|
122
|
+
self,
|
|
123
|
+
requireBridge: !!config.requireBridge,
|
|
124
|
+
status: 'offline',
|
|
125
|
+
peers: [],
|
|
126
|
+
pagePeers: [],
|
|
127
|
+
bridges: 0,
|
|
128
|
+
pageActive: false,
|
|
129
|
+
pending: false,
|
|
130
|
+
unsynced: 0,
|
|
131
|
+
canUndo: false,
|
|
132
|
+
canRedo: false,
|
|
133
|
+
roomError: null,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 中継が保存する部屋名か。外れていれば null を返す(その部屋には繋がない)。
|
|
138
|
+
* report は編集中のページのときだけ true にする(見るだけの紙面の分で接続状態を赤くしない)
|
|
139
|
+
*/
|
|
140
|
+
const warned = new Set<string>();
|
|
141
|
+
const usableRoom = (name: string | null, report = false): string | null => {
|
|
142
|
+
if (!name) return null;
|
|
143
|
+
const room = checkCollabRoom(name);
|
|
144
|
+
if (room.ok) return name;
|
|
145
|
+
if (!warned.has(room.reason)) {
|
|
146
|
+
warned.add(room.reason);
|
|
147
|
+
console.warn(`[collab] 部屋名が中継に保存されない形なので、この部屋は共同編集しません: ${room.reason}`);
|
|
148
|
+
}
|
|
149
|
+
if (report) roomError = room.reason;
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const setProjectPresence = () => {
|
|
154
|
+
project.provider.awareness?.setLocalState({
|
|
155
|
+
user: self,
|
|
156
|
+
kind: 'editor',
|
|
157
|
+
contentId: currentContentId,
|
|
158
|
+
route: currentContentId ? config.routeFor(currentContentId) : null,
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const readPeers = () => {
|
|
163
|
+
const aw = project.provider.awareness;
|
|
164
|
+
if (!aw) return;
|
|
165
|
+
const peers: CollabPeer[] = [];
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
let bridges = 0;
|
|
168
|
+
const states = Array.from(aw.getStates().entries()).sort((a, b) => a[0] - b[0]);
|
|
169
|
+
for (const [clientId, raw] of states) {
|
|
170
|
+
if (clientId === aw.clientID || !raw) continue;
|
|
171
|
+
const state = raw as AwarenessState;
|
|
172
|
+
if (state.kind === 'bridge') {
|
|
173
|
+
bridges += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (state.kind !== 'editor') continue;
|
|
177
|
+
const user = toUser(state.user);
|
|
178
|
+
// 同じ人が別のタブで開いていても 1 人として出す(自分の別タブは出さない)
|
|
179
|
+
if (!user || user.id === self.id || seen.has(user.id)) continue;
|
|
180
|
+
seen.add(user.id);
|
|
181
|
+
peers.push({
|
|
182
|
+
clientId,
|
|
183
|
+
user,
|
|
184
|
+
contentId: typeof state.contentId === 'string' ? state.contentId : null,
|
|
185
|
+
route: typeof state.route === 'string' ? state.route : null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
collabStore.set({ peers, bridges });
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const readPagePeers = () => {
|
|
192
|
+
const aw = page?.room.provider.awareness;
|
|
193
|
+
if (!aw) {
|
|
194
|
+
collabStore.set({ pagePeers: [] });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const list: CollabPagePeer[] = [];
|
|
198
|
+
aw.getStates().forEach((raw, clientId) => {
|
|
199
|
+
if (clientId === aw.clientID || !raw) return;
|
|
200
|
+
const state = raw as AwarenessState;
|
|
201
|
+
if (state.kind !== 'editor') return;
|
|
202
|
+
const user = toUser(state.user);
|
|
203
|
+
if (!user) return;
|
|
204
|
+
list.push({
|
|
205
|
+
clientId,
|
|
206
|
+
user,
|
|
207
|
+
selection: Array.isArray(state.selection) ? state.selection.filter((v): v is string => typeof v === 'string') : [],
|
|
208
|
+
cursor: toCursor(state.cursor),
|
|
209
|
+
editing: typeof state.editing === 'string' ? state.editing : null,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
list.sort((a, b) => a.clientId - b.clientId);
|
|
213
|
+
collabStore.set({ pagePeers: list });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const refresh = () => {
|
|
217
|
+
if (destroyed) return;
|
|
218
|
+
const connected = socket.status === WebSocketStatus.Connected;
|
|
219
|
+
const target = page?.room ?? project;
|
|
220
|
+
const unsynced = page ? page.room.provider.unsyncedChanges : 0;
|
|
221
|
+
const status: CollabStatus = roomError
|
|
222
|
+
? 'error'
|
|
223
|
+
: !connected
|
|
224
|
+
? 'offline'
|
|
225
|
+
: !target.provider.synced || unsynced > 0 || localPending
|
|
226
|
+
? 'syncing'
|
|
227
|
+
: 'synced';
|
|
228
|
+
collabStore.set({ status, unsynced, roomError, ready: project.provider.synced || collabStore.get().ready });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const setLocalPending = (pending: boolean) => {
|
|
232
|
+
if (pending === localPending) return;
|
|
233
|
+
localPending = pending;
|
|
234
|
+
refresh();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ---- 案件の部屋の pages(rev)
|
|
238
|
+
const pagesMap = project.doc.getMap<unknown>('pages');
|
|
239
|
+
const revListeners = new Set<(route: string) => void>();
|
|
240
|
+
const onPages = (event: Y.YMapEvent<unknown>, tx: Y.Transaction) => {
|
|
241
|
+
if (tx.origin === revOrigin) return;
|
|
242
|
+
event.keysChanged.forEach((route) => revListeners.forEach((listener) => listener(route)));
|
|
243
|
+
};
|
|
244
|
+
pagesMap.observe(onPages);
|
|
245
|
+
const revTimers = new Map<string, { last: number; timer: ReturnType<typeof setTimeout> | null }>();
|
|
246
|
+
const writeRev = (route: string) => {
|
|
247
|
+
if (destroyed) return;
|
|
248
|
+
project.doc.transact(() => {
|
|
249
|
+
const prev = pagesMap.get(route) as { rev?: unknown } | undefined;
|
|
250
|
+
const rev = typeof prev?.rev === 'number' ? prev.rev + 1 : 1;
|
|
251
|
+
pagesMap.set(route, { rev, by: self.id, at: new Date().toISOString() });
|
|
252
|
+
}, revOrigin);
|
|
253
|
+
};
|
|
254
|
+
const bumpRev = (route: string) => {
|
|
255
|
+
const entry = revTimers.get(route) ?? { last: 0, timer: null };
|
|
256
|
+
revTimers.set(route, entry);
|
|
257
|
+
if (entry.timer) return;
|
|
258
|
+
const wait = REV_INTERVAL_MS - (Date.now() - entry.last);
|
|
259
|
+
if (wait <= 0) {
|
|
260
|
+
entry.last = Date.now();
|
|
261
|
+
writeRev(route);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
entry.timer = setTimeout(() => {
|
|
265
|
+
entry.timer = null;
|
|
266
|
+
entry.last = Date.now();
|
|
267
|
+
writeRev(route);
|
|
268
|
+
}, wait);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onProjectAwareness = () => readPeers();
|
|
272
|
+
project.provider.awareness?.on('change', onProjectAwareness);
|
|
273
|
+
project.provider.on('synced', refresh);
|
|
274
|
+
socket.on('status', refresh);
|
|
275
|
+
setProjectPresence();
|
|
276
|
+
readPeers();
|
|
277
|
+
refresh();
|
|
278
|
+
|
|
279
|
+
// 共同編集中のページ切替で、部屋に同期済みの本文があればそれを使わせる
|
|
280
|
+
setCollabSharedReader((contentId) => {
|
|
281
|
+
const name = usableRoom(config.roomFor(contentId));
|
|
282
|
+
const room = name ? peekRoom(config.url, name) : null;
|
|
283
|
+
if (!room || !room.provider.synced) return null;
|
|
284
|
+
const text = room.doc.getText('html').toString();
|
|
285
|
+
return text ? decodeFor(contentId)(text) : null;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const joinPage = (contentId: string, name: string) => {
|
|
289
|
+
const room = acquireRoom(config.url, name, config.token);
|
|
290
|
+
selection = [];
|
|
291
|
+
editing = null;
|
|
292
|
+
cursorLatest = null;
|
|
293
|
+
cursorSent = undefined;
|
|
294
|
+
room.provider.awareness?.setLocalState({ user: self, kind: 'editor', selection, cursor: null, editing });
|
|
295
|
+
const onAwareness = () => readPagePeers();
|
|
296
|
+
room.provider.awareness?.on('change', onAwareness);
|
|
297
|
+
room.provider.on('synced', refresh);
|
|
298
|
+
room.provider.on('unsyncedChanges', refresh);
|
|
299
|
+
page = {
|
|
300
|
+
room,
|
|
301
|
+
contentId,
|
|
302
|
+
off: () => {
|
|
303
|
+
room.provider.awareness?.off('change', onAwareness);
|
|
304
|
+
room.provider.off('synced', refresh);
|
|
305
|
+
room.provider.off('unsyncedChanges', refresh);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
readPagePeers();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const leavePage = () => {
|
|
312
|
+
if (!page) return;
|
|
313
|
+
page.off();
|
|
314
|
+
releaseRoom(page.room);
|
|
315
|
+
page = null;
|
|
316
|
+
collabStore.set({ pagePeers: [] });
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const sendCursor = () => {
|
|
320
|
+
const aw = page?.room.provider.awareness;
|
|
321
|
+
if (!aw) return;
|
|
322
|
+
const key = JSON.stringify(cursorLatest);
|
|
323
|
+
if (key === cursorSent) return;
|
|
324
|
+
cursorSent = key;
|
|
325
|
+
aw.setLocalStateField('cursor', cursorLatest);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
config,
|
|
330
|
+
self,
|
|
331
|
+
setPage(contentId) {
|
|
332
|
+
if (destroyed) return;
|
|
333
|
+
currentContentId = contentId;
|
|
334
|
+
setProjectPresence();
|
|
335
|
+
// 部屋名が保存されない形のページは、繋がずにエラーを出す(自動保存は今までどおり動く)
|
|
336
|
+
roomError = null;
|
|
337
|
+
const name = contentId ? usableRoom(config.roomFor(contentId), true) : null;
|
|
338
|
+
if (page && contentId && name && page.room.name === name) {
|
|
339
|
+
page.contentId = contentId;
|
|
340
|
+
} else {
|
|
341
|
+
leavePage();
|
|
342
|
+
if (contentId && name) joinPage(contentId, name);
|
|
343
|
+
}
|
|
344
|
+
collabStore.set({ pageActive: !!page, pending: false });
|
|
345
|
+
refresh();
|
|
346
|
+
},
|
|
347
|
+
setSelection(anchors) {
|
|
348
|
+
if (JSON.stringify(anchors) === JSON.stringify(selection)) return;
|
|
349
|
+
selection = anchors;
|
|
350
|
+
page?.room.provider.awareness?.setLocalStateField('selection', anchors);
|
|
351
|
+
},
|
|
352
|
+
setCursor(cursor) {
|
|
353
|
+
cursorLatest = cursor;
|
|
354
|
+
if (cursorTimer) return;
|
|
355
|
+
sendCursor();
|
|
356
|
+
cursorTimer = setTimeout(() => {
|
|
357
|
+
cursorTimer = null;
|
|
358
|
+
sendCursor();
|
|
359
|
+
}, CURSOR_THROTTLE_MS);
|
|
360
|
+
},
|
|
361
|
+
setEditing(anchor) {
|
|
362
|
+
if (anchor === editing) return;
|
|
363
|
+
editing = anchor;
|
|
364
|
+
page?.room.provider.awareness?.setLocalStateField('editing', anchor);
|
|
365
|
+
},
|
|
366
|
+
page: () => (page ? { room: page.room, contentId: page.contentId } : null),
|
|
367
|
+
bind({ doc, contentId, initialShared, onApplied }) {
|
|
368
|
+
if (destroyed || !page || page.contentId !== contentId) return null;
|
|
369
|
+
const route = config.routeFor(contentId);
|
|
370
|
+
const binding = new PageBinding({
|
|
371
|
+
room: page.room,
|
|
372
|
+
doc,
|
|
373
|
+
initialShared,
|
|
374
|
+
encode,
|
|
375
|
+
decode: decodeFor(contentId),
|
|
376
|
+
onApplied,
|
|
377
|
+
onLocalSent: () => {
|
|
378
|
+
if (route) bumpRev(route);
|
|
379
|
+
},
|
|
380
|
+
onState: (state) => {
|
|
381
|
+
collabStore.set({ pending: state.pending, canUndo: state.canUndo, canRedo: state.canRedo });
|
|
382
|
+
setLocalPending(state.localPending);
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
return {
|
|
386
|
+
scheduleSend: () => binding.scheduleSend(),
|
|
387
|
+
flush: () => binding.flush(),
|
|
388
|
+
undo: () => binding.undo(),
|
|
389
|
+
redo: () => binding.redo(),
|
|
390
|
+
sharedHtml: () => binding.sharedHtml(),
|
|
391
|
+
destroy: () => {
|
|
392
|
+
binding.destroy();
|
|
393
|
+
collabStore.set({ pending: false, canUndo: false, canRedo: false });
|
|
394
|
+
setLocalPending(false);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
onRemoteRev(listener) {
|
|
399
|
+
revListeners.add(listener);
|
|
400
|
+
pagesMap.forEach((_value, route) => listener(route));
|
|
401
|
+
return () => {
|
|
402
|
+
revListeners.delete(listener);
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
async readShared(contentId) {
|
|
406
|
+
const name = usableRoom(config.roomFor(contentId));
|
|
407
|
+
if (destroyed || !name) return null;
|
|
408
|
+
const fresh = !peekRoom(config.url, name);
|
|
409
|
+
const room = acquireRoom(config.url, name, config.token);
|
|
410
|
+
// 見るだけの紙面のために入る部屋では、居場所を出さない
|
|
411
|
+
if (fresh) room.provider.awareness?.setLocalState(null);
|
|
412
|
+
try {
|
|
413
|
+
if (!(await waitSynced(room, READ_SHARED_TIMEOUT_MS))) return null;
|
|
414
|
+
const text = room.doc.getText('html').toString();
|
|
415
|
+
return text ? decodeFor(contentId)(text) : null;
|
|
416
|
+
} finally {
|
|
417
|
+
releaseRoom(room, PREVIEW_ROOM_GRACE_MS);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
destroy() {
|
|
421
|
+
if (destroyed) return;
|
|
422
|
+
leavePage();
|
|
423
|
+
// 間引き中の rev は捨てずに書く
|
|
424
|
+
revTimers.forEach((entry, route) => {
|
|
425
|
+
if (!entry.timer) return;
|
|
426
|
+
clearTimeout(entry.timer);
|
|
427
|
+
entry.timer = null;
|
|
428
|
+
writeRev(route);
|
|
429
|
+
});
|
|
430
|
+
destroyed = true;
|
|
431
|
+
if (cursorTimer) clearTimeout(cursorTimer);
|
|
432
|
+
setCollabSharedReader(null);
|
|
433
|
+
pagesMap.unobserve(onPages);
|
|
434
|
+
project.provider.awareness?.off('change', onProjectAwareness);
|
|
435
|
+
project.provider.off('synced', refresh);
|
|
436
|
+
socket.off('status', refresh);
|
|
437
|
+
releaseRoom(project);
|
|
438
|
+
releaseSocket(config.url);
|
|
439
|
+
collabStore.reset();
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorCanvas と共同編集の間の合図(依存なし)。
|
|
3
|
+
* EditorCanvas は初期化(initLayout)を終えた文書をこのイベントで知らせる(io.collab があるときだけ)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 「この文書は読み込みと初期化を終えた」。detail は CollabDocumentReadyDetail */
|
|
7
|
+
export const COLLAB_DOCUMENT_READY_EVENT = 'gg:editor-document-ready';
|
|
8
|
+
/** 初期化を終えた文書の <html> に付く印。値はそのときのページ(イベントを聞き逃したときの確認用) */
|
|
9
|
+
export const COLLAB_DOCUMENT_READY_ATTR = 'data-gg-ready';
|
|
10
|
+
|
|
11
|
+
export type CollabDocumentReadyDetail = { doc: Document; contentId: string | null };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共同編集の表示用の状態(依存なし)。
|
|
3
|
+
*
|
|
4
|
+
* 接続の実体(runtime.ts: yjs / Hocuspocus / idiomorph)は io.collab があるときだけ動的に読む。
|
|
5
|
+
* ヘッダーのアバター・フレーム名の点・紙面の選択枠は、ここを購読するだけにして
|
|
6
|
+
* 重い依存を持たない(io.collab が無い利用側では何も読まれず、何も描かれない)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
|
10
|
+
|
|
11
|
+
/** error = 部屋名がサーバーに保存されない形(room.ts の検査に外れた)ので繋いでいない */
|
|
12
|
+
export type CollabStatus = 'offline' | 'syncing' | 'synced' | 'error';
|
|
13
|
+
|
|
14
|
+
export type CollabUser = { id: string; name: string; color: string };
|
|
15
|
+
|
|
16
|
+
/** 案件の部屋に居るエディタ(自分以外)。contentId / route はそのエディタが編集中のページ */
|
|
17
|
+
export type CollabPeer = { clientId: number; user: CollabUser; contentId: string | null; route: string | null };
|
|
18
|
+
|
|
19
|
+
/** いまのページの部屋に居るエディタ(自分以外)。selection / editing は anchorValueOf の値、cursor は紙面の px */
|
|
20
|
+
export type CollabPagePeer = {
|
|
21
|
+
clientId: number;
|
|
22
|
+
user: CollabUser;
|
|
23
|
+
selection: string[];
|
|
24
|
+
cursor: { x: number; y: number } | null;
|
|
25
|
+
editing: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CollabSnapshot = {
|
|
29
|
+
/** io.collab があり、接続の実体が動いている */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** 案件の部屋と一度でも同期できた(書き戻し先の数が当てになる) */
|
|
32
|
+
ready: boolean;
|
|
33
|
+
self: CollabUser | null;
|
|
34
|
+
status: CollabStatus;
|
|
35
|
+
peers: CollabPeer[];
|
|
36
|
+
pagePeers: CollabPagePeer[];
|
|
37
|
+
/** 案件の部屋に居る書き戻し役の数 */
|
|
38
|
+
bridges: number;
|
|
39
|
+
requireBridge: boolean;
|
|
40
|
+
/** いま編集中のページの部屋に入っている(このあいだ onSave の自動保存は呼ばない) */
|
|
41
|
+
pageActive: boolean;
|
|
42
|
+
/** 打鍵中の要素に掛かる他人の変更を blur まで保留している */
|
|
43
|
+
pending: boolean;
|
|
44
|
+
/** 中継にまだ届いていない自分の変更の数 */
|
|
45
|
+
unsynced: number;
|
|
46
|
+
canUndo: boolean;
|
|
47
|
+
canRedo: boolean;
|
|
48
|
+
/** 部屋名が保存される形を外れている理由(null = 問題なし)。接続状態にエラーとして出す */
|
|
49
|
+
roomError: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const COLLAB_DISABLED: CollabSnapshot = {
|
|
53
|
+
enabled: false,
|
|
54
|
+
ready: false,
|
|
55
|
+
self: null,
|
|
56
|
+
status: 'offline',
|
|
57
|
+
peers: [],
|
|
58
|
+
pagePeers: [],
|
|
59
|
+
bridges: 0,
|
|
60
|
+
requireBridge: false,
|
|
61
|
+
pageActive: false,
|
|
62
|
+
pending: false,
|
|
63
|
+
unsynced: 0,
|
|
64
|
+
canUndo: false,
|
|
65
|
+
canRedo: false,
|
|
66
|
+
roomError: null,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let snapshot: CollabSnapshot = COLLAB_DISABLED;
|
|
70
|
+
const listeners = new Set<() => void>();
|
|
71
|
+
|
|
72
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
73
|
+
if (a === b) return true;
|
|
74
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
|
|
75
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const collabStore = {
|
|
79
|
+
get: (): CollabSnapshot => snapshot,
|
|
80
|
+
/** 変わったキーがあるときだけ知らせる(カーソルの 50ms ごとの更新で関係ない部品を描き直さない) */
|
|
81
|
+
set(patch: Partial<CollabSnapshot>): void {
|
|
82
|
+
let changed = false;
|
|
83
|
+
for (const key of Object.keys(patch) as (keyof CollabSnapshot)[]) {
|
|
84
|
+
if (!sameValue(snapshot[key], patch[key])) {
|
|
85
|
+
changed = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!changed) return;
|
|
90
|
+
snapshot = { ...snapshot, ...patch };
|
|
91
|
+
listeners.forEach((l) => l());
|
|
92
|
+
},
|
|
93
|
+
reset(): void {
|
|
94
|
+
if (snapshot === COLLAB_DISABLED) return;
|
|
95
|
+
snapshot = COLLAB_DISABLED;
|
|
96
|
+
listeners.forEach((l) => l());
|
|
97
|
+
},
|
|
98
|
+
subscribe(listener: () => void): () => void {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => {
|
|
101
|
+
listeners.delete(listener);
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 状態の一部だけを購読する。select はモジュールの関数など、描画ごとに変わらないものを渡す。
|
|
108
|
+
* 値は JSON で比べ、同じなら前の参照を返す(描き直さない)
|
|
109
|
+
*/
|
|
110
|
+
export function useCollabSelector<T>(select: (s: CollabSnapshot) => T): T {
|
|
111
|
+
const cache = useRef<{ snap: CollabSnapshot; value: T; key: string } | null>(null);
|
|
112
|
+
const read = useCallback(() => {
|
|
113
|
+
const snap = collabStore.get();
|
|
114
|
+
const c = cache.current;
|
|
115
|
+
if (c && c.snap === snap) return c.value;
|
|
116
|
+
const value = select(snap);
|
|
117
|
+
const key = JSON.stringify(value);
|
|
118
|
+
if (c && c.key === key) {
|
|
119
|
+
c.snap = snap;
|
|
120
|
+
return c.value;
|
|
121
|
+
}
|
|
122
|
+
cache.current = { snap, value, key };
|
|
123
|
+
return value;
|
|
124
|
+
}, [select]);
|
|
125
|
+
return useSyncExternalStore(collabStore.subscribe, read, read);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** いまのページの部屋に入っているか(このあいだ保存の状態の代わりに接続状態を出し、自動保存を止める) */
|
|
129
|
+
export const selectCollabPageActive = (s: CollabSnapshot): boolean => s.enabled && s.pageActive;
|
|
130
|
+
|
|
131
|
+
/** 中継へまだ送れていない変更があるか(離脱の警告に使う) */
|
|
132
|
+
export const selectCollabUnsynced = (s: CollabSnapshot): boolean => s.enabled && (s.unsynced > 0 || s.status === 'offline');
|
|
133
|
+
|
|
134
|
+
/** 共同編集中の取り消しの状態(Y.UndoManager) */
|
|
135
|
+
export const selectCollabUndo = (s: CollabSnapshot) => ({ canUndo: s.canUndo, canRedo: s.canRedo });
|
|
136
|
+
|
|
137
|
+
let flusher: (() => void) | null = null;
|
|
138
|
+
|
|
139
|
+
/** 編集中の紙面の束縛が「まだ送っていない変更を今すぐ送る」関数を登録する(離れるときは null) */
|
|
140
|
+
export function setCollabFlusher(flush: (() => void) | null): void {
|
|
141
|
+
flusher = flush;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** まだ送っていない変更を今すぐ送る(手動保存・ページ切替の直前)。共同編集していなければ何もしない */
|
|
145
|
+
export function flushCollab(): void {
|
|
146
|
+
flusher?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let sharedReader: ((contentId: string) => string | null) | null = null;
|
|
150
|
+
|
|
151
|
+
/** 接続の実体が「そのページの部屋に本文があれば返す」関数を登録する(抜けるときは null) */
|
|
152
|
+
export function setCollabSharedReader(reader: ((contentId: string) => string | null) | null): void {
|
|
153
|
+
sharedReader = reader;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* そのページの部屋に既に同期済みの本文があれば、エディタに渡す HTML(decode 済み)を返す。
|
|
158
|
+
* 共同編集中のページ切替で、ファイル(まだ書き戻されていないことがある)より部屋の本文を優先するために使う
|
|
159
|
+
*/
|
|
160
|
+
export function readCollabSharedHtml(contentId: string): string | null {
|
|
161
|
+
if (!sharedReader) return null;
|
|
162
|
+
try {
|
|
163
|
+
return sharedReader(contentId);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type CollabPresence = {
|
|
170
|
+
enabled: boolean;
|
|
171
|
+
status: CollabStatus;
|
|
172
|
+
self: CollabUser | null;
|
|
173
|
+
/** 自分以外のエディタ(同じ人が複数のタブで開いていても 1 人) */
|
|
174
|
+
peers: CollabPeer[];
|
|
175
|
+
bridges: number;
|
|
176
|
+
/** 部屋名が保存される形を外れている理由(status === 'error' のとき) */
|
|
177
|
+
roomError: string | null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const selectPresence = (s: CollabSnapshot): CollabPresence => ({
|
|
181
|
+
enabled: s.enabled,
|
|
182
|
+
status: s.status,
|
|
183
|
+
self: s.self,
|
|
184
|
+
peers: s.peers,
|
|
185
|
+
bridges: s.bridges,
|
|
186
|
+
roomError: s.roomError,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** 誰がどのページに居るか。io.collab が無ければ enabled: false で peers は空 */
|
|
190
|
+
export function useCollabPresence(): CollabPresence {
|
|
191
|
+
return useCollabSelector(selectPresence);
|
|
192
|
+
}
|