@growgroup/visual-editor 0.1.5 → 0.2.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.
- package/README.md +59 -2
- package/dist/editor.css +1 -1
- package/package.json +7 -3
- package/src/components/ui/alert-dialog.tsx +18 -4
- package/src/components/ui/context-menu.tsx +18 -4
- package/src/components/ui/dialog.tsx +19 -5
- package/src/components/ui/dropdown-menu.tsx +18 -4
- package/src/components/ui/popover.tsx +10 -2
- package/src/components/ui/select.tsx +10 -2
- package/src/components/ui/tooltip.tsx +10 -2
- package/src/editor/EditorToolbar.tsx +10 -20
- package/src/editor/FrontendVisualEditor.tsx +189 -43
- package/src/editor/components/EditorCanvas.tsx +8 -3
- package/src/editor/components/EditorContextMenu.tsx +53 -71
- package/src/editor/components/EditorFooter.tsx +36 -70
- package/src/editor/components/EditorHeader.tsx +37 -20
- package/src/editor/components/EditorLayerPanel.tsx +7 -9
- package/src/editor/components/EditorPropertyPanel.tsx +84 -8
- package/src/editor/components/LayerTreeItem.tsx +28 -5
- package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
- package/src/editor/components/ppt/PptChrome.tsx +156 -344
- package/src/editor/components/ppt/PptComments.tsx +135 -87
- package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
- package/src/editor/components/ppt/PptNotes.tsx +15 -15
- package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
- package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
- package/src/editor/components/shell/EditorTopBar.tsx +29 -68
- package/src/editor/components/shell/LeftPanel.tsx +20 -94
- package/src/editor/components/shell/PagesPanel.tsx +4 -2
- package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
- package/src/editor/editor-skin.css +2 -122
- package/src/editor/hooks/useCanvasControls.ts +3 -2
- package/src/editor/hooks/useComponentEditMode.ts +95 -1
- package/src/editor/hooks/useDragResize.ts +86 -6
- package/src/editor/hooks/useDrawingMode.ts +4 -2
- package/src/editor/hooks/useElementActions.ts +60 -1
- package/src/editor/hooks/useElementSelection.ts +35 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +12 -1
- package/src/editor/hooks/useMediaLibrary.ts +4 -5
- package/src/editor/hooks/useResizablePanel.ts +40 -11
- package/src/editor/parts.ts +321 -0
- package/src/editor/utils/dom-utils.ts +135 -15
- package/src/editor/utils/flex-reorder.ts +268 -43
- package/src/editor/utils/geometry.ts +4 -3
- package/src/editor/utils/html-utils.ts +10 -5
- package/src/hooks/useEditorShortcuts.ts +39 -2
- package/src/index.ts +25 -0
- package/src/io.ts +54 -0
- package/src/styles/skin.css +159 -0
- package/src/types/editor-components.ts +23 -1
|
@@ -4,6 +4,7 @@ import { htmlElementToComponentElement } from '../contexts/EditorComponentsConte
|
|
|
4
4
|
import { renderComponentElement } from '../utils/component-renderer';
|
|
5
5
|
import { propagateMasterChanges } from '../utils/component-sync';
|
|
6
6
|
import { buildDomTree } from '../utils/dom-utils';
|
|
7
|
+
import { applyLockInside, detachPart, partInfoOf, unlockPartDescendants } from '../parts';
|
|
7
8
|
import { toast } from 'sonner';
|
|
8
9
|
import type { MasterComponent, ComponentInstance } from '../../types/editor-components';
|
|
9
10
|
|
|
@@ -37,6 +38,10 @@ interface UseComponentEditModeReturn {
|
|
|
37
38
|
selectedElementInstance: ComponentInstance | null;
|
|
38
39
|
isComponentInstance: boolean;
|
|
39
40
|
hasOverrides: boolean;
|
|
41
|
+
/** 選択要素が部品(data-part)のインスタンスか */
|
|
42
|
+
isPartInstance: boolean;
|
|
43
|
+
/** 「名前 vN」。インスタンスでなければ空 */
|
|
44
|
+
partLabel: string;
|
|
40
45
|
// Handlers
|
|
41
46
|
handleCreateComponent: () => void;
|
|
42
47
|
handleConfirmCreateComponent: () => Promise<void>;
|
|
@@ -49,6 +54,10 @@ interface UseComponentEditModeReturn {
|
|
|
49
54
|
handleDetachInstance: () => void;
|
|
50
55
|
handleResetOverrides: () => Promise<void>;
|
|
51
56
|
handlePushOverridesToMain: () => void;
|
|
57
|
+
/** 部品から切り離す(出自の記録を外して普通の HTML にする) */
|
|
58
|
+
handleDetachPart: () => void;
|
|
59
|
+
/** インスタンスの今の姿で定義を更新する(版 +1) */
|
|
60
|
+
handleUpdatePart: () => Promise<void>;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/**
|
|
@@ -86,6 +95,10 @@ export function useComponentEditMode({
|
|
|
86
95
|
deleteMasterComponent,
|
|
87
96
|
pendingNavigationTarget,
|
|
88
97
|
clearNavigationRequest,
|
|
98
|
+
partsMode,
|
|
99
|
+
getPartDef,
|
|
100
|
+
savePartFromElement,
|
|
101
|
+
updatePartFromElement,
|
|
89
102
|
} = useEditorComponents();
|
|
90
103
|
|
|
91
104
|
// コンポーネントパネル状態
|
|
@@ -128,6 +141,20 @@ export function useComponentEditMode({
|
|
|
128
141
|
return selectedElementInstance.overrides.length > 0;
|
|
129
142
|
}, [selectedElementInstance]);
|
|
130
143
|
|
|
144
|
+
// 部品(data-part)のインスタンスか。状態は持たず、その時点の DOM の属性で決める
|
|
145
|
+
const selectedPart = useMemo(() => {
|
|
146
|
+
if (!selectedElement?.id) return null;
|
|
147
|
+
const doc = getIframeDoc();
|
|
148
|
+
const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`);
|
|
149
|
+
return partInfoOf(el);
|
|
150
|
+
}, [selectedElement, getIframeDoc]);
|
|
151
|
+
const isPartInstance = !!selectedPart;
|
|
152
|
+
const partLabel = useMemo(() => {
|
|
153
|
+
if (!selectedPart) return '';
|
|
154
|
+
const def = getPartDef(selectedPart.id);
|
|
155
|
+
return `${def?.name || selectedPart.id} v${selectedPart.version}`;
|
|
156
|
+
}, [selectedPart, getPartDef]);
|
|
157
|
+
|
|
131
158
|
// Create component from selected element
|
|
132
159
|
const handleCreateComponent = useCallback(() => {
|
|
133
160
|
if (!selectedElement?.id || !iframeRef.current) return;
|
|
@@ -149,6 +176,29 @@ export function useComponentEditMode({
|
|
|
149
176
|
const element = iframeDoc.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement;
|
|
150
177
|
if (!element) return;
|
|
151
178
|
|
|
179
|
+
// 部品モード: 選択要素を HTML の定義として利用側へ保存し、その場でインスタンスにする
|
|
180
|
+
if (partsMode) {
|
|
181
|
+
try {
|
|
182
|
+
const def = await savePartFromElement(element, {
|
|
183
|
+
name: newComponentName.trim(),
|
|
184
|
+
category: newComponentCategory || undefined,
|
|
185
|
+
});
|
|
186
|
+
applyLockInside(element);
|
|
187
|
+
notifyIframeChange(true);
|
|
188
|
+
setCreateComponentDialogOpen(false);
|
|
189
|
+
setNewComponentName('');
|
|
190
|
+
setNewComponentCategory('');
|
|
191
|
+
closeContextMenu();
|
|
192
|
+
toast.success(`部品「${def.name ?? def.id}」として保存しました`, {
|
|
193
|
+
description: `parts/${def.id}.html。スロット(data-slot)の外は編集できなくなります(切り離すと戻ります)`,
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Failed to save part:', error);
|
|
197
|
+
toast.error('部品の保存に失敗しました');
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
152
202
|
try {
|
|
153
203
|
// Debug: Log the original element
|
|
154
204
|
console.log('[handleConfirmCreateComponent] Original element:', {
|
|
@@ -243,7 +293,7 @@ export function useComponentEditMode({
|
|
|
243
293
|
console.error('Failed to create component:', error);
|
|
244
294
|
toast.error('コンポーネントの作成に失敗しました');
|
|
245
295
|
}
|
|
246
|
-
}, [selectedElement?.id, iframeRef, newComponentName, newComponentCategory, createMasterComponent, createInstance, contentId, notifyIframeChange, closeContextMenu, setSelectedElement, setSelectedElementIds]);
|
|
296
|
+
}, [selectedElement?.id, iframeRef, newComponentName, newComponentCategory, createMasterComponent, createInstance, contentId, notifyIframeChange, closeContextMenu, setSelectedElement, setSelectedElementIds, partsMode, savePartFromElement]);
|
|
247
297
|
|
|
248
298
|
// Open master component editor
|
|
249
299
|
const handleEditMasterComponent = useCallback((masterId: string) => {
|
|
@@ -617,6 +667,46 @@ export function useComponentEditMode({
|
|
|
617
667
|
toast.info('この機能は現在開発中です');
|
|
618
668
|
}, [selectedElementInstance, closeContextMenu]);
|
|
619
669
|
|
|
670
|
+
// 部品から切り離す: 出自の記録(data-part)だけ外し、ロックされていた子孫を編集できるようにする
|
|
671
|
+
const handleDetachPart = useCallback(() => {
|
|
672
|
+
if (!selectedElement?.id) return;
|
|
673
|
+
const doc = getIframeDoc();
|
|
674
|
+
const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
|
|
675
|
+
if (!doc || !el || !partInfoOf(el)) return;
|
|
676
|
+
detachPart(el);
|
|
677
|
+
unlockPartDescendants(el);
|
|
678
|
+
notifyIframeChange(true);
|
|
679
|
+
// 部品バッジを消すため、選択情報を作り直す
|
|
680
|
+
void import('../utils/style-utils').then(({ extractElementInfo }) => {
|
|
681
|
+
const info = extractElementInfo(el, doc);
|
|
682
|
+
if (info) setSelectedElement(info);
|
|
683
|
+
});
|
|
684
|
+
closeContextMenu();
|
|
685
|
+
toast.success('部品から切り離しました', {
|
|
686
|
+
description: 'この要素は普通の HTML になり、parts:sync の対象から外れます',
|
|
687
|
+
});
|
|
688
|
+
}, [selectedElement?.id, getIframeDoc, notifyIframeChange, setSelectedElement, closeContextMenu]);
|
|
689
|
+
|
|
690
|
+
// インスタンスの今の姿で定義を更新する(版 +1)。他ページへは利用側の parts:sync で反映する
|
|
691
|
+
const handleUpdatePart = useCallback(async () => {
|
|
692
|
+
if (!selectedElement?.id) return;
|
|
693
|
+
const doc = getIframeDoc();
|
|
694
|
+
const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
|
|
695
|
+
if (!el) return;
|
|
696
|
+
try {
|
|
697
|
+
const def = await updatePartFromElement(el);
|
|
698
|
+
if (!def) return;
|
|
699
|
+
notifyIframeChange(true);
|
|
700
|
+
closeContextMenu();
|
|
701
|
+
toast.success(`部品「${def.name ?? def.id}」を v${def.version} に更新しました`, {
|
|
702
|
+
description: '他のページのインスタンスへは parts:sync で反映します',
|
|
703
|
+
});
|
|
704
|
+
} catch (error) {
|
|
705
|
+
console.error('Failed to update part:', error);
|
|
706
|
+
toast.error('部品の更新に失敗しました');
|
|
707
|
+
}
|
|
708
|
+
}, [selectedElement?.id, getIframeDoc, updatePartFromElement, notifyIframeChange, closeContextMenu]);
|
|
709
|
+
|
|
620
710
|
return {
|
|
621
711
|
// Panel state
|
|
622
712
|
isComponentPanelOpen,
|
|
@@ -642,6 +732,8 @@ export function useComponentEditMode({
|
|
|
642
732
|
selectedElementInstance,
|
|
643
733
|
isComponentInstance,
|
|
644
734
|
hasOverrides,
|
|
735
|
+
isPartInstance,
|
|
736
|
+
partLabel,
|
|
645
737
|
// Handlers
|
|
646
738
|
handleCreateComponent,
|
|
647
739
|
handleConfirmCreateComponent,
|
|
@@ -654,5 +746,7 @@ export function useComponentEditMode({
|
|
|
654
746
|
handleDetachInstance,
|
|
655
747
|
handleResetOverrides,
|
|
656
748
|
handlePushOverridesToMain,
|
|
749
|
+
handleDetachPart,
|
|
750
|
+
handleUpdatePart,
|
|
657
751
|
};
|
|
658
752
|
}
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from '../utils/geometry';
|
|
32
32
|
import {
|
|
33
33
|
shouldReorder,
|
|
34
|
+
planSlideReorder,
|
|
35
|
+
resolveWebpageDrag,
|
|
34
36
|
startReorder,
|
|
35
37
|
computeInsertIndex,
|
|
36
38
|
drawInsertIndicator,
|
|
@@ -290,7 +292,7 @@ const ROTATION_SNAP_DEG = 15;
|
|
|
290
292
|
export function useDragResize(
|
|
291
293
|
options: UseDragResizeOptions
|
|
292
294
|
): UseDragResizeReturn {
|
|
293
|
-
const { setShowLayoutHint, zoom } = useEditorContext();
|
|
295
|
+
const { setShowLayoutHint, zoom, editorMode } = useEditorContext();
|
|
294
296
|
|
|
295
297
|
const {
|
|
296
298
|
dragStateRef,
|
|
@@ -326,11 +328,26 @@ export function useDragResize(
|
|
|
326
328
|
const reorderRef = useRef<ReorderSession | null>(null);
|
|
327
329
|
const reorderIndexRef = useRef<number>(-1);
|
|
328
330
|
|
|
331
|
+
// 「並べ替えたいが相手がいない」ドラッグ。座標も DOM も一切書かずに空振りさせる。
|
|
332
|
+
// ここを立てずに素通りさせると convertDragTargetsToAbsolute が走って版面が崩れ、
|
|
333
|
+
// さらに mouseup が「動いた」と見なして履歴を1段積んでしまう
|
|
334
|
+
const dragBlockedRef = useRef(false);
|
|
335
|
+
// 案内はセッション中1回だけ。ドラッグのたびに帯が出ると操作の邪魔になる
|
|
336
|
+
const reorderHintShownRef = useRef(false);
|
|
337
|
+
|
|
338
|
+
// editorMode も zoom と同じ理由で ref に写す。
|
|
339
|
+
// iframe に張ったリスナーのクロージャは古い値を掴んだままになるため
|
|
340
|
+
const editorModeRef = useRef(editorMode);
|
|
341
|
+
|
|
329
342
|
// zoomが変更されたらrefを更新
|
|
330
343
|
useEffect(() => {
|
|
331
344
|
zoomRef.current = zoom;
|
|
332
345
|
}, [zoom]);
|
|
333
346
|
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
editorModeRef.current = editorMode;
|
|
349
|
+
}, [editorMode]);
|
|
350
|
+
|
|
334
351
|
// 最新のsetShowLayoutHintをrefに同期
|
|
335
352
|
useEffect(() => {
|
|
336
353
|
setShowLayoutHintRef.current = setShowLayoutHint;
|
|
@@ -360,6 +377,7 @@ export function useDragResize(
|
|
|
360
377
|
dragBaseRectRef.current = null;
|
|
361
378
|
reorderRef.current = null;
|
|
362
379
|
reorderIndexRef.current = -1;
|
|
380
|
+
dragBlockedRef.current = false;
|
|
363
381
|
}, [dragStateRef]);
|
|
364
382
|
|
|
365
383
|
/**
|
|
@@ -556,9 +574,15 @@ export function useDragResize(
|
|
|
556
574
|
|
|
557
575
|
// 位置を書けるのは「フローから外れている要素」だけ。
|
|
558
576
|
// 絶対配置モードなら従来通り static → absolute へ倒して位置を持たせる。
|
|
577
|
+
//
|
|
578
|
+
// [webpage は倒さない] webpage は流し込みを保つ約束(README)。ここで倒すと
|
|
579
|
+
// 要素が流れから抜けて後続が詰め上がり、ページ高さが変わって紙面が飛ぶ。
|
|
580
|
+
// 大きさを変えるのに position は要らないので width/height だけ書く。
|
|
559
581
|
const position = win?.getComputedStyle(el).position;
|
|
560
582
|
const outOfFlow = isOutOfFlowPosition(position);
|
|
561
|
-
const canWritePosition =
|
|
583
|
+
const canWritePosition =
|
|
584
|
+
outOfFlow ||
|
|
585
|
+
(layoutModeRef.current === 'absolute' && editorModeRef.current !== 'webpage');
|
|
562
586
|
|
|
563
587
|
if (canWritePosition) {
|
|
564
588
|
// すでにフロー外なら position は触らない(fixed を absolute に落とさない)
|
|
@@ -908,24 +932,50 @@ export function useDragResize(
|
|
|
908
932
|
const targets = getDragTargets(dragState);
|
|
909
933
|
const movingElements = targets.map((t) => t.element);
|
|
910
934
|
const componentEdit = iframeDoc.body.classList.contains('component-edit-mode');
|
|
935
|
+
const isWebpage = editorModeRef.current === 'webpage';
|
|
911
936
|
|
|
912
937
|
// Cmd/Ctrl は「並べ替えから抜けて自由に動かす」逃げ道。
|
|
913
938
|
// スナップの無効化と同じキーに揃える(押している間は座標がそのまま通る)
|
|
914
939
|
const wantsFreeMove = e.metaKey || e.ctrlKey;
|
|
915
940
|
|
|
916
|
-
if (
|
|
941
|
+
if (isWebpage) {
|
|
942
|
+
// ── webpage ──
|
|
943
|
+
// フロー内の要素を絶対配置へ倒すと、カラムが流れから抜けてページ高さが
|
|
944
|
+
// 潰れ、artboard が縮んで紙面が中央寄せで飛ぶ(実測 2424→679px)。
|
|
945
|
+
// README の設計原則どおり倒さず、同一親の並べ替えだけで動かす。
|
|
946
|
+
if (!componentEdit && !wantsFreeMove) {
|
|
947
|
+
const decision = resolveWebpageDrag(movingElements, iframeDoc);
|
|
948
|
+
if (decision.kind === 'reorder') {
|
|
949
|
+
reorderRef.current = startReorder(decision.plan, iframeDoc, {
|
|
950
|
+
restoreFlow: false,
|
|
951
|
+
});
|
|
952
|
+
reorderIndexRef.current = -1;
|
|
953
|
+
} else if (decision.kind !== 'free') {
|
|
954
|
+
// 並べ替える相手がいない/群がバラバラ。倒さず、動かさず、案内だけ出す
|
|
955
|
+
dragBlockedRef.current = true;
|
|
956
|
+
if (!reorderHintShownRef.current) {
|
|
957
|
+
reorderHintShownRef.current = true;
|
|
958
|
+
setShowLayoutHintRef.current(true);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} else if (
|
|
917
963
|
!componentEdit &&
|
|
918
964
|
!wantsFreeMove &&
|
|
919
965
|
dragState.element &&
|
|
920
966
|
shouldReorder(dragState.element, iframeDoc, movingElements.length)
|
|
921
967
|
) {
|
|
968
|
+
// ── slide ──
|
|
922
969
|
// 並べ替え: 絶対配置へ倒さない。倒した瞬間にフレックスの設定が
|
|
923
970
|
// 効かなくなり、右パネルのレイアウト指定が無意味になる
|
|
924
|
-
|
|
925
|
-
|
|
971
|
+
const plan = planSlideReorder(dragState.element, iframeDoc);
|
|
972
|
+
if (plan) {
|
|
973
|
+
reorderRef.current = startReorder(plan, iframeDoc, { restoreFlow: true });
|
|
974
|
+
reorderIndexRef.current = -1;
|
|
975
|
+
}
|
|
926
976
|
}
|
|
927
977
|
|
|
928
|
-
if (!reorderRef.current) {
|
|
978
|
+
if (!reorderRef.current && !dragBlockedRef.current) {
|
|
929
979
|
// in-flow の要素はここで初めて絶対配置へ変換する。
|
|
930
980
|
// mousedown 時に変換すると、選んだだけで後続の兄弟が詰め上がって版面が動く。
|
|
931
981
|
// コンポーネント編集中は変換しない(部品本来の並びを保つため)
|
|
@@ -941,6 +991,14 @@ export function useDragResize(
|
|
|
941
991
|
}
|
|
942
992
|
}
|
|
943
993
|
|
|
994
|
+
// ── 空振りモード ──
|
|
995
|
+
// 「並べ替えたいが相手がいない」ドラッグ。座標も DOM も書かない。
|
|
996
|
+
// hasMoved を立てないことが肝で、立てると mouseup が Tailwind 変換と
|
|
997
|
+
// SLIDE_CONTENT_CHANGED を走らせ、何も変えていないのに履歴が1段積まれる
|
|
998
|
+
if (dragBlockedRef.current) {
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
944
1002
|
dragState.hasMoved = true;
|
|
945
1003
|
|
|
946
1004
|
// ── 並べ替えモード ──
|
|
@@ -1051,6 +1109,16 @@ export function useDragResize(
|
|
|
1051
1109
|
return true;
|
|
1052
1110
|
}
|
|
1053
1111
|
|
|
1112
|
+
// 空振り中の取り消し: 座標も DOM も書いていないので戻すものが無い
|
|
1113
|
+
if (dragBlockedRef.current) {
|
|
1114
|
+
getDragTargets(dragState).forEach(({ element }) =>
|
|
1115
|
+
element.classList.remove('dragging'),
|
|
1116
|
+
);
|
|
1117
|
+
resetDragState();
|
|
1118
|
+
refreshSelectionOverlay(doc);
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1054
1122
|
if (autoLayoutDragStateRef.current) {
|
|
1055
1123
|
// ゴースト並べ替え中:DOMの並びは変えずにゴーストだけ畳む
|
|
1056
1124
|
cancelAutoLayoutDrag(autoLayoutDragStateRef.current, doc);
|
|
@@ -1222,6 +1290,18 @@ export function useDragResize(
|
|
|
1222
1290
|
clearSmartGuides(iframeDoc);
|
|
1223
1291
|
setOverlayLabel(null);
|
|
1224
1292
|
|
|
1293
|
+
// ── 空振りの後始末 ──
|
|
1294
|
+
// 何も書いていないので履歴も送らない。掴んだ見た目だけ戻す
|
|
1295
|
+
if (dragBlockedRef.current) {
|
|
1296
|
+
getDragTargets(dragState).forEach(({ element }) =>
|
|
1297
|
+
element.classList.remove('dragging'),
|
|
1298
|
+
);
|
|
1299
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1300
|
+
sendElementInfo(dragState.element, iframeDoc);
|
|
1301
|
+
resetDragState();
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1225
1305
|
// ── 並べ替えの確定 ──
|
|
1226
1306
|
// 座標(left/top)は書いていないので Tailwind への変換は通さない。
|
|
1227
1307
|
// 変わったのは DOM の順番だけで、それは innerHTML にそのまま出る
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useEffect } from 'react';
|
|
8
8
|
import { useEditorContext } from '../EditorContext';
|
|
9
|
-
import { generateElementId } from '../utils/dom-utils';
|
|
9
|
+
import { generateElementId, lockCaretScroll } from '../utils/dom-utils';
|
|
10
10
|
import { inkStyle } from '../utils/ink-style';
|
|
11
11
|
import { pendingShape, shapeStyles as shapeLibStyles } from '../utils/shape-library';
|
|
12
12
|
|
|
@@ -205,7 +205,9 @@ export function useDrawingMode() {
|
|
|
205
205
|
artboard.appendChild(textEl);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
// 置いた場所は見えているので、フォーカスやキャレット追従で紙面を動かさない
|
|
209
|
+
lockCaretScroll(textEl, iframeDoc);
|
|
210
|
+
textEl.focus({ preventScroll: true });
|
|
209
211
|
textEl.classList.add('selected', 'editing');
|
|
210
212
|
|
|
211
213
|
const range = iframeDoc.createRange();
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
hasEditableChildren,
|
|
16
16
|
prepareElementDragOrigin,
|
|
17
17
|
} from '../utils/dom-utils';
|
|
18
|
+
import {
|
|
19
|
+
resolveWebpageDrag,
|
|
20
|
+
realChildren,
|
|
21
|
+
applyReorder,
|
|
22
|
+
} from '../utils/flex-reorder';
|
|
18
23
|
import { extractElementInfo } from '../utils/style-utils';
|
|
19
24
|
import { applyTailwindStyles, convertInlineStylesToTailwind } from '../utils/tailwind-utils';
|
|
20
25
|
import { copyElementsToFigma, isFigmaExportAvailable } from '../utils/figma-export';
|
|
@@ -152,6 +157,42 @@ export interface MoveElementResult {
|
|
|
152
157
|
blocked: null | 'no-selection' | 'flow';
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
/**
|
|
161
|
+
* webpage モードの矢印キー = 「並べ替えを1段動かす」
|
|
162
|
+
*
|
|
163
|
+
* webpage はフロー内の要素を絶対配置へ倒さない(README の設計原則)。
|
|
164
|
+
* 倒すと版面が固定pxに固まり、後続の要素が詰め上がってページ高さが変わる。
|
|
165
|
+
* そこで矢印キーは座標ではなく **並び順** を1つ動かす。
|
|
166
|
+
* ↑/← = ひとつ前へ / ↓/→ = ひとつ後ろへ(Shift 付きでも1段。並びに10段の意味は無い)。
|
|
167
|
+
*
|
|
168
|
+
* @returns null なら「並べ替えの対象ではない」= 呼び出し側の座標移動へ落とす
|
|
169
|
+
*/
|
|
170
|
+
function reorderSelectionByStep(
|
|
171
|
+
elements: HTMLElement[],
|
|
172
|
+
step: -1 | 1,
|
|
173
|
+
iframeDoc: Document,
|
|
174
|
+
): MoveElementResult | null {
|
|
175
|
+
const decision = resolveWebpageDrag(elements, iframeDoc);
|
|
176
|
+
// すでに絶対配置(Cmd+ドラッグで自由配置にした要素など)は従来どおり座標で動かす
|
|
177
|
+
if (decision.kind === 'free') return null;
|
|
178
|
+
// 相手がいない / 群がバラバラ。ドラッグと同じ理由で何もしない
|
|
179
|
+
if (decision.kind !== 'reorder') return { moved: 0, blocked: 'flow' };
|
|
180
|
+
|
|
181
|
+
const { elements: block, parent } = decision.plan;
|
|
182
|
+
const children = realChildren(parent);
|
|
183
|
+
const moving = new Set(block);
|
|
184
|
+
const siblings = children.filter((c) => !moving.has(c));
|
|
185
|
+
const originalIndex = children.indexOf(block[0]);
|
|
186
|
+
if (originalIndex < 0) return { moved: 0, blocked: 'flow' };
|
|
187
|
+
|
|
188
|
+
const next = originalIndex + step;
|
|
189
|
+
// 端に着いている。黙って止まるのが正しい(案内は出さない)
|
|
190
|
+
if (next < 0 || next > siblings.length) return { moved: 0, blocked: null };
|
|
191
|
+
|
|
192
|
+
const changed = applyReorder(parent, block, siblings, originalIndex, next);
|
|
193
|
+
return { moved: changed ? block.length : 0, blocked: null };
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
/**
|
|
156
197
|
* 最後に使用したスタイルをキャプチャ
|
|
157
198
|
*/
|
|
@@ -1326,6 +1367,24 @@ export function useElementActions() {
|
|
|
1326
1367
|
.filter((el): el is HTMLElement => el !== null);
|
|
1327
1368
|
if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1328
1369
|
|
|
1370
|
+
// webpage はフロー内の要素を倒さない。矢印キーは並べ替えを1段動かす
|
|
1371
|
+
if (editorMode === 'webpage') {
|
|
1372
|
+
const step: -1 | 1 = dx < 0 || dy < 0 ? -1 : 1;
|
|
1373
|
+
const reordered = reorderSelectionByStep(elements, step, iframeDoc);
|
|
1374
|
+
if (reordered) {
|
|
1375
|
+
if (reordered.moved > 0) {
|
|
1376
|
+
notifyIframeChange();
|
|
1377
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1378
|
+
const anchor = selectedElement
|
|
1379
|
+
? getIframeElement(iframeDoc, selectedElement.id) ?? elements[0]
|
|
1380
|
+
: elements[0];
|
|
1381
|
+
const info = extractElementInfo(anchor, iframeDoc);
|
|
1382
|
+
if (info) setSelectedElement(info);
|
|
1383
|
+
}
|
|
1384
|
+
return reordered;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1329
1388
|
// [モード廃止] フロー内の要素は「動かせない」で終わらせず、その要素だけを
|
|
1330
1389
|
// 絶対配置へ変換してから動かす(ドラッグと同じ遅延変換)。
|
|
1331
1390
|
// 以前はここで弾いていたため、矢印キーが無反応になっていた。
|
|
@@ -1350,7 +1409,7 @@ export function useElementActions() {
|
|
|
1350
1409
|
if (info) setSelectedElement(info);
|
|
1351
1410
|
|
|
1352
1411
|
return { moved: elements.length, blocked: null };
|
|
1353
|
-
}, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement]);
|
|
1412
|
+
}, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, editorMode]);
|
|
1354
1413
|
|
|
1355
1414
|
/**
|
|
1356
1415
|
* 選択中の全要素の寸法を変える(キーボードリサイズ用)。
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getArtboardScale,
|
|
20
20
|
refreshSelectionOverlay,
|
|
21
21
|
syncSelectionOverlayRects,
|
|
22
|
+
lockCaretScroll,
|
|
22
23
|
} from "../utils/dom-utils";
|
|
23
24
|
import { extractElementInfo } from "../utils/style-utils";
|
|
24
25
|
import { MARQUEE_DRAG_THRESHOLD } from "../constants";
|
|
@@ -1093,11 +1094,14 @@ export function useElementSelection(
|
|
|
1093
1094
|
const rawTarget = e.target as HTMLElement;
|
|
1094
1095
|
const existingIds = selectedElementIdsRef.current;
|
|
1095
1096
|
|
|
1096
|
-
// selection-box
|
|
1097
|
+
// selection-boxをクリックした場合、対象要素のドラッグを開始。
|
|
1098
|
+
// Cmd/Ctrl(最下層を選ぶ)と Shift(追加/解除)は下の判別器に任せる。
|
|
1099
|
+
// 以前は Cmd でもここで掴んでいたため、選択中の器の上で Cmd+クリックしても
|
|
1100
|
+
// 中の要素を選べなかった(Figmaでは選べる)
|
|
1097
1101
|
const selectionBox = rawTarget.closest(
|
|
1098
1102
|
".selection-box",
|
|
1099
1103
|
) as HTMLElement | null;
|
|
1100
|
-
if (selectionBox && !e.shiftKey) {
|
|
1104
|
+
if (selectionBox && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
|
1101
1105
|
const forElementId = selectionBox.getAttribute("data-for-element");
|
|
1102
1106
|
if (forElementId) {
|
|
1103
1107
|
const targetEl = iframeDoc.querySelector(
|
|
@@ -1158,15 +1162,30 @@ export function useElementSelection(
|
|
|
1158
1162
|
// ============================================================
|
|
1159
1163
|
|
|
1160
1164
|
// ---- ① ヒットテスト ----
|
|
1161
|
-
|
|
1165
|
+
// 選択枠(オーバーレイ)の上で押した場合、e.target は枠なので紙面の要素が分からない。
|
|
1166
|
+
// 枠の下にある要素を elementsFromPoint で拾い、それを押した点として扱う
|
|
1167
|
+
// (Cmd+クリックの最下層選択、Shift+クリックの追加/解除がここを通る)
|
|
1168
|
+
let hitSource: Element | null = rawTarget;
|
|
1169
|
+
if (selectionBox && typeof iframeDoc.elementsFromPoint === "function") {
|
|
1170
|
+
hitSource =
|
|
1171
|
+
iframeDoc
|
|
1172
|
+
.elementsFromPoint(e.clientX, e.clientY)
|
|
1173
|
+
.find(
|
|
1174
|
+
(el) =>
|
|
1175
|
+
!el.closest(
|
|
1176
|
+
".selection-box,.marquee-selection-box,[data-editor-overlay],#gg-smart-guides,#gg-measure-layer",
|
|
1177
|
+
),
|
|
1178
|
+
) ?? null;
|
|
1179
|
+
}
|
|
1180
|
+
const hit = getEditableElement(hitSource, iframeDoc);
|
|
1162
1181
|
// 空白=押した点の祖先に編集可能要素がまったく無い状態。
|
|
1163
1182
|
// #artboard / 紙面と同じ大きさの器 / #artboard-wrapper / #canvas-container は
|
|
1164
1183
|
// initializeEditableElements が意図的に data-editable を付けないので
|
|
1165
1184
|
// ここで自然に「空白」と判定される(IDの白リストは不要)。
|
|
1166
1185
|
const isBlank =
|
|
1167
1186
|
!hit &&
|
|
1168
|
-
!(typeof
|
|
1169
|
-
?
|
|
1187
|
+
!(hitSource && typeof hitSource.closest === "function"
|
|
1188
|
+
? hitSource.closest('[data-editable="true"]')
|
|
1170
1189
|
: null);
|
|
1171
1190
|
|
|
1172
1191
|
// ---- ② 役割決定 ----
|
|
@@ -1211,7 +1230,10 @@ export function useElementSelection(
|
|
|
1211
1230
|
// 押した点を含む「選択済み要素」。あればそれを掴んだものとして扱う。
|
|
1212
1231
|
// (コンテキスト解決で祖先へ引き上げると、掴んだ要素と選択済み要素が
|
|
1213
1232
|
// 食い違って群が壊れる)
|
|
1214
|
-
const selectedHit = findSelectedHit(
|
|
1233
|
+
const selectedHit = findSelectedHit(
|
|
1234
|
+
(hitSource as HTMLElement | null) ?? rawTarget,
|
|
1235
|
+
iframeDoc,
|
|
1236
|
+
);
|
|
1215
1237
|
|
|
1216
1238
|
let target: HTMLElement;
|
|
1217
1239
|
if (meta) {
|
|
@@ -1370,8 +1392,13 @@ export function useElementSelection(
|
|
|
1370
1392
|
.querySelectorAll(".selection-box")
|
|
1371
1393
|
.forEach((box) => box.remove());
|
|
1372
1394
|
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1395
|
+
// 紙面が飛ぶのを防ぐ(キャレット追従のスクロールを打ち消す)。
|
|
1396
|
+
// 祖先のスクロール位置は focus の前に覚える必要があるので、フォーカスより先に張る
|
|
1397
|
+
lockCaretScroll(element, iframeDoc);
|
|
1398
|
+
|
|
1399
|
+
// preventScroll: ダブルクリックした場所は既に見えているので、フォーカスでの
|
|
1400
|
+
// スクロールは要らない(あると編集に入った瞬間に紙面が動く)
|
|
1401
|
+
element.focus({ preventScroll: true });
|
|
1375
1402
|
|
|
1376
1403
|
// テキスト全体を選択(オプション)
|
|
1377
1404
|
const selection = iframeDoc.getSelection();
|
|
@@ -10,6 +10,7 @@ import { useCallback, useRef, useEffect } from 'react';
|
|
|
10
10
|
import { useEditorContext } from '../EditorContext';
|
|
11
11
|
import { buildDomTree, isInlineElement } from '../utils/dom-utils';
|
|
12
12
|
import { EDITOR_IFRAME_STYLES } from '../constants';
|
|
13
|
+
import { isLockedInsidePart } from '../parts';
|
|
13
14
|
|
|
14
15
|
interface UseIframeSetupReturn {
|
|
15
16
|
/**
|
|
@@ -111,6 +112,15 @@ export function useIframeSetup(): UseIframeSetupReturn {
|
|
|
111
112
|
// selection-boxの子孫要素をすべてスキップ(念のため)
|
|
112
113
|
if (element.closest('.selection-box')) return;
|
|
113
114
|
|
|
115
|
+
// 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
|
|
116
|
+
// ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。
|
|
117
|
+
// 保存 HTML には data-editable が残らないので、古い保存物には効かない = 既存の挙動は変わらない
|
|
118
|
+
if (isLockedInsidePart(element)) {
|
|
119
|
+
element.removeAttribute('data-editable');
|
|
120
|
+
element.removeAttribute('data-element-id');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
114
124
|
// 既にIDがある場合はスキップ
|
|
115
125
|
if (!element.getAttribute('data-element-id')) {
|
|
116
126
|
const elementId = `el-${Date.now()}-${elementIndex++}`;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { useCallback } from 'react';
|
|
19
|
-
import { refreshSelectionOverlay } from '../utils/dom-utils';
|
|
19
|
+
import { refreshSelectionOverlay, getArtboardContent } from '../utils/dom-utils';
|
|
20
20
|
|
|
21
21
|
interface UseKeyboardShortcutsOptions {
|
|
22
22
|
/** グループ化関数 */
|
|
@@ -186,6 +186,17 @@ export function exitTextEditingIn(iframeDoc: Document): boolean {
|
|
|
186
186
|
|
|
187
187
|
// 選択ボックスを再表示(1要素ずつ作り直すと他の枠を巻き込んで壊すため全体を再構築)
|
|
188
188
|
refreshSelectionOverlay(iframeDoc);
|
|
189
|
+
|
|
190
|
+
// [確定を履歴に積む]
|
|
191
|
+
// フォーカスが外れて確定する経路(EditorCanvas の focusout)は、contenteditable が
|
|
192
|
+
// まだ付いている要素からのイベントでだけ内容変更を通知する。ここは先に属性を
|
|
193
|
+
// 外すので focusout 経路が黙り、Escape で確定した編集が履歴にも自動保存にも
|
|
194
|
+
// 乗らなかった(Cmd+Z で戻らない・保存されないまま閉じられる)。
|
|
195
|
+
// 同じ内容なら履歴側で重複扱いになるので、変わっていなくても送ってよい
|
|
196
|
+
window.postMessage(
|
|
197
|
+
{ type: 'SLIDE_CONTENT_CHANGED', html: getArtboardContent(iframeDoc) },
|
|
198
|
+
'*',
|
|
199
|
+
);
|
|
189
200
|
return true;
|
|
190
201
|
}
|
|
191
202
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 同時オープン時のリクエスト重複も in-flight promise で防ぐ。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { io, notProvided } from '../../io';
|
|
12
13
|
import { useCallback, useEffect, useState } from 'react';
|
|
13
14
|
|
|
14
15
|
export interface MediaItem {
|
|
@@ -48,11 +49,9 @@ function normalizeItem(raw: unknown): MediaItem | null {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
async function fetchMediaItems(): Promise<MediaItem[]> {
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const data = (await res.json()) as MediaResponse;
|
|
52
|
+
const fetchCatalog = io().apiFetch;
|
|
53
|
+
if (!fetchCatalog) throw notProvided('apiFetch');
|
|
54
|
+
const data = await fetchCatalog('/__media') as MediaResponse;
|
|
56
55
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
57
56
|
return items.map(normalizeItem).filter((i): i is MediaItem => i !== null);
|
|
58
57
|
}
|