@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 埋め込み(マルチフレームのキャンバス)のとき、生きているエディタの iframe で起きた
|
|
3
|
+
* 「キャンバス全体に向けた操作」を親へ転送する。
|
|
4
|
+
*
|
|
5
|
+
* iframe の中で起きたホイール・キー・中ボタンは親には届かない。等倍の単独表示では
|
|
6
|
+
* iframe 自身が #canvas-container をスクロールして受けていたが、埋め込みでは
|
|
7
|
+
* 倍率も位置も外側(useInfiniteCanvas)が持つので、そのまま外へ流す。
|
|
8
|
+
* 座標は iframe の座標のまま送る(受け手が送り元 iframe の実測矩形で親の座標へ直す)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ZOOM_KEYS = new Set(['=', '+', '-', '_', ';']);
|
|
12
|
+
|
|
13
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
14
|
+
const el = target as HTMLElement | null;
|
|
15
|
+
if (!el || typeof el.closest !== 'function') return false;
|
|
16
|
+
return !!el.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFrameElement): () => void {
|
|
20
|
+
const parent = iframeDoc.defaultView?.parent;
|
|
21
|
+
if (!parent) return () => undefined;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* iframe の座標 → 親の client 座標。
|
|
25
|
+
* このリスナーは親の realm で作られた関数なので postMessage の e.source は親自身になり、
|
|
26
|
+
* 受け手が送り元の iframe を特定できない。送る前にここで親の座標へ直す(coords: 'parent')
|
|
27
|
+
*/
|
|
28
|
+
const toParent = (x: number, y: number) => {
|
|
29
|
+
const rect = iframe.getBoundingClientRect();
|
|
30
|
+
const scale = rect.width / (iframe.clientWidth || rect.width) || 1;
|
|
31
|
+
return { clientX: rect.left + x * scale, clientY: rect.top + y * scale };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---- ホイール(すべて外へ。iframe の中にはスクロールする物が無い)
|
|
35
|
+
const handleWheel = (e: WheelEvent) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
parent.postMessage(
|
|
39
|
+
{
|
|
40
|
+
type: 'EMBEDDED_WHEEL',
|
|
41
|
+
coords: 'parent',
|
|
42
|
+
deltaX: e.deltaMode === 1 ? e.deltaX * 16 : e.deltaMode === 2 ? e.deltaX * 100 : e.deltaX,
|
|
43
|
+
deltaY: e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 100 : e.deltaY,
|
|
44
|
+
...toParent(e.clientX, e.clientY),
|
|
45
|
+
ctrlKey: e.ctrlKey,
|
|
46
|
+
metaKey: e.metaKey,
|
|
47
|
+
shiftKey: e.shiftKey,
|
|
48
|
+
},
|
|
49
|
+
'*',
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---- Safari の gesture(ピンチ)はブラウザ拡大を止めるだけ
|
|
54
|
+
const handleGesture = (e: Event) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- 2 本指タッチ = ピンチ
|
|
60
|
+
let lastDistance: number | null = null;
|
|
61
|
+
const distance = (t: TouchList) => (t.length < 2 ? 0 : Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY));
|
|
62
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
63
|
+
if (e.touches.length >= 2) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
lastDistance = distance(e.touches);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
69
|
+
if (e.touches.length < 2) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
if (lastDistance == null) return;
|
|
72
|
+
const current = distance(e.touches);
|
|
73
|
+
const center = toParent((e.touches[0].clientX + e.touches[1].clientX) / 2, (e.touches[0].clientY + e.touches[1].clientY) / 2);
|
|
74
|
+
parent.postMessage(
|
|
75
|
+
{
|
|
76
|
+
type: 'EMBEDDED_PINCH',
|
|
77
|
+
coords: 'parent',
|
|
78
|
+
previousDistance: lastDistance,
|
|
79
|
+
currentDistance: current,
|
|
80
|
+
centerX: center.clientX,
|
|
81
|
+
centerY: center.clientY,
|
|
82
|
+
},
|
|
83
|
+
'*',
|
|
84
|
+
);
|
|
85
|
+
lastDistance = current;
|
|
86
|
+
};
|
|
87
|
+
const handleTouchEnd = (e: TouchEvent) => {
|
|
88
|
+
if (e.touches.length < 2) lastDistance = null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ---- キー: Space(パン)、Cmd+/- (ズーム)、Shift+0/1/2(100%・全体・このページ)、Shift+R(定規)
|
|
92
|
+
const forwardKey = (kind: 'down' | 'up') => (e: KeyboardEvent) => {
|
|
93
|
+
const cmd = e.metaKey || e.ctrlKey;
|
|
94
|
+
const typing = isTypingTarget(e.target);
|
|
95
|
+
const isSpace = e.code === 'Space';
|
|
96
|
+
const isZoomKey = cmd && ZOOM_KEYS.has(e.key);
|
|
97
|
+
const isShiftView = !cmd && e.shiftKey && !e.altKey && (['0', '1', '2'].includes(e.key) || ['Digit0', 'Digit1', 'Digit2', 'KeyR'].includes(e.code) || e.key.toLowerCase() === 'r');
|
|
98
|
+
if (!isSpace && !isZoomKey && !isShiftView) return;
|
|
99
|
+
if (typing) return;
|
|
100
|
+
if (isSpace && e.repeat) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (kind === 'down') e.preventDefault();
|
|
105
|
+
parent.postMessage(
|
|
106
|
+
{
|
|
107
|
+
type: 'EMBEDDED_KEY',
|
|
108
|
+
kind,
|
|
109
|
+
key: e.key,
|
|
110
|
+
code: e.code,
|
|
111
|
+
metaKey: e.metaKey,
|
|
112
|
+
ctrlKey: e.ctrlKey,
|
|
113
|
+
shiftKey: e.shiftKey,
|
|
114
|
+
altKey: e.altKey,
|
|
115
|
+
},
|
|
116
|
+
'*',
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
const handleKeyDown = forwardKey('down');
|
|
120
|
+
const handleKeyUp = forwardKey('up');
|
|
121
|
+
|
|
122
|
+
// ---- 中ボタン = パンの開始(以降のマウス移動は親が受ける: 親が iframe の pointer-events を切る)
|
|
123
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
124
|
+
if (e.button !== 1) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
parent.postMessage({ type: 'EMBEDDED_PAN_START', coords: 'parent', ...toParent(e.clientX, e.clientY) }, '*');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const options: AddEventListenerOptions = { passive: false, capture: true };
|
|
130
|
+
iframeDoc.addEventListener('wheel', handleWheel, options);
|
|
131
|
+
iframeDoc.addEventListener('gesturestart', handleGesture, options);
|
|
132
|
+
iframeDoc.addEventListener('gesturechange', handleGesture, options);
|
|
133
|
+
iframeDoc.addEventListener('gestureend', handleGesture, options);
|
|
134
|
+
iframeDoc.addEventListener('touchstart', handleTouchStart, options);
|
|
135
|
+
iframeDoc.addEventListener('touchmove', handleTouchMove, options);
|
|
136
|
+
iframeDoc.addEventListener('touchend', handleTouchEnd, options);
|
|
137
|
+
iframeDoc.addEventListener('touchcancel', handleTouchEnd, options);
|
|
138
|
+
iframeDoc.addEventListener('keydown', handleKeyDown, options);
|
|
139
|
+
iframeDoc.addEventListener('keyup', handleKeyUp, options);
|
|
140
|
+
iframeDoc.addEventListener('mousedown', handleMouseDown, options);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
iframeDoc.removeEventListener('wheel', handleWheel, options);
|
|
144
|
+
iframeDoc.removeEventListener('gesturestart', handleGesture, options);
|
|
145
|
+
iframeDoc.removeEventListener('gesturechange', handleGesture, options);
|
|
146
|
+
iframeDoc.removeEventListener('gestureend', handleGesture, options);
|
|
147
|
+
iframeDoc.removeEventListener('touchstart', handleTouchStart, options);
|
|
148
|
+
iframeDoc.removeEventListener('touchmove', handleTouchMove, options);
|
|
149
|
+
iframeDoc.removeEventListener('touchend', handleTouchEnd, options);
|
|
150
|
+
iframeDoc.removeEventListener('touchcancel', handleTouchEnd, options);
|
|
151
|
+
iframeDoc.removeEventListener('keydown', handleKeyDown, options);
|
|
152
|
+
iframeDoc.removeEventListener('keyup', handleKeyUp, options);
|
|
153
|
+
iframeDoc.removeEventListener('mousedown', handleMouseDown, options);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '../constants';
|
|
6
|
+
import type { PreviewStyle } from '../../io';
|
|
6
7
|
import type { EditorMode } from '../EditorContext';
|
|
7
8
|
import { prepareHtmlForSave } from './viewport-utils';
|
|
8
9
|
import { classifySaveAndRevert } from './dom-utils';
|
|
@@ -133,21 +134,29 @@ ${collectHostThemeTokens()}
|
|
|
133
134
|
* - #artboard-wrapper: transform適用対象(ズーム/パン)
|
|
134
135
|
* - #artboard: 白背景、コンテンツ配置エリア
|
|
135
136
|
*/
|
|
136
|
-
export
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
export interface EditableHtmlOptions {
|
|
138
|
+
/**
|
|
139
|
+
* マルチフレームのキャンバスに埋め込む姿。
|
|
140
|
+
* 倍率は外側(親)の CSS transform が持つので、iframe の中は等倍・スクロール無しにする。
|
|
141
|
+
* #canvas-container 等の id は残す(数十か所の getElementById を壊さないため)
|
|
142
|
+
*/
|
|
143
|
+
embedded?: boolean;
|
|
144
|
+
/** webpage の版面の幅。省略時は WEBPAGE_WIDTH(EditorCanvas が後から viewportWidth で上書きする) */
|
|
145
|
+
artboardWidth?: number;
|
|
146
|
+
}
|
|
140
147
|
|
|
148
|
+
/**
|
|
149
|
+
* 紙面の外側で共通に読む <head> の中身(Tailwind の注入判定・フォント)。
|
|
150
|
+
* 編集用(generateEditableHtml)と見るだけ(generatePreviewHtml)で同じ判定を使う。
|
|
151
|
+
* 別々に持つと、片方だけ Tailwind を二重注入して見え方が食い違う
|
|
152
|
+
*/
|
|
153
|
+
function buildCommonHead(content: string): string {
|
|
141
154
|
// コンテンツにTailwind CSSが含まれていない場合は注入
|
|
142
155
|
// 外部スタイルがある場合はセーフモードを使用(既存スタイルを保護)
|
|
143
156
|
const needsTailwind = !hasTailwindCss(content);
|
|
144
157
|
const useSafeMode = needsTailwind && hasExternalStyles(content);
|
|
145
158
|
const tailwindScript = needsTailwind ? getTailwindScriptHtml(useSafeMode) : '';
|
|
146
|
-
|
|
147
159
|
return `
|
|
148
|
-
<!DOCTYPE html>
|
|
149
|
-
<html lang="ja">
|
|
150
|
-
<head>
|
|
151
160
|
<meta charset="UTF-8">
|
|
152
161
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
153
162
|
${tailwindScript}
|
|
@@ -156,7 +165,73 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
|
|
|
156
165
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
157
166
|
<!-- スライドの欧文(font-en)。読み込まないと游ゴシックで代替され、
|
|
158
167
|
数字・フッターの幅がビューアと10px以上ずれる(検証で実測) -->
|
|
159
|
-
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&display=swap" rel="stylesheet"
|
|
168
|
+
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&display=swap" rel="stylesheet">`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 見るだけの紙面(マルチフレームのキャンバスで、編集していないページの姿)。
|
|
173
|
+
* 編集用と同じ head を使い、body は等倍・白地・スクロール無し。
|
|
174
|
+
* 編集用の属性は付けない(選択も編集も、このページを開いてから)
|
|
175
|
+
*/
|
|
176
|
+
export function generatePreviewHtml(
|
|
177
|
+
content: string,
|
|
178
|
+
editorMode: EditorMode = 'slide',
|
|
179
|
+
artboardWidth?: number,
|
|
180
|
+
styles: PreviewStyle[] = [],
|
|
181
|
+
): string {
|
|
182
|
+
// 利用側が渡したスタイル(コンパイル済み Tailwind など)。head の共通部分の後、紙面の基本 CSS の前
|
|
183
|
+
const extraStyles = styles
|
|
184
|
+
.map((s) => ('href' in s ? `<link rel="stylesheet" href="${s.href.replace(/"/g, '"')}">` : `<style data-preview-style>${s.css.replace(/<\/style/gi, '<\\/style')}</style>`))
|
|
185
|
+
.join('\n ');
|
|
186
|
+
const width = editorMode === 'webpage' ? (artboardWidth ?? WEBPAGE_WIDTH) : SLIDE_WIDTH;
|
|
187
|
+
const heightRule = editorMode === 'webpage' ? '' : `height: ${SLIDE_HEIGHT}px; overflow: hidden;`;
|
|
188
|
+
// sandbox(allow-same-origin のみ)では script が動かない。残すと 1 枚ごとに
|
|
189
|
+
// 「Blocked script execution」がコンソールへ出るだけなので、本文からも head からも外す
|
|
190
|
+
const stripScripts = (html: string) => html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
191
|
+
return `<!DOCTYPE html>
|
|
192
|
+
<html lang="ja">
|
|
193
|
+
<head>${stripScripts(buildCommonHead(content))}
|
|
194
|
+
${extraStyles}
|
|
195
|
+
<style>
|
|
196
|
+
* { box-sizing: border-box; }
|
|
197
|
+
html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
|
|
198
|
+
body { width: ${width}px; }
|
|
199
|
+
#artboard { position: relative; width: ${width}px; ${heightRule} background: white; }
|
|
200
|
+
/* 見るだけなので、リンク・入力を触れなくする */
|
|
201
|
+
#artboard a, #artboard button, #artboard input, #artboard textarea, #artboard select { pointer-events: none; }
|
|
202
|
+
</style>
|
|
203
|
+
</head>
|
|
204
|
+
<body data-editor-mode="${editorMode}" data-preview="1">
|
|
205
|
+
<div id="artboard">${stripScripts(content)}</div>
|
|
206
|
+
</body>
|
|
207
|
+
</html>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function generateEditableHtml(
|
|
211
|
+
content: string,
|
|
212
|
+
editorMode: EditorMode = 'slide',
|
|
213
|
+
options: EditableHtmlOptions = {},
|
|
214
|
+
): string {
|
|
215
|
+
const embedded = !!options.embedded;
|
|
216
|
+
const artboardWidth = editorMode === 'webpage' ? (options.artboardWidth ?? WEBPAGE_WIDTH) : SLIDE_WIDTH;
|
|
217
|
+
const artboardHeight = editorMode === 'webpage' ? WEBPAGE_MIN_HEIGHT : SLIDE_HEIGHT;
|
|
218
|
+
const artboardHeightStyle = editorMode === 'webpage' ? 'min-height' : 'height';
|
|
219
|
+
|
|
220
|
+
// 埋め込み: 倍率もスクロールも外側が持つ。中は「紙面がそのまま置いてある」だけの姿にする。
|
|
221
|
+
// id は残し(getElementById の呼び出しを壊さない)、役割だけ CSS で外す
|
|
222
|
+
const embeddedCss = embedded
|
|
223
|
+
? `
|
|
224
|
+
html, body { overflow: visible; height: auto; background: transparent; }
|
|
225
|
+
#canvas-container { position: static; overflow: visible; background: transparent; }
|
|
226
|
+
#canvas-scroll-area { display: block; padding: 0 !important; width: auto !important; height: auto !important; min-width: 0 !important; min-height: 0 !important; }
|
|
227
|
+
#artboard-wrapper { transform: none !important; visibility: visible; }
|
|
228
|
+
#artboard { box-shadow: none; ${editorMode === 'webpage' ? 'min-height: 200px;' : ''} }`
|
|
229
|
+
: '';
|
|
230
|
+
|
|
231
|
+
return `
|
|
232
|
+
<!DOCTYPE html>
|
|
233
|
+
<html lang="ja">
|
|
234
|
+
<head>${buildCommonHead(content)}
|
|
160
235
|
<style>
|
|
161
236
|
* { box-sizing: border-box; }
|
|
162
237
|
html, body {
|
|
@@ -232,10 +307,10 @@ export function generateEditableHtml(content: string, editorMode: EditorMode = '
|
|
|
232
307
|
body.panning #artboard,
|
|
233
308
|
body.panning [data-editable="true"] {
|
|
234
309
|
pointer-events: none !important;
|
|
235
|
-
}
|
|
310
|
+
}${embeddedCss}
|
|
236
311
|
</style>
|
|
237
312
|
</head>
|
|
238
|
-
<body tabindex="-1" data-editor-mode="${editorMode}">
|
|
313
|
+
<body tabindex="-1" data-editor-mode="${editorMode}"${embedded ? ' data-embedded="1"' : ''}>
|
|
239
314
|
<div id="canvas-container">
|
|
240
315
|
<div id="canvas-scroll-area">
|
|
241
316
|
<div id="artboard-wrapper">
|
package/src/io.ts
CHANGED
|
@@ -143,6 +143,23 @@ export type EditorIO = {
|
|
|
143
143
|
/** 保存結果の通知。無ければ何もしない */
|
|
144
144
|
notifySave?: (info: { page: number }) => void;
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* 1件の本文(HTML)を取る。マルチフレームのキャンバス(enableMultiPageCanvas)で、
|
|
148
|
+
* 隣のページへ移るとき・見えてきたページを描くときに呼ばれる。
|
|
149
|
+
* id は contentList の id。無ければ contentList[].thumbnailHtml を使い、
|
|
150
|
+
* それも無ければ(parentId がある利用側では)API から取る
|
|
151
|
+
*/
|
|
152
|
+
loadContent?: (id: string) => Promise<string>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 見るだけの紙面(マルチフレームのキャンバスで、編集していないページ)に足すスタイル。
|
|
156
|
+
* 紙面は script を動かさない(sandbox)ので、本文が Tailwind のブラウザ版 JIT に
|
|
157
|
+
* 頼っている利用側は、コンパイル済み CSS の URL か CSS 文字列をここで渡す
|
|
158
|
+
* (提案書のテンプレートは自分のページの stylesheet を渡す)。
|
|
159
|
+
* 本文に CSS が入っている利用側(構成ラフ)は不要
|
|
160
|
+
*/
|
|
161
|
+
previewStyles?: () => PreviewStyle[] | Promise<PreviewStyle[]>;
|
|
162
|
+
|
|
146
163
|
/**
|
|
147
164
|
* 部品(HTML の template)の一覧。渡すと部品パネルはこれを使い、
|
|
148
165
|
* ブラウザ内(localStorage)の JSON コンポーネントは読まない。
|
|
@@ -186,3 +203,6 @@ export function notProvided(what: string): Error {
|
|
|
186
203
|
}
|
|
187
204
|
|
|
188
205
|
export const EMPTY_DECK: EditorDeck = { version: 0, title: "", slides: [] };
|
|
206
|
+
|
|
207
|
+
/** 見るだけの紙面(マルチフレームのキャンバス)に足すスタイル(URL か CSS 文字列)。io.previewStyles が返す */
|
|
208
|
+
export type PreviewStyle = { href: string } | { css: string };
|
package/src/styles/skin.css
CHANGED
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
`@layer base` を自分の指令と誤認して「@tailwind base が無い」とビルドを止める。
|
|
127
127
|
独自名の層なら素通しされ、層なしの utilities が上書きする関係も保てる。 */
|
|
128
128
|
@layer gg-editor-defaults {
|
|
129
|
-
.gg-editor-ui { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", Meiryo, sans-serif; font-size:
|
|
129
|
+
.gg-editor-ui { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", Meiryo, sans-serif; font-size: 12px; line-height: 1.5; text-align: left; }
|
|
130
130
|
.gg-editor-ui, .gg-editor-ui :where(*, *::before, *::after) { box-sizing: border-box; border-width: 0; border-style: solid; }
|
|
131
131
|
.gg-editor-ui :where(button, input, textarea, select) { font: inherit; color: inherit; margin: 0; }
|
|
132
132
|
.gg-editor-ui :where(button) { background: transparent; cursor: pointer; }
|
|
@@ -137,25 +137,29 @@
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
.gg-editor-ui {
|
|
140
|
+
/* Figma(UI3 ライト)の実測値に寄せる:
|
|
141
|
+
カンバス #F5F5F5 / パネル #FFFFFF / 罫線 #E6E6E6 / 文字 #1E1E1E / 補助 #6B6B6B / アクセント #0D99FF */
|
|
140
142
|
color-scheme: light;
|
|
141
|
-
--ed-bg: #
|
|
143
|
+
--ed-bg: #f5f5f5;
|
|
144
|
+
--ed-canvas: #f5f5f5;
|
|
142
145
|
--ed-panel: #ffffff;
|
|
143
146
|
--ed-panel-raised: #ffffff;
|
|
144
|
-
--ed-surface: #
|
|
145
|
-
--ed-surface-hover: #
|
|
146
|
-
--ed-text: #
|
|
147
|
-
--ed-muted: #
|
|
148
|
-
--ed-line: #
|
|
149
|
-
--ed-line-strong: #
|
|
150
|
-
--ed-accent: #
|
|
151
|
-
--ed-accent-strong: #
|
|
152
|
-
--ed-accent-hover: #
|
|
153
|
-
--ed-accent-soft: #
|
|
154
|
-
--ed-success: #
|
|
155
|
-
--ed-warning: #
|
|
156
|
-
--ed-danger: #
|
|
157
|
-
--ed-
|
|
158
|
-
--ed-
|
|
147
|
+
--ed-surface: #f5f5f5;
|
|
148
|
+
--ed-surface-hover: #e6e6e6;
|
|
149
|
+
--ed-text: #1e1e1e;
|
|
150
|
+
--ed-muted: #6b6b6b;
|
|
151
|
+
--ed-line: #e6e6e6;
|
|
152
|
+
--ed-line-strong: #b3b3b3;
|
|
153
|
+
--ed-accent: #0d99ff;
|
|
154
|
+
--ed-accent-strong: #0d99ff;
|
|
155
|
+
--ed-accent-hover: #0c8ce9;
|
|
156
|
+
--ed-accent-soft: #e5f4ff;
|
|
157
|
+
--ed-success: #14ae5c;
|
|
158
|
+
--ed-warning: #8f5a00;
|
|
159
|
+
--ed-danger: #dc3412;
|
|
160
|
+
--ed-frame-edge: rgba(0, 0, 0, 0.08);
|
|
161
|
+
--ed-shadow: 0 5px 17px rgba(0, 0, 0, 0.2), 0 2px 7px rgba(0, 0, 0, 0.15);
|
|
162
|
+
--ed-radius: 6px;
|
|
159
163
|
--ed-focus: 0 0 0 2px var(--ed-panel), 0 0 0 4px var(--ed-accent);
|
|
160
164
|
--color-background: var(--ed-panel);
|
|
161
165
|
--color-foreground: var(--ed-text);
|
|
@@ -180,24 +184,27 @@
|
|
|
180
184
|
scrollbar-color: var(--ed-line-strong) transparent;
|
|
181
185
|
}
|
|
182
186
|
.gg-editor-ui[data-editor-theme="dark"] {
|
|
187
|
+
/* Figma(UI3 ダーク): カンバス #1E1E1E / パネル #2C2C2C / 入力 #383838 / 罫線 white 10% / アクセント #0D99FF */
|
|
183
188
|
color-scheme: dark;
|
|
184
|
-
--ed-bg: #
|
|
185
|
-
--ed-
|
|
186
|
-
--ed-panel
|
|
187
|
-
--ed-
|
|
188
|
-
--ed-surface
|
|
189
|
-
--ed-
|
|
190
|
-
--ed-
|
|
191
|
-
--ed-
|
|
192
|
-
--ed-line
|
|
193
|
-
--ed-
|
|
194
|
-
--ed-accent
|
|
195
|
-
--ed-accent-
|
|
196
|
-
--ed-accent-
|
|
197
|
-
--ed-
|
|
198
|
-
--ed-
|
|
199
|
-
--ed-
|
|
200
|
-
--ed-
|
|
189
|
+
--ed-bg: #1e1e1e;
|
|
190
|
+
--ed-canvas: #1e1e1e;
|
|
191
|
+
--ed-panel: #2c2c2c;
|
|
192
|
+
--ed-panel-raised: #333333;
|
|
193
|
+
--ed-surface: #383838;
|
|
194
|
+
--ed-surface-hover: #444444;
|
|
195
|
+
--ed-text: #ffffff;
|
|
196
|
+
--ed-muted: #b3b3b3;
|
|
197
|
+
--ed-line: #444444;
|
|
198
|
+
--ed-line-strong: #5c5c5c;
|
|
199
|
+
--ed-accent: #4fb8ff;
|
|
200
|
+
--ed-accent-strong: #0d99ff;
|
|
201
|
+
--ed-accent-hover: #0c8ce9;
|
|
202
|
+
--ed-accent-soft: #244761;
|
|
203
|
+
--ed-success: #85e0a3;
|
|
204
|
+
--ed-warning: #ffcd29;
|
|
205
|
+
--ed-danger: #fca397;
|
|
206
|
+
--ed-frame-edge: rgba(255, 255, 255, 0.12);
|
|
207
|
+
--ed-shadow: 0 5px 17px rgba(0, 0, 0, 0.45), 0 2px 7px rgba(0, 0, 0, 0.3);
|
|
201
208
|
}
|
|
202
209
|
/* 古い固定色の部品も同じトークンへ移行。塗り見本・紙面のコンテンツは対象外。 */
|
|
203
210
|
.gg-editor-ui :is(.text-white, .text-gray-100, .text-gray-200, .text-gray-300):not([data-editor-preview] *) { color: var(--ed-text); }
|
|
@@ -279,3 +286,28 @@
|
|
|
279
286
|
|
|
280
287
|
/* 入力の識別に必要な境界は、レイアウトの薄い区切り線とは分ける。 */
|
|
281
288
|
.gg-editor-ui :is(input:not([type="color"]), textarea, select):not([data-editor-preview] *) { border-color: var(--ed-line-strong) !important; }
|
|
289
|
+
|
|
290
|
+
/* ── マルチフレームのキャンバス(Figma 風) ──────────────
|
|
291
|
+
フレーム名とリングは画面座標に置く(倍率に関係なく同じ太さ・同じ文字サイズ)。
|
|
292
|
+
見るだけの紙面の骨組み(スケルトン)だけは転写層の中にあるので、フレームの寸法に対する % で描く */
|
|
293
|
+
.gg-editor-ui .ed-frame-label {
|
|
294
|
+
position: absolute; left: 0; height: 20px; display: inline-flex; align-items: center; gap: 6px;
|
|
295
|
+
padding: 0 6px 0 4px; border-radius: 4px; font-size: 11px; line-height: 1; white-space: nowrap;
|
|
296
|
+
color: var(--ed-muted); pointer-events: auto; cursor: pointer; overflow: hidden; text-overflow: ellipsis;
|
|
297
|
+
}
|
|
298
|
+
.gg-editor-ui .ed-frame-label:hover { color: var(--ed-text); background: var(--ed-panel); box-shadow: 0 0 0 1px var(--ed-line); }
|
|
299
|
+
.gg-editor-ui .ed-frame-label-active, .gg-editor-ui .ed-frame-label-active:hover { color: var(--ed-accent); font-weight: 600; }
|
|
300
|
+
.gg-editor-ui .ed-frame-label-index { font-variant-numeric: tabular-nums; opacity: 0.7; }
|
|
301
|
+
.gg-editor-ui .ed-frame-label-title { overflow: hidden; text-overflow: ellipsis; }
|
|
302
|
+
.gg-editor-ui .ed-frame-label-dirty { width: 6px; height: 6px; border-radius: 50%; background: var(--ed-warning); flex-shrink: 0; }
|
|
303
|
+
.gg-editor-ui .ed-frame-label-loading { width: 9px; height: 9px; border: 1.5px solid var(--ed-line-strong); border-top-color: var(--ed-accent); border-radius: 50%; animation: ed-spin 0.8s linear infinite; flex-shrink: 0; }
|
|
304
|
+
@keyframes ed-spin { to { transform: rotate(360deg); } }
|
|
305
|
+
.gg-editor-ui .ed-frame-ring { position: absolute; left: 0; top: 0; pointer-events: none; border-radius: 1px; }
|
|
306
|
+
.gg-editor-ui .ed-canvas-marquee { position: absolute; z-index: 20; pointer-events: none; border: 1px solid var(--ed-accent); background: color-mix(in srgb, var(--ed-accent) 12%, transparent); }
|
|
307
|
+
.gg-editor-ui .ed-frame-ring-active { box-shadow: 0 0 0 1.5px var(--ed-accent-strong); }
|
|
308
|
+
.gg-editor-ui .ed-frame-ring-hover { box-shadow: 0 0 0 1px var(--ed-accent-strong); opacity: 0.55; }
|
|
309
|
+
.gg-editor-ui .ed-frame-skeleton { position: absolute; inset: 0; padding: 6% 8%; display: flex; flex-direction: column; gap: 3%; background: #fff; }
|
|
310
|
+
.gg-editor-ui .ed-frame-skeleton-bar { display: block; height: 2.5%; min-height: 8px; border-radius: 999px; background: #eceff3; }
|
|
311
|
+
.gg-editor-ui .ed-frame-skeleton-note { font-size: 40px; color: #9aa3b2; }
|
|
312
|
+
/* 定規の下に来る部分は透けないように */
|
|
313
|
+
.gg-editor-ui [data-canvas-rulers] canvas { display: block; }
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { memo, useCallback, useRef } from 'react';
|
|
4
|
-
import { Minus, Plus, Maximize } from 'lucide-react';
|
|
5
|
-
import { Button } from '../../../components/ui/button';
|
|
6
|
-
import { useMultiPageCanvas } from '../../contexts/MultiPageCanvasContext';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* ズームコントロール(右下固定)
|
|
10
|
-
* - ズームイン/アウトボタン
|
|
11
|
-
* - ズーム値表示
|
|
12
|
-
* - フィットボタン (全ページを表示)
|
|
13
|
-
*/
|
|
14
|
-
export const CanvasZoomControls = memo(function CanvasZoomControls() {
|
|
15
|
-
const { viewState, zoomIn, zoomOut, zoomToFit } = useMultiPageCanvas();
|
|
16
|
-
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
17
|
-
|
|
18
|
-
const handleFitToView = useCallback(() => {
|
|
19
|
-
// 親のInfiniteCanvasコンテナサイズを取得
|
|
20
|
-
const canvas = containerRef.current?.closest('[data-infinite-canvas]');
|
|
21
|
-
if (canvas) {
|
|
22
|
-
const rect = canvas.getBoundingClientRect();
|
|
23
|
-
zoomToFit(rect.width, rect.height);
|
|
24
|
-
}
|
|
25
|
-
}, [zoomToFit]);
|
|
26
|
-
|
|
27
|
-
const zoomPercent = Math.round(viewState.canvasZoom * 100);
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<div
|
|
31
|
-
ref={containerRef}
|
|
32
|
-
className="absolute bottom-4 right-4 z-10 flex items-center gap-1 bg-[#2a2a2a] border border-[#404040] rounded-lg px-1 py-1 shadow-lg"
|
|
33
|
-
>
|
|
34
|
-
<Button
|
|
35
|
-
variant="ghost"
|
|
36
|
-
size="sm"
|
|
37
|
-
className="h-7 w-7 p-0 text-gray-400 hover:text-white hover:bg-[#404040]"
|
|
38
|
-
onClick={zoomOut}
|
|
39
|
-
title="ズームアウト"
|
|
40
|
-
>
|
|
41
|
-
<Minus className="w-3.5 h-3.5" />
|
|
42
|
-
</Button>
|
|
43
|
-
|
|
44
|
-
<span className="text-xs text-gray-300 min-w-[40px] text-center tabular-nums select-none">
|
|
45
|
-
{zoomPercent}%
|
|
46
|
-
</span>
|
|
47
|
-
|
|
48
|
-
<Button
|
|
49
|
-
variant="ghost"
|
|
50
|
-
size="sm"
|
|
51
|
-
className="h-7 w-7 p-0 text-gray-400 hover:text-white hover:bg-[#404040]"
|
|
52
|
-
onClick={zoomIn}
|
|
53
|
-
title="ズームイン"
|
|
54
|
-
>
|
|
55
|
-
<Plus className="w-3.5 h-3.5" />
|
|
56
|
-
</Button>
|
|
57
|
-
|
|
58
|
-
<div className="w-px h-4 bg-[#404040] mx-0.5" />
|
|
59
|
-
|
|
60
|
-
<Button
|
|
61
|
-
variant="ghost"
|
|
62
|
-
size="sm"
|
|
63
|
-
className="h-7 w-7 p-0 text-gray-400 hover:text-white hover:bg-[#404040]"
|
|
64
|
-
onClick={handleFitToView}
|
|
65
|
-
title="全体にフィット (Ctrl+0)"
|
|
66
|
-
>
|
|
67
|
-
<Maximize className="w-3.5 h-3.5" />
|
|
68
|
-
</Button>
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
71
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { memo, useRef, useEffect, useCallback } from 'react';
|
|
4
|
-
import { useMultiPageCanvas } from '../../contexts/MultiPageCanvasContext';
|
|
5
|
-
import { useInfiniteCanvas } from '../../hooks/useInfiniteCanvas';
|
|
6
|
-
import { PageFrameOverlay } from './PageFrameOverlay';
|
|
7
|
-
import { CanvasZoomControls } from './CanvasZoomControls';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* InfiniteCanvas: 無限キャンバスコンテナ(レガシー)
|
|
11
|
-
*
|
|
12
|
-
* 全ページをフレームとして表示し、CSS transformでズーム/パンを実現。
|
|
13
|
-
* Note: マルチページ統合ビューはMultiPageCanvasViewを使用。
|
|
14
|
-
* このコンポーネントはサムネイル表示のみ(編集iframe含まず)。
|
|
15
|
-
*/
|
|
16
|
-
export const InfiniteCanvas = memo(function InfiniteCanvas() {
|
|
17
|
-
const {
|
|
18
|
-
viewState,
|
|
19
|
-
pages,
|
|
20
|
-
switchPage,
|
|
21
|
-
zoomToFit,
|
|
22
|
-
} = useMultiPageCanvas();
|
|
23
|
-
const { canvasOffset, canvasZoom, activePageId } = viewState;
|
|
24
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
-
|
|
26
|
-
// 無限キャンバスのズーム/パン操作
|
|
27
|
-
useInfiniteCanvas(containerRef);
|
|
28
|
-
|
|
29
|
-
// 初回表示時にフィットズーム
|
|
30
|
-
const hasInitialFitRef = useRef(false);
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
if (hasInitialFitRef.current || pages.length === 0) return;
|
|
33
|
-
const container = containerRef.current;
|
|
34
|
-
if (!container) return;
|
|
35
|
-
|
|
36
|
-
const timer = setTimeout(() => {
|
|
37
|
-
const rect = container.getBoundingClientRect();
|
|
38
|
-
if (rect.width > 0 && rect.height > 0) {
|
|
39
|
-
zoomToFit(rect.width, rect.height);
|
|
40
|
-
hasInitialFitRef.current = true;
|
|
41
|
-
}
|
|
42
|
-
}, 100);
|
|
43
|
-
return () => clearTimeout(timer);
|
|
44
|
-
}, [pages.length, zoomToFit]);
|
|
45
|
-
|
|
46
|
-
const handlePageClick = useCallback((pageId: string) => {
|
|
47
|
-
switchPage(pageId);
|
|
48
|
-
}, [switchPage]);
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<div
|
|
52
|
-
ref={containerRef}
|
|
53
|
-
data-infinite-canvas="true"
|
|
54
|
-
className="absolute inset-0 overflow-hidden select-none"
|
|
55
|
-
style={{ backgroundColor: '#1a1a1a' }}
|
|
56
|
-
>
|
|
57
|
-
{/* ドットグリッド背景 */}
|
|
58
|
-
<div
|
|
59
|
-
className="absolute inset-0 pointer-events-none"
|
|
60
|
-
style={{
|
|
61
|
-
backgroundImage: `radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px)`,
|
|
62
|
-
backgroundSize: `${24 * canvasZoom}px ${24 * canvasZoom}px`,
|
|
63
|
-
backgroundPosition: `${canvasOffset.x % (24 * canvasZoom)}px ${canvasOffset.y % (24 * canvasZoom)}px`,
|
|
64
|
-
}}
|
|
65
|
-
/>
|
|
66
|
-
|
|
67
|
-
{/* CSS transform による無限キャンバス */}
|
|
68
|
-
<div
|
|
69
|
-
className="canvas-transform-layer"
|
|
70
|
-
style={{
|
|
71
|
-
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`,
|
|
72
|
-
transformOrigin: '0 0',
|
|
73
|
-
willChange: 'transform',
|
|
74
|
-
}}
|
|
75
|
-
>
|
|
76
|
-
{pages.map(page => (
|
|
77
|
-
<PageFrameOverlay
|
|
78
|
-
key={page.id}
|
|
79
|
-
page={page}
|
|
80
|
-
isActive={page.id === activePageId}
|
|
81
|
-
onClick={handlePageClick}
|
|
82
|
-
/>
|
|
83
|
-
))}
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
{/* ズームコントロール */}
|
|
87
|
-
<CanvasZoomControls />
|
|
88
|
-
|
|
89
|
-
{/* ページ数表示 */}
|
|
90
|
-
<div className="absolute top-4 left-4 z-10 text-xs text-gray-500 select-none">
|
|
91
|
-
{pages.length} pages
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
});
|