@growgroup/visual-editor 0.1.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/LICENSE +21 -0
- package/NOTICE +52 -0
- package/README.md +143 -0
- package/dist/editor.css +2 -0
- package/package.json +94 -0
- package/src/components/DeckSlideRender.tsx +21 -0
- package/src/components/SaveNote.tsx +21 -0
- package/src/components/auth/AuthProvider.tsx +44 -0
- package/src/components/ui/accordion.tsx +57 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +200 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +201 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/scrubbable-label.tsx +95 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/viewer/useDeck.ts +58 -0
- package/src/editor/EditorContext.tsx +632 -0
- package/src/editor/EditorToolbar.tsx +446 -0
- package/src/editor/FrontendVisualEditor.tsx +2595 -0
- package/src/editor/autosave.ts +34 -0
- package/src/editor/components/AiPromptPopover.tsx +378 -0
- package/src/editor/components/BreakpointGuides.tsx +323 -0
- package/src/editor/components/BreakpointSelector.tsx +180 -0
- package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
- package/src/editor/components/CssEditorDialog.tsx +168 -0
- package/src/editor/components/EditorCanvas.tsx +987 -0
- package/src/editor/components/EditorContextMenu.tsx +515 -0
- package/src/editor/components/EditorFooter.tsx +81 -0
- package/src/editor/components/EditorHeader.tsx +524 -0
- package/src/editor/components/EditorLayerPanel.tsx +1197 -0
- package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
- package/src/editor/components/EditorSidebar.tsx +226 -0
- package/src/editor/components/FigmaColorPicker.tsx +1577 -0
- package/src/editor/components/HtmlEditorDialog.tsx +71 -0
- package/src/editor/components/HtmlImportDialog.tsx +346 -0
- package/src/editor/components/JsEditorDialog.tsx +175 -0
- package/src/editor/components/LayerTreeItem.tsx +494 -0
- package/src/editor/components/MasterComponentEditor.tsx +846 -0
- package/src/editor/components/MediaLibraryDialog.tsx +447 -0
- package/src/editor/components/PageSettingsDialog.tsx +776 -0
- package/src/editor/components/VariablePicker.tsx +355 -0
- package/src/editor/components/VariablesDialog.tsx +564 -0
- package/src/editor/components/VariantEditor.tsx +687 -0
- package/src/editor/components/VariantMatrix.tsx +507 -0
- package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
- package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
- package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
- package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
- package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
- package/src/editor/components/component-panel/VariantList.tsx +260 -0
- package/src/editor/components/component-panel/index.ts +12 -0
- package/src/editor/components/index.ts +48 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
- package/src/editor/components/multi-page/PageLabel.tsx +34 -0
- package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
- package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
- package/src/editor/components/multi-page/index.ts +7 -0
- package/src/editor/components/ppt/PptChrome.tsx +2508 -0
- package/src/editor/components/ppt/PptComments.tsx +589 -0
- package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
- package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
- package/src/editor/components/ppt/PptNotes.tsx +246 -0
- package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
- package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
- package/src/editor/components/property-panel/BorderSection.tsx +104 -0
- package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
- package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
- package/src/editor/components/property-panel/EffectSection.tsx +157 -0
- package/src/editor/components/property-panel/FillSection.tsx +69 -0
- package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
- package/src/editor/components/property-panel/ImageSection.tsx +206 -0
- package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
- package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
- package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
- package/src/editor/components/property-panel/LinkSection.tsx +103 -0
- package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
- package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
- package/src/editor/components/property-panel/TypographySection.tsx +230 -0
- package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
- package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
- package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
- package/src/editor/components/property-panel/index.ts +24 -0
- package/src/editor/components/property-panel/unit-utils.ts +261 -0
- package/src/editor/components/shell/EditorTopBar.tsx +392 -0
- package/src/editor/components/shell/LeftPanel.tsx +47 -0
- package/src/editor/components/shell/PagesPanel.tsx +118 -0
- package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
- package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
- package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
- package/src/editor/components/variables-panel/index.ts +7 -0
- package/src/editor/constants.ts +468 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
- package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
- package/src/editor/contexts/EditorRefsContext.tsx +204 -0
- package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
- package/src/editor/contexts/EditorToolContext.tsx +58 -0
- package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
- package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
- package/src/editor/contexts/EditorViewContext.tsx +101 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
- package/src/editor/contexts/index.ts +17 -0
- package/src/editor/editor-skin.css +122 -0
- package/src/editor/hooks/index.ts +38 -0
- package/src/editor/hooks/useAiReplace.ts +397 -0
- package/src/editor/hooks/useAltMeasure.ts +67 -0
- package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
- package/src/editor/hooks/useCanvasControls.ts +1222 -0
- package/src/editor/hooks/useComponentEditMode.ts +658 -0
- package/src/editor/hooks/useComponentInstances.ts +515 -0
- package/src/editor/hooks/useContextMenuHandler.ts +157 -0
- package/src/editor/hooks/useCoordinateTransform.ts +207 -0
- package/src/editor/hooks/useDragResize.ts +1317 -0
- package/src/editor/hooks/useDrawingMode.ts +472 -0
- package/src/editor/hooks/useEditorColors.ts +144 -0
- package/src/editor/hooks/useEditorMessages.ts +108 -0
- package/src/editor/hooks/useElementActions.ts +1754 -0
- package/src/editor/hooks/useElementSelection.ts +1573 -0
- package/src/editor/hooks/useFocusManagement.ts +249 -0
- package/src/editor/hooks/useGoogleFonts.ts +305 -0
- package/src/editor/hooks/useIframeInitializer.ts +584 -0
- package/src/editor/hooks/useIframeSetup.ts +360 -0
- package/src/editor/hooks/useImageUpload.ts +270 -0
- package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
- package/src/editor/hooks/useMarqueeSelection.ts +388 -0
- package/src/editor/hooks/useMediaLibrary.ts +129 -0
- package/src/editor/hooks/usePageSettingsManager.ts +739 -0
- package/src/editor/hooks/useResizablePanel.ts +163 -0
- package/src/editor/hooks/useRichPaste.ts +255 -0
- package/src/editor/hooks/useTouchGestures.ts +197 -0
- package/src/editor/index.ts +27 -0
- package/src/editor/types/page-settings.ts +513 -0
- package/src/editor/types.ts +382 -0
- package/src/editor/utils/align-elements.ts +169 -0
- package/src/editor/utils/api-json.ts +21 -0
- package/src/editor/utils/aspect-lock.ts +50 -0
- package/src/editor/utils/component-renderer.ts +702 -0
- package/src/editor/utils/component-sync.ts +845 -0
- package/src/editor/utils/crop-mode.ts +265 -0
- package/src/editor/utils/dom-utils.ts +2238 -0
- package/src/editor/utils/element-effects.ts +218 -0
- package/src/editor/utils/eyedropper.ts +35 -0
- package/src/editor/utils/figma-export.ts +560 -0
- package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
- package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
- package/src/editor/utils/figma-paste.ts +2262 -0
- package/src/editor/utils/flex-reorder.ts +231 -0
- package/src/editor/utils/flex-utils.ts +1230 -0
- package/src/editor/utils/geometry.ts +232 -0
- package/src/editor/utils/grid-layout.ts +210 -0
- package/src/editor/utils/html-utils.ts +470 -0
- package/src/editor/utils/index.ts +11 -0
- package/src/editor/utils/ink-style.ts +57 -0
- package/src/editor/utils/inline-format.ts +236 -0
- package/src/editor/utils/measure-distance.ts +120 -0
- package/src/editor/utils/override-apply.ts +526 -0
- package/src/editor/utils/override-detection.ts +613 -0
- package/src/editor/utils/paste-processors.ts +609 -0
- package/src/editor/utils/paste-sanitizer.ts +244 -0
- package/src/editor/utils/restore-flow.ts +188 -0
- package/src/editor/utils/shape-library.ts +171 -0
- package/src/editor/utils/slide-root.ts +29 -0
- package/src/editor/utils/smart-guides.ts +218 -0
- package/src/editor/utils/style-utils.ts +685 -0
- package/src/editor/utils/table-edit.ts +151 -0
- package/src/editor/utils/tailwind-mappings.ts +778 -0
- package/src/editor/utils/tailwind-utils.ts +804 -0
- package/src/editor/utils/text-highlight.ts +216 -0
- package/src/editor/utils/theme-to-variables.ts +341 -0
- package/src/editor/utils/variant-resolver.ts +456 -0
- package/src/editor/utils/viewport-utils.ts +295 -0
- package/src/hooks/useEditorHistory.ts +195 -0
- package/src/hooks/useEditorShortcuts.ts +260 -0
- package/src/index.ts +40 -0
- package/src/io.ts +134 -0
- package/src/lib/agent/slide-agent/types.ts +547 -0
- package/src/lib/agent/website-agent/types.ts +805 -0
- package/src/lib/api/auth-fetch.ts +58 -0
- package/src/lib/deck.ts +69 -0
- package/src/lib/export.ts +47 -0
- package/src/lib/firebase/config.ts +29 -0
- package/src/lib/firebase/css-variables.ts +378 -0
- package/src/lib/firebase/editor-components.ts +677 -0
- package/src/lib/firebase/storage.ts +469 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/editor.css +52 -0
- package/src/styles/skin.css +122 -0
- package/src/types/css-variables.ts +645 -0
- package/src/types/editor-components.ts +944 -0
- package/src/types/editor.ts +420 -0
- package/src/types/page-master.ts +197 -0
- package/src/types/page.ts +419 -0
- package/src/types/slide.ts +402 -0
- package/src/types/website-theme.ts +788 -0
- package/src/types/website.ts +374 -0
- package/src/vendor/firebase-firestore.ts +13 -0
- package/src/vendor/firebase-functions.ts +17 -0
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ドラッグ・リサイズ処理フック
|
|
3
|
+
* - 要素のドラッグ移動(単一・複数)
|
|
4
|
+
* - リサイズハンドルによるサイズ変更
|
|
5
|
+
* - 回転ハンドルによる回転
|
|
6
|
+
* - Scale Toolによる拡大縮小
|
|
7
|
+
* - 複数要素の一括リサイズ
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useRef, useEffect, MutableRefObject } from 'react';
|
|
11
|
+
import { useEditorContext } from '../EditorContext';
|
|
12
|
+
import {
|
|
13
|
+
getArtboardContent,
|
|
14
|
+
getArtboardScale,
|
|
15
|
+
convertDragTargetsToAbsolute,
|
|
16
|
+
prepareElementDragOrigin,
|
|
17
|
+
refreshSelectionOverlay,
|
|
18
|
+
syncSelectionOverlayRects,
|
|
19
|
+
getOverlayRect,
|
|
20
|
+
setOverlayLabel,
|
|
21
|
+
} from '../utils/dom-utils';
|
|
22
|
+
import {
|
|
23
|
+
DRAG_START_THRESHOLD,
|
|
24
|
+
MIN_ELEMENT_SIZE,
|
|
25
|
+
computeResizeGeometry,
|
|
26
|
+
constrainAxis,
|
|
27
|
+
handleDirection,
|
|
28
|
+
isOutOfFlowPosition,
|
|
29
|
+
measureStyleSize,
|
|
30
|
+
roundPx,
|
|
31
|
+
} from '../utils/geometry';
|
|
32
|
+
import {
|
|
33
|
+
shouldReorder,
|
|
34
|
+
startReorder,
|
|
35
|
+
computeInsertIndex,
|
|
36
|
+
drawInsertIndicator,
|
|
37
|
+
commitReorder,
|
|
38
|
+
endReorder,
|
|
39
|
+
type ReorderSession,
|
|
40
|
+
} from '../utils/flex-reorder';
|
|
41
|
+
import {
|
|
42
|
+
collectGuideCandidates,
|
|
43
|
+
snapToGuides,
|
|
44
|
+
drawSmartGuides,
|
|
45
|
+
clearSmartGuides,
|
|
46
|
+
SNAP_THRESHOLD_SCREEN_PX,
|
|
47
|
+
type GuideCandidates,
|
|
48
|
+
type MovingRect,
|
|
49
|
+
} from '../utils/smart-guides';
|
|
50
|
+
import { extractElementInfo } from '../utils/style-utils';
|
|
51
|
+
import { convertInlineStylesToTailwind } from '../utils/tailwind-utils';
|
|
52
|
+
import { isAspectRatioLocked } from '../utils/aspect-lock';
|
|
53
|
+
import {
|
|
54
|
+
getFlexContainerInfo,
|
|
55
|
+
calculateDropIndex,
|
|
56
|
+
hideFlexDropIndicator,
|
|
57
|
+
endAutoLayoutDrag,
|
|
58
|
+
cancelAutoLayoutDrag,
|
|
59
|
+
removeDragGhost,
|
|
60
|
+
type AutoLayoutDragState,
|
|
61
|
+
} from '../utils/flex-utils';
|
|
62
|
+
import type { DragState, ResizeState, EditorTool } from '../types';
|
|
63
|
+
|
|
64
|
+
// NOTE: zoom値はiframeロード時にイベントリスナーのクロージャに閉じ込められるため、
|
|
65
|
+
// refを使って最新値を参照する必要がある
|
|
66
|
+
|
|
67
|
+
interface UseDragResizeOptions {
|
|
68
|
+
/** ドラッグ状態のref */
|
|
69
|
+
dragStateRef: MutableRefObject<DragState>;
|
|
70
|
+
/** リサイズ状態のref */
|
|
71
|
+
resizeStateRef: MutableRefObject<ResizeState>;
|
|
72
|
+
/** アクティブツールのref */
|
|
73
|
+
activeToolRef: MutableRefObject<EditorTool>;
|
|
74
|
+
/** レイアウトモードのref */
|
|
75
|
+
layoutModeRef: MutableRefObject<'absolute' | 'auto'>;
|
|
76
|
+
/** レイアウトヒント表示関数のref */
|
|
77
|
+
setShowLayoutHintRef: MutableRefObject<(show: boolean) => void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface UseDragResizeReturn {
|
|
81
|
+
/**
|
|
82
|
+
* ドラッグ・リサイズのマウス移動処理
|
|
83
|
+
* @returns true if drag/resize was handled, false otherwise
|
|
84
|
+
*/
|
|
85
|
+
handleDragResizeMouseMove: (e: MouseEvent, iframeDoc: Document) => boolean;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ドラッグ・リサイズのマウスアップ処理
|
|
89
|
+
* @returns true if drag/resize was handled, false otherwise
|
|
90
|
+
*/
|
|
91
|
+
handleDragResizeMouseUp: (iframeDoc: Document) => boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 要素情報を親ウィンドウに送信
|
|
95
|
+
*/
|
|
96
|
+
sendElementInfo: (element: HTMLElement, iframeDoc: Document) => void;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ドラッグ状態をリセット
|
|
100
|
+
*/
|
|
101
|
+
resetDragState: () => void;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* リサイズ状態をリセット
|
|
105
|
+
*/
|
|
106
|
+
resetResizeState: () => void;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ドラッグ中の操作を取り消して開始位置へ戻す
|
|
110
|
+
*
|
|
111
|
+
* Esc のキーバインドはここでは張らない(キーボードの単一経路は
|
|
112
|
+
* useKeyboardShortcuts 側が持つ)。呼び出し側から使えるように公開だけする。
|
|
113
|
+
*
|
|
114
|
+
* @param iframeDoc 省略時はドラッグ中要素の ownerDocument を使う
|
|
115
|
+
* @returns 取り消すドラッグがあった場合 true
|
|
116
|
+
*/
|
|
117
|
+
cancelDrag: (iframeDoc?: Document | null) => boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 初期ドラッグ状態
|
|
122
|
+
*/
|
|
123
|
+
const INITIAL_DRAG_STATE: DragState = {
|
|
124
|
+
element: null,
|
|
125
|
+
elements: [],
|
|
126
|
+
startX: 0,
|
|
127
|
+
startY: 0,
|
|
128
|
+
origLeft: 0,
|
|
129
|
+
origTop: 0,
|
|
130
|
+
origPositions: [],
|
|
131
|
+
isDragging: false,
|
|
132
|
+
hasMoved: false,
|
|
133
|
+
// Flex reorder mode (auto-layout)
|
|
134
|
+
flexReorderMode: false,
|
|
135
|
+
flexParent: null,
|
|
136
|
+
originalIndex: -1,
|
|
137
|
+
targetIndex: -1,
|
|
138
|
+
// Enhanced auto-layout drag (hierarchy change support)
|
|
139
|
+
autoLayoutDragMode: false,
|
|
140
|
+
dragGhost: null,
|
|
141
|
+
originalParent: null,
|
|
142
|
+
currentDropTarget: null,
|
|
143
|
+
dropPosition: null,
|
|
144
|
+
dropIndex: -1,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 初期リサイズ状態
|
|
149
|
+
*/
|
|
150
|
+
const INITIAL_RESIZE_STATE: ResizeState = {
|
|
151
|
+
isResizing: false,
|
|
152
|
+
isRotating: false,
|
|
153
|
+
element: null,
|
|
154
|
+
elements: [],
|
|
155
|
+
handle: '',
|
|
156
|
+
startX: 0,
|
|
157
|
+
startY: 0,
|
|
158
|
+
origLeft: 0,
|
|
159
|
+
origTop: 0,
|
|
160
|
+
origWidth: 0,
|
|
161
|
+
origHeight: 0,
|
|
162
|
+
origRadius: 0,
|
|
163
|
+
selectionBounds: null,
|
|
164
|
+
origElementStates: [],
|
|
165
|
+
origScaleX: 1,
|
|
166
|
+
origScaleY: 1,
|
|
167
|
+
rotation: 0,
|
|
168
|
+
rotationStartAngle: 0,
|
|
169
|
+
centerX: 0,
|
|
170
|
+
centerY: 0,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* ドラッグの種類
|
|
175
|
+
* - free : left/top を書いて自由に動かす(フローから外れている要素)
|
|
176
|
+
* - ghost : ゴーストを掴んで flex 内を並べ替える(フローの中にいる要素)
|
|
177
|
+
*/
|
|
178
|
+
type DragMode = 'free' | 'ghost';
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* リサイズの基準値スナップショット
|
|
182
|
+
*
|
|
183
|
+
* [なぜ mousedown 時の resizeState をそのまま使わないか]
|
|
184
|
+
* resizeState.origLeft/origTop は `parseFloat(el.style.left) || 0` で採られている。
|
|
185
|
+
* `absolute left-[96px]` のように Tailwind クラスだけで配置された要素は
|
|
186
|
+
* インライン style を持たないため origin が 0 と誤読され、
|
|
187
|
+
* nw/w ハンドルを掴んだ瞬間に要素が x=0 付近へ飛ぶ。
|
|
188
|
+
* origWidth/origHeight も getBoundingClientRect / React側 zoom で割っており、
|
|
189
|
+
* 実際の artboard スケールとズレる余地がある。
|
|
190
|
+
* ここでリサイズ1フレーム目に offsetLeft/offsetTop/offsetWidth/offsetHeight で
|
|
191
|
+
* 採り直し、以後のフレームはこの値だけを基準にする(誤差が蓄積しない)。
|
|
192
|
+
*/
|
|
193
|
+
interface ResizeSnapshot {
|
|
194
|
+
element: HTMLElement;
|
|
195
|
+
left: number;
|
|
196
|
+
top: number;
|
|
197
|
+
width: number;
|
|
198
|
+
height: number;
|
|
199
|
+
/** left/top を書いて対辺を固定できるか(フロー外 or 絶対配置モードで変換済み) */
|
|
200
|
+
canWritePosition: boolean;
|
|
201
|
+
/** リサイズ開始前のインライン width/height(無効だったとき元に戻すため) */
|
|
202
|
+
prevInlineWidth: string;
|
|
203
|
+
prevInlineHeight: string;
|
|
204
|
+
/** 幅・高さの書き込みが描画に反映されるか検査済みか */
|
|
205
|
+
widthChecked: boolean;
|
|
206
|
+
heightChecked: boolean;
|
|
207
|
+
/** 書いても描画が変わらない軸(flex:1 の子など) */
|
|
208
|
+
widthInert: boolean;
|
|
209
|
+
heightInert: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* このドラッグ中に一度でも書き込んだ軸
|
|
212
|
+
*
|
|
213
|
+
* Shift(比率維持)で従属軸を書いた後に Shift を離すと、
|
|
214
|
+
* 「その軸はもう動かさない」と判定されて書き込みが止まり、
|
|
215
|
+
* 比率維持で作った値が取り残される。一度触った軸は
|
|
216
|
+
* このドラッグが終わるまで責任を持って書き続ける。
|
|
217
|
+
*/
|
|
218
|
+
wroteWidth: boolean;
|
|
219
|
+
wroteHeight: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 「効かないハンドル」を表す印
|
|
224
|
+
*
|
|
225
|
+
* flex:1(flex-basis:0)の子は inline width を書いても描画が変わらない。
|
|
226
|
+
* 黙って空振りさせず、選択枠にこの印を付けて操作不能であることを示す。
|
|
227
|
+
* 見た目のスタイルは constants.ts 側(EDITOR_IFRAME_STYLES)に用意される想定だが、
|
|
228
|
+
* スタイルが無くても伝わるようにハンドルの cursor も直接落とす。
|
|
229
|
+
*/
|
|
230
|
+
const RESIZE_INERT_CLASS = 'resize-inert';
|
|
231
|
+
const RESIZE_INERT_ATTR = 'data-resize-inert';
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* transform の中の指定した関数だけを差し替える(他の関数は順序を保って残す)
|
|
235
|
+
*
|
|
236
|
+
* [なぜ必要か]
|
|
237
|
+
* 回転もScaleツールも `rotate(Xdeg) scale(Y)` と丸ごと書き直していたため、
|
|
238
|
+
* 画像の反転(scaleX(-1))や translate が、回転させた瞬間に消えていた。
|
|
239
|
+
* `scale(` は `scaleX(` に一致しないので、この置換なら反転は残る。
|
|
240
|
+
*
|
|
241
|
+
* @param value null を渡すとその関数を取り除くだけ
|
|
242
|
+
*/
|
|
243
|
+
function setTransformFn(transform: string, name: string, value: string | null): string {
|
|
244
|
+
const rest = transform
|
|
245
|
+
.replace(new RegExp(`${name}\\([^)]*\\)`, 'g'), '')
|
|
246
|
+
.trim()
|
|
247
|
+
.replace(/\s+/g, ' ');
|
|
248
|
+
if (!value) return rest;
|
|
249
|
+
return rest ? `${value} ${rest}` : value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Scaleツール用。rotate と scale だけを差し替え、
|
|
254
|
+
* 反転(scaleX/scaleY)や translate はそのまま残す
|
|
255
|
+
*/
|
|
256
|
+
function applyRotateScale(element: HTMLElement, rotation: number, scale: number): string {
|
|
257
|
+
const withScale = setTransformFn(element.style.transform || '', 'scale', `scale(${scale})`);
|
|
258
|
+
return setTransformFn(withScale, 'rotate', `rotate(${rotation}deg)`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 複数要素の外接矩形をアートボード座標で返す(単一選択も要素1個の群として扱う) */
|
|
262
|
+
function unionOverlayRect(iframeDoc: Document, elements: HTMLElement[]): MovingRect | null {
|
|
263
|
+
let left = Infinity;
|
|
264
|
+
let top = Infinity;
|
|
265
|
+
let right = -Infinity;
|
|
266
|
+
let bottom = -Infinity;
|
|
267
|
+
for (const el of elements) {
|
|
268
|
+
const r = getOverlayRect(iframeDoc, el);
|
|
269
|
+
left = Math.min(left, r.left);
|
|
270
|
+
top = Math.min(top, r.top);
|
|
271
|
+
right = Math.max(right, r.left + r.width);
|
|
272
|
+
bottom = Math.max(bottom, r.top + r.height);
|
|
273
|
+
}
|
|
274
|
+
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
|
|
275
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 角度の表示用。-180〜180 の範囲に畳む(atan2 の折り返しで 370° 等になるため) */
|
|
279
|
+
function normalizeAngle(deg: number): number {
|
|
280
|
+
const wrapped = ((deg % 360) + 360) % 360;
|
|
281
|
+
return wrapped > 180 ? wrapped - 360 : wrapped;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 回転のスナップ角(Shift 押下中) */
|
|
285
|
+
const ROTATION_SNAP_DEG = 15;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* ドラッグ・リサイズ処理フック
|
|
289
|
+
*/
|
|
290
|
+
export function useDragResize(
|
|
291
|
+
options: UseDragResizeOptions
|
|
292
|
+
): UseDragResizeReturn {
|
|
293
|
+
const { setShowLayoutHint, zoom } = useEditorContext();
|
|
294
|
+
|
|
295
|
+
const {
|
|
296
|
+
dragStateRef,
|
|
297
|
+
resizeStateRef,
|
|
298
|
+
activeToolRef,
|
|
299
|
+
layoutModeRef,
|
|
300
|
+
setShowLayoutHintRef,
|
|
301
|
+
} = options;
|
|
302
|
+
|
|
303
|
+
// zoomをrefで管理(イベントリスナーのクロージャ問題を回避)
|
|
304
|
+
// iframeロード時にイベントリスナーが設定され、その後zoom値が変わっても
|
|
305
|
+
// クロージャ内の値は更新されないため、refを使って最新値を参照する
|
|
306
|
+
const zoomRef = useRef(zoom);
|
|
307
|
+
|
|
308
|
+
// Auto-layout drag state (placeholder-based approach)
|
|
309
|
+
const autoLayoutDragStateRef = useRef<AutoLayoutDragState | null>(null);
|
|
310
|
+
|
|
311
|
+
// ドラッグの種類は「最初に閾値を超えた1回」だけ決めてキャッシュする。
|
|
312
|
+
// 毎フレーム getComputedStyle を読むと重いうえ、ドラッグ中に position を
|
|
313
|
+
// 書き換えた瞬間に意味が変わってしまう(自由移動と並べ替えが途中で入れ替わる)。
|
|
314
|
+
const dragModeRef = useRef<DragMode | null>(null);
|
|
315
|
+
|
|
316
|
+
// リサイズの基準値(1フレーム目に採る)
|
|
317
|
+
const resizeSnapshotRef = useRef<ResizeSnapshot | null>(null);
|
|
318
|
+
|
|
319
|
+
// スマートガイドの候補座標と、動かす前の外接矩形(どちらもドラッグ開始時に1回だけ採る)。
|
|
320
|
+
// 毎フレーム集め直すと数百要素で重くなるうえ、動かしている自分自身が候補に混ざる
|
|
321
|
+
const guideCandidatesRef = useRef<GuideCandidates | null>(null);
|
|
322
|
+
const dragBaseRectRef = useRef<MovingRect | null>(null);
|
|
323
|
+
|
|
324
|
+
// フレックス内の並べ替え。動き出した最初の1回だけ作り、mouseupで畳む。
|
|
325
|
+
// 並べ替え中は座標を書かないので、スマートガイドもXYバッジも出さない
|
|
326
|
+
const reorderRef = useRef<ReorderSession | null>(null);
|
|
327
|
+
const reorderIndexRef = useRef<number>(-1);
|
|
328
|
+
|
|
329
|
+
// zoomが変更されたらrefを更新
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
zoomRef.current = zoom;
|
|
332
|
+
}, [zoom]);
|
|
333
|
+
|
|
334
|
+
// 最新のsetShowLayoutHintをrefに同期
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
setShowLayoutHintRef.current = setShowLayoutHint;
|
|
337
|
+
}, [setShowLayoutHint, setShowLayoutHintRef]);
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 要素情報を親ウィンドウに送信
|
|
341
|
+
*/
|
|
342
|
+
const sendElementInfo = useCallback(
|
|
343
|
+
(element: HTMLElement, iframeDoc: Document) => {
|
|
344
|
+
const info = extractElementInfo(element, iframeDoc);
|
|
345
|
+
if (info) {
|
|
346
|
+
window.postMessage({ type: 'ELEMENT_SELECTED', element: info }, '*');
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
[]
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* ドラッグ状態をリセット
|
|
354
|
+
*/
|
|
355
|
+
const resetDragState = useCallback(() => {
|
|
356
|
+
dragStateRef.current = { ...INITIAL_DRAG_STATE };
|
|
357
|
+
dragModeRef.current = null;
|
|
358
|
+
// 次のドラッグは別の要素・別の並びから始まるので、候補と基準は持ち越さない
|
|
359
|
+
guideCandidatesRef.current = null;
|
|
360
|
+
dragBaseRectRef.current = null;
|
|
361
|
+
reorderRef.current = null;
|
|
362
|
+
reorderIndexRef.current = -1;
|
|
363
|
+
}, [dragStateRef]);
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* リサイズ状態をリセット
|
|
367
|
+
*/
|
|
368
|
+
const resetResizeState = useCallback(() => {
|
|
369
|
+
resizeStateRef.current = { ...INITIAL_RESIZE_STATE };
|
|
370
|
+
resizeSnapshotRef.current = null;
|
|
371
|
+
}, [resizeStateRef]);
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* ドラッグ・リサイズの移動量をCSS座標へ割り戻すためのスケールを返す
|
|
375
|
+
*
|
|
376
|
+
* [なぜ基準を一本化するか]
|
|
377
|
+
* 選択枠は #artboard-wrapper の transform:scale() を基準に配置している。
|
|
378
|
+
* 一方で移動量は React 側の zoom(%) から算出していたため、
|
|
379
|
+
* 両者が乖離すると「枠と要素がズレる」「移動量が合わない」が起きる。
|
|
380
|
+
* DOM上の実測値(getArtboardScale)を正とし、取得できない場合のみ zoom にフォールバックする。
|
|
381
|
+
*/
|
|
382
|
+
const getDragScale = useCallback((iframeDoc: Document): number => {
|
|
383
|
+
const artboardScale = getArtboardScale(iframeDoc);
|
|
384
|
+
if (artboardScale > 0) return artboardScale;
|
|
385
|
+
const zoomScale = (zoomRef.current || 100) / 100;
|
|
386
|
+
return zoomScale > 0 ? zoomScale : 1;
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 回転処理
|
|
391
|
+
*/
|
|
392
|
+
const handleRotation = useCallback(
|
|
393
|
+
(e: MouseEvent, iframeDoc: Document): boolean => {
|
|
394
|
+
const resizeState = resizeStateRef.current;
|
|
395
|
+
if (!resizeState.isRotating || !resizeState.element) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const element = resizeState.element;
|
|
400
|
+
|
|
401
|
+
// 現在のマウス位置から中心への角度を計算
|
|
402
|
+
const currentAngle =
|
|
403
|
+
Math.atan2(
|
|
404
|
+
e.clientY - resizeState.centerY,
|
|
405
|
+
e.clientX - resizeState.centerX
|
|
406
|
+
) *
|
|
407
|
+
(180 / Math.PI);
|
|
408
|
+
|
|
409
|
+
// 回転差分を計算
|
|
410
|
+
const angleDelta = currentAngle - resizeState.rotationStartAngle;
|
|
411
|
+
let newRotation = resizeState.rotation + angleDelta;
|
|
412
|
+
|
|
413
|
+
// Shift = 15°刻み(Figma/PowerPointと同じ逃げ道)
|
|
414
|
+
if (e.shiftKey) {
|
|
415
|
+
newRotation = Math.round(newRotation / ROTATION_SNAP_DEG) * ROTATION_SNAP_DEG;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// [修正] 以前は `rotate(x) scale(y)` と丸ごと書き直していたため、
|
|
419
|
+
// 画像の反転(scaleX(-1))や translate が回転させた瞬間に消えていた。
|
|
420
|
+
// rotate だけを差し替え、他の transform 関数はそのまま残す。
|
|
421
|
+
element.style.transform = setTransformFn(
|
|
422
|
+
element.style.transform || '',
|
|
423
|
+
'rotate',
|
|
424
|
+
`rotate(${Math.round(newRotation * 10) / 10}deg)`,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// 角度はサイズラベルと同じ場所に出す(何度回っているかを見ながら回せる)
|
|
428
|
+
setOverlayLabel(`${Math.round(normalizeAngle(newRotation))}°`);
|
|
429
|
+
|
|
430
|
+
// 選択枠は「選択セット全体」を同期する(1要素だけ作り直すと他の枠が消える)
|
|
431
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
432
|
+
|
|
433
|
+
return true;
|
|
434
|
+
},
|
|
435
|
+
[resizeStateRef]
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Scale Toolによるリサイズ処理
|
|
440
|
+
*/
|
|
441
|
+
const handleScaleResize = useCallback(
|
|
442
|
+
(
|
|
443
|
+
e: MouseEvent,
|
|
444
|
+
iframeDoc: Document,
|
|
445
|
+
deltaX: number,
|
|
446
|
+
deltaY: number
|
|
447
|
+
): boolean => {
|
|
448
|
+
const resizeState = resizeStateRef.current;
|
|
449
|
+
if (activeToolRef.current !== 'scale' || !resizeState.element) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const element = resizeState.element;
|
|
454
|
+
const handle = resizeState.handle;
|
|
455
|
+
|
|
456
|
+
// 基準サイズ
|
|
457
|
+
const origSize = resizeState.origWidth;
|
|
458
|
+
if (origSize === 0) return true;
|
|
459
|
+
|
|
460
|
+
// ハンドルによる方向係数
|
|
461
|
+
let directionX = 1;
|
|
462
|
+
let directionY = 1;
|
|
463
|
+
|
|
464
|
+
if (handle.includes('w')) directionX = -1;
|
|
465
|
+
if (handle.includes('n')) directionY = -1;
|
|
466
|
+
|
|
467
|
+
let delta = deltaX * directionX;
|
|
468
|
+
|
|
469
|
+
// 上下ハンドルの場合は高さの変化を使用
|
|
470
|
+
if (handle === 'n' || handle === 's') {
|
|
471
|
+
delta = deltaY * directionY;
|
|
472
|
+
const baseScale = resizeState.origScaleY;
|
|
473
|
+
const newScale =
|
|
474
|
+
baseScale *
|
|
475
|
+
((resizeState.origHeight + delta) / resizeState.origHeight);
|
|
476
|
+
element.style.transform = applyRotateScale(element, resizeState.rotation, newScale);
|
|
477
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const baseScale = resizeState.origScaleX;
|
|
482
|
+
const newScale =
|
|
483
|
+
baseScale *
|
|
484
|
+
((resizeState.origWidth + delta) / resizeState.origWidth);
|
|
485
|
+
|
|
486
|
+
// アスペクト比維持で適用
|
|
487
|
+
element.style.transform = applyRotateScale(element, resizeState.rotation, newScale);
|
|
488
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
489
|
+
|
|
490
|
+
return true;
|
|
491
|
+
},
|
|
492
|
+
[resizeStateRef, activeToolRef]
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 複数要素のリサイズ処理(群バウンディングボックスの変形)
|
|
497
|
+
*
|
|
498
|
+
* [単一要素と同じ規則に揃える]
|
|
499
|
+
* - 対辺固定・Shift(縦横比維持)・Alt(中心固定)は computeResizeGeometry に委ねる
|
|
500
|
+
* - 最小サイズは 1px(従来は 10px でクランプしていた)
|
|
501
|
+
* - left/top を書くかどうかはモードではなく各要素の computed position で決める
|
|
502
|
+
* (フローの中にいる要素に left を書いても描画は変わらないため、書かない)
|
|
503
|
+
*/
|
|
504
|
+
const handleMultiElementResize = useCallback(
|
|
505
|
+
(
|
|
506
|
+
iframeDoc: Document,
|
|
507
|
+
deltaX: number,
|
|
508
|
+
deltaY: number,
|
|
509
|
+
mods: { shiftKey: boolean; altKey: boolean }
|
|
510
|
+
): boolean => {
|
|
511
|
+
const resizeState = resizeStateRef.current;
|
|
512
|
+
if (
|
|
513
|
+
!resizeState.elements ||
|
|
514
|
+
resizeState.elements.length <= 1 ||
|
|
515
|
+
!resizeState.selectionBounds
|
|
516
|
+
) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const bounds = resizeState.selectionBounds;
|
|
521
|
+
const handle = resizeState.handle;
|
|
522
|
+
|
|
523
|
+
// バウンディングボックスの新しい矩形(単一要素と同じ計算規則)
|
|
524
|
+
const next = computeResizeGeometry(
|
|
525
|
+
{
|
|
526
|
+
left: bounds.left,
|
|
527
|
+
top: bounds.top,
|
|
528
|
+
width: bounds.width,
|
|
529
|
+
height: bounds.height,
|
|
530
|
+
},
|
|
531
|
+
handle,
|
|
532
|
+
deltaX,
|
|
533
|
+
deltaY,
|
|
534
|
+
{ shiftKey: mods.shiftKey, altKey: mods.altKey }
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// スケール率を計算
|
|
538
|
+
const scaleX = bounds.width > 0 ? next.width / bounds.width : 1;
|
|
539
|
+
const scaleY = bounds.height > 0 ? next.height / bounds.height : 1;
|
|
540
|
+
|
|
541
|
+
const win = iframeDoc.defaultView;
|
|
542
|
+
|
|
543
|
+
// 各要素を更新
|
|
544
|
+
resizeState.elements.forEach((el, index) => {
|
|
545
|
+
const orig = resizeState.origElementStates[index];
|
|
546
|
+
if (!orig) return;
|
|
547
|
+
|
|
548
|
+
// 相対位置を維持して座標変換
|
|
549
|
+
const relativeLeft = orig.left - bounds.left;
|
|
550
|
+
const relativeTop = orig.top - bounds.top;
|
|
551
|
+
|
|
552
|
+
const newElementWidth = Math.max(MIN_ELEMENT_SIZE, orig.width * scaleX);
|
|
553
|
+
const newElementHeight = Math.max(MIN_ELEMENT_SIZE, orig.height * scaleY);
|
|
554
|
+
const newElementLeft = next.left + relativeLeft * scaleX;
|
|
555
|
+
const newElementTop = next.top + relativeTop * scaleY;
|
|
556
|
+
|
|
557
|
+
// 位置を書けるのは「フローから外れている要素」だけ。
|
|
558
|
+
// 絶対配置モードなら従来通り static → absolute へ倒して位置を持たせる。
|
|
559
|
+
const position = win?.getComputedStyle(el).position;
|
|
560
|
+
const outOfFlow = isOutOfFlowPosition(position);
|
|
561
|
+
const canWritePosition = outOfFlow || layoutModeRef.current === 'absolute';
|
|
562
|
+
|
|
563
|
+
if (canWritePosition) {
|
|
564
|
+
// すでにフロー外なら position は触らない(fixed を absolute に落とさない)
|
|
565
|
+
if (!outOfFlow) el.style.position = 'absolute';
|
|
566
|
+
el.style.left = `${roundPx(newElementLeft)}px`;
|
|
567
|
+
el.style.top = `${roundPx(newElementTop)}px`;
|
|
568
|
+
}
|
|
569
|
+
el.style.width = `${roundPx(newElementWidth)}px`;
|
|
570
|
+
el.style.height = `${roundPx(newElementHeight)}px`;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ループ内で1要素ずつ枠を作り直すと他の枠を巻き込んで壊すため、
|
|
574
|
+
// 全要素を動かし終えてから選択セット全体を同期する
|
|
575
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
576
|
+
|
|
577
|
+
return true;
|
|
578
|
+
},
|
|
579
|
+
[resizeStateRef, layoutModeRef]
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* リサイズの基準値を実測して固定する(リサイズ1フレーム目に1度だけ)
|
|
584
|
+
*/
|
|
585
|
+
const createResizeSnapshot = useCallback(
|
|
586
|
+
(element: HTMLElement, iframeDoc: Document): ResizeSnapshot => {
|
|
587
|
+
const win = iframeDoc.defaultView;
|
|
588
|
+
const position = win?.getComputedStyle(element).position;
|
|
589
|
+
const outOfFlow = isOutOfFlowPosition(position);
|
|
590
|
+
|
|
591
|
+
// [リサイズでは絶対配置へ倒さない]
|
|
592
|
+
// 大きさを変えるのに position:absolute は要らない。width/height を書けば足りる。
|
|
593
|
+
// 倒すと要素が流れから外れ、**未選択の兄弟が空いた場所へ詰め上がって重なる**。
|
|
594
|
+
// 実例: 縦に積んだ2枚のカードの上側の幅を変えただけで、下側が上へ来て重なった。
|
|
595
|
+
//
|
|
596
|
+
// その代わり、流れの中にいる要素は left/top を書いても効かない(下の canWritePosition)。
|
|
597
|
+
// 左辺・上辺のハンドルは動かないので、markResizeInert が「効かないハンドル」として印を付ける。
|
|
598
|
+
// 黙って版面を壊すより、効かないことが見えるほうがよい。
|
|
599
|
+
const origin = prepareElementDragOrigin(element, iframeDoc, {
|
|
600
|
+
convertToAbsolute: false,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const size = measureStyleSize(element, iframeDoc);
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
element,
|
|
607
|
+
left: origin.left,
|
|
608
|
+
top: origin.top,
|
|
609
|
+
width: size.width,
|
|
610
|
+
height: size.height,
|
|
611
|
+
canWritePosition: outOfFlow,
|
|
612
|
+
prevInlineWidth: element.style.width,
|
|
613
|
+
prevInlineHeight: element.style.height,
|
|
614
|
+
widthChecked: false,
|
|
615
|
+
heightChecked: false,
|
|
616
|
+
widthInert: false,
|
|
617
|
+
heightInert: false,
|
|
618
|
+
wroteWidth: false,
|
|
619
|
+
wroteHeight: false,
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
[layoutModeRef]
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 「効かないハンドル」の印を選択枠に付ける
|
|
627
|
+
*
|
|
628
|
+
* 選択枠は操作のたびに作り直されるので、ドラッグ中と操作後の両方から呼べるようにしている。
|
|
629
|
+
*/
|
|
630
|
+
const markResizeInert = useCallback(
|
|
631
|
+
(
|
|
632
|
+
element: HTMLElement,
|
|
633
|
+
iframeDoc: Document,
|
|
634
|
+
inert: { width: boolean; height: boolean }
|
|
635
|
+
): void => {
|
|
636
|
+
if (!inert.width && !inert.height) return;
|
|
637
|
+
|
|
638
|
+
const elementId = element.getAttribute('data-element-id') || '';
|
|
639
|
+
const box = iframeDoc.querySelector<HTMLElement>(
|
|
640
|
+
`.selection-box[data-for-element="${elementId}"]`
|
|
641
|
+
);
|
|
642
|
+
if (!box) return;
|
|
643
|
+
|
|
644
|
+
box.classList.add(RESIZE_INERT_CLASS);
|
|
645
|
+
const axes = [inert.width ? 'width' : '', inert.height ? 'height' : '']
|
|
646
|
+
.filter(Boolean)
|
|
647
|
+
.join(' ');
|
|
648
|
+
box.setAttribute(RESIZE_INERT_ATTR, axes);
|
|
649
|
+
|
|
650
|
+
// 無効表示のスタイルが未整備でも伝わるよう、該当軸のハンドルの cursor を落とす
|
|
651
|
+
box.querySelectorAll<HTMLElement>('.resize-handle').forEach((handleEl) => {
|
|
652
|
+
const name = handleEl.getAttribute('data-handle') || '';
|
|
653
|
+
const affectsWidth = name.includes('e') || name.includes('w');
|
|
654
|
+
const affectsHeight = name.includes('n') || name.includes('s');
|
|
655
|
+
if (
|
|
656
|
+
(inert.width && affectsWidth) ||
|
|
657
|
+
(inert.height && affectsHeight)
|
|
658
|
+
) {
|
|
659
|
+
handleEl.style.cursor = 'not-allowed';
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
[]
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* 「書いても描画が変わらない軸」を検出して、無効なインラインスタイルを残さない
|
|
668
|
+
*
|
|
669
|
+
* 例: flex:1(flex-basis:0)の子は inline width を書いても幅が変わらない。
|
|
670
|
+
* 何も起きないまま inline width だけが残ると、保存HTMLに嘘のサイズが焼き付く
|
|
671
|
+
* (終了時に Tailwind クラスへ変換されるので、嘘がクラスとして固定化する)。
|
|
672
|
+
* 反映されなかった軸は元のインライン値へ戻し、ハンドルを操作不能として示す。
|
|
673
|
+
*/
|
|
674
|
+
const verifyResizeEffect = useCallback(
|
|
675
|
+
(
|
|
676
|
+
snapshot: ResizeSnapshot,
|
|
677
|
+
iframeDoc: Document,
|
|
678
|
+
requested: { width: number; height: number },
|
|
679
|
+
drives: { width: boolean; height: boolean }
|
|
680
|
+
): void => {
|
|
681
|
+
if (snapshot.widthChecked && snapshot.heightChecked) return;
|
|
682
|
+
|
|
683
|
+
const rendered = measureStyleSize(snapshot.element, iframeDoc);
|
|
684
|
+
let found = false;
|
|
685
|
+
|
|
686
|
+
// 丸めの都合で 1px 未満の要求は判定材料にならないため 2px 以上動かしたときだけ判定する
|
|
687
|
+
if (
|
|
688
|
+
drives.width &&
|
|
689
|
+
!snapshot.widthChecked &&
|
|
690
|
+
Math.abs(requested.width - snapshot.width) >= 2
|
|
691
|
+
) {
|
|
692
|
+
snapshot.widthChecked = true;
|
|
693
|
+
if (Math.abs(rendered.width - snapshot.width) < 0.5) {
|
|
694
|
+
snapshot.widthInert = true;
|
|
695
|
+
snapshot.element.style.width = snapshot.prevInlineWidth;
|
|
696
|
+
found = true;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (
|
|
700
|
+
drives.height &&
|
|
701
|
+
!snapshot.heightChecked &&
|
|
702
|
+
Math.abs(requested.height - snapshot.height) >= 2
|
|
703
|
+
) {
|
|
704
|
+
snapshot.heightChecked = true;
|
|
705
|
+
if (Math.abs(rendered.height - snapshot.height) < 0.5) {
|
|
706
|
+
snapshot.heightInert = true;
|
|
707
|
+
snapshot.element.style.height = snapshot.prevInlineHeight;
|
|
708
|
+
found = true;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!found) return;
|
|
713
|
+
markResizeInert(snapshot.element, iframeDoc, {
|
|
714
|
+
width: snapshot.widthInert,
|
|
715
|
+
height: snapshot.heightInert,
|
|
716
|
+
});
|
|
717
|
+
},
|
|
718
|
+
[markResizeInert]
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* 単一要素のリサイズ処理
|
|
723
|
+
*
|
|
724
|
+
* [対辺固定をモードで分岐させない]
|
|
725
|
+
* 従来は `layoutModeRef.current === 'absolute'` のときしか left/top を書かなかったため、
|
|
726
|
+
* 既定のオートレイアウトモードでは nw/n/w/sw が対辺を固定できず、
|
|
727
|
+
* 掴んだ辺ではなく反対側が伸びていた。
|
|
728
|
+
* 位置を書けるかどうかは「モード」ではなく「その要素がフローから外れているか」で決まる。
|
|
729
|
+
*/
|
|
730
|
+
const handleSingleElementResize = useCallback(
|
|
731
|
+
(
|
|
732
|
+
iframeDoc: Document,
|
|
733
|
+
deltaX: number,
|
|
734
|
+
deltaY: number,
|
|
735
|
+
mods: { shiftKey: boolean; altKey: boolean }
|
|
736
|
+
): boolean => {
|
|
737
|
+
const resizeState = resizeStateRef.current;
|
|
738
|
+
if (!resizeState.element) return false;
|
|
739
|
+
|
|
740
|
+
const element = resizeState.element;
|
|
741
|
+
const handle = resizeState.handle;
|
|
742
|
+
|
|
743
|
+
// border-radius調整
|
|
744
|
+
if (handle === 'radius') {
|
|
745
|
+
const maxRadius =
|
|
746
|
+
Math.min(resizeState.origWidth, resizeState.origHeight) / 2;
|
|
747
|
+
const newRadius = Math.max(
|
|
748
|
+
0,
|
|
749
|
+
Math.min(maxRadius, resizeState.origRadius - deltaX - deltaY)
|
|
750
|
+
);
|
|
751
|
+
element.style.borderRadius = `${roundPx(newRadius)}px`;
|
|
752
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// 1フレーム目に基準値を実測して固定する(以降のフレームは常にこの値が基準)
|
|
757
|
+
let snapshot = resizeSnapshotRef.current;
|
|
758
|
+
if (!snapshot || snapshot.element !== element) {
|
|
759
|
+
snapshot = createResizeSnapshot(element, iframeDoc);
|
|
760
|
+
resizeSnapshotRef.current = snapshot;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const next = computeResizeGeometry(
|
|
764
|
+
{
|
|
765
|
+
left: snapshot.left,
|
|
766
|
+
top: snapshot.top,
|
|
767
|
+
width: snapshot.width,
|
|
768
|
+
height: snapshot.height,
|
|
769
|
+
},
|
|
770
|
+
handle,
|
|
771
|
+
deltaX,
|
|
772
|
+
deltaY,
|
|
773
|
+
{ shiftKey: mods.shiftKey, altKey: mods.altKey }
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
// 掴んだハンドルが実際に動かす軸だけを書く。
|
|
777
|
+
// (例: e ハンドルで height まで書くと、触っていない高さが inline → Tailwind クラスへ
|
|
778
|
+
// 焼き付いてしまう。Shift の比率維持のときだけ従属軸も動く)
|
|
779
|
+
// 一度でも書いた軸は、途中で Shift を離しても書き続ける
|
|
780
|
+
// (書くのをやめると比率維持で作った値が取り残されて元に戻らない)
|
|
781
|
+
const dir = handleDirection(handle);
|
|
782
|
+
const drives = {
|
|
783
|
+
width:
|
|
784
|
+
dir.x !== 0 || (!!mods.shiftKey && dir.y !== 0) || snapshot.wroteWidth,
|
|
785
|
+
height:
|
|
786
|
+
dir.y !== 0 || (!!mods.shiftKey && dir.x !== 0) || snapshot.wroteHeight,
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// 位置(=対辺の固定)はフローから外れている要素にだけ意味がある。
|
|
790
|
+
// position 自体は snapshot 作成時(prepareElementDragOrigin)で確定済みなので
|
|
791
|
+
// ここでは触らない(fixed の要素を absolute に書き換えて包含ブロックを変えないため)
|
|
792
|
+
if (snapshot.canWritePosition) {
|
|
793
|
+
element.style.left = `${next.left}px`;
|
|
794
|
+
element.style.top = `${next.top}px`;
|
|
795
|
+
}
|
|
796
|
+
if (drives.width && !snapshot.widthInert) {
|
|
797
|
+
element.style.width = `${next.width}px`;
|
|
798
|
+
snapshot.wroteWidth = true;
|
|
799
|
+
}
|
|
800
|
+
if (drives.height && !snapshot.heightInert) {
|
|
801
|
+
element.style.height = `${next.height}px`;
|
|
802
|
+
snapshot.wroteHeight = true;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 書いた結果が描画に反映されたかを1度だけ検査する
|
|
806
|
+
verifyResizeEffect(snapshot, iframeDoc, next, drives);
|
|
807
|
+
|
|
808
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
809
|
+
|
|
810
|
+
return true;
|
|
811
|
+
},
|
|
812
|
+
[resizeStateRef, createResizeSnapshot, verifyResizeEffect]
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* ドラッグ対象の各要素と、その開始座標の組を返す
|
|
817
|
+
* 単一選択も「要素1個の群」として同じ経路で扱い、選択数で意味が変わらないようにする
|
|
818
|
+
*/
|
|
819
|
+
const getDragTargets = useCallback(
|
|
820
|
+
(
|
|
821
|
+
dragState: DragState
|
|
822
|
+
): { element: HTMLElement; origin: { left: number; top: number } }[] => {
|
|
823
|
+
const elements =
|
|
824
|
+
dragState.elements.length > 0
|
|
825
|
+
? dragState.elements
|
|
826
|
+
: dragState.element
|
|
827
|
+
? [dragState.element]
|
|
828
|
+
: [];
|
|
829
|
+
|
|
830
|
+
return elements
|
|
831
|
+
.map((element, i) => {
|
|
832
|
+
const origin =
|
|
833
|
+
dragState.origPositions[i] ??
|
|
834
|
+
(i === 0
|
|
835
|
+
? { left: dragState.origLeft, top: dragState.origTop }
|
|
836
|
+
: null);
|
|
837
|
+
return origin ? { element, origin } : null;
|
|
838
|
+
})
|
|
839
|
+
.filter(
|
|
840
|
+
(v): v is { element: HTMLElement; origin: { left: number; top: number } } =>
|
|
841
|
+
v !== null
|
|
842
|
+
);
|
|
843
|
+
},
|
|
844
|
+
[]
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* ドラッグの種類を決める
|
|
849
|
+
*
|
|
850
|
+
* [なぜ position で決めるか]
|
|
851
|
+
* 従来は「選択数 > 1」と「レイアウトモード」で分岐していたため、
|
|
852
|
+
* 既定のオートレイアウトモードでは 1個選ぶと ghost 並べ替えに入って
|
|
853
|
+
* left/top を一切書かず(=絶対配置の要素が動かない)、
|
|
854
|
+
* 2個選ぶと自由移動になる、という一貫性のない状態になっていた。
|
|
855
|
+
* 実際に何ができるかを決めるのは要素の computed position だけなので、それで判定する。
|
|
856
|
+
* - absolute / fixed → left/top で自由移動(モードにも選択数にも依存しない)
|
|
857
|
+
* - static など → フローの中にいるので ghost で並べ替える
|
|
858
|
+
*/
|
|
859
|
+
const resolveDragMode = useCallback(
|
|
860
|
+
(): DragMode => {
|
|
861
|
+
// [モード廃止] ドラッグは常に自由移動。
|
|
862
|
+
// 以前は in-flow の要素を「ゴースト並べ替え」(ドロップ先へ再挿入)に振り分けていたが、
|
|
863
|
+
// 開いた時の一括変換から漏れた要素がこれを踏むと、離した瞬間に別の場所へ
|
|
864
|
+
// 再挿入されて「画面の外へ消える」ように見えていた。
|
|
865
|
+
// in-flow の要素は動き始めに絶対配置へ倒して、同じ自由移動で扱う。
|
|
866
|
+
return 'free';
|
|
867
|
+
},
|
|
868
|
+
[]
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* ドラッグ移動処理
|
|
873
|
+
*/
|
|
874
|
+
const handleDrag = useCallback(
|
|
875
|
+
(e: MouseEvent, iframeDoc: Document): boolean => {
|
|
876
|
+
const dragState = dragStateRef.current;
|
|
877
|
+
if (!dragState.isDragging || !dragState.element) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 移動量をCSS座標へ割り戻す。基準は選択枠と同じ artboard のスケールに統一する
|
|
882
|
+
const scale = getDragScale(iframeDoc);
|
|
883
|
+
const rawDeltaX = (e.clientX - dragState.startX) / scale;
|
|
884
|
+
const rawDeltaY = (e.clientY - dragState.startY) / scale;
|
|
885
|
+
|
|
886
|
+
// クリックとドラッグの区別。閾値を超えた最初の1回だけ種類を決めてキャッシュする
|
|
887
|
+
if (dragModeRef.current === null) {
|
|
888
|
+
if (
|
|
889
|
+
Math.abs(rawDeltaX) <= DRAG_START_THRESHOLD &&
|
|
890
|
+
Math.abs(rawDeltaY) <= DRAG_START_THRESHOLD
|
|
891
|
+
) {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
dragModeRef.current = resolveDragMode();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// [モード廃止] ここにあった「ゴースト並べ替え」(フロー内の要素をドロップ先へ
|
|
898
|
+
// 再挿入するオートレイアウトのドラッグ)は撤去した。in-flow の要素は下で
|
|
899
|
+
// 絶対配置へ倒して自由移動する。並べ替えに入ると、離した位置のドロップ先へ
|
|
900
|
+
// 要素が再挿入され「画面の外に消える」ように見えていた。
|
|
901
|
+
|
|
902
|
+
// 自由移動。Shift の軸拘束は「押した瞬間」ではなく毎フレーム評価する
|
|
903
|
+
const { dx, dy } = constrainAxis(rawDeltaX, rawDeltaY, e.shiftKey);
|
|
904
|
+
|
|
905
|
+
// 動き出した最初の1回だけ、このドラッグの性格を決める。
|
|
906
|
+
// オートレイアウト(flex)の子なら「並べ替え」、それ以外は従来どおり座標移動
|
|
907
|
+
if (!dragState.hasMoved) {
|
|
908
|
+
const targets = getDragTargets(dragState);
|
|
909
|
+
const movingElements = targets.map((t) => t.element);
|
|
910
|
+
const componentEdit = iframeDoc.body.classList.contains('component-edit-mode');
|
|
911
|
+
|
|
912
|
+
// Cmd/Ctrl は「並べ替えから抜けて自由に動かす」逃げ道。
|
|
913
|
+
// スナップの無効化と同じキーに揃える(押している間は座標がそのまま通る)
|
|
914
|
+
const wantsFreeMove = e.metaKey || e.ctrlKey;
|
|
915
|
+
|
|
916
|
+
if (
|
|
917
|
+
!componentEdit &&
|
|
918
|
+
!wantsFreeMove &&
|
|
919
|
+
dragState.element &&
|
|
920
|
+
shouldReorder(dragState.element, iframeDoc, movingElements.length)
|
|
921
|
+
) {
|
|
922
|
+
// 並べ替え: 絶対配置へ倒さない。倒した瞬間にフレックスの設定が
|
|
923
|
+
// 効かなくなり、右パネルのレイアウト指定が無意味になる
|
|
924
|
+
reorderRef.current = startReorder(dragState.element, iframeDoc);
|
|
925
|
+
reorderIndexRef.current = -1;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!reorderRef.current) {
|
|
929
|
+
// in-flow の要素はここで初めて絶対配置へ変換する。
|
|
930
|
+
// mousedown 時に変換すると、選んだだけで後続の兄弟が詰め上がって版面が動く。
|
|
931
|
+
// コンポーネント編集中は変換しない(部品本来の並びを保つため)
|
|
932
|
+
if (!componentEdit) {
|
|
933
|
+
convertDragTargetsToAbsolute(targets, iframeDoc);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// スマートガイドの下ごしらえ。動き出した最初の1回だけ、
|
|
937
|
+
// 「整列先の候補」と「動かす前の外接矩形」をアートボード座標で採る。
|
|
938
|
+
// 以後のフレームは基準に delta を足すだけなので、要素数に関係なく軽い
|
|
939
|
+
dragBaseRectRef.current = unionOverlayRect(iframeDoc, movingElements);
|
|
940
|
+
guideCandidatesRef.current = collectGuideCandidates(iframeDoc, movingElements);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
dragState.hasMoved = true;
|
|
945
|
+
|
|
946
|
+
// ── 並べ替えモード ──
|
|
947
|
+
// 座標は一切書かない。DOMの順番だけを mouseup で変える。
|
|
948
|
+
// 途中で並べ替えると掴んでいる要素が指の下から逃げるので、
|
|
949
|
+
// ドラッグ中は「どこに入るか」を線で示すだけに留める
|
|
950
|
+
const reorder = reorderRef.current;
|
|
951
|
+
if (reorder) {
|
|
952
|
+
const index = computeInsertIndex(reorder, e.clientX, e.clientY, iframeDoc);
|
|
953
|
+
if (index !== reorder.lastIndex) {
|
|
954
|
+
reorder.lastIndex = index;
|
|
955
|
+
drawInsertIndicator(reorder, index, iframeDoc);
|
|
956
|
+
}
|
|
957
|
+
reorderIndexRef.current = index;
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// スナップ補正。Cmd/Ctrl を押している間は吸着しない(Figmaと同じ逃げ道)
|
|
962
|
+
let snappedDx = dx;
|
|
963
|
+
let snappedDy = dy;
|
|
964
|
+
const candidates = guideCandidatesRef.current;
|
|
965
|
+
const baseRect = dragBaseRectRef.current;
|
|
966
|
+
if (!e.metaKey && !e.ctrlKey && candidates && baseRect) {
|
|
967
|
+
// 吸着距離は「画面上で4px」。ズームで見た目の効きが変わらないよう割り戻す
|
|
968
|
+
const threshold = SNAP_THRESHOLD_SCREEN_PX / (scale || 1);
|
|
969
|
+
const snap = snapToGuides(
|
|
970
|
+
candidates,
|
|
971
|
+
{
|
|
972
|
+
left: baseRect.left + dx,
|
|
973
|
+
top: baseRect.top + dy,
|
|
974
|
+
width: baseRect.width,
|
|
975
|
+
height: baseRect.height,
|
|
976
|
+
},
|
|
977
|
+
threshold,
|
|
978
|
+
);
|
|
979
|
+
// 補正は delta として足す。origin(offsetLeft基準)とアートボード座標の
|
|
980
|
+
// 原点がずれていても、差分なら基準の違いが打ち消し合う
|
|
981
|
+
snappedDx = dx + snap.dx;
|
|
982
|
+
snappedDy = dy + snap.dy;
|
|
983
|
+
|
|
984
|
+
// Shift(軸拘束)中は、拘束した軸へスナップで動かさない。
|
|
985
|
+
// constrainAxis が 0 にした側に補正を足すと、まっすぐ動かしたいのに
|
|
986
|
+
// 斜めへずれる(拘束が破れる)
|
|
987
|
+
let guideX = snap.guideX;
|
|
988
|
+
let guideY = snap.guideY;
|
|
989
|
+
if (e.shiftKey) {
|
|
990
|
+
if (dx === 0) {
|
|
991
|
+
snappedDx = 0;
|
|
992
|
+
guideX = null;
|
|
993
|
+
}
|
|
994
|
+
if (dy === 0) {
|
|
995
|
+
snappedDy = 0;
|
|
996
|
+
guideY = null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
drawSmartGuides(iframeDoc, guideX, guideY);
|
|
1000
|
+
} else {
|
|
1001
|
+
clearSmartGuides(iframeDoc);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// 単一選択も複数選択も、全要素に「同一の delta」を加算する(群ごと移動)。
|
|
1005
|
+
// origin は mousedown 時に offsetLeft/offsetTop で採ってあり、
|
|
1006
|
+
// 書き込む直前に Math.round を通すので、要素ごとに移動量がズレない。
|
|
1007
|
+
getDragTargets(dragState).forEach(({ element, origin }) => {
|
|
1008
|
+
element.classList.add('dragging');
|
|
1009
|
+
element.style.left = `${roundPx(origin.left + snappedDx)}px`;
|
|
1010
|
+
element.style.top = `${roundPx(origin.top + snappedDy)}px`;
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// 移動中の X, Y をサイズラベルと同じ場所に出す(Figmaと同じ)
|
|
1014
|
+
if (baseRect) {
|
|
1015
|
+
setOverlayLabel(
|
|
1016
|
+
`${Math.round(baseRect.left + snappedDx)}, ${Math.round(baseRect.top + snappedDy)}`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// 選択枠(各要素の枠 + 群バウンディングボックス)を要素に追従させる。
|
|
1021
|
+
// 枠を作り直さず位置だけ同期するので、掴んでいるハンドルやパンくずの
|
|
1022
|
+
// イベントリスナーが失われない
|
|
1023
|
+
syncSelectionOverlayRects(iframeDoc);
|
|
1024
|
+
|
|
1025
|
+
return true;
|
|
1026
|
+
},
|
|
1027
|
+
// zoomはzoomRefで参照するため依存配列から除外
|
|
1028
|
+
[dragStateRef, getDragScale, getDragTargets, resolveDragMode]
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* ドラッグ中の操作を取り消して開始位置へ戻す
|
|
1033
|
+
*
|
|
1034
|
+
* Esc のキーバインドはここでは張らない(keydown の経路を1本に保つため)。
|
|
1035
|
+
* 呼び出し側(キーボード担当)から使えるように公開だけする。
|
|
1036
|
+
*/
|
|
1037
|
+
const cancelDrag = useCallback(
|
|
1038
|
+
(iframeDoc?: Document | null): boolean => {
|
|
1039
|
+
const dragState = dragStateRef.current;
|
|
1040
|
+
if (!dragState.isDragging || !dragState.element) return false;
|
|
1041
|
+
|
|
1042
|
+
const doc = iframeDoc ?? dragState.element.ownerDocument;
|
|
1043
|
+
if (!doc) return false;
|
|
1044
|
+
|
|
1045
|
+
// 並べ替え中の取り消し: DOMの順番はまだ変えていないので、
|
|
1046
|
+
// 持ち上げの見た目と線を戻すだけで元の状態に戻る
|
|
1047
|
+
if (reorderRef.current) {
|
|
1048
|
+
endReorder(reorderRef.current, doc);
|
|
1049
|
+
resetDragState();
|
|
1050
|
+
refreshSelectionOverlay(doc);
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (autoLayoutDragStateRef.current) {
|
|
1055
|
+
// ゴースト並べ替え中:DOMの並びは変えずにゴーストだけ畳む
|
|
1056
|
+
cancelAutoLayoutDrag(autoLayoutDragStateRef.current, doc);
|
|
1057
|
+
autoLayoutDragStateRef.current = null;
|
|
1058
|
+
hideFlexDropIndicator(doc);
|
|
1059
|
+
} else {
|
|
1060
|
+
// 自由移動中:開始座標へ戻す
|
|
1061
|
+
getDragTargets(dragState).forEach(({ element, origin }) => {
|
|
1062
|
+
element.classList.remove('dragging');
|
|
1063
|
+
element.style.left = `${roundPx(origin.left)}px`;
|
|
1064
|
+
element.style.top = `${roundPx(origin.top)}px`;
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ガイド線と X, Y バッジは操作の道具なので、取り消しでも必ず片付ける
|
|
1069
|
+
clearSmartGuides(doc);
|
|
1070
|
+
setOverlayLabel(null);
|
|
1071
|
+
|
|
1072
|
+
// 取り消しなので SLIDE_CONTENT_CHANGED は送らない(履歴を汚さない)
|
|
1073
|
+
resetDragState();
|
|
1074
|
+
refreshSelectionOverlay(doc);
|
|
1075
|
+
return true;
|
|
1076
|
+
},
|
|
1077
|
+
[dragStateRef, getDragTargets, resetDragState]
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* ドラッグ・リサイズのマウス移動処理
|
|
1082
|
+
*/
|
|
1083
|
+
const handleDragResizeMouseMove = useCallback(
|
|
1084
|
+
(e: MouseEvent, iframeDoc: Document): boolean => {
|
|
1085
|
+
// 回転中
|
|
1086
|
+
if (handleRotation(e, iframeDoc)) {
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// リサイズ中
|
|
1091
|
+
const resizeState = resizeStateRef.current;
|
|
1092
|
+
if (resizeState.isResizing && resizeState.element) {
|
|
1093
|
+
// 移動量をCSS座標へ割り戻す。基準は選択枠と同じ artboard のスケールに統一する
|
|
1094
|
+
const scale = getDragScale(iframeDoc);
|
|
1095
|
+
const deltaX = (e.clientX - resizeState.startX) / scale;
|
|
1096
|
+
const deltaY = (e.clientY - resizeState.startY) / scale;
|
|
1097
|
+
|
|
1098
|
+
// 修飾キーは毎フレーム読む(押し直し・離しに即応させるため)
|
|
1099
|
+
// プロパティパネルの縦横比ロックは「Shift を押しっぱなし」と同義なので、
|
|
1100
|
+
// 別フラグを足さずに shiftKey へ畳み込む(従属軸を書く判定もそのまま効かせるため)。
|
|
1101
|
+
// ロック状態は React state ではなく関数で読む(このハンドラは古い値を掴むため)。
|
|
1102
|
+
const mods = {
|
|
1103
|
+
shiftKey: e.shiftKey || isAspectRatioLocked(),
|
|
1104
|
+
altKey: e.altKey,
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
// Scale Toolによるリサイズ
|
|
1108
|
+
if (handleScaleResize(e, iframeDoc, deltaX, deltaY)) {
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// 複数要素のリサイズ
|
|
1113
|
+
if (handleMultiElementResize(iframeDoc, deltaX, deltaY, mods)) {
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// 単一要素のリサイズ
|
|
1118
|
+
if (handleSingleElementResize(iframeDoc, deltaX, deltaY, mods)) {
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ドラッグ中
|
|
1124
|
+
if (handleDrag(e, iframeDoc)) {
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return false;
|
|
1129
|
+
},
|
|
1130
|
+
[
|
|
1131
|
+
handleRotation,
|
|
1132
|
+
handleScaleResize,
|
|
1133
|
+
handleMultiElementResize,
|
|
1134
|
+
handleSingleElementResize,
|
|
1135
|
+
handleDrag,
|
|
1136
|
+
resizeStateRef,
|
|
1137
|
+
getDragScale,
|
|
1138
|
+
// zoomはzoomRefで参照するため依存配列から除外
|
|
1139
|
+
]
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* ドラッグ・リサイズのマウスアップ処理
|
|
1144
|
+
*/
|
|
1145
|
+
const handleDragResizeMouseUp = useCallback(
|
|
1146
|
+
(iframeDoc: Document): boolean => {
|
|
1147
|
+
const resizeState = resizeStateRef.current;
|
|
1148
|
+
const dragState = dragStateRef.current;
|
|
1149
|
+
|
|
1150
|
+
// 回転終了
|
|
1151
|
+
if (resizeState.isRotating && resizeState.element) {
|
|
1152
|
+
iframeDoc.body.classList.remove('rotating');
|
|
1153
|
+
setOverlayLabel(null);
|
|
1154
|
+
|
|
1155
|
+
window.postMessage(
|
|
1156
|
+
{
|
|
1157
|
+
type: 'SLIDE_CONTENT_CHANGED',
|
|
1158
|
+
html: getArtboardContent(iframeDoc),
|
|
1159
|
+
},
|
|
1160
|
+
'*'
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
sendElementInfo(resizeState.element, iframeDoc);
|
|
1164
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1165
|
+
resetResizeState();
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// リサイズ終了
|
|
1170
|
+
if (resizeState.isResizing && resizeState.element) {
|
|
1171
|
+
// インラインスタイルをTailwindクラスに変換
|
|
1172
|
+
// リサイズ中はパフォーマンスのためインラインスタイルを使用し、
|
|
1173
|
+
// 終了時にTailwindクラスに変換する
|
|
1174
|
+
const resizeProperties = ['width', 'height', 'left', 'top', 'borderRadius'];
|
|
1175
|
+
if (resizeState.elements && resizeState.elements.length > 0) {
|
|
1176
|
+
resizeState.elements.forEach((el) => {
|
|
1177
|
+
convertInlineStylesToTailwind(el, resizeProperties);
|
|
1178
|
+
});
|
|
1179
|
+
} else {
|
|
1180
|
+
convertInlineStylesToTailwind(resizeState.element, resizeProperties);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
window.postMessage(
|
|
1184
|
+
{
|
|
1185
|
+
type: 'SLIDE_CONTENT_CHANGED',
|
|
1186
|
+
html: getArtboardContent(iframeDoc),
|
|
1187
|
+
},
|
|
1188
|
+
'*'
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
// 複数選択時は全要素の情報を送信
|
|
1192
|
+
if (resizeState.elements && resizeState.elements.length > 0) {
|
|
1193
|
+
resizeState.elements.forEach((el) =>
|
|
1194
|
+
sendElementInfo(el, iframeDoc)
|
|
1195
|
+
);
|
|
1196
|
+
} else {
|
|
1197
|
+
sendElementInfo(resizeState.element, iframeDoc);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Tailwindクラスへの丸め込みで実寸がわずかに変わるため、
|
|
1201
|
+
// 変換後に選択セット全体から枠を作り直して再同期する
|
|
1202
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1203
|
+
|
|
1204
|
+
// 枠を作り直すと「効かないハンドル」の印が消える。
|
|
1205
|
+
// 操作をやめた後も操作不能であることが分かるように付け直す
|
|
1206
|
+
const snapshot = resizeSnapshotRef.current;
|
|
1207
|
+
if (snapshot && (snapshot.widthInert || snapshot.heightInert)) {
|
|
1208
|
+
markResizeInert(snapshot.element, iframeDoc, {
|
|
1209
|
+
width: snapshot.widthInert,
|
|
1210
|
+
height: snapshot.heightInert,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
resetResizeState();
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ドラッグ終了
|
|
1219
|
+
if (dragState.isDragging && dragState.element) {
|
|
1220
|
+
// ガイド線は #artboard 直下にあり、getArtboardContent は innerHTML を
|
|
1221
|
+
// そのまま返す。SLIDE_CONTENT_CHANGED を送る前に必ず消す
|
|
1222
|
+
clearSmartGuides(iframeDoc);
|
|
1223
|
+
setOverlayLabel(null);
|
|
1224
|
+
|
|
1225
|
+
// ── 並べ替えの確定 ──
|
|
1226
|
+
// 座標(left/top)は書いていないので Tailwind への変換は通さない。
|
|
1227
|
+
// 変わったのは DOM の順番だけで、それは innerHTML にそのまま出る
|
|
1228
|
+
const reorder = reorderRef.current;
|
|
1229
|
+
if (reorder) {
|
|
1230
|
+
const changed = commitReorder(reorder, reorderIndexRef.current, iframeDoc);
|
|
1231
|
+
reorderRef.current = null;
|
|
1232
|
+
reorderIndexRef.current = -1;
|
|
1233
|
+
dragState.element.classList.remove('dragging');
|
|
1234
|
+
|
|
1235
|
+
if (changed) {
|
|
1236
|
+
window.postMessage(
|
|
1237
|
+
{
|
|
1238
|
+
type: 'SLIDE_CONTENT_CHANGED',
|
|
1239
|
+
html: getArtboardContent(iframeDoc),
|
|
1240
|
+
},
|
|
1241
|
+
'*',
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
// 並べ替えで要素の位置が変わるので、枠は作り直して合わせる
|
|
1245
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1246
|
+
sendElementInfo(dragState.element, iframeDoc);
|
|
1247
|
+
resetDragState();
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// [モード廃止] ゴースト並べ替えのドロップ確定はここにあったが、
|
|
1252
|
+
// ドラッグが常に自由移動になったため撤去した。
|
|
1253
|
+
|
|
1254
|
+
// [モード廃止] 旧flex並べ替え(flexReorderMode)もここにあったが、
|
|
1255
|
+
// これを立てるコードはもう存在しないため撤去した。
|
|
1256
|
+
|
|
1257
|
+
// 通常のドラッグ終了処理
|
|
1258
|
+
// 複数選択時は全要素のdraggingクラスを削除
|
|
1259
|
+
if (dragState.elements.length > 1) {
|
|
1260
|
+
dragState.elements.forEach((el) => el.classList.remove('dragging'));
|
|
1261
|
+
} else {
|
|
1262
|
+
dragState.element.classList.remove('dragging');
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// インラインスタイルをTailwindクラスに変換
|
|
1266
|
+
// ドラッグ中はパフォーマンスのためインラインスタイルを使用し、
|
|
1267
|
+
// 終了時にTailwindクラスに変換する
|
|
1268
|
+
if (dragState.hasMoved) {
|
|
1269
|
+
const dragProperties = ['left', 'top'];
|
|
1270
|
+
if (dragState.elements.length > 1) {
|
|
1271
|
+
dragState.elements.forEach((el) => {
|
|
1272
|
+
convertInlineStylesToTailwind(el, dragProperties);
|
|
1273
|
+
});
|
|
1274
|
+
} else {
|
|
1275
|
+
convertInlineStylesToTailwind(dragState.element, dragProperties);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
window.postMessage(
|
|
1279
|
+
{
|
|
1280
|
+
type: 'SLIDE_CONTENT_CHANGED',
|
|
1281
|
+
html: getArtboardContent(iframeDoc),
|
|
1282
|
+
},
|
|
1283
|
+
'*'
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// [重要] convertInlineStylesToTailwind は left/top を `left-[142px]` のような
|
|
1288
|
+
// クラスへ丸め込むため、要素の実描画位置がわずかに変わる。
|
|
1289
|
+
// 変換後に選択セット全体から枠を作り直さないと、枠が古い位置に残る。
|
|
1290
|
+
refreshSelectionOverlay(iframeDoc);
|
|
1291
|
+
|
|
1292
|
+
sendElementInfo(dragState.element, iframeDoc);
|
|
1293
|
+
resetDragState();
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return false;
|
|
1298
|
+
},
|
|
1299
|
+
[
|
|
1300
|
+
resizeStateRef,
|
|
1301
|
+
dragStateRef,
|
|
1302
|
+
sendElementInfo,
|
|
1303
|
+
resetResizeState,
|
|
1304
|
+
resetDragState,
|
|
1305
|
+
markResizeInert,
|
|
1306
|
+
]
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
return {
|
|
1310
|
+
handleDragResizeMouseMove,
|
|
1311
|
+
handleDragResizeMouseUp,
|
|
1312
|
+
sendElementInfo,
|
|
1313
|
+
resetDragState,
|
|
1314
|
+
resetResizeState,
|
|
1315
|
+
cancelDrag,
|
|
1316
|
+
};
|
|
1317
|
+
}
|