@growgroup/visual-editor 0.3.1 → 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 (60) hide show
  1. package/README.md +6 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +3 -2
  5. package/src/editor/EditorToolbar.tsx +8 -2
  6. package/src/editor/FrontendVisualEditor.tsx +114 -44
  7. package/src/editor/components/EditorCanvas.tsx +73 -15
  8. package/src/editor/components/EditorFooter.tsx +2 -0
  9. package/src/editor/components/EditorHeader.tsx +3 -2
  10. package/src/editor/components/FigmaColorPicker.tsx +2 -1
  11. package/src/editor/components/JsEditorDialog.tsx +1 -0
  12. package/src/editor/components/PageSettingsDialog.tsx +1 -0
  13. package/src/editor/components/comments/CommentBoard.tsx +9 -6
  14. package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
  15. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +173 -77
  16. package/src/editor/components/multi-page/PageFramePreview.tsx +96 -20
  17. package/src/editor/components/multi-page/index.ts +1 -0
  18. package/src/editor/components/ppt/PptChrome.tsx +6 -2
  19. package/src/editor/components/ppt/PptComments.tsx +411 -71
  20. package/src/editor/constants.ts +16 -1
  21. package/src/editor/contexts/EditorArtboardContext.tsx +8 -0
  22. package/src/editor/contexts/EditorComponentsContext.tsx +31 -27
  23. package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
  24. package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
  25. package/src/editor/contexts/MultiPageCanvasContext.tsx +26 -2
  26. package/src/editor/hooks/useAiReplace.ts +4 -3
  27. package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
  28. package/src/editor/hooks/useComponentEditMode.ts +27 -26
  29. package/src/editor/hooks/useEditorMessages.ts +3 -2
  30. package/src/editor/hooks/useElementActions.ts +27 -26
  31. package/src/editor/hooks/useElementSelection.ts +19 -16
  32. package/src/editor/hooks/useFocusManagement.ts +7 -6
  33. package/src/editor/hooks/useGoogleFonts.ts +3 -2
  34. package/src/editor/hooks/useIframeInitializer.ts +6 -3
  35. package/src/editor/hooks/useIframeSetup.ts +4 -3
  36. package/src/editor/hooks/useImageUpload.ts +4 -3
  37. package/src/editor/hooks/useMarqueeSelection.ts +2 -1
  38. package/src/editor/hooks/usePageSettingsManager.ts +24 -23
  39. package/src/editor/hooks/useRichPaste.ts +11 -10
  40. package/src/editor/parts.ts +7 -25
  41. package/src/editor/utils/component-renderer.ts +8 -7
  42. package/src/editor/utils/component-sync.ts +16 -15
  43. package/src/editor/utils/debug.ts +52 -0
  44. package/src/editor/utils/dom-utils.ts +11 -10
  45. package/src/editor/utils/drop-target.ts +135 -0
  46. package/src/editor/utils/eyedropper.ts +3 -1
  47. package/src/editor/utils/figma-export.ts +6 -5
  48. package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
  49. package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
  50. package/src/editor/utils/figma-paste.ts +57 -56
  51. package/src/editor/utils/flex-utils.ts +12 -10
  52. package/src/editor/utils/override-apply.ts +3 -2
  53. package/src/editor/utils/override-detection.ts +11 -10
  54. package/src/editor/utils/tailwind-utils.ts +4 -3
  55. package/src/io.ts +22 -1
  56. package/src/lib/firebase/css-variables.ts +4 -3
  57. package/src/lib/firebase/storage.ts +3 -2
  58. package/src/styles/skin.css +9 -4
  59. package/src/types/editor.ts +6 -2
  60. package/src/version.ts +1 -1
@@ -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,11 +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';
42
55
  import { ArrowUpRight } from 'lucide-react';
43
56
 
44
57
  /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
45
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;
46
72
 
47
73
  // ------------------------------------------------------------
48
74
  // フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
@@ -50,47 +76,53 @@ const LABEL_HEIGHT = 20;
50
76
 
51
77
  interface FrameShellProps {
52
78
  page: PageFrameLayout;
53
- zoom: number;
54
79
  isActive: boolean;
55
80
  passThrough: boolean;
81
+ /** 大きく縮小している間は紙面を画像で描く(この層の zoom には依存しない真偽値) */
82
+ imageMode: boolean;
83
+ /** 画像を裏で先に読んでおく(縮小の途中で切り替えられるように) */
84
+ preloadImage: boolean;
56
85
  rootRef: React.RefObject<HTMLDivElement | null>;
57
86
  onMouseDown: (e: React.MouseEvent, id: string) => void;
58
87
  onHover: (id: string | null) => void;
59
88
  }
60
89
 
61
- 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) {
62
95
  return (
63
96
  <div
64
97
  data-page-frame={page.id}
65
98
  data-page-active={isActive ? 'true' : undefined}
66
- className="absolute"
99
+ className="ed-frame-shell absolute"
67
100
  style={{
68
101
  left: page.position.x,
69
102
  top: page.position.y,
70
103
  width: page.size.width,
71
104
  height: page.size.height,
72
- background: '#fff',
73
- boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
74
105
  cursor: isActive || passThrough ? undefined : 'pointer',
75
106
  }}
76
107
  onMouseDown={(e) => onMouseDown(e, page.id)}
77
108
  onMouseEnter={() => onHover(page.id)}
78
109
  onMouseLeave={() => onHover(null)}
79
110
  >
80
- <PageFramePreview page={page} rootRef={rootRef} />
111
+ <PageFramePreview page={page} rootRef={rootRef} imageMode={imageMode} preloadImage={preloadImage} />
81
112
  </div>
82
113
  );
83
114
  });
84
115
 
85
116
  // ------------------------------------------------------------
86
- // フレーム名・リング(オーバーレイの中。画面の座標 = 紙面の位置 × 倍率)
117
+ // フレーム名・リング(オーバーレイの中)
87
118
  // ------------------------------------------------------------
88
119
 
89
120
  interface FrameChromeProps {
90
121
  page: PageFrameLayout;
91
- zoom: number;
92
- /** 名前を出せる倍率か(行の隙間に名前が収まる)。全フレーム共通なので親で判定する */
93
- labelsVisible: boolean;
122
+ /** 名前を出すか(粗い倍率から親が判定する。全フレーム共通の条件 + このフレームの幅) */
123
+ showLabel: boolean;
124
+ /** 「別タブで開く」を出すか(名前の右。フレームが細いときは出さない) */
125
+ showLink: boolean;
94
126
  /** リングと名前の強調(切替中はクリックしたページへ先に移る) */
95
127
  isSelected: boolean;
96
128
  isHover: boolean;
@@ -100,18 +132,31 @@ interface FrameChromeProps {
100
132
  onZoomTo: (id: string) => void;
101
133
  }
102
134
 
103
- const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
104
- const sx = page.position.x * zoom;
105
- const sy = page.position.y * zoom;
106
- const sw = page.size.width * zoom;
107
- const sh = page.size.height * zoom;
108
- // 縮小してフレームが細くなったら名前は幅に収める(重ならない)。極端に小さければ出さない
109
- 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;
110
143
  return (
111
- <div className="absolute left-0 top-0" data-frame-chrome={page.id} style={{ transform: `translate(${sx}px, ${sy}px)` }}>
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
+ >
112
157
  {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
113
158
  {showLabel && (
114
- <div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}>
159
+ <div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: 'max(28px, calc(var(--ed-fw) * var(--ed-zoom, 1)))' }}>
115
160
  <button
116
161
  type="button"
117
162
  data-frame-label={page.id}
@@ -126,7 +171,7 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
126
171
  {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
127
172
  {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
128
173
  </button>
129
- {page.href && sw >= 96 && (
174
+ {page.href && showLink && (
130
175
  <a
131
176
  className="ed-frame-label-link"
132
177
  data-frame-link={page.id}
@@ -143,46 +188,17 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
143
188
  )}
144
189
  </div>
145
190
  )}
146
- {/* リング(選択中 = 実線、ホバー = 淡い実線) */}
147
- {(isSelected || isHover) && (
191
+ {/* リング(選択中 = 実線、ホバー = 淡い実線)。画面上の寸法 = 紙面の寸法 × 倍率 */}
192
+ {hasRing && (
148
193
  <div
149
194
  className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
150
- 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))' }}
151
196
  />
152
197
  )}
153
198
  </div>
154
199
  );
155
200
  });
156
201
 
157
- // ------------------------------------------------------------
158
- // 階層の線(親の下端の中央 → 子の上端の中央。転写層の中、紙面の座標)
159
- // ------------------------------------------------------------
160
-
161
- const TreeConnectors = memo(function TreeConnectors({ pages, gapY }: { pages: PageFrameLayout[]; gapY: number }) {
162
- const byId = useMemo(() => new Map(pages.map((p) => [p.id, p] as const)), [pages]);
163
- const d = useMemo(() => {
164
- const parts: string[] = [];
165
- pages.forEach((child) => {
166
- const parent = child.parentId ? byId.get(child.parentId) : undefined;
167
- if (!parent) return;
168
- const x1 = parent.position.x + parent.size.width / 2;
169
- const y1 = parent.position.y + parent.size.height;
170
- const x2 = child.position.x + child.size.width / 2;
171
- const y2 = child.position.y;
172
- // 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
173
- const ym = y2 - gapY / 2;
174
- parts.push(`M${x1} ${y1} V${ym} H${x2} V${y2}`);
175
- });
176
- return parts.join(' ');
177
- }, [pages, byId, gapY]);
178
- if (!d) return null;
179
- return (
180
- <svg className="ed-frame-connectors" data-canvas-connectors aria-hidden="true" width={1} height={1} style={{ overflow: 'visible' }}>
181
- <path d={d} fill="none" vectorEffect="non-scaling-stroke" />
182
- </svg>
183
- );
184
- });
185
-
186
202
  // ------------------------------------------------------------
187
203
  // キャンバス
188
204
  // ------------------------------------------------------------
@@ -192,7 +208,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
192
208
  const {
193
209
  pages,
194
210
  bounds,
195
- layout,
211
+ viewStore,
196
212
  registerContainer,
197
213
  activatePage,
198
214
  activatingPageId,
@@ -203,10 +219,14 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
203
219
  isInteracting,
204
220
  initialViewRestored,
205
221
  } = canvas;
206
- const { canvasOffset, canvasZoom, activePageId } = useCanvasViewState();
222
+ // 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
223
+ // 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
224
+ const activePageId = useCanvasActivePageId();
207
225
  const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
208
226
  const theme = useContext(EditorAppearanceContext) ?? 'light';
209
227
  const containerRef = useRef<HTMLDivElement>(null);
228
+ const layerRef = useRef<HTMLDivElement>(null);
229
+ const overlayRef = useRef<HTMLDivElement>(null);
210
230
  const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
211
231
  // リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
212
232
  const selectedId = activatingPageId ?? activePageId;
@@ -223,6 +243,60 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
223
243
  return () => registerContainer(null);
224
244
  }, [registerContainer]);
225
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
+
226
300
  // エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
227
301
  useEffect(() => {
228
302
  setZoom(100);
@@ -289,44 +363,59 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
289
363
  const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
290
364
  const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
291
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
+
292
387
  const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
293
388
  // 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
294
389
  // 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
295
- const labelsVisible = FRAME_GAP[canvas.editorMode].y * canvasZoom >= LABEL_HEIGHT + 6;
296
- // data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
297
- const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
298
- const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
390
+ const labelsVisible = FRAME_GAP[canvas.editorMode].y * coarseZoom >= LABEL_HEIGHT + 6;
299
391
 
300
392
  return (
301
393
  <div
302
394
  ref={containerRef}
303
395
  data-infinite-canvas="true"
304
- data-canvas-zoom={Math.round(canvasZoom * 100)}
305
396
  data-interacting={isInteracting ? 'true' : undefined}
306
397
  className="absolute inset-0 overflow-hidden select-none"
307
398
  style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
308
399
  onMouseDown={onMarqueeMouseDown}
309
400
  >
310
- {/* 転写層 */}
401
+ {/* 転写層(transform は useLayoutEffect が直接書く。React の style には入れない) */}
311
402
  <div
403
+ ref={layerRef}
312
404
  data-canvas-transform-layer
313
405
  className="absolute left-0 top-0"
314
406
  style={{
315
- transform: layerTransform,
316
407
  transformOrigin: '0 0',
317
408
  willChange: isInteracting ? 'transform' : undefined,
318
409
  }}
319
410
  >
320
- {/* 階層の線(フレームの下に描く。線の太さは倍率に関係なく一定 = non-scaling-stroke) */}
321
- {layout === 'tree' && <TreeConnectors pages={pages} gapY={FRAME_GAP[canvas.editorMode].y} />}
322
-
323
411
  {pages.map((page) => (
324
412
  <FrameShell
325
413
  key={page.id}
326
414
  page={page}
327
- zoom={canvasZoom}
328
415
  isActive={page.id === activePageId}
329
416
  passThrough={passThrough}
417
+ imageMode={imageMode}
418
+ preloadImage={preloadImage}
330
419
  rootRef={containerRef}
331
420
  onMouseDown={handleFrameMouseDown}
332
421
  onHover={handleHover}
@@ -356,20 +445,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
356
445
  )}
357
446
  </div>
358
447
 
359
- {/* 画面座標のオーバーレイ: フレーム名・リング(層ごと平行移動。倍率だけ各要素に渡す) */}
448
+ {/* フレームの枠線と階層の線(画面の座標の canvas)。転写層の中に置くと 1 段ごとに
449
+ 転写層まるごとが塗り直される ── 詳しくは CanvasFrameDecor の頭の注記 */}
450
+ <CanvasFrameDecor theme={theme} />
451
+
452
+ {/* オーバーレイ: フレーム名・リング。転写層と同じ transform を掛け、中身が scale(1/倍率) で戻す */}
360
453
  <div
454
+ ref={overlayRef}
361
455
  className="pointer-events-none absolute left-0 top-0 z-10"
362
456
  data-canvas-overlay
363
- style={{ transform: overlayTransform, willChange: isInteracting ? 'transform' : undefined }}
457
+ style={{ transformOrigin: '0 0', willChange: isInteracting ? 'transform' : undefined }}
364
458
  >
365
459
  {pages.map((page) => {
366
460
  const isSelected = page.id === selectedId;
461
+ // 縮小してフレームが細くなったら名前は出さない(重ならない)。粗い倍率で判定する
462
+ const screenWidth = page.size.width * coarseZoom;
367
463
  return (
368
464
  <FrameChrome
369
465
  key={page.id}
370
466
  page={page}
371
- zoom={canvasZoom}
372
- labelsVisible={labelsVisible}
467
+ showLabel={labelsVisible && screenWidth >= 28}
468
+ showLink={screenWidth >= 96}
373
469
  isSelected={isSelected}
374
470
  isHover={hoverId === page.id && !isSelected && !passThrough}
375
471
  isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}
@@ -14,6 +14,12 @@
14
14
  * 1 枚ごとに「Blocked script execution in 'about:srcdoc'」をコンソールへ出す(実測 26 枚 = 26 件)
15
15
  * - 本文が外から変わった(revision / invalidatePages)ときは、次の本文が来るまで前の姿を出したまま読み直す
16
16
  * - 文書の属性(documentAttributes)は読み直さずに <html> へ付け替える
17
+ *
18
+ * [大きく縮小している間は画像]
19
+ * 全体が見えるところまで縮めると、26 枚の iframe を倍率が変わるたびにラスタライズし直す
20
+ * のが一番重い。利用側が contentList[].thumbnail(ページ全体を写した画像)を渡していれば、
21
+ * 倍率が PREVIEW_IMAGE_ZOOM 未満の間は iframe を外して画像 1 枚にする(本文も読まない)。
22
+ * 画像が無いページ・画像が読めなかったページは今までどおり iframe
17
23
  */
18
24
 
19
25
  import { memo, useEffect, useRef, useState } from 'react';
@@ -36,9 +42,16 @@ interface PageFramePreviewProps {
36
42
  page: PageFrameLayout;
37
43
  /** 画面上の判定に使う容器(IntersectionObserver の root) */
38
44
  rootRef: React.RefObject<HTMLDivElement | null>;
45
+ /** キャンバスが大きく縮小されている(親が操作の終わりに決める。段ごとには変わらない) */
46
+ imageMode?: boolean;
47
+ /**
48
+ * 画像を裏で先に読んでおく。縮小の途中(倍率 0.4 未満)で iframe を止めて画像を前に出すのは
49
+ * CSS の仕事だが、そのとき画像が読めていないと空白のフレームになる。だから先に読ませる
50
+ */
51
+ preloadImage?: boolean;
39
52
  }
40
53
 
41
- export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
54
+ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef, imageMode = false, preloadImage = false }: PageFramePreviewProps) {
42
55
  const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
43
56
  const documentAttributesRef = useRef(documentAttributes);
44
57
  documentAttributesRef.current = documentAttributes;
@@ -47,6 +60,13 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
47
60
  const [nearViewport, setNearViewport] = useState(false);
48
61
  const [loaded, setLoaded] = useState(false);
49
62
 
63
+ // 画像の URL が変わったら、前の URL で失敗した記録は捨てる(直った画像が二度と出ないのを防ぐ)
64
+ const [imageFailed, setImageFailed] = useState(false);
65
+ useEffect(() => { setImageFailed(false); }, [page.thumbnail]);
66
+ const [imageLoaded, setImageLoaded] = useState(false);
67
+ useEffect(() => { setImageLoaded(false); }, [page.thumbnail]);
68
+ const useImage = imageMode && !!page.thumbnail && !imageFailed;
69
+
50
70
  // 画面に近づいたら描く(遠ざかっても消さない: 戻ったときに白く抜けないため)
51
71
  useEffect(() => {
52
72
  const host = hostRef.current;
@@ -63,8 +83,9 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
63
83
  }, [rootRef]);
64
84
 
65
85
  // webpage は高さの実測がレイアウトに要る(未測定だと仮の高さで並ぶ)ので、見えていなくても読む。
66
- // slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)
67
- const wantsMount = editorMode === 'webpage' || nearViewport;
86
+ // slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)。
87
+ // 画像で描いている間は本文を読まない(iframe も載せない)
88
+ const wantsMount = !useImage && (editorMode === 'webpage' || nearViewport);
68
89
  const [granted, setGranted] = useState(false);
69
90
  const grantedRef = useRef(false);
70
91
  useEffect(() => {
@@ -79,7 +100,17 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
79
100
  };
80
101
  }, [wantsMount, page.id, requestPreviewSlot, releasePreviewSlot]);
81
102
  useEffect(() => () => releasePreviewSlot(page.id), [page.id, releasePreviewSlot]);
82
- const shouldMount = granted;
103
+
104
+ // 画像に落ちたら iframe を外してメモリを返す(戻ってきたら順番待ちから取り直す)
105
+ useEffect(() => {
106
+ if (!useImage) return;
107
+ grantedRef.current = false;
108
+ setGranted(false);
109
+ setLoaded(false);
110
+ releasePreviewSlot(page.id);
111
+ }, [useImage, page.id, releasePreviewSlot]);
112
+
113
+ const shouldMount = granted && !useImage;
83
114
 
84
115
  useEffect(() => {
85
116
  if (shouldMount && page.html == null && !page.loading && !page.error) void ensurePageHtml(page.id);
@@ -92,10 +123,12 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
92
123
  setDoc(generatePreviewHtml(page.html, editorMode, page.size.width, previewStyles));
93
124
  }, [shouldMount, page.html, editorMode, page.size.width, previewStyles]);
94
125
 
95
- // 高さの実測(同一オリジンなので親から読む)
126
+ // 高さの実測(同一オリジンなので親から読む)。
127
+ // shouldMount を deps に入れるのは、画像から iframe に戻ったとき(doc は前のまま)にも
128
+ // 新しい iframe へ load を張り直すため
96
129
  useEffect(() => {
97
130
  const iframe = iframeRef.current;
98
- if (!iframe || doc == null) return;
131
+ if (!iframe || doc == null || !shouldMount) return;
99
132
  let observer: ResizeObserver | null = null;
100
133
  let disposed = false;
101
134
  const measure = () => {
@@ -126,12 +159,14 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
126
159
  }
127
160
  };
128
161
  iframe.addEventListener('load', onLoad);
162
+ // 画像から戻ったときは srcdoc が同じなので load が来ないことがある。読めていればその場で測る
163
+ if (iframe.contentDocument?.readyState === 'complete') onLoad();
129
164
  return () => {
130
165
  disposed = true;
131
166
  iframe.removeEventListener('load', onLoad);
132
167
  observer?.disconnect();
133
168
  };
134
- }, [doc, page.id, setPageHeight]);
169
+ }, [doc, shouldMount, page.id, setPageHeight]);
135
170
 
136
171
  // 文書の属性が変わったら、読み直さずに <html> へ付け替える(読み込み前なら onLoad が付ける)
137
172
  useEffect(() => {
@@ -154,21 +189,60 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
154
189
  return () => clearTimeout(timer);
155
190
  }, [granted, loaded, page.error, page.id, releasePreviewSlot]);
156
191
 
192
+ /**
193
+ * 画像を出す(または iframe の下に敷いておく)か。敷いておく理由は 2 つ:
194
+ * - 画像 → iframe に戻る途中、本文が描けるまで下に見せる(26 枚が一度白く抜けない)
195
+ * - 縮小の途中で 0.4 を割ったとき、CSS が iframe を止めるだけで画像に切り替わる
196
+ * (読めていない画像は data-image-ready が付かないので、iframe を見せたまま)
197
+ */
198
+ const showImage = !!page.thumbnail && !imageFailed && (useImage || preloadImage || !loaded);
199
+
157
200
  return (
158
201
  <div
159
202
  ref={hostRef}
160
203
  className="absolute inset-0 overflow-hidden"
161
204
  data-page-preview={page.id}
205
+ data-preview-mode={useImage ? 'image' : 'iframe'}
206
+ // 画像が読めている = 縮小の途中でも iframe を止めて前に出せる(skin.css が拾う)
207
+ data-image-ready={showImage && imageLoaded ? '1' : undefined}
162
208
  style={{ background: '#fff' }}
163
209
  >
164
- {doc != null ? (
210
+ {showImage && (
211
+ <img
212
+ src={page.thumbnail}
213
+ alt=""
214
+ aria-hidden="true"
215
+ draggable={false}
216
+ decoding="async"
217
+ className="absolute inset-0 block"
218
+ style={{
219
+ width: '100%', height: '100%', objectFit: 'fill', pointerEvents: 'none',
220
+ // iframe と同じ理由。無いと倍率が変わるたびに 26 枚の画像がラスタライズし直され、
221
+ // 画像に落としたのに縮小 p95 が 19〜23ms のままになる(付けると 16ms 台。実測)
222
+ willChange: 'transform',
223
+ }}
224
+ onLoad={(e) => {
225
+ setImageLoaded(true);
226
+ // 画像しか出していないページは iframe が高さを測れない。画像はページと同じ縦横比
227
+ // (利用側との約束)なので、フレームの幅から高さを出して並びを正す
228
+ const img = e.currentTarget;
229
+ if (!img.naturalWidth || !img.naturalHeight) return;
230
+ setPageHeight(page.id, (page.size.width * img.naturalHeight) / img.naturalWidth, 'preview');
231
+ }}
232
+ onError={() => setImageFailed(true)}
233
+ />
234
+ )}
235
+ {useImage ? (
236
+ // 画像がまだ来ていない間は骨組みを下に敷く(白く抜けない)
237
+ !imageLoaded && <div className="ed-frame-skeleton" aria-hidden="true" />
238
+ ) : doc != null ? (
165
239
  <iframe
166
240
  ref={iframeRef}
167
241
  title={page.title || page.id}
168
242
  srcDoc={doc}
169
243
  tabIndex={-1}
170
244
  aria-hidden="true"
171
- className="block border-0"
245
+ className="relative block border-0"
172
246
  style={{
173
247
  width: page.size.width,
174
248
  height: page.size.height,
@@ -180,17 +254,19 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
180
254
  }}
181
255
  />
182
256
  ) : (
183
- <div className="ed-frame-skeleton" aria-hidden="true">
184
- {page.error ? (
185
- <span className="ed-frame-skeleton-note">読み込めませんでした</span>
186
- ) : (
187
- <>
188
- <span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
189
- <span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
190
- <span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
191
- </>
192
- )}
193
- </div>
257
+ !showImage && (
258
+ <div className="ed-frame-skeleton" aria-hidden="true">
259
+ {page.error ? (
260
+ <span className="ed-frame-skeleton-note">読み込めませんでした</span>
261
+ ) : (
262
+ <>
263
+ <span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
264
+ <span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
265
+ <span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
266
+ </>
267
+ )}
268
+ </div>
269
+ )
194
270
  )}
195
271
  </div>
196
272
  );
@@ -1,3 +1,4 @@
1
1
  export { MultiPageCanvasView } from './MultiPageCanvasView';
2
2
  export { PageFramePreview } from './PageFramePreview';
3
3
  export { CanvasRulers } from './CanvasRulers';
4
+ export { CanvasFrameDecor } from './CanvasFrameDecor';
@@ -27,7 +27,7 @@ import {
27
27
  Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
28
28
  Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
29
29
  Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
30
- Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play,
30
+ Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play, Crosshair,
31
31
  Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
32
32
  MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
33
33
  PaintBucket, PenLine, Eraser, Highlighter, Table, Shapes, Sticker, Wand2,
@@ -1010,7 +1010,7 @@ export function PptRibbon({
1010
1010
  onSwitchUi: () => void;
1011
1011
  page: number;
1012
1012
  deckTitle?: string;
1013
- comments: { open: boolean; toggle: () => void; newComment: () => void };
1013
+ comments: { open: boolean; toggle: () => void; newComment: () => void; pickRegion?: () => void };
1014
1014
  }) {
1015
1015
  const pal = PALETTES[theme];
1016
1016
  const [tab, setTab] = useState<TabId>('home');
@@ -1820,6 +1820,10 @@ export function PptRibbon({
1820
1820
  <>
1821
1821
  <BigButton icon={MessageSquare} label={'新しい\nコメント'} onClick={comments.newComment}
1822
1822
  title="コメントを追加(要素を選択していればその要素に添付)" pal={pal} />
1823
+ {comments.pickRegion && (
1824
+ <BigButton icon={Crosshair} label={'範囲を\n指定'} active={actions.activeTool === 'comment'} onClick={comments.pickRegion}
1825
+ title="スライドをドラッグして、コメントする範囲を指定します (C)" pal={pal} />
1826
+ )}
1823
1827
  <BigButton icon={MessageSquare} label={'コメント\nの表示'} active={comments.open} onClick={comments.toggle} pal={pal} />
1824
1828
  <Sep pal={pal} />
1825
1829
  {(() => {