@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
|
@@ -28,6 +28,7 @@ import { useEditorContext } from "../EditorContext";
|
|
|
28
28
|
import { ConfirmDialog } from "./shell/ConfirmDialog";
|
|
29
29
|
import { EditorTopBar } from "./shell/EditorTopBar";
|
|
30
30
|
import { MIN_ZOOM, MAX_ZOOM, editorZoomApiRef } from "../hooks/useCanvasControls";
|
|
31
|
+
import { useMultiPageCanvasOptional, useCanvasViewStateOptional, MIN_ZOOM as CANVAS_MIN_ZOOM, MAX_ZOOM as CANVAS_MAX_ZOOM } from "../contexts/MultiPageCanvasContext";
|
|
31
32
|
import { generateEditableHtml } from "../utils/html-utils";
|
|
32
33
|
import { useDeck } from "../../components/viewer/useDeck";
|
|
33
34
|
import { unresolvedCount } from "./ppt/PptComments";
|
|
@@ -99,8 +100,8 @@ export function EditorHeader({
|
|
|
99
100
|
const {
|
|
100
101
|
iframeRef,
|
|
101
102
|
originalHtml,
|
|
102
|
-
zoom,
|
|
103
|
-
setZoom,
|
|
103
|
+
zoom: singleZoom,
|
|
104
|
+
setZoom: setSingleZoom,
|
|
104
105
|
fitZoom,
|
|
105
106
|
setSelectedElement,
|
|
106
107
|
setSelectedElementIds,
|
|
@@ -118,6 +119,17 @@ export function EditorHeader({
|
|
|
118
119
|
|
|
119
120
|
const [resetOpen, setResetOpen] = useState(false);
|
|
120
121
|
|
|
122
|
+
// マルチフレームのキャンバスでは倍率はキャンバス全体のもの。表示も操作もそちらへ向ける
|
|
123
|
+
const canvas = useMultiPageCanvasOptional();
|
|
124
|
+
const canvasView = useCanvasViewStateOptional();
|
|
125
|
+
const zoom = canvas && canvasView ? canvasView.canvasZoom * 100 : singleZoom;
|
|
126
|
+
const minZoom = canvas ? CANVAS_MIN_ZOOM * 100 : MIN_ZOOM;
|
|
127
|
+
const maxZoom = canvas ? CANVAS_MAX_ZOOM * 100 : MAX_ZOOM;
|
|
128
|
+
const setZoom = (value: number) => {
|
|
129
|
+
if (canvas) canvas.zoomTo(value / 100, { animate: true });
|
|
130
|
+
else setSingleZoom(value);
|
|
131
|
+
};
|
|
132
|
+
|
|
121
133
|
// 保存・共有・プレビュー・閉じるは共通トップバー(EditorTopBar)が持つ。
|
|
122
134
|
// ここに実装を残すと、PowerPoint風UIと2本の保存経路ができてしまう
|
|
123
135
|
|
|
@@ -245,12 +257,12 @@ export function EditorHeader({
|
|
|
245
257
|
// 以前は上限200%/下限25%で止めていたが、キャンバス側(useCanvasControls)の
|
|
246
258
|
// 実際の可動域は 10〜400%。ボタンだけ手前で止まるため、ホイールで250%まで
|
|
247
259
|
// 拡大したあとに「+を押しても何も起きない」状態が生まれていた。
|
|
248
|
-
const clampZoom = (v: number) => Math.max(
|
|
249
|
-
const canZoomIn = zoom <
|
|
250
|
-
const canZoomOut = zoom >
|
|
260
|
+
const clampZoom = (v: number) => Math.max(minZoom, Math.min(maxZoom, v));
|
|
261
|
+
const canZoomIn = zoom < maxZoom - 0.01;
|
|
262
|
+
const canZoomOut = zoom > minZoom + 0.01;
|
|
251
263
|
|
|
252
|
-
const handleZoomIn = () => setZoom(clampZoom(Math.floor(zoom / 10) * 10 + 10));
|
|
253
|
-
const handleZoomOut = () => setZoom(clampZoom(Math.ceil(zoom / 10) * 10 - 10));
|
|
264
|
+
const handleZoomIn = () => (canvas ? canvas.zoomIn() : setZoom(clampZoom(Math.floor(zoom / 10) * 10 + 10)));
|
|
265
|
+
const handleZoomOut = () => (canvas ? canvas.zoomOut() : setZoom(clampZoom(Math.ceil(zoom / 10) * 10 - 10)));
|
|
254
266
|
// 全体表示/100% はキャンバス側(useCanvasControls)の実装に委ねる。
|
|
255
267
|
// Cmd+0 / Cmd+1 と同じ関数を呼ぶことで、「ヘッダーから押したときだけ
|
|
256
268
|
// 固定点の扱いが違う」というズレを作らない。未登録時は従来どおり値を入れる。
|
|
@@ -333,7 +345,7 @@ export function EditorHeader({
|
|
|
333
345
|
data-zoom-out
|
|
334
346
|
onClick={handleZoomOut}
|
|
335
347
|
disabled={!canZoomOut}
|
|
336
|
-
title={canZoomOut ? "縮小" : `これ以上縮小できません(下限 ${
|
|
348
|
+
title={canZoomOut ? "縮小" : `これ以上縮小できません(下限 ${minZoom}%)`}
|
|
337
349
|
className="h-8 w-8 text-gray-400 hover:text-white hover:bg-[#444444] disabled:opacity-30"
|
|
338
350
|
>
|
|
339
351
|
<ZoomOut className="h-4 w-4" />
|
|
@@ -361,8 +373,8 @@ export function EditorHeader({
|
|
|
361
373
|
ref={zoomInputRef}
|
|
362
374
|
data-zoom-input
|
|
363
375
|
type="number"
|
|
364
|
-
min={
|
|
365
|
-
max={
|
|
376
|
+
min={minZoom}
|
|
377
|
+
max={maxZoom}
|
|
366
378
|
value={zoomInput}
|
|
367
379
|
onChange={(e) => setZoomInput(e.target.value)}
|
|
368
380
|
onKeyDown={(e) => {
|
|
@@ -373,7 +385,7 @@ export function EditorHeader({
|
|
|
373
385
|
if (e.key === "Escape") setZoomMenuOpen(false);
|
|
374
386
|
}}
|
|
375
387
|
className="h-7 w-full rounded border border-[#4a4a4a] bg-[#1e1e1e] px-2 text-xs tabular-nums text-white outline-none focus:border-[#0d99ff]"
|
|
376
|
-
aria-label={`表示倍率(%) ${
|
|
388
|
+
aria-label={`表示倍率(%) ${minZoom}〜${maxZoom}`}
|
|
377
389
|
/>
|
|
378
390
|
<span className="text-xs text-gray-500">%</span>
|
|
379
391
|
<Button
|
|
@@ -403,6 +415,16 @@ export function EditorHeader({
|
|
|
403
415
|
100%
|
|
404
416
|
<span className="text-[10px] text-gray-500">⌘1</span>
|
|
405
417
|
</DropdownMenuItem>
|
|
418
|
+
{canvas && (
|
|
419
|
+
<DropdownMenuItem
|
|
420
|
+
data-zoom-preset="page"
|
|
421
|
+
onClick={() => { const id = canvas.viewStore.get().activePageId; if (id) canvas.zoomToPage(id, { animate: true }); }}
|
|
422
|
+
className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
|
|
423
|
+
>
|
|
424
|
+
編集中のページに合わせる
|
|
425
|
+
<span className="text-[10px] text-gray-500">⇧2</span>
|
|
426
|
+
</DropdownMenuItem>
|
|
427
|
+
)}
|
|
406
428
|
<DropdownMenuItem
|
|
407
429
|
data-zoom-preset="50"
|
|
408
430
|
onClick={() => setZoom(50)}
|
|
@@ -417,6 +439,19 @@ export function EditorHeader({
|
|
|
417
439
|
>
|
|
418
440
|
200%
|
|
419
441
|
</DropdownMenuItem>
|
|
442
|
+
{canvas && (
|
|
443
|
+
<>
|
|
444
|
+
<DropdownMenuSeparator className="bg-[#444444]" />
|
|
445
|
+
<DropdownMenuItem
|
|
446
|
+
data-zoom-rulers
|
|
447
|
+
onClick={canvas.toggleRulers}
|
|
448
|
+
className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
|
|
449
|
+
>
|
|
450
|
+
<span className="flex items-center gap-2"><Ruler className="h-3.5 w-3.5" />定規{canvas.rulersVisible ? "を隠す" : "を表示"}</span>
|
|
451
|
+
<span className="text-[10px] text-gray-500">⇧R</span>
|
|
452
|
+
</DropdownMenuItem>
|
|
453
|
+
</>
|
|
454
|
+
)}
|
|
420
455
|
</DropdownMenuContent>
|
|
421
456
|
</DropdownMenu>
|
|
422
457
|
<Button
|
|
@@ -425,7 +460,7 @@ export function EditorHeader({
|
|
|
425
460
|
data-zoom-in
|
|
426
461
|
onClick={handleZoomIn}
|
|
427
462
|
disabled={!canZoomIn}
|
|
428
|
-
title={canZoomIn ? "拡大" : `これ以上拡大できません(上限 ${
|
|
463
|
+
title={canZoomIn ? "拡大" : `これ以上拡大できません(上限 ${maxZoom}%)`}
|
|
429
464
|
className="h-8 w-8 text-gray-400 hover:text-white hover:bg-[#444444] disabled:opacity-30"
|
|
430
465
|
>
|
|
431
466
|
<ZoomIn className="h-4 w-4" />
|
|
@@ -370,6 +370,8 @@ function LiveGeometryProvider({
|
|
|
370
370
|
// ドラッグ/リサイズは style を、確定時の Tailwind 変換は class を書き換える。
|
|
371
371
|
// 親のリフローで矩形が変わる場合もあるので artboard 全体を subtree で監視する。
|
|
372
372
|
const target = doc.getElementById("artboard") || doc.body;
|
|
373
|
+
// 読み込み中の文書(body 無し)には付けない。準備できたら iframeReady で張り直る
|
|
374
|
+
if (!target) return;
|
|
373
375
|
const observer = new MutationObserver(schedule);
|
|
374
376
|
observer.observe(target, {
|
|
375
377
|
attributes: true,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 定規(上・左)。Figma と同じく紙面の座標(等倍の px)で目盛りを打ち、
|
|
5
|
+
* 編集中のフレームの範囲を淡く塗る。倍率に応じて目盛りの間隔を切り替える。
|
|
6
|
+
* <canvas> に描くので、ズーム・パンの毎フレームでも DOM を作り直さない
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { memo, useEffect, useRef } from 'react';
|
|
10
|
+
import { RULER_SIZE, useMultiPageCanvas, useCanvasViewState } from '../../contexts/MultiPageCanvasContext';
|
|
11
|
+
|
|
12
|
+
/** 目盛りの候補(紙面の px)。画面上で 60px 以上離れる最小のものを選ぶ */
|
|
13
|
+
const STEPS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
14
|
+
|
|
15
|
+
function pickStep(zoom: number): number {
|
|
16
|
+
return STEPS.find((s) => s * zoom >= 60) ?? STEPS[STEPS.length - 1];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readVar(el: HTMLElement, name: string, fallback: string): string {
|
|
20
|
+
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
|
21
|
+
return v || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CanvasRulers = memo(function CanvasRulers({ theme }: { theme: 'light' | 'dark' }) {
|
|
25
|
+
const { pages } = useMultiPageCanvas();
|
|
26
|
+
const viewState = useCanvasViewState();
|
|
27
|
+
const topRef = useRef<HTMLCanvasElement>(null);
|
|
28
|
+
const leftRef = useRef<HTMLCanvasElement>(null);
|
|
29
|
+
const { canvasOffset, canvasZoom, activePageId } = viewState;
|
|
30
|
+
const active = pages.find((p) => p.id === activePageId);
|
|
31
|
+
const activeKey = active ? `${active.position.x},${active.position.y},${active.size.width},${active.size.height}` : '';
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const top = topRef.current;
|
|
35
|
+
const left = leftRef.current;
|
|
36
|
+
if (!top || !left) return;
|
|
37
|
+
const host = top.parentElement as HTMLElement | null;
|
|
38
|
+
if (!host) return;
|
|
39
|
+
const dpr = window.devicePixelRatio || 1;
|
|
40
|
+
const width = host.clientWidth;
|
|
41
|
+
const height = host.clientHeight;
|
|
42
|
+
if (width === 0 || height === 0) return;
|
|
43
|
+
|
|
44
|
+
const colors = {
|
|
45
|
+
bg: readVar(host, '--ed-panel', theme === 'dark' ? '#2c2c2c' : '#ffffff'),
|
|
46
|
+
line: readVar(host, '--ed-line', theme === 'dark' ? '#444444' : '#e6e6e6'),
|
|
47
|
+
text: readVar(host, '--ed-muted', theme === 'dark' ? '#b3b3b3' : '#6b6b6b'),
|
|
48
|
+
accent: readVar(host, '--ed-accent', '#0d99ff'),
|
|
49
|
+
};
|
|
50
|
+
const step = pickStep(canvasZoom);
|
|
51
|
+
const minor = step / 5;
|
|
52
|
+
|
|
53
|
+
const setup = (cv: HTMLCanvasElement, w: number, h: number) => {
|
|
54
|
+
cv.width = Math.round(w * dpr);
|
|
55
|
+
cv.height = Math.round(h * dpr);
|
|
56
|
+
cv.style.width = `${w}px`;
|
|
57
|
+
cv.style.height = `${h}px`;
|
|
58
|
+
const ctx = cv.getContext('2d')!;
|
|
59
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
60
|
+
ctx.fillStyle = colors.bg;
|
|
61
|
+
ctx.fillRect(0, 0, w, h);
|
|
62
|
+
ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif';
|
|
63
|
+
ctx.fillStyle = colors.text;
|
|
64
|
+
ctx.strokeStyle = colors.line;
|
|
65
|
+
ctx.lineWidth = 1;
|
|
66
|
+
return ctx;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---- 上の定規
|
|
70
|
+
{
|
|
71
|
+
const ctx = setup(top, width, RULER_SIZE);
|
|
72
|
+
if (active) {
|
|
73
|
+
ctx.fillStyle = theme === 'dark' ? 'rgba(13,153,255,0.22)' : 'rgba(13,153,255,0.16)';
|
|
74
|
+
const x0 = canvasOffset.x + active.position.x * canvasZoom;
|
|
75
|
+
ctx.fillRect(x0, 0, active.size.width * canvasZoom, RULER_SIZE);
|
|
76
|
+
ctx.fillStyle = colors.text;
|
|
77
|
+
}
|
|
78
|
+
const first = Math.floor((RULER_SIZE - canvasOffset.x) / canvasZoom / minor) * minor;
|
|
79
|
+
const last = (width - canvasOffset.x) / canvasZoom;
|
|
80
|
+
ctx.textAlign = 'left';
|
|
81
|
+
ctx.textBaseline = 'top';
|
|
82
|
+
ctx.beginPath();
|
|
83
|
+
for (let v = first; v <= last; v += minor) {
|
|
84
|
+
const x = Math.round(canvasOffset.x + v * canvasZoom) + 0.5;
|
|
85
|
+
if (x < RULER_SIZE) continue;
|
|
86
|
+
const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
|
|
87
|
+
ctx.moveTo(x, RULER_SIZE);
|
|
88
|
+
ctx.lineTo(x, major ? RULER_SIZE - 9 : RULER_SIZE - 4);
|
|
89
|
+
if (major) ctx.fillText(String(Math.round(v)), x + 3, 3);
|
|
90
|
+
}
|
|
91
|
+
ctx.stroke();
|
|
92
|
+
ctx.beginPath();
|
|
93
|
+
ctx.moveTo(0, RULER_SIZE - 0.5);
|
|
94
|
+
ctx.lineTo(width, RULER_SIZE - 0.5);
|
|
95
|
+
ctx.stroke();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- 左の定規(文字は縦書きにせず、横のまま小さく置く)
|
|
99
|
+
{
|
|
100
|
+
const ctx = setup(left, RULER_SIZE, height);
|
|
101
|
+
if (active) {
|
|
102
|
+
ctx.fillStyle = theme === 'dark' ? 'rgba(13,153,255,0.22)' : 'rgba(13,153,255,0.16)';
|
|
103
|
+
const y0 = canvasOffset.y + active.position.y * canvasZoom;
|
|
104
|
+
ctx.fillRect(0, y0, RULER_SIZE, active.size.height * canvasZoom);
|
|
105
|
+
ctx.fillStyle = colors.text;
|
|
106
|
+
}
|
|
107
|
+
const first = Math.floor((RULER_SIZE - canvasOffset.y) / canvasZoom / minor) * minor;
|
|
108
|
+
const last = (height - canvasOffset.y) / canvasZoom;
|
|
109
|
+
ctx.beginPath();
|
|
110
|
+
for (let v = first; v <= last; v += minor) {
|
|
111
|
+
const y = Math.round(canvasOffset.y + v * canvasZoom) + 0.5;
|
|
112
|
+
if (y < RULER_SIZE) continue;
|
|
113
|
+
const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
|
|
114
|
+
ctx.moveTo(RULER_SIZE, y);
|
|
115
|
+
ctx.lineTo(major ? RULER_SIZE - 9 : RULER_SIZE - 4, y);
|
|
116
|
+
if (major) {
|
|
117
|
+
ctx.save();
|
|
118
|
+
ctx.translate(3, y + 3);
|
|
119
|
+
ctx.rotate(-Math.PI / 2);
|
|
120
|
+
ctx.textAlign = 'right';
|
|
121
|
+
ctx.textBaseline = 'top';
|
|
122
|
+
ctx.fillText(String(Math.round(v)), 0, 0);
|
|
123
|
+
ctx.restore();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
ctx.stroke();
|
|
127
|
+
ctx.beginPath();
|
|
128
|
+
ctx.moveTo(RULER_SIZE - 0.5, 0);
|
|
129
|
+
ctx.lineTo(RULER_SIZE - 0.5, height);
|
|
130
|
+
ctx.stroke();
|
|
131
|
+
}
|
|
132
|
+
}, [canvasOffset.x, canvasOffset.y, canvasZoom, activeKey, theme, active]);
|
|
133
|
+
|
|
134
|
+
// 容器のサイズ変更でも描き直す
|
|
135
|
+
const [, force] = useForceRedraw(topRef);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="pointer-events-none absolute inset-0 z-20" data-canvas-rulers aria-hidden="true" data-redraw={force}>
|
|
139
|
+
<canvas ref={topRef} className="absolute left-0 top-0" />
|
|
140
|
+
<canvas ref={leftRef} className="absolute left-0 top-0" />
|
|
141
|
+
<div
|
|
142
|
+
className="absolute left-0 top-0"
|
|
143
|
+
style={{ width: RULER_SIZE, height: RULER_SIZE, background: 'var(--ed-panel)', borderRight: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)' }}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function useForceRedraw(ref: React.RefObject<HTMLCanvasElement | null>): [number, number] {
|
|
150
|
+
const tick = useRef(0);
|
|
151
|
+
const [, set] = useStateSafe();
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const host = ref.current?.parentElement;
|
|
154
|
+
if (!host || typeof ResizeObserver === 'undefined') return;
|
|
155
|
+
const observer = new ResizeObserver(() => { tick.current++; set(tick.current); });
|
|
156
|
+
observer.observe(host);
|
|
157
|
+
return () => observer.disconnect();
|
|
158
|
+
}, [ref, set]);
|
|
159
|
+
return [tick.current, tick.current];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
import { useState } from 'react';
|
|
163
|
+
function useStateSafe(): [number, (v: number) => void] {
|
|
164
|
+
const [v, set] = useState(0);
|
|
165
|
+
return [v, set];
|
|
166
|
+
}
|