@growgroup/visual-editor 0.3.1 → 0.4.1

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 +123 -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 +179 -77
  16. package/src/editor/components/multi-page/PageFramePreview.tsx +99 -21
  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 +194 -26
  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 +43 -11
  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
@@ -38,6 +38,14 @@ export interface ContentListItem {
38
38
  parentId?: string | null;
39
39
  /** 編集しない表示(ページそのもの・ビューア)の URL。渡すとフレーム名と一覧に「別タブで開く」が付く */
40
40
  href?: string;
41
+ /**
42
+ * ページ全体を写した画像(PNG / JPEG)の URL。幅は 480px 以上、縦横比はページと同じ。
43
+ * マルチフレームのキャンバスが大きく縮小されている間(倍率 0.5 未満)は、見るだけの紙面を
44
+ * srcdoc の iframe ではなくこの画像で描く(26 枚の iframe をラスタライズし直さずに済む)。
45
+ * 内容が変わったら URL も変える(`&v=…`)。変わると紙面が読み直す。
46
+ * 渡さないページ・画像が読めなかったページは、今までどおり iframe で描く
47
+ */
48
+ thumbnail?: string;
41
49
  /**
42
50
  * 本文の版。利用側でファイルが外から変わったとき(部品の一括反映・CLI・他の人の編集)に
43
51
  * 数を進めると、キャンバスの見るだけの紙面がそのページを読み直す。編集中のページには効かない
@@ -46,6 +46,7 @@ import {
46
46
  import { io } from '../../io';
47
47
  import type { EditorPartDef, EditorPartsLibrary } from '../../io';
48
48
  import { useMultiPageCanvasOptional } from './MultiPageCanvasContext';
49
+ import { debugLog, isDebugEnabled } from '../utils/debug';
49
50
  import {
50
51
  materializePart,
51
52
  partDefFromElement,
@@ -211,7 +212,7 @@ export function htmlElementToComponentElement(element: HTMLElement, isRoot: bool
211
212
  if (key && value) {
212
213
  // Skip position-related styles for root element
213
214
  if (isRoot && POSITION_STYLES.has(key)) {
214
- console.log(`[htmlElementToComponentElement] Skipping position style for root: ${key}`);
215
+ debugLog(`[htmlElementToComponentElement] Skipping position style for root: ${key}`);
215
216
  continue;
216
217
  }
217
218
  // Convert kebab-case to camelCase
@@ -238,7 +239,7 @@ export function htmlElementToComponentElement(element: HTMLElement, isRoot: bool
238
239
  if (hasMixedContent) {
239
240
  // Mixed content: use innerHTML to preserve all content
240
241
  innerHTML = element.innerHTML;
241
- console.log('[htmlElementToComponentElement] Mixed content detected, using innerHTML');
242
+ debugLog('[htmlElementToComponentElement] Mixed content detected, using innerHTML');
242
243
  } else if (hasElementChildren) {
243
244
  // Only element children: recursively process (not root)
244
245
  for (const child of Array.from(element.children)) {
@@ -261,17 +262,20 @@ export function htmlElementToComponentElement(element: HTMLElement, isRoot: bool
261
262
  overridable: { ...defaultOverridable },
262
263
  };
263
264
 
264
- // Debug logging
265
- console.log('[htmlElementToComponentElement] Converted:', {
266
- tagName: result.tagName,
267
- className: result.className,
268
- hasTextContent: !!result.textContent,
269
- hasInnerHTML: !!result.innerHTML,
270
- textContentPreview: result.textContent?.substring(0, 50),
271
- innerHTMLPreview: result.innerHTML?.substring(0, 50),
272
- childrenCount: result.children.length,
273
- stylesCount: Object.keys(result.styles).length,
274
- });
265
+ // 要素 1 つにつき 1 行出る。部品 89 個 + 26 ページで 2,000 件を超えるので、
266
+ // 出さないときは引数のオブジェクトを組み立てること自体をやめる
267
+ if (isDebugEnabled()) {
268
+ debugLog('[htmlElementToComponentElement] Converted:', {
269
+ tagName: result.tagName,
270
+ className: result.className,
271
+ hasTextContent: !!result.textContent,
272
+ hasInnerHTML: !!result.innerHTML,
273
+ textContentPreview: result.textContent?.substring(0, 50),
274
+ innerHTMLPreview: result.innerHTML?.substring(0, 50),
275
+ childrenCount: result.children.length,
276
+ stylesCount: Object.keys(result.styles).length,
277
+ });
278
+ }
275
279
 
276
280
  return result;
277
281
  }
@@ -586,7 +590,7 @@ export function EditorComponentsProvider({
586
590
  setIsLoadingComponents(true);
587
591
  setError(null);
588
592
  try {
589
- console.log(
593
+ debugLog(
590
594
  `[EditorComponentsContext] Loading components for website: ${targetWebsiteId}`
591
595
  );
592
596
 
@@ -610,13 +614,13 @@ export function EditorComponentsProvider({
610
614
  setMasterComponents(componentsMap);
611
615
  setComponentLibrary(categoriesFromParts(lib, componentsMap));
612
616
  setComponentInstances(new Map());
613
- console.log(`[EditorComponentsContext] Loaded ${defs.size} parts`);
617
+ debugLog(`[EditorComponentsContext] Loaded ${defs.size} parts`);
614
618
  return;
615
619
  }
616
620
 
617
621
  // Load master components from Firestore
618
622
  const components = await getAllMasterComponents(targetWebsiteId);
619
- console.log(`[EditorComponentsContext] Loaded ${components.length} components`);
623
+ debugLog(`[EditorComponentsContext] Loaded ${components.length} components`);
620
624
 
621
625
  // Create Map from components
622
626
  const componentsMap = new Map<string, MasterComponent>();
@@ -689,9 +693,9 @@ export function EditorComponentsProvider({
689
693
 
690
694
  const loadInstances = async () => {
691
695
  try {
692
- console.log(`[EditorComponentsContext] Loading instances for page: ${pageId}`);
696
+ debugLog(`[EditorComponentsContext] Loading instances for page: ${pageId}`);
693
697
  const instances = await getPageComponentInstances(websiteId, pageId);
694
- console.log(`[EditorComponentsContext] Loaded ${instances.length} instances`);
698
+ debugLog(`[EditorComponentsContext] Loaded ${instances.length} instances`);
695
699
 
696
700
  // Create Map from instances (keyed by domElementId)
697
701
  const instancesMap = new Map<string, ComponentInstance>();
@@ -766,7 +770,7 @@ export function EditorComponentsProvider({
766
770
  });
767
771
  }
768
772
 
769
- console.log(
773
+ debugLog(
770
774
  `[EditorComponentsContext] Created master component: ${name} (${id})`
771
775
  );
772
776
  return masterComponent;
@@ -996,7 +1000,7 @@ export function EditorComponentsProvider({
996
1000
  });
997
1001
  }
998
1002
 
999
- console.log(`[EditorComponentsContext] Deleted master component: ${id}`);
1003
+ debugLog(`[EditorComponentsContext] Deleted master component: ${id}`);
1000
1004
  }, [websiteId, partsMode]);
1001
1005
 
1002
1006
  const getMasterComponent = useCallback(
@@ -1069,7 +1073,7 @@ export function EditorComponentsProvider({
1069
1073
  });
1070
1074
  }
1071
1075
 
1072
- console.log(
1076
+ debugLog(
1073
1077
  `[EditorComponentsContext] Created instance: ${id} of master ${masterComponentId}`
1074
1078
  );
1075
1079
  return instance;
@@ -1157,7 +1161,7 @@ export function EditorComponentsProvider({
1157
1161
  return next;
1158
1162
  });
1159
1163
 
1160
- console.log(`[EditorComponentsContext] Deleted instance: ${instanceId}`);
1164
+ debugLog(`[EditorComponentsContext] Deleted instance: ${instanceId}`);
1161
1165
  }, [websiteId, pageId]);
1162
1166
 
1163
1167
  const getInstanceByDomId = useCallback(
@@ -1219,7 +1223,7 @@ export function EditorComponentsProvider({
1219
1223
  return next;
1220
1224
  });
1221
1225
 
1222
- console.log(
1226
+ debugLog(
1223
1227
  `[EditorComponentsContext] Added override to instance: ${instanceId}`
1224
1228
  );
1225
1229
  },
@@ -1269,7 +1273,7 @@ export function EditorComponentsProvider({
1269
1273
  return next;
1270
1274
  });
1271
1275
 
1272
- console.log(
1276
+ debugLog(
1273
1277
  `[EditorComponentsContext] Removed override ${overrideId} from instance: ${instanceId}`
1274
1278
  );
1275
1279
  },
@@ -1321,7 +1325,7 @@ export function EditorComponentsProvider({
1321
1325
  return next;
1322
1326
  });
1323
1327
 
1324
- console.log(
1328
+ debugLog(
1325
1329
  `[EditorComponentsContext] Reset all overrides for instance: ${instanceId}`
1326
1330
  );
1327
1331
  }, [websiteId, pageId]);
@@ -1394,7 +1398,7 @@ export function EditorComponentsProvider({
1394
1398
  return next;
1395
1399
  });
1396
1400
 
1397
- console.log(
1401
+ debugLog(
1398
1402
  `[EditorComponentsContext] Changed variant for instance ${instanceId} to ${variantId}`
1399
1403
  );
1400
1404
  },
@@ -1458,7 +1462,7 @@ export function EditorComponentsProvider({
1458
1462
  });
1459
1463
  }
1460
1464
 
1461
- console.log(
1465
+ debugLog(
1462
1466
  `[EditorComponentsContext] Detached instance: ${instanceId}`
1463
1467
  );
1464
1468
  return htmlElement;
@@ -18,6 +18,7 @@
18
18
  import React, { createContext, useContext, useState, useCallback } from 'react';
19
19
  import type { DOMTreeNode, PanelSections, MarqueeState } from '../types';
20
20
  import { DEFAULT_PANEL_SECTIONS } from '../constants';
21
+ import { debugLog } from '../utils/debug';
21
22
 
22
23
  export interface EditorUIStateContextValue {
23
24
  // DOMツリー
@@ -81,7 +82,7 @@ export function EditorUIStateProvider({
81
82
  const restoreFocus = useCallback(() => {
82
83
  const iframeDoc = getIframeDoc();
83
84
  if (!iframeDoc) {
84
- console.log('[EditorUIStateContext] restoreFocus: iframe doc not available');
85
+ debugLog('[EditorUIStateContext] restoreFocus: iframe doc not available');
85
86
  return;
86
87
  }
87
88
 
@@ -89,14 +90,14 @@ export function EditorUIStateProvider({
89
90
  const editingElement = iframeDoc.querySelector('[contenteditable="true"]') as HTMLElement;
90
91
  if (editingElement) {
91
92
  editingElement.focus();
92
- console.log('[EditorUIStateContext] restoreFocus: focused editing element');
93
+ debugLog('[EditorUIStateContext] restoreFocus: focused editing element');
93
94
  return;
94
95
  }
95
96
 
96
97
  // iframeのbodyにフォーカスを設定
97
98
  if (iframeDoc.body) {
98
99
  iframeDoc.body.focus();
99
- console.log('[EditorUIStateContext] restoreFocus: focused iframe body');
100
+ debugLog('[EditorUIStateContext] restoreFocus: focused iframe body');
100
101
  }
101
102
  }, [getIframeDoc]);
102
103
 
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
25
+ import { debugLog } from '../utils/debug';
25
26
  import type {
26
27
  CSSVariableDefinition,
27
28
  CSSVariableCategory,
@@ -264,7 +265,7 @@ export function EditorVariablesProvider({
264
265
  await onSave(variables);
265
266
  savedVariablesRef.current = [...variables];
266
267
  setHasChanges(false);
267
- console.log('[EditorVariablesContext] Variables saved successfully');
268
+ debugLog('[EditorVariablesContext] Variables saved successfully');
268
269
  } catch (error) {
269
270
  console.error('[EditorVariablesContext] Failed to save variables:', error);
270
271
  throw error;
@@ -286,7 +287,7 @@ export function EditorVariablesProvider({
286
287
  setVariablesState(loaded);
287
288
  savedVariablesRef.current = [...loaded];
288
289
  setHasChanges(false);
289
- console.log('[EditorVariablesContext] Variables loaded:', loaded.length);
290
+ debugLog('[EditorVariablesContext] Variables loaded:', loaded.length);
290
291
  } catch (error) {
291
292
  console.error('[EditorVariablesContext] Failed to load variables:', error);
292
293
  throw error;
@@ -299,7 +300,7 @@ export function EditorVariablesProvider({
299
300
  const syncFromTheme = useCallback((themeVariables: CSSVariableDefinition[]) => {
300
301
  setVariablesState(themeVariables);
301
302
  markAsChanged();
302
- console.log('[EditorVariablesContext] Synced from theme:', themeVariables.length, 'variables');
303
+ debugLog('[EditorVariablesContext] Synced from theme:', themeVariables.length, 'variables');
303
304
  }, [markAsChanged]);
304
305
 
305
306
  // バルク設定
@@ -327,7 +328,7 @@ export function EditorVariablesProvider({
327
328
  }
328
329
 
329
330
  styleEl.textContent = cssString;
330
- console.log('[EditorVariablesContext] Injected CSS variables to iframe');
331
+ debugLog('[EditorVariablesContext] Injected CSS variables to iframe');
331
332
  }, [cssString]);
332
333
 
333
334
  // Context値
@@ -56,12 +56,44 @@ export const MAX_ZOOM = 4;
56
56
  const ZOOM_STEP = 1.25;
57
57
  /** 全体表示・ページ表示のときの余白(px、画面上) */
58
58
  const FIT_PADDING = 72;
59
+ /** 画面の縁からこれだけ内側にあれば「見えている」とする(px、画面上) */
60
+ const REVEAL_MARGIN = 48;
61
+ /**
62
+ * ホスト発の切替で「そのページが見えている」と認めるフレームの画面上の幅(px)。
63
+ * 全体表示(2%)だと 1920 の紙面が 38px の点にしかならず、画面には入っていても
64
+ * どこへ移ったのか分からない。これを下回るならページを画面に合わせて寄る
65
+ */
66
+ const FOCUS_MIN_WIDTH = 400;
59
67
  /** 上と左の定規の太さ(px)。全体表示の余白に足す */
60
68
  export const RULER_SIZE = 20;
61
69
  /** ビューのアニメーション(ms) */
62
70
  const VIEW_ANIMATION_MS = 240;
63
71
  /** 見るだけの紙面を同時に読む枚数 */
64
72
  const PREVIEW_PARALLEL = 4;
73
+ /**
74
+ * 見るだけの紙面を画像で描く倍率の **上限の最大値**。これ以上に拡大されていれば、
75
+ * どんなに解像度の高い画像でも iframe で描く(画像は文字を選べない・検索できないため)。
76
+ * 実際の上限はこれより小さくなることがある ── getPreviewImageZoomCap() を見ること
77
+ */
78
+ export const PREVIEW_IMAGE_ZOOM = 0.5;
79
+ /** 画像 → iframe に戻す倍率(上限の最大値のとき)。上限に対する比は下の HYSTERESIS */
80
+ export const PREVIEW_IMAGE_ZOOM_OUT = 0.4;
81
+ /**
82
+ * 画像を引き伸ばしてよい上限。1.25 = 画面の画素に対して 25% まで。
83
+ * これを超えると Retina(DPR 2)で文字がにじんで見える(実測: 960px の画像を
84
+ * 1766 device px の枠に出すと 1.84 倍で、同じ倍率の iframe と並べて粗さが分かる)
85
+ */
86
+ export const PREVIEW_IMAGE_MAX_STRETCH = 1.25;
87
+ /**
88
+ * 画像の実寸がまだ 1 枚も分からないときに仮定する「画像の幅 ÷ 紙面の幅」。
89
+ * 0.5 = 1920 の紙面に 960px の画像(殻の既定)
90
+ */
91
+ const PREVIEW_IMAGE_PRESUMED_RATIO = 0.5;
92
+ /**
93
+ * 上限に対する「画像に落とす」側の比(ヒステリシス)。
94
+ * 画像 → iframe は上限以上、iframe → 画像は上限 × これ未満。境目で行き来しないため
95
+ */
96
+ export const PREVIEW_IMAGE_ZOOM_HYSTERESIS = PREVIEW_IMAGE_ZOOM_OUT / PREVIEW_IMAGE_ZOOM;
65
97
 
66
98
  /** フレームどうしの間隔(紙面の座標系、等倍のとき)。フレーム名を出すかの判定にも使う */
67
99
  export const FRAME_GAP: Record<EditorMode, { x: number; y: number }> = {
@@ -99,6 +131,8 @@ export interface PageFrame {
99
131
  depth: number;
100
132
  /** 編集しない表示の URL(contentList[].href) */
101
133
  href?: string;
134
+ /** ページ全体を写した画像の URL(contentList[].thumbnail)。大きく縮小した間だけ紙面の代わりに出す */
135
+ thumbnail?: string;
102
136
  /** 利用側が進める本文の版(contentList[].revision)。変わったら読み直す */
103
137
  revision: number;
104
138
  /**
@@ -217,6 +251,19 @@ export interface MultiPageCanvasContextValue {
217
251
  */
218
252
  requestPreviewSlot: (id: string, grant: () => void) => void;
219
253
  releasePreviewSlot: (id: string) => void;
254
+
255
+ // ---- 紙面の画像(contentList[].thumbnail)
256
+ /**
257
+ * 紙面を画像で描いてよい倍率の上限。画像の解像度と devicePixelRatio から決める
258
+ * (= min(PREVIEW_IMAGE_ZOOM, 画像の幅 × 1.25 ÷ (紙面の幅 × DPR)))。
259
+ * 上限はキャンバス全体で 1 つで、報告された中でいちばん粗い画像に合わせる。
260
+ * DPR は呼ぶたびに読む(別のディスプレイへ移すと変わる)
261
+ */
262
+ getPreviewImageZoomCap: () => number;
263
+ /** 画像の実寸(naturalWidth)を報告する。PageFramePreview が onLoad で呼ぶ */
264
+ reportThumbnailWidth: (id: string, naturalWidth: number) => void;
265
+ /** 上限が変わった回数。React 側が判定をやり直すきっかけに使う */
266
+ previewImageCapVersion: number;
220
267
  }
221
268
 
222
269
  // ============================================================
@@ -244,6 +291,17 @@ export function useCanvasViewState(): CanvasViewState {
244
291
  return useSyncExternalStore(viewStore.subscribe, viewStore.get, viewStore.get);
245
292
  }
246
293
 
294
+ /**
295
+ * 編集中のページだけを購読する。
296
+ * useCanvasViewState() だと倍率・位置が変わるたびに描き直る(ホイール 1 段ごとに
297
+ * キャンバス全体が React を通る)。編集中のページしか要らない側はこちらを使う
298
+ */
299
+ export function useCanvasActivePageId(): string | null {
300
+ const { viewStore } = useMultiPageCanvas();
301
+ const getSnapshot = useCallback(() => viewStore.get().activePageId, [viewStore]);
302
+ return useSyncExternalStore(viewStore.subscribe, getSnapshot, getSnapshot);
303
+ }
304
+
247
305
  const NULL_VIEW_STORE: CanvasViewStore = {
248
306
  get: () => NULL_VIEW,
249
307
  set: () => undefined,
@@ -487,6 +545,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
487
545
  const depth = depths.get(content.id) ?? 0;
488
546
  const revision = content.revision ?? 0;
489
547
  const href = content.href;
548
+ const thumbnail = content.thumbnail;
490
549
  if (existing) {
491
550
  const title = content.title || existing.title;
492
551
  const width = frameWidth;
@@ -499,9 +558,9 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
499
558
  if (
500
559
  title !== existing.title || html !== existing.html || width !== existing.size.width ||
501
560
  parentId !== existing.parentId || depth !== existing.depth || href !== existing.href ||
502
- revision !== existing.revision || stale !== existing.stale
561
+ revision !== existing.revision || stale !== existing.stale || thumbnail !== existing.thumbnail
503
562
  ) changed = true;
504
- next.set(content.id, { ...existing, title, html, stale, parentId, depth, href, revision, size: { width, height: existing.size.height } });
563
+ next.set(content.id, { ...existing, title, html, stale, parentId, depth, href, thumbnail, revision, size: { width, height: existing.size.height } });
505
564
  } else {
506
565
  changed = true;
507
566
  next.set(content.id, {
@@ -516,6 +575,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
516
575
  parentId,
517
576
  depth,
518
577
  href,
578
+ thumbnail,
519
579
  revision,
520
580
  stale: false,
521
581
  });
@@ -611,15 +671,6 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
611
671
  [updatePageFrame, viewStore],
612
672
  );
613
673
 
614
- // 生きているページは利用側(currentContentId)と揃える。
615
- // 本文が読めて currentContentId が変わったときに初めてエディタが移る
616
- // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)
617
- useEffect(() => {
618
- if (!enabled) return;
619
- const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
620
- viewStore.set((prev) => (prev.activePageId === id ? prev : { ...prev, activePageId: id }));
621
- }, [enabled, artboard.currentContentId, artboard.contentList, viewStore]);
622
-
623
674
  // 見えている範囲の保存(変化が止まってから)
624
675
  useEffect(() => {
625
676
  if (!storageKey || typeof localStorage === 'undefined') return;
@@ -786,32 +837,107 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
786
837
  [setView],
787
838
  );
788
839
 
789
- const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
790
- (id) => {
840
+ /**
841
+ * フレームが今どこに、どれだけの大きさで画面に出ているか(容器の座標)。
842
+ * 「見えているか」「寄る必要があるか」を revealPage と focusPage が同じ式で判定するため
843
+ */
844
+ const frameScreenRect = useCallback(
845
+ (id: string) => {
791
846
  const page = pagesRef.current.find((p) => p.id === id);
792
- if (!page) return;
793
- const { width, height } = containerSize();
847
+ if (!page) return null;
848
+ const container = containerSize();
794
849
  const { canvasZoom: z, canvasOffset: o } = viewStore.get();
795
850
  const inset = rulersRef.current ? RULER_SIZE : 0;
796
851
  const left = o.x + page.position.x * z;
797
852
  const top = o.y + page.position.y * z;
798
- const right = left + page.size.width * z;
799
- const bottom = top + page.size.height * z;
800
- const margin = 48;
853
+ const width = page.size.width * z;
854
+ const height = page.size.height * z;
855
+ return {
856
+ page,
857
+ container,
858
+ inset,
859
+ zoom: z,
860
+ left,
861
+ top,
862
+ width,
863
+ height,
864
+ // 一部でも画面に入っていれば「見えている」
865
+ visible:
866
+ left + width > inset + REVEAL_MARGIN &&
867
+ left < container.width - REVEAL_MARGIN &&
868
+ top + height > inset + REVEAL_MARGIN &&
869
+ top < container.height - REVEAL_MARGIN,
870
+ };
871
+ },
872
+ [viewStore],
873
+ );
874
+
875
+ const revealPage = useCallback<MultiPageCanvasContextValue['revealPage']>(
876
+ (id) => {
877
+ const r = frameScreenRect(id);
801
878
  // 一部でも見えていればそのまま。見えていなければ、そのフレームの左上を画面へ寄せる
802
- const visible = right > inset + margin && left < width - margin && bottom > inset + margin && top < height - margin;
803
- if (visible) return;
804
- const w = page.size.width * z;
805
- const h = page.size.height * z;
879
+ if (!r || r.visible) return;
880
+ const { page, container, inset, zoom: z, width: w, height: h } = r;
806
881
  const offset = {
807
- x: w <= width - inset ? inset + (width - inset - w) / 2 - page.position.x * z : inset + margin - page.position.x * z,
808
- y: h <= height - inset ? inset + (height - inset - h) / 2 - page.position.y * z : inset + margin - page.position.y * z,
882
+ x: w <= container.width - inset ? inset + (container.width - inset - w) / 2 - page.position.x * z : inset + REVEAL_MARGIN - page.position.x * z,
883
+ y: h <= container.height - inset ? inset + (container.height - inset - h) / 2 - page.position.y * z : inset + REVEAL_MARGIN - page.position.y * z,
809
884
  };
810
885
  setView({ zoom: z, offset }, { animate: true });
811
886
  },
812
- [setView, viewStore],
887
+ [frameScreenRect, setView],
888
+ );
889
+
890
+ /**
891
+ * ホスト発(利用側のレールなど)でページが変わったときに視点を寄せる。
892
+ * 倍率はできるだけ変えない ── ただし「画面に入ってはいるが点にしか見えない」ときは
893
+ * 寄せても何も起きていないように見えるので、そのときだけページに合わせる
894
+ */
895
+ const focusPage = useCallback(
896
+ (id: string) => {
897
+ const r = frameScreenRect(id);
898
+ if (!r) return;
899
+ if (r.width < FOCUS_MIN_WIDTH) {
900
+ zoomToPage(id, { animate: true });
901
+ return;
902
+ }
903
+ if (!r.visible) revealPage(id);
904
+ },
905
+ [frameScreenRect, revealPage, zoomToPage],
813
906
  );
814
907
 
908
+ /**
909
+ * 直前の activatePage(フレームのクリック・左パネル)の宛先。
910
+ * 下の同期 effect が「エディタ発かホスト発か」を見分けるのに使う。
911
+ * state(activatingPageId)ではなく ref で持つのは、切替が終わって state が戻るのと
912
+ * 同期 effect が走るのが同じ描画にまとまるため(state では読んだときに既に null)。
913
+ * 消すのは effect が使ったときだけ ── A → B と続けて押したときは A の後始末で消さない
914
+ */
915
+ const editorInitiatedPageIdRef = useRef<string | null>(null);
916
+
917
+ // 生きているページは利用側(currentContentId)と揃える。
918
+ // 本文が読めて currentContentId が変わったときに初めてエディタが移る
919
+ // (クリックの時点では移さない。移すと前のページの本文が新しい枠に見えてしまう)。
920
+ // ホスト発(利用側がレールで contentId を変えた)のときは視点も寄せる ── フレームの
921
+ // クリックと違って画面のどこにも手掛かりが無く、26 ページの木では移り先がほぼ画面外
922
+ useEffect(() => {
923
+ if (!enabled) return;
924
+ const id = artboard.currentContentId ?? artboard.contentList[0]?.id ?? null;
925
+ let prevId: string | null | undefined;
926
+ viewStore.set((prev) => {
927
+ if (prev.activePageId === id) return prev;
928
+ prevId = prev.activePageId;
929
+ return { ...prev, activePageId: id };
930
+ });
931
+ if (prevId === undefined || !id) return; // 変わっていない
932
+ if (prevId === null) return; // 最初の 1 ページが決まっただけ。初回の視点は MultiPageCanvasView が決める
933
+ if (editorInitiatedPageIdRef.current === id) {
934
+ // エディタ発(activatePage)。あちらで既に寄せてあるので二重に動かさない
935
+ editorInitiatedPageIdRef.current = null;
936
+ return;
937
+ }
938
+ focusPage(id);
939
+ }, [enabled, artboard.currentContentId, artboard.contentList, viewStore, focusPage]);
940
+
815
941
  // ---- 本文の読み込み
816
942
  const loadingRef = useRef(new Set<string>());
817
943
  const ensurePageHtml = useCallback<MultiPageCanvasContextValue['ensurePageHtml']>(
@@ -869,13 +995,21 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
869
995
  }
870
996
  const notify = onContentChangeRef.current;
871
997
  if (!notify) return false;
998
+ // エディタ発だと印す。利用側の contentId が変わって同期 effect が走るころには
999
+ // この切替は終わっているので、state ではなく ref で残す
1000
+ editorInitiatedPageIdRef.current = id;
872
1001
  setActivatingPageId(id);
873
1002
  if (options?.reveal) revealPage(id);
1003
+ let moved = false;
874
1004
  try {
875
1005
  const result = await (notify as (contentId: string) => unknown)(id);
876
- return result !== false;
1006
+ moved = result !== false;
1007
+ return moved;
877
1008
  } finally {
878
1009
  setActivatingPageId((v) => (v === id ? null : v));
1010
+ // 移れなかったら印を消す(残すと、あとで同じページへホスト発で移るときに視点が動かない)。
1011
+ // 別のページへ移ったあとなら消さない ── その印はそちらの切替のもの
1012
+ if (!moved && editorInitiatedPageIdRef.current === id) editorInitiatedPageIdRef.current = null;
879
1013
  }
880
1014
  },
881
1015
  [revealPage, viewStore],
@@ -930,6 +1064,36 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
930
1064
  [pumpPreviewSlots],
931
1065
  );
932
1066
 
1067
+ // ---- 紙面の画像の解像度(画像を出してよい倍率の上限を決める)
1068
+ // 引き伸ばしすぎるとにじむ。上限はキャンバス全体で 1 つにし、いちばん粗い画像に合わせる
1069
+ const thumbnailWidthsRef = useRef(new Map<string, number>());
1070
+ const thumbnailRatioRef = useRef<number | null>(null);
1071
+ const [previewImageCapVersion, setPreviewImageCapVersion] = useState(0);
1072
+
1073
+ const reportThumbnailWidth = useCallback<MultiPageCanvasContextValue['reportThumbnailWidth']>((id, naturalWidth) => {
1074
+ if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return;
1075
+ if (thumbnailWidthsRef.current.get(id) === naturalWidth) return;
1076
+ thumbnailWidthsRef.current.set(id, naturalWidth);
1077
+ let min = Infinity;
1078
+ thumbnailWidthsRef.current.forEach((width, key) => {
1079
+ const pageWidth = framesMapRef.current.get(key)?.size.width;
1080
+ if (!pageWidth || pageWidth <= 0) return;
1081
+ min = Math.min(min, width / pageWidth);
1082
+ });
1083
+ const ratio = Number.isFinite(min) ? min : null;
1084
+ if (ratio === thumbnailRatioRef.current) return;
1085
+ thumbnailRatioRef.current = ratio;
1086
+ // 上限が動いたので、React 側(画像にするかの判定)にやり直させる
1087
+ setPreviewImageCapVersion((v) => v + 1);
1088
+ }, []);
1089
+
1090
+ const getPreviewImageZoomCap = useCallback<MultiPageCanvasContextValue['getPreviewImageZoomCap']>(() => {
1091
+ // 別のディスプレイへ移すと変わるので、そのつど読む
1092
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
1093
+ const ratio = thumbnailRatioRef.current ?? PREVIEW_IMAGE_PRESUMED_RATIO;
1094
+ return Math.min(PREVIEW_IMAGE_ZOOM, (ratio * PREVIEW_IMAGE_MAX_STRETCH) / dpr);
1095
+ }, []);
1096
+
933
1097
  const value = useMemo<MultiPageCanvasContextValue>(
934
1098
  () => ({
935
1099
  isEnabled: enabled,
@@ -968,12 +1132,16 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
968
1132
  toggleRulers,
969
1133
  requestPreviewSlot,
970
1134
  releasePreviewSlot,
1135
+ getPreviewImageZoomCap,
1136
+ reportThumbnailWidth,
1137
+ previewImageCapVersion,
971
1138
  }),
972
1139
  [
973
1140
  enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
974
1141
  setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
975
1142
  zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
976
1143
  persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1144
+ getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
977
1145
  ],
978
1146
  );
979
1147
 
@@ -13,6 +13,7 @@ import { createAuthApi } from '../../lib/api/auth-fetch';
13
13
  import { convertSingleElementToAbsolute, generateElementId } from '../utils/dom-utils';
14
14
  import type { SlideAgentRequest, SlideAgentResponse, AttachedFile as SlideAttachedFile } from '../../lib/agent/slide-agent/types';
15
15
  import type { WebsiteAgentRequest, WebsiteAgentResponse, AttachedFile as WebsiteAttachedFile } from '../../lib/agent/website-agent/types';
16
+ import { debugLog } from '../utils/debug';
16
17
 
17
18
  // AttachedFile型を共通化(両方同じ構造)
18
19
  export type AttachedFile = SlideAttachedFile;
@@ -176,7 +177,7 @@ export function useAiReplace({ presentationId, websiteId }: UseAiReplaceProps =
176
177
  const convertChildrenToAbsolute = async () => {
177
178
  // オートレイアウトモードの場合は変換をスキップ
178
179
  if (layoutMode === 'auto') {
179
- console.log('[useAiReplace] Auto layout mode - skipping absolute positioning conversion for children');
180
+ debugLog('[useAiReplace] Auto layout mode - skipping absolute positioning conversion for children');
180
181
  return;
181
182
  }
182
183
 
@@ -196,7 +197,7 @@ export function useAiReplace({ presentationId, websiteId }: UseAiReplaceProps =
196
197
  convertSingleElementToAbsolute(iframeDoc, htmlChild);
197
198
  });
198
199
 
199
- console.log('[useAiReplace] Converted', editableChildren.length, 'child elements to absolute positioning');
200
+ debugLog('[useAiReplace] Converted', editableChildren.length, 'child elements to absolute positioning');
200
201
 
201
202
  // 変更を再度通知
202
203
  notifyIframeChange();
@@ -263,7 +264,7 @@ export function useAiReplace({ presentationId, websiteId }: UseAiReplaceProps =
263
264
  // editorModeに基づいてAPI呼び出しを分岐
264
265
  const isWebpageMode = editorMode === 'webpage';
265
266
 
266
- console.log('[useAiReplace] API呼び出し:', {
267
+ debugLog('[useAiReplace] API呼び出し:', {
267
268
  editorMode,
268
269
  apiEndpoint: isWebpageMode ? '/api/website-agent' : '/api/slide-agent',
269
270
  promptLength: prompt.length,
@@ -1,4 +1,5 @@
1
1
  import { useEffect } from 'react';
2
+ import { debugLog } from '../utils/debug';
2
3
 
3
4
  /**
4
5
  * ブラウザのネイティブズーム(ピンチ、Ctrl+ホイール、キーボードショートカット等)を防止するフック
@@ -67,7 +68,7 @@ export function useBrowserZoomPrevention(): void {
67
68
  // e.key ではなく e.code を使用して物理キーを判定する
68
69
  // preventDefault のみでブラウザのデフォルト動作を止め、自前のハンドラーは動くようにする
69
70
  if (isCmdOrCtrl && e.altKey && (e.code === 'KeyC' || e.code === 'KeyV')) {
70
- console.log('[preventBrowserShortcuts] Preventing browser default for Cmd/Ctrl+Alt+C/V', {
71
+ debugLog('[preventBrowserShortcuts] Preventing browser default for Cmd/Ctrl+Alt+C/V', {
71
72
  key: e.key,
72
73
  code: e.code,
73
74
  });