@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.
- package/README.md +4 -0
- package/dist/editor.css +1 -1
- package/package.json +3 -2
- package/src/editor/EditorContext.tsx +8 -0
- package/src/editor/FrontendVisualEditor.tsx +14 -1
- package/src/editor/components/EditorCanvas.tsx +14 -0
- package/src/editor/components/EditorContextMenu.tsx +12 -3
- package/src/editor/components/EditorLayerPanel.tsx +28 -2
- package/src/editor/components/EditorPropertyPanel.tsx +54 -25
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +66 -16
- package/src/editor/components/multi-page/PageFramePreview.tsx +35 -2
- package/src/editor/constants.ts +28 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +52 -1
- package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +187 -34
- package/src/editor/utils/dom-utils.ts +16 -0
- package/src/editor/utils/html-utils.ts +19 -6
- package/src/index.ts +5 -0
- package/src/styles/skin.css +57 -3
- package/src/version.ts +9 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
851
|
+
// 部品(data-part)のインスタンスか(DOM の属性で判定)。ルートを選んでいても、中のスロットを選んでいても
|
|
852
|
+
// 「どの部品の中か」を出す(中を編集しているときに、部品だと分からずに直してしまうのを防ぐ)
|
|
853
|
+
const selectedPartRoot = useMemo(() => {
|
|
853
854
|
if (!selectedElement?.id) return null;
|
|
854
855
|
const doc = getIframeDoc();
|
|
855
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
const
|
|
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
|
-
}, [
|
|
874
|
+
}, [selectedPartRoot, updatePartFromElement, notifyIframeChange]);
|
|
870
875
|
const handleDetachPartFromPanel = useCallback(() => {
|
|
871
|
-
if (!selectedElement?.id) return;
|
|
872
876
|
const doc = getIframeDoc();
|
|
873
|
-
const
|
|
874
|
-
if (!doc || !
|
|
875
|
-
detachPart(
|
|
876
|
-
unlockPartDescendants(
|
|
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(
|
|
883
|
+
const info = extractElementInfo(root, doc);
|
|
880
884
|
if (info) setSelectedElement(info);
|
|
881
885
|
});
|
|
882
|
-
}, [
|
|
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
|
|
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
|
|
1295
|
-
<span className="truncate font-medium"
|
|
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
|
|
1298
|
-
|
|
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
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
) : (
|
package/src/editor/constants.ts
CHANGED
|
@@ -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)
|
|
934
|
-
|
|
935
|
-
|
|
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
|
},
|