@growgroup/visual-editor 0.2.2 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +41 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +15 -3
  5. package/src/editor/FrontendVisualEditor.tsx +168 -55
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +107 -8
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorHeader.tsx +47 -12
  10. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  11. package/src/editor/components/EditorPropertyPanel.tsx +56 -25
  12. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  13. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
  14. package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
  15. package/src/editor/components/multi-page/index.ts +2 -6
  16. package/src/editor/components/ppt/PptComments.tsx +8 -4
  17. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  19. package/src/editor/constants.ts +28 -0
  20. package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
  21. package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
  23. package/src/editor/hooks/useCanvasControls.ts +1 -1
  24. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  25. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  26. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  27. package/src/editor/hooks/useDrawingMode.ts +1 -0
  28. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  29. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  30. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  31. package/src/editor/utils/dom-utils.ts +23 -1
  32. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  33. package/src/editor/utils/html-utils.ts +102 -14
  34. package/src/index.ts +5 -0
  35. package/src/io.ts +20 -0
  36. package/src/styles/skin.css +122 -36
  37. package/src/version.ts +9 -0
  38. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  39. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  40. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  41. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  42. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  43. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -131,7 +131,7 @@ function isTypingTarget(target: EventTarget | null): boolean {
131
131
  * キーボードだけで操作している人がそのボタンを押せなくなる。
132
132
  * realm をまたいでも壊れないプロパティだけで判定する。
133
133
  */
134
- function isSpaceActivatedControl(target: EventTarget | null): boolean {
134
+ export function isSpaceActivatedControl(target: EventTarget | null): boolean {
135
135
  const el = target as (HTMLElement & { closest?: (s: string) => Element | null }) | null;
136
136
  if (!el || typeof el !== 'object' || typeof el.tagName !== 'string') return false;
137
137
  const tag = el.tagName.toUpperCase();
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * キャンバスの余白から始めるマーキー選択(マルチフレームのキャンバス用)。
5
+ *
6
+ * 単独表示では紙面の外側(灰色の器)が「空白」なので、そこからドラッグして紙面の要素を
7
+ * 囲めた。埋め込み(キャンバス)では iframe が紙面ぴったりの大きさで外側が無い。
8
+ * そこでキャンバスの余白で押したドラッグを親が受け、矩形を編集中の iframe の座標に
9
+ * 直して、iframe 内のマーキーと同じ判定(交差 → 最上位に畳む)で選ぶ。
10
+ *
11
+ * [縁をまたいだだけの「器」は選ばない]
12
+ * 紙面の外から中へ引くと、ページ全体の器(main 等)の縁を必ずまたぐ。交差だけで判定すると
13
+ * 常に器が選ばれて畳まれてしまう。そこで「他の候補をすべて含み、かつ矩形にすっぽり
14
+ * 入っていない」要素は背景とみなして外す(外→中に引いたときの main / section がこれ)。
15
+ * カード 1 枚やセクションを丸ごと囲んだときは、それがすっぽり入っているので残る
16
+ *
17
+ * [押している間はエディタの iframe に触らせない]
18
+ * ドラッグ中にポインタが iframe の上へ入ると、mousemove / mouseup は iframe の文書へ
19
+ * 届き、親の window には来ない(実測)。押した瞬間から離すまで、エディタの層を
20
+ * pointer-events: none にして、親が最後まで受ける(armed)
21
+ *
22
+ * [途中で Space を押しても取り残さない]
23
+ * mousemove / mouseup の購読は enabled に関係なく張っておく(enabled は押し始めだけに効く)。
24
+ * 押している途中で Space(パン)を押して enabled が切れても、離したときに必ず畳める。
25
+ * Escape とウィンドウの blur でも畳む(ボタンを離したことが分からないため)
26
+ */
27
+
28
+ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
29
+ import { useEditorContext } from '../EditorContext';
30
+ import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
31
+ import { findElementsIntersectingMarquee, collapseToTopMost } from './useMarqueeSelection';
32
+ import { refreshSelectionOverlay } from '../utils/dom-utils';
33
+ import { extractElementInfo } from '../utils/style-utils';
34
+ import { MARQUEE_DRAG_THRESHOLD } from '../constants';
35
+
36
+ export interface CanvasMarqueeRect {
37
+ left: number;
38
+ top: number;
39
+ width: number;
40
+ height: number;
41
+ }
42
+
43
+ type Bounds = { left: number; top: number; right: number; bottom: number };
44
+
45
+ /** 要素が矩形にすっぽり入っているか(2px の遊び) */
46
+ function isEnclosed(el: HTMLElement, b: Bounds): boolean {
47
+ const r = el.getBoundingClientRect();
48
+ return r.left >= b.left - 2 && r.top >= b.top - 2 && r.right <= b.right + 2 && r.bottom <= b.bottom + 2;
49
+ }
50
+
51
+ /**
52
+ * 他の候補をすべて含む器を、矩形にすっぽり入っていない限り外す(外側から順に)。
53
+ * 器ごと囲んだ(すっぽり入った)ならその器が「選んだもの」。縁をまたいだだけなら背景
54
+ */
55
+ function dropEnclosingContainers(candidates: HTMLElement[], b: Bounds): HTMLElement[] {
56
+ let list = candidates.slice();
57
+ while (list.length > 1) {
58
+ const outer = list.find((el) => list.every((other) => other === el || el.contains(other)));
59
+ if (!outer || isEnclosed(outer, b)) break;
60
+ list = list.filter((el) => el !== outer);
61
+ }
62
+ return list;
63
+ }
64
+
65
+ export function useCanvasMarquee(containerRef: RefObject<HTMLDivElement | null>, options: { enabled: boolean }) {
66
+ const { enabled } = options;
67
+ const { setSelectedElementIds } = useEditorContext();
68
+ const canvas = useMultiPageCanvasOptional();
69
+ // Context の値はズーム・パン中(markInteracting)に変わる。effect の依存に入れると
70
+ // ドラッグの最中に張り直されて状態が畳まれる(実測)ので ref で持つ
71
+ const canvasRef = useRef(canvas);
72
+ canvasRef.current = canvas;
73
+ const [rect, setRect] = useState<CanvasMarqueeRect | null>(null);
74
+ const [armed, setArmed] = useState(false);
75
+ const startRef = useRef<{ x: number; y: number; additive: boolean } | null>(null);
76
+ const activeRef = useRef(false);
77
+ const setSelectedRef = useRef(setSelectedElementIds);
78
+ setSelectedRef.current = setSelectedElementIds;
79
+
80
+ const editorIframe = useCallback(
81
+ () => containerRef.current?.querySelector<HTMLIFrameElement>('[data-editor-frame] iframe') ?? null,
82
+ [containerRef],
83
+ );
84
+
85
+ /** キャンバスの余白(フレームの外)で押したか */
86
+ const isBlankTarget = useCallback(
87
+ (target: EventTarget | null) => {
88
+ const el = target as HTMLElement | null;
89
+ const container = containerRef.current;
90
+ if (!el || !container) return false;
91
+ if (el === container) return true;
92
+ return el.hasAttribute?.('data-canvas-transform-layer') === true;
93
+ },
94
+ [containerRef],
95
+ );
96
+
97
+ const onMouseDown = useCallback(
98
+ (e: React.MouseEvent) => {
99
+ if (!enabled || e.button !== 0 || !isBlankTarget(e.target)) return;
100
+ e.preventDefault();
101
+ const container = containerRef.current!;
102
+ const box = container.getBoundingClientRect();
103
+ startRef.current = { x: e.clientX - box.left, y: e.clientY - box.top, additive: e.shiftKey };
104
+ activeRef.current = false;
105
+ setArmed(true);
106
+ // Figma と同じく、空白を押した瞬間に選択は消える(Shift のときは残す)
107
+ if (!e.shiftKey) {
108
+ const doc = editorIframe()?.contentDocument;
109
+ if (doc) {
110
+ doc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
111
+ refreshSelectionOverlay(doc);
112
+ }
113
+ setSelectedRef.current([]);
114
+ window.postMessage({ type: 'ELEMENT_DESELECTED' }, '*');
115
+ }
116
+ },
117
+ [enabled, isBlankTarget, containerRef, editorIframe],
118
+ );
119
+
120
+ const cancel = useCallback(() => {
121
+ startRef.current = null;
122
+ activeRef.current = false;
123
+ setRect(null);
124
+ setArmed(false);
125
+ }, []);
126
+
127
+ useEffect(() => {
128
+ const onMove = (e: MouseEvent) => {
129
+ const start = startRef.current;
130
+ const container = containerRef.current;
131
+ if (!start || !container) return;
132
+ const box = container.getBoundingClientRect();
133
+ const x = e.clientX - box.left;
134
+ const y = e.clientY - box.top;
135
+ if (!activeRef.current) {
136
+ if (Math.abs(x - start.x) < MARQUEE_DRAG_THRESHOLD && Math.abs(y - start.y) < MARQUEE_DRAG_THRESHOLD) return;
137
+ activeRef.current = true;
138
+ canvasRef.current?.markInteracting();
139
+ }
140
+ setRect({ left: Math.min(start.x, x), top: Math.min(start.y, y), width: Math.abs(x - start.x), height: Math.abs(y - start.y) });
141
+ };
142
+ const finish = (e: MouseEvent) => {
143
+ const start = startRef.current;
144
+ startRef.current = null;
145
+ const wasActive = activeRef.current;
146
+ activeRef.current = false;
147
+ setRect(null);
148
+ setArmed(false);
149
+ const container = containerRef.current;
150
+ if (!start || !wasActive || !container) return;
151
+ const box = container.getBoundingClientRect();
152
+ const x = e.clientX - box.left;
153
+ const y = e.clientY - box.top;
154
+ const screen: Bounds = { left: Math.min(start.x, x), top: Math.min(start.y, y), right: Math.max(start.x, x), bottom: Math.max(start.y, y) };
155
+ const iframe = editorIframe();
156
+ const doc = iframe?.contentDocument;
157
+ if (!iframe || !doc) return;
158
+ // 画面の矩形 → iframe の座標(外側の倍率は実測)
159
+ const r = iframe.getBoundingClientRect();
160
+ const scale = r.width / (iframe.clientWidth || r.width) || 1;
161
+ const toX = (sx: number) => (sx + box.left - r.left) / scale;
162
+ const toY = (sy: number) => (sy + box.top - r.top) / scale;
163
+ const bounds: Bounds = { left: toX(screen.left), top: toY(screen.top), right: toX(screen.right), bottom: toY(screen.bottom) };
164
+ const existing = start.additive ? Array.from(doc.querySelectorAll<HTMLElement>('[data-element-id].selected')) : [];
165
+ const hit = dropEnclosingContainers(findElementsIntersectingMarquee(doc, bounds), bounds);
166
+ const merged = collapseToTopMost(Array.from(new Set<HTMLElement>([...existing, ...hit])));
167
+ doc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
168
+ merged.forEach((el) => el.classList.add('selected'));
169
+ refreshSelectionOverlay(doc);
170
+ const ids = merged.map((el) => el.getAttribute('data-element-id') || '').filter(Boolean);
171
+ setSelectedRef.current(ids);
172
+ if (merged.length > 0) {
173
+ const info = extractElementInfo(merged[0], doc);
174
+ if (info) window.postMessage({ type: 'ELEMENT_SELECTED', element: info }, '*');
175
+ } else {
176
+ window.postMessage({ type: 'ELEMENT_DESELECTED' }, '*');
177
+ }
178
+ };
179
+ const onKeyDown = (e: KeyboardEvent) => {
180
+ if (e.key === 'Escape' && startRef.current) cancel();
181
+ };
182
+ window.addEventListener('mousemove', onMove);
183
+ window.addEventListener('mouseup', finish);
184
+ window.addEventListener('blur', cancel);
185
+ window.addEventListener('keydown', onKeyDown);
186
+ return () => {
187
+ window.removeEventListener('mousemove', onMove);
188
+ window.removeEventListener('mouseup', finish);
189
+ window.removeEventListener('blur', cancel);
190
+ window.removeEventListener('keydown', onKeyDown);
191
+ cancel();
192
+ };
193
+ }, [containerRef, editorIframe, cancel]);
194
+
195
+ return { marqueeRect: rect, marqueeArmed: armed, onMouseDown };
196
+ }
@@ -9,7 +9,6 @@ import { useCallback, useRef, useEffect, MutableRefObject } from 'react';
9
9
  import { useEditorContext } from '../EditorContext';
10
10
  import { updateSelectionBox } from '../utils/dom-utils';
11
11
  import { extractElementInfo } from '../utils/style-utils';
12
- import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH } from '../constants';
13
12
 
14
13
  interface UseContextMenuHandlerOptions {
15
14
  /** 編集可能要素を取得する関数 */
@@ -80,19 +79,11 @@ export function useContextMenuHandler(
80
79
  const iframeRect = iframe?.getBoundingClientRect();
81
80
  if (!iframeRect) return;
82
81
 
83
- // クリック位置のiframe内での比率(0〜1)を計算
84
- const currentWidth =
85
- editorModeRef.current === 'webpage' ? WEBPAGE_WIDTH : SLIDE_WIDTH;
86
- const currentHeight =
87
- editorModeRef.current === 'webpage'
88
- ? contentHeightRef.current
89
- : SLIDE_HEIGHT;
90
- const proportionX = e.clientX / currentWidth;
91
- const proportionY = e.clientY / currentHeight;
92
-
93
- // 比率をスケール後のビジュアルサイズに適用
94
- const x = iframeRect.left + iframeRect.width * proportionX;
95
- const y = iframeRect.top + iframeRect.height * proportionY;
82
+ // iframe の座標 → 親の座標。iframe 要素が外側の transform で縮んでいる
83
+ // (マルチフレームのキャンバス)ときは実測の矩形と内寸の比で掛ける。単独表示では 1
84
+ const outerScale = iframeRect.width / (iframe!.clientWidth || iframeRect.width) || 1;
85
+ const x = iframeRect.left + e.clientX * outerScale;
86
+ const y = iframeRect.top + e.clientY * outerScale;
96
87
 
97
88
  // 要素がクリックされた場合は選択
98
89
  const element = getEditableElement(e.target, iframeDoc);
@@ -76,10 +76,12 @@ export function useCoordinateTransform(): UseCoordinateTransformReturn {
76
76
  const iframeRect = iframe?.getBoundingClientRect();
77
77
  if (!iframeRect) return { x: clientX, y: clientY };
78
78
 
79
- // iframeがフルスクリーンなので、オフセットを加えるだけ
79
+ // iframe 要素が外側の transform で縮んでいる(マルチフレーム)ときは実測の倍率で掛ける。
80
+ // 単独表示では iframe は等倍なので 1
81
+ const outerScale = iframeRect.width / (iframe!.clientWidth || iframeRect.width) || 1;
80
82
  return {
81
- x: iframeRect.left + clientX,
82
- y: iframeRect.top + clientY,
83
+ x: iframeRect.left + clientX * outerScale,
84
+ y: iframeRect.top + clientY * outerScale,
83
85
  };
84
86
  },
85
87
  [iframeRef]
@@ -94,9 +96,10 @@ export function useCoordinateTransform(): UseCoordinateTransformReturn {
94
96
  const iframeRect = iframe?.getBoundingClientRect();
95
97
  if (!iframeRect) return { x: windowX, y: windowY };
96
98
 
99
+ const outerScale = iframeRect.width / (iframe!.clientWidth || iframeRect.width) || 1;
97
100
  return {
98
- x: windowX - iframeRect.left,
99
- y: windowY - iframeRect.top,
101
+ x: (windowX - iframeRect.left) / outerScale,
102
+ y: (windowY - iframeRect.top) / outerScale,
100
103
  };
101
104
  },
102
105
  [iframeRef]
@@ -154,10 +157,10 @@ export function useCoordinateTransform(): UseCoordinateTransformReturn {
154
157
  toWindow: (clientX: number, clientY: number): Point => {
155
158
  const iframeRect = iframe?.getBoundingClientRect();
156
159
  if (!iframeRect) return { x: clientX, y: clientY };
157
-
160
+ const outerScale = iframeRect.width / (iframe!.clientWidth || iframeRect.width) || 1;
158
161
  return {
159
- x: iframeRect.left + clientX,
160
- y: iframeRect.top + clientY,
162
+ x: iframeRect.left + clientX * outerScale,
163
+ y: iframeRect.top + clientY * outerScale,
161
164
  };
162
165
  },
163
166
 
@@ -456,6 +456,7 @@ export function useDrawingMode() {
456
456
  if (e.key === 'Escape') window.postMessage({ type: 'TOOL_FINISHED' }, '*');
457
457
  };
458
458
 
459
+ if (!iframeDoc.body) return;
459
460
  const prevCursor = iframeDoc.body.style.cursor;
460
461
  iframeDoc.body.style.cursor = 'crosshair';
461
462
  iframeDoc.addEventListener('mousedown', down, true);
@@ -171,8 +171,12 @@ export function useGoogleFonts() {
171
171
 
172
172
  const response = await fetch(apiUrl);
173
173
 
174
- if (!response.ok) {
175
- throw new Error('Failed to fetch Google Fonts');
174
+ // 利用側に /api/google-fonts が無い(404 や SPA の index.html が返る)のは普通のこと。
175
+ // エラーにせず、同梱の人気フォント一覧で静かに済ませる
176
+ const contentType = response.headers.get('content-type') || '';
177
+ if (!response.ok || !contentType.includes('json')) {
178
+ setFonts([...SYSTEM_FONTS, ...POPULAR_GOOGLE_FONTS]);
179
+ return;
176
180
  }
177
181
 
178
182
  const data = await response.json();