@growgroup/visual-editor 0.4.0 → 0.4.1
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 +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +9 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +14 -8
- package/src/editor/components/multi-page/PageFramePreview.tsx +3 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +173 -29
- package/src/editor/utils/dom-utils.ts +32 -1
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ setEditorIO({
|
|
|
108
108
|
- 複数選択はキャンバスの余白からドラッグ(マーキー)。ページの中に空白が無くても、余白から引けば帯がまたいだ要素が選ばれる
|
|
109
109
|
- `contentList[].parentId` を渡すと、フレームがサイトマップの階層(親の下に子)のツリーで並び、親子が線でつながる(0.3.1)
|
|
110
110
|
- `contentList[].href` を渡すと、フレーム名と一覧に「別タブで開く」が付く。`contentList[].revision` を進めると、そのページの紙面が読み直される(外からファイルが変わったとき)
|
|
111
|
-
- `contentList[].thumbnail` にページ全体を写した画像(PNG / JPEG、幅 480px 以上、縦横比はページと同じ)の URL
|
|
111
|
+
- `contentList[].thumbnail` にページ全体を写した画像(PNG / JPEG、幅 480px 以上、縦横比はページと同じ)の URL を渡すと、大きく縮めた表示で紙面が iframe ではなくその画像になり、縮小が軽くなる。実測で縮小の p95 が 26.5ms → 18.4ms、50ms を超えるフレームが 0 回。画像を出す倍率の上限は画像の解像度と `devicePixelRatio` から決まり(`min(0.5, 画像の幅 × 1.25 ÷ (紙面の幅 × DPR))`)、**Retina では 960px の画像だと 31% まで**。にじんで見えないように、それより拡大すると iframe に戻る。解像度の高い画像を渡せば上限は 0.5 まで上がる。縮小している最中も、上限 × 0.8 を割った時点で(画像が読めていれば)切り替わる。内容が変わったら URL も変える(`&v=…`)。渡さないページ・画像が読めなかったページは今までどおり iframe(0.4.0)
|
|
112
112
|
- `documentAttributes={{ "data-notes": "0" }}` のように渡すと、編集中の文書と紙面の `<html>` に読み直さずに属性が付く(利用側の CSS で表示を切り替える)
|
|
113
113
|
- 部品(`data-part`)を選ぶと枠が紫になり、名前と版の札が出る。「この姿で更新」で他ページの紙面もその場で描き直される
|
|
114
114
|
- 仕組みと確認したことは `docs/multi-frame-canvas-2026-09.md`
|
package/package.json
CHANGED
|
@@ -374,6 +374,15 @@ function FrontendVisualEditorInner({
|
|
|
374
374
|
useEffect(() => { if (pptCommentsOpen) setCommentsMounted(true); }, [pptCommentsOpen]);
|
|
375
375
|
// PowerPoint風UIの右ペイン「図の書式設定」(影・反射・光彩・ぼかし)
|
|
376
376
|
const [pptFormatPaneOpen, setPptFormatPaneOpen] = useState(false);
|
|
377
|
+
// コメントツール(C)にした時点で右のコメントパネルも開く。範囲を描き終えるまで開かないと、
|
|
378
|
+
// 書き込む先が見えないまま範囲を引くことになる。入口(ツールバー・校閲タブ・
|
|
379
|
+
// パネルの「範囲を指定」・キー C)はどれも activeTool を変えるだけなので、ここ 1 か所で拾う。
|
|
380
|
+
// ツールを抜けても閉じない(閉じるのは利用者の操作)。開いているときは state が同じ値で止まる
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (activeTool !== 'comment' || !can('commentAction')) return;
|
|
383
|
+
setPptFormatPaneOpen(false);
|
|
384
|
+
setPptCommentsOpen(true);
|
|
385
|
+
}, [activeTool]);
|
|
377
386
|
const [pptCommentFocus, setPptCommentFocus] = useState(0);
|
|
378
387
|
const [pptActiveThread, setPptActiveThread] = useState<string | null>(null);
|
|
379
388
|
// コメントツール(C)で指定した範囲の下書き。投稿・解除・Esc・ページ切替で消える
|
|
@@ -38,8 +38,7 @@ import {
|
|
|
38
38
|
useCanvasActivePageId,
|
|
39
39
|
RULER_SIZE,
|
|
40
40
|
FRAME_GAP,
|
|
41
|
-
|
|
42
|
-
PREVIEW_IMAGE_ZOOM_OUT,
|
|
41
|
+
PREVIEW_IMAGE_ZOOM_HYSTERESIS,
|
|
43
42
|
type PageFrameLayout,
|
|
44
43
|
} from '../../contexts/MultiPageCanvasContext';
|
|
45
44
|
import { useEditorContext } from '../../EditorContext';
|
|
@@ -218,6 +217,8 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
218
217
|
rulersVisible,
|
|
219
218
|
isInteracting,
|
|
220
219
|
initialViewRestored,
|
|
220
|
+
getPreviewImageZoomCap,
|
|
221
|
+
previewImageCapVersion,
|
|
221
222
|
} = canvas;
|
|
222
223
|
// 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
|
|
223
224
|
// 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
|
|
@@ -280,7 +281,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
280
281
|
// (CSS が拾う。属性 1 つの書き換えで 26 枚がいっぺんに切り替わり、React は通らない)。
|
|
281
282
|
// iframe を外してメモリを返すのは操作が止まってから(下の imageMode)。
|
|
282
283
|
// 外す向き(画像 → iframe)もここではやらない ── 止まってから 0.5 以上で判定する
|
|
283
|
-
if (el && z <
|
|
284
|
+
if (el && z < getPreviewImageZoomCap() * PREVIEW_IMAGE_ZOOM_HYSTERESIS && el.getAttribute('data-canvas-preview-image') !== '1') {
|
|
284
285
|
el.setAttribute('data-canvas-preview-image', '1');
|
|
285
286
|
}
|
|
286
287
|
// 粗い倍率: 150ms に 1 回まで。末尾のタイマーで操作の終わりに必ず追いつく
|
|
@@ -295,7 +296,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
295
296
|
unsubscribe();
|
|
296
297
|
if (coarseTimer) clearTimeout(coarseTimer);
|
|
297
298
|
};
|
|
298
|
-
}, [viewStore]);
|
|
299
|
+
}, [viewStore, getPreviewImageZoomCap]);
|
|
299
300
|
|
|
300
301
|
// エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
|
|
301
302
|
useEffect(() => {
|
|
@@ -366,21 +367,26 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
366
367
|
// ---- 紙面を画像に落とすか(大きく縮小している間だけ)
|
|
367
368
|
// 判定は操作が止まってからしか動かさない。縮小の途中で 26 枚の iframe を差し替えると、
|
|
368
369
|
// 軽くするための切替そのものが重いフレームを作る
|
|
369
|
-
const [imageMode, setImageMode] = useState(
|
|
370
|
+
const [imageMode, setImageMode] = useState(
|
|
371
|
+
() => viewStore.get().canvasZoom < getPreviewImageZoomCap() * PREVIEW_IMAGE_ZOOM_HYSTERESIS,
|
|
372
|
+
);
|
|
370
373
|
const imageModeRef = useRef(imageMode);
|
|
371
374
|
imageModeRef.current = imageMode;
|
|
375
|
+
// previewImageCapVersion は「画像の解像度が分かって上限が動いた」きっかけ。
|
|
376
|
+
// 止まったまま画像が読み終わった場合でも、ここで判定をやり直す
|
|
372
377
|
useEffect(() => {
|
|
373
378
|
if (isInteracting) return;
|
|
374
379
|
const timer = setTimeout(() => {
|
|
375
380
|
const z = viewStore.get().canvasZoom;
|
|
376
|
-
|
|
377
|
-
|
|
381
|
+
const cap = getPreviewImageZoomCap();
|
|
382
|
+
// ヒステリシス: 画像 → iframe は上限以上、iframe → 画像は上限 × 0.8 未満(境目で行き来しない)
|
|
383
|
+
const next = imageModeRef.current ? z < cap : z < cap * PREVIEW_IMAGE_ZOOM_HYSTERESIS;
|
|
378
384
|
if (next !== imageModeRef.current) setImageMode(next);
|
|
379
385
|
// iframe に戻す向きに決まったら、縮小の途中で立てた掛け金も外す(iframe がまた見える)
|
|
380
386
|
if (!next) containerRef.current?.removeAttribute('data-canvas-preview-image');
|
|
381
387
|
}, PREVIEW_MODE_SETTLE_MS);
|
|
382
388
|
return () => clearTimeout(timer);
|
|
383
|
-
}, [isInteracting, coarseZoom, viewStore]);
|
|
389
|
+
}, [isInteracting, coarseZoom, viewStore, getPreviewImageZoomCap, previewImageCapVersion]);
|
|
384
390
|
// 画像を裏で先に読んでおくか(粗い倍率なので、段ごとには変わらない真偽値)
|
|
385
391
|
const preloadImage = coarseZoom < PREVIEW_IMAGE_PRELOAD_ZOOM;
|
|
386
392
|
|
|
@@ -52,7 +52,7 @@ interface PageFramePreviewProps {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export const PageFramePreview = memo(function PageFramePreview({ page, rootRef, imageMode = false, preloadImage = false }: PageFramePreviewProps) {
|
|
55
|
-
const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
|
|
55
|
+
const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes, reportThumbnailWidth } = useMultiPageCanvas();
|
|
56
56
|
const documentAttributesRef = useRef(documentAttributes);
|
|
57
57
|
documentAttributesRef.current = documentAttributes;
|
|
58
58
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
@@ -228,6 +228,8 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef,
|
|
|
228
228
|
const img = e.currentTarget;
|
|
229
229
|
if (!img.naturalWidth || !img.naturalHeight) return;
|
|
230
230
|
setPageHeight(page.id, (page.size.width * img.naturalHeight) / img.naturalWidth, 'preview');
|
|
231
|
+
// 画像を出してよい倍率の上限は、いちばん粗い画像に合わせて決まる(にじませないため)
|
|
232
|
+
reportThumbnailWidth(page.id, img.naturalWidth);
|
|
231
233
|
}}
|
|
232
234
|
onError={() => setImageFailed(true)}
|
|
233
235
|
/>
|
|
@@ -56,6 +56,14 @@ export const MAX_ZOOM = 4;
|
|
|
56
56
|
const ZOOM_STEP = 1.25;
|
|
57
57
|
/** 全体表示・ページ表示のときの余白(px、画面上) */
|
|
58
58
|
const FIT_PADDING = 72;
|
|
59
|
+
/** 画面の縁からこれだけ内側にあれば「見えている」とする(px、画面上) */
|
|
60
|
+
const REVEAL_MARGIN = 48;
|
|
61
|
+
/**
|
|
62
|
+
* ホスト発の切替で「そのページが見えている」と認めるフレームの画面上の幅(px)。
|
|
63
|
+
* 全体表示(2%)だと 1920 の紙面が 38px の点にしかならず、画面には入っていても
|
|
64
|
+
* どこへ移ったのか分からない。これを下回るならページを画面に合わせて寄る
|
|
65
|
+
*/
|
|
66
|
+
const FOCUS_MIN_WIDTH = 400;
|
|
59
67
|
/** 上と左の定規の太さ(px)。全体表示の余白に足す */
|
|
60
68
|
export const RULER_SIZE = 20;
|
|
61
69
|
/** ビューのアニメーション(ms) */
|
|
@@ -63,14 +71,29 @@ const VIEW_ANIMATION_MS = 240;
|
|
|
63
71
|
/** 見るだけの紙面を同時に読む枚数 */
|
|
64
72
|
const PREVIEW_PARALLEL = 4;
|
|
65
73
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 行き来でちらつかないように戻りは別のしきい値にする(ヒステリシス)
|
|
74
|
+
* 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
|
|
75
|
+
* どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
|
|
76
|
+
* 実際の上限はこれより小さくなることがある ── getPreviewImageZoomCap() を見ること
|
|
70
77
|
*/
|
|
71
78
|
export const PREVIEW_IMAGE_ZOOM = 0.5;
|
|
72
|
-
/** 画像 → iframe に戻す倍率(
|
|
79
|
+
/** 画像 → iframe に戻す倍率(上限の最大値のとき)。上限に対する比は下の HYSTERESIS */
|
|
73
80
|
export const PREVIEW_IMAGE_ZOOM_OUT = 0.4;
|
|
81
|
+
/**
|
|
82
|
+
* 画像を引き伸ばしてよい上限。1.25 = 画面の画素に対して 25% まで。
|
|
83
|
+
* これを超えると Retina(DPR 2)で文字がにじんで見える(実測: 960px の画像を
|
|
84
|
+
* 1766 device px の枠に出すと 1.84 倍で、同じ倍率の iframe と並べて粗さが分かる)
|
|
85
|
+
*/
|
|
86
|
+
export const PREVIEW_IMAGE_MAX_STRETCH = 1.25;
|
|
87
|
+
/**
|
|
88
|
+
* 画像の実寸がまだ 1 枚も分からないときに仮定する「画像の幅 ÷ 紙面の幅」。
|
|
89
|
+
* 0.5 = 1920 の紙面に 960px の画像(殻の既定)
|
|
90
|
+
*/
|
|
91
|
+
const PREVIEW_IMAGE_PRESUMED_RATIO = 0.5;
|
|
92
|
+
/**
|
|
93
|
+
* 上限に対する「画像に落とす」側の比(ヒステリシス)。
|
|
94
|
+
* 画像 → iframe は上限以上、iframe → 画像は上限 × これ未満。境目で行き来しないため
|
|
95
|
+
*/
|
|
96
|
+
export const PREVIEW_IMAGE_ZOOM_HYSTERESIS = PREVIEW_IMAGE_ZOOM_OUT / PREVIEW_IMAGE_ZOOM;
|
|
74
97
|
|
|
75
98
|
/** フレームどうしの間隔(紙面の座標系、等倍のとき)。フレーム名を出すかの判定にも使う */
|
|
76
99
|
export const FRAME_GAP: Record<EditorMode, { x: number; y: number }> = {
|
|
@@ -228,6 +251,19 @@ export interface MultiPageCanvasContextValue {
|
|
|
228
251
|
*/
|
|
229
252
|
requestPreviewSlot: (id: string, grant: () => void) => void;
|
|
230
253
|
releasePreviewSlot: (id: string) => void;
|
|
254
|
+
|
|
255
|
+
// ---- 紙面の画像(contentList[].thumbnail)
|
|
256
|
+
/**
|
|
257
|
+
* 紙面を画像で描いてよい倍率の上限。画像の解像度と devicePixelRatio から決める
|
|
258
|
+
* (= min(PREVIEW_IMAGE_ZOOM, 画像の幅 × 1.25 ÷ (紙面の幅 × DPR)))。
|
|
259
|
+
* 上限はキャンバス全体で 1 つで、報告された中でいちばん粗い画像に合わせる。
|
|
260
|
+
* DPR は呼ぶたびに読む(別のディスプレイへ移すと変わる)
|
|
261
|
+
*/
|
|
262
|
+
getPreviewImageZoomCap: () => number;
|
|
263
|
+
/** 画像の実寸(naturalWidth)を報告する。PageFramePreview が onLoad で呼ぶ */
|
|
264
|
+
reportThumbnailWidth: (id: string, naturalWidth: number) => void;
|
|
265
|
+
/** 上限が変わった回数。React 側が判定をやり直すきっかけに使う */
|
|
266
|
+
previewImageCapVersion: number;
|
|
231
267
|
}
|
|
232
268
|
|
|
233
269
|
// ============================================================
|
|
@@ -635,15 +671,6 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
635
671
|
[updatePageFrame, viewStore],
|
|
636
672
|
);
|
|
637
673
|
|
|
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
674
|
// 見えている範囲の保存(変化が止まってから)
|
|
648
675
|
useEffect(() => {
|
|
649
676
|
if (!storageKey || typeof localStorage === 'undefined') return;
|
|
@@ -810,32 +837,107 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
810
837
|
[setView],
|
|
811
838
|
);
|
|
812
839
|
|
|
813
|
-
|
|
814
|
-
|
|
840
|
+
/**
|
|
841
|
+
* フレームが今どこに、どれだけの大きさで画面に出ているか(容器の座標)。
|
|
842
|
+
* 「見えているか」「寄る必要があるか」を revealPage と focusPage が同じ式で判定するため
|
|
843
|
+
*/
|
|
844
|
+
const frameScreenRect = useCallback(
|
|
845
|
+
(id: string) => {
|
|
815
846
|
const page = pagesRef.current.find((p) => p.id === id);
|
|
816
|
-
if (!page) return;
|
|
817
|
-
const
|
|
847
|
+
if (!page) return null;
|
|
848
|
+
const container = containerSize();
|
|
818
849
|
const { canvasZoom: z, canvasOffset: o } = viewStore.get();
|
|
819
850
|
const inset = rulersRef.current ? RULER_SIZE : 0;
|
|
820
851
|
const left = o.x + page.position.x * z;
|
|
821
852
|
const top = o.y + page.position.y * z;
|
|
822
|
-
const
|
|
823
|
-
const
|
|
824
|
-
|
|
853
|
+
const width = page.size.width * z;
|
|
854
|
+
const height = page.size.height * z;
|
|
855
|
+
return {
|
|
856
|
+
page,
|
|
857
|
+
container,
|
|
858
|
+
inset,
|
|
859
|
+
zoom: z,
|
|
860
|
+
left,
|
|
861
|
+
top,
|
|
862
|
+
width,
|
|
863
|
+
height,
|
|
864
|
+
// 一部でも画面に入っていれば「見えている」
|
|
865
|
+
visible:
|
|
866
|
+
left + width > inset + REVEAL_MARGIN &&
|
|
867
|
+
left < container.width - REVEAL_MARGIN &&
|
|
868
|
+
top + height > inset + REVEAL_MARGIN &&
|
|
869
|
+
top < container.height - REVEAL_MARGIN,
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
[viewStore],
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
|
|
876
|
+
(id) => {
|
|
877
|
+
const r = frameScreenRect(id);
|
|
825
878
|
// 一部でも見えていればそのまま。見えていなければ、そのフレームの左上を画面へ寄せる
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
const w = page.size.width * z;
|
|
829
|
-
const h = page.size.height * z;
|
|
879
|
+
if (!r || r.visible) return;
|
|
880
|
+
const { page, container, inset, zoom: z, width: w, height: h } = r;
|
|
830
881
|
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 +
|
|
882
|
+
x: w <= container.width - inset ? inset + (container.width - inset - w) / 2 - page.position.x * z : inset + REVEAL_MARGIN - page.position.x * z,
|
|
883
|
+
y: h <= container.height - inset ? inset + (container.height - inset - h) / 2 - page.position.y * z : inset + REVEAL_MARGIN - page.position.y * z,
|
|
833
884
|
};
|
|
834
885
|
setView({ zoom: z, offset }, { animate: true });
|
|
835
886
|
},
|
|
836
|
-
[
|
|
887
|
+
[frameScreenRect, setView],
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* ホスト発(利用側のレールなど)でページが変わったときに視点を寄せる。
|
|
892
|
+
* 倍率はできるだけ変えない ── ただし「画面に入ってはいるが点にしか見えない」ときは
|
|
893
|
+
* 寄せても何も起きていないように見えるので、そのときだけページに合わせる
|
|
894
|
+
*/
|
|
895
|
+
const focusPage = useCallback(
|
|
896
|
+
(id: string) => {
|
|
897
|
+
const r = frameScreenRect(id);
|
|
898
|
+
if (!r) return;
|
|
899
|
+
if (r.width < FOCUS_MIN_WIDTH) {
|
|
900
|
+
zoomToPage(id, { animate: true });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (!r.visible) revealPage(id);
|
|
904
|
+
},
|
|
905
|
+
[frameScreenRect, revealPage, zoomToPage],
|
|
837
906
|
);
|
|
838
907
|
|
|
908
|
+
/**
|
|
909
|
+
* 直前の activatePage(フレームのクリック・左パネル)の宛先。
|
|
910
|
+
* 下の同期 effect が「エディタ発かホスト発か」を見分けるのに使う。
|
|
911
|
+
* state(activatingPageId)ではなく ref で持つのは、切替が終わって state が戻るのと
|
|
912
|
+
* 同期 effect が走るのが同じ描画にまとまるため(state では読んだときに既に null)。
|
|
913
|
+
* 消すのは effect が使ったときだけ ── A → B と続けて押したときは A の後始末で消さない
|
|
914
|
+
*/
|
|
915
|
+
const editorInitiatedPageIdRef = useRef<string | null>(null);
|
|
916
|
+
|
|
917
|
+
// 生きているページは利用側(currentContentId)と揃える。
|
|
918
|
+
// 本文が読めて currentContentId が変わったときに初めてエディタが移る
|
|
919
|
+
// (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)。
|
|
920
|
+
// ホスト発(利用側がレールで contentId を変えた)のときは視点も寄せる ── フレームの
|
|
921
|
+
// クリックと違って画面のどこにも手掛かりが無く、26 ページの木では移り先がほぼ画面外
|
|
922
|
+
useEffect(() => {
|
|
923
|
+
if (!enabled) return;
|
|
924
|
+
const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
|
|
925
|
+
let prevId: string | null | undefined;
|
|
926
|
+
viewStore.set((prev) => {
|
|
927
|
+
if (prev.activePageId === id) return prev;
|
|
928
|
+
prevId = prev.activePageId;
|
|
929
|
+
return { ...prev, activePageId: id };
|
|
930
|
+
});
|
|
931
|
+
if (prevId === undefined || !id) return; // 変わっていない
|
|
932
|
+
if (prevId === null) return; // 最初の 1 ページが決まっただけ。初回の視点は MultiPageCanvasView が決める
|
|
933
|
+
if (editorInitiatedPageIdRef.current === id) {
|
|
934
|
+
// エディタ発(activatePage)。あちらで既に寄せてあるので二重に動かさない
|
|
935
|
+
editorInitiatedPageIdRef.current = null;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
focusPage(id);
|
|
939
|
+
}, [enabled, artboard.currentContentId, artboard.contentList, viewStore, focusPage]);
|
|
940
|
+
|
|
839
941
|
// ---- 本文の読み込み
|
|
840
942
|
const loadingRef = useRef(new Set<string>());
|
|
841
943
|
const ensurePageHtml = useCallback<MultiPageCanvasContextValue['ensurePageHtml']>(
|
|
@@ -893,13 +995,21 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
893
995
|
}
|
|
894
996
|
const notify = onContentChangeRef.current;
|
|
895
997
|
if (!notify) return false;
|
|
998
|
+
// エディタ発だと印す。利用側の contentId が変わって同期 effect が走るころには
|
|
999
|
+
// この切替は終わっているので、state ではなく ref で残す
|
|
1000
|
+
editorInitiatedPageIdRef.current = id;
|
|
896
1001
|
setActivatingPageId(id);
|
|
897
1002
|
if (options?.reveal) revealPage(id);
|
|
1003
|
+
let moved = false;
|
|
898
1004
|
try {
|
|
899
1005
|
const result = await (notify as (contentId: string) => unknown)(id);
|
|
900
|
-
|
|
1006
|
+
moved = result !== false;
|
|
1007
|
+
return moved;
|
|
901
1008
|
} finally {
|
|
902
1009
|
setActivatingPageId((v) => (v === id ? null : v));
|
|
1010
|
+
// 移れなかったら印を消す(残すと、あとで同じページへホスト発で移るときに視点が動かない)。
|
|
1011
|
+
// 別のページへ移ったあとなら消さない ── その印はそちらの切替のもの
|
|
1012
|
+
if (!moved && editorInitiatedPageIdRef.current === id) editorInitiatedPageIdRef.current = null;
|
|
903
1013
|
}
|
|
904
1014
|
},
|
|
905
1015
|
[revealPage, viewStore],
|
|
@@ -954,6 +1064,36 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
954
1064
|
[pumpPreviewSlots],
|
|
955
1065
|
);
|
|
956
1066
|
|
|
1067
|
+
// ---- 紙面の画像の解像度(画像を出してよい倍率の上限を決める)
|
|
1068
|
+
// 引き伸ばしすぎるとにじむ。上限はキャンバス全体で 1 つにし、いちばん粗い画像に合わせる
|
|
1069
|
+
const thumbnailWidthsRef = useRef(new Map<string, number>());
|
|
1070
|
+
const thumbnailRatioRef = useRef<number | null>(null);
|
|
1071
|
+
const [previewImageCapVersion, setPreviewImageCapVersion] = useState(0);
|
|
1072
|
+
|
|
1073
|
+
const reportThumbnailWidth = useCallback<MultiPageCanvasContextValue['reportThumbnailWidth']>((id, naturalWidth) => {
|
|
1074
|
+
if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return;
|
|
1075
|
+
if (thumbnailWidthsRef.current.get(id) === naturalWidth) return;
|
|
1076
|
+
thumbnailWidthsRef.current.set(id, naturalWidth);
|
|
1077
|
+
let min = Infinity;
|
|
1078
|
+
thumbnailWidthsRef.current.forEach((width, key) => {
|
|
1079
|
+
const pageWidth = framesMapRef.current.get(key)?.size.width;
|
|
1080
|
+
if (!pageWidth || pageWidth <= 0) return;
|
|
1081
|
+
min = Math.min(min, width / pageWidth);
|
|
1082
|
+
});
|
|
1083
|
+
const ratio = Number.isFinite(min) ? min : null;
|
|
1084
|
+
if (ratio === thumbnailRatioRef.current) return;
|
|
1085
|
+
thumbnailRatioRef.current = ratio;
|
|
1086
|
+
// 上限が動いたので、React 側(画像にするかの判定)にやり直させる
|
|
1087
|
+
setPreviewImageCapVersion((v) => v + 1);
|
|
1088
|
+
}, []);
|
|
1089
|
+
|
|
1090
|
+
const getPreviewImageZoomCap = useCallback<MultiPageCanvasContextValue['getPreviewImageZoomCap']>(() => {
|
|
1091
|
+
// 別のディスプレイへ移すと変わるので、そのつど読む
|
|
1092
|
+
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
1093
|
+
const ratio = thumbnailRatioRef.current ?? PREVIEW_IMAGE_PRESUMED_RATIO;
|
|
1094
|
+
return Math.min(PREVIEW_IMAGE_ZOOM, (ratio * PREVIEW_IMAGE_MAX_STRETCH) / dpr);
|
|
1095
|
+
}, []);
|
|
1096
|
+
|
|
957
1097
|
const value = useMemo<MultiPageCanvasContextValue>(
|
|
958
1098
|
() => ({
|
|
959
1099
|
isEnabled: enabled,
|
|
@@ -992,12 +1132,16 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
992
1132
|
toggleRulers,
|
|
993
1133
|
requestPreviewSlot,
|
|
994
1134
|
releasePreviewSlot,
|
|
1135
|
+
getPreviewImageZoomCap,
|
|
1136
|
+
reportThumbnailWidth,
|
|
1137
|
+
previewImageCapVersion,
|
|
995
1138
|
}),
|
|
996
1139
|
[
|
|
997
1140
|
enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
|
|
998
1141
|
setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
|
|
999
1142
|
zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1000
1143
|
persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
|
|
1144
|
+
getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
|
|
1001
1145
|
],
|
|
1002
1146
|
);
|
|
1003
1147
|
|
|
@@ -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;
|
package/src/version.ts
CHANGED