@growgroup/visual-editor 0.3.0 → 0.4.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 (65) hide show
  1. package/README.md +10 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +11 -2
  5. package/src/editor/EditorToolbar.tsx +8 -2
  6. package/src/editor/FrontendVisualEditor.tsx +128 -45
  7. package/src/editor/components/EditorCanvas.tsx +87 -15
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorFooter.tsx +2 -0
  10. package/src/editor/components/EditorHeader.tsx +3 -2
  11. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  12. package/src/editor/components/EditorPropertyPanel.tsx +54 -25
  13. package/src/editor/components/FigmaColorPicker.tsx +2 -1
  14. package/src/editor/components/JsEditorDialog.tsx +1 -0
  15. package/src/editor/components/PageSettingsDialog.tsx +1 -0
  16. package/src/editor/components/comments/CommentBoard.tsx +9 -6
  17. package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
  18. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +204 -58
  19. package/src/editor/components/multi-page/PageFramePreview.tsx +131 -22
  20. package/src/editor/components/multi-page/index.ts +1 -0
  21. package/src/editor/components/ppt/PptChrome.tsx +6 -2
  22. package/src/editor/components/ppt/PptComments.tsx +411 -71
  23. package/src/editor/constants.ts +44 -1
  24. package/src/editor/contexts/EditorArtboardContext.tsx +60 -1
  25. package/src/editor/contexts/EditorComponentsContext.tsx +48 -31
  26. package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
  27. package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
  28. package/src/editor/contexts/MultiPageCanvasContext.tsx +211 -34
  29. package/src/editor/hooks/useAiReplace.ts +4 -3
  30. package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
  31. package/src/editor/hooks/useComponentEditMode.ts +27 -26
  32. package/src/editor/hooks/useEditorMessages.ts +3 -2
  33. package/src/editor/hooks/useElementActions.ts +27 -26
  34. package/src/editor/hooks/useElementSelection.ts +19 -16
  35. package/src/editor/hooks/useFocusManagement.ts +7 -6
  36. package/src/editor/hooks/useGoogleFonts.ts +3 -2
  37. package/src/editor/hooks/useIframeInitializer.ts +6 -3
  38. package/src/editor/hooks/useIframeSetup.ts +4 -3
  39. package/src/editor/hooks/useImageUpload.ts +4 -3
  40. package/src/editor/hooks/useMarqueeSelection.ts +2 -1
  41. package/src/editor/hooks/usePageSettingsManager.ts +24 -23
  42. package/src/editor/hooks/useRichPaste.ts +11 -10
  43. package/src/editor/parts.ts +7 -25
  44. package/src/editor/utils/component-renderer.ts +8 -7
  45. package/src/editor/utils/component-sync.ts +16 -15
  46. package/src/editor/utils/debug.ts +52 -0
  47. package/src/editor/utils/dom-utils.ts +27 -10
  48. package/src/editor/utils/drop-target.ts +135 -0
  49. package/src/editor/utils/eyedropper.ts +3 -1
  50. package/src/editor/utils/figma-export.ts +6 -5
  51. package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
  52. package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
  53. package/src/editor/utils/figma-paste.ts +57 -56
  54. package/src/editor/utils/flex-utils.ts +12 -10
  55. package/src/editor/utils/html-utils.ts +19 -6
  56. package/src/editor/utils/override-apply.ts +3 -2
  57. package/src/editor/utils/override-detection.ts +11 -10
  58. package/src/editor/utils/tailwind-utils.ts +4 -3
  59. package/src/index.ts +5 -0
  60. package/src/io.ts +22 -1
  61. package/src/lib/firebase/css-variables.ts +4 -3
  62. package/src/lib/firebase/storage.ts +3 -2
  63. package/src/styles/skin.css +63 -4
  64. package/src/types/editor.ts +6 -2
  65. package/src/version.ts +9 -0
@@ -21,10 +21,10 @@
21
21
  */
22
22
 
23
23
  import { useState } from 'react';
24
- import { Check, CornerUpLeft, ExternalLink, MapPin, MessageSquare, RotateCcw, Send, Trash2 } from 'lucide-react';
24
+ import { Check, CornerUpLeft, Crosshair, ExternalLink, MapPin, MessageSquare, RotateCcw, Send, Trash2 } from 'lucide-react';
25
25
  import type { Deck, SlideComment } from '../../../lib/deck';
26
26
  import { PPT_PALETTES, type PptTheme } from '../ppt/PptChrome';
27
- import { fmtTime, unresolvedCount } from '../ppt/PptComments';
27
+ import { describeRect, fmtTime, unresolvedCount } from '../ppt/PptComments';
28
28
 
29
29
  /**
30
30
  * アンカー・返信のリンク色。
@@ -368,15 +368,18 @@ function BoardCard({
368
368
  </span>
369
369
  </div>
370
370
 
371
- {/* 何に対する指摘か。要素に紐づいていなければページ全体 */}
371
+ {/* 何に対する指摘か。範囲 > 要素 > ページ全体 */}
372
372
  <div
373
373
  className="mt-1.5 flex items-center gap-1 text-[10px]"
374
- style={{ color: comment.anchorSrc ? accent : pal.sub }}
374
+ style={{ color: comment.anchorRect || comment.anchorSrc ? accent : pal.sub }}
375
375
  title={comment.anchorSrc}
376
376
  >
377
- <MapPin className="h-3 w-3 shrink-0" />
377
+ {comment.anchorRect ? <Crosshair className="h-3 w-3 shrink-0" /> : <MapPin className="h-3 w-3 shrink-0" />}
378
378
  <span className="truncate">
379
- {comment.anchorSrc ? (comment.anchorLabel ? `「${comment.anchorLabel}」` : '要素に添付') : 'ページ全体'}
379
+ {comment.seq != null && <span className="mr-1 tabular-nums">#{comment.seq}</span>}
380
+ {comment.anchorRect
381
+ ? describeRect(comment.anchorRect)
382
+ : comment.anchorSrc ? (comment.anchorLabel ? `「${comment.anchorLabel}」` : '要素に添付') : 'ページ全体'}
380
383
  </span>
381
384
  </div>
382
385
 
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * フレームの枠線と階層の線を <canvas> に描く(画面の座標)。
5
+ *
6
+ * [なぜ DOM ではなく canvas か]
7
+ * 枠線を転写層の中の要素に持たせる(box-shadow / outline / border のどれでも)と、
8
+ * 倍率が変わるたびに転写層まるごとが塗り直される。26 ページで 1 段あたり 10〜20ms、
9
+ * 30 段の縮小で主スレッドの Paint が 870ms になる(実測)。階層の線(SVG)も同じ。
10
+ * 逆に、転写層の中身が「白い矩形 + 合成済みの iframe」だけなら 1 段の Paint は 0.5ms 前後。
11
+ *
12
+ * 26 ページ・30 段の縮小での実測(DPR 2、CPU 4 倍遅く):
13
+ * 枠線 box-shadow + 線 SVG … Paint 287 回 / 1293ms、フレーム p95 42ms
14
+ * 枠線 outline + 線 SVG … Paint 218 回 / 793ms、フレーム p95 42ms
15
+ * どちらも消す(この実装と同じ状態) … Paint 82 回 / 64ms、フレーム p95 25ms
16
+ *
17
+ * そこで枠線と線は転写層から出し、画面の座標で 1 枚の canvas に描く。倍率・位置が
18
+ * 変わるたびに描き直すが、26 本の矩形と 25 本の折れ線を 1600×1000 に描くだけなので
19
+ * 1ms もかからない。定規(CanvasRulers)と同じ考え方
20
+ *
21
+ * [書き方の約束]
22
+ * - canvas の寸法(width / height / style)は載せたときと容器の大きさが変わったときだけ書く。
23
+ * 毎段書くと再確保が起き、パン・ズームで style が変わる要素の数も増える(検証が数えている)
24
+ * - 色は載せたときとテーマが変わったときだけ読む(毎回 getComputedStyle すると同期のレイアウトが走る)
25
+ */
26
+
27
+ import { memo, useEffect, useRef } from 'react';
28
+ import { FRAME_GAP, useMultiPageCanvas, type CanvasLayoutKind, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
29
+
30
+ /** 枠線の太さ(画面上の px)。以前の box-shadow: 0 0 0 1px と同じ見え方 */
31
+ const EDGE_WIDTH = 1;
32
+ /** 階層の線の太さ(画面上の px) */
33
+ const CONNECTOR_WIDTH = 1.5;
34
+
35
+ function readVar(el: HTMLElement, name: string, fallback: string): string {
36
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
37
+ return v || fallback;
38
+ }
39
+
40
+ export const CanvasFrameDecor = memo(function CanvasFrameDecor({ theme }: { theme: 'light' | 'dark' }) {
41
+ const { pages, layout, editorMode, viewStore } = useMultiPageCanvas();
42
+ const canvasRef = useRef<HTMLCanvasElement>(null);
43
+ // 描き直しの入口。倍率・位置の購読と、ページ・テーマの変化の両方から呼ぶ
44
+ const drawRef = useRef<() => void>(() => undefined);
45
+ // 購読のコールバックは載せたときの props を閉じ込めてしまうので、ref 越しに読む
46
+ const pagesRef = useRef<PageFrameLayout[]>(pages);
47
+ pagesRef.current = pages;
48
+ const layoutRef = useRef<CanvasLayoutKind>(layout);
49
+ layoutRef.current = layout;
50
+ const gapYRef = useRef(FRAME_GAP[editorMode].y);
51
+ gapYRef.current = FRAME_GAP[editorMode].y;
52
+ const colorsRef = useRef({ edge: 'rgba(0,0,0,0.08)', line: '#e6e6e6' });
53
+ const sizeRef = useRef({ width: 0, height: 0, dpr: 1 });
54
+
55
+ useEffect(() => {
56
+ const canvas = canvasRef.current;
57
+ if (!canvas) return;
58
+ // 容器は自分の親をそのまま使う。context の containerRef は親(MultiPageCanvasView)の
59
+ // useEffect で入るので、子のこの useEffect が走る時点ではまだ null
60
+ const host = canvas.parentElement as HTMLElement | null;
61
+ if (!host) return;
62
+
63
+ const resize = () => {
64
+ const dpr = window.devicePixelRatio || 1;
65
+ const width = host.clientWidth;
66
+ const height = host.clientHeight;
67
+ const prev = sizeRef.current;
68
+ if (width === prev.width && height === prev.height && dpr === prev.dpr) return width > 0 && height > 0;
69
+ sizeRef.current = { width, height, dpr };
70
+ canvas.width = Math.round(width * dpr);
71
+ canvas.height = Math.round(height * dpr);
72
+ canvas.style.width = `${width}px`;
73
+ canvas.style.height = `${height}px`;
74
+ return width > 0 && height > 0;
75
+ };
76
+
77
+ const readColors = () => {
78
+ colorsRef.current = {
79
+ edge: readVar(host, '--ed-frame-edge', theme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'),
80
+ line: readVar(host, '--ed-line-strong', theme === 'dark' ? '#5a5a5a' : '#d0d0d0'),
81
+ };
82
+ };
83
+
84
+ const draw = () => {
85
+ const { width, height, dpr } = sizeRef.current;
86
+ if (width === 0 || height === 0) return;
87
+ const ctx = canvas.getContext('2d');
88
+ if (!ctx) return;
89
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
90
+ ctx.clearRect(0, 0, width, height);
91
+ const { canvasOffset: o, canvasZoom: z } = viewStore.get();
92
+ const list = pagesRef.current;
93
+ if (list.length === 0) return;
94
+ // 画面の座標へ。半 px ずらして 1px の線がぼやけないようにする
95
+ const rectOf = (p: PageFrameLayout) => ({
96
+ x: Math.round(o.x + p.position.x * z),
97
+ y: Math.round(o.y + p.position.y * z),
98
+ w: Math.round(p.size.width * z),
99
+ h: Math.round(p.size.height * z),
100
+ });
101
+ const visible = (r: { x: number; y: number; w: number; h: number }) =>
102
+ r.x + r.w >= -2 && r.x <= width + 2 && r.y + r.h >= -2 && r.y <= height + 2;
103
+
104
+ // 階層の線 → 枠線の順(線の端が枠に隠れる)
105
+ if (layoutRef.current === 'tree') {
106
+ const byId = new Map(list.map((p) => [p.id, p] as const));
107
+ ctx.strokeStyle = colorsRef.current.line;
108
+ ctx.lineWidth = CONNECTOR_WIDTH;
109
+ ctx.lineJoin = 'round';
110
+ ctx.beginPath();
111
+ for (const child of list) {
112
+ const parent = child.parentId ? byId.get(child.parentId) : undefined;
113
+ if (!parent) continue;
114
+ // 画素の境目に載せる(端数のままだと 1.5px の線が 3 画素に散ってぼやける)
115
+ const x1 = Math.round(o.x + (parent.position.x + parent.size.width / 2) * z);
116
+ const y1 = Math.round(o.y + (parent.position.y + parent.size.height) * z);
117
+ const x2 = Math.round(o.x + (child.position.x + child.size.width / 2) * z);
118
+ const y2 = Math.round(o.y + child.position.y * z);
119
+ // 画面から外れている組はたどらない(150 枚でも描く本数は見えている分だけ)
120
+ if (Math.max(x1, x2) < -2 || Math.min(x1, x2) > width + 2 || y2 < -2 || y1 > height + 2) continue;
121
+ // 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
122
+ const ym = Math.round(y2 - (gapYRef.current * z) / 2);
123
+ ctx.moveTo(x1, y1);
124
+ ctx.lineTo(x1, ym);
125
+ ctx.lineTo(x2, ym);
126
+ ctx.lineTo(x2, y2);
127
+ }
128
+ ctx.stroke();
129
+ }
130
+
131
+ ctx.strokeStyle = colorsRef.current.edge;
132
+ ctx.lineWidth = EDGE_WIDTH;
133
+ ctx.lineJoin = 'miter';
134
+ ctx.beginPath();
135
+ for (const p of list) {
136
+ const r = rectOf(p);
137
+ if (!visible(r)) continue;
138
+ // 以前の box-shadow: 0 0 0 1px は枠の外側に出ていたので、境目に重ねて同じ見え方にする
139
+ ctx.rect(r.x - 0.5, r.y - 0.5, r.w + 1, r.h + 1);
140
+ }
141
+ ctx.stroke();
142
+ };
143
+
144
+ drawRef.current = draw;
145
+ readColors();
146
+ resize();
147
+ draw();
148
+ const unsubscribe = viewStore.subscribe(draw);
149
+ let observer: ResizeObserver | null = null;
150
+ if (typeof ResizeObserver !== 'undefined') {
151
+ observer = new ResizeObserver(() => { resize(); draw(); });
152
+ observer.observe(host);
153
+ }
154
+ return () => {
155
+ unsubscribe();
156
+ observer?.disconnect();
157
+ };
158
+ }, [viewStore, theme]);
159
+
160
+ // ページの並び・高さが変わったら描き直す(倍率・位置は上の購読が受ける)。
161
+ // viewStore を空更新して呼ぶことはしない ── 他の購読者(定規・コメントのピン)まで巻き込むので
162
+ useEffect(() => { drawRef.current(); }, [pages, layout, editorMode]);
163
+
164
+ return (
165
+ <canvas
166
+ ref={canvasRef}
167
+ data-canvas-decor
168
+ aria-hidden="true"
169
+ className="pointer-events-none absolute left-0 top-0"
170
+ />
171
+ );
172
+ });
@@ -9,25 +9,37 @@
9
9
  * 層の構成(下から):
10
10
  * 1. 転写層(translate + scale)… フレームの白地 + 見るだけの紙面(PageFramePreview)
11
11
  * 2. 転写層の中、編集中のフレームの位置に EditorCanvas(1 つだけ。ページを移っても作り直さない)
12
- * 3. 画面座標のオーバーレイ … フレーム名・選択リング(倍率に関係なく同じ太さ・同じ文字サイズ)
13
- * 4. 定規
12
+ * 3. 飾りの canvas(画面の座標)… フレームの枠線・階層の線
13
+ * 4. オーバーレイ(転写層と同じ translate + scale)… フレーム名・選択リング。
14
+ * 中身は紙面の座標に置いて scale(1/倍率) で打ち消すので、画面上は倍率に関係なく同じ大きさ
15
+ * 5. 定規
14
16
  *
15
17
  * EditorCanvas を pages.map の中に置くとページを移るたびに React が作り直して
16
18
  * iframe が読み直される。map の外に 1 つだけ置き、位置だけ動かす
17
19
  *
18
20
  * [パン・ズームの軽さ]
19
- * パンは 2 つの層の transform 文字列を変えるだけにする。フレーム 1 枚ずつの要素
20
- * (FrameShell / FrameChrome)は memo で、位置は倍率だけに依存させる(画面上の位置 =
21
- * 層の translate + 紙面の位置 × 倍率)。150 枚あってもパンで描き直る要素は 2 つ。
22
- * ズームは倍率に依存する要素が描き直る(それでも小さな要素が 300 個ほど)
21
+ * ホイール 1 段の仕事を「合成だけ」にする。倍率・位置は React を通さず、viewStore を
22
+ * 直接購読して 2 つの層の style へ書く(転写層とオーバーレイの transform、オーバーレイの
23
+ * --ed-zoom / --ed-inv、容器の data-canvas-zoom の 4 つだけ)。フレーム 1 枚ずつの要素は
24
+ * memo で、倍率を props に持たない。
25
+ *
26
+ * 転写層の中には「白い矩形 + 合成済みの iframe」しか置かない。枠線(box-shadow / outline /
27
+ * border のどれでも)や階層の線(SVG)を中に置くと、倍率が変わるたびに転写層まるごとが
28
+ * 塗り直され、1 段 10〜20ms の Paint になる(実測)。枠線と線は画面の座標の canvas
29
+ * (CanvasFrameDecor)へ出した。
30
+ *
31
+ * 名前の幅と出し分けだけは DOM のままなので、段ごとではなく **粗い倍率**
32
+ * (coarseZoom、150ms に 1 回まで。操作の終わりに必ず追いつく)から決める
23
33
  */
24
34
 
25
- import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
35
+ import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
26
36
  import {
27
37
  useMultiPageCanvas,
28
- useCanvasViewState,
38
+ useCanvasActivePageId,
29
39
  RULER_SIZE,
30
40
  FRAME_GAP,
41
+ PREVIEW_IMAGE_ZOOM,
42
+ PREVIEW_IMAGE_ZOOM_OUT,
31
43
  type PageFrameLayout,
32
44
  } from '../../contexts/MultiPageCanvasContext';
33
45
  import { useEditorContext } from '../../EditorContext';
@@ -38,10 +50,25 @@ import { EditorAppearanceContext } from '../../contexts/EditorAppearanceContext'
38
50
  import { EditorCanvas } from '../EditorCanvas';
39
51
  import { PageFramePreview } from './PageFramePreview';
40
52
  import { CanvasRulers } from './CanvasRulers';
53
+ import { CanvasFrameDecor } from './CanvasFrameDecor';
41
54
  import { cn } from '../../../lib/utils';
55
+ import { ArrowUpRight } from 'lucide-react';
42
56
 
43
57
  /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
44
58
  const LABEL_HEIGHT = 20;
59
+ /**
60
+ * 粗い倍率を更新する間隔(ms)。これを短くすると縮小中の塗り直しが増える。
61
+ * 操作の終わりには必ず本当の倍率に追いつく(末尾で 1 回)
62
+ */
63
+ const COARSE_ZOOM_MS = 150;
64
+ /** 紙面を画像に落とすかの判定を、操作が止まってから何 ms 後に見るか(全体表示の 240ms の動きを跨がない) */
65
+ const PREVIEW_MODE_SETTLE_MS = 300;
66
+ /**
67
+ * この倍率より小さいところにいる間は、紙面の画像(thumbnail)を裏で先に読んでおく。
68
+ * 読めていない画像は縮小の途中で使えない(空白のフレームになるので iframe を見せ続ける)。
69
+ * 1 ページを大きく開いて編集しているときは読まない(利用側に 26 枚の画像を作らせない)
70
+ */
71
+ const PREVIEW_IMAGE_PRELOAD_ZOOM = 1.2;
45
72
 
46
73
  // ------------------------------------------------------------
47
74
  // フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
@@ -49,47 +76,53 @@ const LABEL_HEIGHT = 20;
49
76
 
50
77
  interface FrameShellProps {
51
78
  page: PageFrameLayout;
52
- zoom: number;
53
79
  isActive: boolean;
54
80
  passThrough: boolean;
81
+ /** 大きく縮小している間は紙面を画像で描く(この層の zoom には依存しない真偽値) */
82
+ imageMode: boolean;
83
+ /** 画像を裏で先に読んでおく(縮小の途中で切り替えられるように) */
84
+ preloadImage: boolean;
55
85
  rootRef: React.RefObject<HTMLDivElement | null>;
56
86
  onMouseDown: (e: React.MouseEvent, id: string) => void;
57
87
  onHover: (id: string | null) => void;
58
88
  }
59
89
 
60
- const FrameShell = memo(function FrameShell({ page, zoom, isActive, passThrough, rootRef, onMouseDown, onHover }: FrameShellProps) {
90
+ /**
91
+ * 倍率を props に持たない(持たせるとホイール 1 段ごとに 26 枚が React を通る)。
92
+ * 枠線はこの要素には無い ── 画面の座標の canvas(CanvasFrameDecor)が描く
93
+ */
94
+ const FrameShell = memo(function FrameShell({ page, isActive, passThrough, imageMode, preloadImage, rootRef, onMouseDown, onHover }: FrameShellProps) {
61
95
  return (
62
96
  <div
63
97
  data-page-frame={page.id}
64
98
  data-page-active={isActive ? 'true' : undefined}
65
- className="absolute"
99
+ className="ed-frame-shell absolute"
66
100
  style={{
67
101
  left: page.position.x,
68
102
  top: page.position.y,
69
103
  width: page.size.width,
70
104
  height: page.size.height,
71
- background: '#fff',
72
- boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
73
105
  cursor: isActive || passThrough ? undefined : 'pointer',
74
106
  }}
75
107
  onMouseDown={(e) => onMouseDown(e, page.id)}
76
108
  onMouseEnter={() => onHover(page.id)}
77
109
  onMouseLeave={() => onHover(null)}
78
110
  >
79
- <PageFramePreview page={page} rootRef={rootRef} />
111
+ <PageFramePreview page={page} rootRef={rootRef} imageMode={imageMode} preloadImage={preloadImage} />
80
112
  </div>
81
113
  );
82
114
  });
83
115
 
84
116
  // ------------------------------------------------------------
85
- // フレーム名・リング(オーバーレイの中。画面の座標 = 紙面の位置 × 倍率)
117
+ // フレーム名・リング(オーバーレイの中)
86
118
  // ------------------------------------------------------------
87
119
 
88
120
  interface FrameChromeProps {
89
121
  page: PageFrameLayout;
90
- zoom: number;
91
- /** 名前を出せる倍率か(行の隙間に名前が収まる)。全フレーム共通なので親で判定する */
92
- labelsVisible: boolean;
122
+ /** 名前を出すか(粗い倍率から親が判定する。全フレーム共通の条件 + このフレームの幅) */
123
+ showLabel: boolean;
124
+ /** 「別タブで開く」を出すか(名前の右。フレームが細いときは出さない) */
125
+ showLink: boolean;
93
126
  /** リングと名前の強調(切替中はクリックしたページへ先に移る) */
94
127
  isSelected: boolean;
95
128
  isHover: boolean;
@@ -99,38 +132,67 @@ interface FrameChromeProps {
99
132
  onZoomTo: (id: string) => void;
100
133
  }
101
134
 
102
- const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
103
- const sx = page.position.x * zoom;
104
- const sy = page.position.y * zoom;
105
- const sw = page.size.width * zoom;
106
- const sh = page.size.height * zoom;
107
- // 縮小してフレームが細くなったら名前は幅に収める(重ならない)。極端に小さければ出さない
108
- const showLabel = labelsVisible && sw >= 28;
135
+ /**
136
+ * オーバーレイは転写層と同じ transform(translate + scale)なので、ここで scale(1/倍率) を
137
+ * 掛けて打ち消す。こうすると中身は画面上の大きさのまま、位置は紙面の座標で書ける
138
+ * (段ごとに React を通さず、--ed-inv が変わるだけで動く)。
139
+ * リングだけは画面上の寸法が要るので --ed-zoom から CSS で出す(段ごとに変わるのは 1〜2 個)
140
+ */
141
+ const FrameChrome = memo(function FrameChrome({ page, showLabel, showLink, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
142
+ const hasRing = isSelected || isHover;
109
143
  return (
110
- <div className="absolute left-0 top-0" data-frame-chrome={page.id} style={{ transform: `translate(${sx}px, ${sy}px)` }}>
111
- {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ) */}
144
+ <div
145
+ className="absolute left-0 top-0"
146
+ data-frame-chrome={page.id}
147
+ style={{
148
+ transform: `translate(${page.position.x}px, ${page.position.y}px) scale(var(--ed-inv, 1))`,
149
+ transformOrigin: '0 0',
150
+ // 名前だけの小さな層にしておく(倍率が変わっても塗り直さずに動く)。
151
+ // リングが出ているフレームはフレームと同じ大きさになるので層にしない(巨大な合成層を作らない)
152
+ willChange: hasRing ? undefined : 'transform',
153
+ ['--ed-fw' as string]: `${page.size.width}px`,
154
+ ['--ed-fh' as string]: `${page.size.height}px`,
155
+ }}
156
+ >
157
+ {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
112
158
  {showLabel && (
113
- <button
114
- type="button"
115
- data-frame-label={page.id}
116
- className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
117
- style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}
118
- title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
119
- onMouseDown={(e) => e.stopPropagation()}
120
- onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
121
- onDoubleClick={() => onZoomTo(page.id)}
122
- >
123
- <span className="ed-frame-label-index">{page.index + 1}</span>
124
- <span className="ed-frame-label-title">{page.title || page.id}</span>
125
- {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
126
- {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
127
- </button>
159
+ <div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: 'max(28px, calc(var(--ed-fw) * var(--ed-zoom, 1)))' }}>
160
+ <button
161
+ type="button"
162
+ data-frame-label={page.id}
163
+ className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
164
+ title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
165
+ onMouseDown={(e) => e.stopPropagation()}
166
+ onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
167
+ onDoubleClick={() => onZoomTo(page.id)}
168
+ >
169
+ <span className="ed-frame-label-index">{page.index + 1}</span>
170
+ <span className="ed-frame-label-title">{page.title || page.id}</span>
171
+ {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
172
+ {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
173
+ </button>
174
+ {page.href && showLink && (
175
+ <a
176
+ className="ed-frame-label-link"
177
+ data-frame-link={page.id}
178
+ href={page.href}
179
+ target="_blank"
180
+ rel="noreferrer"
181
+ title="別タブで開く(編集しない表示)"
182
+ aria-label={`${page.title || page.id} を別タブで開く`}
183
+ onMouseDown={(e) => e.stopPropagation()}
184
+ onClick={(e) => e.stopPropagation()}
185
+ >
186
+ <ArrowUpRight aria-hidden="true" />
187
+ </a>
188
+ )}
189
+ </div>
128
190
  )}
129
- {/* リング(選択中 = 実線、ホバー = 淡い実線) */}
130
- {(isSelected || isHover) && (
191
+ {/* リング(選択中 = 実線、ホバー = 淡い実線)。画面上の寸法 = 紙面の寸法 × 倍率 */}
192
+ {hasRing && (
131
193
  <div
132
194
  className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
133
- style={{ width: sw, height: sh }}
195
+ style={{ width: 'calc(var(--ed-fw) * var(--ed-zoom, 1))', height: 'calc(var(--ed-fh) * var(--ed-zoom, 1))' }}
134
196
  />
135
197
  )}
136
198
  </div>
@@ -146,6 +208,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
146
208
  const {
147
209
  pages,
148
210
  bounds,
211
+ viewStore,
149
212
  registerContainer,
150
213
  activatePage,
151
214
  activatingPageId,
@@ -156,10 +219,14 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
156
219
  isInteracting,
157
220
  initialViewRestored,
158
221
  } = canvas;
159
- const { canvasOffset, canvasZoom, activePageId } = useCanvasViewState();
222
+ // 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
223
+ // 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
224
+ const activePageId = useCanvasActivePageId();
160
225
  const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
161
226
  const theme = useContext(EditorAppearanceContext) ?? 'light';
162
227
  const containerRef = useRef<HTMLDivElement>(null);
228
+ const layerRef = useRef<HTMLDivElement>(null);
229
+ const overlayRef = useRef<HTMLDivElement>(null);
163
230
  const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
164
231
  // リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
165
232
  const selectedId = activatingPageId ?? activePageId;
@@ -176,6 +243,60 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
176
243
  return () => registerContainer(null);
177
244
  }, [registerContainer]);
178
245
 
246
+ // ---- 倍率・位置の反映(React を通さない)
247
+ const [coarseZoom, setCoarseZoom] = useState(() => viewStore.get().canvasZoom);
248
+ const coarseZoomRef = useRef(coarseZoom);
249
+ coarseZoomRef.current = coarseZoom;
250
+
251
+ useLayoutEffect(() => {
252
+ let lastZoom = NaN;
253
+ let coarseTimer: ReturnType<typeof setTimeout> | null = null;
254
+ let coarseAt = 0;
255
+ const pushCoarse = () => {
256
+ coarseAt = performance.now();
257
+ const z = viewStore.get().canvasZoom;
258
+ if (coarseZoomRef.current === z) return;
259
+ coarseZoomRef.current = z;
260
+ setCoarseZoom(z);
261
+ };
262
+ const write = () => {
263
+ const { canvasOffset: o, canvasZoom: z } = viewStore.get();
264
+ const transform = `translate(${o.x}px, ${o.y}px) scale(${z})`;
265
+ if (layerRef.current) layerRef.current.style.transform = transform;
266
+ const overlay = overlayRef.current;
267
+ if (overlay) overlay.style.transform = transform;
268
+ if (z === lastZoom) return;
269
+ lastZoom = z;
270
+ if (overlay) {
271
+ // 中身の scale(1/倍率) とリングの寸法に使う。この 1 要素だけが段ごとに変わる
272
+ overlay.style.setProperty('--ed-zoom', String(z));
273
+ overlay.style.setProperty('--ed-inv', String(1 / z));
274
+ }
275
+ // 検証スクリプトとヘッダーの表示が読む(四捨五入した %)
276
+ const el = containerRef.current;
277
+ const pct = String(Math.round(z * 100));
278
+ if (el && el.getAttribute('data-canvas-zoom') !== pct) el.setAttribute('data-canvas-zoom', pct);
279
+ // 縮小の途中でも 0.4 を割ったら、画像が読めている紙面は iframe を止めて画像を前に出す
280
+ // (CSS が拾う。属性 1 つの書き換えで 26 枚がいっぺんに切り替わり、React は通らない)。
281
+ // iframe を外してメモリを返すのは操作が止まってから(下の imageMode)。
282
+ // 外す向き(画像 → iframe)もここではやらない ── 止まってから 0.5 以上で判定する
283
+ if (el && z < PREVIEW_IMAGE_ZOOM_OUT && el.getAttribute('data-canvas-preview-image') !== '1') {
284
+ el.setAttribute('data-canvas-preview-image', '1');
285
+ }
286
+ // 粗い倍率: 150ms に 1 回まで。末尾のタイマーで操作の終わりに必ず追いつく
287
+ if (coarseTimer) clearTimeout(coarseTimer);
288
+ const elapsed = performance.now() - coarseAt;
289
+ if (elapsed >= COARSE_ZOOM_MS) pushCoarse();
290
+ else coarseTimer = setTimeout(() => { coarseTimer = null; pushCoarse(); }, COARSE_ZOOM_MS - elapsed);
291
+ };
292
+ write();
293
+ const unsubscribe = viewStore.subscribe(write);
294
+ return () => {
295
+ unsubscribe();
296
+ if (coarseTimer) clearTimeout(coarseTimer);
297
+ };
298
+ }, [viewStore]);
299
+
179
300
  // エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
180
301
  useEffect(() => {
181
302
  setZoom(100);
@@ -242,30 +363,47 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
242
363
  const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
243
364
  const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
244
365
 
366
+ // ---- 紙面を画像に落とすか(大きく縮小している間だけ)
367
+ // 判定は操作が止まってからしか動かさない。縮小の途中で 26 枚の iframe を差し替えると、
368
+ // 軽くするための切替そのものが重いフレームを作る
369
+ const [imageMode, setImageMode] = useState(() => viewStore.get().canvasZoom < PREVIEW_IMAGE_ZOOM_OUT);
370
+ const imageModeRef = useRef(imageMode);
371
+ imageModeRef.current = imageMode;
372
+ useEffect(() => {
373
+ if (isInteracting) return;
374
+ const timer = setTimeout(() => {
375
+ const z = viewStore.get().canvasZoom;
376
+ // ヒステリシス: 画像 → iframe は 0.5 以上、iframe → 画像は 0.4 未満(境目で行き来しない)
377
+ const next = imageModeRef.current ? z < PREVIEW_IMAGE_ZOOM : z < PREVIEW_IMAGE_ZOOM_OUT;
378
+ if (next !== imageModeRef.current) setImageMode(next);
379
+ // iframe に戻す向きに決まったら、縮小の途中で立てた掛け金も外す(iframe がまた見える)
380
+ if (!next) containerRef.current?.removeAttribute('data-canvas-preview-image');
381
+ }, PREVIEW_MODE_SETTLE_MS);
382
+ return () => clearTimeout(timer);
383
+ }, [isInteracting, coarseZoom, viewStore]);
384
+ // 画像を裏で先に読んでおくか(粗い倍率なので、段ごとには変わらない真偽値)
385
+ const preloadImage = coarseZoom < PREVIEW_IMAGE_PRELOAD_ZOOM;
386
+
245
387
  const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
246
388
  // 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
247
389
  // 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
248
- const labelsVisible = FRAME_GAP[canvas.editorMode].y * canvasZoom >= LABEL_HEIGHT + 6;
249
- // data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
250
- const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
251
- const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
390
+ const labelsVisible = FRAME_GAP[canvas.editorMode].y * coarseZoom >= LABEL_HEIGHT + 6;
252
391
 
253
392
  return (
254
393
  <div
255
394
  ref={containerRef}
256
395
  data-infinite-canvas="true"
257
- data-canvas-zoom={Math.round(canvasZoom * 100)}
258
396
  data-interacting={isInteracting ? 'true' : undefined}
259
397
  className="absolute inset-0 overflow-hidden select-none"
260
398
  style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
261
399
  onMouseDown={onMarqueeMouseDown}
262
400
  >
263
- {/* 転写層 */}
401
+ {/* 転写層(transform は useLayoutEffect が直接書く。React の style には入れない) */}
264
402
  <div
403
+ ref={layerRef}
265
404
  data-canvas-transform-layer
266
405
  className="absolute left-0 top-0"
267
406
  style={{
268
- transform: layerTransform,
269
407
  transformOrigin: '0 0',
270
408
  willChange: isInteracting ? 'transform' : undefined,
271
409
  }}
@@ -274,9 +412,10 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
274
412
  <FrameShell
275
413
  key={page.id}
276
414
  page={page}
277
- zoom={canvasZoom}
278
415
  isActive={page.id === activePageId}
279
416
  passThrough={passThrough}
417
+ imageMode={imageMode}
418
+ preloadImage={preloadImage}
280
419
  rootRef={containerRef}
281
420
  onMouseDown={handleFrameMouseDown}
282
421
  onHover={handleHover}
@@ -306,20 +445,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
306
445
  )}
307
446
  </div>
308
447
 
309
- {/* 画面座標のオーバーレイ: フレーム名・リング(層ごと平行移動。倍率だけ各要素に渡す) */}
448
+ {/* フレームの枠線と階層の線(画面の座標の canvas)。転写層の中に置くと 1 段ごとに
449
+ 転写層まるごとが塗り直される ── 詳しくは CanvasFrameDecor の頭の注記 */}
450
+ <CanvasFrameDecor theme={theme} />
451
+
452
+ {/* オーバーレイ: フレーム名・リング。転写層と同じ transform を掛け、中身が scale(1/倍率) で戻す */}
310
453
  <div
454
+ ref={overlayRef}
311
455
  className="pointer-events-none absolute left-0 top-0 z-10"
312
456
  data-canvas-overlay
313
- style={{ transform: overlayTransform, willChange: isInteracting ? 'transform' : undefined }}
457
+ style={{ transformOrigin: '0 0', willChange: isInteracting ? 'transform' : undefined }}
314
458
  >
315
459
  {pages.map((page) => {
316
460
  const isSelected = page.id === selectedId;
461
+ // 縮小してフレームが細くなったら名前は出さない(重ならない)。粗い倍率で判定する
462
+ const screenWidth = page.size.width * coarseZoom;
317
463
  return (
318
464
  <FrameChrome
319
465
  key={page.id}
320
466
  page={page}
321
- zoom={canvasZoom}
322
- labelsVisible={labelsVisible}
467
+ showLabel={labelsVisible && screenWidth >= 28}
468
+ showLink={screenWidth >= 96}
323
469
  isSelected={isSelected}
324
470
  isHover={hoverId === page.id && !isSelected && !passThrough}
325
471
  isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}