Skip to content

Commit

Permalink
Grid drag duplicate (#6036)
Browse files Browse the repository at this point in the history
**Problem:**

We should be able to drag+alt to duplicate a grid element while moving
it inside the grid.

**Fix:**

- Add a new `gridRearrangeMoveDuplicateStrategy` strategy
- Extract the move logic from the move strategy into a helper, and use
it for both the regular move strategy and the new duplication one
- Tweak the logic to target a cell under the mouse so it can work
recursively for when the duplication is happening



https://github.com/concrete-utopia/utopia/assets/1081051/843a02fe-9935-45a0-80b1-80e2474e7bfe
  • Loading branch information
ruggi authored and liady committed Dec 13, 2024
1 parent 9ea15a1 commit 4c19766
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import { gridRearrangeMoveStrategy } from './strategies/grid-rearrange-move-strategy'
import { resizeGridStrategy } from './strategies/resize-grid-strategy'
import { rearrangeGridSwapStrategy } from './strategies/rearrange-grid-swap-strategy'
import { gridRearrangeMoveDuplicateStrategy } from './strategies/grid-rearrange-move-duplicate-strategy'

export type CanvasStrategyFactory = (
canvasState: InteractionCanvasState,
Expand Down Expand Up @@ -95,6 +96,7 @@ const moveOrReorderStrategies: MetaCanvasStrategy = (
reorderSliderStategy,
gridRearrangeMoveStrategy,
rearrangeGridSwapStrategy,
gridRearrangeMoveDuplicateStrategy,
],
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,154 @@
import type { WindowPoint } from '../../../../core/shared/math-utils'
import type { ElementPath } from 'utopia-shared/src/types'
import { MetadataUtils } from '../../../../core/model/element-metadata-utils'
import type {
ElementInstanceMetadataMap,
GridElementProperties,
} from '../../../../core/shared/element-template'
import type { CanvasVector } from '../../../../core/shared/math-utils'
import {
offsetPoint,
rectContainsPoint,
windowRectangle,
type WindowPoint,
} from '../../../../core/shared/math-utils'
import { create } from '../../../../core/shared/property-path'
import type { CanvasCommand } from '../../commands/commands'
import { setProperty } from '../../commands/set-property-command'
import type { GridCellCoordinates } from '../../controls/grid-controls'
import { gridCellCoordinates } from '../../controls/grid-controls'
import { canvasPointToWindowPoint } from '../../dom-lookup'
import type { DragInteractionData } from '../interaction-state'

export function getGridCellUnderMouse(
export function getGridCellUnderMouse(mousePoint: WindowPoint) {
return getGridCellAtPoint(mousePoint, false)
}

function getGridCellUnderMouseRecursive(mousePoint: WindowPoint) {
return getGridCellAtPoint(mousePoint, true)
}

function getGridCellAtPoint(
windowPoint: WindowPoint,
duplicating: boolean,
): { id: string; coordinates: GridCellCoordinates } | null {
const cellsUnderMouse = document
.elementsFromPoint(windowPoint.x, windowPoint.y)
.filter((el) => el.id.startsWith(`gridcell-`))
if (cellsUnderMouse.length > 0) {
const cellUnderMouse = cellsUnderMouse[0]
const row = cellUnderMouse.getAttribute('data-grid-row')
const column = cellUnderMouse.getAttribute('data-grid-column')
return {
id: cellsUnderMouse[0].id,
coordinates: gridCellCoordinates(
row == null ? 0 : parseInt(row),
column == null ? 0 : parseInt(column),
),
function maybeRecursivelyFindCellAtPoint(elements: Element[]): Element | null {
// If this used during duplication, the canvas controls will be in the way and we need to traverse the children too.
for (const element of elements) {
if (element.id.startsWith('gridcell-')) {
const rect = element.getBoundingClientRect()
if (rectContainsPoint(windowRectangle(rect), windowPoint)) {
return element
}
}

if (duplicating) {
const child = maybeRecursivelyFindCellAtPoint(Array.from(element.children))
if (child != null) {
return child
}
}
}

return null
}

const cellUnderMouse = maybeRecursivelyFindCellAtPoint(
document.elementsFromPoint(windowPoint.x, windowPoint.y),
)
if (cellUnderMouse == null) {
return null
}

const row = cellUnderMouse.getAttribute('data-grid-row')
const column = cellUnderMouse.getAttribute('data-grid-column')
return {
id: cellUnderMouse.id,
coordinates: gridCellCoordinates(
row == null ? 0 : parseInt(row),
column == null ? 0 : parseInt(column),
),
}
}

export function runGridRearrangeMove(
targetElement: ElementPath,
selectedElement: ElementPath,
jsxMetadata: ElementInstanceMetadataMap,
interactionData: DragInteractionData,
canvasScale: number,
canvasOffset: CanvasVector,
targetGridCell: GridCellCoordinates | null,
duplicating: boolean,
): { commands: CanvasCommand[]; targetGridCell: GridCellCoordinates | null } {
let commands: CanvasCommand[] = []

if (interactionData.drag == null) {
return { commands: [], targetGridCell: null }
}

const mouseWindowPoint = canvasPointToWindowPoint(
offsetPoint(interactionData.dragStart, interactionData.drag),
canvasScale,
canvasOffset,
)

let newTargetGridCell = targetGridCell ?? null
const cellUnderMouse = duplicating
? getGridCellUnderMouseRecursive(mouseWindowPoint)
: getGridCellUnderMouse(mouseWindowPoint)
if (cellUnderMouse != null) {
newTargetGridCell = cellUnderMouse.coordinates
}

if (newTargetGridCell == null || newTargetGridCell.row < 1 || newTargetGridCell.column < 1) {
return { commands: [], targetGridCell: null }
}

const originalElementMetadata = MetadataUtils.findElementByElementPath(
jsxMetadata,
selectedElement,
)
if (originalElementMetadata == null) {
return { commands: [], targetGridCell: null }
}

function getGridProperty(field: keyof GridElementProperties, fallback: number) {
const propValue = originalElementMetadata?.specialSizeMeasurements.elementGridProperties[field]
return propValue == null || propValue === 'auto' ? 0 : propValue.numericalPosition ?? fallback
}

const gridColumnStart = getGridProperty('gridColumnStart', 0)
const gridColumnEnd = getGridProperty('gridColumnEnd', 1)
const gridRowStart = getGridProperty('gridRowStart', 0)
const gridRowEnd = getGridProperty('gridRowEnd', 1)

commands.push(
setProperty(
'always',
targetElement,
create('style', 'gridColumnStart'),
newTargetGridCell.column,
),
setProperty(
'always',
targetElement,
create('style', 'gridColumnEnd'),
Math.max(
newTargetGridCell.column,
newTargetGridCell.column + (gridColumnEnd - gridColumnStart),
),
),
setProperty('always', targetElement, create('style', 'gridRowStart'), newTargetGridCell.row),
setProperty(
'always',
targetElement,
create('style', 'gridRowEnd'),
Math.max(newTargetGridCell.row, newTargetGridCell.row + (gridRowEnd - gridRowStart)),
),
)

return {
commands: commands,
targetGridCell: newTargetGridCell,
}
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { MetadataUtils } from '../../../../core/model/element-metadata-utils'
import { generateUidWithExistingComponents } from '../../../../core/model/element-template-utils'
import * as EP from '../../../../core/shared/element-path'
import { CSSCursor } from '../../../../uuiui-deps'
import { duplicateElement } from '../../commands/duplicate-element-command'
import { setCursorCommand } from '../../commands/set-cursor-command'
import { setElementsToRerenderCommand } from '../../commands/set-elements-to-rerender-command'
import { updateHighlightedViews } from '../../commands/update-highlighted-views-command'
import { updateSelectedViews } from '../../commands/update-selected-views-command'
import { GridControls } from '../../controls/grid-controls'
import type { CanvasStrategyFactory } from '../canvas-strategies'
import { onlyFitWhenDraggingThisControl } from '../canvas-strategies'
import type { CustomStrategyState, InteractionCanvasState } from '../canvas-strategy-types'
import {
getTargetPathsFromInteractionTarget,
emptyStrategyApplicationResult,
strategyApplicationResult,
} from '../canvas-strategy-types'
import type { InteractionSession } from '../interaction-state'
import { runGridRearrangeMove } from './grid-helpers'

export const gridRearrangeMoveDuplicateStrategy: CanvasStrategyFactory = (
canvasState: InteractionCanvasState,
interactionSession: InteractionSession | null,
customState: CustomStrategyState,
) => {
const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget)
if (
selectedElements.length === 0 ||
interactionSession == null ||
interactionSession.interactionData.type !== 'DRAG' ||
interactionSession.interactionData.drag == null ||
interactionSession.activeControl.type !== 'GRID_CELL_HANDLE' ||
!interactionSession.interactionData.modifiers.alt
) {
return null
}

const selectedElement = selectedElements[0]
const ok = MetadataUtils.isGridLayoutedContainer(
MetadataUtils.findElementByElementPath(
canvasState.startingMetadata,
EP.parentPath(selectedElement),
),
)
if (!ok) {
return null
}

return {
id: 'rearrange-grid-move-duplicate-strategy',
name: 'Rearrange Grid (Duplicate)',
descriptiveLabel: 'Rearrange Grid (Duplicate)',
icon: {
category: 'tools',
type: 'pointer',
},
controlsToRender: [
{
control: GridControls,
props: {},
key: `grid-controls-${EP.toString(selectedElement)}`,
show: 'always-visible',
},
],
fitness: onlyFitWhenDraggingThisControl(interactionSession, 'GRID_CELL_HANDLE', 3),
apply: () => {
if (
interactionSession == null ||
interactionSession.interactionData.type !== 'DRAG' ||
interactionSession.interactionData.drag == null ||
interactionSession.activeControl.type !== 'GRID_CELL_HANDLE'
) {
return emptyStrategyApplicationResult
}

const oldUid = EP.toUid(selectedElement)

let duplicatedElementNewUids = { ...customState.duplicatedElementNewUids }
let newUid = duplicatedElementNewUids[oldUid]
if (newUid == null) {
newUid = 'dup-' + generateUidWithExistingComponents(canvasState.projectContents)
duplicatedElementNewUids[oldUid] = newUid
}

const targetElement = EP.appendToPath(EP.parentPath(selectedElement), newUid)

const { commands: moveCommands, targetGridCell: newTargetGridCell } = runGridRearrangeMove(
targetElement,
selectedElement,
canvasState.startingMetadata,
interactionSession.interactionData,
canvasState.scale,
canvasState.canvasOffset,
customState.targetGridCell,
true,
)
if (moveCommands.length === 0) {
return emptyStrategyApplicationResult
}

return strategyApplicationResult(
[
duplicateElement('always', selectedElement, newUid),
...moveCommands,
setElementsToRerenderCommand([...selectedElements, targetElement]),
updateSelectedViews('always', [targetElement]),
updateHighlightedViews('always', [targetElement]),
setCursorCommand(CSSCursor.Duplicate),
],
{
targetGridCell: newTargetGridCell,
duplicatedElementNewUids: duplicatedElementNewUids,
},
)
},
}
}
Loading

0 comments on commit 4c19766

Please sign in to comment.