@aiquants/drag-drop-panels 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -10
  3. package/dist/drag-drop/hooks/useCustomDragHandlers.d.ts.map +1 -1
  4. package/dist/index.es.js +88 -88
  5. package/dist/index.umd.js +2 -2
  6. package/dist/styles/drag-drop-panels.standalone.css +3 -0
  7. package/package.json +11 -4
  8. package/src/DragDropLayout.tsx +751 -0
  9. package/src/drag-drop/ColumnControlPanel.tsx +123 -0
  10. package/src/drag-drop/ColumnInsertPanel.tsx +106 -0
  11. package/src/drag-drop/DebugComponents.tsx +222 -0
  12. package/src/drag-drop/DebugControlPanel.tsx +200 -0
  13. package/src/drag-drop/DebugOverlay.tsx +250 -0
  14. package/src/drag-drop/DragDropLayout.css +130 -0
  15. package/src/drag-drop/ResizeHandle.tsx +208 -0
  16. package/src/drag-drop/ResponsiveControl.tsx +33 -0
  17. package/src/drag-drop/hooks/dragGhostScale.spec.tsx +153 -0
  18. package/src/drag-drop/hooks/useColumnLayout.ts +457 -0
  19. package/src/drag-drop/hooks/useCustomDragHandlers.tsx +333 -0
  20. package/src/drag-drop/hooks/useDragDropColumns.tsx +446 -0
  21. package/src/drag-drop/hooks/useDragDropState.tsx +200 -0
  22. package/src/drag-drop/hooks/useFitScale.ts +48 -0
  23. package/src/drag-drop/hooks/useMouseTracking.tsx +102 -0
  24. package/src/drag-drop/hooks/useNormalDragHandlers.tsx +427 -0
  25. package/src/drag-drop/hooks/usePanelManagement.tsx +238 -0
  26. package/src/drag-drop/hooks/useResponsiveColumns.ts +210 -0
  27. package/src/drag-drop/resize-handle.css +71 -0
  28. package/src/drag-drop/types.ts +248 -0
  29. package/src/drag-drop/utils/columnIdUtils.ts +104 -0
  30. package/src/drag-drop/utils/edgeScrollUtils.ts +124 -0
  31. package/src/drag-drop/utils/simple-logger.ts +162 -0
  32. package/src/index.ts +60 -0
  33. package/src/styles/standalone.entry.css +27 -0
@@ -0,0 +1,751 @@
1
+ /**
2
+ * @fileoverview This file defines the main layout component for the drag and drop interface.
3
+ * It orchestrates columns, panels, drag events, and FLIP animations to provide a fully interactive user experience.
4
+ *
5
+ * このファイルは、ドラッグ&ドロップインターフェースのメインレイアウトコンポーネントを定義します。
6
+ * カラム、パネル、ドラッグイベント、FLIPアニメーションを統合し、完全にインタラクティブなユーザーエクスペリエンスを提供します。
7
+ */
8
+ import { Eye, EyeOff, Maximize2, Minimize2, MousePointer, MousePointerClick, X } from "lucide-react"
9
+ import { type CSSProperties, type DragEvent, Fragment, type MouseEvent, memo, type TouchEvent, useEffect, useLayoutEffect, useMemo, useRef } from "react"
10
+ import { debugLog, PanelSizeDisplay } from "./drag-drop/DebugComponents"
11
+ import { DropZoneDebugOverlay, InsertPlaceholder } from "./drag-drop/DebugOverlay"
12
+ import "./drag-drop/DragDropLayout.css"
13
+ import { useFitScale } from "./drag-drop/hooks/useFitScale"
14
+ import { ResizeHandle } from "./drag-drop/ResizeHandle"
15
+ import type { ColumnConfig, CustomDragState, DebugConfig, DragDropEventHandlers, DragDropStateUpdaters, DragOverPosition, DragState, OriginalPanelPosition, PanelConfig, PanelDragHandle, PanelSizeInfo, ResizeConfig } from "./drag-drop/types"
16
+
17
+ /**
18
+ * Props for the DragDropLayout component.
19
+ *
20
+ * DragDropLayout コンポーネントのプロパティ。
21
+ */
22
+ export interface DragDropLayoutProps extends DragDropEventHandlers, DragDropStateUpdaters, ResizeConfig {
23
+ // カラム設定
24
+ columns: ColumnConfig[]
25
+
26
+ // パネル設定
27
+ panels: PanelConfig[]
28
+
29
+ // パネルの配置状態
30
+ columnPanels: Record<string, string[]>
31
+
32
+ // パネルの表示状態
33
+ panelVisibility: Record<string, boolean>
34
+
35
+ // ドラッグ状態
36
+ dragState: DragState
37
+ dragOverPosition: DragOverPosition | null
38
+ originalPanelPosition: OriginalPanelPosition | null
39
+
40
+ // ドラッグモード
41
+ dragModes: Record<string, "normal" | "custom">
42
+
43
+ // デバッグ設定
44
+ debugConfig: DebugConfig
45
+
46
+ // パネルサイズ情報
47
+ panelSizes: Record<string, PanelSizeInfo>
48
+
49
+ // カスタムドラッグ状態
50
+ customDrag?: CustomDragState
51
+
52
+ // ドラッグ開始領域 (既定 "panel" = パネル全体。"header" でヘッダのみ = 本文テキスト選択可)
53
+ dragHandle?: PanelDragHandle
54
+
55
+ // 最大化中のパネル id (制御型。onPanelMaximizeChange とセットで使用)
56
+ maximizedPanel?: string | null
57
+
58
+ // 全パネルの外枠へ追記するクラス (末尾追記のみ。既定ユーティリティと競合するクラスは
59
+ // 生成 CSS の順序依存になるため、ring/outline 等の非競合ユーティリティを推奨)
60
+ panelClassName?: string
61
+
62
+ // 表示倍率 (制御型。1.0 = 等倍、0.5 = 50% 縮小)
63
+ scale?: number
64
+
65
+ // 自動縮小設定 (コンテンツ領域が必要幅を下回る場合に自動で scale を調整する)
66
+ autoScale?: {
67
+ requiredWidth: number
68
+ minScale?: number
69
+ }
70
+
71
+ // 倍率が変化した時の通知
72
+ onScaleChange?: (scale: number) => void
73
+ }
74
+
75
+ /**
76
+ * A memoized version of InsertPlaceholder for local use within this layout.
77
+ * This prevents unnecessary re-renders of the placeholder components.
78
+ *
79
+ * このレイアウト内でローカルに使用するための、メモ化された InsertPlaceholder。
80
+ * プレースホルダーコンポーネントの不要な再レンダリングを防ぎます。
81
+ */
82
+ const LocalInsertPlaceholder = memo(
83
+ ({
84
+ index,
85
+ columnIndex,
86
+ isDragging,
87
+ dragOverPosition,
88
+ originalPosition,
89
+ onDragEnter,
90
+ onDragLeave,
91
+ debugConfig,
92
+ customDrag,
93
+ }: {
94
+ index: number
95
+ columnIndex: number
96
+ isDragging: boolean
97
+ dragOverPosition: DragOverPosition | null
98
+ originalPosition: OriginalPanelPosition | null
99
+ onDragEnter: (columnIndex: number, insertIndex: number) => void
100
+ onDragLeave: (e?: DragEvent, columnIndex?: number, insertIndex?: number) => void
101
+ debugConfig: DebugConfig
102
+ customDrag?: {
103
+ isActive: boolean
104
+ panelId: string
105
+ }
106
+ }) => {
107
+ return <InsertPlaceholder index={index} columnIndex={columnIndex} isDragging={isDragging} dragOverPosition={dragOverPosition} originalPosition={originalPosition} onDragEnter={onDragEnter} onDragLeave={onDragLeave} config={debugConfig} customDrag={customDrag} />
108
+ },
109
+ )
110
+
111
+ LocalInsertPlaceholder.displayName = "LocalInsertPlaceholder"
112
+
113
+ /**
114
+ * The main component for rendering the drag and drop layout.
115
+ * It manages the arrangement of columns and panels, handles all drag and drop logic,
116
+ * and implements FLIP animations for smooth transitions.
117
+ *
118
+ * ドラッグ&ドロップレイアウトを描画するためのメインコンポーネント。
119
+ * カラムとパネルの配置を管理し、すべてのドラッグ&ドロップロジックを処理し、
120
+ * スムーズなトランジションのためのFLIPアニメーションを実装します。
121
+ */
122
+ export const DragDropLayout = ({
123
+ columns,
124
+ panels,
125
+ columnPanels,
126
+ // onColumnPanelsChange,
127
+ panelVisibility,
128
+ onPanelVisibilityChange,
129
+ dragState,
130
+ dragOverPosition,
131
+ originalPanelPosition,
132
+ dragModes,
133
+ onDragStart,
134
+ onDragEnd,
135
+ onPlaceholderDragEnter,
136
+ onPlaceholderDragLeave,
137
+ onDragOverWithInsert,
138
+ onDropWithInsert,
139
+ onColumnDragLeave,
140
+ onPanelClick,
141
+ onPanelMouseDown,
142
+ onTouchStart,
143
+ onTouchMove,
144
+ onTouchEnd,
145
+ debugConfig,
146
+ panelSizes,
147
+ setPanelRef,
148
+ enableResize = false,
149
+ columnWidths,
150
+ // onColumnWidthChange,
151
+ onResizeStart,
152
+ onResize,
153
+ onResizeEnd,
154
+ isResizing = false,
155
+ resizingColumn,
156
+ onPanelSizeInfoClick,
157
+ onToggleDragMode,
158
+ onPanelClose,
159
+ onPanelMaximizeChange,
160
+ customDrag,
161
+ dragHandle = "panel",
162
+ maximizedPanel = null,
163
+ panelClassName = "",
164
+ scale: propScale,
165
+ autoScale,
166
+ onScaleChange,
167
+ }: DragDropLayoutProps) => {
168
+ // debugConfig の ref を作成して依存関係の問題を解決
169
+ const debugConfigRef = useRef(debugConfig)
170
+ useEffect(() => {
171
+ debugConfigRef.current = debugConfig
172
+ }, [debugConfig])
173
+
174
+ // 自動縮小設定がある場合は ResizeObserver を回して倍率を計算する
175
+ const { containerRef: autoScaleRef, scale: calculatedScale } = useFitScale(autoScale?.requiredWidth ?? 0, autoScale?.minScale)
176
+
177
+ // 有効な倍率を決定する (最大化中は強制的に 1.0)
178
+ const rawScale = maximizedPanel ? 1.0 : (propScale ?? (autoScale ? calculatedScale : 1.0))
179
+ // 0 / 負値 / NaN / Infinity は縮小無効 (=1.0) にフォールバックする。
180
+ // これらをそのまま使うと width: `${100/scale}%` が Infinity%、transform: scale(0) で不可視化しレイアウトが壊れる。
181
+ const currentScale = Number.isFinite(rawScale) && rawScale > 0 ? rawScale : 1.0
182
+
183
+ // FLIP の useLayoutEffect (依存 = [columnPanels]) から最新倍率を読むための ref。
184
+ // currentScale を直接参照すると exhaustive-deps 上は依存に含めるべきだが、依存へ入れると
185
+ // リサイズ由来の倍率変化のたびに FLIP が再計測されてしまう。並び替え時 (columnPanels 変化時) に
186
+ // 最新値を読めれば十分なので ref 経由にする。
187
+ const currentScaleRef = useRef(currentScale)
188
+ currentScaleRef.current = currentScale
189
+
190
+ // 倍率が変化したことを通知する
191
+ useEffect(() => {
192
+ if (onScaleChange) {
193
+ onScaleChange(currentScale)
194
+ }
195
+ }, [currentScale, onScaleChange])
196
+
197
+ // スケール適用時のラッパースタイル
198
+ const scaleWrapperStyle: CSSProperties | undefined =
199
+ currentScale < 1
200
+ ? {
201
+ width: `${100 / currentScale}%`,
202
+ height: `${100 / currentScale}%`,
203
+ transform: `scale(${currentScale})`,
204
+ transformOrigin: "top left",
205
+ }
206
+ : undefined
207
+
208
+ // FLIPアニメーション用のRef
209
+ const panelRefs = useRef<Record<string, HTMLDivElement | null>>({})
210
+ const prevLayoutMapRef = useRef(new Map<string, { rect: DOMRect; height: number; width: number }>())
211
+
212
+ // デバッグ用: dragModesの変化を監視
213
+ useEffect(() => {
214
+ debugLog(debugConfigRef.current, 1, "🎨 DragDropLayout - dragModes props changed:", {
215
+ dragModes,
216
+ timestamp: new Date().toISOString(),
217
+ keys: Object.keys(dragModes),
218
+ values: Object.values(dragModes),
219
+ })
220
+ }, [dragModes])
221
+
222
+ // パネルコンポーネントのマッピングを作成
223
+ const panelComponentMap = useMemo(() => {
224
+ const map: Record<string, PanelConfig> = {}
225
+ panels.forEach((panel) => {
226
+ map[panel.id] = panel
227
+ })
228
+ return map
229
+ }, [panels])
230
+
231
+ // 最大化中は Escape キーで元のサイズに戻す (OS ウインドウ風の操作性)
232
+ useEffect(() => {
233
+ if (!(maximizedPanel && onPanelMaximizeChange)) {
234
+ return
235
+ }
236
+ const handleKeyDown = (e: KeyboardEvent) => {
237
+ if (e.key === "Escape") {
238
+ onPanelMaximizeChange(null)
239
+ }
240
+ }
241
+ document.addEventListener("keydown", handleKeyDown)
242
+ return () => document.removeEventListener("keydown", handleKeyDown)
243
+ }, [maximizedPanel, onPanelMaximizeChange])
244
+
245
+ // FLIP (First, Last, Invert, Play) アニメーション効果
246
+ useLayoutEffect(() => {
247
+ // 現在のレイアウト情報を取得 (Last)
248
+ const currentLayoutMap = new Map<string, { rect: DOMRect; height: number; width: number }>()
249
+ Object.keys(panelRefs.current).forEach((panelId) => {
250
+ const el = panelRefs.current[panelId]
251
+ if (el) {
252
+ const rect = el.getBoundingClientRect()
253
+ currentLayoutMap.set(panelId, {
254
+ rect,
255
+ height: el.offsetHeight,
256
+ width: el.offsetWidth,
257
+ })
258
+ }
259
+ })
260
+
261
+ const prevLayoutMap = prevLayoutMapRef.current
262
+ const animationDuration = 300 // 0.3秒
263
+
264
+ // 現在表示されているパネルIDのセットを取得
265
+ const currentPanelIds = new Set<string>()
266
+ Object.values(columnPanels).forEach((panelList) => {
267
+ panelList.forEach((panelId) => {
268
+ currentPanelIds.add(panelId)
269
+ })
270
+ })
271
+
272
+ // 不要になったパネルの情報をクリーンアップ
273
+ const prevPanelIds = new Set(prevLayoutMap.keys())
274
+ prevPanelIds.forEach((panelId) => {
275
+ if (!currentPanelIds.has(panelId)) {
276
+ debugLog(debugConfigRef.current, 1, "パネル削除検出 - クリーンアップ:", panelId)
277
+ prevLayoutMap.delete(panelId)
278
+ if (panelRefs.current[panelId]) {
279
+ delete panelRefs.current[panelId]
280
+ }
281
+ }
282
+ })
283
+
284
+ // 移動したパネルのアニメーション処理
285
+ currentLayoutMap.forEach((newLayout, panelId) => {
286
+ const prevLayout = prevLayoutMap.get(panelId)
287
+
288
+ if (prevLayout) {
289
+ // 既存パネルの移動を検出
290
+ const dx = prevLayout.rect.left - newLayout.rect.left
291
+ const dy = prevLayout.rect.top - newLayout.rect.top
292
+
293
+ if (dx !== 0 || dy !== 0) {
294
+ debugLog(debugConfigRef.current, 2, "パネル移動検出 - アニメーション開始:", panelId, { dx, dy })
295
+ const element = panelRefs.current[panelId]
296
+ if (element) {
297
+ // dx/dy は getBoundingClientRect 由来の画面座標 (縮小ラッパ配下では既に ×scale 済み)。
298
+ // element への transform は縮小ラッパのローカル座標系で適用されさらに ×scale されるため、
299
+ // 目的の画面移動量にするには scale で割り戻す (等倍時は割り戻し無し)。
300
+ const flipScale = currentScaleRef.current || 1
301
+ // First: 変換を適用してアニメーション開始前の位置に強制移動
302
+ element.style.transform = `translate(${dx / flipScale}px, ${dy / flipScale}px)`
303
+ element.style.transition = "none"
304
+ element.classList.add("is-moving") // アニメーション中のクラスを追加
305
+
306
+ // Play: アニメーションを有効にして最終位置に移動
307
+ requestAnimationFrame(() => {
308
+ element.style.transition = `transform ${animationDuration}ms cubic-bezier(0.2, 0, 0.2, 1)`
309
+ element.style.transform = "translate(0, 0)"
310
+
311
+ // Invert: アニメーション完了後にスタイルをクリーンアップ
312
+ setTimeout(() => {
313
+ element.style.transition = ""
314
+ element.style.transform = ""
315
+ element.classList.remove("is-moving") // アニメーション中のクラスを削除
316
+ }, animationDuration)
317
+ })
318
+ }
319
+ }
320
+ } else {
321
+ // 新しく追加されたパネルのフェードインアニメーション
322
+ debugLog(debugConfigRef.current, 1, "新規パネル検出 - フェードインアニメーション:", panelId)
323
+ const element = panelRefs.current[panelId]
324
+ if (element) {
325
+ // First: 初期状態を不可視に設定
326
+ element.style.opacity = "0"
327
+ element.style.transition = "none"
328
+
329
+ // Play: アニメーションを有効にして表示
330
+ requestAnimationFrame(() => {
331
+ element.style.transition = `opacity ${animationDuration}ms ease-out`
332
+ element.style.opacity = "1"
333
+
334
+ // Invert: アニメーション完了後にスタイルをクリーンアップ
335
+ setTimeout(() => {
336
+ element.style.transition = ""
337
+ element.style.opacity = ""
338
+ }, animationDuration)
339
+ })
340
+ }
341
+ }
342
+ })
343
+
344
+ // 次のレンダリングのために現在のレイアウトを保存 (First)
345
+ prevLayoutMapRef.current = currentLayoutMap
346
+ }, [columnPanels])
347
+
348
+ // 縮小率が変わると全パネルの画面座標 (getBoundingClientRect) も変わる。FLIP のベースライン
349
+ // (prevLayoutMapRef) をアニメーション無しで取り直しておかないと、scale 変更直後の初回並び替えで
350
+ // dx/dy を旧 scale のベースラインから算出してしまい、誤った開始位置から滑る (300ms で自己回復するが
351
+ // ちらつく)。並び替え演出は上の本体 FLIP 効果 ([columnPanels]) が担当し、ここは scale 変化のみを
352
+ // 契機にベースラインを最新化する (アニメーションはしない)。
353
+ useLayoutEffect(() => {
354
+ const refreshed = new Map<string, { rect: DOMRect; height: number; width: number }>()
355
+ Object.keys(panelRefs.current).forEach((panelId) => {
356
+ const el = panelRefs.current[panelId]
357
+ if (el) {
358
+ refreshed.set(panelId, { rect: el.getBoundingClientRect(), height: el.offsetHeight, width: el.offsetWidth })
359
+ }
360
+ })
361
+ prevLayoutMapRef.current = refreshed
362
+ }, [currentScale])
363
+
364
+ /**
365
+ * Renders a single draggable panel.
366
+ *
367
+ * 個々のドラッグ可能なパネルを描画します。
368
+ * @param {string} panelId - The ID of the panel to render.
369
+ * @returns {JSX.Element | null} The rendered panel component or null if not visible.
370
+ */
371
+ const renderPanel = (panelId: string) => {
372
+ if (!panelVisibility[panelId]) return null
373
+
374
+ const panelConfig = panelComponentMap[panelId]
375
+ if (!panelConfig) {
376
+ debugLog(debugConfigRef.current, 1, `Panel config not found for panelId: ${panelId}`)
377
+ return null
378
+ }
379
+
380
+ const PanelComponent = panelConfig.component
381
+ const title = panelConfig.title
382
+ const isDragging = dragState.draggedPanel === panelId
383
+ const dragMode = dragModes[panelId] || "normal"
384
+
385
+ // デバッグ用: 各パネルのドラッグモード状態をログ出力
386
+ debugLog(debugConfigRef.current, 2, `🎨 renderPanel - ${panelId}:`, {
387
+ panelId,
388
+ dragMode,
389
+ timestamp: new Date().toISOString(),
390
+ })
391
+
392
+ // panelRefsにRefを登録し、親コンポーネントのセッターも呼び出す
393
+ const setPanelRefLocal = (el: HTMLDivElement | null) => {
394
+ panelRefs.current[panelId] = el
395
+ if (setPanelRef) {
396
+ setPanelRef(panelId, el)
397
+ }
398
+ // パネルがマウントされた際にサイズ情報を更新
399
+ if (el && debugConfig.showPanelSizes) {
400
+ // 次のフレームでサイズ更新を実行(レンダリング後にサイズを取得)
401
+ requestAnimationFrame(() => {
402
+ const rect = el.getBoundingClientRect()
403
+ const computedStyle = window.getComputedStyle(el)
404
+ debugLog(debugConfigRef.current, 1, `Panel ${panelId} mounted with size:`, {
405
+ width: rect.width,
406
+ height: rect.height,
407
+ x: rect.left,
408
+ y: rect.top,
409
+ minHeight: computedStyle.minHeight,
410
+ maxHeight: computedStyle.maxHeight,
411
+ })
412
+ })
413
+ }
414
+ }
415
+
416
+ const sizeInfo = panelSizes[panelId]
417
+
418
+ // 最大化: 対象パネルを DOM ツリー上の位置を変えずに (unmount/remount させずに)
419
+ // fixed オーバーレイとしてビューポート全面に描画する。React の再親付けが起きないため
420
+ // パネル内コンポーネントの state (fetch 済みコンテンツ・スクロール位置等) は保持される。
421
+ const isMaximized = maximizedPanel === panelId
422
+ const isMaximizeEnabled = !!onPanelMaximizeChange
423
+ const toggleMaximize = () => onPanelMaximizeChange?.(isMaximized ? null : panelId)
424
+
425
+ // PanelConfig.minHeight/maxHeight 指定時は従来のハードコード既定 (200px/500px) を上書きする。
426
+ // 未指定時は既定値を inline style で適用するため、既存コンシューマの見た目は不変。
427
+ const heightStyle: CSSProperties = isMaximized
428
+ ? { position: "fixed", inset: 0, zIndex: 50, minHeight: 0, maxHeight: "none" }
429
+ : {
430
+ minHeight: panelConfig.minHeight ?? "200px",
431
+ maxHeight: panelConfig.maxHeight ?? "500px",
432
+ }
433
+
434
+ // dragHandle="header" ではヘッダのみをドラッグ開始領域にし、本文のテキスト選択を可能にする
435
+ const isDragFromHeader = dragHandle === "header"
436
+ const isPanelDraggable = dragMode === "normal" && !isDragFromHeader && !isMaximized
437
+ const isHeaderDraggable = dragMode === "normal" && isDragFromHeader && !isMaximized
438
+ const grabCursorClass = isDragging ? "cursor-grabbing" : "cursor-grab"
439
+
440
+ const basePanelClass = `panel-draggable group relative flex flex-col overflow-hidden border border-slate-200 text-left shadow-lg backdrop-blur-lg ${dragMode === "custom" ? "select-none" : ""}`
441
+ const panelClass = isMaximized
442
+ ? // 最大化中: 角丸・ホバー演出・grab カーソル無し、背景は不透過寄りで下のレイアウトを遮蔽
443
+ `${basePanelClass} rounded-none bg-white/95 p-4 ${panelClassName}`
444
+ : `${basePanelClass} rounded-2xl bg-white/80 p-4 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl ${isDragFromHeader ? "" : grabCursorClass} ${panelClassName}`
445
+
446
+ return (
447
+ // biome-ignore lint/a11y/noStaticElementInteractions: クリックイベントを持つ静的要素
448
+ <div
449
+ key={panelId}
450
+ ref={setPanelRefLocal}
451
+ data-panel-id={panelId}
452
+ data-dragging={isDragging}
453
+ data-maximized={isMaximized || undefined}
454
+ role="presentation"
455
+ draggable={isPanelDraggable}
456
+ onDragStart={(e) => isPanelDraggable && onDragStart(e, panelId)}
457
+ onDragEnd={onDragEnd}
458
+ onClick={(e) => onPanelClick?.(e, panelId)}
459
+ onMouseDown={(e) => onPanelMouseDown?.(e, panelId)}
460
+ onTouchStart={(e) => onTouchStart?.(e, panelId)}
461
+ onTouchMove={onTouchMove}
462
+ onTouchEnd={onTouchEnd}
463
+ style={heightStyle}
464
+ className={panelClass}>
465
+ {/* パネルヘッダー (dragHandle="header" 時はここがドラッグハンドル。ダブルクリックで最大化トグル) */}
466
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: ドラッグハンドルとしての静的要素 */}
467
+ <div
468
+ className={`group relative mb-3 flex items-center justify-between ${isDragFromHeader && !isMaximized ? grabCursorClass : ""}`}
469
+ draggable={isHeaderDraggable}
470
+ onDragStart={(e) => isHeaderDraggable && onDragStart(e, panelId)}
471
+ onDoubleClick={
472
+ isMaximizeEnabled
473
+ ? (e) => {
474
+ e.stopPropagation()
475
+ toggleMaximize()
476
+ }
477
+ : undefined
478
+ }>
479
+ <div className="flex items-center space-x-2">
480
+ <h3 className="font-semibold text-lg text-slate-700 transition-colors group-hover:text-slate-900">{title}</h3>
481
+
482
+ {/* カスタムドラッグモードのバッジ */}
483
+ {dragMode === "custom" && <span className="rounded-md bg-orange-100 px-2 py-1 font-medium text-orange-600 text-xs">Custom</span>}
484
+ </div>
485
+
486
+ {/* パネル操作ボタン (ホバー時に表示) */}
487
+ <div className="flex items-center space-x-1 opacity-0 transition-opacity group-hover:opacity-100">
488
+ {/* ドラッグモード切り替えボタン */}
489
+ <button
490
+ type="button"
491
+ onClick={(e) => {
492
+ e.stopPropagation()
493
+ if (onToggleDragMode) {
494
+ onToggleDragMode(panelId)
495
+ }
496
+ }}
497
+ onKeyDown={(e) => {
498
+ if (e.key === "Enter" || e.key === " ") {
499
+ e.preventDefault()
500
+ e.stopPropagation()
501
+ if (onToggleDragMode) {
502
+ onToggleDragMode(panelId)
503
+ }
504
+ }
505
+ }}
506
+ tabIndex={0}
507
+ className={`cursor-pointer rounded p-1 text-xs transition-colors ${dragMode === "custom" ? "bg-orange-100 text-orange-600 hover:bg-orange-200" : "text-slate-400 hover:bg-slate-100 hover:text-slate-600"}`}
508
+ title={dragMode === "custom" ? "通常ドラッグモードに戻す" : "カスタムドラッグモードに切り替え"}>
509
+ {dragMode === "custom" ? <MousePointerClick className="h-4 w-4" /> : <MousePointer className="h-4 w-4" />}
510
+ </button>
511
+
512
+ {/* パネル非表示ボタン (最大化中は非表示 — 最大化とオーバーレイ状態が矛盾するため) */}
513
+ {!isMaximized && (
514
+ <button
515
+ type="button"
516
+ onClick={(e) => {
517
+ e.stopPropagation()
518
+ onPanelVisibilityChange({
519
+ ...panelVisibility,
520
+ [panelId]: false,
521
+ })
522
+ }}
523
+ className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
524
+ title="パネルを非表示">
525
+ <EyeOff className="h-4 w-4" />
526
+ </button>
527
+ )}
528
+
529
+ {/* パネル最大化/復元ボタン (onPanelMaximizeChange 指定時のみ表示) */}
530
+ {isMaximizeEnabled && (
531
+ <button
532
+ type="button"
533
+ onClick={(e) => {
534
+ e.stopPropagation()
535
+ toggleMaximize()
536
+ }}
537
+ className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
538
+ title={isMaximized ? "元のサイズに戻す" : "パネルを最大化"}>
539
+ {isMaximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
540
+ </button>
541
+ )}
542
+
543
+ {/* パネルクローズボタン (onPanelClose 指定時のみ表示。非表示と異なりパネルを取り除く) */}
544
+ {onPanelClose && (
545
+ <button
546
+ type="button"
547
+ onClick={(e) => {
548
+ e.stopPropagation()
549
+ onPanelClose(panelId)
550
+ }}
551
+ className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-red-500"
552
+ title="パネルを閉じる">
553
+ <X className="h-4 w-4" />
554
+ </button>
555
+ )}
556
+ </div>
557
+ </div>
558
+
559
+ {/* パネルコンテンツ */}
560
+ <div data-aqdd-scroll-area className="panel-scroll-area min-h-0 flex-1 overflow-y-auto">
561
+ <PanelComponent {...(panelConfig.props || {})} />
562
+ </div>
563
+
564
+ {/* デバッグ用サイズ表示 */}
565
+ <PanelSizeDisplay config={debugConfig} panelId={panelId} sizeInfo={sizeInfo} onSizeInfoClick={(e) => onPanelSizeInfoClick?.(e as MouseEvent | TouchEvent, panelId)} />
566
+ </div>
567
+ )
568
+ }
569
+
570
+ /**
571
+ * Renders a drop zone for a specific column.
572
+ *
573
+ * 特定のカラムのドロップゾーンを描画します。
574
+ * @param {string} columnKey - The key of the column.
575
+ * @param {number} columnIndex - The index of the column.
576
+ * @returns {JSX.Element} The rendered drop zone component.
577
+ */
578
+ const renderDropZone = (columnKey: string, columnIndex: number) => {
579
+ const panelIds = columnPanels[columnKey] || []
580
+
581
+ let hasVisiblePanels = false
582
+ for (const panelId of panelIds) {
583
+ if (panelVisibility[panelId]) {
584
+ hasVisiblePanels = true
585
+ break
586
+ }
587
+ }
588
+
589
+ // 通常ドラッグまたはカスタムドラッグがアクティブな場合にドラッグ中と判定
590
+ const isAnyDragActive = dragState.isDragging || (customDrag?.isActive ?? false)
591
+
592
+ return (
593
+ <DropZoneDebugOverlay columnIndex={columnIndex} isDragging={isAnyDragActive} dragOverPosition={dragOverPosition} config={debugConfig}>
594
+ <section
595
+ data-drop-zone="true"
596
+ data-column-key={columnKey}
597
+ data-column-index={columnIndex}
598
+ aria-label={`Drop zone for column ${columnIndex + 1}`}
599
+ onDragOver={(e) => onDragOverWithInsert(e, columnIndex)}
600
+ onDrop={(e) => onDropWithInsert(e, columnKey)}
601
+ onDragLeave={onColumnDragLeave}
602
+ className="relative flex h-full flex-col transition-all duration-200">
603
+ {/* カラムの先頭に配置されるプレースホルダー */}
604
+ <div className="placeholder-container">
605
+ <LocalInsertPlaceholder
606
+ index={0}
607
+ columnIndex={columnIndex}
608
+ isDragging={isAnyDragActive}
609
+ dragOverPosition={dragOverPosition}
610
+ originalPosition={originalPanelPosition}
611
+ onDragEnter={onPlaceholderDragEnter}
612
+ onDragLeave={onPlaceholderDragLeave}
613
+ debugConfig={debugConfig}
614
+ customDrag={customDrag}
615
+ />
616
+ </div>
617
+
618
+ {/* パネルとプレースホルダーを交互に配置 */}
619
+ {panelIds.map((panelId, index) => {
620
+ const isVisible = panelVisibility[panelId]
621
+ const isHiddenAndIdle = !(isVisible || isAnyDragActive)
622
+
623
+ return (
624
+ <div key={`panel-${panelId}`} className={`panel-with-placeholder ${isHiddenAndIdle ? "m-0 h-0 overflow-hidden p-0" : ""}`} data-panel-hidden={isHiddenAndIdle ? "true" : undefined} aria-hidden={isHiddenAndIdle ? "true" : undefined}>
625
+ {isVisible ? (
626
+ renderPanel(panelId)
627
+ ) : isAnyDragActive ? (
628
+ /* ドラッグ中のみ隠されたパネルのダミー表示 */
629
+ <div className="hidden-panel-placeholder relative h-[120px] rounded-2xl border-2 border-amber-300 border-dashed bg-gradient-to-br from-amber-50 to-orange-50 p-3 text-left shadow-lg backdrop-blur-lg">
630
+ {/* ダミーパネルヘッダー */}
631
+ <div className="mb-2 flex items-center space-x-2">
632
+ <h3 className="font-semibold text-amber-700 text-base">{panelComponentMap[panelId]?.title || panelId}</h3>
633
+ <span className="rounded-md bg-amber-100 px-2 py-1 font-medium text-amber-600 text-xs">非表示</span>
634
+ </div>
635
+
636
+ {/* ダミーコンテンツ */}
637
+ <div className="flex-1 overflow-hidden">
638
+ <div className="flex h-full items-center justify-center">
639
+ <div className="text-center text-amber-600">
640
+ <EyeOff className="mx-auto h-6 w-6 opacity-50" />
641
+ <p className="mt-1 text-xs">このパネルは非表示です</p>
642
+ </div>
643
+ </div>
644
+ </div>
645
+ </div>
646
+ ) : null}
647
+
648
+ {/* 各パネルの後に配置されるプレースホルダー */}
649
+ <LocalInsertPlaceholder
650
+ index={index + 1}
651
+ columnIndex={columnIndex}
652
+ isDragging={isAnyDragActive}
653
+ dragOverPosition={dragOverPosition}
654
+ originalPosition={originalPanelPosition}
655
+ onDragEnter={onPlaceholderDragEnter}
656
+ onDragLeave={onPlaceholderDragLeave}
657
+ debugConfig={debugConfig}
658
+ customDrag={customDrag}
659
+ />
660
+ </div>
661
+ )
662
+ })}
663
+
664
+ {/* カラムが空で、かつドラッグ中でない場合に表示されるメッセージ */}
665
+ {!(hasVisiblePanels || isAnyDragActive) && (
666
+ <div className="flex-shrink-0 rounded-xl border-2 border-slate-200 border-dashed bg-white/30 p-8 text-center text-slate-400 transition-all duration-200">
667
+ <div className="text-sm">このエリアは空です</div>
668
+ </div>
669
+ )}
670
+ </section>
671
+ </DropZoneDebugOverlay>
672
+ )
673
+ }
674
+
675
+ // 非表示パネルのリストを取得
676
+ const hiddenPanels = Object.keys(panelVisibility).filter((key) => !panelVisibility[key])
677
+
678
+ return (
679
+ <div className="flex h-full flex-col">
680
+ {/* 非表示パネルを再表示するためのUI */}
681
+ {hiddenPanels.length > 0 && (
682
+ <div className="border-slate-200 border-b bg-white/70 px-4 py-2 backdrop-blur-lg">
683
+ <div className="flex flex-wrap gap-2">
684
+ <span className="mr-2 text-slate-600 text-sm">非表示のパネル:</span>
685
+ {hiddenPanels.map((panelId) => {
686
+ const panelConfig = panelComponentMap[panelId]
687
+ const title = panelConfig?.title || panelId
688
+
689
+ return (
690
+ <button
691
+ type="button"
692
+ key={panelId}
693
+ onClick={() =>
694
+ onPanelVisibilityChange({
695
+ ...panelVisibility,
696
+ [panelId]: true,
697
+ })
698
+ }
699
+ className="flex items-center space-x-1 rounded-lg border border-sky-300 bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1 text-sm text-white shadow-md transition-all duration-200 hover:from-sky-600 hover:to-indigo-600 hover:shadow-lg">
700
+ <Eye className="h-3 w-3" />
701
+ <span>{title}</span>
702
+ </button>
703
+ )
704
+ })}
705
+ </div>
706
+ </div>
707
+ )}
708
+
709
+ {/* メインコンテンツエリア。
710
+ autoScaleRef を計測 + クリップ用の外枠 (overflow-hidden) に置き、縮小ラッパ
711
+ (transform: scale で拡大したレイアウト幅 width:100/scale% を持つ) をその内側に閉じ込める。
712
+ scroll コンテナである <main> の直接の内容 (columns 行) は常に main 幅に収まるため、
713
+ 縮小表示中でも main / ページに横スクロール (scrollWidth 超過) が発生しない
714
+ (縮小を route 側の overflow-hidden ラッパでクリップしていた従来トポロジをパッケージ内で再現)。 */}
715
+ <div ref={autoScaleRef} className="relative min-h-0 flex-1 overflow-hidden">
716
+ <div className="h-full w-full" style={scaleWrapperStyle}>
717
+ <main data-aqdd-scroll-root className="no-scrollbar h-full max-w-full flex-1 overflow-auto px-4 py-6 sm:px-6">
718
+ <div className="relative flex h-full items-start gap-6 pr-4">
719
+ {columns.map((column, index) => {
720
+ const columnWidth = columnWidths?.[column.id] || column.initialWidth || 350
721
+ const canResize = enableResize && (column.resizable ?? true)
722
+
723
+ return (
724
+ <Fragment key={column.id}>
725
+ <div
726
+ className={`column relative ${column.className || ""} ${enableResize ? "resize-enabled-column" : "auto-width-column"} ${isResizing && resizingColumn === column.id ? "column-resizing-active" : ""}`}
727
+ data-column={index}
728
+ data-column-id={column.id}
729
+ data-min-width={column.minWidth || 350}
730
+ data-max-width={column.maxWidth}
731
+ data-column-width={enableResize ? columnWidth : undefined}
732
+ {...(enableResize && {
733
+ style: { "--column-width": `${columnWidth}px` } as CSSProperties,
734
+ })}>
735
+ {renderDropZone(column.key, index)}
736
+
737
+ {/* リサイズハンドル (リサイズが有効な場合) */}
738
+ {canResize && onResizeStart && onResize && onResizeEnd && (
739
+ <ResizeHandle columnId={column.id} onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd} isResizing={isResizing && resizingColumn === column.id} position="right" debug={debugConfig} />
740
+ )}
741
+ </div>
742
+ </Fragment>
743
+ )
744
+ })}
745
+ </div>
746
+ </main>
747
+ </div>
748
+ </div>
749
+ </div>
750
+ )
751
+ }