mirror of
https://github.com/enso-org/enso.git
synced 2024-12-20 11:11:38 +03:00
Look and feel: Lists and dropdowns (#11620)
https://github.com/user-attachments/assets/d8c039e6-670c-4ff5-9d71-c07ee6114570 Lists: - Drag handles are icons. - List controls are shown only when component is sole selection. - Each item has delete button. - Integration tests cover dragging, adding, removing. https://github.com/user-attachments/assets/58054cb2-22bc-4279-850c-0bc4929fc246 Dropdowns: - Arrows are shown when hovered or component is sole selection. - Arrows change opacity when hovered. Implements #11533.
This commit is contained in:
parent
dcabf6d0ae
commit
a2e87d37a9
@ -34,6 +34,7 @@
|
|||||||
- [Table Input Widget is now matched for Table.input method instead of
|
- [Table Input Widget is now matched for Table.input method instead of
|
||||||
Table.new. Values must be string literals, and their content is parsed to the
|
Table.new. Values must be string literals, and their content is parsed to the
|
||||||
suitable type][11612].
|
suitable type][11612].
|
||||||
|
- [New design for vector-editing widget][11620]
|
||||||
|
|
||||||
[11151]: https://github.com/enso-org/enso/pull/11151
|
[11151]: https://github.com/enso-org/enso/pull/11151
|
||||||
[11271]: https://github.com/enso-org/enso/pull/11271
|
[11271]: https://github.com/enso-org/enso/pull/11271
|
||||||
@ -55,6 +56,7 @@
|
|||||||
[11564]: https://github.com/enso-org/enso/pull/11564
|
[11564]: https://github.com/enso-org/enso/pull/11564
|
||||||
[11597]: https://github.com/enso-org/enso/pull/11597
|
[11597]: https://github.com/enso-org/enso/pull/11597
|
||||||
[11612]: https://github.com/enso-org/enso/pull/11612
|
[11612]: https://github.com/enso-org/enso/pull/11612
|
||||||
|
[11620]: https://github.com/enso-org/enso/pull/11620
|
||||||
|
|
||||||
#### Enso Standard Library
|
#### Enso Standard Library
|
||||||
|
|
||||||
|
@ -121,6 +121,16 @@ export function bottomDock(page: Page) {
|
|||||||
return page.getByTestId('bottomDock')
|
return page.getByTestId('bottomDock')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Button to add an item to a vector */
|
||||||
|
export function addItemButton(page: Locator | Page) {
|
||||||
|
return page.getByRole('button', { name: 'new item' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button to delete a specific item from a vector */
|
||||||
|
export function deleteItemButton(page: Locator | Page) {
|
||||||
|
return page.getByRole('button', { name: 'Remove item' })
|
||||||
|
}
|
||||||
|
|
||||||
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
||||||
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
||||||
|
|
||||||
|
@ -105,7 +105,7 @@ test('Multi-selection widget', async ({ page }) => {
|
|||||||
await dropDown.expectVisibleWithOptions(['Column A', 'Column B'])
|
await dropDown.expectVisibleWithOptions(['Column A', 'Column B'])
|
||||||
await expect(dropDown.rootWidget).toHaveClass(/multiSelect/)
|
await expect(dropDown.rootWidget).toHaveClass(/multiSelect/)
|
||||||
const vector = node.locator('.WidgetVector')
|
const vector = node.locator('.WidgetVector')
|
||||||
const vectorItems = vector.locator('.item .WidgetPort input')
|
const vectorItems = vector.getByTestId('list-item-content').locator('.WidgetPort input')
|
||||||
await expect(vector).toBeVisible()
|
await expect(vector).toBeVisible()
|
||||||
await expect(dropDown.selectedItems).toHaveCount(0)
|
await expect(dropDown.selectedItems).toHaveCount(0)
|
||||||
await expect(vectorItems).toHaveCount(0)
|
await expect(vectorItems).toHaveCount(0)
|
||||||
@ -121,7 +121,7 @@ test('Multi-selection widget', async ({ page }) => {
|
|||||||
// Add-item button opens dropdown, after closing with escape.
|
// Add-item button opens dropdown, after closing with escape.
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
await dropDown.expectNotVisible()
|
await dropDown.expectNotVisible()
|
||||||
await vector.locator('.add-item').click()
|
await locate.addItemButton(vector).click()
|
||||||
await expect(dropDown.items).toHaveCount(2)
|
await expect(dropDown.items).toHaveCount(2)
|
||||||
await expect(dropDown.selectedItems).toHaveCount(1)
|
await expect(dropDown.selectedItems).toHaveCount(1)
|
||||||
|
|
||||||
@ -180,7 +180,7 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
|
|||||||
.graphNodeByBinding(page, 'selected')
|
.graphNodeByBinding(page, 'selected')
|
||||||
.locator('.WidgetTopLevelArgument')
|
.locator('.WidgetTopLevelArgument')
|
||||||
.filter({ has: page.getByText('columns') })
|
.filter({ has: page.getByText('columns') })
|
||||||
const vectorItems = columnsArg.locator('.WidgetVector .item .WidgetPort input')
|
const vectorItems = columnsArg.getByTestId('list-item-content').locator('.WidgetPort input')
|
||||||
const dropDown = new DropDownLocator(columnsArg)
|
const dropDown = new DropDownLocator(columnsArg)
|
||||||
await dropDown.clickWidget()
|
await dropDown.clickWidget()
|
||||||
await dropDown.clickOption('Column A')
|
await dropDown.clickOption('Column A')
|
||||||
@ -195,6 +195,46 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
|
|||||||
await expect(dropDown.selectedItem('Column B')).toExist()
|
await expect(dropDown.selectedItem('Column B')).toExist()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Editing list', async ({ page }) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const node = locate.graphNodeByBinding(page, 'autoscoped')
|
||||||
|
const vector = node.locator('.WidgetVector')
|
||||||
|
const vectorItems = vector.locator('.item')
|
||||||
|
const vectorElements = vector.getByTestId('list-item-content')
|
||||||
|
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||||
|
await node.click()
|
||||||
|
|
||||||
|
// Test add
|
||||||
|
await locate.addItemButton(node).click()
|
||||||
|
await locate.addItemButton(node).click()
|
||||||
|
await expect(vectorElements).toHaveText(['..Group_By', '_', '_'])
|
||||||
|
|
||||||
|
// Test drag: remove item
|
||||||
|
await vectorItems.nth(1).locator('[draggable]').dragTo(locate.graphEditor(page))
|
||||||
|
await expect(vectorElements).toHaveText(['..Group_By', '_'])
|
||||||
|
|
||||||
|
// Test drag: reorder items
|
||||||
|
await vectorItems.nth(1).locator('[draggable]').hover()
|
||||||
|
await page.mouse.down()
|
||||||
|
await vectorItems
|
||||||
|
.nth(1)
|
||||||
|
.locator('[draggable]')
|
||||||
|
.hover({ position: { x: 10, y: 10 } })
|
||||||
|
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||||
|
await vectorElements.first().hover({ position: { x: 10, y: 10 }, force: true })
|
||||||
|
await page.mouse.up()
|
||||||
|
await expect(vectorElements).toHaveText(['_', '..Group_By'])
|
||||||
|
|
||||||
|
// Test delete
|
||||||
|
await locate.deleteItemButton(vectorItems.first()).click()
|
||||||
|
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||||
|
|
||||||
|
// Test delete: last item
|
||||||
|
await locate.deleteItemButton(vectorItems).click()
|
||||||
|
await expect(vectorItems).not.toExist()
|
||||||
|
await expect(vector).toExist()
|
||||||
|
})
|
||||||
|
|
||||||
async function dataReadNodeWithMethodCallInfo(page: Page): Promise<Locator> {
|
async function dataReadNodeWithMethodCallInfo(page: Page): Promise<Locator> {
|
||||||
await mockMethodCallInfo(page, 'data', {
|
await mockMethodCallInfo(page, 'data', {
|
||||||
methodPointer: {
|
methodPointer: {
|
||||||
@ -374,7 +414,7 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
|||||||
// Add first aggregate
|
// Add first aggregate
|
||||||
const columnsArg = topLevelArgs.filter({ has: page.getByText('columns') })
|
const columnsArg = topLevelArgs.filter({ has: page.getByText('columns') })
|
||||||
|
|
||||||
await columnsArg.locator('.add-item').click()
|
await locate.addItemButton(columnsArg).click()
|
||||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||||
'Aggregate_Column',
|
'Aggregate_Column',
|
||||||
'.',
|
'.',
|
||||||
@ -423,7 +463,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Set column
|
// Set column
|
||||||
const firstItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection').nth(0)
|
const firstItem = columnsArg
|
||||||
|
.getByTestId('list-item-content')
|
||||||
|
.locator('.WidgetPort > .WidgetSelection')
|
||||||
|
.nth(0)
|
||||||
const firstItemDropdown = new DropDownLocator(firstItem)
|
const firstItemDropdown = new DropDownLocator(firstItem)
|
||||||
await firstItemDropdown.clickWidget()
|
await firstItemDropdown.clickWidget()
|
||||||
await firstItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
await firstItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
||||||
@ -436,7 +479,7 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
|||||||
await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
|
await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
|
||||||
|
|
||||||
// Add another aggregate
|
// Add another aggregate
|
||||||
await columnsArg.locator('.add-item').click()
|
await locate.addItemButton(columnsArg).click()
|
||||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||||
'Aggregate_Column',
|
'Aggregate_Column',
|
||||||
'.',
|
'.',
|
||||||
@ -462,7 +505,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Set new aggregate's column
|
// Set new aggregate's column
|
||||||
const secondItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection').nth(1)
|
const secondItem = columnsArg
|
||||||
|
.getByTestId('list-item-content')
|
||||||
|
.nth(1)
|
||||||
|
.locator('.WidgetPort > .WidgetSelection')
|
||||||
const secondItemDropdown = new DropDownLocator(secondItem)
|
const secondItemDropdown = new DropDownLocator(secondItem)
|
||||||
await secondItemDropdown.clickWidget()
|
await secondItemDropdown.clickWidget()
|
||||||
await secondItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
await secondItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
||||||
@ -533,7 +579,7 @@ test('Autoscoped constructors', async ({ page }) => {
|
|||||||
await node.click()
|
await node.click()
|
||||||
await expect(topLevelArgs).toHaveCount(3)
|
await expect(topLevelArgs).toHaveCount(3)
|
||||||
|
|
||||||
const groupBy = node.locator('.item').nth(0)
|
const groupBy = node.getByTestId('list-item-content').first()
|
||||||
await expect(groupBy).toBeVisible()
|
await expect(groupBy).toBeVisible()
|
||||||
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
|
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
|
||||||
})
|
})
|
||||||
|
@ -238,7 +238,7 @@ const innerWidgetInput = computed<WidgetInput>(() => {
|
|||||||
|
|
||||||
const parentSelectionArrow = injectSelectionArrow(true)
|
const parentSelectionArrow = injectSelectionArrow(true)
|
||||||
const arrowSuppressed = ref(false)
|
const arrowSuppressed = ref(false)
|
||||||
const showArrow = computed(() => isHovered.value && !arrowSuppressed.value)
|
const showArrow = computed(() => !arrowSuppressed.value && (tree.extended || isHovered.value))
|
||||||
provideSelectionArrow(
|
provideSelectionArrow(
|
||||||
proxyRefs({
|
proxyRefs({
|
||||||
id: computed(() => {
|
id: computed(() => {
|
||||||
@ -464,7 +464,11 @@ declare module '@/providers/widgetRegistry' {
|
|||||||
>
|
>
|
||||||
<NodeWidget :input="innerWidgetInput" />
|
<NodeWidget :input="innerWidgetInput" />
|
||||||
<Teleport v-if="showArrow" defer :disabled="!arrowLocation" :to="arrowLocation">
|
<Teleport v-if="showArrow" defer :disabled="!arrowLocation" :to="arrowLocation">
|
||||||
<SvgIcon name="arrow_right_head_only" class="arrow widgetOutOfLayout" />
|
<SvgIcon
|
||||||
|
name="arrow_right_head_only"
|
||||||
|
class="arrow widgetOutOfLayout"
|
||||||
|
:class="{ hovered: isHovered }"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
|
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
|
||||||
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
|
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
|
||||||
@ -515,6 +519,9 @@ svg.arrow {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
|
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
&.hovered {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityElement {
|
.activityElement {
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script setup lang="ts" generic="T">
|
||||||
|
import SizeTransition from '@/components/SizeTransition.vue'
|
||||||
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { useRaf } from '@/composables/animation'
|
import { useRaf } from '@/composables/animation'
|
||||||
import { useEvent } from '@/composables/events'
|
import { useEvent } from '@/composables/events'
|
||||||
import { useAppClass } from '@/providers/appClass'
|
import { useAppClass } from '@/providers/appClass'
|
||||||
|
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||||
import { Range } from '@/util/data/range'
|
import { Range } from '@/util/data/range'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { uuidv4 } from 'lib0/random'
|
import { uuidv4 } from 'lib0/random'
|
||||||
import { computed, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue'
|
import { computed, type Ref, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue'
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts" generic="T">
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: T[]
|
modelValue: T[]
|
||||||
newItem: () => T | undefined
|
newItem: () => T | undefined
|
||||||
@ -44,6 +45,8 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [modelValue: T[]]
|
'update:modelValue': [modelValue: T[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const tree = injectWidgetTree()
|
||||||
|
|
||||||
const listUuid = uuidv4()
|
const listUuid = uuidv4()
|
||||||
|
|
||||||
const mimeType = computed(() => props.dragMimeType ?? 'application/octet-stream')
|
const mimeType = computed(() => props.dragMimeType ?? 'application/octet-stream')
|
||||||
@ -74,30 +77,34 @@ function decodeMetadataFromMime(mime: string): DropMetadata | undefined {
|
|||||||
|
|
||||||
const draggedIndex = ref<number>()
|
const draggedIndex = ref<number>()
|
||||||
|
|
||||||
type DragItem =
|
interface BaseItem {
|
||||||
| {
|
key: string | number
|
||||||
type: 'item'
|
}
|
||||||
index: number
|
|
||||||
item: T
|
interface NonPlaceholderItem extends BaseItem {
|
||||||
key: string | number
|
type: 'item'
|
||||||
}
|
index: number
|
||||||
| {
|
item: T
|
||||||
type: 'placeholder'
|
hintDeletable: Ref<boolean>
|
||||||
width: number
|
}
|
||||||
key: string | number
|
|
||||||
}
|
interface PlaceholderItem extends BaseItem {
|
||||||
|
type: 'placeholder'
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragItem = NonPlaceholderItem | PlaceholderItem
|
||||||
|
|
||||||
const defaultPlaceholderKey = '__placeholder_key__'
|
const defaultPlaceholderKey = '__placeholder_key__'
|
||||||
|
|
||||||
const mappedItems = computed(() => {
|
const mappedItems = computed<DragItem[]>(() => {
|
||||||
return props.modelValue.map(
|
return props.modelValue.map((item, index) => ({
|
||||||
(item, index): DragItem => ({
|
type: 'item',
|
||||||
type: 'item',
|
index,
|
||||||
index,
|
item,
|
||||||
item,
|
key: props.getKey?.(item) ?? index,
|
||||||
key: props.getKey?.(item) ?? index,
|
hintDeletable: ref(false),
|
||||||
}),
|
}))
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dropInfo = ref<DropHoverInfo>()
|
const dropInfo = ref<DropHoverInfo>()
|
||||||
@ -126,8 +133,7 @@ const displayedChildren = computed(() => {
|
|||||||
key,
|
key,
|
||||||
} as const)
|
} as const)
|
||||||
}
|
}
|
||||||
const x = items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value)
|
return items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value)
|
||||||
return x
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
const rootNode = ref<HTMLElement>()
|
||||||
@ -135,69 +141,66 @@ const rootNode = ref<HTMLElement>()
|
|||||||
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius']
|
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius']
|
||||||
|
|
||||||
function onDragStart(event: DragEvent, index: number) {
|
function onDragStart(event: DragEvent, index: number) {
|
||||||
if (
|
if (!event.dataTransfer) return
|
||||||
!(event.target instanceof HTMLElement && event.target.nextElementSibling instanceof HTMLElement)
|
if (!(event.target instanceof HTMLElement)) return
|
||||||
)
|
// The element that will be shown following the mouse.
|
||||||
return
|
const previewElement = event.target.parentElement
|
||||||
|
if (!(previewElement instanceof HTMLElement)) return
|
||||||
|
// The element being replaced with a placeholder during the operation.
|
||||||
|
const sizeElement = previewElement.parentElement
|
||||||
|
if (!(sizeElement instanceof HTMLElement)) return
|
||||||
|
|
||||||
if (event.dataTransfer) {
|
// Create a fake offscreen DOM element to use as the drag "ghost" image. It will hold a visual
|
||||||
// The dragged widget contents is the next sibling of the drag handle.
|
// clone of the widget being dragged. The ghost style is modified to add a background color
|
||||||
const previewElement = event.target.nextElementSibling
|
// and additional border, as well as apply appropriate element scaling in cross-browser way.
|
||||||
|
const elementOffsetWidth = sizeElement.offsetWidth
|
||||||
|
const elementRect = originalBoundingClientRect.call(sizeElement)
|
||||||
|
const elementScale = elementRect.width / elementOffsetWidth
|
||||||
|
const dragGhost = document.createElement('div')
|
||||||
|
dragGhost.classList.add('ListWidget-drag-ghost')
|
||||||
|
const previewElementStyle = getComputedStyle(previewElement)
|
||||||
|
const elementTopLeft = props.toDragPosition(new Vec2(elementRect.left, elementRect.top))
|
||||||
|
const currentMousePos = props.toDragPosition(new Vec2(event.clientX, event.clientY))
|
||||||
|
const elementRelativeOffset = currentMousePos.sub(elementTopLeft)
|
||||||
|
// To maintain appropriate styling, we have to copy over a set of node tree CSS variables from
|
||||||
|
// the preview element to the ghost element.
|
||||||
|
cssPropsToCopy.forEach((prop) => {
|
||||||
|
dragGhost.style.setProperty(prop, previewElementStyle.getPropertyValue(prop))
|
||||||
|
})
|
||||||
|
dragGhost.style.setProperty('transform', `scale(${elementScale})`)
|
||||||
|
dragGhost.appendChild(previewElement.cloneNode(true))
|
||||||
|
document.body.appendChild(dragGhost)
|
||||||
|
event.dataTransfer.setDragImage(dragGhost, elementRelativeOffset.x, elementRelativeOffset.y)
|
||||||
|
// Remove the ghost element after a short delay, giving the browser time to render it and set
|
||||||
|
// the drag image.
|
||||||
|
setTimeout(() => dragGhost.remove(), 0)
|
||||||
|
|
||||||
// Create a fake offscreen DOM element to use as the drag "ghost" image. It will hold a visual
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
// clone of the widget being dragged. The ghost style is modified to add a background color
|
// `dropEffect: none` does not work for removing an element - it disables drop completely.
|
||||||
// and additional border, as well as apply appropriate element scaling in cross-browser way.
|
event.dataTransfer.dropEffect = 'move'
|
||||||
const elementOffsetWidth = previewElement.offsetWidth
|
const dragItem = props.modelValue[index]!
|
||||||
const elementRect = originalBoundingClientRect.call(previewElement)
|
|
||||||
const elementScale = elementRect.width / elementOffsetWidth
|
|
||||||
const dragGhost = document.createElement('div')
|
|
||||||
dragGhost.classList.add('ListWidget-drag-ghost')
|
|
||||||
const dragGhostInner = document.createElement('div')
|
|
||||||
dragGhost.appendChild(dragGhostInner)
|
|
||||||
const previewElementStyle = getComputedStyle(previewElement)
|
|
||||||
const elementTopLeft = props.toDragPosition(new Vec2(elementRect.left, elementRect.top))
|
|
||||||
const currentMousePos = props.toDragPosition(new Vec2(event.clientX, event.clientY))
|
|
||||||
const elementRelativeOffset = currentMousePos.sub(elementTopLeft)
|
|
||||||
// To maintain appropriate styling, we have to copy over a set of node tree CSS variables from
|
|
||||||
// the original preview element to the ghost element.
|
|
||||||
cssPropsToCopy.forEach((prop) => {
|
|
||||||
dragGhostInner.style.setProperty(prop, previewElementStyle.getPropertyValue(prop))
|
|
||||||
})
|
|
||||||
dragGhostInner.style.setProperty('transform', `scale(${elementScale})`)
|
|
||||||
dragGhostInner.appendChild(previewElement.cloneNode(true))
|
|
||||||
document.body.appendChild(dragGhost)
|
|
||||||
event.dataTransfer.setDragImage(dragGhost, elementRelativeOffset.x, elementRelativeOffset.y)
|
|
||||||
// Remove the ghost element after a short delay, giving the browser time to render it and set
|
|
||||||
// the drag image.
|
|
||||||
setTimeout(() => dragGhost.remove(), 0)
|
|
||||||
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
const meta: DropMetadata = {
|
||||||
// `dropEffect: none` does not work for removing an element - it disables drop completely.
|
list: listUuid,
|
||||||
event.dataTransfer.dropEffect = 'move'
|
key: props.getKey?.(dragItem) ?? index,
|
||||||
const dragItem = props.modelValue[index]!
|
width: elementOffsetWidth,
|
||||||
|
|
||||||
const meta: DropMetadata = {
|
|
||||||
list: listUuid,
|
|
||||||
key: props.getKey?.(dragItem) ?? index,
|
|
||||||
width: elementOffsetWidth,
|
|
||||||
}
|
|
||||||
const payload = props.toDragPayload(dragItem)
|
|
||||||
event.dataTransfer.setData(mimeType.value, payload)
|
|
||||||
|
|
||||||
if (props.toPlainText) {
|
|
||||||
event.dataTransfer.setData('text/plain', props.toPlainText(dragItem))
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaMime = encodeMetadataToMime(meta)
|
|
||||||
event.dataTransfer.setData(metaMime, '')
|
|
||||||
// The code below will remove the item from list; because doing it in the same frame ends drag
|
|
||||||
// immediately, we need to put it in setTimeout (nextTick is not enough).
|
|
||||||
setTimeout(() => {
|
|
||||||
updateItemBounds()
|
|
||||||
draggedIndex.value = index
|
|
||||||
dropInfo.value = { meta, position: currentMousePos }
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
|
const payload = props.toDragPayload(dragItem)
|
||||||
|
event.dataTransfer.setData(mimeType.value, payload)
|
||||||
|
|
||||||
|
if (props.toPlainText) {
|
||||||
|
event.dataTransfer.setData('text/plain', props.toPlainText(dragItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaMime = encodeMetadataToMime(meta)
|
||||||
|
event.dataTransfer.setData(metaMime, '')
|
||||||
|
// The code below will remove the item from list; because doing it in the same frame ends drag
|
||||||
|
// immediately, we need to put it in setTimeout (nextTick is not enough).
|
||||||
|
setTimeout(() => {
|
||||||
|
updateItemBounds()
|
||||||
|
draggedIndex.value = index
|
||||||
|
dropInfo.value = { meta, position: currentMousePos }
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropMetadata {
|
interface DropMetadata {
|
||||||
@ -219,6 +222,7 @@ function areaDragOver(e: DragEvent) {
|
|||||||
const metaMime = e.dataTransfer?.types.find((ty) => ty.startsWith(dragMetaMimePrefix))
|
const metaMime = e.dataTransfer?.types.find((ty) => ty.startsWith(dragMetaMimePrefix))
|
||||||
const typesMatch = e.dataTransfer?.types.includes(mimeType.value)
|
const typesMatch = e.dataTransfer?.types.includes(mimeType.value)
|
||||||
if (!metaMime || !typesMatch) return
|
if (!metaMime || !typesMatch) return
|
||||||
|
e.preventDefault()
|
||||||
const meta = decodeMetadataFromMime(metaMime)
|
const meta = decodeMetadataFromMime(metaMime)
|
||||||
if (meta == null) return
|
if (meta == null) return
|
||||||
|
|
||||||
@ -367,6 +371,11 @@ function addItem() {
|
|||||||
const item = props.newItem()
|
const item = props.newItem()
|
||||||
if (item) emit('update:modelValue', [...props.modelValue, item])
|
if (item) emit('update:modelValue', [...props.modelValue, item])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteItem(index: number) {
|
||||||
|
const modelValue = props.modelValue.filter((_, i) => i !== index)
|
||||||
|
emit('update:modelValue', modelValue)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -388,21 +397,49 @@ function addItem() {
|
|||||||
>
|
>
|
||||||
<template v-for="entry in displayedChildren" :key="entry.key">
|
<template v-for="entry in displayedChildren" :key="entry.key">
|
||||||
<template v-if="entry.type === 'item'">
|
<template v-if="entry.type === 'item'">
|
||||||
<li :ref="(el) => setItemRef(el, entry.index)" class="item">
|
<li :ref="patchBoundingClientRectScaling" class="item">
|
||||||
|
<div :ref="(el) => setItemRef(el, entry.index)" class="draggableContent">
|
||||||
|
<SizeTransition width>
|
||||||
|
<!-- This wrapper is needed because an SVG element cannot directly be draggable. -->
|
||||||
|
<div
|
||||||
|
v-if="tree.extended"
|
||||||
|
class="deletable"
|
||||||
|
:class="{ hintDeletable: entry.hintDeletable.value }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, entry.index)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<SvgIcon name="grab" class="handle" />
|
||||||
|
</div>
|
||||||
|
</SizeTransition>
|
||||||
|
<div
|
||||||
|
class="deletable"
|
||||||
|
:class="{ hintDeletable: entry.hintDeletable.value }"
|
||||||
|
data-testid="list-item-content"
|
||||||
|
>
|
||||||
|
<slot :item="entry.item"></slot>
|
||||||
|
</div>
|
||||||
|
<SizeTransition width>
|
||||||
|
<!-- This wrapper is needed to animate an `SvgButton` because it ultimately contains a `TooltipTrigger`,
|
||||||
|
which has a fragment root. -->
|
||||||
|
<div v-if="tree.extended" class="displayContents">
|
||||||
|
<SvgButton
|
||||||
|
class="item-button"
|
||||||
|
name="close"
|
||||||
|
title="Remove item"
|
||||||
|
@click.stop="deleteItem(entry.index)"
|
||||||
|
@pointerenter="entry.hintDeletable.value = true"
|
||||||
|
@pointerleave="entry.hintDeletable.value = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SizeTransition>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="handle"
|
v-if="entry.index != props.modelValue.length - 1"
|
||||||
draggable="true"
|
class="token widgetApplyPadding"
|
||||||
@dragstart="onDragStart($event, entry.index)"
|
>
|
||||||
@dragend="onDragEnd"
|
,
|
||||||
></div>
|
</div>
|
||||||
<slot :item="entry.item"></slot>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
v-show="entry.index != props.modelValue.length - 1"
|
|
||||||
:ref="patchBoundingClientRectScaling"
|
|
||||||
class="token widgetApplyPadding"
|
|
||||||
>
|
|
||||||
,
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -414,7 +451,18 @@ function addItem() {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<SvgIcon class="add-item" name="vector_add" @click.stop="addItem" />
|
<SizeTransition width>
|
||||||
|
<!-- This wrapper is a workaround: If the `v-if` is applied to the `SvgIcon`, once the button is shown it will
|
||||||
|
never go back to hidden. This might be a Vue bug? -->
|
||||||
|
<div v-if="tree.extended" class="displayContents">
|
||||||
|
<SvgButton
|
||||||
|
class="item-button after-last-item"
|
||||||
|
name="vector_add"
|
||||||
|
title="Add a new item"
|
||||||
|
@click.stop="addItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SizeTransition>
|
||||||
<span class="token widgetApplyPadding">]</span>
|
<span class="token widgetApplyPadding">]</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -489,10 +537,10 @@ div {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item .preview {
|
.draggableContent {
|
||||||
background: var(--color-node-primary);
|
display: flex;
|
||||||
padding: 4px;
|
flex-direction: row;
|
||||||
border-radius: var(--node-border-radius);
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token {
|
.token {
|
||||||
@ -514,85 +562,60 @@ div {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
position: absolute;
|
transition: color 0.2s ease;
|
||||||
display: block;
|
|
||||||
left: -6px;
|
|
||||||
height: calc(100% - 12px);
|
|
||||||
width: 2px;
|
|
||||||
box-shadow:
|
|
||||||
2px 0 0 transparent,
|
|
||||||
-2px 0 0 transparent;
|
|
||||||
transition: box-shadow 0.2s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
|
||||||
|
color: var(--color-widget-unfocus);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-widget-focus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.item:hover {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover .handle {
|
.item-button {
|
||||||
box-shadow:
|
|
||||||
2px 0 0 var(--color-widget-unfocus),
|
|
||||||
-2px 0 0 var(--color-widget-unfocus);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow:
|
|
||||||
2px 0 0 var(--color-widget-focus),
|
|
||||||
-2px 0 0 var(--color-widget-focus);
|
|
||||||
}
|
|
||||||
|
|
||||||
background: var(--color-node-background);
|
|
||||||
pointer-events: all;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
left: -1px;
|
|
||||||
right: -4px;
|
|
||||||
top: -3px;
|
|
||||||
bottom: -3px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover .handle:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.GraphEditor.draggingEdge .handle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item {
|
|
||||||
transition-property: opacity;
|
transition-property: opacity;
|
||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
transition-timing-function: ease-in-out;
|
transition-timing-function: ease-in-out;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
margin-left: 4px;
|
|
||||||
transition: margin 0.2s ease-in-out;
|
transition: margin 0.2s ease-in-out;
|
||||||
.items:empty + & {
|
.items:empty + & {
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item:hover {
|
.after-last-item {
|
||||||
opacity: 1;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.ListWidget-drag-ghost) {
|
:global(.ListWidget-drag-ghost) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -5000px;
|
left: -5000px;
|
||||||
}
|
|
||||||
:global(.ListWidget-drag-ghost > div) {
|
|
||||||
background-color: var(--color-node-primary);
|
background-color: var(--color-node-primary);
|
||||||
border-radius: var(--node-border-radius);
|
border-radius: var(--node-border-radius);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deletable {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
&.hintDeletable {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.displayContents {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SvgButton {
|
||||||
|
--color-menu-entry-hover-bg: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
Loading…
Reference in New Issue
Block a user