msyavuz commented on code in PR #45090: URL: https://github.com/apache/superset/pull/45090#discussion_r4350812465
########## superset-frontend/src/features/canvas/CanvasGridSurface.tsx: ########## @@ -0,0 +1,792 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview One grid of a canvas, with drag and resize built in. + * + * There is no edit mode: a user who may edit gets a drag grip and a resize + * corner on each widget, on the canvas as they view it. A user who may not + * gets the same grid with no handles. Dragging never covers the widget's own + * surface, so a filter or chart underneath stays clickable. + * + * While a gesture is live the widget follows the pointer and a dashed outline + * marks the cells it would take; releasing persists exactly those cells. + */ + +import { + PointerEvent as ReactPointerEvent, + KeyboardEvent as ReactKeyboardEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { css, styled } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; +import { Icons } from '@superset-ui/core/components'; +import { + GridMetrics, + gridDelta, + movedPlacement, + NO_DELTA, + RESIZE_CORNERS, + type ResizeCorner, + resizedPlacement, + samePlacement, +} from './gridGeometry'; +import { previewGrid, type GridLayout } from './resolveGrid'; +import type { GridPlacement, SpanConstraints } from './types'; + +type GestureKind = 'move' | 'resize'; + +interface Gesture { + nodeId: string; + kind: GestureKind; + pointerId: number; + startX: number; + startY: number; + /** + * Page scroll when the widget was grabbed. Pointer coordinates are relative + * to the viewport, so scrolling mid-gesture moves the content under a still + * pointer; without this the drag would report no movement at all. + */ + startScrollX: number; + startScrollY: number; + dx: number; + dy: number; + /** + * Where the widget sat when it was grabbed. The gesture resolves against + * this, not against the current placement: a write landing mid-gesture (a + * sibling's drop pushing this one down, or a draft poll) would otherwise + * move the ground under the pointer and persist the drop relative to a row + * the user never aimed at. + */ + origin: GridPlacement; + /** + * Whether this gesture is actually dragging yet. One begun on a handle is + * armed at once; one begun on the widget body waits until the pointer has + * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click + * reaches the widget underneath instead of nudging it. + */ + armed: boolean; + /** + * The element the press landed on, so a gesture that arms later can take + * the pointer from it. Held rather than looked up when it arms: by then the + * pointer may be over something else entirely. + */ + capture: HTMLElement; + /** For a resize, the corner being dragged; the opposite one stays put. */ + corner: ResizeCorner; +} + +/** Pixels of travel before a press on a widget body becomes a drag. */ +const DRAG_THRESHOLD = 4; + +/** + * Presses already claimed by a grid, so one that bubbles through nested grids + * starts exactly one gesture: the innermost grid's. + */ +const claimedPresses = new WeakSet<Event>(); + +/** + * Controls that handle press-and-drag themselves, so a press on one must not + * also move the widget: a filter's slider, a form control, a link, a handle + * for reordering something inside a widget. `data-canvas-no-drag` lets a + * widget opt anything else out. + */ +const OWN_GESTURE = [ + 'input', + 'textarea', + 'select', + 'button', + 'a[href]', + '[role="slider"]', + '[role="button"]', + '[role="scrollbar"]', + '[contenteditable=""]', + '[contenteditable="true"]', + '[draggable="true"]', + '[data-canvas-no-drag]', +].join(','); + +/** + * Whether a press landed on a scrollbar rather than inside the content. + * + * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a + * widget tall enough to scroll -- a table, usually -- would otherwise be + * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop + * short of the scrollbar, so a press past either is on it; the comparison is + * skipped for an element that isn't overflowing, which has no scrollbar to + * press. A left-hand scrollbar (RTL) is not detected. + */ +const onScrollbar = ( + target: Element, + clientX: number, + clientY: number, +): boolean => { + const box = target.getBoundingClientRect(); + return ( + (target.scrollHeight > target.clientHeight && + clientX > box.left + target.clientWidth) || + (target.scrollWidth > target.clientWidth && + clientY > box.top + target.clientHeight) + ); +}; + +/** Whether something inside the widget owns this press, so the grid doesn't. */ +const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => { + const { target } = event; + if (!(target instanceof Element)) return false; + const owner = target.closest(OWN_GESTURE); + // Bounded to this widget: a control that happens to sit above the grid in + // the page isn't what the press landed on. + return ( + (owner !== null && event.currentTarget.contains(owner)) || + onScrollbar(target, event.clientX, event.clientY) + ); +}; + +/** A gesture's movement over the page content, scrolling included. */ +const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({ + dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX), + dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY), +}); + +const Grid = styled.div<GridMetrics>` + display: grid; + grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr)); + grid-auto-rows: ${({ rowUnit }) => rowUnit}px; + gap: ${({ gap }) => gap}px; +`; + +const GridItem = styled.div<{ + placement?: GridPlacement; + dragging?: boolean; + offset?: { x: number; y: number }; +}>` + ${({ placement, dragging, offset }) => css` + min-width: 0; + min-height: 0; + position: relative; + ${ + placement && + css` + grid-column: ${placement.col} / span ${placement.colSpan}; + grid-row: ${placement.row} / span ${placement.rowSpan}; + ` + } + ${ + dragging && + offset && + css` + /* Follow the pointer without reflowing the grid under it. */ + transform: translate(${offset.x}px, ${offset.y}px); + z-index: 2; + opacity: 0.85; Review Comment: The dragged widget is actually on top, but widgets have no background, and with 0.85 opacity the neighbours and the drop preview show through it. Giving it a fill fixes that: ```suggestion background: ${theme.colorBgContainer}; box-shadow: ${theme.boxShadowSecondary}; border-radius: ${theme.borderRadius}px; ``` (`theme` also needs adding to this interpolation's args.) ########## superset-frontend/src/features/canvas/CanvasGridSurface.tsx: ########## @@ -0,0 +1,792 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview One grid of a canvas, with drag and resize built in. + * + * There is no edit mode: a user who may edit gets a drag grip and a resize + * corner on each widget, on the canvas as they view it. A user who may not + * gets the same grid with no handles. Dragging never covers the widget's own + * surface, so a filter or chart underneath stays clickable. + * + * While a gesture is live the widget follows the pointer and a dashed outline + * marks the cells it would take; releasing persists exactly those cells. + */ + +import { + PointerEvent as ReactPointerEvent, + KeyboardEvent as ReactKeyboardEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { css, styled } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; +import { Icons } from '@superset-ui/core/components'; +import { + GridMetrics, + gridDelta, + movedPlacement, + NO_DELTA, + RESIZE_CORNERS, + type ResizeCorner, + resizedPlacement, + samePlacement, +} from './gridGeometry'; +import { previewGrid, type GridLayout } from './resolveGrid'; +import type { GridPlacement, SpanConstraints } from './types'; + +type GestureKind = 'move' | 'resize'; + +interface Gesture { + nodeId: string; + kind: GestureKind; + pointerId: number; + startX: number; + startY: number; + /** + * Page scroll when the widget was grabbed. Pointer coordinates are relative + * to the viewport, so scrolling mid-gesture moves the content under a still + * pointer; without this the drag would report no movement at all. + */ + startScrollX: number; + startScrollY: number; + dx: number; + dy: number; + /** + * Where the widget sat when it was grabbed. The gesture resolves against + * this, not against the current placement: a write landing mid-gesture (a + * sibling's drop pushing this one down, or a draft poll) would otherwise + * move the ground under the pointer and persist the drop relative to a row + * the user never aimed at. + */ + origin: GridPlacement; + /** + * Whether this gesture is actually dragging yet. One begun on a handle is + * armed at once; one begun on the widget body waits until the pointer has + * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click + * reaches the widget underneath instead of nudging it. + */ + armed: boolean; + /** + * The element the press landed on, so a gesture that arms later can take + * the pointer from it. Held rather than looked up when it arms: by then the + * pointer may be over something else entirely. + */ + capture: HTMLElement; + /** For a resize, the corner being dragged; the opposite one stays put. */ + corner: ResizeCorner; +} + +/** Pixels of travel before a press on a widget body becomes a drag. */ +const DRAG_THRESHOLD = 4; + +/** + * Presses already claimed by a grid, so one that bubbles through nested grids + * starts exactly one gesture: the innermost grid's. + */ +const claimedPresses = new WeakSet<Event>(); + +/** + * Controls that handle press-and-drag themselves, so a press on one must not + * also move the widget: a filter's slider, a form control, a link, a handle + * for reordering something inside a widget. `data-canvas-no-drag` lets a + * widget opt anything else out. + */ +const OWN_GESTURE = [ + 'input', + 'textarea', + 'select', + 'button', + 'a[href]', + '[role="slider"]', + '[role="button"]', + '[role="scrollbar"]', + '[contenteditable=""]', + '[contenteditable="true"]', + '[draggable="true"]', + '[data-canvas-no-drag]', +].join(','); + +/** + * Whether a press landed on a scrollbar rather than inside the content. + * + * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a + * widget tall enough to scroll -- a table, usually -- would otherwise be + * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop + * short of the scrollbar, so a press past either is on it; the comparison is + * skipped for an element that isn't overflowing, which has no scrollbar to + * press. A left-hand scrollbar (RTL) is not detected. + */ +const onScrollbar = ( + target: Element, + clientX: number, + clientY: number, +): boolean => { + const box = target.getBoundingClientRect(); + return ( + (target.scrollHeight > target.clientHeight && + clientX > box.left + target.clientWidth) || + (target.scrollWidth > target.clientWidth && + clientY > box.top + target.clientHeight) + ); +}; + +/** Whether something inside the widget owns this press, so the grid doesn't. */ +const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => { + const { target } = event; + if (!(target instanceof Element)) return false; + const owner = target.closest(OWN_GESTURE); + // Bounded to this widget: a control that happens to sit above the grid in + // the page isn't what the press landed on. + return ( + (owner !== null && event.currentTarget.contains(owner)) || + onScrollbar(target, event.clientX, event.clientY) + ); +}; + +/** A gesture's movement over the page content, scrolling included. */ +const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({ + dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX), + dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY), +}); + +const Grid = styled.div<GridMetrics>` + display: grid; + grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr)); + grid-auto-rows: ${({ rowUnit }) => rowUnit}px; + gap: ${({ gap }) => gap}px; +`; + +const GridItem = styled.div<{ + placement?: GridPlacement; + dragging?: boolean; + offset?: { x: number; y: number }; +}>` + ${({ placement, dragging, offset }) => css` + min-width: 0; + min-height: 0; + position: relative; + ${ + placement && + css` + grid-column: ${placement.col} / span ${placement.colSpan}; + grid-row: ${placement.row} / span ${placement.rowSpan}; + ` + } + ${ + dragging && + offset && + css` + /* Follow the pointer without reflowing the grid under it. */ + transform: translate(${offset.x}px, ${offset.y}px); + z-index: 2; + opacity: 0.85; + ` + } + `} +`; + +/** + * Where the widget being dragged will land. No error state: the neighbours + * are already rendered where they will end up, so an overlap shows as them + * moving aside rather than as a warning colour here. + */ +const DropPreview = styled.div<{ placement: GridPlacement }>` + ${({ theme, placement }) => css` + grid-column: ${placement.col} / span ${placement.colSpan}; + grid-row: ${placement.row} / span ${placement.rowSpan}; + border: 2px dashed ${theme.colorPrimary}; + border-radius: ${theme.borderRadius}px; + background: ${theme.colorPrimaryBg}; + pointer-events: none; + z-index: 1; + `} +`; + +/** + * Wraps a widget so its handles can sit on top of it. The handles stay hidden + * until the pointer is over the widget or a handle has focus, so a canvas at + * rest looks the same whether or not the user may edit it. + * + * Only the innermost hovered widget shows its handles and its outline: + * `:hover` matches every ancestor, so a container would otherwise light up + * alongside whichever of its children the pointer is actually over. + */ +const Interactive = styled.div` + ${({ theme }) => css` + height: 100%; + position: relative; + border-radius: ${theme.borderRadius}px; + transition: box-shadow ${theme.motionDurationMid}; + + /* Direct children only: hovering a container must not reveal the + handles of every widget nested inside it. */ + & > .canvas-handle { + opacity: 0; + /* + * Transparent is not absent: without this a hidden handle still wins + * the hit test over whatever it happens to sit above. + */ + pointer-events: none; + transition: opacity ${theme.motionDurationMid}; + } + + &:hover:not(:has(.canvas-interactive:hover)) { + /* Borderless widgets (a metric tile, say) otherwise give no clue which + widget the handles belong to. */ + box-shadow: 0 0 0 1px ${theme.colorBorder}; + } + + &:hover:not(:has(.canvas-interactive:hover)) > .canvas-handle, + & > .canvas-handle:focus-visible { + opacity: 1; + pointer-events: auto; + } + `} +`; + +/** Step between a container's handles and its children's, in size units. */ +const HANDLE_STEP = 8; +/** Size units a handle itself needs, so an offset can leave room for it. */ +const HANDLE_BOX = 7; + +/** + * How far a nested grid's handles sit from the widget's edge. + * + * Nesting steps the offset so a container's handle never lands exactly on its + * child's, but it is clamped to the widget's own rendered box: pushing past + * that would put the handle over the *next* widget, where it would take + * presses meant for that one. + */ +const handleOffset = ( + sizeUnit: number, + depth: number, + extent: number, +): number => { + const room = Math.max(sizeUnit, extent - sizeUnit * HANDLE_BOX); + return Math.min(sizeUnit * (1 + depth * HANDLE_STEP), room); +}; + +/** + * The widget itself, kept inside the cells it was given. + * + * A widget renders at its natural height — a table with every row, say — so + * without this it spills past its own box and over whatever is below. The + * handles are siblings rather than children, so they stay pinned to the box + * while the content scrolls behind them. + */ +const Content = styled.div<{ editable: boolean }>` + ${({ editable }) => css` + height: 100%; + overflow: auto; Review Comment: This clips a child dragged out of its container: dragging a KPI tile out of the group cuts it off at the group's edge and shows a scrollbar. Something like `&:has([data-dragging='true']) { overflow: visible; }` inside the `editable` block, with `data-dragging` set on the dragged `GridItem`, fixes it. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
