@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.
Files changed (38) hide show
  1. package/README.md +37 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +7 -3
  5. package/src/editor/FrontendVisualEditor.tsx +154 -54
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +93 -8
  8. package/src/editor/components/EditorHeader.tsx +47 -12
  9. package/src/editor/components/EditorPropertyPanel.tsx +2 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  11. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +295 -1345
  12. package/src/editor/components/multi-page/PageFramePreview.tsx +164 -0
  13. package/src/editor/components/multi-page/index.ts +2 -6
  14. package/src/editor/components/ppt/PptComments.tsx +8 -4
  15. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  16. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  17. package/src/editor/contexts/EditorArtboardContext.tsx +8 -2
  18. package/src/editor/contexts/MultiPageCanvasContext.tsx +710 -358
  19. package/src/editor/hooks/useCanvasControls.ts +1 -1
  20. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  21. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  22. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  23. package/src/editor/hooks/useDrawingMode.ts +1 -0
  24. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  25. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  26. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  27. package/src/editor/parts.ts +38 -12
  28. package/src/editor/utils/dom-utils.ts +7 -1
  29. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  30. package/src/editor/utils/html-utils.ts +86 -11
  31. package/src/io.ts +20 -0
  32. package/src/styles/skin.css +66 -34
  33. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  34. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  35. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  36. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  37. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  38. 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 { PageFrameOverlay } from './PageFrameOverlay';
4
- export { PageThumbnail } from './PageThumbnail';
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 scale = rootRect.width / artboard.offsetWidth || 1;
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) / scale - size / 2));
218
- const y = Math.max(0, (rect.top - rootRect.top) / scale - size / 2) + stack * (size + 4 / scale);
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: ((contentId: string) => void) | null;
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?: (contentId: string) => void;
81
+ onContentChange?: ContentChangeHandler;
76
82
  // 状態同期用のコールバック(ファサードで設定)
77
83
  onArtboardSwitch?: (
78
84
  newId: string,