@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
|
@@ -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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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();
|