@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
@@ -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(MIN_ZOOM, Math.min(MAX_ZOOM, v));
249
- const canZoomIn = zoom < MAX_ZOOM - 0.01;
250
- const canZoomOut = zoom > MIN_ZOOM + 0.01;
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 ? "縮小" : `これ以上縮小できません(下限 ${MIN_ZOOM}%)`}
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={MIN_ZOOM}
365
- max={MAX_ZOOM}
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={`表示倍率(%) ${MIN_ZOOM}〜${MAX_ZOOM}`}
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 ? "拡大" : `これ以上拡大できません(上限 ${MAX_ZOOM}%)`}
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
+ }