@growgroup/visual-editor 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +80 -17
- package/src/editor/collab/CollabPresence.tsx +172 -0
- package/src/editor/collab/binding.ts +269 -0
- package/src/editor/collab/color.ts +28 -0
- package/src/editor/collab/connection.ts +110 -0
- package/src/editor/collab/layer.ts +168 -0
- package/src/editor/collab/merge.ts +112 -0
- package/src/editor/collab/morph.ts +113 -0
- package/src/editor/collab/room.ts +82 -0
- package/src/editor/collab/runtime.ts +442 -0
- package/src/editor/collab/signals.ts +11 -0
- package/src/editor/collab/store.ts +192 -0
- package/src/editor/collab/useCollab.ts +266 -0
- package/src/editor/components/AiPromptPopover.tsx +3 -2
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +17 -8
- package/src/editor/components/multi-page/PageFramePreview.tsx +3 -1
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +187 -40
- package/src/editor/hooks/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +2 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +32 -1
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- package/src/editor/utils/html-utils.ts +2 -0
- package/src/editor/utils/restore-flow.ts +1 -1
- package/src/editor/utils/smart-guides.ts +1 -1
- package/src/editor/utils/storage.ts +47 -0
- package/src/index.ts +15 -0
- package/src/io.ts +29 -0
- package/src/styles/skin.css +17 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 他人の変更(共有する本文)を、編集中の紙面(#artboard)へ当てる。idiomorph で差分だけを書き換え、
|
|
3
|
+
* エディタが紙面に持っているもの(選択・編集用の属性・一時クラス・選択枠などの表示専用ノード)は残す。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Idiomorph } from 'idiomorph';
|
|
7
|
+
|
|
8
|
+
/** 紙面の中身ではなく、エディタが差し込んでいる表示専用のノード。当て込みの間だけ外して、あとで戻す */
|
|
9
|
+
const PRESERVE_SELECTOR = [
|
|
10
|
+
'.selection-box',
|
|
11
|
+
'.marquee-selection-box',
|
|
12
|
+
'.drag-ghost',
|
|
13
|
+
'.flex-drop-indicator',
|
|
14
|
+
'.nesting-drop-indicator',
|
|
15
|
+
'.auto-layout-drop-indicator',
|
|
16
|
+
'.drawing-preview',
|
|
17
|
+
'.gg-comment-layer',
|
|
18
|
+
'.gg-collab-layer',
|
|
19
|
+
'.gg-crop-ui',
|
|
20
|
+
'#gg-measure-layer',
|
|
21
|
+
'#gg-smart-guides',
|
|
22
|
+
'#gg-reorder-indicator',
|
|
23
|
+
].join(',');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* エディタだけが付ける属性。共有する本文(getCleanHtml)には無いので、当てるときに消させない。
|
|
27
|
+
* data-gg-base / data-gg-prestyle / data-gg-pre-overflow は開いた直後の指紋(保存時の書き戻しの判定に使う)
|
|
28
|
+
*/
|
|
29
|
+
const EDITOR_ATTRS = new Set([
|
|
30
|
+
'contenteditable',
|
|
31
|
+
'data-editable',
|
|
32
|
+
'data-element-id',
|
|
33
|
+
'data-shape-type',
|
|
34
|
+
'data-inline',
|
|
35
|
+
'data-gg-base',
|
|
36
|
+
'data-gg-prestyle',
|
|
37
|
+
'data-gg-pre-overflow',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** getCleanHtml が消す一時クラス。class 属性は丸ごと置き換わるので、当てたあとに付け直す */
|
|
41
|
+
const TRANSIENT_CLASSES = [
|
|
42
|
+
'selected', 'dragging', 'editing', 'rotating', 'panning',
|
|
43
|
+
'hover-preview', 'marquee-hover', 'marquee-active', 'text-editable-hover', 'drag-ghost',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const isEditorAttr = (name: string) => EDITOR_ATTRS.has(name) || name.startsWith('data-original-');
|
|
47
|
+
|
|
48
|
+
/** 比べるための姿(エディタの属性・一時クラス・表示専用ノード・書き戻しの印を除く) */
|
|
49
|
+
function comparableHtml(el: Element): string {
|
|
50
|
+
const clone = el.cloneNode(true) as Element;
|
|
51
|
+
clone.querySelectorAll(PRESERVE_SELECTOR).forEach((n) => n.remove());
|
|
52
|
+
for (const node of [clone, ...Array.from(clone.querySelectorAll('*'))]) {
|
|
53
|
+
for (const attr of Array.from(node.attributes)) {
|
|
54
|
+
if (isEditorAttr(attr.name) || attr.name === 'data-gg-dirty') node.removeAttribute(attr.name);
|
|
55
|
+
}
|
|
56
|
+
node.classList.remove(...TRANSIENT_CLASSES);
|
|
57
|
+
if (node.getAttribute('class') === '') node.removeAttribute('class');
|
|
58
|
+
}
|
|
59
|
+
return clone.outerHTML;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 紙面を html の姿にする。
|
|
64
|
+
* hold(打鍵中の要素)には触らない。hold に掛かる変更があったら heldChanged が立つ(blur のあとにもう一度当てる)
|
|
65
|
+
*/
|
|
66
|
+
export function morphArtboard(artboard: HTMLElement, html: string, hold: HTMLElement | null): { heldChanged: boolean } {
|
|
67
|
+
// 表示専用のノード(一番外側だけ)を外す。消させないだけだと、似た形の本文の要素として使い回されることがある
|
|
68
|
+
const chrome = Array.from(artboard.querySelectorAll<HTMLElement>(PRESERVE_SELECTOR)).filter(
|
|
69
|
+
(el) => !el.parentElement?.closest(PRESERVE_SELECTOR),
|
|
70
|
+
);
|
|
71
|
+
const placements = chrome.map((el) => ({ el, parent: el.parentElement, next: el.nextSibling }));
|
|
72
|
+
chrome.forEach((el) => el.remove());
|
|
73
|
+
|
|
74
|
+
const transient: [Element, string[]][] = [];
|
|
75
|
+
artboard.querySelectorAll(TRANSIENT_CLASSES.map((c) => `.${c}`).join(',')).forEach((el) => {
|
|
76
|
+
transient.push([el, TRANSIENT_CLASSES.filter((c) => el.classList.contains(c))]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let heldChanged = false;
|
|
80
|
+
try {
|
|
81
|
+
Idiomorph.morph(artboard, html, {
|
|
82
|
+
morphStyle: 'innerHTML',
|
|
83
|
+
callbacks: {
|
|
84
|
+
beforeNodeMorphed: (oldNode, newNode) => {
|
|
85
|
+
if (hold && oldNode === hold) {
|
|
86
|
+
if (!heldChanged && (newNode.nodeType !== 1 || comparableHtml(hold) !== comparableHtml(newNode as Element))) {
|
|
87
|
+
heldChanged = true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
beforeNodeRemoved: (node) => {
|
|
94
|
+
if (hold && (node === hold || node.contains?.(hold))) {
|
|
95
|
+
heldChanged = true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
beforeAttributeUpdated: (name, _node, mutation) => !(mutation === 'remove' && isEditorAttr(name)),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
for (const [el, classes] of transient) {
|
|
105
|
+
if (el.isConnected) el.classList.add(...classes);
|
|
106
|
+
}
|
|
107
|
+
for (const { el, parent, next } of placements) {
|
|
108
|
+
const target = parent && artboard.contains(parent) ? parent : artboard;
|
|
109
|
+
target.insertBefore(el, next && next.parentNode === target ? next : null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { heldChanged };
|
|
113
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中継(gg-manager の hocuspocus-server)が保存する部屋名の形。
|
|
3
|
+
*
|
|
4
|
+
* 形を外れた部屋も**中継はされる**ので動いているように見えるが、サーバーは保存しない
|
|
5
|
+
* (全員が抜けるか、翌日の掃除で中身が消える)。繋ぐ前にここで検査し、外れたら共同編集を始めない。
|
|
6
|
+
*
|
|
7
|
+
* 規則(取り決め §3 / サーバー側の persistence.ts と同じ):
|
|
8
|
+
* - 部屋名は `wf/<room>__<route の encodeURIComponent>` か `wf/<room>__@project`
|
|
9
|
+
* - room は `[A-Za-z0-9_-]` の 8〜100 文字(`__` が混ざっても、後半は `@` か `%` で始まるので境界は一意)
|
|
10
|
+
* - route の部分は `/` 始まり = `%2F` 始まりで、`encodeURIComponent` の正規形
|
|
11
|
+
* (decode してから encode し直すと同じ文字列。16 進は大文字)
|
|
12
|
+
* - encode 後の route は 1024 文字以内
|
|
13
|
+
*
|
|
14
|
+
* この検査は殻(書き戻し役・ホスト側)でも使えるように公開している(`checkCollabRoom`)。
|
|
15
|
+
* 部屋名そのものが鍵なので、外れたことを知らせるときも名前は出さない。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type CollabRoomCheck = { ok: true } | { ok: false; reason: string };
|
|
19
|
+
|
|
20
|
+
/** サーバーが保存する部屋名(persistence.ts の WF_DOCUMENT_NAME と同じ) */
|
|
21
|
+
const WF_DOCUMENT_NAME = /^wf\/([A-Za-z0-9_-]{8,100})__(@project|%2F[A-Za-z0-9%._~!*'()-]*)$/;
|
|
22
|
+
/** 部屋名を分けるためだけの緩い形(どこが外れているかを言うために使う) */
|
|
23
|
+
const WF_LOOSE = /^wf\/([A-Za-z0-9_-]+)__([\s\S]*)$/;
|
|
24
|
+
const PROJECT_PAGE = '@project';
|
|
25
|
+
/** encodeURIComponent 済み route の長さの上限 */
|
|
26
|
+
const ROUTE_MAX_LENGTH = 1024;
|
|
27
|
+
|
|
28
|
+
const SHAPE = '部屋名は wf/<room>__<route> か wf/<room>__@project の形にする';
|
|
29
|
+
const ROOM = 'room は [A-Za-z0-9_-] の 8〜100 文字にする';
|
|
30
|
+
const ROUTE_HEAD = 'route の部分は @project か、/ 始まりの route を encodeURIComponent した形(%2F…)にする';
|
|
31
|
+
const ROUTE_CANONICAL = 'route は encodeURIComponent の正規形にする(16 進は大文字。%2f は不可)';
|
|
32
|
+
const ROUTE_LENGTH = `encode 後の route は ${ROUTE_MAX_LENGTH} 文字以内にする`;
|
|
33
|
+
|
|
34
|
+
/** encodeURIComponent の正規形か(長さの上限も含めて、サーバーの isCanonicalEncodedRoute と同じ) */
|
|
35
|
+
function isCanonicalEncodedRoute(encoded: string): boolean {
|
|
36
|
+
if (encoded.length > ROUTE_MAX_LENGTH) return false;
|
|
37
|
+
try {
|
|
38
|
+
return encodeURIComponent(decodeURIComponent(encoded)) === encoded;
|
|
39
|
+
} catch {
|
|
40
|
+
return false; // 壊れた % 列(URIError)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 部屋名がサーバーに保存される形か。
|
|
46
|
+
* 外れていれば理由を返す(繋がずに、接続状態へエラーとして出すため)
|
|
47
|
+
*/
|
|
48
|
+
export function checkCollabRoom(name: unknown): CollabRoomCheck {
|
|
49
|
+
if (typeof name !== 'string' || !name) return { ok: false, reason: SHAPE };
|
|
50
|
+
if (WF_DOCUMENT_NAME.test(name) && isCanonicalEncodedRoute(pageOf(name))) return { ok: true };
|
|
51
|
+
|
|
52
|
+
const loose = WF_LOOSE.exec(name);
|
|
53
|
+
let room: string;
|
|
54
|
+
let page: string;
|
|
55
|
+
if (loose) {
|
|
56
|
+
room = loose[1];
|
|
57
|
+
page = loose[2];
|
|
58
|
+
} else {
|
|
59
|
+
// room に使えない文字が入っているときも「room が悪い」と言えるようにする
|
|
60
|
+
const rest = name.startsWith('wf/') ? name.slice(3) : null;
|
|
61
|
+
const at = rest ? rest.indexOf('__') : -1;
|
|
62
|
+
if (rest == null || at < 0) return { ok: false, reason: SHAPE };
|
|
63
|
+
room = rest.slice(0, at);
|
|
64
|
+
page = rest.slice(at + 2);
|
|
65
|
+
}
|
|
66
|
+
if (!/^[A-Za-z0-9_-]{8,100}$/.test(room)) return { ok: false, reason: ROOM };
|
|
67
|
+
if (page === PROJECT_PAGE) return { ok: true };
|
|
68
|
+
if (page.length > ROUTE_MAX_LENGTH) return { ok: false, reason: ROUTE_LENGTH };
|
|
69
|
+
// encode していない(/ のまま)・@project でも %2F でもない
|
|
70
|
+
if (page.startsWith('/') || (!page.startsWith('%2F') && isCanonicalEncodedRoute(page))) {
|
|
71
|
+
return { ok: false, reason: ROUTE_HEAD };
|
|
72
|
+
}
|
|
73
|
+
if (!isCanonicalEncodedRoute(page)) return { ok: false, reason: ROUTE_CANONICAL };
|
|
74
|
+
// ここまで通って全体の形に合わないのは、route に使えない文字が残っているとき
|
|
75
|
+
return { ok: false, reason: ROUTE_HEAD };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `wf/<room>__<page>` の page の部分(検査を通った名前にだけ使う) */
|
|
79
|
+
function pageOf(name: string): string {
|
|
80
|
+
const m = WF_DOCUMENT_NAME.exec(name);
|
|
81
|
+
return m ? m[2] : '';
|
|
82
|
+
}
|