@growgroup/visual-editor 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +10 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +11 -2
  5. package/src/editor/EditorToolbar.tsx +8 -2
  6. package/src/editor/FrontendVisualEditor.tsx +128 -45
  7. package/src/editor/components/EditorCanvas.tsx +87 -15
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorFooter.tsx +2 -0
  10. package/src/editor/components/EditorHeader.tsx +3 -2
  11. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  12. package/src/editor/components/EditorPropertyPanel.tsx +54 -25
  13. package/src/editor/components/FigmaColorPicker.tsx +2 -1
  14. package/src/editor/components/JsEditorDialog.tsx +1 -0
  15. package/src/editor/components/PageSettingsDialog.tsx +1 -0
  16. package/src/editor/components/comments/CommentBoard.tsx +9 -6
  17. package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
  18. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +204 -58
  19. package/src/editor/components/multi-page/PageFramePreview.tsx +131 -22
  20. package/src/editor/components/multi-page/index.ts +1 -0
  21. package/src/editor/components/ppt/PptChrome.tsx +6 -2
  22. package/src/editor/components/ppt/PptComments.tsx +411 -71
  23. package/src/editor/constants.ts +44 -1
  24. package/src/editor/contexts/EditorArtboardContext.tsx +60 -1
  25. package/src/editor/contexts/EditorComponentsContext.tsx +48 -31
  26. package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
  27. package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
  28. package/src/editor/contexts/MultiPageCanvasContext.tsx +211 -34
  29. package/src/editor/hooks/useAiReplace.ts +4 -3
  30. package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
  31. package/src/editor/hooks/useComponentEditMode.ts +27 -26
  32. package/src/editor/hooks/useEditorMessages.ts +3 -2
  33. package/src/editor/hooks/useElementActions.ts +27 -26
  34. package/src/editor/hooks/useElementSelection.ts +19 -16
  35. package/src/editor/hooks/useFocusManagement.ts +7 -6
  36. package/src/editor/hooks/useGoogleFonts.ts +3 -2
  37. package/src/editor/hooks/useIframeInitializer.ts +6 -3
  38. package/src/editor/hooks/useIframeSetup.ts +4 -3
  39. package/src/editor/hooks/useImageUpload.ts +4 -3
  40. package/src/editor/hooks/useMarqueeSelection.ts +2 -1
  41. package/src/editor/hooks/usePageSettingsManager.ts +24 -23
  42. package/src/editor/hooks/useRichPaste.ts +11 -10
  43. package/src/editor/parts.ts +7 -25
  44. package/src/editor/utils/component-renderer.ts +8 -7
  45. package/src/editor/utils/component-sync.ts +16 -15
  46. package/src/editor/utils/debug.ts +52 -0
  47. package/src/editor/utils/dom-utils.ts +27 -10
  48. package/src/editor/utils/drop-target.ts +135 -0
  49. package/src/editor/utils/eyedropper.ts +3 -1
  50. package/src/editor/utils/figma-export.ts +6 -5
  51. package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
  52. package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
  53. package/src/editor/utils/figma-paste.ts +57 -56
  54. package/src/editor/utils/flex-utils.ts +12 -10
  55. package/src/editor/utils/html-utils.ts +19 -6
  56. package/src/editor/utils/override-apply.ts +3 -2
  57. package/src/editor/utils/override-detection.ts +11 -10
  58. package/src/editor/utils/tailwind-utils.ts +4 -3
  59. package/src/index.ts +5 -0
  60. package/src/io.ts +22 -1
  61. package/src/lib/firebase/css-variables.ts +4 -3
  62. package/src/lib/firebase/storage.ts +3 -2
  63. package/src/styles/skin.css +63 -4
  64. package/src/types/editor.ts +6 -2
  65. package/src/version.ts +9 -0
@@ -29,8 +29,12 @@ import { generateEditableHtml } from "../utils/html-utils";
29
29
  import { setupInlineFormatToolbar } from "../utils/inline-format";
30
30
  import { recalculateViewportUnits } from "../utils/viewport-utils";
31
31
  import { useMultiPageCanvasOptional } from "../contexts/MultiPageCanvasContext";
32
+ import { applyDocumentAttributes } from "./multi-page/PageFramePreview";
32
33
  import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
33
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";
34
38
  import type {
35
39
  DOMTreeNode,
36
40
  DragState,
@@ -124,6 +128,7 @@ export function EditorCanvas() {
124
128
  setShowLayoutHint,
125
129
  viewportWidth,
126
130
  setIframeReady,
131
+ documentAttributes,
127
132
  currentContentId,
128
133
  } = useEditorContext();
129
134
  // 高さの報告先は「本文が載っているページ」(利用側の currentContentId)。
@@ -133,6 +138,17 @@ export function EditorCanvas() {
133
138
 
134
139
  // マルチページモード判定
135
140
  const multiPageCanvas = useMultiPageCanvasOptional();
141
+ // 文書(<html>)に付ける属性(利用側の documentAttributes)。読み込み時と、変わったときに付け替える
142
+ const documentAttributesRef = useRef(documentAttributes);
143
+ documentAttributesRef.current = documentAttributes;
144
+ useEffect(() => {
145
+ try {
146
+ applyDocumentAttributes(iframeRef.current?.contentDocument, documentAttributes);
147
+ } catch {
148
+ /* まだ読めなければ読み込み時に付く */
149
+ }
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [documentAttributes]);
136
152
  const isInMultiPageMode = !!multiPageCanvas?.isEnabled;
137
153
  // ロードハンドラ(deps=[])から最新の値を読むための ref
138
154
  const multiPageCanvasRef = useRef(multiPageCanvas);
@@ -176,6 +192,9 @@ export function EditorCanvas() {
176
192
  const setHtmlRef = useRef(setHtml);
177
193
  const clearHistoryRef = useRef(clearHistory);
178
194
  const editorModeRef = useRef(editorMode);
195
+ // 部品モードか(io に loadParts があるか)。挿入位置の印を出してよいかの判定に使う
196
+ const { partsMode } = useEditorComponents();
197
+ const partsModeRef = useRef(partsMode);
179
198
 
180
199
  // Webページモード用のコンテンツ高さ追跡
181
200
  const [contentHeight, setContentHeight] = useState(WEBPAGE_MIN_HEIGHT);
@@ -195,6 +214,7 @@ export function EditorCanvas() {
195
214
  setHtmlRef.current = setHtml;
196
215
  clearHistoryRef.current = clearHistory;
197
216
  editorModeRef.current = editorMode;
217
+ partsModeRef.current = partsMode;
198
218
  contentHeightRef.current = contentHeight;
199
219
  }, [
200
220
  activeTool,
@@ -205,6 +225,7 @@ export function EditorCanvas() {
205
225
  setHtml,
206
226
  clearHistory,
207
227
  editorMode,
228
+ partsMode,
208
229
  contentHeight,
209
230
  ]);
210
231
 
@@ -454,12 +475,13 @@ export function EditorCanvas() {
454
475
  const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
455
476
  if (!iframeDoc) return;
456
477
 
457
- console.log("[Canvas] iframe loaded, initializing...");
478
+ debugLog("[Canvas] iframe loaded, initializing...");
458
479
 
459
480
  // 最初のペイントより先に現在の倍率を当ててから見せる。
460
481
  // initLayout はフォント待ち等で数百msかかるため、その後に適用すると
461
482
  // 「等倍の巨大な一瞬 → 縮む」のフラッシュがノイズになる
462
483
  applyCanvasZoomDom(iframeDoc, zoomRef.current);
484
+ applyDocumentAttributes(iframeDoc, documentAttributesRef.current);
463
485
  const wrapperEl = iframeDoc.getElementById("artboard-wrapper");
464
486
  if (wrapperEl) wrapperEl.style.visibility = "visible";
465
487
 
@@ -467,7 +489,7 @@ export function EditorCanvas() {
467
489
  // これにより、iframeがリロードされたり状態が変わるたびに
468
490
  // 古いリスナーを削除せずに新しいリスナーが追加される問題を防ぐ
469
491
  if (cleanupFunctionsRef.current.length > 0) {
470
- console.log("[Canvas] Cleaning up", cleanupFunctionsRef.current.length, "previous listeners");
492
+ debugLog("[Canvas] Cleaning up", cleanupFunctionsRef.current.length, "previous listeners");
471
493
  cleanupFunctionsRef.current.forEach(cleanup => {
472
494
  try {
473
495
  cleanup();
@@ -620,7 +642,7 @@ export function EditorCanvas() {
620
642
  if (iframe) iframe.focus();
621
643
  if (iframeDoc.body) {
622
644
  iframeDoc.body.focus();
623
- console.log('[Canvas] Focus restored after background click');
645
+ debugLog('[Canvas] Focus restored after background click');
624
646
  }
625
647
  });
626
648
  }
@@ -653,10 +675,28 @@ export function EditorCanvas() {
653
675
  // 7. ドラッグ&ドロップ(画像アップロード用)
654
676
  let dragCounter = 0;
655
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
+
656
695
  const handleDragEnter = (e: DragEvent) => {
657
696
  e.preventDefault();
658
697
  e.stopPropagation();
659
698
  dragCounter++;
699
+ // 画像のオーバーレイはファイルのときだけ。部品は挿入位置の横棒で示す
660
700
  if (e.dataTransfer?.types.includes("Files")) {
661
701
  window.parent.postMessage({ type: "IFRAME_DRAG_ENTER" }, "*");
662
702
  }
@@ -667,6 +707,17 @@ export function EditorCanvas() {
667
707
  e.stopPropagation();
668
708
  if (e.dataTransfer?.types.includes("Files")) {
669
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);
670
721
  }
671
722
  };
672
723
 
@@ -675,19 +726,33 @@ export function EditorCanvas() {
675
726
  e.stopPropagation();
676
727
  dragCounter--;
677
728
  if (dragCounter === 0) {
729
+ lastTarget = null;
730
+ clearPartDropIndicator(iframeDoc);
678
731
  window.parent.postMessage({ type: "IFRAME_DRAG_LEAVE" }, "*");
679
732
  }
680
733
  };
681
734
 
735
+ // ドラッグが途中で捨てられた(Esc・パネルの上で離した)ときも印を残さない。
736
+ // dragend が起きるのはドラッグ「元」= 親ドキュメントのパネル項目なので、
737
+ // iframe ではなく親の document で受ける
738
+ const handleDragEnd = () => {
739
+ dragCounter = 0;
740
+ lastTarget = null;
741
+ clearPartDropIndicator(iframeDoc);
742
+ };
743
+
682
744
  const handleDrop = (e: DragEvent) => {
683
745
  e.preventDefault();
684
746
  e.stopPropagation();
685
747
  dragCounter = 0;
748
+ lastTarget = null;
749
+ // 印は挿入より先に消す。残っていると履歴と保存HTMLに混ざる
750
+ clearPartDropIndicator(iframeDoc);
686
751
 
687
752
  // コンポーネントのドロップをチェックして親ウィンドウに転送
688
753
  const componentData = e.dataTransfer?.getData('application/x-editor-component');
689
754
  if (componentData) {
690
- console.log('[EditorCanvas] Component drop detected, forwarding to parent:', componentData);
755
+ debugLog('[EditorCanvas] Component drop detected, forwarding to parent:', componentData);
691
756
  window.parent.postMessage(
692
757
  {
693
758
  type: "IFRAME_COMPONENT_DROP",
@@ -742,12 +807,15 @@ export function EditorCanvas() {
742
807
  iframeDoc.addEventListener("dragenter", handleDragEnter);
743
808
  iframeDoc.addEventListener("dragover", handleDragOver);
744
809
  iframeDoc.addEventListener("dragleave", handleDragLeave);
810
+ document.addEventListener("dragend", handleDragEnd);
745
811
  iframeDoc.addEventListener("drop", handleDrop);
746
812
  cleanupFunctions.push(() => {
747
813
  iframeDoc.removeEventListener("dragenter", handleDragEnter);
748
814
  iframeDoc.removeEventListener("dragover", handleDragOver);
749
815
  iframeDoc.removeEventListener("dragleave", handleDragLeave);
816
+ document.removeEventListener("dragend", handleDragEnd);
750
817
  iframeDoc.removeEventListener("drop", handleDrop);
818
+ clearPartDropIndicator(iframeDoc);
751
819
  });
752
820
 
753
821
  // [修正] ここにあった「iframe内のpasteを親へpostMessageして画像を挿入する」
@@ -755,7 +823,7 @@ export function EditorCanvas() {
755
823
  // 直接 paste リスナーを張っており、2系統が同時に走るため1回の貼り付けで
756
824
  // 画像が2枚入っていた。ペーストの処理は FrontendVisualEditor の1本に集約する。
757
825
 
758
- console.log("[Canvas] Drag/drop handlers registered");
826
+ debugLog("[Canvas] Drag/drop handlers registered");
759
827
 
760
828
  // 9. レイアウトモードに応じた初期化とDOMツリー構築
761
829
  const initLayout = async () => {
@@ -804,17 +872,17 @@ export function EditorCanvas() {
804
872
  // 別の幅で崩れ、デザインツールへの取り込みで意味を失う。
805
873
  if (editorModeRef.current !== "webpage") {
806
874
  const converted = convertToAbsolutePositioning(iframeDoc);
807
- console.log('[Canvas] 絶対配置へ一括変換:', converted, '要素');
875
+ debugLog('[Canvas] 絶対配置へ一括変換:', converted, '要素');
808
876
  }
809
877
 
810
878
  // ここまでが「開いただけ」の姿。以後の変化=ユーザーの編集、と
811
879
  // 切り分けるための指紋を全要素に刻む(保存時の変換ノイズ巻き戻しに使う)
812
880
  const stamped = stampBaselines(iframeDoc);
813
- console.log('[Canvas] 書き戻し用ベースライン:', stamped, '要素');
881
+ debugLog('[Canvas] 書き戻し用ベースライン:', stamped, '要素');
814
882
 
815
883
  const artboardEl = iframeDoc.getElementById("artboard");
816
884
  const tree = buildDomTree(iframeDoc, artboardEl || undefined);
817
- console.log("[Canvas] Built DOM tree with", tree.length, "root nodes");
885
+ debugLog("[Canvas] Built DOM tree with", tree.length, "root nodes");
818
886
 
819
887
  setDomTreeRef.current(tree);
820
888
  const firstLevelIds = new Set<string>(
@@ -830,7 +898,7 @@ export function EditorCanvas() {
830
898
  // 変換後の姿を「変更なし」の基準にする。これをしないと開いただけで
831
899
  // 未保存扱いになり、サムネイル移動のたびに確認ダイアログが出る
832
900
  setOriginalHtml(initializedHtml);
833
- console.log(
901
+ debugLog(
834
902
  "[Canvas] History reset with initialized HTML from artboard"
835
903
  );
836
904
 
@@ -865,7 +933,7 @@ export function EditorCanvas() {
865
933
  resizeObserver.observe(artboardEl);
866
934
  cleanupFunctions.push(() => resizeObserver.disconnect());
867
935
 
868
- console.log(
936
+ debugLog(
869
937
  "[Canvas] Webpage mode: content height tracking initialized on artboard"
870
938
  );
871
939
  }
@@ -874,11 +942,11 @@ export function EditorCanvas() {
874
942
  // 重要: クリーンアップ関数をrefに保存して、
875
943
  // 次のiframeロード時やアンマウント時にクリーンアップできるようにする
876
944
  cleanupFunctionsRef.current = cleanupFunctions;
877
- console.log("[Canvas] Registered", cleanupFunctions.length, "cleanup functions");
945
+ debugLog("[Canvas] Registered", cleanupFunctions.length, "cleanup functions");
878
946
 
879
947
  // iframeの読み込み完了をマーク(CSS変数注入等のトリガー用)
880
948
  setIframeReady(true);
881
- console.log("[Canvas] iframe ready, setIframeReady(true) called");
949
+ debugLog("[Canvas] iframe ready, setIframeReady(true) called");
882
950
 
883
951
  // eslint-disable-next-line react-hooks/exhaustive-deps
884
952
  }, []);
@@ -910,7 +978,7 @@ export function EditorCanvas() {
910
978
  // 重要: アンマウント時にすべてのイベントリスナーをクリーンアップ
911
979
  // これにより、メモリリークとゴーストリスナーを防ぐ
912
980
  if (cleanupFunctionsRef.current.length > 0) {
913
- console.log("[Canvas] Unmounting: cleaning up", cleanupFunctionsRef.current.length, "listeners");
981
+ debugLog("[Canvas] Unmounting: cleaning up", cleanupFunctionsRef.current.length, "listeners");
914
982
  cleanupFunctionsRef.current.forEach(cleanup => {
915
983
  try {
916
984
  cleanup();
@@ -932,6 +1000,7 @@ export function EditorCanvas() {
932
1000
  "move-mode",
933
1001
  "draw-mode",
934
1002
  "text-mode",
1003
+ "comment-mode",
935
1004
  "tool-rectangle",
936
1005
  "tool-ellipse",
937
1006
  "tool-line",
@@ -961,6 +1030,9 @@ export function EditorCanvas() {
961
1030
  iframeDoc.body.classList.add("draw-mode", `tool-${activeTool}`);
962
1031
  } else if (activeTool === "text") {
963
1032
  iframeDoc.body.classList.add("text-mode", "tool-text");
1033
+ } else if (activeTool === "comment") {
1034
+ // コメントの範囲指定。要素の選択・ホバーを止め、紙面のドラッグを範囲にする
1035
+ iframeDoc.body.classList.add("comment-mode");
964
1036
  }
965
1037
  }
966
1038
  }, [activeTool, getIframeDoc]);
@@ -987,7 +1059,7 @@ export function EditorCanvas() {
987
1059
  refreshSelectionOverlay(iframeDoc);
988
1060
  });
989
1061
 
990
- console.log(
1062
+ debugLog(
991
1063
  `[Canvas] Applied zoom: ${zoom}%, wrapper transform: scale(${currentScale})`
992
1064
  );
993
1065
  }, [zoom, editorMode, contentHeight, viewportWidth, getIframeDoc]);
@@ -1053,7 +1125,7 @@ export function EditorCanvas() {
1053
1125
  : SLIDE_HEIGHT;
1054
1126
  recalculateViewportUnits(iframeDoc, canvasWidth, canvasHeight);
1055
1127
 
1056
- 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}`);
1057
1129
  }, [viewportWidth, editorMode, getIframeDoc, calculateFitZoom, setFitZoom]);
1058
1130
 
1059
1131
  return (
@@ -5,6 +5,7 @@
5
5
  * 右クリックで要素操作メニューを表示
6
6
  */
7
7
 
8
+ import { createPortal } from 'react-dom';
8
9
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
9
10
  import { useEditorContext } from '../EditorContext';
10
11
  import {
@@ -201,11 +202,19 @@ export function EditorContextMenu({
201
202
  }
202
203
  };
203
204
 
205
+ // 見えない起点(右クリックの位置)。position は画面(viewport)の座標なので、document.body に出して
206
+ // position: fixed の基準を viewport にする。エディタの木の中に置くと、利用側がエディタを transform の付いた
207
+ // 器に入れているとき(構成ラフの殻の .wf-editor-main は translateZ(0))、fixed の基準がその器になり、
208
+ // メニューが右クリックの位置から器の左端ぶん(左パネルの幅 248px)ずれていた(実測)
209
+ const anchor = (
210
+ <DropdownMenuTrigger asChild>
211
+ <button tabIndex={-1} aria-label="要素の操作" style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1, opacity: 0, pointerEvents: 'none' }} />
212
+ </DropdownMenuTrigger>
213
+ );
214
+
204
215
  return (
205
216
  <DropdownMenu open onOpenChange={(open) => { if (!open) onClose(); }}>
206
- <DropdownMenuTrigger asChild>
207
- <button tabIndex={-1} aria-label="要素の操作" style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1, opacity: 0, pointerEvents: 'none' }} />
208
- </DropdownMenuTrigger>
217
+ {typeof document === 'undefined' ? anchor : createPortal(anchor, document.body)}
209
218
  <DropdownMenuContent align="start" sideOffset={0} collisionPadding={8} className="w-60"
210
219
  onCloseAutoFocus={(e) => { e.preventDefault(); restoreFocus(); }}>
211
220
  {/* クリップボード操作 */}
@@ -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",
@@ -17,8 +17,10 @@ import {
17
17
  ChevronRight,
18
18
  ChevronDown,
19
19
  Presentation,
20
+ ArrowUpRight,
20
21
  } from 'lucide-react';
21
22
  import { useEditorContext, type ContentListItem } from '../EditorContext';
23
+ import { computeContentDepths } from '../contexts/EditorArtboardContext';
22
24
  import { useElementActions } from '../hooks/useElementActions';
23
25
  import { useResizablePanel } from '../hooks/useResizablePanel';
24
26
  import { refreshSelectionOverlay, buildDomTree } from '../utils/dom-utils';
@@ -297,10 +299,13 @@ return (
297
299
  const ContentListItemComponent = memo(function ContentListItemComponent({
298
300
  slide,
299
301
  isCurrent,
302
+ depth = 0,
300
303
  onClick,
301
304
  }: {
302
305
  slide: ContentListItem;
303
306
  isCurrent: boolean;
307
+ /** 階層の深さ(parentId から)。段を付ける */
308
+ depth?: number;
304
309
  onClick: () => void;
305
310
  }) {
306
311
  return (
@@ -313,6 +318,8 @@ const ContentListItemComponent = memo(function ContentListItemComponent({
313
318
  : 'text-gray-400 hover:bg-[#2c2c2c] border-l-2 border-transparent'
314
319
  }
315
320
  `}
321
+ style={depth > 0 ? { paddingLeft: 8 + depth * 14 } : undefined}
322
+ data-depth={depth}
316
323
  disabled={isCurrent}
317
324
  title={slide.title || `スライド ${slide.order}`}
318
325
  >
@@ -347,12 +354,14 @@ const ContentList = memo(function ContentList({
347
354
  currentContentId: string | null;
348
355
  onContentClick: (slideId: string) => void;
349
356
  }) {
357
+ const depths = useMemo(() => computeContentDepths(slides), [slides]);
350
358
  return (
351
359
  <>
352
360
  {slides.map((slide) => (
353
361
  <ContentListItemComponent
354
362
  key={slide.id}
355
363
  slide={slide}
364
+ depth={depths.get(slide.id) ?? 0}
356
365
  isCurrent={slide.id === currentContentId}
357
366
  onClick={() => onContentClick(slide.id)}
358
367
  />
@@ -387,6 +396,8 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
387
396
  } = useEditorContext();
388
397
 
389
398
  const [contentListExpanded, setContentListExpanded] = useState(true);
399
+ // 階層(parentId)の深さ。一覧に段を付ける
400
+ const slideDepths = useMemo(() => computeContentDepths(slides), [slides]);
390
401
  // 各スライドのツリー展開状態(スライドID → 展開状態)
391
402
  const [slideTreeExpanded, setSlideTreeExpanded] = useState<Map<string, boolean>>(new Map());
392
403
 
@@ -1132,6 +1143,7 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1132
1143
  {!hideSlideList && slides.length > 0 ? (
1133
1144
  slides.map((slide, index) => {
1134
1145
  const isActive = slide.id === currentContentId;
1146
+ const depth = slideDepths.get(slide.id) ?? 0;
1135
1147
  const isTreeExpanded = slideTreeExpanded.get(slide.id) ?? isActive;
1136
1148
  const slideTree = searchQuery ?
1137
1149
  // 検索時は該当スライドのフィルタ済みツリーを表示
@@ -1139,14 +1151,15 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1139
1151
  getSlideTree(slide.id);
1140
1152
 
1141
1153
  return (
1142
- <div key={slide.id} className="mb-1">
1143
- {/* スライドヘッダー */}
1154
+ <div key={slide.id} className="mb-1" data-depth={depth}>
1155
+ {/* スライドヘッダー(階層があれば深さぶん段を付ける) */}
1144
1156
  <div
1145
1157
  className={`flex items-center gap-1 py-1 px-1 rounded cursor-pointer text-xs transition-colors ${
1146
1158
  isActive
1147
1159
  ? 'bg-[#0d99ff]/20 text-[#4fb8ff] border-l-2 border-[#0d99ff]'
1148
1160
  : 'text-gray-300 hover:bg-[#444444] border-l-2 border-transparent'
1149
1161
  }`}
1162
+ style={depth > 0 ? { marginLeft: depth * 14 } : undefined}
1150
1163
  onClick={() => handleContentClick(slide.id)}
1151
1164
  >
1152
1165
  {/* 展開/折りたたみボタン */}
@@ -1175,6 +1188,19 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1175
1188
  編集中
1176
1189
  </span>
1177
1190
  )}
1191
+ {slide.href && (
1192
+ <a
1193
+ href={slide.href}
1194
+ target="_blank"
1195
+ rel="noreferrer"
1196
+ className="ed-icon-button !h-5 !w-5 flex-shrink-0 opacity-60 hover:opacity-100"
1197
+ title="別タブで開く(編集しない表示)"
1198
+ aria-label={`${slide.title || '無題'} を別タブで開く`}
1199
+ onClick={(e) => e.stopPropagation()}
1200
+ >
1201
+ <ArrowUpRight className="w-3 h-3" />
1202
+ </a>
1203
+ )}
1178
1204
  </div>
1179
1205
 
1180
1206
  {/* スライドのDOMツリー */}
@@ -848,38 +848,52 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
848
848
  return getInstanceByDomId(selectedElement.id);
849
849
  }, [selectedElement?.id, getInstanceByDomId]);
850
850
 
851
- // 部品(data-part)のインスタンスか(DOM の属性で判定)
852
- const selectedPart = useMemo(() => {
851
+ // 部品(data-part)のインスタンスか(DOM の属性で判定)。ルートを選んでいても、中のスロットを選んでいても
852
+ // 「どの部品の中か」を出す(中を編集しているときに、部品だと分からずに直してしまうのを防ぐ)
853
+ const selectedPartRoot = useMemo(() => {
853
854
  if (!selectedElement?.id) return null;
854
855
  const doc = getIframeDoc();
855
- return partInfoOf(doc?.querySelector(`[data-element-id="${selectedElement.id}"]`));
856
+ const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
857
+ if (!el) return null;
858
+ const root = el.hasAttribute("data-part") ? el : (el.parentElement?.closest("[data-part]") as HTMLElement | null) ?? null;
859
+ if (!root) return null;
860
+ const slot = root === el ? null : (el.closest("[data-slot]") as HTMLElement | null);
861
+ return { root, isRoot: root === el, slotName: slot && root.contains(slot) && slot !== root ? slot.getAttribute("data-slot") : null };
856
862
  }, [selectedElement, getIframeDoc]);
863
+ const selectedPart = useMemo(() => (selectedPartRoot ? partInfoOf(selectedPartRoot.root) : null), [selectedPartRoot]);
857
864
  const selectedPartLabel = useMemo(() => {
858
865
  if (!selectedPart) return "";
859
866
  const def = getPartDef(selectedPart.id);
860
867
  return `${def?.name || selectedPart.id} v${selectedPart.version}`;
861
868
  }, [selectedPart, getPartDef]);
862
869
  const handleUpdatePartFromPanel = useCallback(async () => {
863
- if (!selectedElement?.id) return;
864
- const doc = getIframeDoc();
865
- const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
866
- if (!el) return;
867
- const def = await updatePartFromElement(el);
870
+ const root = selectedPartRoot?.root;
871
+ if (!root) return;
872
+ const def = await updatePartFromElement(root);
868
873
  if (def) notifyIframeChange(true);
869
- }, [selectedElement?.id, getIframeDoc, updatePartFromElement, notifyIframeChange]);
874
+ }, [selectedPartRoot, updatePartFromElement, notifyIframeChange]);
870
875
  const handleDetachPartFromPanel = useCallback(() => {
871
- if (!selectedElement?.id) return;
872
876
  const doc = getIframeDoc();
873
- const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
874
- if (!doc || !el || !partInfoOf(el)) return;
875
- detachPart(el);
876
- unlockPartDescendants(el);
877
+ const root = selectedPartRoot?.root;
878
+ if (!doc || !root || !partInfoOf(root)) return;
879
+ detachPart(root);
880
+ unlockPartDescendants(root);
877
881
  notifyIframeChange(true);
878
882
  void import("../utils/style-utils").then(({ extractElementInfo }) => {
879
- const info = extractElementInfo(el, doc);
883
+ const info = extractElementInfo(root, doc);
880
884
  if (info) setSelectedElement(info);
881
885
  });
882
- }, [selectedElement?.id, getIframeDoc, notifyIframeChange, setSelectedElement]);
886
+ }, [selectedPartRoot, getIframeDoc, notifyIframeChange, setSelectedElement]);
887
+ // 中のスロットを選んでいるとき: 部品のルートを選び直す(移動・削除・「この姿で更新」の対象を部品全体にする)
888
+ const handleSelectPartRoot = useCallback(() => {
889
+ const doc = getIframeDoc();
890
+ const root = selectedPartRoot?.root;
891
+ if (!doc || !root) return;
892
+ void import("../utils/style-utils").then(({ extractElementInfo }) => {
893
+ const info = extractElementInfo(root, doc);
894
+ if (info) setSelectedElement(info);
895
+ });
896
+ }, [selectedPartRoot, getIframeDoc, setSelectedElement]);
883
897
 
884
898
  const selectedMaster = useMemo(() => {
885
899
  if (!selectedInstance) return null;
@@ -1287,29 +1301,44 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1287
1301
  />
1288
1302
  )}
1289
1303
 
1290
- {/* 部品(data-part)のインスタンス */}
1291
- {selectedPart && (
1292
- <div className="space-y-2 rounded border border-[#444444] bg-[#2a2a2a] p-2 text-xs text-gray-300">
1304
+ {/* 部品(data-part)のインスタンス。紙面の選択枠も紫になる(部品だと一目で分かるように) */}
1305
+ {selectedPart && selectedPartRoot && (
1306
+ <div className="ed-part-card space-y-2 rounded p-2 text-xs" data-part-card={selectedPart.id} data-part-inside={selectedPartRoot.isRoot ? undefined : "true"}>
1293
1307
  <div className="flex items-center gap-2">
1294
- <PartIcon className="h-3.5 w-3.5 text-purple-400" />
1295
- <span className="truncate font-medium">部品: {selectedPartLabel}</span>
1308
+ <PartIcon className="h-3.5 w-3.5 ed-part-accent" />
1309
+ <span className="truncate font-medium">部品 {selectedPartLabel}</span>
1296
1310
  </div>
1297
- <p className="text-[10px] leading-snug text-gray-500">
1298
- スロット(data-slot)の中だけ編集できます。「この姿で部品を更新」で定義が変わり、他ページへの反映は利用側(savePart)が行います。
1311
+ <p className="text-[10px] leading-snug ed-part-note">
1312
+ {selectedPartRoot.isRoot
1313
+ ? "スロット(点線の枠)の中だけ編集できます。外側を直したいときは切り離してください。"
1314
+ : `スロット「${selectedPartRoot.slotName ?? "—"}」の中を編集しています。ここの変更はこのページだけに残ります(定義には入りません)。`}
1299
1315
  </p>
1300
- <div className="flex gap-1">
1316
+ <div className="flex flex-wrap gap-1">
1317
+ {!selectedPartRoot.isRoot && (
1318
+ <Button
1319
+ size="sm"
1320
+ variant="outline"
1321
+ className="h-6 px-2 text-[10px]"
1322
+ title="部品のルート要素を選ぶ(移動・削除・更新の対象を部品全体にする)"
1323
+ onClick={handleSelectPartRoot}
1324
+ >
1325
+ 部品全体を選ぶ
1326
+ </Button>
1327
+ )}
1301
1328
  <Button
1302
1329
  size="sm"
1303
1330
  variant="outline"
1304
1331
  className="h-6 px-2 text-[10px]"
1332
+ title="このインスタンスの今の姿を定義にする(版 +1)。他のページの同じ部品もその場で描き直されます"
1305
1333
  onClick={() => void handleUpdatePartFromPanel()}
1306
1334
  >
1307
- この姿で更新
1335
+ この姿で更新(全ページ)
1308
1336
  </Button>
1309
1337
  <Button
1310
1338
  size="sm"
1311
1339
  variant="outline"
1312
1340
  className="h-6 px-2 text-[10px]"
1341
+ title="部品から切り離して普通の HTML にする(以後、定義の更新が及ばない)"
1313
1342
  onClick={handleDetachPartFromPanel}
1314
1343
  >
1315
1344
  切り離す
@@ -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,