Skip to content

Commit

Permalink
Create node from port button (#11836)
Browse files Browse the repository at this point in the history
Closes #11508

https://github.com/user-attachments/assets/7943f63d-4d34-4909-ac47-bf8ea4dd3eb7

A few notes regarding implementation:

- The button is drawn as SVG and embedded into the output port because it is relatively easy to position it correctly and seamlessly blend it with the surrounding output port.
- We have a ready SVG `plus` icon, but the initial design suggested we want to make it transparent in the center, so I used a mask to clip the plus shape from the background. This is relatively unimportant for the implementation and can be replaced by a separate SVG if we want.
- I fixed the positioning for “port labels.” They are only visible when multiple output ports are present, so it is not important.
- There is a hover area extending around the actual button and its “connection”, so it is easy to click. It is a nuance for the tests, though, so I was forced to use `force: true`.
- The connection is not a real connection, but has the same dimensions and color scheme. Implementing this visual part as a “fake” edge is extremely tricky, so I chose not to do that.
- These buttons must be hidden when the component browser is connected, or they will visually conflict with the normal edge. There is no clear way to check if the component browser is connected to _that_ port, so _every_ button is hidden when the component browser is opened.
- As an unrelated change, I renamed `circular menu` → `component menu`.
  • Loading branch information
vitvakatu authored Dec 18, 2024
1 parent e4a4bcc commit 7a92d1c
Show file tree
Hide file tree
Showing 16 changed files with 186 additions and 81 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Next Next Release

#### Enso IDE

- [Round ‘Add component’ button under the component menu replaced by a small
button protruding from the output port.][11836].

[11836]: https://github.com/enso-org/enso/pull/11836

#### Enso Language & Runtime

- [Intersection types & type checks][11600]
Expand Down
4 changes: 2 additions & 2 deletions app/gui/integration-test/project-view/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export async function dragNodeByBinding(page: Page, nodeBinding: string, x: numb
}

/** Move mouse away to avoid random hover events and wait for any circular menus to disappear. */
export async function ensureNoCircularMenusVisibleDueToHovering(page: Page) {
export async function ensureNoComponentMenusVisibleDueToHovering(page: Page) {
await page.mouse.move(-1000, 0)
await expect(locate.circularMenu(page)).toBeHidden()
await expect(locate.componentMenu(page)).toBeHidden()
}

/** Ensure no nodes are selected. */
Expand Down
22 changes: 9 additions & 13 deletions app/gui/integration-test/project-view/componentBrowser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,21 @@ test('Different ways of opening Component Browser', async ({ page }) => {
await expectAndCancelBrowser(page, '', 'selected')
})

test('Opening Component Browser with small plus buttons', async ({ page }) => {
test('Opening Component Browser from output port buttons', async ({ page }) => {
await actions.goToGraph(page)

// Small (+) button shown when node is hovered
await page.keyboard.press('Escape')
await page.mouse.move(100, 80)
await expect(locate.smallPlusButton(page)).toBeHidden()
await locate.graphNodeIcon(locate.graphNodeByBinding(page, 'selected')).hover()
await expect(locate.smallPlusButton(page)).toBeVisible()
await locate.smallPlusButton(page).click()
const node = locate.graphNodeByBinding(page, 'selected')
await locate.graphNodeIcon(node).hover()
await expect(locate.createNodeFromPort(node)).toBeVisible()
await locate.createNodeFromPort(node).click({ force: true })
await expectAndCancelBrowser(page, '', 'selected')

// Small (+) button shown when node is sole selection
// Small (+) button shown when node is selected
await page.keyboard.press('Escape')
await page.mouse.move(300, 300)
await expect(locate.smallPlusButton(page)).toBeHidden()
await locate.graphNodeByBinding(page, 'selected').click()
await expect(locate.smallPlusButton(page)).toBeVisible()
await locate.smallPlusButton(page).click()
await node.click()
await expect(locate.createNodeFromPort(node)).toBeVisible()
await locate.createNodeFromPort(node).click({ force: true })
await expectAndCancelBrowser(page, '', 'selected')
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ test('Node can open and load visualization', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNode(page).last()
await node.click({ position: { x: 8, y: 8 } })
await expect(locate.circularMenu(page)).toExist()
await expect(locate.componentMenu(page)).toExist()
await locate.toggleVisualizationButton(page).click()
await expect(locate.anyVisualization(page)).toExist()
await expect(locate.loadingVisualization(page)).toHaveCount(0)
Expand Down
4 changes: 2 additions & 2 deletions app/gui/integration-test/project-view/locate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ export const graphEditor = componentLocator('.GraphEditor')
export const codeEditor = componentLocator('.CodeEditor')
export const anyVisualization = componentLocator('.GraphVisualization')
export const loadingVisualization = componentLocator('.LoadingVisualization')
export const circularMenu = componentLocator('.CircularMenu')
export const componentMenu = componentLocator('.ComponentMenu')
export const addNewNodeButton = componentLocator('.PlusButton')
export const componentBrowser = componentLocator('.ComponentBrowser')
export const nodeOutputPort = componentLocator('.outputPortHoverArea')
export const smallPlusButton = componentLocator('.SmallPlusButton')
export const createNodeFromPort = componentLocator('.CreateNodeFromPortButton .plusIcon')
export const editorRoot = componentLocator('.CodeMirror')
export const nodeComment = componentLocator('.GraphNodeComment')
export const nodeCommentContent = componentLocator('.GraphNodeComment div[contentEditable]')
Expand Down
8 changes: 4 additions & 4 deletions app/gui/integration-test/project-view/nodeComments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ test('Start editing comment via menu', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNodeByBinding(page, 'final')
await node.click()
await locate.circularMenu(node).getByRole('button', { name: 'More' }).click()
await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click()
await locate.componentMenu(node).getByRole('button', { name: 'More' }).click()
await locate.componentMenu(node).getByRole('button', { name: 'Comment' }).click()
await expect(locate.nodeCommentContent(node)).toBeFocused()
})

Expand Down Expand Up @@ -60,8 +60,8 @@ test('Add new comment via menu', async ({ page }) => {
const nodeComment = locate.nodeCommentContent(node)

await node.click()
await locate.circularMenu(node).getByRole('button', { name: 'More' }).click()
await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click()
await locate.componentMenu(node).getByRole('button', { name: 'More' }).click()
await locate.componentMenu(node).getByRole('button', { name: 'Comment' }).click()
await expect(locate.nodeCommentContent(node)).toBeFocused()
const NEW_COMMENT = 'New comment text'
await nodeComment.fill(NEW_COMMENT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ async function assertTypeLabelOnNode(
) {
// Ensure the visualization button won't be covered by any other parts of another node (e.g. a comment).
await bringNodeToFront(page, node)
await node.hover({ position: { x: 8, y: 8 } })
await locate.toggleVisualizationButton(node).click()
await node.hover({ position: { x: 8, y: 8 }, force: true })
await locate.toggleVisualizationButton(node).click({ force: true })
const targetLabel = node.locator('.node-type').first()
await expect(targetLabel).toHaveText(type.short)
await expect(targetLabel).toHaveAttribute('title', type.full)
await locate.toggleVisualizationButton(node).click()
await locate.toggleVisualizationButton(node).click({ force: true })
await actions.deselectNodes(page)
}

async function bringNodeToFront(page: Page, node: Locator) {
await node.click({ position: { x: 8, y: 8 } })
await node.click({ position: { x: 0, y: 8 }, force: true })
await page.keyboard.press('Escape')
}

Expand Down
2 changes: 1 addition & 1 deletion app/gui/src/project-view/components/ComponentBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ const handler = componentBrowserBindings.handler({
:nodeSize="inputSize"
:nodePosition="nodePosition"
:scale="1"
:isCircularMenuVisible="false"
:isComponentMenuVisible="false"
:isFullscreen="false"
:isFullscreenAllowed="false"
:isResizable="false"
Expand Down
4 changes: 2 additions & 2 deletions app/gui/src/project-view/components/ComponentMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const isDropdownOpened = ref(false)

<template>
<div
class="CircularMenu"
class="ComponentMenu"
:class="{
menu: !componentButtons.pickColor.state,
openedDropdown: isDropdownOpened,
Expand Down Expand Up @@ -58,7 +58,7 @@ const isDropdownOpened = ref(false)
</template>

<style scoped>
.CircularMenu {
.ComponentMenu {
position: absolute;
left: -36px;
bottom: -36px;
Expand Down
15 changes: 9 additions & 6 deletions app/gui/src/project-view/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/co
import { groupColorVar } from '@/composables/nodeColors'
import type { PlacementStrategy } from '@/composables/nodeCreation'
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
import { provideGraphEditorState } from '@/providers/graphEditorState'
import type { GraphNavigator } from '@/providers/graphNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideNodeColors } from '@/providers/graphNodeColors'
Expand Down Expand Up @@ -286,7 +287,7 @@ const graphBindingsHandler = graphBindings.handler({
projectStore.lsRpcConnection.profilingStop()
},
openComponentBrowser() {
if (graphNavigator.sceneMousePos != null && !componentBrowserVisible.value) {
if (graphNavigator.sceneMousePos != null && !componentBrowserOpened.value) {
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } })
}
},
Expand Down Expand Up @@ -390,23 +391,25 @@ const documentationEditorHandler = documentationEditorBindings.handler({
// === Component Browser ===
const componentBrowserVisible = ref(false)
const { componentBrowserOpened } = provideGraphEditorState({
componentBrowserOpened: ref(false),
})
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
watch(componentBrowserVisible, (v) =>
watch(componentBrowserOpened, (v) =>
rightDock.setStorageMode(v ? StorageMode.ComponentBrowser : StorageMode.Default),
)
function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserUsage.value = usage
componentBrowserNodePosition.value = position
componentBrowserVisible.value = true
componentBrowserOpened.value = true
}
function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false
componentBrowserOpened.value = false
displayedDocs.value = undefined
}
Expand Down Expand Up @@ -639,7 +642,7 @@ const groupColors = computed(() => {
/>
<GraphEdges :navigator="graphNavigator" @createNodeFromEdge="handleEdgeDrop" />
<ComponentBrowser
v-if="componentBrowserVisible"
v-if="componentBrowserOpened"
ref="componentBrowser"
:navigator="graphNavigator"
:nodePosition="componentBrowserNodePosition"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue'
import { AstId } from 'ydoc-shared/ast'
const props = defineProps<{ portId: AstId }>()
const emit = defineEmits<{ click: [] }>()
const hovered = ref(false)
</script>

<template>
<g :class="{ CreateNodeFromPortButton: true, hovered }" @click="emit('click')">
<rect :class="{ connection: true }" fill="currentColor"></rect>
<g :class="{ plusIcon: true }">
<mask :id="`${props.portId}_add_node_clip_path`">
<rect class="maskBackground"></rect>
<rect class="plusV"></rect>
<rect class="plusH"></rect>
</mask>
<circle :mask="`url(#${props.portId}_add_node_clip_path)`" fill="currentColor"></circle>
</g>
<rect class="hoverArea" @pointerenter="hovered = true" @pointerleave="hovered = false"></rect>
</g>
</template>

<style scoped>
.CreateNodeFromPortButton {
--radius: 6px;
--maskSize: calc(var(--radius) * 2);
--strokeWidth: 1.5px;
--leftOffset: 16px;
--topOffset: 40px;
--color-dimmed: color-mix(in oklab, var(--color-node-primary) 60%, white 40%);
--color: var(--color-node-primary);
}
.connection {
--width: 4px;
--direct-hover-offset: calc(
var(--output-port-hovered-extra-width) * var(--direct-hover-animation)
);
width: var(--width);
height: calc((var(--topOffset) - var(--direct-hover-offset) + 2px) * var(--hover-animation));
transform: translate(
calc(var(--port-clip-start) * (100% + 1px) + var(--leftOffset) - var(--width) / 2),
calc(var(--node-size-y) + var(--direct-hover-offset) - var(--output-port-overlap))
);
cursor: pointer;
color: var(--color-dimmed);
transition: color 0.2s ease;
}
.hovered * {
color: var(--color);
}
.plusIcon {
transform: translate(
calc(var(--port-clip-start) * (100% + 1px) + var(--leftOffset) - var(--radius)),
calc(
var(--node-size-y) + var(--output-port-max-width) + var(--node-vertical-gap) +
var(--topOffset)
)
);
color: var(--color-dimmed);
cursor: pointer;
& .maskBackground {
fill: white;
width: var(--maskSize);
height: var(--maskSize);
}
& .plusV {
x: calc(var(--maskSize) / 2 - var(--strokeWidth) / 2);
y: calc(var(--radius) / 2);
width: var(--strokeWidth);
height: var(--radius);
fill: black;
}
& .plusH {
x: calc(var(--radius) / 2);
y: calc(var(--maskSize) / 2 - var(--strokeWidth) / 2);
width: var(--radius);
height: var(--strokeWidth);
fill: black;
}
& circle {
cx: var(--radius);
cy: var(--radius);
r: calc(var(--radius) * var(--hover-animation));
transition: color 0.2s ease;
}
}
.hoverArea {
--margin: 4px;
--width: calc(var(--radius) * 2 + var(--margin) * 2);
fill: transparent;
width: var(--width);
height: calc(
var(--node-vertical-gap) + var(--output-port-max-width) + var(--margin) * 2 + var(--topOffset) +
var(--radius)
);
transform: translate(
calc(var(--port-clip-start) * (100% + 1px) + var(--leftOffset) - var(--width) / 2),
calc(var(--node-size-y) + var(--output-port-max-width))
);
cursor: pointer;
}
</style>
13 changes: 5 additions & 8 deletions app/gui/src/project-view/components/GraphEditor/GraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { usePointer, useResizeObserver } from '@/composables/events'
Expand Down Expand Up @@ -500,7 +499,7 @@ const showMenuAt = ref<{ x: number; y: number }>()
:nodeSize="nodeSize"
:scale="navigator?.scale ?? 1"
:nodePosition="nodePosition"
:isCircularMenuVisible="menuVisible"
:isComponentMenuVisible="menuVisible"
:currentType="props.node.vis?.identifier"
:dataSource="dataSource"
:typename="expressionInfo?.typename"
Expand Down Expand Up @@ -562,17 +561,15 @@ const showMenuAt = ref<{ x: number; y: number }>()
v-if="props.node.type !== 'output'"
:nodeId="nodeId"
:forceVisible="nodeHovered"
@newNodeClick="
setSoleSelected(), emit('createNodes', [{ commit: false, content: undefined }])
"
@portClick="(...args) => emit('outputPortClick', ...args)"
@portDoubleClick="(...args) => emit('outputPortDoubleClick', ...args)"
@update:hoverAnim="emit('update:hoverAnim', $event)"
@update:nodeHovered="outputHovered = $event"
/>
</svg>
<SmallPlusButton
v-if="menuVisible"
:class="isVisualizationVisible ? 'afterNode' : 'belowMenu'"
@createNodes="setSoleSelected(), emit('createNodes', $event)"
/>
</div>
<PointFloatingMenu v-if="showMenuAt" :point="showMenuAt" @close="showMenuAt = undefined">
<ComponentContextMenu @close="showMenuAt = undefined" />
Expand Down Expand Up @@ -641,7 +638,7 @@ const showMenuAt = ref<{ x: number; y: number }>()
opacity: 1;
}
.CircularMenu {
.ComponentMenu {
z-index: 25;
&.partial {
z-index: 1;
Expand Down
Loading

0 comments on commit 7a92d1c

Please sign in to comment.