Skip to content

Commit

Permalink
Merge branch 'develop' into wip/sb/keyboard-shortcuts-settings
Browse files Browse the repository at this point in the history
  • Loading branch information
somebody1234 committed Feb 26, 2024
2 parents f9d390d + a7251eb commit b88ff74
Show file tree
Hide file tree
Showing 18 changed files with 810 additions and 95 deletions.
184 changes: 184 additions & 0 deletions app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/** @file A selection brush to indicate the area being selected by the mouse drag action. */
import * as React from 'react'

import * as reactDom from 'react-dom'

import * as animationHooks from '#/hooks/animationHooks'

import * as modalProvider from '#/providers/ModalProvider'

import type * as geometry from '#/utilities/geometry'

// ======================
// === SelectionBrush ===
// ======================

/** Props for a {@link SelectionBrush}. */
export interface SelectionBrushProps {
readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void
readonly onDragEnd: (event: MouseEvent) => void
readonly onDragCancel: () => void
}

/** A selection brush to indicate the area being selected by the mouse drag action. */
export default function SelectionBrush(props: SelectionBrushProps) {
const { onDrag, onDragEnd, onDragCancel } = props
const { modalRef } = modalProvider.useModalRef()
const isMouseDownRef = React.useRef(false)
const didMoveWhileDraggingRef = React.useRef(false)
const onDragRef = React.useRef(onDrag)
const onDragEndRef = React.useRef(onDragEnd)
const onDragCancelRef = React.useRef(onDragCancel)
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
const [anchor, setAnchor] = React.useState<geometry.Coordinate2D | null>(null)
// This will be `null` if `anchor` is `null`.
const [position, setPosition] = React.useState<geometry.Coordinate2D | null>(null)
const [lastSetAnchor, setLastSetAnchor] = React.useState<geometry.Coordinate2D | null>(null)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const anchorAnimFactor = animationHooks.useApproach(anchor != null ? 1 : 0, 60)
const hidden =
anchor == null ||
position == null ||
(anchor.left === position.left && anchor.top === position.top)

React.useEffect(() => {
onDragRef.current = onDrag
}, [onDrag])

React.useEffect(() => {
onDragEndRef.current = onDragEnd
}, [onDragEnd])

React.useEffect(() => {
onDragCancelRef.current = onDragCancel
}, [onDragCancel])

React.useEffect(() => {
if (anchor != null) {
anchorAnimFactor.skip()
}
}, [anchorAnimFactor, anchor])

React.useEffect(() => {
const onMouseDown = (event: MouseEvent) => {
if (
modalRef.current == null &&
!(event.target instanceof HTMLInputElement) &&
!(event.target instanceof HTMLTextAreaElement) &&
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
!(event.target instanceof HTMLButtonElement) &&
!(event.target instanceof HTMLAnchorElement)
) {
isMouseDownRef.current = true
didMoveWhileDraggingRef.current = false
lastMouseEvent.current = event
const newAnchor = { left: event.pageX, top: event.pageY }
setAnchor(newAnchor)
setLastSetAnchor(newAnchor)
setPosition(newAnchor)
}
}
const onMouseUp = (event: MouseEvent) => {
if (didMoveWhileDraggingRef.current) {
onDragEndRef.current(event)
}
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
// is executed.
window.setTimeout(() => {
isMouseDownRef.current = false
didMoveWhileDraggingRef.current = false
})
setAnchor(null)
}
const onMouseMove = (event: MouseEvent) => {
if (!(event.buttons & 1)) {
isMouseDownRef.current = false
}
if (isMouseDownRef.current) {
// Left click is being held.
didMoveWhileDraggingRef.current = true
lastMouseEvent.current = event
setPosition({ left: event.pageX, top: event.pageY })
}
}
const onClick = (event: MouseEvent) => {
if (isMouseDownRef.current && didMoveWhileDraggingRef.current) {
event.stopImmediatePropagation()
}
}
const onDragStart = () => {
if (isMouseDownRef.current) {
isMouseDownRef.current = false
onDragCancelRef.current()
setAnchor(null)
}
}
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('dragstart', onDragStart, { capture: true })
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('click', onClick)
return () => {
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('dragstart', onDragStart, { capture: true })
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('click', onClick)
}
}, [/* should never change */ modalRef])

const rectangle = React.useMemo(() => {
if (position != null && lastSetAnchor != null) {
const start: geometry.Coordinate2D = {
left:
position.left * (1 - anchorAnimFactor.value) +
lastSetAnchor.left * anchorAnimFactor.value,
top:
position.top * (1 - anchorAnimFactor.value) + lastSetAnchor.top * anchorAnimFactor.value,
}
return {
left: Math.min(position.left, start.left),
top: Math.min(position.top, start.top),
right: Math.max(position.left, start.left),
bottom: Math.max(position.top, start.top),
width: Math.abs(position.left - start.left),
height: Math.abs(position.top - start.top),
signedWidth: position.left - start.left,
signedHeight: position.top - start.top,
}
} else {
return null
}
}, [anchorAnimFactor.value, lastSetAnchor, position])

const selectionRectangle = React.useMemo(() => (hidden ? null : rectangle), [hidden, rectangle])

React.useEffect(() => {
if (selectionRectangle != null && lastMouseEvent.current != null) {
onDrag(selectionRectangle, lastMouseEvent.current)
}
// `onChange` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionRectangle])

const brushStyle =
rectangle == null
? {}
: {
left: `${rectangle.left}px`,
top: `${rectangle.top}px`,
width: `${rectangle.width}px`,
height: `${rectangle.height}px`,
}

return reactDom.createPortal(
<div
className={`fixed bg-selection-brush pointer-events-none box-content rounded-lg border-transparent z-1 transition-border-margin duration-100 ${
hidden ? 'border-0 m-0' : 'border-6 -m-1.5'
}`}
style={brushStyle}
/>,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
document.getElementById('enso-dashboard')!
)
}
53 changes: 36 additions & 17 deletions app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ import Visibility, * as visibilityModule from '#/utilities/visibility'
// === Constants ===
// =================

/** The height of the header row. */
const HEADER_HEIGHT_PX = 34
/** The amount of time (in milliseconds) the drag item must be held over this component
* to make a directory row expand. */
const DRAG_EXPAND_DELAY_MS = 500

/** Placeholder row for directories that are empty. */
const EMPTY_DIRECTORY_PLACEHOLDER = <span className="px-2 opacity-75">This folder is empty.</span>

Expand All @@ -70,7 +71,8 @@ export interface AssetRowProps
readonly columns: columnUtils.Column[]
readonly selected: boolean
readonly setSelected: (selected: boolean) => void
readonly isSoleSelectedItem: boolean
readonly isSoleSelected: boolean
readonly isKeyboardSelected: boolean
readonly allowContextMenu: boolean
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
readonly onContextMenu?: (
Expand All @@ -81,10 +83,11 @@ export interface AssetRowProps

/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelectedItem, setSelected } = props
const { allowContextMenu, onContextMenu, state, columns, onClick } = props
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state

const { user, userInfo } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
Expand Down Expand Up @@ -181,10 +184,7 @@ export default function AssetRow(props: AssetRowProps) {
item: asset,
})
setItem(oldItem =>
oldItem.with({
directoryKey: nonNullNewParentKey,
directoryId: nonNullNewParentId,
})
oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId })
)
setAsset(object.merger({ parentId: nonNullNewParentId }))
await backend.updateAsset(
Expand Down Expand Up @@ -222,10 +222,16 @@ export default function AssetRow(props: AssetRowProps) {
)

React.useEffect(() => {
if (isSoleSelectedItem) {
if (isSoleSelected) {
setAssetPanelProps({ item, setItem })
setIsAssetPanelTemporarilyVisible(false)
}
}, [item, isSoleSelectedItem, /* should never change */ setAssetPanelProps])
}, [
item,
isSoleSelected,
/* should never change */ setAssetPanelProps,
/* should never change */ setIsAssetPanelTemporarilyVisible,
])

const doDelete = React.useCallback(async () => {
setInsertionVisibility(Visibility.hidden)
Expand All @@ -239,10 +245,7 @@ export default function AssetRow(props: AssetRowProps) {
})
}
try {
dispatchAssetListEvent({
type: AssetListEventType.willDelete,
key: item.key,
})
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key })
if (
asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local
Expand Down Expand Up @@ -546,9 +549,25 @@ export default function AssetRow(props: AssetRowProps) {
<tr
draggable
tabIndex={-1}
className={`h-8 transition duration-300 ease-in-out ${
ref={element => {
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
const rect = element.getBoundingClientRect()
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
const scrollDown = rect.bottom - scrollRect.bottom
if (scrollUp < 0 || scrollDown > 0) {
scrollContainerRef.current.scrollBy({
top: scrollUp < 0 ? scrollUp : scrollDown,
behavior: 'smooth',
})
}
}
}}
className={`h-8 transition duration-300 ease-in-out rounded-full outline-2 -outline-offset-2 outline-prmary ${
visibilityModule.CLASS_NAME[visibility]
} ${isDraggedOver || selected ? 'selected' : ''}`}
} ${isKeyboardSelected ? 'outline' : ''} ${
isDraggedOver || selected ? 'selected' : ''
}`}
onClick={event => {
unsetModal()
onClick(innerProps, event)
Expand Down Expand Up @@ -678,7 +697,7 @@ export default function AssetRow(props: AssetRowProps) {
setItem={setItem}
selected={selected}
setSelected={setSelected}
isSoleSelectedItem={isSoleSelectedItem}
isSoleSelected={isSoleSelected}
state={state}
rowState={rowState}
setRowState={setRowState}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface DataLinkNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function DataLinkNameColumn(props: DataLinkNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState } = props
const { assetEvents, dispatchAssetListEvent } = state
const { assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const inputBindings = inputBindingsProvider.useInputBindings()
Expand Down Expand Up @@ -133,7 +133,7 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) {
setRowState(object.merger({ isEditingName: true }))
} else if (eventModule.isDoubleClick(event)) {
event.stopPropagation()
// FIXME: Open sidebar and show DataLinkInput populated with the current value
setIsAssetPanelTemporarilyVisible(true)
}
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface AssetColumnProps {
readonly setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
readonly selected: boolean
readonly setSelected: (selected: boolean) => void
readonly isSoleSelectedItem: boolean
readonly isSoleSelected: boolean
readonly state: assetsTable.AssetsTableState
readonly rowState: assetsTable.AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
Expand Down
Loading

0 comments on commit b88ff74

Please sign in to comment.