diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e833861a..095fecd42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/common/src/utilities/data/iter.ts b/app/common/src/utilities/data/iter.ts index aaaa66742e..d8b44cd541 100644 --- a/app/common/src/utilities/data/iter.ts +++ b/app/common/src/utilities/data/iter.ts @@ -57,6 +57,11 @@ export function map(it: Iterable, f: (value: T) => U): IterableIterator return mapIterator(it[Symbol.iterator](), f) } +export function filter( + iter: Iterable, + include: (value: T) => value is S, +): IterableIterator +export function filter(iter: Iterable, include: (value: T) => boolean): IterableIterator /** * 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(iter: Iterable, 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(iter: Iterable, 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(iter: Iterable, f: (value: T) => boolean): T | undefined { for (const value of iter) { diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 352b2c5f5e..8dd1679e82 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -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 = keyof A & keyof B extends never ? A & B : never diff --git a/app/gui/integration-test/project-view/edgeInteractions.spec.ts b/app/gui/integration-test/project-view/edgeInteractions.spec.ts index b04ecec146..574e68db95 100644 --- a/app/gui/integration-test/project-view/edgeInteractions.spec.ts +++ b/app/gui/integration-test/project-view/edgeInteractions.spec.ts @@ -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) }) diff --git a/app/gui/integration-test/project-view/nodeClipboard.spec.ts b/app/gui/integration-test/project-view/nodeClipboard.spec.ts index 92d9b4248b..92fafae9e8 100644 --- a/app/gui/integration-test/project-view/nodeClipboard.spec.ts +++ b/app/gui/integration-test/project-view/nodeClipboard.spec.ts @@ -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, +) { 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() + }) }) diff --git a/app/gui/integration-test/project-view/nodeComments.spec.ts b/app/gui/integration-test/project-view/nodeComments.spec.ts index e2835086de..c814159e3f 100644 --- a/app/gui/integration-test/project-view/nodeComments.spec.ts +++ b/app/gui/integration-test/project-view/nodeComments.spec.ts @@ -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 diff --git a/app/gui/integration-test/project-view/removingNodes.spec.ts b/app/gui/integration-test/project-view/removingNodes.spec.ts index 7ba843d707..8c95af552c 100644 --- a/app/gui/integration-test/project-view/removingNodes.spec.ts +++ b/app/gui/integration-test/project-view/removingNodes.spec.ts @@ -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) diff --git a/app/gui/src/project-view/components/ColorRing.vue b/app/gui/src/project-view/components/ColorRing.vue index e46a944dc2..276b13dc22 100644 --- a/app/gui/src/project-view/components/ColorRing.vue +++ b/app/gui/src/project-view/components/ColorRing.vue @@ -34,7 +34,7 @@ const FIXED_RANGE_WIDTH = 1 / 16 const selectedColor = defineModel() const props = defineProps<{ - matchableColors: Set + matchableColors: ReadonlySet /** Angle, measured in degrees from the positive Y-axis, where the initially-selected color should be placed. */ initialColorAngle?: number }>() diff --git a/app/gui/src/project-view/components/ComponentButton.vue b/app/gui/src/project-view/components/ComponentButton.vue new file mode 100644 index 0000000000..76e443bdba --- /dev/null +++ b/app/gui/src/project-view/components/ComponentButton.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/app/gui/src/project-view/components/ComponentContextMenu.vue b/app/gui/src/project-view/components/ComponentContextMenu.vue new file mode 100644 index 0000000000..430b0d6e22 --- /dev/null +++ b/app/gui/src/project-view/components/ComponentContextMenu.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/gui/src/project-view/components/ComponentMenu.vue b/app/gui/src/project-view/components/ComponentMenu.vue index 2eb1f4d176..8911369d59 100644 --- a/app/gui/src/project-view/components/ComponentMenu.vue +++ b/app/gui/src/project-view/components/ComponentMenu.vue @@ -1,67 +1,39 @@ +
@@ -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; + } +} diff --git a/app/gui/src/project-view/components/GraphEditor/GraphNode.vue b/app/gui/src/project-view/components/GraphEditor/GraphNode.vue index 5d0e7204b6..c628a4cef6 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphNode.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphNode.vue @@ -1,5 +1,6 @@ diff --git a/app/gui/src/project-view/components/PointFloatingMenu.vue b/app/gui/src/project-view/components/PointFloatingMenu.vue new file mode 100644 index 0000000000..8ec0925827 --- /dev/null +++ b/app/gui/src/project-view/components/PointFloatingMenu.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app/gui/src/project-view/components/SelectionButton.vue b/app/gui/src/project-view/components/SelectionButton.vue new file mode 100644 index 0000000000..49ec8ba68f --- /dev/null +++ b/app/gui/src/project-view/components/SelectionButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/gui/src/project-view/components/SelectionDropdown.vue b/app/gui/src/project-view/components/SelectionDropdown.vue index a0e8b7697a..aecc8e5115 100644 --- a/app/gui/src/project-view/components/SelectionDropdown.vue +++ b/app/gui/src/project-view/components/SelectionDropdown.vue @@ -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) /> - diff --git a/app/gui/src/project-view/components/SelectionDropdownText.vue b/app/gui/src/project-view/components/SelectionDropdownText.vue index 6af6efddc8..72b6f791b8 100644 --- a/app/gui/src/project-view/components/SelectionDropdownText.vue +++ b/app/gui/src/project-view/components/SelectionDropdownText.vue @@ -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)
- diff --git a/app/gui/src/project-view/components/SelectionMenu.vue b/app/gui/src/project-view/components/SelectionMenu.vue index e83c2eded2..62f9c16cc3 100644 --- a/app/gui/src/project-view/components/SelectionMenu.vue +++ b/app/gui/src/project-view/components/SelectionMenu.vue @@ -1,35 +1,34 @@ diff --git a/app/gui/src/project-view/components/SvgButton.vue b/app/gui/src/project-view/components/SvgButton.vue index 068b3f758e..ff4dd237ba 100644 --- a/app/gui/src/project-view/components/SvgButton.vue +++ b/app/gui/src/project-view/components/SvgButton.vue @@ -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 }>() diff --git a/app/gui/src/project-view/components/TopBar.vue b/app/gui/src/project-view/components/TopBar.vue index f2780515c1..e053142733 100644 --- a/app/gui/src/project-view/components/TopBar.vue +++ b/app/gui/src/project-view/components/TopBar.vue @@ -5,19 +5,15 @@ import RecordControl from '@/components/RecordControl.vue' import SelectionMenu from '@/components/SelectionMenu.vue' import UndoRedoButtons from './UndoRedoButtons.vue' -const showColorPicker = defineModel('showColorPicker', { required: true }) const showCodeEditor = defineModel('showCodeEditor', { required: true }) const showDocumentationEditor = defineModel('showDocumentationEditor', { required: true }) const props = defineProps<{ zoomLevel: number - componentsSelected: number }>() const emit = defineEmits<{ fitToAllClicked: [] zoomIn: [] zoomOut: [] - collapseNodes: [] - removeNodes: [] }>() @@ -26,15 +22,7 @@ const emit = defineEmits<{ - - - + diff --git a/app/gui/src/project-view/components/WithFullscreenMode.vue b/app/gui/src/project-view/components/WithFullscreenMode.vue index b9eb1490f8..c727fc7635 100644 --- a/app/gui/src/project-view/components/WithFullscreenMode.vue +++ b/app/gui/src/project-view/components/WithFullscreenMode.vue @@ -1,7 +1,7 @@