@growgroup/visual-editor 0.2.2 → 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 (37) 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/utils/dom-utils.ts +7 -1
  28. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  29. package/src/editor/utils/html-utils.ts +86 -11
  30. package/src/io.ts +20 -0
  31. package/src/styles/skin.css +66 -34
  32. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  33. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  34. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  35. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  36. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  37. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -1,76 +0,0 @@
1
- 'use client';
2
-
3
- import { memo, useCallback } from 'react';
4
- import { cn } from '../../../lib/utils';
5
- import type { PageFrame } from '../../contexts/MultiPageCanvasContext';
6
- import { PageLabel } from './PageLabel';
7
- import { PageThumbnail } from './PageThumbnail';
8
-
9
- interface PageFrameOverlayProps {
10
- page: PageFrame;
11
- isActive?: boolean;
12
- onDoubleClick?: (pageId: string) => void;
13
- onClick?: (pageId: string) => void;
14
- }
15
-
16
- /**
17
- * ページフレームオーバーレイ: 無限キャンバス上の各ページ表示
18
- * - ページ名ラベル(Figma風、上部)
19
- * - サムネイルプレビュー
20
- * - ホバー時のリング表示
21
- * - ダブルクリックでページフォーカス(Phase 2で完全実装)
22
- */
23
- export const PageFrameOverlay = memo(function PageFrameOverlay({
24
- page,
25
- isActive = false,
26
- onDoubleClick,
27
- onClick,
28
- }: PageFrameOverlayProps) {
29
- const handleDoubleClick = useCallback((e: React.MouseEvent) => {
30
- e.stopPropagation();
31
- onDoubleClick?.(page.id);
32
- }, [onDoubleClick, page.id]);
33
-
34
- const handleClick = useCallback((e: React.MouseEvent) => {
35
- e.stopPropagation();
36
- onClick?.(page.id);
37
- }, [onClick, page.id]);
38
-
39
- return (
40
- <div
41
- className="absolute cursor-pointer group"
42
- style={{
43
- left: page.position.x,
44
- top: page.position.y,
45
- width: page.size.width,
46
- height: page.size.height,
47
- }}
48
- onDoubleClick={handleDoubleClick}
49
- onClick={handleClick}
50
- >
51
- {/* ページ名ラベル */}
52
- <PageLabel
53
- title={page.title}
54
- isDirty={page.isDirty}
55
- isActive={isActive}
56
- />
57
-
58
- {/* サムネイルコンテナ */}
59
- <div
60
- className={cn(
61
- 'w-full h-full bg-white rounded-sm shadow-lg overflow-hidden',
62
- 'ring-2 transition-all duration-150',
63
- isActive
64
- ? 'ring-[#0d99ff] shadow-blue-500/20'
65
- : 'ring-transparent group-hover:ring-[#0d99ff]/50'
66
- )}
67
- >
68
- <PageThumbnail
69
- html={page.thumbnailHtml}
70
- dataUrl={page.thumbnailDataUrl}
71
- size={page.size}
72
- />
73
- </div>
74
- </div>
75
- );
76
- });
@@ -1,34 +0,0 @@
1
- 'use client';
2
-
3
- import { memo } from 'react';
4
- import { cn } from '../../../lib/utils';
5
-
6
- interface PageLabelProps {
7
- title: string;
8
- isDirty?: boolean;
9
- isActive?: boolean;
10
- }
11
-
12
- /**
13
- * ページ名ラベル(Figma風: フレーム上部に表示)
14
- */
15
- export const PageLabel = memo(function PageLabel({
16
- title,
17
- isDirty = false,
18
- isActive = false,
19
- }: PageLabelProps) {
20
- return (
21
- <div
22
- className={cn(
23
- 'absolute -top-7 left-0 px-1.5 py-0.5 text-[11px] font-medium truncate max-w-[200px]',
24
- 'select-none pointer-events-none',
25
- 'text-gray-400'
26
- )}
27
- >
28
- {title}
29
- {isDirty && (
30
- <span className="ml-1 text-orange-400">*</span>
31
- )}
32
- </div>
33
- );
34
- });
@@ -1,242 +0,0 @@
1
- 'use client';
2
-
3
- import { memo, useRef, useEffect } from 'react';
4
-
5
- interface PageLivePreviewProps {
6
- html: string;
7
- pageId: string;
8
- /** iframe読み込み完了時にiframe要素を親に通知 */
9
- onIframeLoad?: (pageId: string, iframe: HTMLIFrameElement) => void;
10
- }
11
-
12
- /**
13
- * Multi-page canvas用のシンプルHTML生成
14
- * body > #artboard > content のフラット構造
15
- */
16
- function generateCanvasPageHtml(content: string): string {
17
- const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
18
- const scriptUrl = baseUrl
19
- ? `${baseUrl}/vendor/tailwindcss-browser.js`
20
- : 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4';
21
-
22
- return `<!DOCTYPE html>
23
- <html lang="ja">
24
- <head>
25
- <meta charset="UTF-8">
26
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
27
- <script src="${scriptUrl}"></script>
28
- <style type="text/tailwindcss">
29
- @layer base {}
30
- @theme { --font-sans: 'Noto Sans JP', system-ui, sans-serif; }
31
- </style>
32
- <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap" rel="stylesheet">
33
- <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
34
- <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
35
- <style>
36
- * { box-sizing: border-box; }
37
- html { margin: 0; padding: 0; touch-action: none; }
38
- body { margin: 0; padding: 0; width: 100%; background: white; font-family: 'Noto Sans JP', system-ui, sans-serif; overflow: hidden; touch-action: none; }
39
- #artboard { position: relative; }
40
- </style>
41
- </head>
42
- <body>
43
- <div id="artboard">${content}</div>
44
- </body>
45
- </html>`;
46
- }
47
-
48
- /**
49
- * 全ページ同時編集可能なライブiframeプレビュー
50
- * - シンプルHTML構造(ラッパーなし)
51
- * - コンテンツ高さ自動測定 → 親にpostMessage
52
- * - 編集ロジックは親(MultiPageCanvasView)が管理
53
- */
54
- export const PageLivePreview = memo(function PageLivePreview({
55
- html,
56
- pageId,
57
- onIframeLoad,
58
- }: PageLivePreviewProps) {
59
- const iframeRef = useRef<HTMLIFrameElement>(null);
60
- const isLoadedRef = useRef(false);
61
-
62
- // HTML初回読み込み(一度だけ)
63
- // 初回ロード後はDOM操作で直接編集されるため、html prop変更でのiframe再読込は不要
64
- useEffect(() => {
65
- const iframe = iframeRef.current;
66
- if (!iframe) return;
67
- if (!html?.trim()) return;
68
- if (isLoadedRef.current) return;
69
-
70
- isLoadedRef.current = true;
71
- const fullHtml = generateCanvasPageHtml(html);
72
- const blob = new Blob([fullHtml], { type: 'text/html' });
73
- const url = URL.createObjectURL(blob);
74
- iframe.src = url;
75
-
76
- return () => URL.revokeObjectURL(url);
77
- }, [html]);
78
-
79
- // 最小限のスクリプト注入(ID付与・高さ測定・イベント転送のみ)
80
- useEffect(() => {
81
- const iframe = iframeRef.current;
82
- if (!iframe) return;
83
-
84
- const injectMinimalScripts = (doc: Document) => {
85
- if (doc.querySelector('[data-canvas-init]')) return;
86
-
87
- const script = doc.createElement('script');
88
- script.setAttribute('data-canvas-init', 'true');
89
- script.textContent = `(function(){
90
- var PAGE_ID = ${JSON.stringify(pageId)};
91
- var artboard = document.getElementById('artboard');
92
- if (!artboard) return;
93
-
94
- // === Assign data-element-id + data-editable to ALL elements ===
95
- var idCounter = 0;
96
- function assignElementIds(parent, isArtboardChild) {
97
- for (var i = 0; i < parent.children.length; i++) {
98
- var child = parent.children[i];
99
- if (child.nodeType !== 1) continue;
100
- if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') continue;
101
- if (!child.getAttribute('data-element-id')) {
102
- child.setAttribute('data-element-id', 'el-' + (idCounter++));
103
- }
104
- // artboard直下の子要素にdata-editableを付与(レイアウトモード変換で必要)
105
- if (isArtboardChild && !child.hasAttribute('data-editable')) {
106
- child.setAttribute('data-editable', 'true');
107
- }
108
- assignElementIds(child, false);
109
- }
110
- }
111
- assignElementIds(artboard, true);
112
-
113
- // === Height measurement (one-shot) ===
114
- var heightReported = false;
115
- function reportHeight() {
116
- if (heightReported) return;
117
- var h = artboard.scrollHeight || document.body.scrollHeight;
118
- if (h > 0) {
119
- h = Math.min(h, 8000);
120
- heightReported = true;
121
- window.parent.postMessage({ type: 'PAGE_CONTENT_HEIGHT', pageId: PAGE_ID, height: h }, '*');
122
- }
123
- }
124
- setTimeout(reportHeight, 500);
125
- setTimeout(reportHeight, 1500);
126
-
127
- // === Disable trackpad/keyboard zoom natively ===
128
- var preventZoom = function(e) {
129
- if (e.ctrlKey || e.metaKey) {
130
- e.preventDefault();
131
- }
132
- };
133
- window.addEventListener('wheel', preventZoom, { passive: false, capture: true });
134
- document.addEventListener('wheel', preventZoom, { passive: false, capture: true });
135
-
136
- var preventShortcuts = function(e) {
137
- if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) {
138
- e.preventDefault();
139
- }
140
- };
141
- window.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
142
- document.addEventListener('keydown', preventShortcuts, { passive: false, capture: true });
143
-
144
- // === Prevent Safari gesture event native behavior ===
145
- var preventGesture = function(e) {
146
- e.preventDefault();
147
- };
148
- window.addEventListener('gesturestart', preventGesture, { passive: false, capture: true });
149
- window.addEventListener('gesturechange', preventGesture, { passive: false, capture: true });
150
- window.addEventListener('gestureend', preventGesture, { passive: false, capture: true });
151
- document.addEventListener('gesturestart', preventGesture, { passive: false, capture: true });
152
- document.addEventListener('gesturechange', preventGesture, { passive: false, capture: true });
153
- document.addEventListener('gestureend', preventGesture, { passive: false, capture: true });
154
-
155
- // === Wheel forwarding ===
156
- window.addEventListener('wheel', function(e) {
157
- window.parent.postMessage({
158
- type: 'PAGE_PREVIEW_WHEEL',
159
- deltaX: e.deltaX, deltaY: e.deltaY,
160
- ctrlKey: e.ctrlKey, metaKey: e.metaKey
161
- }, '*');
162
- }, { passive: false });
163
-
164
- // === Touch forwarding for pinch zoom ===
165
- var lastTouches = null;
166
- document.addEventListener('touchstart', function(e) {
167
- if (e.touches.length >= 2) {
168
- e.preventDefault();
169
- lastTouches = [];
170
- for (var i = 0; i < e.touches.length; i++) {
171
- lastTouches.push({ clientX: e.touches[i].clientX, clientY: e.touches[i].clientY });
172
- }
173
- }
174
- }, { passive: false });
175
-
176
- document.addEventListener('touchmove', function(e) {
177
- if (e.touches.length >= 2) {
178
- e.preventDefault();
179
- var touches = [];
180
- for (var i = 0; i < e.touches.length; i++) {
181
- touches.push({ clientX: e.touches[i].clientX, clientY: e.touches[i].clientY });
182
- }
183
- window.parent.postMessage({
184
- type: 'PAGE_PREVIEW_TOUCH',
185
- touchType: 'move',
186
- touches: touches,
187
- prevTouches: lastTouches
188
- }, '*');
189
- lastTouches = touches;
190
- }
191
- }, { passive: false });
192
-
193
- document.addEventListener('touchend', function() {
194
- lastTouches = null;
195
- });
196
- document.addEventListener('touchcancel', function() {
197
- lastTouches = null;
198
- });
199
-
200
- // === Prevent link navigation ===
201
- document.addEventListener('click', function(e) {
202
- var a = e.target.closest('a');
203
- if (a) e.preventDefault();
204
- }, true);
205
- })();`;
206
- doc.body.appendChild(script);
207
- };
208
-
209
- try {
210
- const doc = iframe.contentDocument;
211
- if (doc && doc.readyState === 'complete' && doc.body) injectMinimalScripts(doc);
212
- } catch { /* cross-origin */ }
213
-
214
- const handleLoad = () => {
215
- try {
216
- const doc = iframe.contentDocument;
217
- if (doc) injectMinimalScripts(doc);
218
- } catch { /* cross-origin */ }
219
- if (onIframeLoad && iframe) onIframeLoad(pageId, iframe);
220
- };
221
- iframe.addEventListener('load', handleLoad);
222
- return () => iframe.removeEventListener('load', handleLoad);
223
- }, [pageId, onIframeLoad]);
224
-
225
- if (!html?.trim()) {
226
- return (
227
- <div className="w-full h-full flex items-center justify-center bg-gray-100">
228
- <span className="text-gray-400 text-sm">Empty Page</span>
229
- </div>
230
- );
231
- }
232
-
233
- return (
234
- <iframe
235
- ref={iframeRef}
236
- className="w-full h-full border-0"
237
- style={{ touchAction: 'none', overscrollBehavior: 'none' }}
238
- sandbox="allow-same-origin allow-scripts"
239
- title={`Page ${pageId}`}
240
- />
241
- );
242
- });
@@ -1,59 +0,0 @@
1
- 'use client';
2
-
3
- import { memo } from 'react';
4
- import { WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '../../contexts/MultiPageCanvasContext';
5
-
6
- interface PageThumbnailProps {
7
- html: string;
8
- dataUrl: string | null;
9
- size: { width: number; height: number };
10
- }
11
-
12
- /**
13
- * ページサムネイル: CSS scaleで縮小描画
14
- * dataUrl(高品質キャプチャ)がある場合はそれを表示、
15
- * なければHTMLをdangerouslySetInnerHTMLで描画
16
- */
17
- export const PageThumbnail = memo(function PageThumbnail({
18
- html,
19
- dataUrl,
20
- size,
21
- }: PageThumbnailProps) {
22
- // 高品質サムネイルがあればそれを表示
23
- if (dataUrl) {
24
- return (
25
- <img
26
- src={dataUrl}
27
- alt=""
28
- className="w-full h-full object-cover object-top"
29
- draggable={false}
30
- />
31
- );
32
- }
33
-
34
- // HTMLが空の場合はプレースホルダー
35
- if (!html?.trim()) {
36
- return (
37
- <div className="w-full h-full flex items-center justify-center bg-gray-100">
38
- <span className="text-gray-400 text-sm">Empty Page</span>
39
- </div>
40
- );
41
- }
42
-
43
- // HTMLをCSS scaleで縮小描画
44
- const scale = size.width / WEBPAGE_WIDTH;
45
-
46
- return (
47
- <div className="w-full h-full overflow-hidden">
48
- <div
49
- className="origin-top-left pointer-events-none"
50
- style={{
51
- width: WEBPAGE_WIDTH,
52
- height: WEBPAGE_MIN_HEIGHT,
53
- transform: `scale(${scale})`,
54
- }}
55
- dangerouslySetInnerHTML={{ __html: html }}
56
- />
57
- </div>
58
- );
59
- });