@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
@@ -32,6 +32,9 @@ import { useMultiPageCanvasOptional } from "../contexts/MultiPageCanvasContext";
32
32
  import { applyDocumentAttributes } from "./multi-page/PageFramePreview";
33
33
  import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
34
34
  import { endContentSwitch } from "../autosave";
35
+ import { showPartDropIndicator, clearPartDropIndicator, findFlowInsertion } from "../utils/drop-target";
36
+ import { debugLog } from '../utils/debug';
37
+ import { useEditorComponents } from "../contexts/EditorComponentsContext";
35
38
  import type {
36
39
  DOMTreeNode,
37
40
  DragState,
@@ -189,6 +192,9 @@ export function EditorCanvas() {
189
192
  const setHtmlRef = useRef(setHtml);
190
193
  const clearHistoryRef = useRef(clearHistory);
191
194
  const editorModeRef = useRef(editorMode);
195
+ // 部品モードか(io に loadParts があるか)。挿入位置の印を出してよいかの判定に使う
196
+ const { partsMode } = useEditorComponents();
197
+ const partsModeRef = useRef(partsMode);
192
198
 
193
199
  // Webページモード用のコンテンツ高さ追跡
194
200
  const [contentHeight, setContentHeight] = useState(WEBPAGE_MIN_HEIGHT);
@@ -208,6 +214,7 @@ export function EditorCanvas() {
208
214
  setHtmlRef.current = setHtml;
209
215
  clearHistoryRef.current = clearHistory;
210
216
  editorModeRef.current = editorMode;
217
+ partsModeRef.current = partsMode;
211
218
  contentHeightRef.current = contentHeight;
212
219
  }, [
213
220
  activeTool,
@@ -218,6 +225,7 @@ export function EditorCanvas() {
218
225
  setHtml,
219
226
  clearHistory,
220
227
  editorMode,
228
+ partsMode,
221
229
  contentHeight,
222
230
  ]);
223
231
 
@@ -467,7 +475,7 @@ export function EditorCanvas() {
467
475
  const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
468
476
  if (!iframeDoc) return;
469
477
 
470
- console.log("[Canvas] iframe loaded, initializing...");
478
+ debugLog("[Canvas] iframe loaded, initializing...");
471
479
 
472
480
  // 最初のペイントより先に現在の倍率を当ててから見せる。
473
481
  // initLayout はフォント待ち等で数百msかかるため、その後に適用すると
@@ -481,7 +489,7 @@ export function EditorCanvas() {
481
489
  // これにより、iframeがリロードされたり状態が変わるたびに
482
490
  // 古いリスナーを削除せずに新しいリスナーが追加される問題を防ぐ
483
491
  if (cleanupFunctionsRef.current.length > 0) {
484
- console.log("[Canvas] Cleaning up", cleanupFunctionsRef.current.length, "previous listeners");
492
+ debugLog("[Canvas] Cleaning up", cleanupFunctionsRef.current.length, "previous listeners");
485
493
  cleanupFunctionsRef.current.forEach(cleanup => {
486
494
  try {
487
495
  cleanup();
@@ -634,7 +642,7 @@ export function EditorCanvas() {
634
642
  if (iframe) iframe.focus();
635
643
  if (iframeDoc.body) {
636
644
  iframeDoc.body.focus();
637
- console.log('[Canvas] Focus restored after background click');
645
+ debugLog('[Canvas] Focus restored after background click');
638
646
  }
639
647
  });
640
648
  }
@@ -667,10 +675,28 @@ export function EditorCanvas() {
667
675
  // 7. ドラッグ&ドロップ(画像アップロード用)
668
676
  let dragCounter = 0;
669
677
 
678
+ // 部品のドラッグか(ファイルのドラッグと見分ける)。
679
+ // dragover 中は getData が使えないので types だけで判定する
680
+ const isPartDrag = (e: DragEvent) =>
681
+ !!e.dataTransfer?.types.includes("application/x-editor-component");
682
+
683
+ // 挿入位置の横棒を出してよい場面か。
684
+ // FrontendVisualEditor の drop 処理が insertIntoFlow(フローに差し込む)を使うのは
685
+ // 「部品モード かつ Web ページモード」のときだけで、それ以外(スライド、および
686
+ // Firestore のコンポーネント)は落とした座標に絶対配置する。条件を揃えないと
687
+ // 「横棒の位置と実際に入る位置が違う」という一番たちの悪いズレ方をする
688
+ const canShowFlowIndicator = () =>
689
+ partsModeRef.current && editorModeRef.current === "webpage";
690
+
691
+ // 前回の挿入先。同じなら描き直さない(dragover は毎秒数十回来るので、
692
+ // 毎回 DOM を差し替えると強制レイアウトが走り、印もちらつく)
693
+ let lastTarget: { parent: Element; before: Element | null } | null = null;
694
+
670
695
  const handleDragEnter = (e: DragEvent) => {
671
696
  e.preventDefault();
672
697
  e.stopPropagation();
673
698
  dragCounter++;
699
+ // 画像のオーバーレイはファイルのときだけ。部品は挿入位置の横棒で示す
674
700
  if (e.dataTransfer?.types.includes("Files")) {
675
701
  window.parent.postMessage({ type: "IFRAME_DRAG_ENTER" }, "*");
676
702
  }
@@ -681,6 +707,17 @@ export function EditorCanvas() {
681
707
  e.stopPropagation();
682
708
  if (e.dataTransfer?.types.includes("Files")) {
683
709
  e.dataTransfer.dropEffect = "copy";
710
+ return;
711
+ }
712
+ if (isPartDrag(e)) {
713
+ e.dataTransfer!.dropEffect = "copy";
714
+ if (!canShowFlowIndicator()) return;
715
+ // 落とす先を紙面の中に描く。実際の挿入と同じ関数で位置を出しているので、
716
+ // 印の場所とずれない(drop-target.ts)
717
+ const next = findFlowInsertion(iframeDoc, e.clientX, e.clientY);
718
+ if (lastTarget && lastTarget.parent === next.parent && lastTarget.before === next.before) return;
719
+ lastTarget = next;
720
+ showPartDropIndicator(iframeDoc, e.clientX, e.clientY);
684
721
  }
685
722
  };
686
723
 
@@ -689,19 +726,33 @@ export function EditorCanvas() {
689
726
  e.stopPropagation();
690
727
  dragCounter--;
691
728
  if (dragCounter === 0) {
729
+ lastTarget = null;
730
+ clearPartDropIndicator(iframeDoc);
692
731
  window.parent.postMessage({ type: "IFRAME_DRAG_LEAVE" }, "*");
693
732
  }
694
733
  };
695
734
 
735
+ // ドラッグが途中で捨てられた(Esc・パネルの上で離した)ときも印を残さない。
736
+ // dragend が起きるのはドラッグ「元」= 親ドキュメントのパネル項目なので、
737
+ // iframe ではなく親の document で受ける
738
+ const handleDragEnd = () => {
739
+ dragCounter = 0;
740
+ lastTarget = null;
741
+ clearPartDropIndicator(iframeDoc);
742
+ };
743
+
696
744
  const handleDrop = (e: DragEvent) => {
697
745
  e.preventDefault();
698
746
  e.stopPropagation();
699
747
  dragCounter = 0;
748
+ lastTarget = null;
749
+ // 印は挿入より先に消す。残っていると履歴と保存HTMLに混ざる
750
+ clearPartDropIndicator(iframeDoc);
700
751
 
701
752
  // コンポーネントのドロップをチェックして親ウィンドウに転送
702
753
  const componentData = e.dataTransfer?.getData('application/x-editor-component');
703
754
  if (componentData) {
704
- console.log('[EditorCanvas] Component drop detected, forwarding to parent:', componentData);
755
+ debugLog('[EditorCanvas] Component drop detected, forwarding to parent:', componentData);
705
756
  window.parent.postMessage(
706
757
  {
707
758
  type: "IFRAME_COMPONENT_DROP",
@@ -756,12 +807,15 @@ export function EditorCanvas() {
756
807
  iframeDoc.addEventListener("dragenter", handleDragEnter);
757
808
  iframeDoc.addEventListener("dragover", handleDragOver);
758
809
  iframeDoc.addEventListener("dragleave", handleDragLeave);
810
+ document.addEventListener("dragend", handleDragEnd);
759
811
  iframeDoc.addEventListener("drop", handleDrop);
760
812
  cleanupFunctions.push(() => {
761
813
  iframeDoc.removeEventListener("dragenter", handleDragEnter);
762
814
  iframeDoc.removeEventListener("dragover", handleDragOver);
763
815
  iframeDoc.removeEventListener("dragleave", handleDragLeave);
816
+ document.removeEventListener("dragend", handleDragEnd);
764
817
  iframeDoc.removeEventListener("drop", handleDrop);
818
+ clearPartDropIndicator(iframeDoc);
765
819
  });
766
820
 
767
821
  // [修正] ここにあった「iframe内のpasteを親へpostMessageして画像を挿入する」
@@ -769,7 +823,7 @@ export function EditorCanvas() {
769
823
  // 直接 paste リスナーを張っており、2系統が同時に走るため1回の貼り付けで
770
824
  // 画像が2枚入っていた。ペーストの処理は FrontendVisualEditor の1本に集約する。
771
825
 
772
- console.log("[Canvas] Drag/drop handlers registered");
826
+ debugLog("[Canvas] Drag/drop handlers registered");
773
827
 
774
828
  // 9. レイアウトモードに応じた初期化とDOMツリー構築
775
829
  const initLayout = async () => {
@@ -818,17 +872,17 @@ export function EditorCanvas() {
818
872
  // 別の幅で崩れ、デザインツールへの取り込みで意味を失う。
819
873
  if (editorModeRef.current !== "webpage") {
820
874
  const converted = convertToAbsolutePositioning(iframeDoc);
821
- console.log('[Canvas] 絶対配置へ一括変換:', converted, '要素');
875
+ debugLog('[Canvas] 絶対配置へ一括変換:', converted, '要素');
822
876
  }
823
877
 
824
878
  // ここまでが「開いただけ」の姿。以後の変化=ユーザーの編集、と
825
879
  // 切り分けるための指紋を全要素に刻む(保存時の変換ノイズ巻き戻しに使う)
826
880
  const stamped = stampBaselines(iframeDoc);
827
- console.log('[Canvas] 書き戻し用ベースライン:', stamped, '要素');
881
+ debugLog('[Canvas] 書き戻し用ベースライン:', stamped, '要素');
828
882
 
829
883
  const artboardEl = iframeDoc.getElementById("artboard");
830
884
  const tree = buildDomTree(iframeDoc, artboardEl || undefined);
831
- console.log("[Canvas] Built DOM tree with", tree.length, "root nodes");
885
+ debugLog("[Canvas] Built DOM tree with", tree.length, "root nodes");
832
886
 
833
887
  setDomTreeRef.current(tree);
834
888
  const firstLevelIds = new Set<string>(
@@ -844,7 +898,7 @@ export function EditorCanvas() {
844
898
  // 変換後の姿を「変更なし」の基準にする。これをしないと開いただけで
845
899
  // 未保存扱いになり、サムネイル移動のたびに確認ダイアログが出る
846
900
  setOriginalHtml(initializedHtml);
847
- console.log(
901
+ debugLog(
848
902
  "[Canvas] History reset with initialized HTML from artboard"
849
903
  );
850
904
 
@@ -879,7 +933,7 @@ export function EditorCanvas() {
879
933
  resizeObserver.observe(artboardEl);
880
934
  cleanupFunctions.push(() => resizeObserver.disconnect());
881
935
 
882
- console.log(
936
+ debugLog(
883
937
  "[Canvas] Webpage mode: content height tracking initialized on artboard"
884
938
  );
885
939
  }
@@ -888,11 +942,11 @@ export function EditorCanvas() {
888
942
  // 重要: クリーンアップ関数をrefに保存して、
889
943
  // 次のiframeロード時やアンマウント時にクリーンアップできるようにする
890
944
  cleanupFunctionsRef.current = cleanupFunctions;
891
- console.log("[Canvas] Registered", cleanupFunctions.length, "cleanup functions");
945
+ debugLog("[Canvas] Registered", cleanupFunctions.length, "cleanup functions");
892
946
 
893
947
  // iframeの読み込み完了をマーク(CSS変数注入等のトリガー用)
894
948
  setIframeReady(true);
895
- console.log("[Canvas] iframe ready, setIframeReady(true) called");
949
+ debugLog("[Canvas] iframe ready, setIframeReady(true) called");
896
950
 
897
951
  // eslint-disable-next-line react-hooks/exhaustive-deps
898
952
  }, []);
@@ -924,7 +978,7 @@ export function EditorCanvas() {
924
978
  // 重要: アンマウント時にすべてのイベントリスナーをクリーンアップ
925
979
  // これにより、メモリリークとゴーストリスナーを防ぐ
926
980
  if (cleanupFunctionsRef.current.length > 0) {
927
- console.log("[Canvas] Unmounting: cleaning up", cleanupFunctionsRef.current.length, "listeners");
981
+ debugLog("[Canvas] Unmounting: cleaning up", cleanupFunctionsRef.current.length, "listeners");
928
982
  cleanupFunctionsRef.current.forEach(cleanup => {
929
983
  try {
930
984
  cleanup();
@@ -946,6 +1000,7 @@ export function EditorCanvas() {
946
1000
  "move-mode",
947
1001
  "draw-mode",
948
1002
  "text-mode",
1003
+ "comment-mode",
949
1004
  "tool-rectangle",
950
1005
  "tool-ellipse",
951
1006
  "tool-line",
@@ -975,6 +1030,9 @@ export function EditorCanvas() {
975
1030
  iframeDoc.body.classList.add("draw-mode", `tool-${activeTool}`);
976
1031
  } else if (activeTool === "text") {
977
1032
  iframeDoc.body.classList.add("text-mode", "tool-text");
1033
+ } else if (activeTool === "comment") {
1034
+ // コメントの範囲指定。要素の選択・ホバーを止め、紙面のドラッグを範囲にする
1035
+ iframeDoc.body.classList.add("comment-mode");
978
1036
  }
979
1037
  }
980
1038
  }, [activeTool, getIframeDoc]);
@@ -1001,7 +1059,7 @@ export function EditorCanvas() {
1001
1059
  refreshSelectionOverlay(iframeDoc);
1002
1060
  });
1003
1061
 
1004
- console.log(
1062
+ debugLog(
1005
1063
  `[Canvas] Applied zoom: ${zoom}%, wrapper transform: scale(${currentScale})`
1006
1064
  );
1007
1065
  }, [zoom, editorMode, contentHeight, viewportWidth, getIframeDoc]);
@@ -1067,7 +1125,7 @@ export function EditorCanvas() {
1067
1125
  : SLIDE_HEIGHT;
1068
1126
  recalculateViewportUnits(iframeDoc, canvasWidth, canvasHeight);
1069
1127
 
1070
- console.log(`[Canvas] Viewport width changed to ${viewportWidth}px, recalculated viewport units with canvas dimensions: ${canvasWidth}x${canvasHeight}`);
1128
+ debugLog(`[Canvas] Viewport width changed to ${viewportWidth}px, recalculated viewport units with canvas dimensions: ${canvasWidth}x${canvasHeight}`);
1071
1129
  }, [viewportWidth, editorMode, getIframeDoc, calculateFitZoom, setFitZoom]);
1072
1130
 
1073
1131
  return (
@@ -10,11 +10,13 @@ export function EditorFooter() {
10
10
  const cmd = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl+';
11
11
  const hint = activeTool === 'move' ? 'ドラッグで紙面を移動 · V で選択に戻る'
12
12
  : activeTool === 'scale' ? 'ドラッグで拡大・縮小 · V で選択に戻る'
13
+ : activeTool === 'comment' ? '紙面をドラッグして範囲を指定 · クリックで点 · Esc で選択に戻る'
13
14
  : activeTool !== 'select' ? 'ドラッグで追加 · Esc でキャンセル'
14
15
  : selectedElement || selectedElementIds.length ? 'ダブルクリックで文字を編集 · Esc で選択を解除'
15
16
  : 'クリックで選択 · Space+ドラッグで紙面を移動';
16
17
  const shortcuts = [
17
18
  ['選択 / 移動 / 拡大・縮小', 'V / H / K'],
19
+ ['コメント(範囲を指定)', 'C'],
18
20
  ['元に戻す / やり直し', `${cmd}Z / ${cmd}⇧Z`],
19
21
  ['保存', `${cmd}S`], ['複製', `${cmd}D`],
20
22
  ['全体表示 / 100% / 選択範囲', `${cmd}0 / ${cmd}1 / ${cmd}2`],
@@ -38,6 +38,7 @@ import {
38
38
  getArtboardContent,
39
39
  } from "../utils/dom-utils";
40
40
  import { can } from "../../io";
41
+ import { debugLog } from '../utils/debug';
41
42
 
42
43
  interface EditorHeaderProps {
43
44
  theme?: "light" | "dark";
@@ -164,7 +165,7 @@ export function EditorHeader({
164
165
 
165
166
  // 全要素を絶対配置に変換
166
167
  const count = convertToAbsolutePositioning(iframeDoc);
167
- console.log(
168
+ debugLog(
168
169
  "[Header] Converted",
169
170
  count,
170
171
  "elements to absolute positioning",
@@ -226,7 +227,7 @@ export function EditorHeader({
226
227
  element.style.removeProperty("flex-grow");
227
228
  element.style.removeProperty("flex-shrink");
228
229
  });
229
- console.log(
230
+ debugLog(
230
231
  "[Header] Removed absolute positioning from",
231
232
  editableElements.length,
232
233
  "elements",
@@ -53,6 +53,7 @@ import { useEditorVariables } from "../EditorContext";
53
53
  import { isEyeDropperSupported, pickScreenColor } from "../utils/eyedropper";
54
54
  import type { CSSVariableDefinition } from "../../types/css-variables";
55
55
  import { isVariableReference, extractVariableName, generateVarReference } from "../../types/css-variables";
56
+ import { debugLog } from '../utils/debug';
56
57
 
57
58
  // 塗りのタイプ
58
59
  export type FillType = "solid" | "linear" | "radial" | "image" | "variable" | "none";
@@ -794,7 +795,7 @@ export function FigmaColorPicker({
794
795
  // 変数選択ハンドラ
795
796
  const handleVariableSelect = useCallback((variable: CSSVariableDefinition) => {
796
797
  const varRef = generateVarReference(variable);
797
- console.log('[DEBUG FigmaColorPicker] handleVariableSelect:', {
798
+ debugLog('[DEBUG FigmaColorPicker] handleVariableSelect:', {
798
799
  variable: { id: variable.id, name: variable.name, cssName: variable.cssName },
799
800
  generatedVarReference: varRef,
800
801
  hasOnVariableSelect: !!onVariableSelect,
@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
10
10
  import { Button } from '../../components/ui/button';
11
11
  import { Textarea } from '../../components/ui/textarea';
12
12
  import { Braces, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
13
+ import { debugLog } from '../utils/debug';
13
14
 
14
15
  export interface JsEditorDialogProps {
15
16
  isOpen: boolean;
@@ -14,6 +14,7 @@ import { Label } from '../../components/ui/label';
14
14
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
15
15
  import { Switch } from '../../components/ui/switch';
16
16
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
17
+ import { debugLog } from '../utils/debug';
17
18
  import {
18
19
  Settings,
19
20
  Search,
@@ -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
+ });