@growgroup/visual-editor 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +47 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +71 -17
  5. package/src/editor/collab/CollabPresence.tsx +172 -0
  6. package/src/editor/collab/binding.ts +269 -0
  7. package/src/editor/collab/color.ts +28 -0
  8. package/src/editor/collab/connection.ts +110 -0
  9. package/src/editor/collab/layer.ts +168 -0
  10. package/src/editor/collab/merge.ts +112 -0
  11. package/src/editor/collab/morph.ts +113 -0
  12. package/src/editor/collab/room.ts +82 -0
  13. package/src/editor/collab/runtime.ts +442 -0
  14. package/src/editor/collab/signals.ts +11 -0
  15. package/src/editor/collab/store.ts +192 -0
  16. package/src/editor/collab/useCollab.ts +266 -0
  17. package/src/editor/components/AiPromptPopover.tsx +3 -2
  18. package/src/editor/components/EditorCanvas.tsx +11 -0
  19. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +3 -0
  20. package/src/editor/components/ppt/PptComments.tsx +3 -8
  21. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  22. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  23. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  24. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  25. package/src/editor/contexts/MultiPageCanvasContext.tsx +14 -11
  26. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  27. package/src/editor/hooks/useIframeSetup.ts +2 -0
  28. package/src/editor/hooks/useResizablePanel.ts +3 -2
  29. package/src/editor/utils/debug.ts +2 -5
  30. package/src/editor/utils/drop-target.ts +1 -1
  31. package/src/editor/utils/flex-reorder.ts +1 -1
  32. package/src/editor/utils/html-utils.ts +2 -0
  33. package/src/editor/utils/restore-flow.ts +1 -1
  34. package/src/editor/utils/smart-guides.ts +1 -1
  35. package/src/editor/utils/storage.ts +47 -0
  36. package/src/index.ts +15 -0
  37. package/src/io.ts +29 -0
  38. package/src/styles/skin.css +17 -0
  39. 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
- () => (typeof localStorage !== 'undefined' && localStorage.getItem('gg-editor:ui-mode') === 'ppt' ? 'ppt' : 'figma'),
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
- try { localStorage.setItem('gg-editor:ui-mode', mode); } catch { /* 記憶できなくても動作は継続 */ }
390
+ writeStorage('gg-editor:ui-mode', mode);
365
391
  };
366
392
  // PowerPoint風UIのテーマ(OS設定に追従・切替は記憶)とサムネイル検索
367
393
  const { theme: pptTheme, toggleTheme: togglePptTheme } = useEditorTheme();
@@ -814,6 +840,12 @@ function FrontendVisualEditorInner({
814
840
  */
815
841
  const saveIfDirty = useCallback(async (): Promise<boolean> => {
816
842
  const state = saveStateRef.current;
843
+ // 共同編集でそのページの部屋に入っている間は、ファイルへ書くのは書き戻し役の仕事。
844
+ // ここで保存すると、書き戻し役の書いた内容と二重になる(まだ送っていない変更だけ送っておく)
845
+ if (collabPageActiveRef.current) {
846
+ collabRef.current?.flush();
847
+ return true;
848
+ }
817
849
  // ページ切替の途中(履歴の本文がまだ前のページ)は保存しない。
818
850
  // ここで送ると前のページの本文を次のページへ書いてしまう
819
851
  if (isContentSwitching()) return true;
@@ -884,7 +916,8 @@ function FrontendVisualEditorInner({
884
916
  }, [contentId]);
885
917
 
886
918
  // マルチフレームのキャンバス: フレーム名の「未保存」印と、見るだけの紙面の本文を同期する
887
- const isDirtyForCanvas = hasChanges && canUndo;
919
+ // 共同編集中は保存の概念が書き戻し役へ移り、印が立ちっぱなしになるので出さない(代わりに接続状態を出す)
920
+ const isDirtyForCanvas = !collabPageActive && hasChanges && canUndo;
888
921
  useEffect(() => {
889
922
  if (!multiPageCanvas || !contentId) return;
890
923
  multiPageCanvas.updatePageFrame(contentId, { isDirty: isDirtyForCanvas });
@@ -895,16 +928,33 @@ function FrontendVisualEditorInner({
895
928
  // eslint-disable-next-line react-hooks/exhaustive-deps
896
929
  }, [sourceHtml]);
897
930
 
898
- /** 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す */
931
+ /**
932
+ * 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す。
933
+ * 共同編集中は「保存」ではなく「中継へ送れているか」を見る(未同期があれば警告する)
934
+ */
935
+ const collabUnsynced = useCollabSelector(selectCollabUnsynced);
936
+ const warnOnUnload = collabPageActive ? collabUnsynced : hasChanges;
899
937
  useEffect(() => {
900
- if (!hasChanges) return;
938
+ if (!warnOnUnload) return;
901
939
  const onBeforeUnload = (e: BeforeUnloadEvent) => {
902
940
  e.preventDefault();
903
941
  e.returnValue = '';
904
942
  };
905
943
  window.addEventListener('beforeunload', onBeforeUnload);
906
944
  return () => window.removeEventListener('beforeunload', onBeforeUnload);
907
- }, [hasChanges]);
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]);
908
958
 
909
959
  /** 閉じる/一覧へ戻る。未保存は黙って保存してから離れ、失敗したときだけ確認する */
910
960
  const handleClose = useCallback(() => {
@@ -1928,11 +1978,11 @@ function FrontendVisualEditorInner({
1928
1978
  // undo/redo は常に生やす(履歴が無いときは内部で何もしない)。
1929
1979
  // 条件付きで undefined にするとブラウザ既定の取り消しが走ってしまう。
1930
1980
  undo: () => {
1931
- if (canUndo) undo();
1981
+ if (effectiveCanUndo) effectiveUndo();
1932
1982
  restoreFocus();
1933
1983
  },
1934
1984
  redo: () => {
1935
- if (canRedo) redo();
1985
+ if (effectiveCanRedo) effectiveRedo();
1936
1986
  restoreFocus();
1937
1987
  },
1938
1988
  delete: hasSelection ? deleteElement : undefined,
@@ -2020,10 +2070,10 @@ function FrontendVisualEditorInner({
2020
2070
  <>
2021
2071
  {(() => {
2022
2072
  const pptActions = {
2023
- undo,
2024
- redo,
2025
- canUndo,
2026
- canRedo,
2073
+ undo: effectiveUndo,
2074
+ redo: effectiveRedo,
2075
+ canUndo: effectiveCanUndo,
2076
+ canRedo: effectiveCanRedo,
2027
2077
  deleteElement: selectedElement ? deleteElement : undefined,
2028
2078
  duplicateElement: selectedElement ? duplicateElement : undefined,
2029
2079
  bringToFront: selectedElement ? bringToFront : undefined,
@@ -2218,10 +2268,10 @@ function FrontendVisualEditorInner({
2218
2268
  <EditorToolbar
2219
2269
  activeTool={activeTool}
2220
2270
  onToolChange={setActiveTool}
2221
- onUndo={undo}
2222
- onRedo={redo}
2223
- canUndo={canUndo}
2224
- canRedo={canRedo}
2271
+ onUndo={effectiveUndo}
2272
+ onRedo={effectiveRedo}
2273
+ canUndo={effectiveCanUndo}
2274
+ canRedo={effectiveCanRedo}
2225
2275
  selectedCount={selectedElementIds.length > 0 ? selectedElementIds.length : (selectedElement ? 1 : 0)}
2226
2276
  onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
2227
2277
  onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
@@ -2862,7 +2912,11 @@ export function FrontendVisualEditor({
2862
2912
  try {
2863
2913
  let newHtml = '';
2864
2914
  let newLayoutMode: 'absolute' | 'auto' = 'auto';
2865
- if (loader) {
2915
+ // 共同編集: そのページの部屋に本文があればそれが最新(書き戻し役がファイルへ書く前でも正しい姿で開く)
2916
+ const shared = readCollabSharedHtml(newContentId);
2917
+ if (shared != null) {
2918
+ newHtml = shared;
2919
+ } else if (loader) {
2866
2920
  newHtml = (await loader(newContentId)) ?? '';
2867
2921
  } else if (!canUseApi) {
2868
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
+ }