@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.
Files changed (64) hide show
  1. package/README.md +92 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +244 -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 +27 -0
  19. package/src/editor/components/EditorContextMenu.tsx +80 -0
  20. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  21. package/src/editor/components/EditorSidebar.tsx +2 -2
  22. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
  23. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  24. package/src/editor/components/ppt/PptComments.tsx +3 -8
  25. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  26. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  27. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  28. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  29. package/src/editor/constants.ts +24 -0
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  31. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  32. package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
  33. package/src/editor/hooks/index.ts +3 -0
  34. package/src/editor/hooks/useDragResize.ts +30 -5
  35. package/src/editor/hooks/useElementActions.ts +41 -9
  36. package/src/editor/hooks/useElementSelection.ts +4 -2
  37. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  38. package/src/editor/hooks/useIframeSetup.ts +10 -1
  39. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  40. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  41. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  42. package/src/editor/hooks/useResizablePanel.ts +3 -2
  43. package/src/editor/parts.ts +2 -0
  44. package/src/editor/types.ts +4 -0
  45. package/src/editor/utils/debug.ts +2 -5
  46. package/src/editor/utils/dom-utils.ts +45 -0
  47. package/src/editor/utils/drop-target.ts +1 -1
  48. package/src/editor/utils/flex-reorder.ts +1 -1
  49. package/src/editor/utils/free-layout-tree.ts +562 -0
  50. package/src/editor/utils/free-layout.ts +794 -0
  51. package/src/editor/utils/geometry.ts +41 -3
  52. package/src/editor/utils/html-utils.ts +4 -1
  53. package/src/editor/utils/inline-text-style.ts +426 -0
  54. package/src/editor/utils/link-navigation.ts +474 -0
  55. package/src/editor/utils/restore-flow.ts +1 -1
  56. package/src/editor/utils/smart-guides.ts +1 -1
  57. package/src/editor/utils/storage.ts +47 -0
  58. package/src/editor/utils/style-utils.ts +24 -0
  59. package/src/editor/utils/tailwind-utils.ts +12 -0
  60. package/src/editor/utils/text-line-break.ts +121 -0
  61. package/src/index.ts +15 -0
  62. package/src/io.ts +29 -0
  63. package/src/styles/skin.css +17 -0
  64. package/src/version.ts +1 -1
@@ -72,6 +72,20 @@ import { EditorToolbar } from './EditorToolbar';
72
72
  import { MultiPageCanvasView } from './components/multi-page';
73
73
  import { useMultiPageCanvasOptional } from './contexts/MultiPageCanvasContext';
74
74
  import { convertToAbsolutePositioning, buildDomTree, getArtboardContent, canUngroup, updateSelectionBox } from './utils/dom-utils';
75
+ import {
76
+ enableFreeLayout,
77
+ disableFreeLayout,
78
+ enablePageFreeLayout,
79
+ disablePageFreeLayout,
80
+ isFreeLayoutContainer,
81
+ hasFreeLayout,
82
+ disableFreeLayoutTree,
83
+ FREELAYOUT_CLASS,
84
+ FREELAYOUT_HOLD_CLASS,
85
+ FREELAYOUT_ITEM_CLASS,
86
+ } from './utils/free-layout';
87
+ import { enableFreeLayoutTree, canEnableFreeLayoutTree, waitForFreeLayoutTreeReady } from './utils/free-layout-tree';
88
+ import { ConfirmDialog } from './components/shell/ConfirmDialog';
75
89
  import { extractElementInfo } from './utils/style-utils';
76
90
  import { findTableCell, insertRow, insertColumn, deleteRow, deleteColumn } from './utils/table-edit';
77
91
  import { Loader2 } from 'lucide-react';
@@ -95,6 +109,9 @@ import { Button } from '../components/ui/button';
95
109
  import { cn } from '../lib/utils';
96
110
  import { clearPartDropIndicator } from './utils/drop-target';
97
111
  import { debugLog } from './utils/debug';
112
+ import { readStorage, writeStorage } from './utils/storage';
113
+ import { useCollab } from './collab/useCollab';
114
+ import { readCollabSharedHtml, selectCollabPageActive, selectCollabUndo, selectCollabUnsynced, useCollabSelector } from './collab/store';
98
115
 
99
116
  /**
100
117
  * 自動保存のデバウンス(ms)。
@@ -322,6 +339,29 @@ function FrontendVisualEditorInner({
322
339
  const { getIdToken } = useAuth();
323
340
  const hasComponents = masterComponents.size > 0;
324
341
 
342
+ // リアルタイム共同編集(io.collab があるときだけ動く。無ければ何もしない)
343
+ const collab = useCollab({
344
+ contentId: currentContentId ?? contentId ?? null,
345
+ selectedIds: selectedElementIds.length > 0 ? selectedElementIds : selectedElement?.id ? [selectedElement.id] : [],
346
+ });
347
+ const collabRef = useRef(collab);
348
+ collabRef.current = collab;
349
+ // ページの部屋に入っている間: onSave の自動保存を呼ばず、取り消しは Y.UndoManager に切り替える
350
+ const collabPageActive = useCollabSelector(selectCollabPageActive);
351
+ const collabPageActiveRef = useRef(collabPageActive);
352
+ collabPageActiveRef.current = collabPageActive;
353
+ const collabUndoState = useCollabSelector(selectCollabUndo);
354
+ const effectiveUndo = useCallback(() => {
355
+ if (collabPageActiveRef.current) collabRef.current?.undo();
356
+ else undo();
357
+ }, [undo]);
358
+ const effectiveRedo = useCallback(() => {
359
+ if (collabPageActiveRef.current) collabRef.current?.redo();
360
+ else redo();
361
+ }, [redo]);
362
+ const effectiveCanUndo = collabPageActive ? collabUndoState.canUndo : canUndo;
363
+ const effectiveCanRedo = collabPageActive ? collabUndoState.canRedo : canRedo;
364
+
325
365
  // ドラッグ&ドロップ状態
326
366
  const [isDraggingOver, setIsDraggingOver] = useState(false);
327
367
  const dragCounterRef = useRef(0); // ドラッグイベントのカウンター(子要素の出入りを追跡)
@@ -354,14 +394,14 @@ function FrontendVisualEditorInner({
354
394
  const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
355
395
  // PowerPoint風UIとFigma風UIの切り替え(殻だけ。エンジンは共通)
356
396
  const [uiMode, setUiMode] = useState<'figma' | 'ppt'>(
357
- () => (typeof localStorage !== 'undefined' && localStorage.getItem('gg-editor:ui-mode') === 'ppt' ? 'ppt' : 'figma'),
397
+ () => (readStorage('gg-editor:ui-mode') === 'ppt' ? 'ppt' : 'figma'),
358
398
  );
359
399
  const switchUi = (mode: 'figma' | 'ppt') => {
360
400
  // 殻を差し替えるだけで編集内容は残るが、どちらのUIから見ても同じ状態で始まるよう
361
401
  // 未保存があればここで保存しておく(切替の見た目は待たせない)
362
402
  void saveIfDirtyRef.current();
363
403
  setUiMode(mode);
364
- try { localStorage.setItem('gg-editor:ui-mode', mode); } catch { /* 記憶できなくても動作は継続 */ }
404
+ writeStorage('gg-editor:ui-mode', mode);
365
405
  };
366
406
  // PowerPoint風UIのテーマ(OS設定に追従・切替は記憶)とサムネイル検索
367
407
  const { theme: pptTheme, toggleTheme: togglePptTheme } = useEditorTheme();
@@ -814,6 +854,12 @@ function FrontendVisualEditorInner({
814
854
  */
815
855
  const saveIfDirty = useCallback(async (): Promise<boolean> => {
816
856
  const state = saveStateRef.current;
857
+ // 共同編集でそのページの部屋に入っている間は、ファイルへ書くのは書き戻し役の仕事。
858
+ // ここで保存すると、書き戻し役の書いた内容と二重になる(まだ送っていない変更だけ送っておく)
859
+ if (collabPageActiveRef.current) {
860
+ collabRef.current?.flush();
861
+ return true;
862
+ }
817
863
  // ページ切替の途中(履歴の本文がまだ前のページ)は保存しない。
818
864
  // ここで送ると前のページの本文を次のページへ書いてしまう
819
865
  if (isContentSwitching()) return true;
@@ -884,7 +930,8 @@ function FrontendVisualEditorInner({
884
930
  }, [contentId]);
885
931
 
886
932
  // マルチフレームのキャンバス: フレーム名の「未保存」印と、見るだけの紙面の本文を同期する
887
- const isDirtyForCanvas = hasChanges && canUndo;
933
+ // 共同編集中は保存の概念が書き戻し役へ移り、印が立ちっぱなしになるので出さない(代わりに接続状態を出す)
934
+ const isDirtyForCanvas = !collabPageActive && hasChanges && canUndo;
888
935
  useEffect(() => {
889
936
  if (!multiPageCanvas || !contentId) return;
890
937
  multiPageCanvas.updatePageFrame(contentId, { isDirty: isDirtyForCanvas });
@@ -895,16 +942,33 @@ function FrontendVisualEditorInner({
895
942
  // eslint-disable-next-line react-hooks/exhaustive-deps
896
943
  }, [sourceHtml]);
897
944
 
898
- /** 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す */
945
+ /**
946
+ * 保存しきれていない状態でタブを閉じられたときだけ、ブラウザ既定の離脱警告を出す。
947
+ * 共同編集中は「保存」ではなく「中継へ送れているか」を見る(未同期があれば警告する)
948
+ */
949
+ const collabUnsynced = useCollabSelector(selectCollabUnsynced);
950
+ const warnOnUnload = collabPageActive ? collabUnsynced : hasChanges;
899
951
  useEffect(() => {
900
- if (!hasChanges) return;
952
+ if (!warnOnUnload) return;
901
953
  const onBeforeUnload = (e: BeforeUnloadEvent) => {
902
954
  e.preventDefault();
903
955
  e.returnValue = '';
904
956
  };
905
957
  window.addEventListener('beforeunload', onBeforeUnload);
906
958
  return () => window.removeEventListener('beforeunload', onBeforeUnload);
907
- }, [hasChanges]);
959
+ }, [warnOnUnload]);
960
+
961
+ /** 共同編集中の未同期は、タブが隠れる・閉じる直前にもう一度送る(打鍵の 120ms 間引きが残っていることがある) */
962
+ useEffect(() => {
963
+ if (!collabPageActive) return;
964
+ const flush = () => collabRef.current?.flush();
965
+ window.addEventListener('pagehide', flush);
966
+ document.addEventListener('visibilitychange', flush);
967
+ return () => {
968
+ window.removeEventListener('pagehide', flush);
969
+ document.removeEventListener('visibilitychange', flush);
970
+ };
971
+ }, [collabPageActive]);
908
972
 
909
973
  /** 閉じる/一覧へ戻る。未保存は黙って保存してから離れ、失敗したときだけ確認する */
910
974
  const handleClose = useCallback(() => {
@@ -1088,6 +1152,146 @@ function FrontendVisualEditorInner({
1088
1152
  notifyIframeChange();
1089
1153
  }, [getIframeDoc, selectedElement?.id, notifyIframeChange]);
1090
1154
 
1155
+ // ── 自由配置(webpage 専用) ──────────────────────────────
1156
+ // 構成ラフは流し込みのまま保つのが既定で、ここだけが例外を作る入口。
1157
+ // 「選んだ器の中だけ」と「ページの最上位セクション全部」の2段構えにして、
1158
+ // 全体を倒す側には必ず確認ダイアログを挟む(高さが固定され、以後は
1159
+ // 内容を足しても下へ押し出されなくなるため、取り返しの印象が強い)。
1160
+ const isWebpage = editorMode === 'webpage';
1161
+ const [pageFreeLayoutConfirm, setPageFreeLayoutConfirm] = useState<'on' | 'off' | null>(null);
1162
+
1163
+ /** 右クリック時点の選択要素を DOM から引く */
1164
+ const selectedDomElement = useCallback((): HTMLElement | null => {
1165
+ const doc = getIframeDoc();
1166
+ if (!doc || !selectedElement?.id) return null;
1167
+ return doc.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`);
1168
+ }, [getIframeDoc, selectedElement?.id]);
1169
+
1170
+ /**
1171
+ * 変換のあとの後始末。
1172
+ * notifyIframeChange が履歴の積み増しとレイヤーツリーの作り直しをやるので、
1173
+ * ここでは選択枠だけを新しい位置へ合わせ直す(変換で矩形が変わるため)
1174
+ */
1175
+ const afterFreeLayoutChange = useCallback(
1176
+ (doc: Document) => {
1177
+ notifyIframeChange();
1178
+ const el = selectedDomElement();
1179
+ if (el) requestAnimationFrame(() => updateSelectionBox(doc, el));
1180
+ },
1181
+ [notifyIframeChange, selectedDomElement],
1182
+ );
1183
+
1184
+ const handleFreeLayoutOn = useCallback(() => {
1185
+ const doc = getIframeDoc();
1186
+ const el = selectedDomElement();
1187
+ if (!doc || !el) return;
1188
+ const n = enableFreeLayout(el, doc);
1189
+ if (n === 0) {
1190
+ toast.error('絶対配置にできませんでした(子が無いか、変換で位置がずれました)');
1191
+ return;
1192
+ }
1193
+ afterFreeLayoutChange(doc);
1194
+ toast.success(`${n}個の子を絶対配置にしました`);
1195
+ }, [getIframeDoc, selectedDomElement, afterFreeLayoutChange]);
1196
+
1197
+ const handleFreeLayoutOff = useCallback(() => {
1198
+ const doc = getIframeDoc();
1199
+ const el = selectedDomElement();
1200
+ if (!doc || !el) return;
1201
+ const n = disableFreeLayout(el, doc);
1202
+ afterFreeLayoutChange(doc);
1203
+ toast.success(`${n}個の子を流し込みに戻しました`);
1204
+ }, [getIframeDoc, selectedDomElement, afterFreeLayoutChange]);
1205
+
1206
+ const handlePageFreeLayoutOn = useCallback(() => {
1207
+ const doc = getIframeDoc();
1208
+ if (!doc) return;
1209
+ const { sections, elements } = enablePageFreeLayout(doc);
1210
+ afterFreeLayoutChange(doc);
1211
+ if (sections === 0) toast.error('自由配置にできるセクションがありませんでした');
1212
+ else toast.success(`${sections}セクション・${elements}要素を自由配置にしました`);
1213
+ }, [getIframeDoc, afterFreeLayoutChange]);
1214
+
1215
+ const handlePageFreeLayoutOff = useCallback(() => {
1216
+ const doc = getIframeDoc();
1217
+ if (!doc) return;
1218
+ const { sections, elements } = disablePageFreeLayout(doc);
1219
+ afterFreeLayoutChange(doc);
1220
+ toast.success(`${sections}セクション・${elements}要素を流し込みに戻しました`);
1221
+ }, [getIframeDoc, afterFreeLayoutChange]);
1222
+
1223
+ // ツリーの絶対配置: 選んだ要素から下の箱を入れ子まで全部倒す(free-layout-tree.ts)。
1224
+ // 書体と画像の読み込みを待ってから測る。待っている間に共同編集の反映などで
1225
+ // 要素が差し替わっていたら何もしない(古い要素を測っても意味がない)
1226
+ const handleFreeLayoutTreeOn = useCallback(async () => {
1227
+ const doc = getIframeDoc();
1228
+ const el = selectedDomElement();
1229
+ if (!doc || !el) return;
1230
+ await waitForFreeLayoutTreeReady(el, doc);
1231
+ if (!el.isConnected) return;
1232
+ const r = enableFreeLayoutTree(el, doc);
1233
+ if (r.error) {
1234
+ toast.error(r.error);
1235
+ return;
1236
+ }
1237
+ if (r.items === 0) {
1238
+ toast.error('絶対配置にできる箱がありませんでした(文字の流れ・表・部品のスロットの外は対象外です)');
1239
+ return;
1240
+ }
1241
+ afterFreeLayoutChange(doc);
1242
+ toast.success(`${r.items}個の要素を絶対配置にしました`);
1243
+ }, [getIframeDoc, selectedDomElement, afterFreeLayoutChange]);
1244
+
1245
+ const handleFreeLayoutTreeOff = useCallback(() => {
1246
+ const doc = getIframeDoc();
1247
+ const el = selectedDomElement();
1248
+ if (!doc || !el) return;
1249
+ const n = disableFreeLayoutTree(el, doc);
1250
+ afterFreeLayoutChange(doc);
1251
+ toast.success(`${n}個の要素を流し込みに戻しました`);
1252
+ }, [getIframeDoc, selectedDomElement, afterFreeLayoutChange]);
1253
+
1254
+ /**
1255
+ * 右クリックのメニューの「配置」の出し分け。メニューが開いている間の DOM を見て決める
1256
+ * (ツリーの判定は木の computed style を読むので、メニューが開いていない再描画では回さない)。
1257
+ *
1258
+ * - 中に入れ子の器か、器の外で倒した要素がある … 「このツリーの絶対配置を解除する」
1259
+ * - 自身が 1 段だけの器 … 「流し込みに戻す」(見えている順に並べ直す従来の解除)
1260
+ * - どちらでもない … 「直下の子だけ絶対配置にする」
1261
+ * - 倒せる箱が残っていれば、どの場合も「このツリーをすべて絶対配置にする」
1262
+ *
1263
+ * メニューが閉じている間は何もしない。選択の反映(ELEMENT_SELECTED)はメニューを開く合図より
1264
+ * 1 テンポ遅れて届くことがあるので、選択が変わったら開いたまま決め直す
1265
+ */
1266
+ const freeLayoutMenu = useMemo(() => {
1267
+ if (!isWebpage || !contextMenuPosition) return {};
1268
+ const doc = getIframeDoc();
1269
+ const el = selectedDomElement();
1270
+ const container = !!el && isFreeLayoutContainer(el);
1271
+ const treeInside =
1272
+ !!el &&
1273
+ (!!el.querySelector(`.${FREELAYOUT_CLASS}`) ||
1274
+ (!container && !!el.querySelector(`.${FREELAYOUT_ITEM_CLASS}, .${FREELAYOUT_HOLD_CLASS}`)));
1275
+ return {
1276
+ onFreeLayoutTreeOn: el && doc && canEnableFreeLayoutTree(el, doc) ? handleFreeLayoutTreeOn : undefined,
1277
+ onFreeLayoutTreeOff: el && treeInside ? handleFreeLayoutTreeOff : undefined,
1278
+ onFreeLayoutOn: el && !container && !treeInside ? handleFreeLayoutOn : undefined,
1279
+ onFreeLayoutOff: el && container && !treeInside ? handleFreeLayoutOff : undefined,
1280
+ onPageFreeLayoutOn: () => setPageFreeLayoutConfirm('on'),
1281
+ onPageFreeLayoutOff: () => setPageFreeLayoutConfirm('off'),
1282
+ pageHasFreeLayout: !!doc && hasFreeLayout(doc),
1283
+ };
1284
+ }, [
1285
+ isWebpage,
1286
+ contextMenuPosition,
1287
+ getIframeDoc,
1288
+ selectedDomElement,
1289
+ handleFreeLayoutTreeOn,
1290
+ handleFreeLayoutTreeOff,
1291
+ handleFreeLayoutOn,
1292
+ handleFreeLayoutOff,
1293
+ ]);
1294
+
1091
1295
  // 右クリックハンドラ
1092
1296
  const handleContextMenu = useCallback((e: React.MouseEvent) => {
1093
1297
  e.preventDefault();
@@ -1928,11 +2132,11 @@ function FrontendVisualEditorInner({
1928
2132
  // undo/redo は常に生やす(履歴が無いときは内部で何もしない)。
1929
2133
  // 条件付きで undefined にするとブラウザ既定の取り消しが走ってしまう。
1930
2134
  undo: () => {
1931
- if (canUndo) undo();
2135
+ if (effectiveCanUndo) effectiveUndo();
1932
2136
  restoreFocus();
1933
2137
  },
1934
2138
  redo: () => {
1935
- if (canRedo) redo();
2139
+ if (effectiveCanRedo) effectiveRedo();
1936
2140
  restoreFocus();
1937
2141
  },
1938
2142
  delete: hasSelection ? deleteElement : undefined,
@@ -2020,10 +2224,10 @@ function FrontendVisualEditorInner({
2020
2224
  <>
2021
2225
  {(() => {
2022
2226
  const pptActions = {
2023
- undo,
2024
- redo,
2025
- canUndo,
2026
- canRedo,
2227
+ undo: effectiveUndo,
2228
+ redo: effectiveRedo,
2229
+ canUndo: effectiveCanUndo,
2230
+ canRedo: effectiveCanRedo,
2027
2231
  deleteElement: selectedElement ? deleteElement : undefined,
2028
2232
  duplicateElement: selectedElement ? duplicateElement : undefined,
2029
2233
  bringToFront: selectedElement ? bringToFront : undefined,
@@ -2218,10 +2422,10 @@ function FrontendVisualEditorInner({
2218
2422
  <EditorToolbar
2219
2423
  activeTool={activeTool}
2220
2424
  onToolChange={setActiveTool}
2221
- onUndo={undo}
2222
- onRedo={redo}
2223
- canUndo={canUndo}
2224
- canRedo={canRedo}
2425
+ onUndo={effectiveUndo}
2426
+ onRedo={effectiveRedo}
2427
+ canUndo={effectiveCanUndo}
2428
+ canRedo={effectiveCanRedo}
2225
2429
  selectedCount={selectedElementIds.length > 0 ? selectedElementIds.length : (selectedElement ? 1 : 0)}
2226
2430
  onGroup={selectedElementIds.length > 1 ? groupElements : undefined}
2227
2431
  onUngroup={selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined}
@@ -2378,6 +2582,25 @@ function FrontendVisualEditorInner({
2378
2582
  onDetachInstance={handleDetachInstance}
2379
2583
  onResetOverrides={handleResetOverrides}
2380
2584
  onPushOverridesToMain={handlePushOverridesToMain}
2585
+ {...freeLayoutMenu}
2586
+ />
2587
+
2588
+ {/* ページ一括の自由配置。版面全体の性格が変わるので必ず確認を挟む */}
2589
+ <ConfirmDialog
2590
+ open={pageFreeLayoutConfirm !== null}
2591
+ onOpenChange={(open) => { if (!open) setPageFreeLayoutConfirm(null); }}
2592
+ title={pageFreeLayoutConfirm === 'off' ? 'ページを流し込みに戻しますか?' : 'このページを自由配置にしますか?'}
2593
+ description={
2594
+ pageFreeLayoutConfirm === 'off'
2595
+ ? '自由配置にしたセクションをすべて流し込みに戻します。要素は今見えている位置の順に並び直り、絶対配置の座標は失われます。'
2596
+ : 'ページの最上位セクションをすべて自由配置にします。セクションの高さが固定され、内容を足しても下に押し出されなくなります。'
2597
+ }
2598
+ confirmLabel={pageFreeLayoutConfirm === 'off' ? '流し込みに戻す' : '自由配置にする'}
2599
+ onConfirm={() => {
2600
+ if (pageFreeLayoutConfirm === 'off') handlePageFreeLayoutOff();
2601
+ else handlePageFreeLayoutOn();
2602
+ setPageFreeLayoutConfirm(null);
2603
+ }}
2381
2604
  />
2382
2605
 
2383
2606
  {/* AI生成ポップオーバー */}
@@ -2862,7 +3085,11 @@ export function FrontendVisualEditor({
2862
3085
  try {
2863
3086
  let newHtml = '';
2864
3087
  let newLayoutMode: 'absolute' | 'auto' = 'auto';
2865
- if (loader) {
3088
+ // 共同編集: そのページの部屋に本文があればそれが最新(書き戻し役がファイルへ書く前でも正しい姿で開く)
3089
+ const shared = readCollabSharedHtml(newContentId);
3090
+ if (shared != null) {
3091
+ newHtml = shared;
3092
+ } else if (loader) {
2866
3093
  newHtml = (await loader(newContentId)) ?? '';
2867
3094
  } else if (!canUseApi) {
2868
3095
  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
+ }