@growgroup/visual-editor 0.4.1 → 0.7.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 +92 -0
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +244 -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 +27 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +4 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -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/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ページの部屋の本文(Y.Text "html")と、編集中の紙面をつなぐ(取り決め §4)。
|
|
3
|
+
*
|
|
4
|
+
* - 種まき: 部屋と同期して本文が空なら、読み込み直後の姿を FNV-1a の clientID で 1 回だけ入れる
|
|
5
|
+
* (同じ本文なら何人が同時に撒いても構造体の ID が同じになり、Yjs が重複を捨てる)
|
|
6
|
+
* - 自分の変更: 履歴の present が変わる・打鍵する(input)たびに 120ms 間引いて、
|
|
7
|
+
* 紙面(getCleanHtml → encode)と shadow の差分を 1 つの transaction で送る。同じなら送らない
|
|
8
|
+
* - 他人の変更: idiomorph で紙面に当て、履歴は段を作らずに追従させる。
|
|
9
|
+
* 打鍵中の要素に掛かる分は blur まで保留する。ドラッグ中は離すまで待つ
|
|
10
|
+
* - 取り消し: Y.UndoManager(自分の origin だけを追う)。結果は他人の変更と同じ経路で紙面に当てる
|
|
11
|
+
*
|
|
12
|
+
* shadow と差分の合わせ方は merge.ts を参照
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as Y from 'yjs';
|
|
16
|
+
import { getCleanHtml } from '../utils/html-utils';
|
|
17
|
+
import { refreshSelectionOverlay } from '../utils/dom-utils';
|
|
18
|
+
import { fnv1a32 } from './color';
|
|
19
|
+
import { morphArtboard } from './morph';
|
|
20
|
+
import { replacementsOf, transformReplacements } from './merge';
|
|
21
|
+
import type { Room } from './connection';
|
|
22
|
+
|
|
23
|
+
/** 自分の変更を送る間隔(ms)。打鍵中も相手に 1 秒以内に見えるように */
|
|
24
|
+
const SEND_THROTTLE_MS = 120;
|
|
25
|
+
/** 他人の変更を当てるまでの待ち(ms)。続けて届いた更新を 1 回にまとめる */
|
|
26
|
+
const APPLY_DELAY_MS = 16;
|
|
27
|
+
/** 取り消しの 1 段にまとめる時間(ms) */
|
|
28
|
+
const CAPTURE_TIMEOUT_MS = 500;
|
|
29
|
+
/** ポインタを押している間は当てない。離した合図を取りこぼしてもこれ以上は待たない(ms) */
|
|
30
|
+
const POINTER_HOLD_MAX_MS = 15000;
|
|
31
|
+
|
|
32
|
+
export type BindingState = { pending: boolean; canUndo: boolean; canRedo: boolean; localPending: boolean };
|
|
33
|
+
|
|
34
|
+
export type PageBindingOptions = {
|
|
35
|
+
room: Room;
|
|
36
|
+
doc: Document;
|
|
37
|
+
/** 読み込み直後の紙面の姿(encode 済み)。種まきに使う */
|
|
38
|
+
initialShared: string;
|
|
39
|
+
encode: (cleanHtml: string) => string;
|
|
40
|
+
decode: (shared: string) => string;
|
|
41
|
+
/** 他人の変更を当てた直後の #artboard.innerHTML(履歴の present を段を作らずに追従させる) */
|
|
42
|
+
onApplied: (artboardHtml: string) => void;
|
|
43
|
+
/** 自分の変更を送った(案件の部屋の pages の rev を進める) */
|
|
44
|
+
onLocalSent: () => void;
|
|
45
|
+
onState: (state: BindingState) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class PageBinding {
|
|
49
|
+
private readonly opts: PageBindingOptions;
|
|
50
|
+
private readonly origin = { collab: 'local' };
|
|
51
|
+
private readonly text: Y.Text;
|
|
52
|
+
private readonly undoManager: Y.UndoManager;
|
|
53
|
+
/** 最後に Y.Text と揃えたときの紙面の姿(encode 済み) */
|
|
54
|
+
private shadow: string;
|
|
55
|
+
/** 部屋と一度でも同期した(それまでは送らない・当てない) */
|
|
56
|
+
private synced = false;
|
|
57
|
+
private destroyed = false;
|
|
58
|
+
private sendTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
|
+
private applyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
private pointerDownAt: number | null = null;
|
|
61
|
+
private applyAfterPointer = false;
|
|
62
|
+
private state: BindingState = { pending: false, canUndo: false, canRedo: false, localPending: false };
|
|
63
|
+
private readonly cleanups: (() => void)[] = [];
|
|
64
|
+
|
|
65
|
+
constructor(opts: PageBindingOptions) {
|
|
66
|
+
this.opts = opts;
|
|
67
|
+
this.text = opts.room.doc.getText('html');
|
|
68
|
+
this.shadow = opts.initialShared;
|
|
69
|
+
this.undoManager = new Y.UndoManager(this.text, {
|
|
70
|
+
trackedOrigins: new Set([this.origin]),
|
|
71
|
+
captureTimeout: CAPTURE_TIMEOUT_MS,
|
|
72
|
+
});
|
|
73
|
+
const onStack = () =>
|
|
74
|
+
this.setState({ canUndo: this.undoManager.undoStack.length > 0, canRedo: this.undoManager.redoStack.length > 0 });
|
|
75
|
+
this.undoManager.on('stack-item-added', onStack);
|
|
76
|
+
this.undoManager.on('stack-item-popped', onStack);
|
|
77
|
+
this.undoManager.on('stack-cleared', onStack);
|
|
78
|
+
|
|
79
|
+
this.text.observe(this.onTextChange);
|
|
80
|
+
|
|
81
|
+
const provider = opts.room.provider;
|
|
82
|
+
const onSynced = () => this.onSynced();
|
|
83
|
+
provider.on('synced', onSynced);
|
|
84
|
+
this.cleanups.push(() => provider.off('synced', onSynced));
|
|
85
|
+
|
|
86
|
+
const doc = opts.doc;
|
|
87
|
+
const onInput = () => this.scheduleSend();
|
|
88
|
+
// blur: 打鍵の分を送り、保留していた他人の変更を当てる(contenteditable が外れてから)
|
|
89
|
+
const onFocusOut = () => {
|
|
90
|
+
this.scheduleSend();
|
|
91
|
+
setTimeout(() => this.scheduleApply(), 30);
|
|
92
|
+
};
|
|
93
|
+
const onPointerDown = () => {
|
|
94
|
+
this.pointerDownAt = Date.now();
|
|
95
|
+
};
|
|
96
|
+
const onPointerUp = () => {
|
|
97
|
+
this.pointerDownAt = null;
|
|
98
|
+
if (this.applyAfterPointer) {
|
|
99
|
+
this.applyAfterPointer = false;
|
|
100
|
+
this.scheduleApply();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
doc.addEventListener('input', onInput, true);
|
|
104
|
+
doc.addEventListener('focusout', onFocusOut, true);
|
|
105
|
+
doc.addEventListener('pointerdown', onPointerDown, true);
|
|
106
|
+
doc.addEventListener('pointerup', onPointerUp, true);
|
|
107
|
+
window.addEventListener('pointerup', onPointerUp, true);
|
|
108
|
+
this.cleanups.push(() => {
|
|
109
|
+
doc.removeEventListener('input', onInput, true);
|
|
110
|
+
doc.removeEventListener('focusout', onFocusOut, true);
|
|
111
|
+
doc.removeEventListener('pointerdown', onPointerDown, true);
|
|
112
|
+
doc.removeEventListener('pointerup', onPointerUp, true);
|
|
113
|
+
window.removeEventListener('pointerup', onPointerUp, true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (provider.synced) this.onSynced();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 自分の変更があったかもしれない(履歴の present が変わった・打鍵した)。間引いて送る */
|
|
120
|
+
scheduleSend(): void {
|
|
121
|
+
if (this.destroyed) return;
|
|
122
|
+
this.setState({ localPending: true });
|
|
123
|
+
if (this.sendTimer) return;
|
|
124
|
+
this.sendTimer = setTimeout(() => {
|
|
125
|
+
this.sendTimer = null;
|
|
126
|
+
this.flushLocal();
|
|
127
|
+
}, SEND_THROTTLE_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** まだ送っていない変更を今すぐ送る(ページ切替・保存ボタン・離脱の前) */
|
|
131
|
+
flush(): void {
|
|
132
|
+
if (this.sendTimer) {
|
|
133
|
+
clearTimeout(this.sendTimer);
|
|
134
|
+
this.sendTimer = null;
|
|
135
|
+
}
|
|
136
|
+
this.flushLocal();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
undo(): void {
|
|
140
|
+
if (this.destroyed) return;
|
|
141
|
+
this.flush();
|
|
142
|
+
this.undoManager.undo();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
redo(): void {
|
|
146
|
+
if (this.destroyed) return;
|
|
147
|
+
this.flush();
|
|
148
|
+
this.undoManager.redo();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 部屋の本文(decode 済み)。空なら null */
|
|
152
|
+
sharedHtml(): string | null {
|
|
153
|
+
const text = this.text.toString();
|
|
154
|
+
return text ? this.opts.decode(text) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
destroy(): void {
|
|
158
|
+
if (this.destroyed) return;
|
|
159
|
+
this.flush();
|
|
160
|
+
this.destroyed = true;
|
|
161
|
+
if (this.sendTimer) clearTimeout(this.sendTimer);
|
|
162
|
+
if (this.applyTimer) clearTimeout(this.applyTimer);
|
|
163
|
+
this.text.unobserve(this.onTextChange);
|
|
164
|
+
this.undoManager.destroy();
|
|
165
|
+
this.cleanups.forEach((fn) => fn());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
private readonly onTextChange = (_event: Y.YTextEvent, tx: Y.Transaction) => {
|
|
171
|
+
// 自分が送った変更は紙面に既にある。それ以外(他人・種まき・取り消し)は紙面へ当てる
|
|
172
|
+
if (tx.origin === this.origin) return;
|
|
173
|
+
this.scheduleApply();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
private onSynced(): void {
|
|
177
|
+
if (this.destroyed || !this.opts.room.provider.synced) return;
|
|
178
|
+
if (!this.synced && this.text.length === 0 && this.opts.initialShared) this.seed(this.opts.initialShared);
|
|
179
|
+
this.synced = true;
|
|
180
|
+
this.flushLocal();
|
|
181
|
+
this.applyRemote();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 空の部屋に最初の本文を入れる(取り決め §4 の手順どおり) */
|
|
185
|
+
private seed(text: string): void {
|
|
186
|
+
const tmp = new Y.Doc();
|
|
187
|
+
tmp.clientID = fnv1a32(text) || 1;
|
|
188
|
+
tmp.getText('html').insert(0, text);
|
|
189
|
+
Y.applyUpdate(this.opts.room.doc, Y.encodeStateAsUpdate(tmp), 'seed');
|
|
190
|
+
tmp.destroy();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private flushLocal(): void {
|
|
194
|
+
if (this.destroyed) return;
|
|
195
|
+
// 部屋と揃う前は送らない(同期したときに、ここまでの変更をまとめて送る)
|
|
196
|
+
if (!this.synced) return;
|
|
197
|
+
const doc = this.opts.doc;
|
|
198
|
+
if (!doc.defaultView || !doc.getElementById('artboard')) {
|
|
199
|
+
this.setState({ localPending: false });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const next = this.opts.encode(getCleanHtml(doc));
|
|
203
|
+
this.setState({ localPending: false });
|
|
204
|
+
if (next === this.shadow) return;
|
|
205
|
+
const current = this.text.toString();
|
|
206
|
+
const ops = transformReplacements(
|
|
207
|
+
replacementsOf(this.shadow, next),
|
|
208
|
+
current === this.shadow ? [] : replacementsOf(this.shadow, current),
|
|
209
|
+
);
|
|
210
|
+
this.shadow = next;
|
|
211
|
+
if (ops.length === 0) return;
|
|
212
|
+
this.opts.room.doc.transact(() => {
|
|
213
|
+
for (const op of ops) {
|
|
214
|
+
if (op.deleteCount > 0) this.text.delete(op.at, op.deleteCount);
|
|
215
|
+
if (op.insert) this.text.insert(op.at, op.insert);
|
|
216
|
+
}
|
|
217
|
+
}, this.origin);
|
|
218
|
+
this.opts.onLocalSent();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private scheduleApply(): void {
|
|
222
|
+
if (this.destroyed || this.applyTimer) return;
|
|
223
|
+
this.applyTimer = setTimeout(() => {
|
|
224
|
+
this.applyTimer = null;
|
|
225
|
+
this.applyRemote();
|
|
226
|
+
}, APPLY_DELAY_MS);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private applyRemote(): void {
|
|
230
|
+
if (this.destroyed || !this.synced) return;
|
|
231
|
+
// 掴んで動かしている最中に書き換えると、掴んでいる要素が入れ替わる。離してから当てる
|
|
232
|
+
if (this.pointerDownAt != null && Date.now() - this.pointerDownAt < POINTER_HOLD_MAX_MS) {
|
|
233
|
+
this.applyAfterPointer = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const doc = this.opts.doc;
|
|
237
|
+
const artboard = doc.getElementById('artboard');
|
|
238
|
+
if (!doc.defaultView || !artboard) return;
|
|
239
|
+
// 自分のまだ送っていない変更を先に送る(当て込みで紙面から消さないため)
|
|
240
|
+
this.flush();
|
|
241
|
+
const shared = this.text.toString();
|
|
242
|
+
if (shared === this.shadow) {
|
|
243
|
+
this.setState({ pending: false });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const hold = artboard.querySelector<HTMLElement>('[contenteditable="true"]');
|
|
247
|
+
const { heldChanged } = morphArtboard(artboard, this.opts.decode(shared), hold);
|
|
248
|
+
this.shadow = this.opts.encode(getCleanHtml(doc));
|
|
249
|
+
try {
|
|
250
|
+
refreshSelectionOverlay(doc);
|
|
251
|
+
} catch {
|
|
252
|
+
/* 選択枠が無い */
|
|
253
|
+
}
|
|
254
|
+
this.opts.onApplied(artboard.innerHTML);
|
|
255
|
+
this.setState({ pending: !!hold && heldChanged });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private setState(patch: Partial<BindingState>): void {
|
|
259
|
+
const next = { ...this.state, ...patch };
|
|
260
|
+
if (
|
|
261
|
+
next.pending === this.state.pending &&
|
|
262
|
+
next.canUndo === this.state.canUndo &&
|
|
263
|
+
next.canRedo === this.state.canRedo &&
|
|
264
|
+
next.localPending === this.state.localPending
|
|
265
|
+
) return;
|
|
266
|
+
this.state = next;
|
|
267
|
+
this.opts.onState(next);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共同編集の小道具(依存なし)。種まきの clientID と参加者の色。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** FNV-1a 32bit。UTF-8 のバイト列に掛ける(取り決め §4 の種まきと同じ式) */
|
|
6
|
+
export function fnv1a32(str: string): number {
|
|
7
|
+
const bytes = new TextEncoder().encode(str);
|
|
8
|
+
let h = 0x811c9dc5;
|
|
9
|
+
for (const b of bytes) {
|
|
10
|
+
h ^= b;
|
|
11
|
+
h = Math.imul(h, 0x01000193);
|
|
12
|
+
}
|
|
13
|
+
return h >>> 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 参加者の色。白い文字を載せても読める濃さの 8 色 */
|
|
17
|
+
export const COLLAB_COLORS = ['#E5484D', '#E0661B', '#A77B00', '#2F9E5B', '#0E8F8A', '#1F7AE0', '#8B5CF6', '#D6409F'] as const;
|
|
18
|
+
|
|
19
|
+
/** 本人が color を渡していなければ id の hash で決める(同じ人はどの画面でも同じ色) */
|
|
20
|
+
export function colorFor(id: string, preferred?: string): string {
|
|
21
|
+
if (preferred) return preferred;
|
|
22
|
+
return COLLAB_COLORS[fnv1a32(id) % COLLAB_COLORS.length];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** アバターの文字(名前の頭 1 文字) */
|
|
26
|
+
export function initialOf(name: string): string {
|
|
27
|
+
return (name.trim() || '?').slice(0, 1).toUpperCase();
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hocuspocus への接続(1 タブ 1 本の WebSocket に部屋を相乗りさせる)。
|
|
3
|
+
*
|
|
4
|
+
* - WebSocket は URL ごとに 1 本(HocuspocusProviderWebsocket)。部屋(HocuspocusProvider)はそこへ attach する。
|
|
5
|
+
* v3 は websocketProvider を共有すると attach() を呼ぶまで繋がらない(取り決め §2 の実測)
|
|
6
|
+
* - 部屋を手放すときは、まず自分の居場所(awareness)を消し、数秒の猶予のあと detach する。
|
|
7
|
+
* 猶予の間に同じ部屋へ戻れば(ページを行き来した・1 ページ表示でエディタが作り直された)繋ぎ直さない
|
|
8
|
+
* - WebSocket も最後の部屋が抜けてから猶予を置いて閉じる
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
|
12
|
+
import * as Y from 'yjs';
|
|
13
|
+
|
|
14
|
+
/** WebSocket を閉じるまでの猶予(ms) */
|
|
15
|
+
const SOCKET_GRACE_MS = 5000;
|
|
16
|
+
/** 部屋を detach するまでの猶予(ms) */
|
|
17
|
+
export const ROOM_GRACE_MS = 4000;
|
|
18
|
+
|
|
19
|
+
type SocketEntry = { socket: HocuspocusProviderWebsocket; refs: number; timer: ReturnType<typeof setTimeout> | null };
|
|
20
|
+
const sockets = new Map<string, SocketEntry>();
|
|
21
|
+
|
|
22
|
+
export function acquireSocket(url: string): HocuspocusProviderWebsocket {
|
|
23
|
+
let entry = sockets.get(url);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
entry = { socket: new HocuspocusProviderWebsocket({ url }), refs: 0, timer: null };
|
|
26
|
+
sockets.set(url, entry);
|
|
27
|
+
}
|
|
28
|
+
if (entry.timer) {
|
|
29
|
+
clearTimeout(entry.timer);
|
|
30
|
+
entry.timer = null;
|
|
31
|
+
}
|
|
32
|
+
entry.refs += 1;
|
|
33
|
+
return entry.socket;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function releaseSocket(url: string): void {
|
|
37
|
+
const entry = sockets.get(url);
|
|
38
|
+
if (!entry) return;
|
|
39
|
+
entry.refs = Math.max(0, entry.refs - 1);
|
|
40
|
+
if (entry.refs > 0 || entry.timer) return;
|
|
41
|
+
entry.timer = setTimeout(() => {
|
|
42
|
+
entry.timer = null;
|
|
43
|
+
if (entry.refs > 0) return;
|
|
44
|
+
sockets.delete(url);
|
|
45
|
+
entry.socket.destroy();
|
|
46
|
+
}, SOCKET_GRACE_MS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** いま張っている WebSocket(検証で切断・再接続を起こすのに使う) */
|
|
50
|
+
export function socketFor(url: string): HocuspocusProviderWebsocket | null {
|
|
51
|
+
return sockets.get(url)?.socket ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type Room = {
|
|
55
|
+
key: string;
|
|
56
|
+
url: string;
|
|
57
|
+
name: string;
|
|
58
|
+
doc: Y.Doc;
|
|
59
|
+
provider: HocuspocusProvider;
|
|
60
|
+
refs: number;
|
|
61
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
62
|
+
};
|
|
63
|
+
const rooms = new Map<string, Room>();
|
|
64
|
+
|
|
65
|
+
export function acquireRoom(url: string, name: string, token?: () => Promise<string | undefined>): Room {
|
|
66
|
+
const key = `${url}\n${name}`;
|
|
67
|
+
const existing = rooms.get(key);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (existing.timer) {
|
|
70
|
+
clearTimeout(existing.timer);
|
|
71
|
+
existing.timer = null;
|
|
72
|
+
}
|
|
73
|
+
existing.refs += 1;
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
const socket = acquireSocket(url);
|
|
77
|
+
const doc = new Y.Doc();
|
|
78
|
+
const provider = new HocuspocusProvider({
|
|
79
|
+
websocketProvider: socket,
|
|
80
|
+
name,
|
|
81
|
+
document: doc,
|
|
82
|
+
// 本番の wf/ の部屋は token 無し(空文字)で通る。渡されていればそれを使う
|
|
83
|
+
token: token ? async () => (await token()) ?? '' : '',
|
|
84
|
+
});
|
|
85
|
+
provider.attach();
|
|
86
|
+
const room: Room = { key, url, name, doc, provider, refs: 1, timer: null };
|
|
87
|
+
rooms.set(key, room);
|
|
88
|
+
return room;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 部屋を手放す。居場所はすぐ消し、接続は猶予のあと切る */
|
|
92
|
+
export function releaseRoom(room: Room, graceMs = ROOM_GRACE_MS): void {
|
|
93
|
+
room.refs = Math.max(0, room.refs - 1);
|
|
94
|
+
if (room.refs > 0) return;
|
|
95
|
+
room.provider.awareness?.setLocalState(null);
|
|
96
|
+
if (room.timer) clearTimeout(room.timer);
|
|
97
|
+
room.timer = setTimeout(() => {
|
|
98
|
+
room.timer = null;
|
|
99
|
+
if (room.refs > 0) return;
|
|
100
|
+
rooms.delete(room.key);
|
|
101
|
+
room.provider.destroy();
|
|
102
|
+
room.doc.destroy();
|
|
103
|
+
releaseSocket(room.url);
|
|
104
|
+
}, graceMs);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 既に持っている部屋(猶予中も含む)。無ければ null */
|
|
108
|
+
export function peekRoom(url: string, name: string): Room | null {
|
|
109
|
+
return rooms.get(`${url}\n${name}`) ?? null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 紙面の中に、他人の選択枠(名前札)とカーソルを描く(.gg-collab-layer)。
|
|
5
|
+
* コメントのピン(.gg-comment-layer)と同じ作り: #artboard の中の表示専用の層で、座標は紙面の px。
|
|
6
|
+
* 線の太さ・名前札の大きさは「内側の倍率 × 外側のキャンバス倍率」で割って画面上で一定に保つ。
|
|
7
|
+
* 保存 HTML(getCleanHtml)・履歴・共有する本文には入らない
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect } from 'react';
|
|
11
|
+
import { findAnchored } from '../components/ppt/PptComments';
|
|
12
|
+
import { useCanvasViewStateOptional } from '../contexts/MultiPageCanvasContext';
|
|
13
|
+
import { collabStore } from './store';
|
|
14
|
+
|
|
15
|
+
/** 要素の移動・編集にゆるく追従する間隔(ms) */
|
|
16
|
+
const RENDER_INTERVAL_MS = 400;
|
|
17
|
+
|
|
18
|
+
const CURSOR_SVG =
|
|
19
|
+
'<svg viewBox="0 0 16 16" width="16" height="16" style="display:block;overflow:visible">' +
|
|
20
|
+
'<path d="M1 1 L1 13.5 L4.6 10.1 L7.1 15.2 L9.4 14.1 L6.9 9.1 L11.8 9.1 Z" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/></svg>';
|
|
21
|
+
|
|
22
|
+
/** 同じ値の再代入をしない(無駄な再計算を起こさない) */
|
|
23
|
+
function applyStyles(el: HTMLElement, styles: Record<string, string>) {
|
|
24
|
+
const style = el.style as unknown as Record<string, string>;
|
|
25
|
+
for (const [key, value] of Object.entries(styles)) {
|
|
26
|
+
if (style[key] !== value) style[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useCollabLayer(doc: Document | null, enabled: boolean): void {
|
|
31
|
+
const outerZoom = useCanvasViewStateOptional()?.canvasZoom ?? 1;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled || !doc) return;
|
|
35
|
+
let alive = true;
|
|
36
|
+
|
|
37
|
+
const render = () => {
|
|
38
|
+
if (!alive || !doc.defaultView) return;
|
|
39
|
+
const artboard = doc.getElementById('artboard');
|
|
40
|
+
if (!artboard) return;
|
|
41
|
+
const peers = collabStore.get().pagePeers;
|
|
42
|
+
let layer = artboard.querySelector<HTMLElement>(':scope > .gg-collab-layer');
|
|
43
|
+
if (!layer) {
|
|
44
|
+
if (peers.length === 0) return;
|
|
45
|
+
layer = doc.createElement('div');
|
|
46
|
+
layer.className = 'gg-collab-layer';
|
|
47
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
48
|
+
layer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9001;overflow:visible;';
|
|
49
|
+
artboard.appendChild(layer);
|
|
50
|
+
}
|
|
51
|
+
const root = artboard.getBoundingClientRect();
|
|
52
|
+
const inner = root.width / (artboard.offsetWidth || 1) || 1;
|
|
53
|
+
const scale = inner * outerZoom || 1;
|
|
54
|
+
const wanted = new Set<string>();
|
|
55
|
+
const ensure = (key: string, build: () => HTMLElement): HTMLElement => {
|
|
56
|
+
let el = layer!.querySelector<HTMLElement>(`:scope > [data-collab-key="${CSS.escape(key)}"]`);
|
|
57
|
+
if (!el) {
|
|
58
|
+
el = build();
|
|
59
|
+
el.setAttribute('data-collab-key', key);
|
|
60
|
+
layer!.appendChild(el);
|
|
61
|
+
}
|
|
62
|
+
wanted.add(key);
|
|
63
|
+
return el;
|
|
64
|
+
};
|
|
65
|
+
const nameTag = (label: string, color: string): HTMLElement => {
|
|
66
|
+
const tag = doc.createElement('span');
|
|
67
|
+
tag.setAttribute('data-collab-name', '');
|
|
68
|
+
tag.textContent = label;
|
|
69
|
+
tag.style.background = color;
|
|
70
|
+
return tag;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const peer of peers) {
|
|
74
|
+
const anchors = [...peer.selection];
|
|
75
|
+
if (peer.editing && !anchors.includes(peer.editing)) anchors.push(peer.editing);
|
|
76
|
+
anchors.forEach((anchor, index) => {
|
|
77
|
+
const target = findAnchored(artboard, anchor);
|
|
78
|
+
if (!target) return;
|
|
79
|
+
const r = target.getBoundingClientRect();
|
|
80
|
+
const editing = anchor === peer.editing;
|
|
81
|
+
const box = ensure(`sel:${peer.clientId}:${index}`, () => {
|
|
82
|
+
const el = doc.createElement('div');
|
|
83
|
+
el.setAttribute('data-collab-selection', String(peer.clientId));
|
|
84
|
+
el.appendChild(nameTag('', peer.user.color));
|
|
85
|
+
return el;
|
|
86
|
+
});
|
|
87
|
+
box.setAttribute('data-collab-user', peer.user.id);
|
|
88
|
+
box.toggleAttribute('data-collab-editing', editing);
|
|
89
|
+
applyStyles(box, {
|
|
90
|
+
position: 'absolute',
|
|
91
|
+
left: `${(r.left - root.left) / inner}px`,
|
|
92
|
+
top: `${(r.top - root.top) / inner}px`,
|
|
93
|
+
width: `${r.width / inner}px`,
|
|
94
|
+
height: `${r.height / inner}px`,
|
|
95
|
+
boxSizing: 'border-box',
|
|
96
|
+
border: `${2 / scale}px ${editing ? 'dashed' : 'solid'} ${peer.user.color}`,
|
|
97
|
+
borderRadius: `${2 / scale}px`,
|
|
98
|
+
});
|
|
99
|
+
const tag = box.firstElementChild as HTMLElement;
|
|
100
|
+
const label = editing ? `${peer.user.name}(入力中)` : peer.user.name;
|
|
101
|
+
if (tag.textContent !== label) tag.textContent = label;
|
|
102
|
+
applyStyles(tag, {
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
left: `${-2 / scale}px`,
|
|
105
|
+
bottom: '100%',
|
|
106
|
+
marginBottom: `${2 / scale}px`,
|
|
107
|
+
background: peer.user.color,
|
|
108
|
+
color: '#fff',
|
|
109
|
+
font: `600 ${11 / scale}px/1.35 system-ui, -apple-system, sans-serif`,
|
|
110
|
+
padding: `${1 / scale}px ${5 / scale}px`,
|
|
111
|
+
borderRadius: `${3 / scale}px`,
|
|
112
|
+
whiteSpace: 'nowrap',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (peer.cursor) {
|
|
117
|
+
const cursor = ensure(`cur:${peer.clientId}`, () => {
|
|
118
|
+
const el = doc.createElement('div');
|
|
119
|
+
el.setAttribute('data-collab-cursor', String(peer.clientId));
|
|
120
|
+
el.innerHTML = CURSOR_SVG;
|
|
121
|
+
el.appendChild(nameTag(peer.user.name, peer.user.color));
|
|
122
|
+
return el;
|
|
123
|
+
});
|
|
124
|
+
cursor.setAttribute('data-collab-user', peer.user.id);
|
|
125
|
+
applyStyles(cursor, {
|
|
126
|
+
position: 'absolute',
|
|
127
|
+
left: `${peer.cursor.x}px`,
|
|
128
|
+
top: `${peer.cursor.y}px`,
|
|
129
|
+
transform: `scale(${1 / scale})`,
|
|
130
|
+
transformOrigin: '0 0',
|
|
131
|
+
});
|
|
132
|
+
const path = cursor.querySelector('path');
|
|
133
|
+
if (path && path.getAttribute('fill') !== peer.user.color) path.setAttribute('fill', peer.user.color);
|
|
134
|
+
const tag = cursor.querySelector<HTMLElement>('[data-collab-name]')!;
|
|
135
|
+
if (tag.textContent !== peer.user.name) tag.textContent = peer.user.name;
|
|
136
|
+
applyStyles(tag, {
|
|
137
|
+
position: 'absolute',
|
|
138
|
+
left: '12px',
|
|
139
|
+
top: '15px',
|
|
140
|
+
background: peer.user.color,
|
|
141
|
+
color: '#fff',
|
|
142
|
+
font: '600 11px/1.35 system-ui, -apple-system, sans-serif',
|
|
143
|
+
padding: '1px 5px',
|
|
144
|
+
borderRadius: '3px',
|
|
145
|
+
whiteSpace: 'nowrap',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
layer.querySelectorAll<HTMLElement>(':scope > [data-collab-key]').forEach((el) => {
|
|
150
|
+
if (!wanted.has(el.getAttribute('data-collab-key') ?? '')) el.remove();
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
render();
|
|
155
|
+
const unsubscribe = collabStore.subscribe(render);
|
|
156
|
+
const timer = setInterval(render, RENDER_INTERVAL_MS);
|
|
157
|
+
return () => {
|
|
158
|
+
alive = false;
|
|
159
|
+
unsubscribe();
|
|
160
|
+
clearInterval(timer);
|
|
161
|
+
try {
|
|
162
|
+
doc.querySelector('#artboard > .gg-collab-layer')?.remove();
|
|
163
|
+
} catch {
|
|
164
|
+
/* 文書が既に無い */
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}, [doc, enabled, outerZoom]);
|
|
168
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共有する本文(文字列)の差分と、同時の変更の合わせ方。
|
|
3
|
+
*
|
|
4
|
+
* エディタは「最後に Y.Text と揃えた紙面の姿」(shadow)を持つ。自分の変更は
|
|
5
|
+
* shadow → いまの紙面 の差分(local)で、その間に届いた他人の変更は shadow → Y.Text の差分(remote)。
|
|
6
|
+
* local の位置を remote に通して Y.Text の位置へ写し、1 つの transaction で当てる。
|
|
7
|
+
*
|
|
8
|
+
* こうするのは、紙面と Y.Text が 1 文字も違わないとは限らないため:
|
|
9
|
+
* - 打鍵中の要素に掛かる他人の変更は blur まで紙面に入れない(その間も自分の打鍵は送る)
|
|
10
|
+
* - 他人の本文を当てた紙面を getCleanHtml すると、属性の並び・書き戻しの印(data-gg-dirty)などが
|
|
11
|
+
* 元の文字列と少し違うことがある
|
|
12
|
+
* 「Y.Text と今の紙面の差分」をそのまま送ると、前者では他人の変更を消し、後者では
|
|
13
|
+
* 同じ整形を何人もが送って挿入が重なる。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import diff from 'fast-diff';
|
|
17
|
+
|
|
18
|
+
/** base の [from, to) を insert に置き換える */
|
|
19
|
+
export type Replacement = { from: number; to: number; insert: string };
|
|
20
|
+
|
|
21
|
+
/** Y.Text に当てる 1 手(at は当てる前の Y.Text の位置) */
|
|
22
|
+
export type TextOp = { at: number; deleteCount: number; insert: string };
|
|
23
|
+
|
|
24
|
+
/** base → next の差分を、base の位置での置き換えの列にする(位置の昇順・重ならない) */
|
|
25
|
+
export function replacementsOf(base: string, next: string): Replacement[] {
|
|
26
|
+
if (base === next) return [];
|
|
27
|
+
const out: Replacement[] = [];
|
|
28
|
+
let pos = 0;
|
|
29
|
+
let current: Replacement | null = null;
|
|
30
|
+
for (const [type, text] of diff(base, next)) {
|
|
31
|
+
if (type === diff.EQUAL) {
|
|
32
|
+
if (current) out.push(current);
|
|
33
|
+
current = null;
|
|
34
|
+
pos += text.length;
|
|
35
|
+
} else if (type === diff.DELETE) {
|
|
36
|
+
if (!current) current = { from: pos, to: pos, insert: '' };
|
|
37
|
+
current.to += text.length;
|
|
38
|
+
pos += text.length;
|
|
39
|
+
} else {
|
|
40
|
+
if (!current) current = { from: pos, to: pos, insert: '' };
|
|
41
|
+
current.insert += text;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (current) out.push(current);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* base の位置 → remote を当てたあとの位置。
|
|
50
|
+
* remote に置き換えられた範囲の中は、その置き換えの先頭へ寄せる。
|
|
51
|
+
* 同じ位置への挿入は remote が先(自分の挿入はその後ろ)
|
|
52
|
+
*/
|
|
53
|
+
function mapPosition(pos: number, remote: Replacement[]): number {
|
|
54
|
+
let delta = 0;
|
|
55
|
+
for (const r of remote) {
|
|
56
|
+
if (pos < r.from) break;
|
|
57
|
+
if (pos >= r.to) {
|
|
58
|
+
delta += r.insert.length - (r.to - r.from);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
return r.from + delta;
|
|
62
|
+
}
|
|
63
|
+
return pos + delta;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* local(base の位置)を remote を当てたあとの文字列の手に写す。
|
|
68
|
+
* - local の削除のうち、remote が既に置き換えた範囲は消さない(他人の挿入も消さない)
|
|
69
|
+
* - local の挿入は、写した位置に入れる
|
|
70
|
+
* 戻り値は位置の降順(前から当てると後ろの位置がずれるため)。同じ位置では削除 → 挿入の順
|
|
71
|
+
*/
|
|
72
|
+
export function transformReplacements(local: Replacement[], remote: Replacement[]): TextOp[] {
|
|
73
|
+
const ops: (TextOp & { order: number })[] = [];
|
|
74
|
+
let order = 0;
|
|
75
|
+
for (const l of local) {
|
|
76
|
+
// 削除: [from, to) から remote の範囲を抜いた残り。remote の挿入(幅 0)は切れ目にする
|
|
77
|
+
if (l.to > l.from) {
|
|
78
|
+
let start = l.from;
|
|
79
|
+
for (const r of remote) {
|
|
80
|
+
if (r.to < l.from || r.from > l.to) continue;
|
|
81
|
+
if (r.from === r.to) {
|
|
82
|
+
if (r.from > l.from && r.from < l.to) {
|
|
83
|
+
if (r.from > start) ops.push({ at: mapPosition(start, remote), deleteCount: r.from - start, insert: '', order: order++ });
|
|
84
|
+
start = r.from;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const cutFrom = Math.max(r.from, l.from);
|
|
89
|
+
const cutTo = Math.min(r.to, l.to);
|
|
90
|
+
if (cutTo <= cutFrom) continue;
|
|
91
|
+
if (cutFrom > start) ops.push({ at: mapPosition(start, remote), deleteCount: cutFrom - start, insert: '', order: order++ });
|
|
92
|
+
start = Math.max(start, cutTo);
|
|
93
|
+
}
|
|
94
|
+
if (l.to > start) ops.push({ at: mapPosition(start, remote), deleteCount: l.to - start, insert: '', order: order++ });
|
|
95
|
+
}
|
|
96
|
+
if (l.insert) ops.push({ at: mapPosition(l.from, remote), deleteCount: 0, insert: l.insert, order: order++ });
|
|
97
|
+
}
|
|
98
|
+
ops.sort((a, b) => {
|
|
99
|
+
if (a.at !== b.at) return b.at - a.at;
|
|
100
|
+
// 同じ位置: 削除を先に(挿入してから消すと、入れた文字を消してしまう)。挿入どうしは後のものを先に入れる
|
|
101
|
+
if ((a.deleteCount > 0) !== (b.deleteCount > 0)) return a.deleteCount > 0 ? -1 : 1;
|
|
102
|
+
return b.order - a.order;
|
|
103
|
+
});
|
|
104
|
+
return ops.map(({ at, deleteCount, insert }) => ({ at, deleteCount, insert }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 文字列に手を当てる(検証と、Y.Text に当てる前の確かめに使う) */
|
|
108
|
+
export function applyTextOps(text: string, ops: TextOp[]): string {
|
|
109
|
+
let out = text;
|
|
110
|
+
for (const op of ops) out = out.slice(0, op.at) + op.insert + out.slice(op.at + op.deleteCount);
|
|
111
|
+
return out;
|
|
112
|
+
}
|