Repository: vercel/next.js
Pull request: [devtools] fullscreen mode should not be draggable

When the DevTools panel is in full-screen mode, it should not be draggable.

Closes NEXT-4565

Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-footer.tsx
patch: @@ -7,14 +7,19 @@ import { css } from '../../utils/css'
 
 export function DevToolsPanelFooter({
   versionInfo,
+  isDraggable,
 }: {
   versionInfo: OverlayState['versionInfo']
+  isDraggable: boolean
 }) {
   const bundlerName = (
     process.env.__NEXT_BUNDLER || 'WEBPACK'
   ).toUpperCase() as 'WEBPACK' | 'TURBOPACK' | 'RSPACK'
   return (
-    <div data-nextjs-devtools-panel-footer>
+    <div
+      data-nextjs-devtools-panel-footer
+      data-nextjs-devtools-panel-draggable={isDraggable}
+    >
       <div data-nextjs-devtools-panel-footer-tab-group>
         <DevToolsPanelVersionInfo versionInfo={versionInfo} />
         <div data-nextjs-devtools-panel-footer-tab>
@@ -51,15 +56,6 @@ export const DEVTOOLS_PANEL_FOOTER_STYLES = css`
     align-items: center;
     border-top: 1px solid var(--color-gray-400);
     border-radius: 0 0 var(--rounded-xl) var(--rounded-xl);
-
-    /* For draggable */
-    cursor: move;
-    user-select: none;
-    & > * {
-      cursor: auto;
-      /* user-select: auto; follows the parent (parent none -> child none), so reset the direct child to text */
-      user-select: text;
-    }
   }
 
   [data-nextjs-devtools-panel-footer-tab-group] {

filename: packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx
patch: @@ -110,6 +110,7 @@ export function DevToolsPanel({
           })
         }}
         dragHandleSelector="[data-nextjs-devtools-panel-header], [data-nextjs-devtools-panel-footer]"
+        disableDrag={isFullscreen}
       >
         <>
           <Dialog
@@ -120,7 +121,10 @@ export function DevToolsPanel({
           >
             <DialogContent data-nextjs-devtools-panel-dialog-content>
               <DialogHeader data-nextjs-devtools-panel-dialog-header>
-                <div data-nextjs-devtools-panel-header>
+                <div
+                  data-nextjs-devtools-panel-header
+                  data-nextjs-devtools-panel-draggable={!isFullscreen}
+                >
                   <div data-nextjs-devtools-panel-header-tab-group>
                     <button
                       data-nextjs-devtools-panel-header-tab={
@@ -187,7 +191,10 @@ export function DevToolsPanel({
                 />
               </DialogBody>
             </DialogContent>
-            <DevToolsPanelFooter versionInfo={state.versionInfo} />
+            <DevToolsPanelFooter
+              versionInfo={state.versionInfo}
+              isDraggable={!isFullscreen}
+            />
           </Dialog>
         </>
       </Draggable>
@@ -273,15 +280,6 @@ export const DEVTOOLS_PANEL_STYLES = css`
     justify-content: space-between;
     align-items: center;
     border-bottom: 1px solid var(--color-gray-400);
-
-    /* For draggable */
-    cursor: move;
-    user-select: none;
-    & > * {
-      cursor: auto;
-      /* user-select: auto; follows the parent (parent none -> child none), so reset the direct child to text */
-      user-select: text;
-    }
   }
 
   [data-nextjs-devtools-panel-header-tab-group] {
@@ -357,4 +355,14 @@ export const DEVTOOLS_PANEL_STYLES = css`
       background-color: var(--color-gray-300);
     }
   }
+
+  [data-nextjs-devtools-panel-draggable='true'] {
+    cursor: move;
+    user-select: none;
+    & > * {
+      cursor: auto;
+      /* user-select: auto; follows the parent (parent none -> child none), so reset the direct child to text */
+      user-select: text;
+    }
+  }
 `

filename: packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/draggable.tsx
patch: @@ -1,5 +1,5 @@
 import type { Corners } from '../../../shared'
-import { useRef } from 'react'
+import { useCallback, useLayoutEffect, useRef } from 'react'
 
 interface Point {
   x: number
@@ -18,6 +18,7 @@ export function Draggable({
   setPosition: setCurrentCorner,
   onDragStart,
   dragHandleSelector,
+  disableDrag = false,
   ...props
 }: {
   children: React.ReactElement
@@ -26,8 +27,10 @@ export function Draggable({
   setPosition: (position: Corners) => void
   onDragStart?: () => void
   dragHandleSelector?: string
+  disableDrag?: boolean
 }) {
   const { ref, animate, ...drag } = useDrag({
+    disabled: disableDrag,
     threshold: 5,
     onDragStart,
     onDragEnd,
@@ -132,6 +135,7 @@ export function Draggable({
 }
 
 interface UseDragOptions {
+  disabled: boolean
   onDragStart?: () => void
   onDrag?: (translation: Point) => void
   onDragEnd?: (translation: Point, velocity: Point) => void
@@ -147,13 +151,45 @@ interface Velocity {
 
 export function useDrag(options: UseDragOptions) {
   const ref = useRef<HTMLDivElement>(null)
-  const state = useRef<'idle' | 'press' | 'drag' | 'drag-end'>('idle')
+  const machine = useRef<
+    | { state: 'idle' | 'press' | 'drag-end' }
+    | { state: 'drag'; pointerId: number }
+  >({
+    state: 'idle',
+  })
+  const cleanup = useRef<() => void>(null)
 
   const origin = useRef<Point>({ x: 0, y: 0 })
   const translation = useRef<Point>({ x: 0, y: 0 })
   const lastTimestamp = useRef(0)
   const velocities = useRef<Velocity[]>([])
 
+  const cancel = useCallback(() => {
+    if (machine.current.state === 'drag') {
+      ref.current?.releasePointerCapture(machine.current.pointerId)
+    }
+
+    machine.current =
+      machine.current.state === 'drag'
+        ? { state: 'drag-end' }
+        : { state: 'idle' }
+
+    if (cleanup.current !== null) {
+      cleanup.current()
+      cleanup.current = null
+    }
+
+    velocities.current = []
+
+    ref.current?.classList.remove('dev-tools-grabbing')
+  }, [])
+
+  useLayoutEffect(() => {
+    if (options.disabled) {
+      cancel()
+    }
+  }, [cancel, options.disabled])
+
   function set(position: Point) {
     if (ref.current) {
       translation.current = position
@@ -181,10 +217,10 @@ export function useDrag(options: UseDragOptions) {
   }
 
   function onClick(e: MouseEvent) {
-    if (state.current === 'drag-end') {
+    if (machine.current.state === 'drag-end') {
       e.preventDefault()
       e.stopPropagation()
-      state.current = 'idle'
+      machine.current = { state: 'idle' }
       ref.current?.removeEventListener('click', onClick)
     }
   }
@@ -215,27 +251,37 @@ export function useDrag(options: UseDragOptions) {
     }
 
     origin.current = { x: e.clientX, y: e.clientY }
-    state.current = 'press'
+    machine.current = { state: 'press' }
     window.addEventListener('pointermove', onPointerMove)
     window.addEventListener('pointerup', onPointerUp)
+
+    if (cleanup.current !== null) {
+      cleanup.current()
+      cleanup.current = null
+    }
+    cleanup.current = () => {
+      window.removeEventListener('pointermove', onPointerMove)
+      window.removeEventListener('pointerup', onPointerUp)
+    }
+
     ref.current?.addEventListener('click', onClick)
   }
 
   function onPointerMove(e: PointerEvent) {
-    if (state.current === 'press') {
+    if (machine.current.state === 'press') {
       const dx = e.clientX - origin.current.x
       const dy = e.clientY - origin.current.y
       const distance = Math.sqrt(dx * dx + dy * dy)
 
       if (distance >= options.threshold) {
-        state.current = 'drag'
+        machine.current = { state: 'drag', pointerId: e.pointerId }
         ref.current?.setPointerCapture(e.pointerId)
         ref.current?.classList.add('dev-tools-grabbing')
         options.onDragStart?.()
       }
     }
 
-    if (state.current !== 'drag') return
+    if (machine.current.state !== 'drag') return
 
     const currentPosition = { x: e.clientX, y: e.clientY }
 
@@ -265,17 +311,12 @@ export function useDrag(options: UseDragOptions) {
     options.onDrag?.(translation.current)
   }
 
-  function onPointerUp(e: PointerEvent) {
-    state.current = state.current === 'drag' ? 'drag-end' : 'idle'
-
-    window.removeEventListener('pointermove', onPointerMove)
-    window.removeEventListener('pointerup', onPointerUp)
-
+  function onPointerUp() {
     const velocity = calculateVelocity(velocities.current)
-    velocities.current = []
 
-    ref.current?.classList.remove('dev-tools-grabbing')
-    ref.current?.releasePointerCapture(e.pointerId)
+    cancel()
+
+    // TODO: This is the onDragEnd when the pointerdown event was fired not the onDragEnd when the pointerup event was fired
     options.onDragEnd?.(translation.current, velocity)
   }