@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
|
@@ -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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 行き来でちらつかないように戻りは別のしきい値にする(ヒステリシス)
|
|
75
|
+
* 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
|
|
76
|
+
* どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
|
|
77
|
+
* 実際の上限はこれより小さくなることがある ── getPreviewImageZoomCap() を見ること
|
|
70
78
|
*/
|
|
71
79
|
export const PREVIEW_IMAGE_ZOOM = 0.5;
|
|
72
|
-
/** 画像 → iframe に戻す倍率(
|
|
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
|
|
513
|
+
if (!key) return null;
|
|
472
514
|
try {
|
|
473
|
-
const raw =
|
|
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
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
|
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
|
|
823
|
-
const
|
|
824
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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 +
|
|
832
|
-
y: h <= height - inset ? inset + (height - inset - h) / 2 - page.position.y * z : inset +
|
|
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
|
-
[
|
|
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
|
-
|
|
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 =
|
|
144
|
-
const cacheExpiry =
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 = {};
|