mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 02:11:29 +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].
|
||||
- [Visualizations on components are slightly transparent when not
|
||||
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].
|
||||
- [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].
|
||||
|
||||
[11151]: https://github.com/enso-org/enso/pull/11151
|
||||
@ -64,7 +66,7 @@
|
||||
[11612]: https://github.com/enso-org/enso/pull/11612
|
||||
[11620]: https://github.com/enso-org/enso/pull/11620
|
||||
[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
|
||||
|
||||
#### Enso Standard Library
|
||||
@ -90,8 +92,10 @@
|
||||
#### Enso Language & Runtime
|
||||
|
||||
- [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
|
||||
[11671]: https://github.com/enso-org/enso/pull/11671
|
||||
|
||||
# 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)
|
||||
}
|
||||
|
||||
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
|
||||
* that pass the given predicate.
|
||||
@ -179,6 +184,15 @@ export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
||||
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. */
|
||||
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
|
||||
for (const value of iter) {
|
||||
|
@ -197,3 +197,9 @@ export function useObjectId() {
|
||||
}
|
||||
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 * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'
|
||||
|
||||
@ -97,8 +98,7 @@ test('Conditional ports: Enabled', async ({ page }) => {
|
||||
const node = graphNodeByBinding(page, 'filtered')
|
||||
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
|
||||
|
||||
await page.keyboard.down('Meta')
|
||||
await page.keyboard.down('Control')
|
||||
await page.keyboard.down(CONTROL_KEY)
|
||||
|
||||
await expect(conditionalPort).toHaveClass(/enabled/)
|
||||
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
|
||||
@ -109,6 +109,5 @@ test('Conditional ports: Enabled', async ({ page }) => {
|
||||
await conditionalPort.click({ force: true })
|
||||
await expect(node.locator('.WidgetToken')).toHaveText(['final'])
|
||||
|
||||
await page.keyboard.up('Meta')
|
||||
await page.keyboard.up('Control')
|
||||
await page.keyboard.up(CONTROL_KEY)
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import test from 'playwright/test'
|
||||
import test, { type Locator, type Page } from 'playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
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)
|
||||
|
||||
// Check state before operation.
|
||||
@ -51,7 +67,10 @@ test('Copy node with comment', async ({ page }) => {
|
||||
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)
|
||||
|
||||
// Check state before operation.
|
||||
@ -61,13 +80,10 @@ test('Copy multiple nodes', async ({ page }) => {
|
||||
|
||||
// Select some nodes.
|
||||
const node1 = locate.graphNodeByBinding(page, 'final')
|
||||
await node1.click()
|
||||
const node2 = locate.graphNodeByBinding(page, 'prod')
|
||||
await node2.click({ modifiers: ['Shift'] })
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
|
||||
// Copy and paste.
|
||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||
await copyNodes(node1, node2)
|
||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||
await expect(node1).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, 'prod1')).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()
|
||||
})
|
||||
|
||||
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 }) => {
|
||||
await actions.goToGraph(page)
|
||||
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)
|
||||
})
|
||||
|
||||
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 }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
||||
|
@ -34,7 +34,7 @@ const FIXED_RANGE_WIDTH = 1 / 16
|
||||
|
||||
const selectedColor = defineModel<string | undefined>()
|
||||
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. */
|
||||
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">
|
||||
import { graphBindings, nodeEditBindings } from '@/bindings'
|
||||
import ColorRing from '@/components/ColorRing.vue'
|
||||
import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { injectComponentButtons } from '@/providers/componentButtons'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const nodeColor = defineModel<string | undefined>('nodeColor')
|
||||
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 componentButtons = injectComponentButtons()
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="CircularMenu"
|
||||
:class="{
|
||||
menu: !showColorPicker,
|
||||
menu: !componentButtons.pickColor.state,
|
||||
openedDropdown: isDropdownOpened,
|
||||
}"
|
||||
>
|
||||
<template v-if="!showColorPicker">
|
||||
<template v-if="!componentButtons.pickColor.state">
|
||||
<SvgButton
|
||||
name="eye"
|
||||
class="slotS"
|
||||
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
|
||||
v-model:open="isDropdownOpened"
|
||||
placement="bottom-start"
|
||||
@ -70,84 +42,17 @@ function readableBinding<T extends string, BS extends BindingSpace<T>>(
|
||||
class="slotW More"
|
||||
>
|
||||
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
||||
<template #entries>
|
||||
<MenuButton @click.stop="closeDropdown(), emit('toggleDocPanel')">
|
||||
<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 #menu>
|
||||
<ComponentContextMenu @close="isDropdownOpened = false" />
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
<ColorRing
|
||||
v-else
|
||||
v-model="nodeColor"
|
||||
:matchableColors="matchableNodeColors"
|
||||
v-model="componentButtons.pickColor.actionData.currentColor"
|
||||
:matchableColors="componentButtons.pickColor.actionData.matchableColors"
|
||||
:initialColorAngle="90"
|
||||
@close="showColorPicker = false"
|
||||
@close="componentButtons.pickColor.state = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -171,32 +76,6 @@ function readableBinding<T extends string, BS extends BindingSpace<T>>(
|
||||
--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 {
|
||||
> * {
|
||||
pointer-events: all;
|
||||
|
@ -2,10 +2,11 @@
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import SizeTransition from '@/components/SizeTransition.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useUnrefHTMLElement } from '@/composables/events'
|
||||
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { endOnClickOutside } from '@/util/autoBlur'
|
||||
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 props = defineProps<{
|
||||
@ -15,7 +16,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const rootElement = shallowRef<HTMLElement>()
|
||||
const floatElement = shallowRef<HTMLElement>()
|
||||
const menuPanel = useTemplateRef<Element | VueElement | undefined | null>('menuPanel')
|
||||
const floatElement = useUnrefHTMLElement(menuPanel)
|
||||
const hovered = ref(false)
|
||||
|
||||
injectInteractionHandler().setWhen(
|
||||
@ -49,9 +51,7 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
||||
class="arrow"
|
||||
/>
|
||||
<SizeTransition height :duration="100">
|
||||
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
|
||||
<slot name="entries" />
|
||||
</div>
|
||||
<slot v-if="open" ref="menuPanel" name="menu" :style="floatingStyles" />
|
||||
</SizeTransition>
|
||||
</div>
|
||||
</template>
|
||||
@ -63,18 +63,6 @@ const { floatingStyles } = useFloating(rootElement, floatElement, {
|
||||
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 {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { codeEditorBindings, documentationEditorBindings } from '@/bindings'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import MenuPanel from '@/components/MenuPanel.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { ref } from 'vue'
|
||||
@ -31,40 +32,47 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
||||
title="Additional Options"
|
||||
>
|
||||
<template #button><SvgIcon name="3_dot_menu" class="moreIcon" /></template>
|
||||
<template #entries>
|
||||
<div>
|
||||
<div class="nonInteractive"><SvgIcon name="zoom" class="rowIcon" />Zoom</div>
|
||||
<div class="zoomControl rightSide">
|
||||
<SvgButton
|
||||
class="zoomButton"
|
||||
name="minus"
|
||||
title="Decrease Zoom"
|
||||
@click="emit('zoomOut')"
|
||||
/>
|
||||
<span
|
||||
class="zoomScaleLabel"
|
||||
v-text="props.zoomLevel ? props.zoomLevel.toFixed(0) + '%' : '?'"
|
||||
></span>
|
||||
<SvgButton class="zoomButton" name="add" title="Increase Zoom" @click="emit('zoomIn')" />
|
||||
<div class="divider"></div>
|
||||
<SvgButton
|
||||
name="show_all"
|
||||
class="showAllIcon"
|
||||
title="Show All Components"
|
||||
@click="emit('fitToAllClicked')"
|
||||
/>
|
||||
<template #menu>
|
||||
<MenuPanel>
|
||||
<div>
|
||||
<div class="nonInteractive"><SvgIcon name="zoom" class="rowIcon" />Zoom</div>
|
||||
<div class="zoomControl rightSide">
|
||||
<SvgButton
|
||||
class="zoomButton"
|
||||
name="minus"
|
||||
title="Decrease Zoom"
|
||||
@click="emit('zoomOut')"
|
||||
/>
|
||||
<span
|
||||
class="zoomScaleLabel"
|
||||
v-text="props.zoomLevel ? props.zoomLevel.toFixed(0) + '%' : '?'"
|
||||
></span>
|
||||
<SvgButton
|
||||
class="zoomButton"
|
||||
name="add"
|
||||
title="Increase Zoom"
|
||||
@click="emit('zoomIn')"
|
||||
/>
|
||||
<div class="divider"></div>
|
||||
<SvgButton
|
||||
name="show_all"
|
||||
class="showAllIcon"
|
||||
title="Show All Components"
|
||||
@click="emit('fitToAllClicked')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MenuButton v-model="showCodeEditor" @click="open = false">
|
||||
<SvgIcon name="bottom_panel" class="rowIcon" />
|
||||
Code Editor
|
||||
<div class="rightSide" v-text="toggleCodeEditorShortcut" />
|
||||
</MenuButton>
|
||||
<MenuButton v-model="showDocumentationEditor" @click="open = false">
|
||||
<SvgIcon name="right_panel" class="rowIcon" />
|
||||
Documentation Editor
|
||||
<div class="rightSide" v-text="toggleDocumentationEditorShortcut" />
|
||||
</MenuButton>
|
||||
<MenuButton v-model="showCodeEditor" @click="open = false">
|
||||
<SvgIcon name="bottom_panel" class="rowIcon" />
|
||||
Code Editor
|
||||
<div class="rightSide" v-text="toggleCodeEditorShortcut" />
|
||||
</MenuButton>
|
||||
<MenuButton v-model="showDocumentationEditor" @click="open = false">
|
||||
<SvgIcon name="right_panel" class="rowIcon" />
|
||||
Documentation Editor
|
||||
<div class="rightSide" v-text="toggleDocumentationEditorShortcut" />
|
||||
</MenuButton>
|
||||
</MenuPanel>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@ -80,7 +88,7 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
:deep(.DropdownMenuContent) {
|
||||
.MenuPanel {
|
||||
width: 250px;
|
||||
margin-top: 2px;
|
||||
padding: 4px;
|
||||
|
@ -32,7 +32,7 @@ import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/co
|
||||
import { groupColorVar } from '@/composables/nodeColors'
|
||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
||||
import { provideFullscreenContext } from '@/providers/fullscreenContext'
|
||||
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideNodeColors } from '@/providers/graphNodeColors'
|
||||
@ -41,10 +41,12 @@ import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import { provideStackNavigator } from '@/providers/graphStackNavigator'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { provideKeyboard } from '@/providers/keyboard'
|
||||
import { provideSelectionButtons } from '@/providers/selectionButtons'
|
||||
import { injectVisibility } from '@/providers/visibility'
|
||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||
import type { NodeId } from '@/stores/graph'
|
||||
import type { Node, NodeId } from '@/stores/graph'
|
||||
import { provideGraphStore } from '@/stores/graph'
|
||||
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { provideNodeExecution } from '@/stores/project/nodeExecution'
|
||||
@ -87,7 +89,6 @@ const graphStore = provideGraphStore(projectStore, suggestionDb)
|
||||
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||
const _visualizationStore = provideVisualizationStore(projectStore)
|
||||
const visible = injectVisibility()
|
||||
provideFullscreenContext(rootNode)
|
||||
provideNodeExecution(projectStore)
|
||||
;(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),
|
||||
})
|
||||
|
||||
// === Exposed layers ===
|
||||
|
||||
const floatingLayer = ref<HTMLElement>()
|
||||
provideGraphEditorLayers({
|
||||
fullscreen: rootNode,
|
||||
floating: floatingLayer,
|
||||
})
|
||||
|
||||
// === Client saved state ===
|
||||
|
||||
const storedShowRightDock = ref()
|
||||
@ -232,6 +241,20 @@ const nodeSelection = provideGraphSelection(
|
||||
{
|
||||
isValid: (id) => graphStore.db.isNodeId(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 ===
|
||||
|
||||
const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(
|
||||
graphStore,
|
||||
toRef(nodeSelection, 'selected'),
|
||||
createNodes,
|
||||
const { copyNodesToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(createNodes)
|
||||
|
||||
// === Selection Buttons ===
|
||||
|
||||
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 ===
|
||||
@ -317,7 +353,7 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } })
|
||||
}
|
||||
},
|
||||
deleteSelected,
|
||||
deleteSelected: selectionButtons.deleteSelected.action!,
|
||||
zoomToSelected() {
|
||||
zoomToSelected()
|
||||
},
|
||||
@ -341,15 +377,11 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
}
|
||||
})
|
||||
},
|
||||
copyNode() {
|
||||
copySelectionToClipboard()
|
||||
},
|
||||
copyNode: selectionButtons.copy.action!,
|
||||
pasteNode() {
|
||||
createNodesFromClipboard()
|
||||
},
|
||||
collapse() {
|
||||
collapseNodes()
|
||||
},
|
||||
collapse: selectionButtons.collapse.action!,
|
||||
enterNode() {
|
||||
const selectedNode = set.first(nodeSelection.selected)
|
||||
if (selectedNode) {
|
||||
@ -360,7 +392,7 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
stackNavigator.exitNode()
|
||||
},
|
||||
changeColorSelectedNodes() {
|
||||
showColorPicker.value = true
|
||||
selectionButtons.pickColorMulti.state = true
|
||||
},
|
||||
openDocumentation() {
|
||||
const result = tryGetSelectionDocUrl()
|
||||
@ -392,10 +424,6 @@ const { handleClick } = useDoubleClick(
|
||||
},
|
||||
)
|
||||
|
||||
function deleteSelected() {
|
||||
graphStore.deleteNodes(nodeSelection.selected)
|
||||
}
|
||||
|
||||
// === Code Editor ===
|
||||
|
||||
const codeEditor = shallowRef<ComponentInstance<typeof CodeEditor>>()
|
||||
@ -610,11 +638,11 @@ function handleEdgeDrop(source: Ast.AstId, position: Vec2) {
|
||||
|
||||
// === Node Collapsing ===
|
||||
|
||||
function collapseNodes() {
|
||||
function collapseNodes(nodes: Node[]) {
|
||||
const selected = new Set(
|
||||
iter.filter(
|
||||
nodeSelection.selected,
|
||||
(id) => graphStore.db.nodeIdToNode.get(id)?.type === 'component',
|
||||
iter.map(
|
||||
iter.filter(nodes, ({ type }) => type === 'component'),
|
||||
nodeId,
|
||||
),
|
||||
)
|
||||
if (selected.size == 0) return
|
||||
@ -698,8 +726,6 @@ provideNodeColors(graphStore, (variable) =>
|
||||
viewportNode.value ? getComputedStyle(viewportNode.value).getPropertyValue(variable) : '',
|
||||
)
|
||||
|
||||
const showColorPicker = ref(false)
|
||||
|
||||
const groupColors = computed(() => {
|
||||
const styles: { [key: string]: string } = {}
|
||||
for (const group of suggestionDb.groups) {
|
||||
@ -747,23 +773,24 @@ const documentationEditorFullscreen = ref(false)
|
||||
</template>
|
||||
<TopBar
|
||||
v-model:recordMode="projectStore.recordMode"
|
||||
v-model:showColorPicker="showColorPicker"
|
||||
v-model:showCodeEditor="showCodeEditor"
|
||||
v-model:showDocumentationEditor="rightDockVisible"
|
||||
:zoomLevel="100.0 * graphNavigator.targetScale"
|
||||
:componentsSelected="nodeSelection.selected.size"
|
||||
:class="{ extraRightSpace: !rightDockVisible }"
|
||||
@fitToAllClicked="zoomToSelected"
|
||||
@zoomIn="graphNavigator.stepZoom(+1)"
|
||||
@zoomOut="graphNavigator.stepZoom(-1)"
|
||||
@collapseNodes="collapseNodes"
|
||||
@removeNodes="deleteSelected"
|
||||
/>
|
||||
<SceneScroller
|
||||
:navigator="graphNavigator"
|
||||
:scrollableArea="Rect.Bounding(...graphStore.visibleNodeAreas)"
|
||||
/>
|
||||
<GraphMouse />
|
||||
<div
|
||||
ref="floatingLayer"
|
||||
class="floatingLayer"
|
||||
:style="{ transform: graphNavigator.transform }"
|
||||
/>
|
||||
</div>
|
||||
<BottomPanel v-model:show="showCodeEditor">
|
||||
<Suspense>
|
||||
@ -837,4 +864,19 @@ const documentationEditorFullscreen = ref(false)
|
||||
--node-color-no-type: #596b81;
|
||||
--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>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<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 GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
|
||||
import GraphNodeMessage, {
|
||||
@ -16,10 +17,12 @@ import NodeWidgetTree, {
|
||||
GRAB_HANDLE_X_MARGIN_R,
|
||||
ICON_WIDTH,
|
||||
} from '@/components/GraphEditor/NodeWidgetTree.vue'
|
||||
import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
|
||||
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useDoubleClick } from '@/composables/doubleClick'
|
||||
import { usePointer, useResizeObserver } from '@/composables/events'
|
||||
import { provideComponentButtons } from '@/providers/componentButtons'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectNodeColors } from '@/providers/graphNodeColors'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
@ -28,7 +31,6 @@ import { useGraphStore, type Node } from '@/stores/graph'
|
||||
import { asNodeId } from '@/stores/graph/graphDatabase'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useNodeExecution } from '@/stores/project/nodeExecution'
|
||||
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import { prefixes } from '@/util/ast/node'
|
||||
@ -58,7 +60,6 @@ const emit = defineEmits<{
|
||||
dragging: [offset: Vec2]
|
||||
draggingCommited: []
|
||||
draggingCancelled: []
|
||||
delete: []
|
||||
replaceSelection: []
|
||||
outputPortClick: [event: PointerEvent, portId: AstId]
|
||||
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
||||
@ -216,17 +217,19 @@ watch(menuVisible, (visible) => {
|
||||
if (!visible) menuFull.value = false
|
||||
})
|
||||
|
||||
function openFullMenu() {
|
||||
menuFull.value = true
|
||||
setSelected()
|
||||
function setSoleSelected() {
|
||||
nodeSelection?.setSelection(new Set([nodeId.value]))
|
||||
}
|
||||
|
||||
function setSelected() {
|
||||
nodeSelection?.setSelection(new Set([nodeId.value]))
|
||||
function ensureSelected() {
|
||||
if (!nodeSelection?.isSelected(nodeId.value)) {
|
||||
setSoleSelected()
|
||||
}
|
||||
}
|
||||
|
||||
const outputHovered = ref(false)
|
||||
const keyboard = injectKeyboard()
|
||||
|
||||
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
|
||||
const visualizationHeight = computed(() => props.node.vis?.height ?? null)
|
||||
const isVisualizationEnabled = computed({
|
||||
@ -311,11 +314,7 @@ const isRecordingOverridden = computed({
|
||||
|
||||
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.node.innerExpr.externalId))
|
||||
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 documentationUrl = computed(
|
||||
() => suggestionEntry.value && suggestionDocumentationUrl(suggestionEntry.value),
|
||||
)
|
||||
|
||||
const nodeEditHandler = nodeEditBindings.handler({
|
||||
cancel(e) {
|
||||
@ -390,11 +389,6 @@ function updateVisualizationRect(rect: Rect | undefined) {
|
||||
emit('update:visualizationRect', rect)
|
||||
}
|
||||
|
||||
const editingComment = ref(false)
|
||||
|
||||
const { getNodeColor, getNodeColors } = injectNodeColors()
|
||||
const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
|
||||
|
||||
const graphSelectionSize = computed(() =>
|
||||
isVisualizationEnabled.value && visRect.value ? visRect.value.size : nodeSize.value,
|
||||
)
|
||||
@ -415,18 +409,71 @@ const dataSource = computed(
|
||||
|
||||
// === Recompute node expression ===
|
||||
|
||||
// The node is considered to be recomputing for at least this time.
|
||||
const MINIMAL_EXECUTION_TIMEOUT_MS = 500
|
||||
const recomputationTimeout = ref(false)
|
||||
const actualRecomputationStatus = nodeExecution.isBeingRecomputed(nodeId.value)
|
||||
const isBeingRecomputed = computed(
|
||||
() => recomputationTimeout.value || actualRecomputationStatus.value,
|
||||
)
|
||||
function recomputeOnce() {
|
||||
nodeExecution.recomputeOnce(nodeId.value, 'Live')
|
||||
recomputationTimeout.value = true
|
||||
setTimeout(() => (recomputationTimeout.value = false), MINIMAL_EXECUTION_TIMEOUT_MS)
|
||||
function useRecomputation() {
|
||||
// The node is considered to be recomputing for at least this time.
|
||||
const MINIMAL_EXECUTION_TIMEOUT_MS = 500
|
||||
const recomputationTimeout = ref(false)
|
||||
const actualRecomputationStatus = nodeExecution.isBeingRecomputed(nodeId.value)
|
||||
const isBeingRecomputed = computed(
|
||||
() => recomputationTimeout.value || actualRecomputationStatus.value,
|
||||
)
|
||||
function recomputeOnce() {
|
||||
nodeExecution.recomputeOnce(nodeId.value, 'Live')
|
||||
recomputationTimeout.value = true
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -456,6 +503,7 @@ function recomputeOnce() {
|
||||
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
|
||||
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
|
||||
@pointermove="updateNodeHover"
|
||||
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
|
||||
>
|
||||
<Teleport v-if="navigator && !edited && graphNodeSelections" :to="graphNodeSelections">
|
||||
<GraphNodeSelection
|
||||
@ -478,32 +526,15 @@ function recomputeOnce() {
|
||||
v-if="!menuVisible && isRecordingOverridden"
|
||||
class="overrideRecordButton clickable"
|
||||
data-testid="recordingOverriddenButton"
|
||||
@click="(isRecordingOverridden = false), setSelected()"
|
||||
@click="(isRecordingOverridden = false), setSoleSelected()"
|
||||
>
|
||||
<SvgIcon name="record" />
|
||||
</button>
|
||||
<ComponentMenu
|
||||
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"
|
||||
@pointerleave="menuHovered = false"
|
||||
@update:nodeColor="emit('setNodeColor', $event)"
|
||||
@createNewNode="setSelected(), emit('createNodes', [{ commit: false, content: undefined }])"
|
||||
@toggleDocPanel="emit('toggleDocPanel')"
|
||||
@click.capture="setSelected"
|
||||
@recompute="recomputeOnce"
|
||||
@click.capture="ensureSelected"
|
||||
/>
|
||||
<GraphVisualization
|
||||
v-if="isVisualizationVisible"
|
||||
@ -527,13 +558,13 @@ function recomputeOnce() {
|
||||
@update:height="emit('update:visualizationHeight', $event)"
|
||||
@update:nodePosition="graph.setNodePosition(nodeId, $event)"
|
||||
@createNodes="emit('createNodes', $event)"
|
||||
@click.capture="setSelected"
|
||||
@click.capture="setSoleSelected"
|
||||
/>
|
||||
<GraphNodeComment
|
||||
v-model:editing="editingComment"
|
||||
v-model:editing="editingComment.state"
|
||||
:node="node"
|
||||
class="beforeNode"
|
||||
@click.capture="setSelected"
|
||||
@click.capture="setSoleSelected"
|
||||
/>
|
||||
<div
|
||||
ref="contentNode"
|
||||
@ -551,7 +582,6 @@ function recomputeOnce() {
|
||||
:potentialSelfArgumentId="potentialSelfArgumentId"
|
||||
:conditionalPorts="props.node.conditionalPorts"
|
||||
:extended="isOnlyOneSelected"
|
||||
@openFullMenu="openFullMenu"
|
||||
/>
|
||||
</div>
|
||||
<div class="statuses">
|
||||
@ -582,9 +612,12 @@ function recomputeOnce() {
|
||||
<SmallPlusButton
|
||||
v-if="menuVisible"
|
||||
:class="isVisualizationVisible ? 'afterNode' : 'belowMenu'"
|
||||
@createNodes="setSelected(), emit('createNodes', $event)"
|
||||
@createNodes="setSoleSelected(), emit('createNodes', $event)"
|
||||
/>
|
||||
</div>
|
||||
<PointFloatingMenu v-if="showMenuAt" :point="showMenuAt" @close="showMenuAt = undefined">
|
||||
<ComponentContextMenu @close="showMenuAt = undefined" />
|
||||
</PointFloatingMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -69,7 +69,6 @@ const graphNodeSelections = shallowRef<HTMLElement>()
|
||||
:node="node"
|
||||
:edited="id === graphStore.editedNodeInfo?.id"
|
||||
:graphNodeSelections="graphNodeSelections"
|
||||
@delete="graphStore.deleteNodes([id])"
|
||||
@dragging="nodeIsDragged(id, $event)"
|
||||
@draggingCommited="dragging.finishDrag()"
|
||||
@draggingCancelled="dragging.cancelDrag()"
|
||||
|
@ -23,9 +23,6 @@ const props = defineProps<{
|
||||
conditionalPorts: Set<Ast.AstId>
|
||||
extended: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
openFullMenu: []
|
||||
}>()
|
||||
const graph = useGraphStore()
|
||||
const rootPort = computed(() => {
|
||||
const input = WidgetInput.FromAst(props.ast)
|
||||
@ -104,7 +101,6 @@ const widgetTree = provideWidgetTree(
|
||||
toRef(props, 'conditionalPorts'),
|
||||
toRef(props, 'extended'),
|
||||
layoutTransitions.active,
|
||||
() => emit('openFullMenu'),
|
||||
)
|
||||
|
||||
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
|
||||
|
@ -1,12 +1,11 @@
|
||||
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 { Pattern } from '@/util/ast/match'
|
||||
import { nodeDocumentationText } from '@/util/ast/node'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
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'
|
||||
|
||||
// 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. */
|
||||
export function useGraphEditorClipboard(
|
||||
graphStore: GraphStore,
|
||||
selected: ToValue<Set<NodeId>>,
|
||||
createNodes: (nodesOptions: Iterable<NodeCreationOptions>) => void,
|
||||
) {
|
||||
/** Copy the content of the selected node to the clipboard. */
|
||||
async function copySelectionToClipboard() {
|
||||
const nodes = new Array<Node>()
|
||||
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))
|
||||
/** Copy the content of the specified nodes to the clipboard, in the order provided. */
|
||||
async function copyNodesToClipboard(nodes: Node[]): Promise<void> {
|
||||
if (nodes.length) await writeClipboard(nodesToClipboardData(nodes))
|
||||
}
|
||||
|
||||
/** Read the clipboard and if it contains valid data, create nodes from the content. */
|
||||
@ -105,7 +94,7 @@ export function useGraphEditorClipboard(
|
||||
}
|
||||
|
||||
return {
|
||||
copySelectionToClipboard,
|
||||
copyNodesToClipboard,
|
||||
createNodesFromClipboard,
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import NodeWidget from '../NodeWidget.vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const tree = injectWidgetTree()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -33,11 +31,7 @@ export const widgetDefinition = defineWidget(
|
||||
|
||||
<template>
|
||||
<div class="WidgetIcon">
|
||||
<SvgIcon
|
||||
class="nodeCategoryIcon grab-handle"
|
||||
:name="props.input[DisplayIcon].icon"
|
||||
@click.right.stop.prevent="tree.emitOpenFullMenu()"
|
||||
/>
|
||||
<SvgIcon class="nodeCategoryIcon grab-handle" :name="props.input[DisplayIcon].icon" />
|
||||
<NodeWidget v-if="props.input[DisplayIcon].showContents === true" :input="props.input" />
|
||||
</div>
|
||||
</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 MenuButton from '@/components/MenuButton.vue'
|
||||
import MenuPanel from '@/components/MenuPanel.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import type { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import { ref } from 'vue'
|
||||
@ -31,18 +32,20 @@ const open = ref(false)
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template #entries>
|
||||
<MenuButton
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@click="open = false"
|
||||
>
|
||||
<SvgIcon :name="option.icon" :style="option.iconStyle" :data-testid="option.dataTestid" />
|
||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||
</MenuButton>
|
||||
<template #menu>
|
||||
<MenuPanel>
|
||||
<MenuButton
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@click="open = false"
|
||||
>
|
||||
<SvgIcon :name="option.icon" :style="option.iconStyle" :data-testid="option.dataTestid" />
|
||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||
</MenuButton>
|
||||
</MenuPanel>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
@ -2,6 +2,7 @@
|
||||
/** @file A dropdown menu supporting the pattern of selecting a single entry from a list. */
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import MenuPanel from '@/components/MenuPanel.vue'
|
||||
import { TextSelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@ -25,17 +26,19 @@ const open = ref(false)
|
||||
<div v-if="options[selected]?.label" class="iconLabel" v-text="options[selected]?.label" />
|
||||
</template>
|
||||
</template>
|
||||
<template #entries>
|
||||
<MenuButton
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@click="open = false"
|
||||
>
|
||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||
</MenuButton>
|
||||
<template #menu>
|
||||
<MenuPanel>
|
||||
<MenuButton
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@click="open = false"
|
||||
>
|
||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||
</MenuButton>
|
||||
</MenuPanel>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
@ -1,35 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import ColorPickerMenu from '@/components/ColorPickerMenu.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import SvgButton from './SvgButton.vue'
|
||||
import SelectionButton from '@/components/SelectionButton.vue'
|
||||
import { injectSelectionButtons } from '@/providers/selectionButtons'
|
||||
|
||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
||||
const _props = defineProps<{ selectedComponents: number }>()
|
||||
const emit = defineEmits<{
|
||||
collapseNodes: []
|
||||
removeNodes: []
|
||||
}>()
|
||||
const { selectedNodeCount, buttons } = injectSelectionButtons()
|
||||
const { pickColorMulti } = buttons
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="SelectionMenu">
|
||||
<span
|
||||
v-text="`${selectedComponents} component${selectedComponents === 1 ? '' : 's'} selected`"
|
||||
/>
|
||||
<SvgButton name="group" title="Group Selected Components" @click.stop="emit('collapseNodes')" />
|
||||
<ToggleIcon
|
||||
v-model="showColorPicker"
|
||||
title="Color Selected Components"
|
||||
icon="paint_palette"
|
||||
:class="{
|
||||
// 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.
|
||||
disableInput: showColorPicker,
|
||||
}"
|
||||
/>
|
||||
<SvgButton name="trash" title="Delete Selected Components" @click.stop="emit('removeNodes')" />
|
||||
<ColorPickerMenu v-if="showColorPicker" class="submenu" @close="showColorPicker = false" />
|
||||
</div>
|
||||
<Transition>
|
||||
<div v-if="selectedNodeCount > 1" class="SelectionMenu">
|
||||
<span v-text="`${selectedNodeCount} components selected`" />
|
||||
<SelectionButton button="collapse" />
|
||||
<SelectionButton
|
||||
button="pickColorMulti"
|
||||
:class="{
|
||||
// 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.
|
||||
disableInput: pickColorMulti.state,
|
||||
}"
|
||||
/>
|
||||
<SelectionButton button="copy" />
|
||||
<SelectionButton button="deleteSelected" />
|
||||
<ColorPickerMenu
|
||||
v-if="pickColorMulti.state"
|
||||
class="submenu"
|
||||
@close="pickColorMulti.state = false"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -41,10 +40,7 @@ const emit = defineEmits<{
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
@ -63,4 +59,14 @@ const emit = defineEmits<{
|
||||
.disableInput {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -7,7 +7,7 @@ import type { Icon } from '@/util/iconName'
|
||||
const _props = defineProps<{
|
||||
name?: Icon | URLString | undefined
|
||||
label?: string | undefined
|
||||
disabled?: boolean
|
||||
disabled?: boolean | undefined
|
||||
title?: string | undefined
|
||||
}>()
|
||||
</script>
|
||||
|
@ -5,19 +5,15 @@ import RecordControl from '@/components/RecordControl.vue'
|
||||
import SelectionMenu from '@/components/SelectionMenu.vue'
|
||||
import UndoRedoButtons from './UndoRedoButtons.vue'
|
||||
|
||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
||||
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
|
||||
const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true })
|
||||
const props = defineProps<{
|
||||
zoomLevel: number
|
||||
componentsSelected: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
fitToAllClicked: []
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
collapseNodes: []
|
||||
removeNodes: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@ -26,15 +22,7 @@ const emit = defineEmits<{
|
||||
<NavBreadcrumbs />
|
||||
<RecordControl />
|
||||
<UndoRedoButtons />
|
||||
<Transition name="selection-menu">
|
||||
<SelectionMenu
|
||||
v-if="componentsSelected > 1"
|
||||
v-model:showColorPicker="showColorPicker"
|
||||
:selectedComponents="componentsSelected"
|
||||
@collapseNodes="emit('collapseNodes')"
|
||||
@removeNodes="emit('removeNodes')"
|
||||
/>
|
||||
</Transition>
|
||||
<SelectionMenu />
|
||||
<ExtendedMenu
|
||||
v-model:showCodeEditor="showCodeEditor"
|
||||
v-model:showDocumentationEditor="showDocumentationEditor"
|
||||
@ -64,14 +52,4 @@ const emit = defineEmits<{
|
||||
.TopBar.extraRightSpace {
|
||||
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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/** @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 { computed, ref, toRef, watch } from 'vue'
|
||||
|
||||
@ -41,7 +41,7 @@ const emit = defineEmits<{
|
||||
|
||||
const content = ref<HTMLElement>()
|
||||
|
||||
const { fullscreenContainer } = useFullscreenContext()
|
||||
const { fullscreen: fullscreenContainer } = useGraphEditorLayers()
|
||||
|
||||
const fullscreenSize: Keyframe = {
|
||||
top: 0,
|
||||
|
@ -114,6 +114,7 @@ defineExpose({
|
||||
:style="inputStyle"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
@keydown.arrow-left.stop
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
toValue,
|
||||
watch,
|
||||
watchEffect,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
type ShallowRef,
|
||||
type WatchSource,
|
||||
@ -184,6 +185,16 @@ export function unrefElement(
|
||||
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 {
|
||||
refCount: number
|
||||
boundRectUsers: number
|
||||
|
@ -16,6 +16,7 @@ import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { identity } from '@vueuse/core'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { nextTick, toValue } from 'vue'
|
||||
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[] {
|
||||
const rects = new Array<Rect>()
|
||||
const { place } = usePlacement(rects, viewport)
|
||||
|
@ -7,6 +7,7 @@ import type { Rect } from '@/util/data/rect'
|
||||
import { intersectionSize } from '@/util/data/set'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { dataAttribute, elementHierarchy } from '@/util/dom'
|
||||
import { identity } from '@vueuse/core'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import * as set from 'lib0/set'
|
||||
import { computed, ref, shallowReactive, shallowRef } from 'vue'
|
||||
@ -18,6 +19,7 @@ interface BaseSelectionOptions<T> {
|
||||
isValid?: (element: T) => boolean
|
||||
onSelected?: (element: T) => void
|
||||
onDeselected?: (element: T) => void
|
||||
toSorted?: (elements: Iterable<T>) => Iterable<T>
|
||||
}
|
||||
interface SelectionPackingOptions<T, PackedT> {
|
||||
/**
|
||||
@ -57,6 +59,7 @@ export function useSelection<T, PackedT>(
|
||||
isValid: () => true,
|
||||
onSelected: () => {},
|
||||
onDeselected: () => {},
|
||||
toSorted: identity,
|
||||
}
|
||||
const PACKING_DEFAULTS: SelectionPackingOptions<T, T> = {
|
||||
pack: (element: T) => element,
|
||||
@ -76,7 +79,7 @@ type UseSelection<T, PackedT> = ReturnType<typeof useSelectionImpl<T, PackedT>>
|
||||
function useSelectionImpl<T, PackedT>(
|
||||
navigator: NavigatorComposable,
|
||||
elementRects: Map<T, Rect>,
|
||||
{ margin, isValid, onSelected, onDeselected }: Required<BaseSelectionOptions<T>>,
|
||||
{ margin, isValid, onSelected, onDeselected, toSorted }: Required<BaseSelectionOptions<T>>,
|
||||
{ pack, unpack }: SelectionPackingOptions<T, PackedT>,
|
||||
) {
|
||||
const anchor = shallowRef<Vec2>()
|
||||
@ -88,7 +91,9 @@ function useSelectionImpl<T, PackedT>(
|
||||
const unpackedRawSelected = computed(() =>
|
||||
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 committedSelection = computed(() =>
|
||||
isChanging.value ? set.from(iter.filter(initiallySelected, isValid)) : selected.value,
|
||||
@ -224,6 +229,7 @@ function useSelectionImpl<T, PackedT>(
|
||||
|
||||
return {
|
||||
// === Selected nodes ===
|
||||
/** The valid currently-selected elements, in the order defined by `toSorted`, if provided. */
|
||||
selected,
|
||||
selectAll: () => {
|
||||
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>>,
|
||||
extended: Ref<boolean>,
|
||||
hasActiveAnimations: Ref<boolean>,
|
||||
emitOpenFullMenu: () => void,
|
||||
) => {
|
||||
const graph = useGraphStore()
|
||||
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
|
||||
@ -35,7 +34,6 @@ const { provideFn, injectFn } = createContextStore(
|
||||
hasActiveAnimations,
|
||||
setCurrentEditRoot,
|
||||
currentEdit,
|
||||
emitOpenFullMenu,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -360,7 +360,7 @@ export class GraphDb {
|
||||
prefixes,
|
||||
conditionalPorts,
|
||||
argIndex,
|
||||
} satisfies NodeDataFromAst
|
||||
} satisfies AllNodeFieldsFromAst
|
||||
}
|
||||
}
|
||||
|
||||
@ -514,7 +514,14 @@ export function nodeIdFromOuterAst(outerAst: Ast.Statement | Ast.Expression) {
|
||||
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
|
||||
/**
|
||||
* The statement or top-level expression.
|
||||
@ -540,7 +547,7 @@ export interface NodeDataFromAst {
|
||||
*/
|
||||
innerExpr: Ast.Expression
|
||||
/**
|
||||
Prefixes that are present in `rootExpr` but omitted in `innerExpr` to ensure a clean output.
|
||||
Prefixes that are present in `rootExpr` but omitted in `innerExpr` to ensure a clean output.
|
||||
*/
|
||||
prefixes: Record<'enableRecording', Ast.AstId[] | undefined>
|
||||
/** A child AST in a syntactic position to be a self-argument input to the node. */
|
||||
@ -551,12 +558,34 @@ export interface NodeDataFromAst {
|
||||
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 {
|
||||
position: Vec2
|
||||
vis: Opt<VisualizationMetadata>
|
||||
colorOverride: Opt<string>
|
||||
}
|
||||
|
||||
export interface Node extends NodeDataFromAst, NodeDataFromMetadata {
|
||||
zIndex: number
|
||||
}
|
||||
export type Node = NodeDataFromAst &
|
||||
NodeDataFromMetadata & {
|
||||
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. */
|
||||
function pickInCodeOrder(ids: Set<NodeId>): NodeId[] {
|
||||
if (ids.size === 0) return []
|
||||
assert(syncModule.value != null)
|
||||
const func = unwrap(getExecutedMethodAst(syncModule.value))
|
||||
const body = func.bodyExpressions()
|
||||
|
@ -44,10 +44,9 @@ export function nodeFromAst(ast: Ast.Statement, isOutput: boolean): NodeDataFrom
|
||||
const { root, assignment } = nodeRootExpr(ast)
|
||||
if (!root) return
|
||||
const { innerExpr, matches } = prefixes.extractMatches(root)
|
||||
const type = assignment == null && isOutput ? 'output' : 'component'
|
||||
const primaryApplication = primaryApplicationSubject(innerExpr)
|
||||
return {
|
||||
type,
|
||||
type: assignment == null && isOutput ? 'output' : 'component',
|
||||
outerAst: ast,
|
||||
pattern: assignment?.pattern,
|
||||
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