@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.
Files changed (41) hide show
  1. package/README.md +48 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +80 -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 +17 -8
  20. package/src/editor/components/multi-page/PageFramePreview.tsx +3 -1
  21. package/src/editor/components/ppt/PptComments.tsx +3 -8
  22. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  23. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  24. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  25. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  26. package/src/editor/contexts/MultiPageCanvasContext.tsx +187 -40
  27. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  28. package/src/editor/hooks/useIframeSetup.ts +2 -0
  29. package/src/editor/hooks/useResizablePanel.ts +3 -2
  30. package/src/editor/utils/debug.ts +2 -5
  31. package/src/editor/utils/dom-utils.ts +32 -1
  32. package/src/editor/utils/drop-target.ts +1 -1
  33. package/src/editor/utils/flex-reorder.ts +1 -1
  34. package/src/editor/utils/html-utils.ts +2 -0
  35. package/src/editor/utils/restore-flow.ts +1 -1
  36. package/src/editor/utils/smart-guides.ts +1 -1
  37. package/src/editor/utils/storage.ts +47 -0
  38. package/src/index.ts +15 -0
  39. package/src/io.ts +29 -0
  40. package/src/styles/skin.css +17 -0
  41. package/src/version.ts +1 -1
@@ -40,6 +40,7 @@ import { useEditorView } from './EditorViewContext';
40
40
  import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
41
41
  import type { EditorMode } from './EditorToolContext';
42
42
  import { io, type PreviewStyle } from '../../io';
43
+ import { readStorage, writeStorage } from '../utils/storage';
43
44
 
44
45
  // ============================================================
45
46
  // 定数
@@ -56,6 +57,14 @@ export const MAX_ZOOM = 4;
56
57
  const ZOOM_STEP = 1.25;
57
58
  /** 全体表示・ページ表示のときの余白(px、画面上) */
58
59
  const FIT_PADDING = 72;
60
+ /** 画面の縁からこれだけ内側にあれば「見えている」とする(px、画面上) */
61
+ const REVEAL_MARGIN = 48;
62
+ /**
63
+ * ホスト発の切替で「そのページが見えている」と認めるフレームの画面上の幅(px)。
64
+ * 全体表示(2%)だと 1920 の紙面が 38px の点にしかならず、画面には入っていても
65
+ * どこへ移ったのか分からない。これを下回るならページを画面に合わせて寄る
66
+ */
67
+ const FOCUS_MIN_WIDTH = 400;
59
68
  /** 上と左の定規の太さ(px)。全体表示の余白に足す */
60
69
  export const RULER_SIZE = 20;
61
70
  /** ビューのアニメーション(ms) */
@@ -63,14 +72,29 @@ const VIEW_ANIMATION_MS = 240;
63
72
  /** 見るだけの紙面を同時に読む枚数 */
64
73
  const PREVIEW_PARALLEL = 4;
65
74
  /**
66
- * 見るだけの紙面を画像で描く倍率のしきい値。これ未満に縮小されている間は
67
- * srcdoc の iframe ではなく contentList[].thumbnail の画像を出す。
68
- * 判定は操作が終わってからしか動かさない(縮小の途中で 26 枚を差し替えない)。
69
- * 行き来でちらつかないように戻りは別のしきい値にする(ヒステリシス)
75
+ * 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
76
+ * どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
77
+ * 実際の上限はこれより小さくなることがある ── getPreviewImageZoomCap() を見ること
70
78
  */
71
79
  export const PREVIEW_IMAGE_ZOOM = 0.5;
72
- /** 画像 → iframe に戻す倍率(PREVIEW_IMAGE_ZOOM 以上)。iframe → 画像は PREVIEW_IMAGE_ZOOM_OUT 未満 */
80
+ /** 画像 → iframe に戻す倍率(上限の最大値のとき)。上限に対する比は下の HYSTERESIS */
73
81
  export const PREVIEW_IMAGE_ZOOM_OUT = 0.4;
82
+ /**
83
+ * 画像を引き伸ばしてよい上限。1.25 = 画面の画素に対して 25% まで。
84
+ * これを超えると Retina(DPR 2)で文字がにじんで見える(実測: 960px の画像を
85
+ * 1766 device px の枠に出すと 1.84 倍で、同じ倍率の iframe と並べて粗さが分かる)
86
+ */
87
+ export const PREVIEW_IMAGE_MAX_STRETCH = 1.25;
88
+ /**
89
+ * 画像の実寸がまだ 1 枚も分からないときに仮定する「画像の幅 ÷ 紙面の幅」。
90
+ * 0.5 = 1920 の紙面に 960px の画像(殻の既定)
91
+ */
92
+ const PREVIEW_IMAGE_PRESUMED_RATIO = 0.5;
93
+ /**
94
+ * 上限に対する「画像に落とす」側の比(ヒステリシス)。
95
+ * 画像 → iframe は上限以上、iframe → 画像は上限 × これ未満。境目で行き来しないため
96
+ */
97
+ export const PREVIEW_IMAGE_ZOOM_HYSTERESIS = PREVIEW_IMAGE_ZOOM_OUT / PREVIEW_IMAGE_ZOOM;
74
98
 
75
99
  /** フレームどうしの間隔(紙面の座標系、等倍のとき)。フレーム名を出すかの判定にも使う */
76
100
  export const FRAME_GAP: Record<EditorMode, { x: number; y: number }> = {
@@ -197,6 +221,11 @@ export interface MultiPageCanvasContextValue {
197
221
  zoomToActual: () => void;
198
222
  /** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
199
223
  revealPage: (id: string) => void;
224
+ /**
225
+ * そのページへ視点を寄せる。見えていなければ寄せ、点にしか見えない倍率ならページに合わせる
226
+ * (ホスト発のページ切替・共同編集のアバターのクリックで使う)
227
+ */
228
+ focusPage: (id: string) => void;
200
229
 
201
230
  // ---- ページ切替
202
231
  /**
@@ -228,6 +257,19 @@ export interface MultiPageCanvasContextValue {
228
257
  */
229
258
  requestPreviewSlot: (id: string, grant: () => void) => void;
230
259
  releasePreviewSlot: (id: string) => void;
260
+
261
+ // ---- 紙面の画像(contentList[].thumbnail)
262
+ /**
263
+ * 紙面を画像で描いてよい倍率の上限。画像の解像度と devicePixelRatio から決める
264
+ * (= min(PREVIEW_IMAGE_ZOOM, 画像の幅 × 1.25 ÷ (紙面の幅 × DPR)))。
265
+ * 上限はキャンバス全体で 1 つで、報告された中でいちばん粗い画像に合わせる。
266
+ * DPR は呼ぶたびに読む(別のディスプレイへ移すと変わる)
267
+ */
268
+ getPreviewImageZoomCap: () => number;
269
+ /** 画像の実寸(naturalWidth)を報告する。PageFramePreview が onLoad で呼ぶ */
270
+ reportThumbnailWidth: (id: string, naturalWidth: number) => void;
271
+ /** 上限が変わった回数。React 側が判定をやり直すきっかけに使う */
272
+ previewImageCapVersion: number;
231
273
  }
232
274
 
233
275
  // ============================================================
@@ -468,9 +510,9 @@ interface MultiPageCanvasProviderProps {
468
510
  type PersistedView = { zoom: number; offset: { x: number; y: number }; rulers?: boolean };
469
511
 
470
512
  function readPersistedView(key: string | null | undefined): PersistedView | null {
471
- if (!key || typeof localStorage === 'undefined') return null;
513
+ if (!key) return null;
472
514
  try {
473
- const raw = localStorage.getItem(`gg-editor:canvas-view:${key}`);
515
+ const raw = readStorage(`gg-editor:canvas-view:${key}`);
474
516
  if (!raw) return null;
475
517
  const v = JSON.parse(raw) as PersistedView;
476
518
  if (!Number.isFinite(v.zoom) || !v.offset || !Number.isFinite(v.offset.x) || !Number.isFinite(v.offset.y)) return null;
@@ -635,30 +677,17 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
635
677
  [updatePageFrame, viewStore],
636
678
  );
637
679
 
638
- // 生きているページは利用側(currentContentId)と揃える。
639
- // 本文が読めて currentContentId が変わったときに初めてエディタが移る
640
- // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)
641
- useEffect(() => {
642
- if (!enabled) return;
643
- const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
644
- viewStore.set((prev) => (prev.activePageId === id ? prev : { ...prev, activePageId: id }));
645
- }, [enabled, artboard.currentContentId, artboard.contentList, viewStore]);
646
-
647
680
  // 見えている範囲の保存(変化が止まってから)
648
681
  useEffect(() => {
649
- if (!storageKey || typeof localStorage === 'undefined') return;
682
+ if (!storageKey) return;
650
683
  let timer: ReturnType<typeof setTimeout> | null = null;
651
684
  const persist = () => {
652
685
  if (timer) clearTimeout(timer);
653
686
  timer = setTimeout(() => {
654
687
  timer = null;
655
- try {
656
- const { canvasZoom, canvasOffset } = viewStore.get();
657
- const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
658
- localStorage.setItem(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
659
- } catch {
660
- /* 保存できなくても表示には影響しない */
661
- }
688
+ const { canvasZoom, canvasOffset } = viewStore.get();
689
+ const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
690
+ writeStorage(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
662
691
  }, 250);
663
692
  };
664
693
  const unsubscribe = viewStore.subscribe(persist);
@@ -810,32 +839,107 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
810
839
  [setView],
811
840
  );
812
841
 
813
- const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
814
- (id) => {
842
+ /**
843
+ * フレームが今どこに、どれだけの大きさで画面に出ているか(容器の座標)。
844
+ * 「見えているか」「寄る必要があるか」を revealPage と focusPage が同じ式で判定するため
845
+ */
846
+ const frameScreenRect = useCallback(
847
+ (id: string) => {
815
848
  const page = pagesRef.current.find((p) => p.id === id);
816
- if (!page) return;
817
- const { width, height } = containerSize();
849
+ if (!page) return null;
850
+ const container = containerSize();
818
851
  const { canvasZoom: z, canvasOffset: o } = viewStore.get();
819
852
  const inset = rulersRef.current ? RULER_SIZE : 0;
820
853
  const left = o.x + page.position.x * z;
821
854
  const top = o.y + page.position.y * z;
822
- const right = left + page.size.width * z;
823
- const bottom = top + page.size.height * z;
824
- const margin = 48;
855
+ const width = page.size.width * z;
856
+ const height = page.size.height * z;
857
+ return {
858
+ page,
859
+ container,
860
+ inset,
861
+ zoom: z,
862
+ left,
863
+ top,
864
+ width,
865
+ height,
866
+ // 一部でも画面に入っていれば「見えている」
867
+ visible:
868
+ left + width > inset + REVEAL_MARGIN &&
869
+ left < container.width - REVEAL_MARGIN &&
870
+ top + height > inset + REVEAL_MARGIN &&
871
+ top < container.height - REVEAL_MARGIN,
872
+ };
873
+ },
874
+ [viewStore],
875
+ );
876
+
877
+ const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
878
+ (id) => {
879
+ const r = frameScreenRect(id);
825
880
  // 一部でも見えていればそのまま。見えていなければ、そのフレームの左上を画面へ寄せる
826
- const visible = right > inset + margin && left < width - margin && bottom > inset + margin && top < height - margin;
827
- if (visible) return;
828
- const w = page.size.width * z;
829
- const h = page.size.height * z;
881
+ if (!r || r.visible) return;
882
+ const { page, container, inset, zoom: z, width: w, height: h } = r;
830
883
  const offset = {
831
- x: w <= width - inset ? inset + (width - inset - w) / 2 - page.position.x * z : inset + margin - page.position.x * z,
832
- y: h <= height - inset ? inset + (height - inset - h) / 2 - page.position.y * z : inset + margin - page.position.y * z,
884
+ x: w <= container.width - inset ? inset + (container.width - inset - w) / 2 - page.position.x * z : inset + REVEAL_MARGIN - page.position.x * z,
885
+ y: h <= container.height - inset ? inset + (container.height - inset - h) / 2 - page.position.y * z : inset + REVEAL_MARGIN - page.position.y * z,
833
886
  };
834
887
  setView({ zoom: z, offset }, { animate: true });
835
888
  },
836
- [setView, viewStore],
889
+ [frameScreenRect, setView],
837
890
  );
838
891
 
892
+ /**
893
+ * ホスト発(利用側のレールなど)でページが変わったときに視点を寄せる。
894
+ * 倍率はできるだけ変えない ── ただし「画面に入ってはいるが点にしか見えない」ときは
895
+ * 寄せても何も起きていないように見えるので、そのときだけページに合わせる
896
+ */
897
+ const focusPage = useCallback(
898
+ (id: string) => {
899
+ const r = frameScreenRect(id);
900
+ if (!r) return;
901
+ if (r.width < FOCUS_MIN_WIDTH) {
902
+ zoomToPage(id, { animate: true });
903
+ return;
904
+ }
905
+ if (!r.visible) revealPage(id);
906
+ },
907
+ [frameScreenRect, revealPage, zoomToPage],
908
+ );
909
+
910
+ /**
911
+ * 直前の activatePage(フレームのクリック・左パネル)の宛先。
912
+ * 下の同期 effect が「エディタ発かホスト発か」を見分けるのに使う。
913
+ * state(activatingPageId)ではなく ref で持つのは、切替が終わって state が戻るのと
914
+ * 同期 effect が走るのが同じ描画にまとまるため(state では読んだときに既に null)。
915
+ * 消すのは effect が使ったときだけ ── A → B と続けて押したときは A の後始末で消さない
916
+ */
917
+ const editorInitiatedPageIdRef = useRef<string | null>(null);
918
+
919
+ // 生きているページは利用側(currentContentId)と揃える。
920
+ // 本文が読めて currentContentId が変わったときに初めてエディタが移る
921
+ // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)。
922
+ // ホスト発(利用側がレールで contentId を変えた)のときは視点も寄せる ── フレームの
923
+ // クリックと違って画面のどこにも手掛かりが無く、26 ページの木では移り先がほぼ画面外
924
+ useEffect(() => {
925
+ if (!enabled) return;
926
+ const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
927
+ let prevId: string | null | undefined;
928
+ viewStore.set((prev) => {
929
+ if (prev.activePageId === id) return prev;
930
+ prevId = prev.activePageId;
931
+ return { ...prev, activePageId: id };
932
+ });
933
+ if (prevId === undefined || !id) return; // 変わっていない
934
+ if (prevId === null) return; // 最初の 1 ページが決まっただけ。初回の視点は MultiPageCanvasView が決める
935
+ if (editorInitiatedPageIdRef.current === id) {
936
+ // エディタ発(activatePage)。あちらで既に寄せてあるので二重に動かさない
937
+ editorInitiatedPageIdRef.current = null;
938
+ return;
939
+ }
940
+ focusPage(id);
941
+ }, [enabled, artboard.currentContentId, artboard.contentList, viewStore, focusPage]);
942
+
839
943
  // ---- 本文の読み込み
840
944
  const loadingRef = useRef(new Set<string>());
841
945
  const ensurePageHtml = useCallback<MultiPageCanvasContextValue['ensurePageHtml']>(
@@ -893,13 +997,21 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
893
997
  }
894
998
  const notify = onContentChangeRef.current;
895
999
  if (!notify) return false;
1000
+ // エディタ発だと印す。利用側の contentId が変わって同期 effect が走るころには
1001
+ // この切替は終わっているので、state ではなく ref で残す
1002
+ editorInitiatedPageIdRef.current = id;
896
1003
  setActivatingPageId(id);
897
1004
  if (options?.reveal) revealPage(id);
1005
+ let moved = false;
898
1006
  try {
899
1007
  const result = await (notify as (contentId: string) => unknown)(id);
900
- return result !== false;
1008
+ moved = result !== false;
1009
+ return moved;
901
1010
  } finally {
902
1011
  setActivatingPageId((v) => (v === id ? null : v));
1012
+ // 移れなかったら印を消す(残すと、あとで同じページへホスト発で移るときに視点が動かない)。
1013
+ // 別のページへ移ったあとなら消さない ── その印はそちらの切替のもの
1014
+ if (!moved && editorInitiatedPageIdRef.current === id) editorInitiatedPageIdRef.current = null;
903
1015
  }
904
1016
  },
905
1017
  [revealPage, viewStore],
@@ -954,6 +1066,36 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
954
1066
  [pumpPreviewSlots],
955
1067
  );
956
1068
 
1069
+ // ---- 紙面の画像の解像度(画像を出してよい倍率の上限を決める)
1070
+ // 引き伸ばしすぎるとにじむ。上限はキャンバス全体で 1 つにし、いちばん粗い画像に合わせる
1071
+ const thumbnailWidthsRef = useRef(new Map<string, number>());
1072
+ const thumbnailRatioRef = useRef<number | null>(null);
1073
+ const [previewImageCapVersion, setPreviewImageCapVersion] = useState(0);
1074
+
1075
+ const reportThumbnailWidth = useCallback<MultiPageCanvasContextValue['reportThumbnailWidth']>((id, naturalWidth) => {
1076
+ if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return;
1077
+ if (thumbnailWidthsRef.current.get(id) === naturalWidth) return;
1078
+ thumbnailWidthsRef.current.set(id, naturalWidth);
1079
+ let min = Infinity;
1080
+ thumbnailWidthsRef.current.forEach((width, key) => {
1081
+ const pageWidth = framesMapRef.current.get(key)?.size.width;
1082
+ if (!pageWidth || pageWidth <= 0) return;
1083
+ min = Math.min(min, width / pageWidth);
1084
+ });
1085
+ const ratio = Number.isFinite(min) ? min : null;
1086
+ if (ratio === thumbnailRatioRef.current) return;
1087
+ thumbnailRatioRef.current = ratio;
1088
+ // 上限が動いたので、React 側(画像にするかの判定)にやり直させる
1089
+ setPreviewImageCapVersion((v) => v + 1);
1090
+ }, []);
1091
+
1092
+ const getPreviewImageZoomCap = useCallback<MultiPageCanvasContextValue['getPreviewImageZoomCap']>(() => {
1093
+ // 別のディスプレイへ移すと変わるので、そのつど読む
1094
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
1095
+ const ratio = thumbnailRatioRef.current ?? PREVIEW_IMAGE_PRESUMED_RATIO;
1096
+ return Math.min(PREVIEW_IMAGE_ZOOM, (ratio * PREVIEW_IMAGE_MAX_STRETCH) / dpr);
1097
+ }, []);
1098
+
957
1099
  const value = useMemo<MultiPageCanvasContextValue>(
958
1100
  () => ({
959
1101
  isEnabled: enabled,
@@ -981,6 +1123,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
981
1123
  zoomToPage,
982
1124
  zoomToActual,
983
1125
  revealPage,
1126
+ focusPage,
984
1127
  activatePage,
985
1128
  activatingPageId,
986
1129
  registerContainer,
@@ -992,12 +1135,16 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
992
1135
  toggleRulers,
993
1136
  requestPreviewSlot,
994
1137
  releasePreviewSlot,
1138
+ getPreviewImageZoomCap,
1139
+ reportThumbnailWidth,
1140
+ previewImageCapVersion,
995
1141
  }),
996
1142
  [
997
1143
  enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
998
1144
  setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
999
- zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1145
+ zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1000
1146
  persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1147
+ getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
1001
1148
  ],
1002
1149
  );
1003
1150
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { debugLog } from '../utils/debug';
5
+ import { readStorage, writeStorage } from '../utils/storage';
5
6
 
6
7
  /**
7
8
  * Google Fontの情報
@@ -140,8 +141,8 @@ export function useGoogleFonts() {
140
141
  const fetchGoogleFonts = useCallback(async () => {
141
142
  // キャッシュをチェック
142
143
  try {
143
- const cachedFonts = localStorage.getItem(FONTS_CACHE_KEY);
144
- const cacheExpiry = localStorage.getItem(FONTS_CACHE_EXPIRY_KEY);
144
+ const cachedFonts = readStorage(FONTS_CACHE_KEY);
145
+ const cacheExpiry = readStorage(FONTS_CACHE_EXPIRY_KEY);
145
146
 
146
147
  if (cachedFonts && cacheExpiry && Date.now() < parseInt(cacheExpiry, 10)) {
147
148
  const parsed = JSON.parse(cachedFonts) as GoogleFont[];
@@ -197,12 +198,9 @@ export function useGoogleFonts() {
197
198
  }));
198
199
 
199
200
  // キャッシュに保存
200
- try {
201
- localStorage.setItem(FONTS_CACHE_KEY, JSON.stringify(googleFonts));
202
- localStorage.setItem(FONTS_CACHE_EXPIRY_KEY, String(Date.now() + CACHE_DURATION));
203
- } catch {
204
- // ストレージが一杯の場合は無視
205
- }
201
+ // ストレージが一杯・使えない場合は黙って諦める
202
+ writeStorage(FONTS_CACHE_KEY, JSON.stringify(googleFonts));
203
+ writeStorage(FONTS_CACHE_EXPIRY_KEY, String(Date.now() + CACHE_DURATION));
206
204
 
207
205
  setFonts([...SYSTEM_FONTS, ...googleFonts]);
208
206
  } catch (err) {
@@ -112,6 +112,8 @@ export function useIframeSetup(): UseIframeSetupReturn {
112
112
  if (element.classList.contains('rotation-handle')) return;
113
113
  // selection-boxの子孫要素をすべてスキップ(念のため)
114
114
  if (element.closest('.selection-box')) return;
115
+ // 共同編集の他人の選択枠・カーソル(表示専用の層)
116
+ if (element.closest('.gg-collab-layer')) return;
115
117
 
116
118
  // 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
117
119
  // ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useCallback, useEffect, useRef } from 'react';
4
+ import { readStorage, writeStorage } from '../utils/storage';
4
5
 
5
6
  export interface UseResizablePanelOptions {
6
7
  /** 初期幅 */
@@ -54,7 +55,7 @@ export function useResizablePanel({
54
55
  const getInitialWidth = () => {
55
56
  if (storageKey && typeof window !== 'undefined') {
56
57
  let stored: string | null = null;
57
- try { stored = localStorage.getItem(storageKey); } catch { /* 既定幅を使う */ }
58
+ stored = readStorage(storageKey);
58
59
  if (stored) {
59
60
  const parsed = parseInt(stored, 10);
60
61
  if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
@@ -80,7 +81,7 @@ export function useResizablePanel({
80
81
  // ドラッグ中の同期ストレージ書込みを避け、確定時だけ幅を記憶する。
81
82
  useEffect(() => {
82
83
  if (isDragging || !storageKey) return;
83
- try { localStorage.setItem(storageKey, String(width)); } catch { /* 表示は継続 */ }
84
+ writeStorage(storageKey, String(width));
84
85
  }, [width, isDragging, storageKey]);
85
86
 
86
87
  // マウスダウン
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  const DEBUG_KEY = 'gg-editor:debug';
19
+ import { readStorage } from './storage';
19
20
 
20
21
  /**
21
22
  * デバッグログを出す設定か。
@@ -23,11 +24,7 @@ const DEBUG_KEY = 'gg-editor:debug';
23
24
  * 参照そのものが例外を投げるので、必ず握りつぶして false に倒す。
24
25
  */
25
26
  export function isDebugEnabled(): boolean {
26
- try {
27
- return typeof localStorage !== 'undefined' && localStorage.getItem(DEBUG_KEY) === '1';
28
- } catch {
29
- return false;
30
- }
27
+ return readStorage(DEBUG_KEY) === '1';
31
28
  }
32
29
 
33
30
  /** 内部の様子を見るためのログ。既定では出ない */
@@ -229,6 +229,37 @@ export function isInlineElement(element: HTMLElement, computedStyle?: CSSStyleDe
229
229
  * EditorCanvas側のエフェクト(スライダー等の状態駆動の経路)も同じ関数を使い、
230
230
  * 適用ロジックの二重実装を作らない。
231
231
  */
232
+ /**
233
+ * ズームが止まってから will-change を外すまでの間(ms)。
234
+ * 操作が続いている間は張り直されるので、実質「最後の 1 段から 200ms」
235
+ */
236
+ const ZOOM_WILL_CHANGE_RELEASE_MS = 200;
237
+ /** wrapper ごとの後始末タイマー(iframe が差し替わったら参照ごと消える) */
238
+ const zoomWillChangeTimers = new WeakMap<HTMLElement, number>();
239
+
240
+ /**
241
+ * ズーム中だけ will-change: transform を付ける。
242
+ *
243
+ * 付けっぱなしにすると、Chrome は倍率が変わっても層を作り直さない。67% で描いたラスタを
244
+ * 384% へ引き伸ばして出すので、手を止めても文字がにじんだまま残る(DPR 2 の実機で再現)。
245
+ * 外した瞬間に今の倍率で描き直されてくっきりする。次のホイールでまた付くので、
246
+ * 操作中の滑らかさは変わらない(マルチフレームのキャンバスの転写層も同じ考え方で、
247
+ * あちらは isInteracting の間だけ付けている)
248
+ */
249
+ function markZooming(wrapper: HTMLElement, view: (Window & typeof globalThis) | null): void {
250
+ wrapper.style.willChange = 'transform';
251
+ if (!view) return;
252
+ const previous = zoomWillChangeTimers.get(wrapper);
253
+ if (previous != null) view.clearTimeout(previous);
254
+ zoomWillChangeTimers.set(
255
+ wrapper,
256
+ view.setTimeout(() => {
257
+ zoomWillChangeTimers.delete(wrapper);
258
+ wrapper.style.willChange = '';
259
+ }, ZOOM_WILL_CHANGE_RELEASE_MS),
260
+ );
261
+ }
262
+
232
263
  export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
233
264
  // 埋め込み(マルチフレームのキャンバス内)では倍率は外側の CSS transform が持つ。
234
265
  // iframe の中は常に等倍で、スクロール領域も作らない
@@ -240,7 +271,7 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
240
271
  if (!wrapper || !scrollArea || !container || !artboard) return;
241
272
 
242
273
  const scale = zoomPct / 100;
243
- wrapper.style.willChange = 'transform';
274
+ markZooming(wrapper, iframeDoc.defaultView);
244
275
  wrapper.style.transform = `scale(${scale})`;
245
276
 
246
277
  const w = artboard.offsetWidth;
@@ -22,7 +22,7 @@ const INDICATOR_CLASS = `selection-box ${PART_DROP_INDICATOR_CLASS}`;
22
22
 
23
23
  /** 紙面の中身ではなく、エディタが差し込んでいる表示専用のノード */
24
24
  const EDITOR_CHROME =
25
- '.selection-box,.marquee-selection-box,.gg-comment-layer,.gg-crop-ui,' +
25
+ '.selection-box,.marquee-selection-box,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
26
26
  '#gg-measure-layer,#gg-smart-guides,.flex-drop-indicator,.nesting-drop-indicator,' +
27
27
  '.auto-layout-drop-indicator,.drag-ghost';
28
28
 
@@ -27,7 +27,7 @@ const BLUE = '#0d99ff';
27
27
  /** 並べ替えの対象にしない、エディタが描いている飾り */
28
28
  const OVERLAY_SELECTOR =
29
29
  '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
30
- '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
30
+ '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
31
31
  '.flex-drop-indicator,#gg-measure-layer,#gg-smart-guides,#' + INDICATOR_ID;
32
32
 
33
33
  /**
@@ -364,6 +364,8 @@ export function getCleanHtml(doc: Document): string {
364
364
 
365
365
  // コメントの吹き出しレイヤー(表示専用)を削除
366
366
  tempContainer.querySelectorAll('.gg-comment-layer').forEach(el => el.remove());
367
+ // 共同編集の他人の選択枠・カーソル(表示専用)も保存しない(共有する本文にも混ぜない)
368
+ tempContainer.querySelectorAll('.gg-collab-layer').forEach(el => el.remove());
367
369
  // Alt計測の赤い線(表示専用)も保存しない
368
370
  tempContainer.querySelectorAll('#gg-measure-layer').forEach(el => el.remove());
369
371
  // ドラッグ中の整列ガイド(表示専用)も保存しない
@@ -122,7 +122,7 @@ export function relaxContainerSize(container: HTMLElement): void {
122
122
  /** 並べ替え・整列の対象にしない、エディタが描いている飾り */
123
123
  const OVERLAY_SELECTOR =
124
124
  '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
125
- '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
125
+ '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
126
126
  '#gg-measure-layer,#gg-smart-guides,#gg-reorder-indicator';
127
127
 
128
128
  /** 実体のある直接の子だけを取り出す */
@@ -24,7 +24,7 @@ export const SNAP_THRESHOLD_SCREEN_PX = 4;
24
24
  /** ガイドの候補にしないオーバーレイ類(エディタが描いている飾り) */
25
25
  const OVERLAY_SELECTOR =
26
26
  '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
27
- '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
27
+ '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
28
28
  '#gg-measure-layer,#gg-smart-guides,[data-drag-ghost],[data-flex-drop-indicator]';
29
29
 
30
30
  /** 整列の候補座標。x/y ともに昇順ソート済み */
@@ -0,0 +1,47 @@
1
+ /**
2
+ * ブラウザの記憶(localStorage / sessionStorage)への安全な出入口。
3
+ *
4
+ * 【なぜ関数越しにするのか】
5
+ * 共有ドロップに置く静的なエディタは `Content-Security-Policy: sandbox`(allow-same-origin 無し)で
6
+ * 配られる。この文脈は **opaque origin** で、`localStorage` は読むだけで SecurityError を投げる
7
+ * (`typeof localStorage` でも投げる ── プロパティの getter が走るため)。
8
+ * 「サイトデータをブロック」設定や、srcdoc / sandbox の紙面の中でも同じことが起きる。
9
+ *
10
+ * 記憶できるかどうかで挙動を変えないこと。ここは全部「読めなければ既定値、書けなければ黙って続行」。
11
+ */
12
+
13
+ function storage(kind: 'local' | 'session'): Storage | null {
14
+ try {
15
+ const s = kind === 'local' ? window.localStorage : window.sessionStorage;
16
+ return s ?? null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /** 読む。使えない・入っていなければ null */
23
+ export function readStorage(key: string, kind: 'local' | 'session' = 'local'): string | null {
24
+ try {
25
+ return storage(kind)?.getItem(key) ?? null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** 書く。使えない・容量が一杯なら黙って諦める(表示や編集は続ける) */
32
+ export function writeStorage(key: string, value: string, kind: 'local' | 'session' = 'local'): void {
33
+ try {
34
+ storage(kind)?.setItem(key, value);
35
+ } catch {
36
+ /* 記憶できなくても動作は継続 */
37
+ }
38
+ }
39
+
40
+ /** 消す */
41
+ export function removeStorage(key: string, kind: 'local' | 'session' = 'local'): void {
42
+ try {
43
+ storage(kind)?.removeItem(key);
44
+ } catch {
45
+ /* 消せなくても動作は継続 */
46
+ }
47
+ }
package/src/index.ts CHANGED
@@ -60,7 +60,22 @@ export type {
60
60
  EditorPartDef,
61
61
  EditorPartCategory,
62
62
  EditorPartsLibrary,
63
+ EditorCollab,
64
+ EditorCollabUser,
63
65
  } from "./io";
66
+
67
+ /**
68
+ * 共同編集の居場所(io.collab を渡したときだけ中身が入る)。
69
+ * 利用側のページ一覧などに「誰がどのページに居るか」を出すのに使う
70
+ */
71
+ export { useCollabPresence } from "./editor/collab/store";
72
+ export type { CollabPresence, CollabPeer, CollabStatus } from "./editor/collab/store";
73
+ /**
74
+ * 部屋名が中継(gg-manager の hocuspocus-server)に保存される形かの検査。
75
+ * 形を外れた部屋は中継はされるが保存されないので、殻・書き戻し役でも繋ぐ前にこれで確かめる
76
+ */
77
+ export { checkCollabRoom } from "./editor/collab/room";
78
+ export type { CollabRoomCheck } from "./editor/collab/room";
64
79
  export type { CSSVariableDefinition, CSSVariableCategory } from "./types/css-variables";
65
80
 
66
81
  /**
package/src/io.ts CHANGED
@@ -196,6 +196,35 @@ export type EditorIO = {
196
196
  loadVariables?: () => Promise<CSSVariableDefinition[]>;
197
197
  /** CSS 変数の保存。無ければブラウザ内(localStorage)に持つ */
198
198
  saveVariables?: (variables: CSSVariableDefinition[]) => Promise<void>;
199
+
200
+ /**
201
+ * リアルタイム共同編集(Figma 風)。Hocuspocus の部屋に本文と居場所を載せる。
202
+ * 無ければ今までどおり(共同編集の UI も出さず、接続もしない)。
203
+ * あるページの部屋に入っている間は onSave の自動保存を呼ばない(ファイルに書くのは書き戻し役)
204
+ */
205
+ collab?: EditorCollab;
206
+ };
207
+
208
+ /** 共同編集の参加者。color が無ければ id から決める */
209
+ export type EditorCollabUser = { id: string; name: string; color?: string };
210
+
211
+ export type EditorCollab = {
212
+ /** Hocuspocus の URL(wss://…) */
213
+ url: string;
214
+ /** contentId → ページの部屋名。null ならそのページは共同編集しない */
215
+ roomFor: (contentId: string) => string | null;
216
+ /** 案件の部屋名(居場所・pages) */
217
+ projectRoom: string;
218
+ /** contentId → route(案件の部屋の awareness と pages のキー) */
219
+ routeFor: (contentId: string) => string | null;
220
+ user: EditorCollabUser;
221
+ token?: () => Promise<string | undefined>;
222
+ /** getCleanHtml の結果 → 共有する本文。無ければそのまま */
223
+ encode?: (cleanHtml: string) => string;
224
+ /** 共有する本文 → エディタに渡す HTML。無ければそのまま */
225
+ decode?: (shared: string, contentId: string) => string;
226
+ /** true: 書き戻し役が居ないとき「変更は保存されません」を出し続ける(静的なエディタ) */
227
+ requireBridge?: boolean;
199
228
  };
200
229
 
201
230
  let current: EditorIO = {};