@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,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
|
+
}
|
|
@@ -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 };
|