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:
Kaz Wesley 2024-11-29 11:52:22 -08:00 committed by GitHub
parent 52feef89ab
commit 0b6b1f0954
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1031 additions and 422 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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)
})

View File

@ -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()
})
})

View File

@ -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

View File

@ -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)

View File

@ -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
}>()

View 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>

View 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>

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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()"

View File

@ -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))

View File

@ -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,
}
}

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -114,6 +114,7 @@ defineExpose({
:style="inputStyle"
@pointerdown.stop
@click.stop
@contextmenu.stop
@keydown.backspace.stop
@keydown.delete.stop
@keydown.arrow-left.stop

View File

@ -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

View File

@ -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)

View File

@ -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()) {

View 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)

View File

@ -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,
}),
)

View 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>,
)

View 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 },
}
}

View File

@ -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,
})
},
)

View File

@ -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
}

View File

@ -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()

View File

@ -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,

View 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,
)
}