@growgroup/visual-editor 0.3.0 → 0.3.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.
@@ -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
  {/* クリップボード操作 */}
@@ -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
  切り離す
@@ -39,6 +39,7 @@ import { EditorCanvas } from '../EditorCanvas';
39
39
  import { PageFramePreview } from './PageFramePreview';
40
40
  import { CanvasRulers } from './CanvasRulers';
41
41
  import { cn } from '../../../lib/utils';
42
+ import { ArrowUpRight } from 'lucide-react';
42
43
 
43
44
  /** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
44
45
  const LABEL_HEIGHT = 20;
@@ -108,23 +109,39 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
108
109
  const showLabel = labelsVisible && sw >= 28;
109
110
  return (
110
111
  <div className="absolute left-0 top-0" data-frame-chrome={page.id} style={{ transform: `translate(${sx}px, ${sy}px)` }}>
111
- {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ) */}
112
+ {/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
112
113
  {showLabel && (
113
- <button
114
- type="button"
115
- data-frame-label={page.id}
116
- className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
117
- style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}
118
- title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
119
- onMouseDown={(e) => e.stopPropagation()}
120
- onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
121
- onDoubleClick={() => onZoomTo(page.id)}
122
- >
123
- <span className="ed-frame-label-index">{page.index + 1}</span>
124
- <span className="ed-frame-label-title">{page.title || page.id}</span>
125
- {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
126
- {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
127
- </button>
114
+ <div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: Math.max(28, sw) }}>
115
+ <button
116
+ type="button"
117
+ data-frame-label={page.id}
118
+ className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
119
+ title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
120
+ onMouseDown={(e) => e.stopPropagation()}
121
+ onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
122
+ onDoubleClick={() => onZoomTo(page.id)}
123
+ >
124
+ <span className="ed-frame-label-index">{page.index + 1}</span>
125
+ <span className="ed-frame-label-title">{page.title || page.id}</span>
126
+ {page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
127
+ {isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
128
+ </button>
129
+ {page.href && sw >= 96 && (
130
+ <a
131
+ className="ed-frame-label-link"
132
+ data-frame-link={page.id}
133
+ href={page.href}
134
+ target="_blank"
135
+ rel="noreferrer"
136
+ title="別タブで開く(編集しない表示)"
137
+ aria-label={`${page.title || page.id} を別タブで開く`}
138
+ onMouseDown={(e) => e.stopPropagation()}
139
+ onClick={(e) => e.stopPropagation()}
140
+ >
141
+ <ArrowUpRight aria-hidden="true" />
142
+ </a>
143
+ )}
144
+ </div>
128
145
  )}
129
146
  {/* リング(選択中 = 実線、ホバー = 淡い実線) */}
130
147
  {(isSelected || isHover) && (
@@ -137,6 +154,35 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
137
154
  );
138
155
  });
139
156
 
157
+ // ------------------------------------------------------------
158
+ // 階層の線(親の下端の中央 → 子の上端の中央。転写層の中、紙面の座標)
159
+ // ------------------------------------------------------------
160
+
161
+ const TreeConnectors = memo(function TreeConnectors({ pages, gapY }: { pages: PageFrameLayout[]; gapY: number }) {
162
+ const byId = useMemo(() => new Map(pages.map((p) => [p.id, p] as const)), [pages]);
163
+ const d = useMemo(() => {
164
+ const parts: string[] = [];
165
+ pages.forEach((child) => {
166
+ const parent = child.parentId ? byId.get(child.parentId) : undefined;
167
+ if (!parent) return;
168
+ const x1 = parent.position.x + parent.size.width / 2;
169
+ const y1 = parent.position.y + parent.size.height;
170
+ const x2 = child.position.x + child.size.width / 2;
171
+ const y2 = child.position.y;
172
+ // 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
173
+ const ym = y2 - gapY / 2;
174
+ parts.push(`M${x1} ${y1} V${ym} H${x2} V${y2}`);
175
+ });
176
+ return parts.join(' ');
177
+ }, [pages, byId, gapY]);
178
+ if (!d) return null;
179
+ return (
180
+ <svg className="ed-frame-connectors" data-canvas-connectors aria-hidden="true" width={1} height={1} style={{ overflow: 'visible' }}>
181
+ <path d={d} fill="none" vectorEffect="non-scaling-stroke" />
182
+ </svg>
183
+ );
184
+ });
185
+
140
186
  // ------------------------------------------------------------
141
187
  // キャンバス
142
188
  // ------------------------------------------------------------
@@ -146,6 +192,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
146
192
  const {
147
193
  pages,
148
194
  bounds,
195
+ layout,
149
196
  registerContainer,
150
197
  activatePage,
151
198
  activatingPageId,
@@ -270,6 +317,9 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
270
317
  willChange: isInteracting ? 'transform' : undefined,
271
318
  }}
272
319
  >
320
+ {/* 階層の線(フレームの下に描く。線の太さは倍率に関係なく一定 = non-scaling-stroke) */}
321
+ {layout === 'tree' && <TreeConnectors pages={pages} gapY={FRAME_GAP[canvas.editorMode].y} />}
322
+
273
323
  {pages.map((page) => (
274
324
  <FrameShell
275
325
  key={page.id}
@@ -9,12 +9,29 @@
9
9
  * フォントや画像の読み込みで高さが変わっても追従する
10
10
  * - 画面から遠いものは描かない(IntersectionObserver)。webpage は高さの実測が
11
11
  * レイアウトに要るので、順番に少しずつ先読みする(eagerMountCount)
12
+ * - iframe に sandbox は付けない。script は本文から外してある(generatePreviewHtml)。
13
+ * sandbox で script を禁じると、ページへ script を差し込むブラウザ拡張(React DevTools 等)が
14
+ * 1 枚ごとに「Blocked script execution in 'about:srcdoc'」をコンソールへ出す(実測 26 枚 = 26 件)
15
+ * - 本文が外から変わった(revision / invalidatePages)ときは、次の本文が来るまで前の姿を出したまま読み直す
16
+ * - 文書の属性(documentAttributes)は読み直さずに <html> へ付け替える
12
17
  */
13
18
 
14
19
  import { memo, useEffect, useRef, useState } from 'react';
15
20
  import { useMultiPageCanvas, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
21
+ import type { DocumentAttributes } from '../../contexts/EditorArtboardContext';
16
22
  import { generatePreviewHtml } from '../../utils/html-utils';
17
23
 
24
+ /** 文書の <html> に属性を付ける(null / undefined は外す)。エディタ本体(EditorCanvas)も同じ関数を使う */
25
+ export function applyDocumentAttributes(doc: Document | null | undefined, attrs: DocumentAttributes): void {
26
+ const root = doc?.documentElement;
27
+ if (!root) return;
28
+ for (const [name, value] of Object.entries(attrs)) {
29
+ if (!/^[A-Za-z_:][\w:.-]*$/.test(name)) continue;
30
+ if (value == null) root.removeAttribute(name);
31
+ else if (root.getAttribute(name) !== value) root.setAttribute(name, value);
32
+ }
33
+ }
34
+
18
35
  interface PageFramePreviewProps {
19
36
  page: PageFrameLayout;
20
37
  /** 画面上の判定に使う容器(IntersectionObserver の root) */
@@ -22,7 +39,9 @@ interface PageFramePreviewProps {
22
39
  }
23
40
 
24
41
  export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
25
- const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot } = useMultiPageCanvas();
42
+ const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
43
+ const documentAttributesRef = useRef(documentAttributes);
44
+ documentAttributesRef.current = documentAttributes;
26
45
  const hostRef = useRef<HTMLDivElement>(null);
27
46
  const iframeRef = useRef<HTMLIFrameElement>(null);
28
47
  const [nearViewport, setNearViewport] = useState(false);
@@ -92,6 +111,7 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
92
111
  const onLoad = () => {
93
112
  if (disposed) return;
94
113
  setLoaded(true);
114
+ applyDocumentAttributes(iframe.contentDocument, documentAttributesRef.current);
95
115
  measure();
96
116
  try {
97
117
  const artboard = iframe.contentDocument?.getElementById('artboard');
@@ -113,6 +133,16 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
113
133
  };
114
134
  }, [doc, page.id, setPageHeight]);
115
135
 
136
+ // 文書の属性が変わったら、読み直さずに <html> へ付け替える(読み込み前なら onLoad が付ける)
137
+ useEffect(() => {
138
+ if (!loaded) return;
139
+ try {
140
+ applyDocumentAttributes(iframeRef.current?.contentDocument, documentAttributes);
141
+ } catch {
142
+ /* 読めなければ次の読み込みで付く */
143
+ }
144
+ }, [documentAttributes, loaded]);
145
+
116
146
  // 読み終わった(または読めない)ら枠を返して次へ。本文が来ない場合の安全弁つき
117
147
  useEffect(() => {
118
148
  if (!granted) return;
@@ -136,7 +166,6 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
136
166
  ref={iframeRef}
137
167
  title={page.title || page.id}
138
168
  srcDoc={doc}
139
- sandbox="allow-same-origin"
140
169
  tabIndex={-1}
141
170
  aria-hidden="true"
142
171
  className="block border-0"
@@ -144,6 +173,10 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
144
173
  width: page.size.width,
145
174
  height: page.size.height,
146
175
  pointerEvents: 'none',
176
+ // ズームで重くならないように。iframe の中身は外側の transform で拡大縮小されるが、
177
+ // will-change が無いと倍率が変わるたびに 26 枚の紙面がラスタライズし直され、
178
+ // 縮小中に 100〜340ms のフレームが混じる(実測)。付けるとラスタが保たれ p95 117ms → 24ms
179
+ willChange: 'transform',
147
180
  }}
148
181
  />
149
182
  ) : (
@@ -375,6 +375,34 @@ export const EDITOR_IFRAME_STYLES = `
375
375
  .selection-box.selection-member .selection-outline {
376
376
  box-shadow: 0 0 0 calc(1px * var(--ov)) rgba(13, 153, 255, 0.75);
377
377
  }
378
+ /*
379
+ * 部品(data-part)のインスタンスは紫(Figma のコンポーネントと同じ色)。
380
+ * ルートを選んだら枠・ハンドル・サイズラベルが紫、中のスロットを選んだら枠は青のまま
381
+ * 「部品の中」の札だけ出す。札はパンくず(上 -22px)の左上に重ならないよう、その上に置く
382
+ */
383
+ .selection-box.selection-part .selection-outline { box-shadow: 0 0 0 var(--outline-w) #7b61ff; }
384
+ .selection-box.selection-part .resize-handle::after { border-color: #7b61ff; }
385
+ .selection-box.selection-part .size-label { background: #7b61ff; }
386
+ .selection-box.selection-member.selection-part .selection-outline { box-shadow: 0 0 0 calc(1px * var(--ov)) rgba(123, 97, 255, 0.8); }
387
+ .selection-box .part-badge {
388
+ position: absolute;
389
+ top: calc(-44px * var(--ov));
390
+ left: 0;
391
+ padding: calc(2px * var(--ov)) calc(6px * var(--ov));
392
+ border-radius: calc(3px * var(--ov));
393
+ background: #7b61ff;
394
+ color: #fff;
395
+ font-size: calc(10px * var(--ov));
396
+ line-height: 1.4;
397
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', sans-serif;
398
+ white-space: nowrap;
399
+ pointer-events: none;
400
+ z-index: 40;
401
+ }
402
+ .selection-box.selection-in-part .part-badge { background: rgba(123, 97, 255, 0.85); }
403
+ /* 選んでいる部品のスロット(編集できる範囲)は点線。中を選んでいるときは部品の外周を薄い点線で示す */
404
+ [data-part].selected [data-slot] { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.75); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
405
+ [data-part]:not(.selected):has(.selected) { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.55); outline-offset: calc(2px * var(--overlay-scale, 1)); }
378
406
  .size-label {
379
407
  position: absolute;
380
408
  bottom: calc(-22px * var(--ov));
@@ -14,7 +14,7 @@
14
14
  * - contentList: コンテンツリスト(スライド、ページ等)
15
15
  */
16
16
 
17
- import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
17
+ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
18
18
  import type { DOMTreeNode } from '../types';
19
19
 
20
20
  /**
@@ -30,6 +30,48 @@ export interface ContentListItem {
30
30
  /** 表示順序(スライド番号、ページ番号等) */
31
31
  order: number;
32
32
  thumbnailHtml?: string;
33
+ /**
34
+ * 親ページの id(サイトマップの階層)。渡すと、マルチフレームのキャンバスが
35
+ * フレームを階層のツリー(親の下に子を並べる)で配置し、左パネルの一覧も段を付ける。
36
+ * 無ければ(全ページ null / undefined)従来どおり行で折り返す
37
+ */
38
+ parentId?: string | null;
39
+ /** 編集しない表示(ページそのもの・ビューア)の URL。渡すとフレーム名と一覧に「別タブで開く」が付く */
40
+ href?: string;
41
+ /**
42
+ * 本文の版。利用側でファイルが外から変わったとき(部品の一括反映・CLI・他の人の編集)に
43
+ * 数を進めると、キャンバスの見るだけの紙面がそのページを読み直す。編集中のページには効かない
44
+ * (編集中の本文は上書きしない)
45
+ */
46
+ revision?: number;
47
+ }
48
+
49
+ /**
50
+ * 文書に付ける属性(`<html>` の属性)。生きているエディタと見るだけの紙面の両方の文書に、
51
+ * 読み直さずに反映する。利用側の CSS が `html[data-…]` で切り替える表示(構成ラフの
52
+ * 注釈カラムの表示・非表示など)に使う。値が null / undefined の属性は外す
53
+ */
54
+ export type DocumentAttributes = Record<string, string | null | undefined>;
55
+
56
+ /** contentList の parentId から各ページの深さ(ルート = 0)を求める。親が一覧に無い・循環しているものはルート扱い */
57
+ export function computeContentDepths(list: readonly ContentListItem[]): Map<string, number> {
58
+ const byId = new Map(list.map((c) => [c.id, c] as const));
59
+ const depths = new Map<string, number>();
60
+ const depthOf = (id: string, seen: Set<string>): number => {
61
+ const cached = depths.get(id);
62
+ if (cached != null) return cached;
63
+ const item = byId.get(id);
64
+ const parent = item?.parentId;
65
+ let d = 0;
66
+ if (parent && parent !== id && byId.has(parent) && !seen.has(parent)) {
67
+ seen.add(id);
68
+ d = depthOf(parent, seen) + 1;
69
+ }
70
+ depths.set(id, d);
71
+ return d;
72
+ };
73
+ list.forEach((c) => depthOf(c.id, new Set()));
74
+ return depths;
33
75
  }
34
76
 
35
77
  // アートボード(スライド/ページ)ごとの状態
@@ -55,6 +97,8 @@ export interface EditorArtboardContextValue {
55
97
  contentList: ContentListItem[];
56
98
  currentContentId: string | null;
57
99
  onContentChange: ContentChangeHandler | null;
100
+ /** 文書(`<html>`)に付ける属性。エディタと見るだけの紙面の両方に、読み直さずに反映する */
101
+ documentAttributes: DocumentAttributes;
58
102
 
59
103
  // Deprecated aliases
60
104
  /** @deprecated Use contentList instead */
@@ -79,6 +123,7 @@ interface EditorArtboardProviderProps {
79
123
  contentList?: ContentListItem[];
80
124
  currentContentId?: string;
81
125
  onContentChange?: ContentChangeHandler;
126
+ documentAttributes?: DocumentAttributes;
82
127
  // 状態同期用のコールバック(ファサードで設定)
83
128
  onArtboardSwitch?: (
84
129
  newId: string,
@@ -110,6 +155,7 @@ export function EditorArtboardProvider({
110
155
  contentList: contentListProp,
111
156
  currentContentId: currentContentIdProp,
112
157
  onContentChange: onContentChangeProp,
158
+ documentAttributes: documentAttributesProp,
113
159
  slides: slidesProp,
114
160
  currentSlideId: currentSlideIdProp,
115
161
  onSlideChange: onSlideChangeProp,
@@ -119,6 +165,10 @@ export function EditorArtboardProvider({
119
165
  const contentList = contentListProp ?? slidesProp ?? [];
120
166
  const currentContentId = currentContentIdProp ?? currentSlideIdProp ?? null;
121
167
  const onContentChange = onContentChangeProp ?? onSlideChangeProp ?? null;
168
+ // 属性は中身で比べる(利用側が毎描画で新しいオブジェクトを渡しても、購読側を描き直さない)
169
+ const documentAttributesKey = JSON.stringify(documentAttributesProp ?? {});
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ const documentAttributes = useMemo<DocumentAttributes>(() => ({ ...(documentAttributesProp ?? {}) }), [documentAttributesKey]);
122
172
 
123
173
  // Deprecated aliases
124
174
  const slides = contentList;
@@ -221,6 +271,7 @@ export function EditorArtboardProvider({
221
271
  contentList,
222
272
  currentContentId,
223
273
  onContentChange,
274
+ documentAttributes,
224
275
  // Deprecated
225
276
  slides,
226
277
  currentSlideId,
@@ -45,6 +45,7 @@ import {
45
45
  } from '../../lib/firebase/editor-components';
46
46
  import { io } from '../../io';
47
47
  import type { EditorPartDef, EditorPartsLibrary } from '../../io';
48
+ import { useMultiPageCanvasOptional } from './MultiPageCanvasContext';
48
49
  import {
49
50
  materializePart,
50
51
  partDefFromElement,
@@ -474,6 +475,13 @@ export function EditorComponentsProvider({
474
475
  websiteId,
475
476
  pageId,
476
477
  }: EditorComponentsProviderProps) {
478
+ // マルチフレームのキャンバス(あれば)。部品の定義を更新したあと、他ページの紙面を読み直させる。
479
+ // ref で持つ: キャンバスの値は高さの実測のたびに変わるので、コールバックの依存に入れない
480
+ const canvas = useMultiPageCanvasOptional();
481
+ const canvasRef = useRef(canvas);
482
+ canvasRef.current = canvas;
483
+ const pageIdRef = useRef(pageId);
484
+ pageIdRef.current = pageId;
477
485
  // State
478
486
  const [masterComponents, setMasterComponents] = useState<
479
487
  Map<string, MasterComponent>
@@ -870,6 +878,9 @@ export function EditorComponentsProvider({
870
878
  const saved = (save ? await save(next) : undefined) ?? next;
871
879
  upsertPartDef(saved);
872
880
  el.setAttribute('data-part-v', String(saved.version));
881
+ // 他ページのインスタンスは利用側(savePart)が書き換えている。キャンバスの見るだけの紙面にもその場で反映する
882
+ // (以前は読み直すまで前の姿のままだった)。編集中のページは今の DOM が正なので触らない
883
+ canvasRef.current?.invalidatePages({ except: [pageIdRef.current] });
873
884
  return saved;
874
885
  },
875
886
  [partDefs, upsertPartDef]
@@ -930,10 +941,12 @@ export function EditorComponentsProvider({
930
941
  m.set(id, next);
931
942
  return m;
932
943
  });
933
- io().savePart?.(next).catch((err) => {
934
- console.error('[EditorComponentsContext] 部品の保存に失敗:', err);
935
- setError('部品の保存に失敗しました');
936
- });
944
+ io().savePart?.(next)
945
+ .then(() => canvasRef.current?.invalidatePages({ except: [pageIdRef.current] }))
946
+ .catch((err) => {
947
+ console.error('[EditorComponentsContext] 部品の保存に失敗:', err);
948
+ setError('部品の保存に失敗しました');
949
+ });
937
950
  }
938
951
  }
939
952
  },