mirror of
https://github.com/enso-org/enso.git
synced 2024-12-20 08:31:50 +03:00
Context menu, copy button, multi-component actions (#11690)
Context menu, copy button, multi-component actions https://github.com/user-attachments/assets/14243102-3848-43fc-82bb-a48648536985 - The 'More' menu can now be opened under the mouse, through the context menu action (right click/control-click on Mac/menu button on keyboard). - Add copy-components button to menu. - The menu can now be opened while multiple components are selected; if the clicked component was among the selected components, the selection will be preserved. Some menu actions--currently *copy* and *delete*, apply to all selected components. These actions will change their displayed labels when multiple components are selected. If a single-component action is executed, the component it was applied to will become the sole selection. Fixes #11633, #11634
This commit is contained in:
parent
52feef89ab
commit
0b6b1f0954
@ -36,9 +36,11 @@
|
|||||||
suitable type][11612].
|
suitable type][11612].
|
||||||
- [Visualizations on components are slightly transparent when not
|
- [Visualizations on components are slightly transparent when not
|
||||||
focused][11582].
|
focused][11582].
|
||||||
|
- [New design for vector-editing widget][11620]
|
||||||
|
- [The component menu can be opened by right-click; supports operations on
|
||||||
|
multiple components; has a 'Copy Component' button][11690]
|
||||||
- [New design for vector-editing widget][11620].
|
- [New design for vector-editing widget][11620].
|
||||||
- [Default values on widgets are displayed in italic][11666].
|
- [Default values on widgets are displayed in italic][11666].
|
||||||
- [The `:` type operator can now be chained][11671].
|
|
||||||
- [Fixed bug causing Table Visualization to show wrong data][11684].
|
- [Fixed bug causing Table Visualization to show wrong data][11684].
|
||||||
|
|
||||||
[11151]: https://github.com/enso-org/enso/pull/11151
|
[11151]: https://github.com/enso-org/enso/pull/11151
|
||||||
@ -64,7 +66,7 @@
|
|||||||
[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
|
[11620]: https://github.com/enso-org/enso/pull/11620
|
||||||
[11666]: https://github.com/enso-org/enso/pull/11666
|
[11666]: https://github.com/enso-org/enso/pull/11666
|
||||||
[11671]: https://github.com/enso-org/enso/pull/11671
|
[11690]: https://github.com/enso-org/enso/pull/11690
|
||||||
[11684]: https://github.com/enso-org/enso/pull/11684
|
[11684]: https://github.com/enso-org/enso/pull/11684
|
||||||
|
|
||||||
#### Enso Standard Library
|
#### Enso Standard Library
|
||||||
@ -90,8 +92,10 @@
|
|||||||
#### Enso Language & Runtime
|
#### Enso Language & Runtime
|
||||||
|
|
||||||
- [Arguments in constructor definitions may now be on their own lines][11374]
|
- [Arguments in constructor definitions may now be on their own lines][11374]
|
||||||
|
- [The `:` type operator can now be chained][11671].
|
||||||
|
|
||||||
[11374]: https://github.com/enso-org/enso/pull/11374
|
[11374]: https://github.com/enso-org/enso/pull/11374
|
||||||
|
[11671]: https://github.com/enso-org/enso/pull/11671
|
||||||
|
|
||||||
# Enso 2024.4
|
# Enso 2024.4
|
||||||
|
|
||||||
|
@ -57,6 +57,11 @@ export function map<T, U>(it: Iterable<T>, f: (value: T) => U): IterableIterator
|
|||||||
return mapIterator(it[Symbol.iterator](), f)
|
return mapIterator(it[Symbol.iterator](), f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filter<T, S extends T>(
|
||||||
|
iter: Iterable<T>,
|
||||||
|
include: (value: T) => value is S,
|
||||||
|
): IterableIterator<S>
|
||||||
|
export function filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T>
|
||||||
/**
|
/**
|
||||||
* Return an {@link Iterable} that `yield`s only the values from the given source iterable
|
* Return an {@link Iterable} that `yield`s only the values from the given source iterable
|
||||||
* that pass the given predicate.
|
* that pass the given predicate.
|
||||||
@ -179,6 +184,15 @@ export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the predicate returned `true` for any values yielded by the provided iterator. Short-circuiting.
|
||||||
|
* Returns `false` if the iterator doesn't yield any values.
|
||||||
|
*/
|
||||||
|
export function some<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
||||||
|
for (const value of iter) if (f(value)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** Return the first element returned by the iterable which meets the condition. */
|
/** Return the first element returned by the iterable which meets the condition. */
|
||||||
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
|
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
|
||||||
for (const value of iter) {
|
for (const value of iter) {
|
||||||
|
@ -197,3 +197,9 @@ export function useObjectId() {
|
|||||||
}
|
}
|
||||||
return { objectId }
|
return { objectId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the union of `A` and `B`, with a type-level assertion that `A` and `B` don't have any keys in common; this
|
||||||
|
* can be used to splice together objects without the risk of collisions.
|
||||||
|
*/
|
||||||
|
export type DisjointKeysUnion<A, B> = keyof A & keyof B extends never ? A & B : never
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { test, type Page } from '@playwright/test'
|
import { test, type Page } from '@playwright/test'
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import { expect } from './customExpect'
|
import { expect } from './customExpect'
|
||||||
|
import { CONTROL_KEY } from './keyboard'
|
||||||
import * as locate from './locate'
|
import * as locate from './locate'
|
||||||
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'
|
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'
|
||||||
|
|
||||||
@ -97,8 +98,7 @@ test('Conditional ports: Enabled', async ({ page }) => {
|
|||||||
const node = graphNodeByBinding(page, 'filtered')
|
const node = graphNodeByBinding(page, 'filtered')
|
||||||
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
|
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
|
||||||
|
|
||||||
await page.keyboard.down('Meta')
|
await page.keyboard.down(CONTROL_KEY)
|
||||||
await page.keyboard.down('Control')
|
|
||||||
|
|
||||||
await expect(conditionalPort).toHaveClass(/enabled/)
|
await expect(conditionalPort).toHaveClass(/enabled/)
|
||||||
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
|
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
|
||||||
@ -109,6 +109,5 @@ test('Conditional ports: Enabled', async ({ page }) => {
|
|||||||
await conditionalPort.click({ force: true })
|
await conditionalPort.click({ force: true })
|
||||||
await expect(node.locator('.WidgetToken')).toHaveText(['final'])
|
await expect(node.locator('.WidgetToken')).toHaveText(['final'])
|
||||||
|
|
||||||
await page.keyboard.up('Meta')
|
await page.keyboard.up(CONTROL_KEY)
|
||||||
await page.keyboard.up('Control')
|
|
||||||
})
|
})
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import test from 'playwright/test'
|
import test, { type Locator, type Page } from 'playwright/test'
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import { expect } from './customExpect'
|
import { expect } from './customExpect'
|
||||||
import { CONTROL_KEY } from './keyboard'
|
import { CONTROL_KEY } from './keyboard'
|
||||||
@ -28,7 +28,23 @@ test.beforeEach(async ({ page }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Copy node with comment', async ({ page }) => {
|
test('Copy component with context menu', async ({ page }) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const originalNodes = await locate.graphNode(page).count()
|
||||||
|
const nodeToCopy = locate.graphNodeByBinding(page, 'final')
|
||||||
|
await nodeToCopy.click({ button: 'right' })
|
||||||
|
await expect(nodeToCopy).toBeSelected()
|
||||||
|
await page
|
||||||
|
.locator('.ComponentContextMenu')
|
||||||
|
.getByRole('button', { name: 'Copy Component' })
|
||||||
|
.click()
|
||||||
|
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||||
|
await expect(nodeToCopy).not.toBeSelected()
|
||||||
|
await expect(locate.selectedNodes(page)).toHaveCount(1)
|
||||||
|
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Copy component with comment', async ({ page }) => {
|
||||||
await actions.goToGraph(page)
|
await actions.goToGraph(page)
|
||||||
|
|
||||||
// Check state before operation.
|
// Check state before operation.
|
||||||
@ -51,7 +67,10 @@ test('Copy node with comment', async ({ page }) => {
|
|||||||
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
|
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Copy multiple nodes', async ({ page }) => {
|
async function testCopyMultiple(
|
||||||
|
page: Page,
|
||||||
|
copyNodes: (node1: Locator, node2: Locator) => Promise<void>,
|
||||||
|
) {
|
||||||
await actions.goToGraph(page)
|
await actions.goToGraph(page)
|
||||||
|
|
||||||
// Check state before operation.
|
// Check state before operation.
|
||||||
@ -61,13 +80,10 @@ test('Copy multiple nodes', async ({ page }) => {
|
|||||||
|
|
||||||
// Select some nodes.
|
// Select some nodes.
|
||||||
const node1 = locate.graphNodeByBinding(page, 'final')
|
const node1 = locate.graphNodeByBinding(page, 'final')
|
||||||
await node1.click()
|
|
||||||
const node2 = locate.graphNodeByBinding(page, 'prod')
|
const node2 = locate.graphNodeByBinding(page, 'prod')
|
||||||
await node2.click({ modifiers: ['Shift'] })
|
|
||||||
await expect(node1).toBeSelected()
|
|
||||||
await expect(node2).toBeSelected()
|
|
||||||
// Copy and paste.
|
// Copy and paste.
|
||||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
await copyNodes(node1, node2)
|
||||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||||
await expect(node1).not.toBeSelected()
|
await expect(node1).not.toBeSelected()
|
||||||
await expect(node2).not.toBeSelected()
|
await expect(node2).not.toBeSelected()
|
||||||
@ -87,4 +103,28 @@ test('Copy multiple nodes', async ({ page }) => {
|
|||||||
await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(1 * EDGE_PARTS)
|
await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(1 * EDGE_PARTS)
|
||||||
await expect(await edgesToNodeWithBinding(page, 'prod1')).toHaveCount(1 * EDGE_PARTS)
|
await expect(await edgesToNodeWithBinding(page, 'prod1')).toHaveCount(1 * EDGE_PARTS)
|
||||||
await expect(await edgesToNodeWithBinding(page, 'final1')).toHaveCount(1 * EDGE_PARTS)
|
await expect(await edgesToNodeWithBinding(page, 'final1')).toHaveCount(1 * EDGE_PARTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Copy multiple components with keyboard shortcut', async ({ page }) => {
|
||||||
|
await testCopyMultiple(page, async (node1, node2) => {
|
||||||
|
await node1.click()
|
||||||
|
await node2.click({ modifiers: ['Shift'] })
|
||||||
|
await expect(node1).toBeSelected()
|
||||||
|
await expect(node2).toBeSelected()
|
||||||
|
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Copy multiple components with context menu', async ({ page }) => {
|
||||||
|
await testCopyMultiple(page, async (node1, node2) => {
|
||||||
|
await node1.click()
|
||||||
|
await node2.click({ modifiers: ['Shift'] })
|
||||||
|
await expect(node1).toBeSelected()
|
||||||
|
await expect(node2).toBeSelected()
|
||||||
|
await node1.click({ button: 'right' })
|
||||||
|
await page
|
||||||
|
.locator('.ComponentContextMenu')
|
||||||
|
.getByRole('button', { name: 'Copy Selected Components' })
|
||||||
|
.click()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -27,6 +27,31 @@ test('Start editing comment via menu', async ({ page }) => {
|
|||||||
await expect(locate.nodeComment(node)).toBeFocused()
|
await expect(locate.nodeComment(node)).toBeFocused()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Start editing comment via context menu', async ({ page }) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const node = locate.graphNodeByBinding(page, 'final')
|
||||||
|
await node.click({ button: 'right' })
|
||||||
|
await page.getByRole('button', { name: 'Add Comment' }).click()
|
||||||
|
await expect(locate.nodeComment(node)).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Start editing comment via context menu when multiple components initially selected', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const otherNode = locate.graphNodeByBinding(page, 'sum')
|
||||||
|
await otherNode.click()
|
||||||
|
const node = locate.graphNodeByBinding(page, 'final')
|
||||||
|
await node.click({ modifiers: ['Shift'] })
|
||||||
|
const anotherNode = locate.graphNodeByBinding(page, 'list')
|
||||||
|
await anotherNode.click({ modifiers: ['Shift'] })
|
||||||
|
await node.click({ button: 'right' })
|
||||||
|
await expect(locate.selectedNodes(page)).toHaveCount(3)
|
||||||
|
await page.getByRole('button', { name: 'Add Comment' }).click()
|
||||||
|
await expect(locate.selectedNodes(page)).toHaveCount(1)
|
||||||
|
await expect(locate.nodeComment(node)).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
test('Add new comment via menu', async ({ page }) => {
|
test('Add new comment via menu', async ({ page }) => {
|
||||||
await actions.goToGraph(page)
|
await actions.goToGraph(page)
|
||||||
const INITIAL_NODE_COMMENTS = 1
|
const INITIAL_NODE_COMMENTS = 1
|
||||||
|
@ -24,6 +24,31 @@ test('Deleting selected node with delete key', async ({ page }) => {
|
|||||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
|
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Deleting node with context menu', async ({ page }) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const nodesCount = await locate.graphNode(page).count()
|
||||||
|
const deletedNode = locate.graphNodeByBinding(page, 'final')
|
||||||
|
await deletedNode.click({ button: 'right' })
|
||||||
|
await expect(locate.selectedNodes(page)).toHaveCount(1)
|
||||||
|
await page.getByRole('button', { name: 'Delete Component' }).click()
|
||||||
|
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Deleting multiple nodes with context menu', async ({ page }) => {
|
||||||
|
await actions.goToGraph(page)
|
||||||
|
const nodesCount = await locate.graphNode(page).count()
|
||||||
|
const deletedNode1 = locate.graphNodeByBinding(page, 'final')
|
||||||
|
await deletedNode1.click()
|
||||||
|
const deletedNode2 = locate.graphNodeByBinding(page, 'sum')
|
||||||
|
await deletedNode2.click({ modifiers: ['Shift'] })
|
||||||
|
await deletedNode2.click({ button: 'right' })
|
||||||
|
await page
|
||||||
|
.locator('.ComponentContextMenu')
|
||||||
|
.getByRole('button', { name: 'Delete Selected Components' })
|
||||||
|
.click()
|
||||||
|
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 2)
|
||||||
|
})
|
||||||
|
|
||||||
test('Graph can be empty', async ({ page }) => {
|
test('Graph can be empty', async ({ page }) => {
|
||||||
await actions.goToGraph(page)
|
await actions.goToGraph(page)
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ const FIXED_RANGE_WIDTH = 1 / 16
|
|||||||
|
|
||||||
const selectedColor = defineModel<string | undefined>()
|
const selectedColor = defineModel<string | undefined>()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
matchableColors: Set<string>
|
matchableColors: ReadonlySet<string>
|
||||||
/** Angle, measured in degrees from the positive Y-axis, where the initially-selected color should be placed. */
|
/** Angle, measured in degrees from the positive Y-axis, where the initially-selected color should be placed. */
|
||||||
initialColorAngle?: number
|
initialColorAngle?: number
|
||||||
}>()
|
}>()
|
||||||
|
49
app/gui/src/project-view/components/ComponentButton.vue
Normal file
49
app/gui/src/project-view/components/ComponentButton.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import {
|
||||||
|
injectComponentAndSelectionButtons,
|
||||||
|
type ComponentAndSelectionButtons,
|
||||||
|
} from '@/providers/selectionButtons'
|
||||||
|
|
||||||
|
const { button: buttonName } = defineProps<{ button: keyof ComponentAndSelectionButtons }>()
|
||||||
|
|
||||||
|
const { buttons } = injectComponentAndSelectionButtons()
|
||||||
|
const button = buttons[buttonName]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MenuButton
|
||||||
|
:data-testid="button.testid"
|
||||||
|
:disabled="button.disabled"
|
||||||
|
class="ComponentButton"
|
||||||
|
v-bind="button.state != null ? { modelValue: button.state } : {}"
|
||||||
|
@update:modelValue="button.state != null && (button.state = $event)"
|
||||||
|
@click="button.action"
|
||||||
|
>
|
||||||
|
<SvgIcon :name="button.icon" class="rowIcon" />
|
||||||
|
<span v-text="button.description" />
|
||||||
|
<span v-if="button.shortcut" class="shortcutHint" v-text="button.shortcut" />
|
||||||
|
</MenuButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ComponentButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: left;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowIcon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutHint {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 2em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
41
app/gui/src/project-view/components/ComponentContextMenu.vue
Normal file
41
app/gui/src/project-view/components/ComponentContextMenu.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComponentButton from '@/components/ComponentButton.vue'
|
||||||
|
import MenuPanel from '@/components/MenuPanel.vue'
|
||||||
|
import { type ComponentButtons } from '@/providers/componentButtons'
|
||||||
|
import { type SelectionButtons } from '@/providers/selectionButtons'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const componentButtons: (keyof ComponentButtons)[] = [
|
||||||
|
'toggleDocPanel',
|
||||||
|
'toggleVisualization',
|
||||||
|
'createNewNode',
|
||||||
|
'editingComment',
|
||||||
|
'recompute',
|
||||||
|
'pickColor',
|
||||||
|
'enterNode',
|
||||||
|
'startEditing',
|
||||||
|
]
|
||||||
|
const selectionButtons: (keyof SelectionButtons)[] = ['copy', 'deleteSelected']
|
||||||
|
const buttons = [...componentButtons, ...selectionButtons]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MenuPanel class="ComponentContextMenu" @contextmenu.stop.prevent="emit('close')">
|
||||||
|
<ComponentButton
|
||||||
|
v-for="button in buttons"
|
||||||
|
:key="button"
|
||||||
|
:button="button"
|
||||||
|
@click.stop="emit('close')"
|
||||||
|
/>
|
||||||
|
</MenuPanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.MenuPanel {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--dropdown-opened-background, var(--color-app-bg));
|
||||||
|
backdrop-filter: var(--dropdown-opened-backdrop-filter, var(--blur-app-bg));
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,67 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { graphBindings, nodeEditBindings } from '@/bindings'
|
|
||||||
import ColorRing from '@/components/ColorRing.vue'
|
import ColorRing from '@/components/ColorRing.vue'
|
||||||
|
import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import { injectComponentButtons } from '@/providers/componentButtons'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const nodeColor = defineModel<string | undefined>('nodeColor')
|
const componentButtons = injectComponentButtons()
|
||||||
const isVisualizationEnabled = defineModel<boolean>('isVisualizationEnabled', { required: true })
|
|
||||||
const props = defineProps<{
|
|
||||||
isRecordingEnabledGlobally: boolean
|
|
||||||
isRemovable: boolean
|
|
||||||
isEnterable: boolean
|
|
||||||
matchableNodeColors: Set<string>
|
|
||||||
documentationUrl: string | undefined
|
|
||||||
isBeingRecomputed: boolean
|
|
||||||
}>()
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:isVisualizationEnabled': [isVisualizationEnabled: boolean]
|
|
||||||
enterNode: []
|
|
||||||
startEditing: []
|
|
||||||
startEditingComment: []
|
|
||||||
openFullMenu: []
|
|
||||||
delete: []
|
|
||||||
createNewNode: []
|
|
||||||
toggleDocPanel: []
|
|
||||||
recompute: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isDropdownOpened = ref(false)
|
const isDropdownOpened = ref(false)
|
||||||
const showColorPicker = ref(false)
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpened.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
type BindingSpace<T extends string> = { bindings: Record<T, { humanReadable: string }> }
|
|
||||||
type Binding<T extends string> = keyof BindingSpace<T>['bindings']
|
|
||||||
function readableBinding<T extends string, BS extends BindingSpace<T>>(
|
|
||||||
binding: Binding<T>,
|
|
||||||
bindingSpace: BS,
|
|
||||||
) {
|
|
||||||
return bindingSpace.bindings[binding].humanReadable
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="CircularMenu"
|
class="CircularMenu"
|
||||||
:class="{
|
:class="{
|
||||||
menu: !showColorPicker,
|
menu: !componentButtons.pickColor.state,
|
||||||
openedDropdown: isDropdownOpened,
|
openedDropdown: isDropdownOpened,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="!showColorPicker">
|
<template v-if="!componentButtons.pickColor.state">
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="eye"
|
name="eye"
|
||||||
class="slotS"
|
class="slotS"
|
||||||
title="Visualization"
|
title="Visualization"
|
||||||
@click.stop="isVisualizationEnabled = !isVisualizationEnabled"
|
@click.stop="
|
||||||
|
componentButtons.toggleVisualization.state = !componentButtons.toggleVisualization.state
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SvgButton
|
||||||
|
name="help"
|
||||||
|
class="slotSW"
|
||||||
|
title="Help"
|
||||||
|
@click.stop="componentButtons.toggleDocPanel.action"
|
||||||
/>
|
/>
|
||||||
<SvgButton name="help" class="slotSW" title="Help" @click.stop="emit('toggleDocPanel')" />
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-model:open="isDropdownOpened"
|
v-model:open="isDropdownOpened"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
@ -70,84 +42,17 @@ function readableBinding<T extends string, BS extends BindingSpace<T>>(
|
|||||||
class="slotW More"
|
class="slotW More"
|
||||||
>
|
>
|
||||||
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
||||||
<template #entries>
|
<template #menu>
|
||||||
<MenuButton @click.stop="closeDropdown(), emit('toggleDocPanel')">
|
<ComponentContextMenu @close="isDropdownOpened = false" />
|
||||||
<SvgIcon name="help" class="rowIcon" />
|
|
||||||
<span>Help</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton
|
|
||||||
:modelValue="isVisualizationEnabled"
|
|
||||||
@update:modelValue="emit('update:isVisualizationEnabled', $event)"
|
|
||||||
@click.stop="closeDropdown"
|
|
||||||
>
|
|
||||||
<SvgIcon name="eye" class="rowIcon" />
|
|
||||||
<span v-text="`${isVisualizationEnabled ? 'Hide' : 'Show'} Visualization`"></span>
|
|
||||||
<span
|
|
||||||
class="shortcutHint"
|
|
||||||
v-text="`${readableBinding('toggleVisualization', graphBindings)}`"
|
|
||||||
></span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton @click.stop="closeDropdown(), emit('createNewNode')">
|
|
||||||
<SvgIcon name="add" class="rowIcon" />
|
|
||||||
<span>Add New Component</span>
|
|
||||||
<span
|
|
||||||
class="shortcutHint"
|
|
||||||
v-text="`${readableBinding('openComponentBrowser', graphBindings)}`"
|
|
||||||
></span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton @click.stop="closeDropdown(), emit('startEditingComment')">
|
|
||||||
<SvgIcon name="comment" class="rowIcon" />
|
|
||||||
<span>Add Comment</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton
|
|
||||||
data-testid="recompute"
|
|
||||||
:disabled="props.isBeingRecomputed"
|
|
||||||
@click.stop="closeDropdown(), emit('recompute')"
|
|
||||||
>
|
|
||||||
<SvgIcon name="workflow_play" class="rowIcon" />
|
|
||||||
<span>Write</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton @click.stop="closeDropdown(), (showColorPicker = true)">
|
|
||||||
<SvgIcon name="paint_palette" class="rowIcon" />
|
|
||||||
<span>Color Component</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton
|
|
||||||
v-if="isEnterable"
|
|
||||||
data-testid="enter-node-button"
|
|
||||||
@click.stop="closeDropdown(), emit('enterNode')"
|
|
||||||
>
|
|
||||||
<SvgIcon name="open" class="rowIcon" />
|
|
||||||
<span>Open Grouped Components</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton data-testid="edit-button" @click.stop="closeDropdown(), emit('startEditing')">
|
|
||||||
<SvgIcon name="edit" class="rowIcon" />
|
|
||||||
<span>Code Edit</span>
|
|
||||||
<span
|
|
||||||
class="shortcutHint"
|
|
||||||
v-text="`${readableBinding('edit', nodeEditBindings)}`"
|
|
||||||
></span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuButton
|
|
||||||
data-testid="removeNode"
|
|
||||||
:disabled="!isRemovable"
|
|
||||||
@click.stop="closeDropdown(), emit('delete')"
|
|
||||||
>
|
|
||||||
<SvgIcon name="trash2" class="rowIcon" />
|
|
||||||
<span>Remove Component</span>
|
|
||||||
<span
|
|
||||||
class="shortcutHint"
|
|
||||||
v-text="`${readableBinding('deleteSelected', graphBindings)}`"
|
|
||||||
></span>
|
|
||||||
</MenuButton>
|
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
<ColorRing
|
<ColorRing
|
||||||
v-else
|
v-else
|
||||||
v-model="nodeColor"
|
v-model="componentButtons.pickColor.actionData.currentColor"
|
||||||
:matchableColors="matchableNodeColors"
|
:matchableColors="componentButtons.pickColor.actionData.matchableColors"
|
||||||
:initialColorAngle="90"
|
:initialColorAngle="90"
|
||||||
@close="showColorPicker = false"
|
@close="componentButtons.pickColor.state = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -171,32 +76,6 @@ function readableBinding<T extends string, BS extends BindingSpace<T>>(
|
|||||||
--dropdown-opened-backdrop-filter: none;
|
--dropdown-opened-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.DropdownMenuContent) {
|
|
||||||
width: 210px;
|
|
||||||
margin-top: 2px;
|
|
||||||
padding: 4px;
|
|
||||||
background: var(--dropdown-opened-background);
|
|
||||||
backdrop-filter: var(--dropdown-opened-backdrop-filter);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: left;
|
|
||||||
padding-left: 8px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowIcon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutHint {
|
|
||||||
margin-left: auto;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
> * {
|
> * {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
@ -2,10 +2,11 @@
|
|||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
import SizeTransition from '@/components/SizeTransition.vue'
|
import SizeTransition from '@/components/SizeTransition.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import { useUnrefHTMLElement } from '@/composables/events'
|
||||||
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||||
import { endOnClickOutside } from '@/util/autoBlur'
|
import { endOnClickOutside } from '@/util/autoBlur'
|
||||||
import { shift, useFloating, type Placement } from '@floating-ui/vue'
|
import { shift, useFloating, type Placement } from '@floating-ui/vue'
|
||||||
import { ref, shallowRef } from 'vue'
|
import { ref, shallowRef, useTemplateRef, type VueElement } from 'vue'
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false })
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -15,7 +16,8 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const rootElement = shallowRef<HTMLElement>()
|
const rootElement = shallowRef<HTMLElement>()
|
||||||
const floatElement = shallowRef<HTMLElement>()
|
const menuPanel = useTemplateRef<Element | VueElement | undefined | null>('menuPanel')
|
||||||
|
const floatElement = useUnrefHTMLElement(menuPanel)
|
||||||
const hovered = ref(false)
|
const hovered = ref(false)
|
||||||
|
|
||||||
injectInteractionHandler().setWhen(
|
injectInteractionHandler().setWhen(
|
||||||
@ -49,9 +51,7 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
|||||||
class="arrow"
|
class="arrow"
|
||||||
/>
|
/>
|
||||||
<SizeTransition height :duration="100">
|
<SizeTransition height :duration="100">
|
||||||
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
|
<slot v-if="open" ref="menuPanel" name="menu" :style="floatingStyles" />
|
||||||
<slot name="entries" />
|
|
||||||
</div>
|
|
||||||
</SizeTransition>
|
</SizeTransition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -63,18 +63,6 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
|||||||
margin: -4px;
|
margin: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.DropdownMenuContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 13px;
|
|
||||||
background: var(--color-frame-bg);
|
|
||||||
backdrop-filter: var(--blur-app-bg);
|
|
||||||
margin: 0 -4px;
|
|
||||||
z-index: 1;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { codeEditorBindings, documentationEditorBindings } from '@/bindings'
|
import { codeEditorBindings, documentationEditorBindings } from '@/bindings'
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
|
import MenuPanel from '@/components/MenuPanel.vue'
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@ -31,7 +32,8 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
|||||||
title="Additional Options"
|
title="Additional Options"
|
||||||
>
|
>
|
||||||
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
||||||
<template #entries>
|
<template #menu>
|
||||||
|
<MenuPanel>
|
||||||
<div>
|
<div>
|
||||||
<div class="nonInteractive"><SvgIcon name="zoom" class="rowIcon" />Zoom</div>
|
<div class="nonInteractive"><SvgIcon name="zoom" class="rowIcon" />Zoom</div>
|
||||||
<div class="zoomControl rightSide">
|
<div class="zoomControl rightSide">
|
||||||
@ -45,7 +47,12 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
|||||||
class="zoomScaleLabel"
|
class="zoomScaleLabel"
|
||||||
v-text="props.zoomLevel ? props.zoomLevel.toFixed(0) + '%' : '?'"
|
v-text="props.zoomLevel ? props.zoomLevel.toFixed(0) + '%' : '?'"
|
||||||
></span>
|
></span>
|
||||||
<SvgButton class="zoomButton" name="add" title="Increase Zoom" @click="emit('zoomIn')" />
|
<SvgButton
|
||||||
|
class="zoomButton"
|
||||||
|
name="add"
|
||||||
|
title="Increase Zoom"
|
||||||
|
@click="emit('zoomIn')"
|
||||||
|
/>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="show_all"
|
name="show_all"
|
||||||
@ -65,6 +72,7 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
|||||||
Documentation Editor
|
Documentation Editor
|
||||||
<div class="rightSide" v-text="toggleDocumentationEditorShortcut" />
|
<div class="rightSide" v-text="toggleDocumentationEditorShortcut" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
</MenuPanel>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
@ -80,7 +88,7 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
|||||||
margin: 4px;
|
margin: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.DropdownMenuContent) {
|
.MenuPanel {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
@ -32,7 +32,7 @@ import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/co
|
|||||||
import { groupColorVar } from '@/composables/nodeColors'
|
import { groupColorVar } from '@/composables/nodeColors'
|
||||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||||
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
||||||
import { provideFullscreenContext } from '@/providers/fullscreenContext'
|
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
|
||||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { provideNodeColors } from '@/providers/graphNodeColors'
|
import { provideNodeColors } from '@/providers/graphNodeColors'
|
||||||
@ -41,10 +41,12 @@ import { provideGraphSelection } from '@/providers/graphSelection'
|
|||||||
import { provideStackNavigator } from '@/providers/graphStackNavigator'
|
import { provideStackNavigator } from '@/providers/graphStackNavigator'
|
||||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||||
import { provideKeyboard } from '@/providers/keyboard'
|
import { provideKeyboard } from '@/providers/keyboard'
|
||||||
|
import { provideSelectionButtons } from '@/providers/selectionButtons'
|
||||||
import { injectVisibility } from '@/providers/visibility'
|
import { injectVisibility } from '@/providers/visibility'
|
||||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||||
import type { NodeId } from '@/stores/graph'
|
import type { Node, NodeId } from '@/stores/graph'
|
||||||
import { provideGraphStore } from '@/stores/graph'
|
import { provideGraphStore } from '@/stores/graph'
|
||||||
|
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
|
||||||
import type { RequiredImport } from '@/stores/graph/imports'
|
import type { RequiredImport } from '@/stores/graph/imports'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { provideNodeExecution } from '@/stores/project/nodeExecution'
|
import { provideNodeExecution } from '@/stores/project/nodeExecution'
|
||||||
@ -87,7 +89,6 @@ const graphStore = provideGraphStore(projectStore, suggestionDb)
|
|||||||
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||||
const _visualizationStore = provideVisualizationStore(projectStore)
|
const _visualizationStore = provideVisualizationStore(projectStore)
|
||||||
const visible = injectVisibility()
|
const visible = injectVisibility()
|
||||||
provideFullscreenContext(rootNode)
|
|
||||||
provideNodeExecution(projectStore)
|
provideNodeExecution(projectStore)
|
||||||
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
|
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
|
||||||
|
|
||||||
@ -109,6 +110,14 @@ const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keybo
|
|||||||
predicate: (e) => (e instanceof KeyboardEvent ? nodeSelection.selected.size === 0 : true),
|
predicate: (e) => (e instanceof KeyboardEvent ? nodeSelection.selected.size === 0 : true),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// === Exposed layers ===
|
||||||
|
|
||||||
|
const floatingLayer = ref<HTMLElement>()
|
||||||
|
provideGraphEditorLayers({
|
||||||
|
fullscreen: rootNode,
|
||||||
|
floating: floatingLayer,
|
||||||
|
})
|
||||||
|
|
||||||
// === Client saved state ===
|
// === Client saved state ===
|
||||||
|
|
||||||
const storedShowRightDock = ref()
|
const storedShowRightDock = ref()
|
||||||
@ -232,6 +241,20 @@ const nodeSelection = provideGraphSelection(
|
|||||||
{
|
{
|
||||||
isValid: (id) => graphStore.db.isNodeId(id),
|
isValid: (id) => graphStore.db.isNodeId(id),
|
||||||
onSelected: (id) => graphStore.db.moveNodeToTop(id),
|
onSelected: (id) => graphStore.db.moveNodeToTop(id),
|
||||||
|
toSorted: (ids) => {
|
||||||
|
const idsSet = new Set(ids)
|
||||||
|
const inputNodes = [
|
||||||
|
...iter.filter(
|
||||||
|
iter.filterDefined(
|
||||||
|
iter.map(idsSet, graphStore.db.nodeIdToNode.get.bind(graphStore.db.nodeIdToNode)),
|
||||||
|
),
|
||||||
|
isInputNode,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
inputNodes.sort((a, b) => a.argIndex - b.argIndex)
|
||||||
|
const nonInputNodeIds = graphStore.pickInCodeOrder(idsSet)
|
||||||
|
return iter.chain(inputNodes.map(nodeId), nonInputNodeIds)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -261,10 +284,23 @@ const { scheduleCreateNode, createNodes, placeNode } = provideNodeCreation(
|
|||||||
|
|
||||||
// === Clipboard Copy/Paste ===
|
// === Clipboard Copy/Paste ===
|
||||||
|
|
||||||
const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(
|
const { copyNodesToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(createNodes)
|
||||||
graphStore,
|
|
||||||
toRef(nodeSelection, 'selected'),
|
// === Selection Buttons ===
|
||||||
createNodes,
|
|
||||||
|
const { buttons: selectionButtons } = provideSelectionButtons(
|
||||||
|
() =>
|
||||||
|
iter.filterDefined(
|
||||||
|
iter.map(
|
||||||
|
nodeSelection.selected,
|
||||||
|
graphStore.db.nodeIdToNode.get.bind(graphStore.db.nodeIdToNode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
collapseNodes,
|
||||||
|
copyNodesToClipboard,
|
||||||
|
deleteNodes: (nodes) => graphStore.deleteNodes(nodes.map(nodeId)),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// === Interactions ===
|
// === Interactions ===
|
||||||
@ -317,7 +353,7 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } })
|
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteSelected,
|
deleteSelected: selectionButtons.deleteSelected.action!,
|
||||||
zoomToSelected() {
|
zoomToSelected() {
|
||||||
zoomToSelected()
|
zoomToSelected()
|
||||||
},
|
},
|
||||||
@ -341,15 +377,11 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
copyNode() {
|
copyNode: selectionButtons.copy.action!,
|
||||||
copySelectionToClipboard()
|
|
||||||
},
|
|
||||||
pasteNode() {
|
pasteNode() {
|
||||||
createNodesFromClipboard()
|
createNodesFromClipboard()
|
||||||
},
|
},
|
||||||
collapse() {
|
collapse: selectionButtons.collapse.action!,
|
||||||
collapseNodes()
|
|
||||||
},
|
|
||||||
enterNode() {
|
enterNode() {
|
||||||
const selectedNode = set.first(nodeSelection.selected)
|
const selectedNode = set.first(nodeSelection.selected)
|
||||||
if (selectedNode) {
|
if (selectedNode) {
|
||||||
@ -360,7 +392,7 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
stackNavigator.exitNode()
|
stackNavigator.exitNode()
|
||||||
},
|
},
|
||||||
changeColorSelectedNodes() {
|
changeColorSelectedNodes() {
|
||||||
showColorPicker.value = true
|
selectionButtons.pickColorMulti.state = true
|
||||||
},
|
},
|
||||||
openDocumentation() {
|
openDocumentation() {
|
||||||
const result = tryGetSelectionDocUrl()
|
const result = tryGetSelectionDocUrl()
|
||||||
@ -392,10 +424,6 @@ const { handleClick } = useDoubleClick(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function deleteSelected() {
|
|
||||||
graphStore.deleteNodes(nodeSelection.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Code Editor ===
|
// === Code Editor ===
|
||||||
|
|
||||||
const codeEditor = shallowRef<ComponentInstance<typeof CodeEditor>>()
|
const codeEditor = shallowRef<ComponentInstance<typeof CodeEditor>>()
|
||||||
@ -610,11 +638,11 @@ function handleEdgeDrop(source: Ast.AstId, position: Vec2) {
|
|||||||
|
|
||||||
// === Node Collapsing ===
|
// === Node Collapsing ===
|
||||||
|
|
||||||
function collapseNodes() {
|
function collapseNodes(nodes: Node[]) {
|
||||||
const selected = new Set(
|
const selected = new Set(
|
||||||
iter.filter(
|
iter.map(
|
||||||
nodeSelection.selected,
|
iter.filter(nodes, ({ type }) => type === 'component'),
|
||||||
(id) => graphStore.db.nodeIdToNode.get(id)?.type === 'component',
|
nodeId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (selected.size == 0) return
|
if (selected.size == 0) return
|
||||||
@ -698,8 +726,6 @@ provideNodeColors(graphStore, (variable) =>
|
|||||||
viewportNode.value ? getComputedStyle(viewportNode.value).getPropertyValue(variable) : '',
|
viewportNode.value ? getComputedStyle(viewportNode.value).getPropertyValue(variable) : '',
|
||||||
)
|
)
|
||||||
|
|
||||||
const showColorPicker = ref(false)
|
|
||||||
|
|
||||||
const groupColors = computed(() => {
|
const groupColors = computed(() => {
|
||||||
const styles: { [key: string]: string } = {}
|
const styles: { [key: string]: string } = {}
|
||||||
for (const group of suggestionDb.groups) {
|
for (const group of suggestionDb.groups) {
|
||||||
@ -747,23 +773,24 @@ const documentationEditorFullscreen = ref(false)
|
|||||||
</template>
|
</template>
|
||||||
<TopBar
|
<TopBar
|
||||||
v-model:recordMode="projectStore.recordMode"
|
v-model:recordMode="projectStore.recordMode"
|
||||||
v-model:showColorPicker="showColorPicker"
|
|
||||||
v-model:showCodeEditor="showCodeEditor"
|
v-model:showCodeEditor="showCodeEditor"
|
||||||
v-model:showDocumentationEditor="rightDockVisible"
|
v-model:showDocumentationEditor="rightDockVisible"
|
||||||
:zoomLevel="100.0 * graphNavigator.targetScale"
|
:zoomLevel="100.0 * graphNavigator.targetScale"
|
||||||
:componentsSelected="nodeSelection.selected.size"
|
|
||||||
:class="{ extraRightSpace: !rightDockVisible }"
|
:class="{ extraRightSpace: !rightDockVisible }"
|
||||||
@fitToAllClicked="zoomToSelected"
|
@fitToAllClicked="zoomToSelected"
|
||||||
@zoomIn="graphNavigator.stepZoom(+1)"
|
@zoomIn="graphNavigator.stepZoom(+1)"
|
||||||
@zoomOut="graphNavigator.stepZoom(-1)"
|
@zoomOut="graphNavigator.stepZoom(-1)"
|
||||||
@collapseNodes="collapseNodes"
|
|
||||||
@removeNodes="deleteSelected"
|
|
||||||
/>
|
/>
|
||||||
<SceneScroller
|
<SceneScroller
|
||||||
:navigator="graphNavigator"
|
:navigator="graphNavigator"
|
||||||
:scrollableArea="Rect.Bounding(...graphStore.visibleNodeAreas)"
|
:scrollableArea="Rect.Bounding(...graphStore.visibleNodeAreas)"
|
||||||
/>
|
/>
|
||||||
<GraphMouse />
|
<GraphMouse />
|
||||||
|
<div
|
||||||
|
ref="floatingLayer"
|
||||||
|
class="floatingLayer"
|
||||||
|
:style="{ transform: graphNavigator.transform }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BottomPanel v-model:show="showCodeEditor">
|
<BottomPanel v-model:show="showCodeEditor">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@ -837,4 +864,19 @@ const documentationEditorFullscreen = ref(false)
|
|||||||
--node-color-no-type: #596b81;
|
--node-color-no-type: #596b81;
|
||||||
--output-node-color: #006b8a;
|
--output-node-color: #006b8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floatingLayer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
/* The size isn't important, except it must be non-zero for `floating-ui` to calculate the scale factor. */
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
contain: layout size style;
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: none;
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nodeEditBindings } from '@/bindings'
|
import { graphBindings, nodeEditBindings } from '@/bindings'
|
||||||
|
import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
|
||||||
import ComponentMenu from '@/components/ComponentMenu.vue'
|
import ComponentMenu from '@/components/ComponentMenu.vue'
|
||||||
import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
|
import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
|
||||||
import GraphNodeMessage, {
|
import GraphNodeMessage, {
|
||||||
@ -16,10 +17,12 @@ import NodeWidgetTree, {
|
|||||||
GRAB_HANDLE_X_MARGIN_R,
|
GRAB_HANDLE_X_MARGIN_R,
|
||||||
ICON_WIDTH,
|
ICON_WIDTH,
|
||||||
} from '@/components/GraphEditor/NodeWidgetTree.vue'
|
} from '@/components/GraphEditor/NodeWidgetTree.vue'
|
||||||
|
import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
|
||||||
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { useDoubleClick } from '@/composables/doubleClick'
|
import { useDoubleClick } from '@/composables/doubleClick'
|
||||||
import { usePointer, useResizeObserver } from '@/composables/events'
|
import { usePointer, useResizeObserver } from '@/composables/events'
|
||||||
|
import { provideComponentButtons } from '@/providers/componentButtons'
|
||||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { injectNodeColors } from '@/providers/graphNodeColors'
|
import { injectNodeColors } from '@/providers/graphNodeColors'
|
||||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||||
@ -28,7 +31,6 @@ import { useGraphStore, type Node } from '@/stores/graph'
|
|||||||
import { asNodeId } from '@/stores/graph/graphDatabase'
|
import { asNodeId } from '@/stores/graph/graphDatabase'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { useNodeExecution } from '@/stores/project/nodeExecution'
|
import { useNodeExecution } from '@/stores/project/nodeExecution'
|
||||||
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
|
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import type { AstId } from '@/util/ast/abstract'
|
import type { AstId } from '@/util/ast/abstract'
|
||||||
import { prefixes } from '@/util/ast/node'
|
import { prefixes } from '@/util/ast/node'
|
||||||
@ -58,7 +60,6 @@ const emit = defineEmits<{
|
|||||||
dragging: [offset: Vec2]
|
dragging: [offset: Vec2]
|
||||||
draggingCommited: []
|
draggingCommited: []
|
||||||
draggingCancelled: []
|
draggingCancelled: []
|
||||||
delete: []
|
|
||||||
replaceSelection: []
|
replaceSelection: []
|
||||||
outputPortClick: [event: PointerEvent, portId: AstId]
|
outputPortClick: [event: PointerEvent, portId: AstId]
|
||||||
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
||||||
@ -216,17 +217,19 @@ watch(menuVisible, (visible) => {
|
|||||||
if (!visible) menuFull.value = false
|
if (!visible) menuFull.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
function openFullMenu() {
|
function setSoleSelected() {
|
||||||
menuFull.value = true
|
nodeSelection?.setSelection(new Set([nodeId.value]))
|
||||||
setSelected()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelected() {
|
function ensureSelected() {
|
||||||
nodeSelection?.setSelection(new Set([nodeId.value]))
|
if (!nodeSelection?.isSelected(nodeId.value)) {
|
||||||
|
setSoleSelected()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputHovered = ref(false)
|
const outputHovered = ref(false)
|
||||||
const keyboard = injectKeyboard()
|
const keyboard = injectKeyboard()
|
||||||
|
|
||||||
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
|
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
|
||||||
const visualizationHeight = computed(() => props.node.vis?.height ?? null)
|
const visualizationHeight = computed(() => props.node.vis?.height ?? null)
|
||||||
const isVisualizationEnabled = computed({
|
const isVisualizationEnabled = computed({
|
||||||
@ -311,11 +314,7 @@ const isRecordingOverridden = computed({
|
|||||||
|
|
||||||
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.node.innerExpr.externalId))
|
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.node.innerExpr.externalId))
|
||||||
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
|
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
|
||||||
const suggestionEntry = computed(() => graph.db.getNodeMainSuggestion(nodeId.value))
|
|
||||||
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
|
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
|
||||||
const documentationUrl = computed(
|
|
||||||
() => suggestionEntry.value && suggestionDocumentationUrl(suggestionEntry.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const nodeEditHandler = nodeEditBindings.handler({
|
const nodeEditHandler = nodeEditBindings.handler({
|
||||||
cancel(e) {
|
cancel(e) {
|
||||||
@ -390,11 +389,6 @@ function updateVisualizationRect(rect: Rect | undefined) {
|
|||||||
emit('update:visualizationRect', rect)
|
emit('update:visualizationRect', rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editingComment = ref(false)
|
|
||||||
|
|
||||||
const { getNodeColor, getNodeColors } = injectNodeColors()
|
|
||||||
const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
|
|
||||||
|
|
||||||
const graphSelectionSize = computed(() =>
|
const graphSelectionSize = computed(() =>
|
||||||
isVisualizationEnabled.value && visRect.value ? visRect.value.size : nodeSize.value,
|
isVisualizationEnabled.value && visRect.value ? visRect.value.size : nodeSize.value,
|
||||||
)
|
)
|
||||||
@ -415,6 +409,7 @@ const dataSource = computed(
|
|||||||
|
|
||||||
// === Recompute node expression ===
|
// === Recompute node expression ===
|
||||||
|
|
||||||
|
function useRecomputation() {
|
||||||
// The node is considered to be recomputing for at least this time.
|
// The node is considered to be recomputing for at least this time.
|
||||||
const MINIMAL_EXECUTION_TIMEOUT_MS = 500
|
const MINIMAL_EXECUTION_TIMEOUT_MS = 500
|
||||||
const recomputationTimeout = ref(false)
|
const recomputationTimeout = ref(false)
|
||||||
@ -427,6 +422,58 @@ function recomputeOnce() {
|
|||||||
recomputationTimeout.value = true
|
recomputationTimeout.value = true
|
||||||
setTimeout(() => (recomputationTimeout.value = false), MINIMAL_EXECUTION_TIMEOUT_MS)
|
setTimeout(() => (recomputationTimeout.value = false), MINIMAL_EXECUTION_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
return { recomputeOnce, isBeingRecomputed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Component actions ===
|
||||||
|
|
||||||
|
const { getNodeColor, getNodeColors } = injectNodeColors()
|
||||||
|
const { recomputeOnce, isBeingRecomputed } = useRecomputation()
|
||||||
|
|
||||||
|
const { editingComment } = provideComponentButtons(
|
||||||
|
{
|
||||||
|
graphBindings: graphBindings.bindings,
|
||||||
|
nodeEditBindings: nodeEditBindings.bindings,
|
||||||
|
onBeforeAction: setSoleSelected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enterNode: {
|
||||||
|
action: () => emit('enterNode'),
|
||||||
|
hidden: computed(() => !graph.nodeCanBeEntered(nodeId.value)),
|
||||||
|
},
|
||||||
|
startEditing: {
|
||||||
|
action: startEditingNode,
|
||||||
|
},
|
||||||
|
editingComment: {
|
||||||
|
state: ref(false),
|
||||||
|
},
|
||||||
|
createNewNode: {
|
||||||
|
action: () => emit('createNodes', [{ commit: false, content: undefined }]),
|
||||||
|
},
|
||||||
|
toggleDocPanel: {
|
||||||
|
action: () => emit('toggleDocPanel'),
|
||||||
|
},
|
||||||
|
toggleVisualization: {
|
||||||
|
state: isVisualizationEnabled,
|
||||||
|
},
|
||||||
|
pickColor: {
|
||||||
|
state: ref(false),
|
||||||
|
actionData: {
|
||||||
|
currentColor: computed({
|
||||||
|
get: () => getNodeColor(nodeId.value),
|
||||||
|
set: (color) => emit('setNodeColor', color),
|
||||||
|
}),
|
||||||
|
matchableColors: getNodeColors((node) => node !== nodeId.value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recompute: {
|
||||||
|
action: recomputeOnce,
|
||||||
|
disabled: isBeingRecomputed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMenuAt = ref<{ x: number; y: number }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -456,6 +503,7 @@ function recomputeOnce() {
|
|||||||
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
|
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
|
||||||
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
|
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
|
||||||
@pointermove="updateNodeHover"
|
@pointermove="updateNodeHover"
|
||||||
|
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
|
||||||
>
|
>
|
||||||
<Teleport v-if="navigator && !edited && graphNodeSelections" :to="graphNodeSelections">
|
<Teleport v-if="navigator && !edited && graphNodeSelections" :to="graphNodeSelections">
|
||||||
<GraphNodeSelection
|
<GraphNodeSelection
|
||||||
@ -478,32 +526,15 @@ function recomputeOnce() {
|
|||||||
v-if="!menuVisible && isRecordingOverridden"
|
v-if="!menuVisible && isRecordingOverridden"
|
||||||
class="overrideRecordButton clickable"
|
class="overrideRecordButton clickable"
|
||||||
data-testid="recordingOverriddenButton"
|
data-testid="recordingOverriddenButton"
|
||||||
@click="(isRecordingOverridden = false), setSelected()"
|
@click="(isRecordingOverridden = false), setSoleSelected()"
|
||||||
>
|
>
|
||||||
<SvgIcon name="record" />
|
<SvgIcon name="record" />
|
||||||
</button>
|
</button>
|
||||||
<ComponentMenu
|
<ComponentMenu
|
||||||
v-if="menuVisible"
|
v-if="menuVisible"
|
||||||
v-model:isVisualizationEnabled="isVisualizationEnabled"
|
|
||||||
:isRecordingEnabledGlobally="projectStore.isRecordingEnabled"
|
|
||||||
:nodeColor="getNodeColor(nodeId)"
|
|
||||||
:matchableNodeColors="matchableNodeColors"
|
|
||||||
:documentationUrl="documentationUrl"
|
|
||||||
:isRemovable="props.node.type === 'component'"
|
|
||||||
:isEnterable="graph.nodeCanBeEntered(nodeId)"
|
|
||||||
:isBeingRecomputed="isBeingRecomputed"
|
|
||||||
@enterNode="emit('enterNode')"
|
|
||||||
@startEditing="startEditingNode"
|
|
||||||
@startEditingComment="editingComment = true"
|
|
||||||
@openFullMenu="openFullMenu"
|
|
||||||
@delete="emit('delete')"
|
|
||||||
@pointerenter="menuHovered = true"
|
@pointerenter="menuHovered = true"
|
||||||
@pointerleave="menuHovered = false"
|
@pointerleave="menuHovered = false"
|
||||||
@update:nodeColor="emit('setNodeColor', $event)"
|
@click.capture="ensureSelected"
|
||||||
@createNewNode="setSelected(), emit('createNodes', [{ commit: false, content: undefined }])"
|
|
||||||
@toggleDocPanel="emit('toggleDocPanel')"
|
|
||||||
@click.capture="setSelected"
|
|
||||||
@recompute="recomputeOnce"
|
|
||||||
/>
|
/>
|
||||||
<GraphVisualization
|
<GraphVisualization
|
||||||
v-if="isVisualizationVisible"
|
v-if="isVisualizationVisible"
|
||||||
@ -527,13 +558,13 @@ function recomputeOnce() {
|
|||||||
@update:height="emit('update:visualizationHeight', $event)"
|
@update:height="emit('update:visualizationHeight', $event)"
|
||||||
@update:nodePosition="graph.setNodePosition(nodeId, $event)"
|
@update:nodePosition="graph.setNodePosition(nodeId, $event)"
|
||||||
@createNodes="emit('createNodes', $event)"
|
@createNodes="emit('createNodes', $event)"
|
||||||
@click.capture="setSelected"
|
@click.capture="setSoleSelected"
|
||||||
/>
|
/>
|
||||||
<GraphNodeComment
|
<GraphNodeComment
|
||||||
v-model:editing="editingComment"
|
v-model:editing="editingComment.state"
|
||||||
:node="node"
|
:node="node"
|
||||||
class="beforeNode"
|
class="beforeNode"
|
||||||
@click.capture="setSelected"
|
@click.capture="setSoleSelected"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref="contentNode"
|
ref="contentNode"
|
||||||
@ -551,7 +582,6 @@ function recomputeOnce() {
|
|||||||
:potentialSelfArgumentId="potentialSelfArgumentId"
|
:potentialSelfArgumentId="potentialSelfArgumentId"
|
||||||
:conditionalPorts="props.node.conditionalPorts"
|
:conditionalPorts="props.node.conditionalPorts"
|
||||||
:extended="isOnlyOneSelected"
|
:extended="isOnlyOneSelected"
|
||||||
@openFullMenu="openFullMenu"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="statuses">
|
<div class="statuses">
|
||||||
@ -582,9 +612,12 @@ function recomputeOnce() {
|
|||||||
<SmallPlusButton
|
<SmallPlusButton
|
||||||
v-if="menuVisible"
|
v-if="menuVisible"
|
||||||
:class="isVisualizationVisible ? 'afterNode' : 'belowMenu'"
|
:class="isVisualizationVisible ? 'afterNode' : 'belowMenu'"
|
||||||
@createNodes="setSelected(), emit('createNodes', $event)"
|
@createNodes="setSoleSelected(), emit('createNodes', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<PointFloatingMenu v-if="showMenuAt" :point="showMenuAt" @close="showMenuAt = undefined">
|
||||||
|
<ComponentContextMenu @close="showMenuAt = undefined" />
|
||||||
|
</PointFloatingMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -69,7 +69,6 @@ const graphNodeSelections = shallowRef<HTMLElement>()
|
|||||||
:node="node"
|
:node="node"
|
||||||
:edited="id === graphStore.editedNodeInfo?.id"
|
:edited="id === graphStore.editedNodeInfo?.id"
|
||||||
:graphNodeSelections="graphNodeSelections"
|
:graphNodeSelections="graphNodeSelections"
|
||||||
@delete="graphStore.deleteNodes([id])"
|
|
||||||
@dragging="nodeIsDragged(id, $event)"
|
@dragging="nodeIsDragged(id, $event)"
|
||||||
@draggingCommited="dragging.finishDrag()"
|
@draggingCommited="dragging.finishDrag()"
|
||||||
@draggingCancelled="dragging.cancelDrag()"
|
@draggingCancelled="dragging.cancelDrag()"
|
||||||
|
@ -23,9 +23,6 @@ const props = defineProps<{
|
|||||||
conditionalPorts: Set<Ast.AstId>
|
conditionalPorts: Set<Ast.AstId>
|
||||||
extended: boolean
|
extended: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
|
||||||
openFullMenu: []
|
|
||||||
}>()
|
|
||||||
const graph = useGraphStore()
|
const graph = useGraphStore()
|
||||||
const rootPort = computed(() => {
|
const rootPort = computed(() => {
|
||||||
const input = WidgetInput.FromAst(props.ast)
|
const input = WidgetInput.FromAst(props.ast)
|
||||||
@ -104,7 +101,6 @@ const widgetTree = provideWidgetTree(
|
|||||||
toRef(props, 'conditionalPorts'),
|
toRef(props, 'conditionalPorts'),
|
||||||
toRef(props, 'extended'),
|
toRef(props, 'extended'),
|
||||||
layoutTransitions.active,
|
layoutTransitions.active,
|
||||||
() => emit('openFullMenu'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
|
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import type { NodeCreationOptions } from '@/composables/nodeCreation'
|
import type { NodeCreationOptions } from '@/composables/nodeCreation'
|
||||||
import type { GraphStore, Node, NodeId } from '@/stores/graph'
|
import type { Node } from '@/stores/graph'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { Pattern } from '@/util/ast/match'
|
import { Pattern } from '@/util/ast/match'
|
||||||
import { nodeDocumentationText } from '@/util/ast/node'
|
import { nodeDocumentationText } from '@/util/ast/node'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import type { ToValue } from '@/util/reactivity'
|
|
||||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
import { computed, toValue } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { NodeMetadataFields } from 'ydoc-shared/ast'
|
import type { NodeMetadataFields } from 'ydoc-shared/ast'
|
||||||
|
|
||||||
// MIME type in *vendor tree*; see https://www.rfc-editor.org/rfc/rfc6838#section-3.2
|
// MIME type in *vendor tree*; see https://www.rfc-editor.org/rfc/rfc6838#section-3.2
|
||||||
@ -57,21 +56,11 @@ function getClipboard(): ExtendedClipboard {
|
|||||||
|
|
||||||
/** A composable for handling copying and pasting nodes in the GraphEditor. */
|
/** A composable for handling copying and pasting nodes in the GraphEditor. */
|
||||||
export function useGraphEditorClipboard(
|
export function useGraphEditorClipboard(
|
||||||
graphStore: GraphStore,
|
|
||||||
selected: ToValue<Set<NodeId>>,
|
|
||||||
createNodes: (nodesOptions: Iterable<NodeCreationOptions>) => void,
|
createNodes: (nodesOptions: Iterable<NodeCreationOptions>) => void,
|
||||||
) {
|
) {
|
||||||
/** Copy the content of the selected node to the clipboard. */
|
/** Copy the content of the specified nodes to the clipboard, in the order provided. */
|
||||||
async function copySelectionToClipboard() {
|
async function copyNodesToClipboard(nodes: Node[]): Promise<void> {
|
||||||
const nodes = new Array<Node>()
|
if (nodes.length) await writeClipboard(nodesToClipboardData(nodes))
|
||||||
const ids = graphStore.pickInCodeOrder(toValue(selected))
|
|
||||||
for (const id of ids) {
|
|
||||||
const node = graphStore.db.nodeIdToNode.get(id)
|
|
||||||
if (!node) continue
|
|
||||||
nodes.push(node)
|
|
||||||
}
|
|
||||||
if (!nodes.length) return
|
|
||||||
return writeClipboard(nodesToClipboardData(nodes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the clipboard and if it contains valid data, create nodes from the content. */
|
/** Read the clipboard and if it contains valid data, create nodes from the content. */
|
||||||
@ -105,7 +94,7 @@ export function useGraphEditorClipboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copySelectionToClipboard,
|
copyNodesToClipboard,
|
||||||
createNodesFromClipboard,
|
createNodesFromClipboard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
|
||||||
import type { URLString } from '@/util/data/urlString'
|
import type { URLString } from '@/util/data/urlString'
|
||||||
import type { Icon } from '@/util/iconName'
|
import type { Icon } from '@/util/iconName'
|
||||||
import NodeWidget from '../NodeWidget.vue'
|
import NodeWidget from '../NodeWidget.vue'
|
||||||
|
|
||||||
const props = defineProps(widgetProps(widgetDefinition))
|
const props = defineProps(widgetProps(widgetDefinition))
|
||||||
const tree = injectWidgetTree()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@ -33,11 +31,7 @@ export const widgetDefinition = defineWidget(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="WidgetIcon">
|
<div class="WidgetIcon">
|
||||||
<SvgIcon
|
<SvgIcon class="nodeCategoryIcon grab-handle" :name="props.input[DisplayIcon].icon" />
|
||||||
class="nodeCategoryIcon grab-handle"
|
|
||||||
:name="props.input[DisplayIcon].icon"
|
|
||||||
@click.right.stop.prevent="tree.emitOpenFullMenu()"
|
|
||||||
/>
|
|
||||||
<NodeWidget v-if="props.input[DisplayIcon].showContents === true" :input="props.input" />
|
<NodeWidget v-if="props.input[DisplayIcon].showContents === true" :input="props.input" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
19
app/gui/src/project-view/components/MenuPanel.vue
Normal file
19
app/gui/src/project-view/components/MenuPanel.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="MenuPanel">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.MenuPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: var(--color-frame-bg);
|
||||||
|
backdrop-filter: var(--blur-app-bg);
|
||||||
|
margin: 0 -4px;
|
||||||
|
z-index: 1;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
73
app/gui/src/project-view/components/PointFloatingMenu.vue
Normal file
73
app/gui/src/project-view/components/PointFloatingMenu.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useResizeObserver } from '@/composables/events'
|
||||||
|
import { useGraphEditorLayers } from '@/providers/graphEditorLayers'
|
||||||
|
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||||
|
import { endOnClickOutside } from '@/util/autoBlur'
|
||||||
|
import { autoUpdate, flip, shift, useFloating } from '@floating-ui/vue'
|
||||||
|
import { computed, onMounted, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const { point } = defineProps<{
|
||||||
|
/** Location to display the menu near, in client coordinates. */
|
||||||
|
point: { x: number; y: number }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const interaction = injectInteractionHandler()
|
||||||
|
const { floating: floatingLayer } = useGraphEditorLayers()
|
||||||
|
|
||||||
|
const menu = useTemplateRef<HTMLElement>('menu')
|
||||||
|
|
||||||
|
function pointVirtualEl({ x, y }: { x: number; y: number }) {
|
||||||
|
return {
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
right: x,
|
||||||
|
bottom: y,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualEl = computed(() => pointVirtualEl(point))
|
||||||
|
const { floatingStyles, update } = useFloating(virtualEl, menu, {
|
||||||
|
placement: 'bottom-start',
|
||||||
|
middleware: [flip(), shift({ crossAxis: true })],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuSize = useResizeObserver(menu)
|
||||||
|
watch(menuSize, update)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
interaction.setCurrent(
|
||||||
|
endOnClickOutside(menu, {
|
||||||
|
cancel: () => emit('close'),
|
||||||
|
end: () => emit('close'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="floatingLayer" :to="floatingLayer">
|
||||||
|
<div ref="menu" class="PointFloatingMenu" :style="floatingStyles">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.PointFloatingMenu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
</style>
|
28
app/gui/src/project-view/components/SelectionButton.vue
Normal file
28
app/gui/src/project-view/components/SelectionButton.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
|
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||||
|
import { injectSelectionButtons, type SelectionButtons } from '@/providers/selectionButtons'
|
||||||
|
|
||||||
|
const { button: buttonName } = defineProps<{ button: keyof SelectionButtons }>()
|
||||||
|
|
||||||
|
const { buttons } = injectSelectionButtons()
|
||||||
|
const button = buttons[buttonName]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToggleIcon
|
||||||
|
v-if="button.state != null"
|
||||||
|
v-model="button.state"
|
||||||
|
:icon="button.icon"
|
||||||
|
:disabled="button.disabled"
|
||||||
|
:title="button.descriptionWithShortcut"
|
||||||
|
@click.stop="button.action ?? ''"
|
||||||
|
/>
|
||||||
|
<SvgButton
|
||||||
|
v-else
|
||||||
|
:name="button.icon"
|
||||||
|
:disabled="button.disabled"
|
||||||
|
:title="button.descriptionWithShortcut"
|
||||||
|
@click.stop="button.action"
|
||||||
|
/>
|
||||||
|
</template>
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
|
import MenuPanel from '@/components/MenuPanel.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import type { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
import type { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@ -31,7 +32,8 @@ const open = ref(false)
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #entries>
|
<template #menu>
|
||||||
|
<MenuPanel>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
v-for="[key, option] in Object.entries(options)"
|
v-for="[key, option] in Object.entries(options)"
|
||||||
:key="key"
|
:key="key"
|
||||||
@ -43,6 +45,7 @@ const open = ref(false)
|
|||||||
<SvgIcon :name="option.icon" :style="option.iconStyle" :data-testid="option.dataTestid" />
|
<SvgIcon :name="option.icon" :style="option.iconStyle" :data-testid="option.dataTestid" />
|
||||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
</MenuPanel>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
/** @file A dropdown menu supporting the pattern of selecting a single entry from a list. */
|
/** @file A dropdown menu supporting the pattern of selecting a single entry from a list. */
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
import MenuButton from '@/components/MenuButton.vue'
|
import MenuButton from '@/components/MenuButton.vue'
|
||||||
|
import MenuPanel from '@/components/MenuPanel.vue'
|
||||||
import { TextSelectionMenuOption } from '@/components/visualizations/toolbar'
|
import { TextSelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
@ -25,7 +26,8 @@ const open = ref(false)
|
|||||||
<div v-if="options[selected]?.label" class="iconLabel" v-text="options[selected]?.label" />
|
<div v-if="options[selected]?.label" class="iconLabel" v-text="options[selected]?.label" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #entries>
|
<template #menu>
|
||||||
|
<MenuPanel>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
v-for="[key, option] in Object.entries(options)"
|
v-for="[key, option] in Object.entries(options)"
|
||||||
:key="key"
|
:key="key"
|
||||||
@ -36,6 +38,7 @@ const open = ref(false)
|
|||||||
>
|
>
|
||||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
</MenuPanel>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,35 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ColorPickerMenu from '@/components/ColorPickerMenu.vue'
|
import ColorPickerMenu from '@/components/ColorPickerMenu.vue'
|
||||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
import SelectionButton from '@/components/SelectionButton.vue'
|
||||||
import SvgButton from './SvgButton.vue'
|
import { injectSelectionButtons } from '@/providers/selectionButtons'
|
||||||
|
|
||||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
const { selectedNodeCount, buttons } = injectSelectionButtons()
|
||||||
const _props = defineProps<{ selectedComponents: number }>()
|
const { pickColorMulti } = buttons
|
||||||
const emit = defineEmits<{
|
|
||||||
collapseNodes: []
|
|
||||||
removeNodes: []
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="SelectionMenu">
|
<Transition>
|
||||||
<span
|
<div v-if="selectedNodeCount > 1" class="SelectionMenu">
|
||||||
v-text="`${selectedComponents} component${selectedComponents === 1 ? '' : 's'} selected`"
|
<span v-text="`${selectedNodeCount} components selected`" />
|
||||||
/>
|
<SelectionButton button="collapse" />
|
||||||
<SvgButton name="group" title="Group Selected Components" @click.stop="emit('collapseNodes')" />
|
<SelectionButton
|
||||||
<ToggleIcon
|
button="pickColorMulti"
|
||||||
v-model="showColorPicker"
|
|
||||||
title="Color Selected Components"
|
|
||||||
icon="paint_palette"
|
|
||||||
:class="{
|
:class="{
|
||||||
// Any `pointerdown` event outside the color picker will close it. Ignore clicks that occur while the color
|
// Any `pointerdown` event outside the color picker will close it. Ignore clicks that occur while the color
|
||||||
// picker is open, so that it isn't toggled back open.
|
// picker is open, so that it isn't toggled back open.
|
||||||
disableInput: showColorPicker,
|
disableInput: pickColorMulti.state,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<SvgButton name="trash" title="Delete Selected Components" @click.stop="emit('removeNodes')" />
|
<SelectionButton button="copy" />
|
||||||
<ColorPickerMenu v-if="showColorPicker" class="submenu" @close="showColorPicker = false" />
|
<SelectionButton button="deleteSelected" />
|
||||||
|
<ColorPickerMenu
|
||||||
|
v-if="pickColorMulti.state"
|
||||||
|
class="submenu"
|
||||||
|
@close="pickColorMulti.state = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -41,10 +40,7 @@ const emit = defineEmits<{
|
|||||||
backdrop-filter: var(--blur-app-bg);
|
backdrop-filter: var(--blur-app-bg);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-left: 10px;
|
padding: 4px 10px;
|
||||||
padding-right: 10px;
|
|
||||||
padding-top: 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
@ -63,4 +59,14 @@ const emit = defineEmits<{
|
|||||||
.disableInput {
|
.disableInput {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -7,7 +7,7 @@ import type { Icon } from '@/util/iconName'
|
|||||||
const _props = defineProps<{
|
const _props = defineProps<{
|
||||||
name?: Icon | URLString | undefined
|
name?: Icon | URLString | undefined
|
||||||
label?: string | undefined
|
label?: string | undefined
|
||||||
disabled?: boolean
|
disabled?: boolean | undefined
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
@ -5,19 +5,15 @@ import RecordControl from '@/components/RecordControl.vue'
|
|||||||
import SelectionMenu from '@/components/SelectionMenu.vue'
|
import SelectionMenu from '@/components/SelectionMenu.vue'
|
||||||
import UndoRedoButtons from './UndoRedoButtons.vue'
|
import UndoRedoButtons from './UndoRedoButtons.vue'
|
||||||
|
|
||||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
|
||||||
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
|
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
|
||||||
const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true })
|
const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true })
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
zoomLevel: number
|
zoomLevel: number
|
||||||
componentsSelected: number
|
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
fitToAllClicked: []
|
fitToAllClicked: []
|
||||||
zoomIn: []
|
zoomIn: []
|
||||||
zoomOut: []
|
zoomOut: []
|
||||||
collapseNodes: []
|
|
||||||
removeNodes: []
|
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -26,15 +22,7 @@ const emit = defineEmits<{
|
|||||||
<NavBreadcrumbs />
|
<NavBreadcrumbs />
|
||||||
<RecordControl />
|
<RecordControl />
|
||||||
<UndoRedoButtons />
|
<UndoRedoButtons />
|
||||||
<Transition name="selection-menu">
|
<SelectionMenu />
|
||||||
<SelectionMenu
|
|
||||||
v-if="componentsSelected > 1"
|
|
||||||
v-model:showColorPicker="showColorPicker"
|
|
||||||
:selectedComponents="componentsSelected"
|
|
||||||
@collapseNodes="emit('collapseNodes')"
|
|
||||||
@removeNodes="emit('removeNodes')"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
<ExtendedMenu
|
<ExtendedMenu
|
||||||
v-model:showCodeEditor="showCodeEditor"
|
v-model:showCodeEditor="showCodeEditor"
|
||||||
v-model:showDocumentationEditor="showDocumentationEditor"
|
v-model:showDocumentationEditor="showDocumentationEditor"
|
||||||
@ -64,14 +52,4 @@ const emit = defineEmits<{
|
|||||||
.TopBar.extraRightSpace {
|
.TopBar.extraRightSpace {
|
||||||
right: 32px;
|
right: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-menu-enter-active,
|
|
||||||
.selection-menu-leave-active {
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-menu-enter-from,
|
|
||||||
.selection-menu-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** @file Provides a fullscreen mode to its slot, based on conditional teleport and conditional styling. */
|
/** @file Provides a fullscreen mode to its slot, based on conditional teleport and conditional styling. */
|
||||||
|
|
||||||
import { useFullscreenContext } from '@/providers/fullscreenContext'
|
import { useGraphEditorLayers } from '@/providers/graphEditorLayers'
|
||||||
import { Rect } from '@/util/data/rect'
|
import { Rect } from '@/util/data/rect'
|
||||||
import { computed, ref, toRef, watch } from 'vue'
|
import { computed, ref, toRef, watch } from 'vue'
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const content = ref<HTMLElement>()
|
const content = ref<HTMLElement>()
|
||||||
|
|
||||||
const { fullscreenContainer } = useFullscreenContext()
|
const { fullscreen: fullscreenContainer } = useGraphEditorLayers()
|
||||||
|
|
||||||
const fullscreenSize: Keyframe = {
|
const fullscreenSize: Keyframe = {
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -114,6 +114,7 @@ defineExpose({
|
|||||||
:style="inputStyle"
|
:style="inputStyle"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@click.stop
|
@click.stop
|
||||||
|
@contextmenu.stop
|
||||||
@keydown.backspace.stop
|
@keydown.backspace.stop
|
||||||
@keydown.delete.stop
|
@keydown.delete.stop
|
||||||
@keydown.arrow-left.stop
|
@keydown.arrow-left.stop
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
toValue,
|
toValue,
|
||||||
watch,
|
watch,
|
||||||
watchEffect,
|
watchEffect,
|
||||||
|
type ComputedRef,
|
||||||
type Ref,
|
type Ref,
|
||||||
type ShallowRef,
|
type ShallowRef,
|
||||||
type WatchSource,
|
type WatchSource,
|
||||||
@ -184,6 +185,16 @@ export function unrefElement(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a reactive value for the input element's root, if it is an HTML element. */
|
||||||
|
export function useUnrefHTMLElement(
|
||||||
|
element: Ref<Element | undefined | null | VueInstance>,
|
||||||
|
): ComputedRef<HTMLElement | undefined> {
|
||||||
|
return computed(() => {
|
||||||
|
const elementValue = unrefElement(element)
|
||||||
|
return elementValue instanceof HTMLElement ? elementValue : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
interface ResizeObserverData {
|
interface ResizeObserverData {
|
||||||
refCount: number
|
refCount: number
|
||||||
boundRectUsers: number
|
boundRectUsers: number
|
||||||
|
@ -16,6 +16,7 @@ import { Rect } from '@/util/data/rect'
|
|||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||||
import type { ToValue } from '@/util/reactivity'
|
import type { ToValue } from '@/util/reactivity'
|
||||||
|
import { identity } from '@vueuse/core'
|
||||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
import { nextTick, toValue } from 'vue'
|
import { nextTick, toValue } from 'vue'
|
||||||
import { assert, assertNever } from 'ydoc-shared/util/assert'
|
import { assert, assertNever } from 'ydoc-shared/util/assert'
|
||||||
@ -83,10 +84,6 @@ export function useNodeCreation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function identity<T>(value: T): T {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeNodes(nodesOptions: Iterable<NodeCreationOptions>): NodeCreationOptions[] {
|
function placeNodes(nodesOptions: Iterable<NodeCreationOptions>): NodeCreationOptions[] {
|
||||||
const rects = new Array<Rect>()
|
const rects = new Array<Rect>()
|
||||||
const { place } = usePlacement(rects, viewport)
|
const { place } = usePlacement(rects, viewport)
|
||||||
|
@ -7,6 +7,7 @@ import type { Rect } from '@/util/data/rect'
|
|||||||
import { intersectionSize } from '@/util/data/set'
|
import { intersectionSize } from '@/util/data/set'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { dataAttribute, elementHierarchy } from '@/util/dom'
|
import { dataAttribute, elementHierarchy } from '@/util/dom'
|
||||||
|
import { identity } from '@vueuse/core'
|
||||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
import * as set from 'lib0/set'
|
import * as set from 'lib0/set'
|
||||||
import { computed, ref, shallowReactive, shallowRef } from 'vue'
|
import { computed, ref, shallowReactive, shallowRef } from 'vue'
|
||||||
@ -18,6 +19,7 @@ interface BaseSelectionOptions<T> {
|
|||||||
isValid?: (element: T) => boolean
|
isValid?: (element: T) => boolean
|
||||||
onSelected?: (element: T) => void
|
onSelected?: (element: T) => void
|
||||||
onDeselected?: (element: T) => void
|
onDeselected?: (element: T) => void
|
||||||
|
toSorted?: (elements: Iterable<T>) => Iterable<T>
|
||||||
}
|
}
|
||||||
interface SelectionPackingOptions<T, PackedT> {
|
interface SelectionPackingOptions<T, PackedT> {
|
||||||
/**
|
/**
|
||||||
@ -57,6 +59,7 @@ export function useSelection<T, PackedT>(
|
|||||||
isValid: () => true,
|
isValid: () => true,
|
||||||
onSelected: () => {},
|
onSelected: () => {},
|
||||||
onDeselected: () => {},
|
onDeselected: () => {},
|
||||||
|
toSorted: identity,
|
||||||
}
|
}
|
||||||
const PACKING_DEFAULTS: SelectionPackingOptions<T, T> = {
|
const PACKING_DEFAULTS: SelectionPackingOptions<T, T> = {
|
||||||
pack: (element: T) => element,
|
pack: (element: T) => element,
|
||||||
@ -76,7 +79,7 @@ type UseSelection<T, PackedT> = ReturnType<typeof useSelectionImpl<T, PackedT>>
|
|||||||
function useSelectionImpl<T, PackedT>(
|
function useSelectionImpl<T, PackedT>(
|
||||||
navigator: NavigatorComposable,
|
navigator: NavigatorComposable,
|
||||||
elementRects: Map<T, Rect>,
|
elementRects: Map<T, Rect>,
|
||||||
{ margin, isValid, onSelected, onDeselected }: Required<BaseSelectionOptions<T>>,
|
{ margin, isValid, onSelected, onDeselected, toSorted }: Required<BaseSelectionOptions<T>>,
|
||||||
{ pack, unpack }: SelectionPackingOptions<T, PackedT>,
|
{ pack, unpack }: SelectionPackingOptions<T, PackedT>,
|
||||||
) {
|
) {
|
||||||
const anchor = shallowRef<Vec2>()
|
const anchor = shallowRef<Vec2>()
|
||||||
@ -88,7 +91,9 @@ function useSelectionImpl<T, PackedT>(
|
|||||||
const unpackedRawSelected = computed(() =>
|
const unpackedRawSelected = computed(() =>
|
||||||
set.from(iter.filterDefined(iter.map(rawSelected, unpack))),
|
set.from(iter.filterDefined(iter.map(rawSelected, unpack))),
|
||||||
)
|
)
|
||||||
const selected = computed(() => set.from(iter.filter(unpackedRawSelected.value, isValid)))
|
const selected = computed(() =>
|
||||||
|
set.from(toSorted(iter.filter(unpackedRawSelected.value, isValid))),
|
||||||
|
)
|
||||||
const isChanging = computed(() => anchor.value != null)
|
const isChanging = computed(() => anchor.value != null)
|
||||||
const committedSelection = computed(() =>
|
const committedSelection = computed(() =>
|
||||||
isChanging.value ? set.from(iter.filter(initiallySelected, isValid)) : selected.value,
|
isChanging.value ? set.from(iter.filter(initiallySelected, isValid)) : selected.value,
|
||||||
@ -224,6 +229,7 @@ function useSelectionImpl<T, PackedT>(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// === Selected nodes ===
|
// === Selected nodes ===
|
||||||
|
/** The valid currently-selected elements, in the order defined by `toSorted`, if provided. */
|
||||||
selected,
|
selected,
|
||||||
selectAll: () => {
|
selectAll: () => {
|
||||||
for (const id of elementRects.keys()) {
|
for (const id of elementRects.keys()) {
|
||||||
|
120
app/gui/src/project-view/providers/componentButtons.ts
Normal file
120
app/gui/src/project-view/providers/componentButtons.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { createContextStore } from '@/providers'
|
||||||
|
import {
|
||||||
|
reactiveButton,
|
||||||
|
type ActionOrStateRequired,
|
||||||
|
type Button,
|
||||||
|
type ButtonBehavior,
|
||||||
|
type Stateful,
|
||||||
|
type StatefulInput,
|
||||||
|
} from '@/util/button'
|
||||||
|
import { computed, proxyRefs, type Ref, type UnwrapRef } from 'vue'
|
||||||
|
|
||||||
|
type Actions =
|
||||||
|
| 'enterNode'
|
||||||
|
| 'startEditing'
|
||||||
|
| 'editingComment'
|
||||||
|
| 'createNewNode'
|
||||||
|
| 'toggleDocPanel'
|
||||||
|
| 'toggleVisualization'
|
||||||
|
| 'recompute'
|
||||||
|
| 'pickColor'
|
||||||
|
|
||||||
|
type ActionsWithVoidActionData = Exclude<Actions, 'pickColor'>
|
||||||
|
type StatefulActions = 'toggleVisualization' | 'pickColor' | 'editingComment'
|
||||||
|
|
||||||
|
type PickColorDataInput = {
|
||||||
|
currentColor: Ref<string | undefined>
|
||||||
|
matchableColors: Readonly<Ref<ReadonlySet<string>>>
|
||||||
|
}
|
||||||
|
type PickColorData = UnwrapRef<PickColorDataInput>
|
||||||
|
|
||||||
|
export type ComponentButtons = Record<ActionsWithVoidActionData, Button<void>> &
|
||||||
|
Record<'pickColor', Button<PickColorData>> &
|
||||||
|
Record<StatefulActions, Stateful>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the {@link ButtonBehavior} for each single-component button and some context, adds the UI information and
|
||||||
|
* constructs a collection of {@link Button}s.
|
||||||
|
*/
|
||||||
|
function useComponentButtons(
|
||||||
|
{
|
||||||
|
graphBindings,
|
||||||
|
nodeEditBindings,
|
||||||
|
onBeforeAction,
|
||||||
|
}: {
|
||||||
|
graphBindings: Record<'openComponentBrowser' | 'toggleVisualization', { humanReadable: string }>
|
||||||
|
nodeEditBindings: Record<'edit', { humanReadable: string }>
|
||||||
|
onBeforeAction: () => void
|
||||||
|
},
|
||||||
|
buttons: Record<Actions, ButtonBehavior & ActionOrStateRequired> &
|
||||||
|
Record<StatefulActions, StatefulInput> & {
|
||||||
|
pickColor: { actionData: PickColorDataInput }
|
||||||
|
},
|
||||||
|
): ComponentButtons {
|
||||||
|
function withHooks<T extends { action?: (() => void) | undefined }>(value: T): T {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
action:
|
||||||
|
value.action ?
|
||||||
|
() => {
|
||||||
|
onBeforeAction()
|
||||||
|
value.action?.()
|
||||||
|
}
|
||||||
|
: onBeforeAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enterNode: reactiveButton({
|
||||||
|
...withHooks(buttons.enterNode),
|
||||||
|
icon: 'open',
|
||||||
|
description: 'Open Grouped Components',
|
||||||
|
testid: 'enter-node-button',
|
||||||
|
}),
|
||||||
|
startEditing: reactiveButton({
|
||||||
|
...withHooks(buttons.startEditing),
|
||||||
|
icon: 'edit',
|
||||||
|
description: 'Code Edit',
|
||||||
|
shortcut: nodeEditBindings.edit,
|
||||||
|
testid: 'edit-button',
|
||||||
|
}),
|
||||||
|
editingComment: reactiveButton({
|
||||||
|
...withHooks(buttons.editingComment),
|
||||||
|
icon: 'comment',
|
||||||
|
description: 'Add Comment',
|
||||||
|
}),
|
||||||
|
createNewNode: reactiveButton({
|
||||||
|
...withHooks(buttons.createNewNode),
|
||||||
|
icon: 'add',
|
||||||
|
description: 'Add New Component',
|
||||||
|
shortcut: graphBindings.openComponentBrowser,
|
||||||
|
}),
|
||||||
|
toggleDocPanel: reactiveButton({
|
||||||
|
...withHooks(buttons.toggleDocPanel),
|
||||||
|
icon: 'help',
|
||||||
|
description: 'Help',
|
||||||
|
}),
|
||||||
|
toggleVisualization: reactiveButton({
|
||||||
|
...withHooks(buttons.toggleVisualization),
|
||||||
|
icon: 'eye',
|
||||||
|
description: computed(() =>
|
||||||
|
buttons.toggleVisualization.state.value ? 'Hide Visualization' : 'Show Visualization',
|
||||||
|
),
|
||||||
|
shortcut: graphBindings.toggleVisualization,
|
||||||
|
}),
|
||||||
|
recompute: reactiveButton({
|
||||||
|
...withHooks(buttons.recompute),
|
||||||
|
icon: 'workflow_play',
|
||||||
|
description: 'Write',
|
||||||
|
testid: 'recompute',
|
||||||
|
}),
|
||||||
|
pickColor: reactiveButton({
|
||||||
|
...withHooks(buttons.pickColor),
|
||||||
|
icon: 'paint_palette',
|
||||||
|
description: 'Color Component',
|
||||||
|
actionData: proxyRefs(buttons.pickColor.actionData),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { injectFn as injectComponentButtons, provideFn as provideComponentButtons }
|
||||||
|
const { provideFn, injectFn } = createContextStore('Component buttons', useComponentButtons)
|
@ -1,11 +0,0 @@
|
|||||||
import { createContextStore } from '@/providers'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
|
|
||||||
export { provideFn as provideFullscreenContext, injectFn as useFullscreenContext }
|
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'fullscreen context',
|
|
||||||
(fullscreenContainer: Readonly<Ref<HTMLElement | undefined>>) => ({
|
|
||||||
/** An element that fullscreen elements should be placed inside. */
|
|
||||||
fullscreenContainer,
|
|
||||||
}),
|
|
||||||
)
|
|
15
app/gui/src/project-view/providers/graphEditorLayers.ts
Normal file
15
app/gui/src/project-view/providers/graphEditorLayers.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContextStore } from '@/providers'
|
||||||
|
import { identity } from '@vueuse/core'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface GraphEditorLayers {
|
||||||
|
/** An element that fullscreen elements should be placed inside. */
|
||||||
|
fullscreen: Readonly<Ref<HTMLElement | undefined>>
|
||||||
|
floating: Readonly<Ref<HTMLElement | undefined>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export { provideFn as provideGraphEditorLayers, injectFn as useGraphEditorLayers }
|
||||||
|
const { provideFn, injectFn } = createContextStore(
|
||||||
|
'Graph editor layers',
|
||||||
|
identity<GraphEditorLayers>,
|
||||||
|
)
|
89
app/gui/src/project-view/providers/selectionButtons.ts
Normal file
89
app/gui/src/project-view/providers/selectionButtons.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { graphBindings } from '@/bindings'
|
||||||
|
import { createContextStore } from '@/providers'
|
||||||
|
import { type ComponentButtons, injectComponentButtons } from '@/providers/componentButtons'
|
||||||
|
import { type Node } from '@/stores/graph'
|
||||||
|
import { type Button, reactiveButton } from '@/util/button'
|
||||||
|
import { type ToValue } from '@/util/reactivity'
|
||||||
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
|
import { type DisjointKeysUnion } from 'enso-common/src/utilities/data/object'
|
||||||
|
import { computed, type ComputedRef, type Ref, ref, toValue } from 'vue'
|
||||||
|
|
||||||
|
export type SelectionButtons = Record<
|
||||||
|
'collapse' | 'copy' | 'deleteSelected' | 'pickColorMulti',
|
||||||
|
Button<void>
|
||||||
|
>
|
||||||
|
|
||||||
|
function useSelectionButtons(
|
||||||
|
selectedNodes: ToValue<Iterable<Node>>,
|
||||||
|
actions: {
|
||||||
|
collapseNodes: (nodes: Node[]) => void
|
||||||
|
copyNodesToClipboard: (nodes: Node[]) => void
|
||||||
|
deleteNodes: (nodes: Node[]) => void
|
||||||
|
},
|
||||||
|
): { selectedNodeCount: Readonly<Ref<number>>; buttons: SelectionButtons } {
|
||||||
|
function everyNode(predicate: (node: Node) => boolean): ComputedRef<boolean> {
|
||||||
|
return computed(() => iter.every(toValue(selectedNodes), predicate))
|
||||||
|
}
|
||||||
|
const selectedNodesArray = computed(() => [...toValue(selectedNodes)])
|
||||||
|
const selectedNodeCount = computed<number>(() => toValue(selectedNodesArray).length)
|
||||||
|
const singleNodeSelected = computed<boolean>(() => selectedNodeCount.value === 1)
|
||||||
|
const noNormalNodes = everyNode((node) => node.type !== 'component')
|
||||||
|
function action(action: keyof typeof actions): () => void {
|
||||||
|
return () => actions[action](toValue(selectedNodesArray))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
selectedNodeCount,
|
||||||
|
buttons: {
|
||||||
|
collapse: reactiveButton({
|
||||||
|
disabled: computed(() => singleNodeSelected.value || noNormalNodes.value),
|
||||||
|
icon: 'group',
|
||||||
|
description: 'Group Selected Components',
|
||||||
|
shortcut: graphBindings.bindings.collapse,
|
||||||
|
action: action('collapseNodes'),
|
||||||
|
}),
|
||||||
|
copy: reactiveButton({
|
||||||
|
disabled: noNormalNodes,
|
||||||
|
icon: 'copy2',
|
||||||
|
description: computed(() =>
|
||||||
|
singleNodeSelected.value ? 'Copy Component' : 'Copy Selected Components',
|
||||||
|
),
|
||||||
|
shortcut: graphBindings.bindings.copyNode,
|
||||||
|
action: action('copyNodesToClipboard'),
|
||||||
|
}),
|
||||||
|
deleteSelected: reactiveButton({
|
||||||
|
disabled: noNormalNodes,
|
||||||
|
icon: 'trash2',
|
||||||
|
description: computed(() =>
|
||||||
|
singleNodeSelected.value ? 'Delete Component' : 'Delete Selected Components',
|
||||||
|
),
|
||||||
|
shortcut: graphBindings.bindings.deleteSelected,
|
||||||
|
action: action('deleteNodes'),
|
||||||
|
testid: 'removeNode',
|
||||||
|
}),
|
||||||
|
pickColorMulti: reactiveButton({
|
||||||
|
state: ref(false),
|
||||||
|
disabled: computed(() => singleNodeSelected.value || noNormalNodes.value),
|
||||||
|
icon: 'paint_palette',
|
||||||
|
description: 'Color Selected Components',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { injectFn as injectSelectionButtons, provideFn as provideSelectionButtons }
|
||||||
|
const { provideFn, injectFn } = createContextStore('Selection buttons', useSelectionButtons)
|
||||||
|
|
||||||
|
export type ComponentAndSelectionButtons = DisjointKeysUnion<ComponentButtons, SelectionButtons>
|
||||||
|
|
||||||
|
/** Returns {@link Button}s for the single-component actions and the selected-components actions. */
|
||||||
|
export function injectComponentAndSelectionButtons(): {
|
||||||
|
selectedNodeCount: Readonly<Ref<number>>
|
||||||
|
buttons: ComponentAndSelectionButtons
|
||||||
|
} {
|
||||||
|
const selectionButtons = injectFn()
|
||||||
|
const componentButtons = injectComponentButtons()
|
||||||
|
return {
|
||||||
|
...selectionButtons,
|
||||||
|
buttons: { ...selectionButtons.buttons, ...componentButtons },
|
||||||
|
}
|
||||||
|
}
|
@ -18,7 +18,6 @@ const { provideFn, injectFn } = createContextStore(
|
|||||||
conditionalPorts: Ref<Set<Ast.AstId>>,
|
conditionalPorts: Ref<Set<Ast.AstId>>,
|
||||||
extended: Ref<boolean>,
|
extended: Ref<boolean>,
|
||||||
hasActiveAnimations: Ref<boolean>,
|
hasActiveAnimations: Ref<boolean>,
|
||||||
emitOpenFullMenu: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
const graph = useGraphStore()
|
const graph = useGraphStore()
|
||||||
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
|
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
|
||||||
@ -35,7 +34,6 @@ const { provideFn, injectFn } = createContextStore(
|
|||||||
hasActiveAnimations,
|
hasActiveAnimations,
|
||||||
setCurrentEditRoot,
|
setCurrentEditRoot,
|
||||||
currentEdit,
|
currentEdit,
|
||||||
emitOpenFullMenu,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -360,7 +360,7 @@ export class GraphDb {
|
|||||||
prefixes,
|
prefixes,
|
||||||
conditionalPorts,
|
conditionalPorts,
|
||||||
argIndex,
|
argIndex,
|
||||||
} satisfies NodeDataFromAst
|
} satisfies AllNodeFieldsFromAst
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,7 +514,14 @@ export function nodeIdFromOuterAst(outerAst: Ast.Statement | Ast.Expression) {
|
|||||||
return root && asNodeId(root.externalId)
|
return root && asNodeId(root.externalId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeDataFromAst {
|
/** Given a node, returns its {@link NodeId}. */
|
||||||
|
export function nodeId({ rootExpr }: { rootExpr: Ast.Expression }): NodeId {
|
||||||
|
return asNodeId(rootExpr.externalId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeDataFromAst = ComponentNodeData | InputNodeData | OutputNodeData
|
||||||
|
|
||||||
|
interface AllNodeFieldsFromAst {
|
||||||
type: NodeType
|
type: NodeType
|
||||||
/**
|
/**
|
||||||
* The statement or top-level expression.
|
* The statement or top-level expression.
|
||||||
@ -551,12 +558,34 @@ export interface NodeDataFromAst {
|
|||||||
argIndex: number | undefined
|
argIndex: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComponentNodeData extends AllNodeFieldsFromAst {
|
||||||
|
type: 'component'
|
||||||
|
outerAst: Ast.Statement
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputNodeData extends AllNodeFieldsFromAst {
|
||||||
|
type: 'input'
|
||||||
|
outerAst: Ast.Expression
|
||||||
|
argIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type predicate for nodes of type `input`. */
|
||||||
|
export function isInputNode(node: Node): node is Node & InputNodeData {
|
||||||
|
return node.type === 'input'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutputNodeData extends AllNodeFieldsFromAst {
|
||||||
|
type: 'output'
|
||||||
|
outerAst: Ast.Statement
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeDataFromMetadata {
|
export interface NodeDataFromMetadata {
|
||||||
position: Vec2
|
position: Vec2
|
||||||
vis: Opt<VisualizationMetadata>
|
vis: Opt<VisualizationMetadata>
|
||||||
colorOverride: Opt<string>
|
colorOverride: Opt<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Node extends NodeDataFromAst, NodeDataFromMetadata {
|
export type Node = NodeDataFromAst &
|
||||||
|
NodeDataFromMetadata & {
|
||||||
zIndex: number
|
zIndex: number
|
||||||
}
|
}
|
||||||
|
@ -666,6 +666,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
|||||||
|
|
||||||
/** Iterate over code lines, return node IDs from `ids` set in the order of code positions. */
|
/** Iterate over code lines, return node IDs from `ids` set in the order of code positions. */
|
||||||
function pickInCodeOrder(ids: Set<NodeId>): NodeId[] {
|
function pickInCodeOrder(ids: Set<NodeId>): NodeId[] {
|
||||||
|
if (ids.size === 0) return []
|
||||||
assert(syncModule.value != null)
|
assert(syncModule.value != null)
|
||||||
const func = unwrap(getExecutedMethodAst(syncModule.value))
|
const func = unwrap(getExecutedMethodAst(syncModule.value))
|
||||||
const body = func.bodyExpressions()
|
const body = func.bodyExpressions()
|
||||||
|
@ -44,10 +44,9 @@ export function nodeFromAst(ast: Ast.Statement, isOutput: boolean): NodeDataFrom
|
|||||||
const { root, assignment } = nodeRootExpr(ast)
|
const { root, assignment } = nodeRootExpr(ast)
|
||||||
if (!root) return
|
if (!root) return
|
||||||
const { innerExpr, matches } = prefixes.extractMatches(root)
|
const { innerExpr, matches } = prefixes.extractMatches(root)
|
||||||
const type = assignment == null && isOutput ? 'output' : 'component'
|
|
||||||
const primaryApplication = primaryApplicationSubject(innerExpr)
|
const primaryApplication = primaryApplicationSubject(innerExpr)
|
||||||
return {
|
return {
|
||||||
type,
|
type: assignment == null && isOutput ? 'output' : 'component',
|
||||||
outerAst: ast,
|
outerAst: ast,
|
||||||
pattern: assignment?.pattern,
|
pattern: assignment?.pattern,
|
||||||
rootExpr: root,
|
rootExpr: root,
|
||||||
|
113
app/gui/src/project-view/util/button.ts
Normal file
113
app/gui/src/project-view/util/button.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { type Icon } from '@/util/iconName'
|
||||||
|
import { computed, type ComputedRef, markRaw, type MaybeRef, type Ref, unref } from 'vue'
|
||||||
|
|
||||||
|
export type ActionOrStateRequired = { action: () => void } | { state: Ref<boolean> }
|
||||||
|
|
||||||
|
export interface ButtonBehavior {
|
||||||
|
action?: (() => void) | undefined
|
||||||
|
state?: Ref<boolean> | undefined
|
||||||
|
hidden?: ComputedRef<boolean> | undefined
|
||||||
|
disabled?: ComputedRef<boolean> | undefined
|
||||||
|
}
|
||||||
|
export interface ButtonUI {
|
||||||
|
icon: Icon
|
||||||
|
description: ComputedRef<string> | string
|
||||||
|
shortcut?: string | undefined
|
||||||
|
testid?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the appearance and behavior of an action or toggleable state, that may be rendered as a button or menu entry.
|
||||||
|
*
|
||||||
|
* By enabling separation between where buttons are logically defined and where they are displayed by UI components,
|
||||||
|
* this type solves two problems:
|
||||||
|
* - Routing data and events: A `Button` can be created in the context where its behavior is most logically defined, and
|
||||||
|
* then made available to the UI components that render it; this avoids the need to pass the various state needed by
|
||||||
|
* different actions through the hierarchy of UI components.
|
||||||
|
* - Duplication: A single `Button` can be used to render buttons or menu entries in various UI containers. This ensures
|
||||||
|
* consistency in appearance and behavior of different UI elements controlling the same action or state.
|
||||||
|
*/
|
||||||
|
export interface Button<T = unknown> {
|
||||||
|
readonly action: (() => void) | undefined
|
||||||
|
readonly hidden: boolean
|
||||||
|
readonly disabled: boolean
|
||||||
|
state: boolean | undefined
|
||||||
|
readonly icon: Icon
|
||||||
|
readonly shortcut: string | undefined
|
||||||
|
readonly testid: string | undefined
|
||||||
|
readonly description: string
|
||||||
|
readonly descriptionWithShortcut: string
|
||||||
|
readonly actionData: T
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactiveButton<T> implements Button<T> {
|
||||||
|
private readonly toDescriptionWithShortcut: MaybeRef<string>
|
||||||
|
constructor(
|
||||||
|
readonly action: (() => void) | undefined,
|
||||||
|
readonly icon: Icon,
|
||||||
|
readonly shortcut: string | undefined,
|
||||||
|
readonly testid: string | undefined,
|
||||||
|
readonly actionData: T,
|
||||||
|
private readonly toDescription: MaybeRef<string>,
|
||||||
|
private readonly toHidden: Readonly<Ref<boolean>> | undefined,
|
||||||
|
private readonly toDisabled: Readonly<Ref<boolean>> | undefined,
|
||||||
|
private readonly refState: Ref<boolean> | undefined,
|
||||||
|
) {
|
||||||
|
markRaw(this)
|
||||||
|
this.toDescriptionWithShortcut =
|
||||||
|
shortcut ? computed(() => `${unref(toDescription)} (${shortcut})`) : toDescription
|
||||||
|
}
|
||||||
|
get description(): string {
|
||||||
|
return unref(this.toDescription)
|
||||||
|
}
|
||||||
|
get descriptionWithShortcut(): string {
|
||||||
|
return unref(this.toDescriptionWithShortcut)
|
||||||
|
}
|
||||||
|
get hidden(): boolean {
|
||||||
|
return this.toHidden ? unref(this.toHidden) : false
|
||||||
|
}
|
||||||
|
get disabled(): boolean {
|
||||||
|
return this.toDisabled ? unref(this.toDisabled) : false
|
||||||
|
}
|
||||||
|
get state(): boolean | undefined {
|
||||||
|
return this.refState && unref(this.refState)
|
||||||
|
}
|
||||||
|
set state(state: boolean) {
|
||||||
|
if (this.refState) this.refState.value = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonInputs<T> = Omit<ButtonBehavior & ButtonUI, 'shortcut'> & {
|
||||||
|
shortcut?: { humanReadable: string }
|
||||||
|
} & {
|
||||||
|
actionData?: T
|
||||||
|
} & (T extends void ? unknown
|
||||||
|
: {
|
||||||
|
actionData: T
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface StatefulInput {
|
||||||
|
state: Ref<boolean>
|
||||||
|
}
|
||||||
|
export interface Stateful {
|
||||||
|
state: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactiveButton<T = void>(
|
||||||
|
inputs: ButtonInputs<T> & StatefulInput,
|
||||||
|
): Button<T> & Stateful
|
||||||
|
export function reactiveButton<T = void>(inputs: ButtonInputs<T>): Button<T>
|
||||||
|
/** Creates a reactive {@link Button}. */
|
||||||
|
export function reactiveButton<T = void>(inputs: ButtonInputs<T>): Button<T> {
|
||||||
|
return new ReactiveButton<T>(
|
||||||
|
inputs.action,
|
||||||
|
inputs.icon,
|
||||||
|
inputs.shortcut?.humanReadable,
|
||||||
|
inputs.testid,
|
||||||
|
inputs.actionData as T,
|
||||||
|
inputs.description,
|
||||||
|
inputs.hidden,
|
||||||
|
inputs.disabled,
|
||||||
|
inputs.state,
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user