@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
|
@@ -95,6 +95,9 @@ import { Button } from '../components/ui/button';
|
|
|
95
95
|
import { cn } from '../lib/utils';
|
|
96
96
|
import { clearPartDropIndicator } from './utils/drop-target';
|
|
97
97
|
import { debugLog } from './utils/debug';
|
|
98
|
+
import { readStorage, writeStorage } from './utils/storage';
|
|
99
|
+
import { useCollab } from './collab/useCollab';
|
|
100
|
+
import { readCollabSharedHtml, selectCollabPageActive, selectCollabUndo, selectCollabUnsynced, useCollabSelector } from './collab/store';
|
|
98
101
|
|
|
99
102
|
/**
|
|
100
103
|
* 自動保存のデバウンス(ms)。
|
|
@@ -322,6 +325,29 @@ function FrontendVisualEditorInner({
|
|
|
322
325
|
const { getIdToken } = useAuth();
|
|
323
326
|
const hasComponents = masterComponents.size > 0;
|
|
324
327
|
|
|
328
|
+
// リアルタイム共同編集(io.collab があるときだけ動く。無ければ何もしない)
|
|
329
|
+
const collab = useCollab({
|
|
330
|
+
contentId: currentContentId ?? contentId ?? null,
|
|
331
|
+
selectedIds: selectedElementIds.length > 0 ? selectedElementIds : selectedElement?.id ? [selectedElement.id] : [],
|
|
332
|
+
});
|
|
333
|
+
const collabRef = useRef(collab);
|
|
334
|
+
collabRef.current = collab;
|
|
335
|
+
// ページの部屋に入っている間: onSave の自動保存を呼ばず、取り消しは Y.UndoManager に切り替える
|
|
336
|
+
const collabPageActive = useCollabSelector(selectCollabPageActive);
|
|
337
|
+
const collabPageActiveRef = useRef(collabPageActive);
|
|
338
|
+
collabPageActiveRef.current = collabPageActive;
|
|
339
|
+
const collabUndoState = useCollabSelector(selectCollabUndo);
|
|
340
|
+
const effectiveUndo = useCallback(() => {
|
|
341
|
+
if (collabPageActiveRef.current) collabRef.current?.undo();
|
|
342
|
+
else undo();
|
|
343
|
+
}, [undo]);
|
|
344
|
+
const effectiveRedo = useCallback(() => {
|
|
345
|
+
if (collabPageActiveRef.current) collabRef.current?.redo();
|
|
346
|
+
else redo();
|
|
347
|
+
}, [redo]);
|
|
348
|
+
const effectiveCanUndo = collabPageActive ? collabUndoState.canUndo : canUndo;
|
|
349
|
+
const effectiveCanRedo = collabPageActive ? collabUndoState.canRedo : canRedo;
|
|
350
|
+
|
|
325
351
|
// ドラッグ&ドロップ状態
|
|
326
352
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
327
353
|
const dragCounterRef = useRef(0); // ドラッグイベントのカウンター(子要素の出入りを追跡)
|
|
@@ -354,14 +380,14 @@ function FrontendVisualEditorInner({
|
|
|
354
380
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
|
355
381
|
// PowerPoint風UIとFigma風UIの切り替え(殻だけ。エンジンは共通)
|
|
356
382
|
const [uiMode, setUiMode] = useState<'figma' | 'ppt'>(
|
|
357
|
-
() => (
|
|
383
|
+
() => (readStorage('gg-editor:ui-mode') === 'ppt' ? 'ppt' : 'figma'),
|
|
358
384
|
);
|
|
359
385
|
const switchUi = (mode: 'figma' | 'ppt') => {
|
|
360
386
|
// 殻を差し替えるだけで編集内容は残るが、どちらのUIから見ても同じ状態で始まるよう
|
|
361
387
|
// 未保存があればここで保存しておく(切替の見た目は待たせない)
|
|
362
388
|
void saveIfDirtyRef.current();
|
|
363
389
|
setUiMode(mode);
|
|
364
|
-
|
|
390
|
+
writeStorage('gg-editor:ui-mode', mode);
|
|
365
391
|
};
|
|
366
392
|
// PowerPoint風UIのテーマ(OS設定に追従・切替は記憶)とサムネイル検索
|
|
367
393
|
const { theme: pptTheme, toggleTheme: togglePptTheme } = useEditorTheme();
|
|
@@ -374,6 +400,15 @@ function FrontendVisualEditorInner({
|
|
|
374
400
|
useEffect(() => { if (pptCommentsOpen) setCommentsMounted(true); }, [pptCommentsOpen]);
|
|
375
401
|
// PowerPoint風UIの右ペイン「図の書式設定」(影・反射・光彩・ぼかし)
|
|
376
402
|
const [pptFormatPaneOpen, setPptFormatPaneOpen] = useState(false);
|
|
403
|
+
// コメントツール(C)にした時点で右のコメントパネルも開く。範囲を描き終えるまで開かないと、
|
|
404
|
+
// 書き込む先が見えないまま範囲を引くことになる。入口(ツールバー・校閲タブ・
|
|
405
|
+
// パネルの「範囲を指定」・キー C)はどれも activeTool を変えるだけなので、ここ 1 か所で拾う。
|
|
406
|
+
// ツールを抜けても閉じない(閉じるのは利用者の操作)。開いているときは state が同じ値で止まる
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (activeTool !== 'comment' || !can('commentAction')) return;
|
|
409
|
+
setPptFormatPaneOpen(false);
|
|
410
|
+
setPptCommentsOpen(true);
|
|
411
|
+
}, [activeTool]);
|
|
377
412
|
const [pptCommentFocus, setPptCommentFocus] = useState(0);
|
|
378
413
|
const [pptActiveThread, setPptActiveThread] = useState<string | null>(null);
|
|
379
414
|
// コメントツール(C)で指定した範囲の下書き。投稿・解除・Esc・ページ切替で消える
|
|
@@ -805,6 +840,12 @@ function FrontendVisualEditorInner({
|
|
|
805
840
|
*/
|
|
806
841
|
const saveIfDirty = useCallback(async (): Promise<boolean> => {
|
|
807
842
|
const state = saveStateRef.current;
|
|
843
|
+
// 共同編集でそのページの部屋に入っている間は、ファイルへ書くのは書き戻し役の仕事。
|
|
844
|
+
// ここで保存すると、書き戻し役の書いた内容と二重になる(まだ送っていない変更だけ送っておく)
|
|
845
|
+
if (collabPageActiveRef.current) {
|
|
846
|
+
collabRef.current?.flush();
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
808
849
|
// ページ切替の途中(履歴の本文がまだ前のページ)は保存しない。
|
|
809
850
|
// ここで送ると前のページの本文を次のページへ書いてしまう
|
|
810
851
|
if (isContentSwitching()) return true;
|
|
@@ -875,7 +916,8 @@ function FrontendVisualEditorInner({
|
|
|
875
916
|
}, [contentId]);
|
|
876
917
|
|
|
877
918
|
// マルチフレームのキャンバス: フレーム名の「未保存」印と、見るだけの紙面の本文を同期する
|
|
878
|
-
|
|
919
|
+
// 共同編集中は保存の概念が書き戻し役へ移り、印が立ちっぱなしになるので出さない(代わりに接続状態を出す)
|
|
920
|
+
const isDirtyForCanvas = !collabPageActive && hasChanges && canUndo;
|
|
879
921
|
useEffect(() => {
|
|
880
922
|
if (!multiPageCanvas || !contentId) return;
|
|
881
923
|
multiPageCanvas.updatePageFrame(contentId, { isDirty: isDirtyForCanvas });
|
|
@@ -886,16 +928,33 @@ function FrontendVisualEditorInner({
|
|
|
886
928
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
887
929
|
}, [sourceHtml]);
|
|
888
930
|
|
|
889
|
-
/**
|
|
931
|
+
/**
|
|
932
|
+
* 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す。
|
|
933
|
+
* 共同編集中は「保存」ではなく「中継へ送れているか」を見る(未同期があれば警告する)
|
|
934
|
+
*/
|
|
935
|
+
const collabUnsynced = useCollabSelector(selectCollabUnsynced);
|
|
936
|
+
const warnOnUnload = collabPageActive ? collabUnsynced : hasChanges;
|
|
890
937
|
useEffect(() => {
|
|
891
|
-
if (!
|
|
938
|
+
if (!warnOnUnload) return;
|
|
892
939
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
893
940
|
e.preventDefault();
|
|
894
941
|
e.returnValue = '';
|
|
895
942
|
};
|
|
896
943
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
897
944
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
898
|
-
}, [
|
|
945
|
+
}, [warnOnUnload]);
|
|
946
|
+
|
|
947
|
+
/** 共同編集中の未同期は、タブが隠れる・閉じる直前にもう一度送る(打鍵の 120ms 間引きが残っていることがある) */
|
|
948
|
+
useEffect(() => {
|
|
949
|
+
if (!collabPageActive) return;
|
|
950
|
+
const flush = () => collabRef.current?.flush();
|
|
951
|
+
window.addEventListener('pagehide', flush);
|
|
952
|
+
document.addEventListener('visibilitychange', flush);
|
|
953
|
+
return () => {
|
|
954
|
+
window.removeEventListener('pagehide', flush);
|
|
955
|
+
document.removeEventListener('visibilitychange', flush);
|
|
956
|
+
};
|
|
957
|
+
}, [collabPageActive]);
|
|
899
958
|
|
|
900
959
|
/** 閉じる/一覧へ戻る。未保存は黙って保存してから離れ、失敗したときだけ確認する */
|
|
901
960
|
const handleClose = useCallback(() => {
|
|
@@ -1919,11 +1978,11 @@ function FrontendVisualEditorInner({
|
|
|
1919
1978
|
// undo/redo は常に生やす(履歴が無いときは内部で何もしない)。
|
|
1920
1979
|
// 条件付きで undefined にするとブラウザ既定の取り消しが走ってしまう。
|
|
1921
1980
|
undo: () => {
|
|
1922
|
-
if (
|
|
1981
|
+
if (effectiveCanUndo) effectiveUndo();
|
|
1923
1982
|
restoreFocus();
|
|
1924
1983
|
},
|
|
1925
1984
|
redo: () => {
|
|
1926
|
-
if (
|
|
1985
|
+
if (effectiveCanRedo) effectiveRedo();
|
|
1927
1986
|
restoreFocus();
|
|
1928
1987
|
},
|
|
1929
1988
|
delete: hasSelection ? deleteElement : undefined,
|
|
@@ -2011,10 +2070,10 @@ function FrontendVisualEditorInner({
|
|
|
2011
2070
|
<>
|
|
2012
2071
|
{(() => {
|
|
2013
2072
|
const pptActions = {
|
|
2014
|
-
undo,
|
|
2015
|
-
redo,
|
|
2016
|
-
canUndo,
|
|
2017
|
-
canRedo,
|
|
2073
|
+
undo: effectiveUndo,
|
|
2074
|
+
redo: effectiveRedo,
|
|
2075
|
+
canUndo: effectiveCanUndo,
|
|
2076
|
+
canRedo: effectiveCanRedo,
|
|
2018
2077
|
deleteElement: selectedElement ? deleteElement : undefined,
|
|
2019
2078
|
duplicateElement: selectedElement ? duplicateElement : undefined,
|
|
2020
2079
|
bringToFront: selectedElement ? bringToFront : undefined,
|
|
@@ -2209,10 +2268,10 @@ function FrontendVisualEditorInner({
|
|
|
2209
2268
|
<EditorToolbar
|
|
2210
2269
|
activeTool={activeTool}
|
|
2211
2270
|
onToolChange={setActiveTool}
|
|
2212
|
-
onUndo={
|
|
2213
|
-
onRedo={
|
|
2214
|
-
canUndo={
|
|
2215
|
-
canRedo={
|
|
2271
|
+
onUndo={effectiveUndo}
|
|
2272
|
+
onRedo={effectiveRedo}
|
|
2273
|
+
canUndo={effectiveCanUndo}
|
|
2274
|
+
canRedo={effectiveCanRedo}
|
|
2216
2275
|
selectedCount={selectedElementIds.length > 0 ? selectedElementIds.length : (selectedElement ? 1 : 0)}
|
|
2217
2276
|
onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
|
|
2218
2277
|
onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
|
|
@@ -2853,7 +2912,11 @@ export function FrontendVisualEditor({
|
|
|
2853
2912
|
try {
|
|
2854
2913
|
let newHtml = '';
|
|
2855
2914
|
let newLayoutMode: 'absolute' | 'auto' = 'auto';
|
|
2856
|
-
|
|
2915
|
+
// 共同編集: そのページの部屋に本文があればそれが最新(書き戻し役がファイルへ書く前でも正しい姿で開く)
|
|
2916
|
+
const shared = readCollabSharedHtml(newContentId);
|
|
2917
|
+
if (shared != null) {
|
|
2918
|
+
newHtml = shared;
|
|
2919
|
+
} else if (loader) {
|
|
2857
2920
|
newHtml = (await loader(newContentId)) ?? '';
|
|
2858
2921
|
} else if (!canUseApi) {
|
|
2859
2922
|
newHtml = listItem?.thumbnailHtml ?? '';
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 共同編集の表示(ヘッダーの参加者・接続状態、フレーム名とページ一覧の色の点)。
|
|
5
|
+
* store.ts を購読するだけ。io.collab が無ければ何も描かない。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback } from 'react';
|
|
9
|
+
import { AlertTriangle, CheckCircle2, CloudOff, Loader2, PauseCircle } from 'lucide-react';
|
|
10
|
+
import { useEditorContext } from '../EditorContext';
|
|
11
|
+
import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
|
|
12
|
+
import { useCollabSelector, type CollabSnapshot } from './store';
|
|
13
|
+
import { initialOf } from './color';
|
|
14
|
+
|
|
15
|
+
/** ヘッダーに並べるアバターの数(超えた分は +n) */
|
|
16
|
+
const MAX_AVATARS = 5;
|
|
17
|
+
|
|
18
|
+
const selectHeader = (s: CollabSnapshot) => ({
|
|
19
|
+
enabled: s.enabled,
|
|
20
|
+
ready: s.ready,
|
|
21
|
+
status: s.status,
|
|
22
|
+
roomError: s.roomError,
|
|
23
|
+
self: s.self,
|
|
24
|
+
peers: s.peers,
|
|
25
|
+
bridges: s.bridges,
|
|
26
|
+
requireBridge: s.requireBridge,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** 参加者のアバターと書き戻し先の数。アバターのクリックでその人のページへ移る */
|
|
30
|
+
export function CollabPresence() {
|
|
31
|
+
const s = useCollabSelector(selectHeader);
|
|
32
|
+
const canvas = useMultiPageCanvasOptional();
|
|
33
|
+
const { onContentChange, currentContentId, contentList } = useEditorContext();
|
|
34
|
+
|
|
35
|
+
const goTo = useCallback(
|
|
36
|
+
(id: string | null) => {
|
|
37
|
+
if (!id) return;
|
|
38
|
+
if (canvas) {
|
|
39
|
+
// 編集をそのページへ移し、視点も寄せる(activatePage 自体は「見えていれば動かさない」)
|
|
40
|
+
void canvas.activatePage(id).then((moved) => {
|
|
41
|
+
if (moved) canvas.focusPage(id);
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (id !== currentContentId) void onContentChange?.(id);
|
|
46
|
+
},
|
|
47
|
+
[canvas, currentContentId, onContentChange],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!s.enabled || !s.self) return null;
|
|
51
|
+
// 部屋名が中継に保存されない形のときは、参加者ではなく理由を出す(繋いでいないので参加者は居ない)
|
|
52
|
+
if (s.status === 'error') {
|
|
53
|
+
return (
|
|
54
|
+
<div data-collab-presence className="ed-collab-presence">
|
|
55
|
+
<span
|
|
56
|
+
data-collab-room-error
|
|
57
|
+
data-collab-status="error"
|
|
58
|
+
role="alert"
|
|
59
|
+
className="ed-collab-bridge ed-collab-bridge-alert"
|
|
60
|
+
title={`部屋名が中継に保存されない形なので繋いでいません(${s.roomError ?? ''})。保存は今までどおりファイルへ行われます`}
|
|
61
|
+
>
|
|
62
|
+
<AlertTriangle aria-hidden="true" className="h-3.5 w-3.5" />
|
|
63
|
+
共同編集できません(部屋名)
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const shown = s.peers.slice(0, MAX_AVATARS);
|
|
69
|
+
const more = s.peers.length - shown.length;
|
|
70
|
+
const pageTitle = (id: string | null) => (id ? contentList.find((c) => c.id === id)?.title : undefined);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div data-collab-presence className="ed-collab-presence">
|
|
74
|
+
{s.ready &&
|
|
75
|
+
(s.bridges > 0 ? (
|
|
76
|
+
<span data-collab-bridge={s.bridges} className="ed-collab-bridge" title="変更をファイルへ書き戻している dev サーバーの数">
|
|
77
|
+
書き戻し先 {s.bridges}
|
|
78
|
+
</span>
|
|
79
|
+
) : s.requireBridge ? (
|
|
80
|
+
<span data-collab-bridge="0" role="alert" className="ed-collab-bridge ed-collab-bridge-alert">
|
|
81
|
+
<AlertTriangle aria-hidden="true" className="h-3.5 w-3.5" />
|
|
82
|
+
書き戻し先がありません。変更は保存されません
|
|
83
|
+
</span>
|
|
84
|
+
) : (
|
|
85
|
+
<span data-collab-bridge="0" className="ed-collab-bridge ed-collab-bridge-warn" title="書き戻し役(dev サーバー)が居ないので、変更はファイルに書かれていません">
|
|
86
|
+
書き戻し先 0
|
|
87
|
+
</span>
|
|
88
|
+
))}
|
|
89
|
+
<div className="ed-collab-avatars" aria-label="共同編集の参加者">
|
|
90
|
+
{shown.map((p) => {
|
|
91
|
+
const title = pageTitle(p.contentId);
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
key={p.user.id}
|
|
95
|
+
type="button"
|
|
96
|
+
data-collab-avatar={p.user.id}
|
|
97
|
+
data-collab-page={p.contentId ?? ''}
|
|
98
|
+
className="ed-collab-avatar"
|
|
99
|
+
style={{ backgroundColor: p.user.color }}
|
|
100
|
+
title={title ? `${p.user.name}: ${title}(クリックでこのページへ)` : p.user.name}
|
|
101
|
+
aria-label={`${p.user.name}${title ? `(${title})` : ''}のページへ移る`}
|
|
102
|
+
onClick={() => goTo(p.contentId)}
|
|
103
|
+
>
|
|
104
|
+
{initialOf(p.user.name)}
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
{more > 0 && (
|
|
109
|
+
<span className="ed-collab-avatar ed-collab-avatar-more" title={s.peers.slice(MAX_AVATARS).map((p) => p.user.name).join('、')}>
|
|
110
|
+
+{more}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
<span data-collab-self className="ed-collab-avatar ed-collab-avatar-self" style={{ backgroundColor: s.self.color }} title={`${s.self.name}(自分)`}>
|
|
114
|
+
{initialOf(s.self.name)}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const selectStatus = (s: CollabSnapshot) => ({ status: s.status, pending: s.pending, unsynced: s.unsynced, roomError: s.roomError });
|
|
122
|
+
|
|
123
|
+
/** 接続状態(ページの部屋に入っている間、保存の状態の代わりに出す) */
|
|
124
|
+
export function CollabStatusPill() {
|
|
125
|
+
const { status, pending, roomError } = useCollabSelector(selectStatus);
|
|
126
|
+
const view = status === 'error'
|
|
127
|
+
? {
|
|
128
|
+
label: '共同編集できません',
|
|
129
|
+
Icon: AlertTriangle,
|
|
130
|
+
title: `部屋名が中継に保存されない形なので繋いでいません(${roomError ?? ''})。保存は今までどおりファイルへ行われます`,
|
|
131
|
+
}
|
|
132
|
+
: pending
|
|
133
|
+
? { label: '反映待ち', Icon: PauseCircle, title: '入力中の要素に他の人の変更があります。入力を終えると反映します' }
|
|
134
|
+
: {
|
|
135
|
+
synced: { label: '同期済み', Icon: CheckCircle2, title: '変更は共同編集の中継に届いています' },
|
|
136
|
+
syncing: { label: '同期中…', Icon: Loader2, title: '変更を中継へ送っています' },
|
|
137
|
+
offline: { label: 'オフライン', Icon: CloudOff, title: '中継に繋がっていません。繋がると変更が送られます' },
|
|
138
|
+
error: { label: '共同編集できません', Icon: AlertTriangle, title: '部屋名が中継に保存されない形です' },
|
|
139
|
+
}[status];
|
|
140
|
+
return (
|
|
141
|
+
<span
|
|
142
|
+
data-topbar="save-status"
|
|
143
|
+
data-collab-status={status}
|
|
144
|
+
data-collab-pending={pending ? '1' : undefined}
|
|
145
|
+
role={status === 'error' ? 'alert' : 'status'}
|
|
146
|
+
aria-live="polite"
|
|
147
|
+
className="ed-save-status"
|
|
148
|
+
title={view.title}
|
|
149
|
+
>
|
|
150
|
+
<view.Icon aria-hidden="true" className={`h-4 w-4 ${status === 'syncing' && !pending ? 'animate-spin' : ''}`} />
|
|
151
|
+
{view.label}
|
|
152
|
+
</span>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** フレーム名・ページ一覧に出す、そのページに居る参加者の色の点 */
|
|
157
|
+
export function CollabPageDots({ contentId }: { contentId: string }) {
|
|
158
|
+
const select = useCallback(
|
|
159
|
+
(s: CollabSnapshot) =>
|
|
160
|
+
s.enabled ? s.peers.filter((p) => p.contentId === contentId).map((p) => ({ id: p.user.id, name: p.user.name, color: p.user.color })) : [],
|
|
161
|
+
[contentId],
|
|
162
|
+
);
|
|
163
|
+
const users = useCollabSelector(select);
|
|
164
|
+
if (users.length === 0) return null;
|
|
165
|
+
return (
|
|
166
|
+
<span className="ed-collab-dots" data-collab-dots={contentId} aria-label={`${users.map((u) => u.name).join('、')}が編集中`}>
|
|
167
|
+
{users.map((u) => (
|
|
168
|
+
<span key={u.id} className="ed-collab-dot" data-collab-dot={u.id} style={{ backgroundColor: u.color }} title={u.name} />
|
|
169
|
+
))}
|
|
170
|
+
</span>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -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
|
+
}
|