@growgroup/visual-editor 0.2.1 → 0.3.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 +37 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +7 -3
- package/src/editor/FrontendVisualEditor.tsx +154 -54
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +93 -8
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorPropertyPanel.tsx +2 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
- package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/parts.ts +38 -12
- package/src/editor/utils/dom-utils.ts +7 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +86 -11
- package/src/io.ts +20 -0
- package/src/styles/skin.css +66 -34
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 見るだけの紙面(編集していないページ)。
|
|
5
|
+
*
|
|
6
|
+
* 生きているエディタは 1 つだけなので、他のページは srcdoc の iframe で描く。
|
|
7
|
+
* - 編集用の属性は付けない(触れない。クリックはフレーム側が受けて、そのページへ編集を移す)
|
|
8
|
+
* - 同一オリジンなので #artboard の高さを親から直接測る(ResizeObserver)。
|
|
9
|
+
* フォントや画像の読み込みで高さが変わっても追従する
|
|
10
|
+
* - 画面から遠いものは描かない(IntersectionObserver)。webpage は高さの実測が
|
|
11
|
+
* レイアウトに要るので、順番に少しずつ先読みする(eagerMountCount)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { memo, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useMultiPageCanvas, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
|
|
16
|
+
import { generatePreviewHtml } from '../../utils/html-utils';
|
|
17
|
+
|
|
18
|
+
interface PageFramePreviewProps {
|
|
19
|
+
page: PageFrameLayout;
|
|
20
|
+
/** 画面上の判定に使う容器(IntersectionObserver の root) */
|
|
21
|
+
rootRef: React.RefObject<HTMLDivElement | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
|
|
25
|
+
const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot } = useMultiPageCanvas();
|
|
26
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
28
|
+
const [nearViewport, setNearViewport] = useState(false);
|
|
29
|
+
const [loaded, setLoaded] = useState(false);
|
|
30
|
+
|
|
31
|
+
// 画面に近づいたら描く(遠ざかっても消さない: 戻ったときに白く抜けないため)
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const host = hostRef.current;
|
|
34
|
+
const root = rootRef.current;
|
|
35
|
+
if (!host || !root) return;
|
|
36
|
+
const observer = new IntersectionObserver(
|
|
37
|
+
(entries) => {
|
|
38
|
+
if (entries.some((e) => e.isIntersecting)) setNearViewport(true);
|
|
39
|
+
},
|
|
40
|
+
{ root, rootMargin: '150% 150%' },
|
|
41
|
+
);
|
|
42
|
+
observer.observe(host);
|
|
43
|
+
return () => observer.disconnect();
|
|
44
|
+
}, [rootRef]);
|
|
45
|
+
|
|
46
|
+
// webpage は高さの実測がレイアウトに要る(未測定だと仮の高さで並ぶ)ので、見えていなくても読む。
|
|
47
|
+
// slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)
|
|
48
|
+
const wantsMount = editorMode === 'webpage' || nearViewport;
|
|
49
|
+
const [granted, setGranted] = useState(false);
|
|
50
|
+
const grantedRef = useRef(false);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!wantsMount || grantedRef.current) return;
|
|
53
|
+
requestPreviewSlot(page.id, () => {
|
|
54
|
+
grantedRef.current = true;
|
|
55
|
+
setGranted(true);
|
|
56
|
+
});
|
|
57
|
+
// 見えなくなった: 順番待ちから外す(読み始めていれば載せたまま)
|
|
58
|
+
return () => {
|
|
59
|
+
if (!grantedRef.current) releasePreviewSlot(page.id);
|
|
60
|
+
};
|
|
61
|
+
}, [wantsMount, page.id, requestPreviewSlot, releasePreviewSlot]);
|
|
62
|
+
useEffect(() => () => releasePreviewSlot(page.id), [page.id, releasePreviewSlot]);
|
|
63
|
+
const shouldMount = granted;
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (shouldMount && page.html == null && !page.loading && !page.error) void ensurePageHtml(page.id);
|
|
67
|
+
}, [shouldMount, page.html, page.loading, page.error, page.id, ensurePageHtml]);
|
|
68
|
+
|
|
69
|
+
// 本文が変わったら描き直す
|
|
70
|
+
const [doc, setDoc] = useState<string | null>(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!shouldMount || page.html == null) return;
|
|
73
|
+
setDoc(generatePreviewHtml(page.html, editorMode, page.size.width, previewStyles));
|
|
74
|
+
}, [shouldMount, page.html, editorMode, page.size.width, previewStyles]);
|
|
75
|
+
|
|
76
|
+
// 高さの実測(同一オリジンなので親から読む)
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const iframe = iframeRef.current;
|
|
79
|
+
if (!iframe || doc == null) return;
|
|
80
|
+
let observer: ResizeObserver | null = null;
|
|
81
|
+
let disposed = false;
|
|
82
|
+
const measure = () => {
|
|
83
|
+
try {
|
|
84
|
+
const body = iframe.contentDocument?.body;
|
|
85
|
+
const artboard = iframe.contentDocument?.getElementById('artboard');
|
|
86
|
+
const h = artboard?.getBoundingClientRect().height || body?.scrollHeight || 0;
|
|
87
|
+
if (h > 0) setPageHeight(page.id, h, 'preview');
|
|
88
|
+
} catch {
|
|
89
|
+
/* 読めなければ仮の高さのまま */
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const onLoad = () => {
|
|
93
|
+
if (disposed) return;
|
|
94
|
+
setLoaded(true);
|
|
95
|
+
measure();
|
|
96
|
+
try {
|
|
97
|
+
const artboard = iframe.contentDocument?.getElementById('artboard');
|
|
98
|
+
if (artboard && typeof ResizeObserver !== 'undefined') {
|
|
99
|
+
observer = new ResizeObserver(() => measure());
|
|
100
|
+
observer.observe(artboard);
|
|
101
|
+
}
|
|
102
|
+
// フォントの読み込みが終わってから測り直す(折り返しが変わる)
|
|
103
|
+
void iframe.contentDocument?.fonts?.ready.then(() => { if (!disposed) measure(); });
|
|
104
|
+
} catch {
|
|
105
|
+
/* cross-origin ではない想定 */
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
iframe.addEventListener('load', onLoad);
|
|
109
|
+
return () => {
|
|
110
|
+
disposed = true;
|
|
111
|
+
iframe.removeEventListener('load', onLoad);
|
|
112
|
+
observer?.disconnect();
|
|
113
|
+
};
|
|
114
|
+
}, [doc, page.id, setPageHeight]);
|
|
115
|
+
|
|
116
|
+
// 読み終わった(または読めない)ら枠を返して次へ。本文が来ない場合の安全弁つき
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!granted) return;
|
|
119
|
+
if (loaded || page.error) {
|
|
120
|
+
releasePreviewSlot(page.id);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const timer = setTimeout(() => releasePreviewSlot(page.id), 8000);
|
|
124
|
+
return () => clearTimeout(timer);
|
|
125
|
+
}, [granted, loaded, page.error, page.id, releasePreviewSlot]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
ref={hostRef}
|
|
130
|
+
className="absolute inset-0 overflow-hidden"
|
|
131
|
+
data-page-preview={page.id}
|
|
132
|
+
style={{ background: '#fff' }}
|
|
133
|
+
>
|
|
134
|
+
{doc != null ? (
|
|
135
|
+
<iframe
|
|
136
|
+
ref={iframeRef}
|
|
137
|
+
title={page.title || page.id}
|
|
138
|
+
srcDoc={doc}
|
|
139
|
+
sandbox="allow-same-origin"
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
className="block border-0"
|
|
143
|
+
style={{
|
|
144
|
+
width: page.size.width,
|
|
145
|
+
height: page.size.height,
|
|
146
|
+
pointerEvents: 'none',
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="ed-frame-skeleton" aria-hidden="true">
|
|
151
|
+
{page.error ? (
|
|
152
|
+
<span className="ed-frame-skeleton-note">読み込めませんでした</span>
|
|
153
|
+
) : (
|
|
154
|
+
<>
|
|
155
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
|
|
156
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
|
|
157
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
});
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
export { InfiniteCanvas } from './InfiniteCanvas';
|
|
2
1
|
export { MultiPageCanvasView } from './MultiPageCanvasView';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export { PageLivePreview } from './PageLivePreview';
|
|
6
|
-
export { PageLabel } from './PageLabel';
|
|
7
|
-
export { CanvasZoomControls } from './CanvasZoomControls';
|
|
2
|
+
export { PageFramePreview } from './PageFramePreview';
|
|
3
|
+
export { CanvasRulers } from './CanvasRulers';
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
16
|
import { Check, CornerUpLeft, Loader2, MapPin, RotateCcw, Send, MessageSquare, Sparkles, Trash2, X } from 'lucide-react';
|
|
17
17
|
import { useEditorContext } from '../../EditorContext';
|
|
18
|
+
import { useCanvasViewStateOptional } from '../../contexts/MultiPageCanvasContext';
|
|
18
19
|
import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
|
|
19
20
|
import { commentAction, type SlideComment } from '../../../lib/deck';
|
|
20
21
|
import { can, io, type EditorDeck } from '../../../io';
|
|
@@ -159,6 +160,8 @@ export function useCommentMarkers({
|
|
|
159
160
|
const comments = deck.slides[page - 1]?.comments ?? [];
|
|
160
161
|
const onOpenRef = useRef(onOpenThread);
|
|
161
162
|
onOpenRef.current = onOpenThread;
|
|
163
|
+
// マルチフレームのキャンバスでは iframe の外側にも倍率が掛かる。ピンは画面上で同じ大きさに保つ
|
|
164
|
+
const outerZoom = useCanvasViewStateOptional()?.canvasZoom ?? 1;
|
|
162
165
|
|
|
163
166
|
useEffect(() => {
|
|
164
167
|
if (!active) return;
|
|
@@ -183,7 +186,8 @@ export function useCommentMarkers({
|
|
|
183
186
|
if (!wanted.has(button.dataset.commentId!)) button.remove();
|
|
184
187
|
});
|
|
185
188
|
const rootRect = artboard.getBoundingClientRect();
|
|
186
|
-
const
|
|
189
|
+
const innerScale = rootRect.width / artboard.offsetWidth || 1;
|
|
190
|
+
const scale = innerScale * outerZoom;
|
|
187
191
|
const size = 32 / scale;
|
|
188
192
|
const stacks = new Map<string, number>();
|
|
189
193
|
for (const c of anchored) {
|
|
@@ -214,8 +218,8 @@ export function useCommentMarkers({
|
|
|
214
218
|
const rect = target.getBoundingClientRect();
|
|
215
219
|
const stack = stacks.get(c.anchorSrc!) ?? 0;
|
|
216
220
|
stacks.set(c.anchorSrc!, stack + 1);
|
|
217
|
-
const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) /
|
|
218
|
-
const y = Math.max(0, (rect.top - rootRect.top) /
|
|
221
|
+
const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) / innerScale - size / 2));
|
|
222
|
+
const y = Math.max(0, (rect.top - rootRect.top) / innerScale - size / 2) + stack * (size + 4 / scale);
|
|
219
223
|
const styles: Partial<CSSStyleDeclaration> = {
|
|
220
224
|
position: 'absolute', left: `${x}px`, top: `${y}px`, width: `${size}px`, height: `${size}px`,
|
|
221
225
|
borderRadius: '50% 50% 50% 4px', background: '#2459c4', color: '#fff', fontSize: `${13 / scale}px`,
|
|
@@ -240,7 +244,7 @@ export function useCommentMarkers({
|
|
|
240
244
|
const layer = getIframeDoc()?.querySelector('.gg-comment-layer');
|
|
241
245
|
layer?.remove();
|
|
242
246
|
};
|
|
243
|
-
}, [active, page, getIframeDoc, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
|
|
247
|
+
}, [active, page, getIframeDoc, outerZoom, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
/* ============================ 右パネル ============================ */
|
|
@@ -8,8 +8,10 @@ import type { PptTheme } from '../ppt/PptChrome';
|
|
|
8
8
|
export function CanvasAppearance({ theme }: { theme: PptTheme }) {
|
|
9
9
|
const { iframeReady, getIframeDoc } = useEditorContext();
|
|
10
10
|
useEffect(() => {
|
|
11
|
+
// 読み込み中(iframeReady=false)は body がまだ無いことがある。準備できてから塗る
|
|
12
|
+
if (!iframeReady) return;
|
|
11
13
|
const doc = getIframeDoc();
|
|
12
|
-
if (!doc) return;
|
|
14
|
+
if (!doc?.body) return;
|
|
13
15
|
const color = theme === 'dark' ? '#191d24' : '#eef1f5';
|
|
14
16
|
doc.body.style.backgroundColor = color;
|
|
15
17
|
const container = doc.getElementById('canvas-container');
|
|
@@ -16,6 +16,7 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
|
16
16
|
import { useDeck } from '../../../components/viewer/useDeck';
|
|
17
17
|
import { DeckSlideRender } from '../../../components/DeckSlideRender';
|
|
18
18
|
import { flushAutoSave } from '../../autosave';
|
|
19
|
+
import { useMultiPageCanvasOptional } from '../../contexts/MultiPageCanvasContext';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* スライド描画はメモ化する。パネル幅の変更や他ページの選択で親が再レンダーしても、
|
|
@@ -28,6 +29,7 @@ const ROW_CHROME_PX = 8 + 8 + 16 + 6;
|
|
|
28
29
|
|
|
29
30
|
export function PagesPanel({ page, height = '33vh' }: { page: number; height?: string }) {
|
|
30
31
|
const deck = useDeck();
|
|
32
|
+
const canvas = useMultiPageCanvasOptional();
|
|
31
33
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
32
34
|
const currentRef = useRef<HTMLButtonElement>(null);
|
|
33
35
|
/**
|
|
@@ -55,6 +57,12 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
|
|
|
55
57
|
/** ページ切替。未保存があれば黙って保存してから移る(失敗したときだけ確認する) */
|
|
56
58
|
const goto = async (n: number) => {
|
|
57
59
|
if (n === page) return;
|
|
60
|
+
// マルチフレームのキャンバスでは画面を移らず、そのフレームへ編集を移して画面に寄せる
|
|
61
|
+
if (canvas) {
|
|
62
|
+
const id = canvas.pages[n - 1]?.id ?? String(n);
|
|
63
|
+
if (await canvas.activatePage(id, { reveal: true })) canvas.zoomToPage(id, { animate: true });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
58
66
|
if (!(await flushAutoSave())) {
|
|
59
67
|
if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) return;
|
|
60
68
|
}
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
|
18
18
|
import type { DOMTreeNode } from '../types';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* ページ切替の通知。戻り値に false(または false に解決する Promise)を返すと
|
|
22
|
+
* 「移れなかった」として扱う(マルチフレームのキャンバスが activatePage の結果に使う)
|
|
23
|
+
*/
|
|
24
|
+
export type ContentChangeHandler = (contentId: string) => void | boolean | Promise<boolean | void>;
|
|
25
|
+
|
|
20
26
|
// コンテンツリスト用の軽量型(スライド、ページ、コンポーネント等で共用)
|
|
21
27
|
export interface ContentListItem {
|
|
22
28
|
id: string;
|
|
@@ -48,7 +54,7 @@ export interface EditorArtboardContextValue {
|
|
|
48
54
|
// コンテンツリスト
|
|
49
55
|
contentList: ContentListItem[];
|
|
50
56
|
currentContentId: string | null;
|
|
51
|
-
onContentChange:
|
|
57
|
+
onContentChange: ContentChangeHandler | null;
|
|
52
58
|
|
|
53
59
|
// Deprecated aliases
|
|
54
60
|
/** @deprecated Use contentList instead */
|
|
@@ -72,7 +78,7 @@ export function useEditorArtboard(): EditorArtboardContextValue {
|
|
|
72
78
|
interface EditorArtboardProviderProps {
|
|
73
79
|
contentList?: ContentListItem[];
|
|
74
80
|
currentContentId?: string;
|
|
75
|
-
onContentChange?:
|
|
81
|
+
onContentChange?: ContentChangeHandler;
|
|
76
82
|
// 状態同期用のコールバック(ファサードで設定)
|
|
77
83
|
onArtboardSwitch?: (
|
|
78
84
|
newId: string,
|