Store graph viewport in client local storage (#9651)

Fixes #6250

With this change, I've also slightly refactored the graph editor component by grouping related functionality into neat block and moving already loosely coupled groups to separate files. Further work will be needed to simplify it, but it is a good first step.

https://github.com/enso-org/enso/assets/919491/fedce111-ea79-463f-a543-da3ecce28bf5
This commit is contained in:
Paweł Grabarz 2024-04-09 14:02:11 +02:00 committed by GitHub
parent 5650c7aed2
commit 4bf79776c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 550 additions and 397 deletions

View File

@ -1,5 +1,5 @@
import { Server } from '@open-rpc/server-js'
import * as random from 'lib0/random.js'
import * as random from 'lib0/random'
import {
methods as pmMethods,
projects,

View File

@ -1,7 +1,6 @@
import test, { type Locator, type Page } from 'playwright/test'
import test from 'playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { mockMethodCallInfo } from './expressionUpdates'
import * as locate from './locate'
test('Adding new node', async ({ page }) => {

View File

@ -9,7 +9,6 @@ import { MockTransport, MockWebSocket } from '@/util/net'
import { getActivePinia } from 'pinia'
import { ref, type App } from 'vue'
import { mockDataHandler, mockLSHandler } from './engine'
export * as providers from './providers'
export * as vue from './vue'
export function languageServer() {

View File

@ -1,59 +0,0 @@
import type { GraphSelection } from '@/providers/graphSelection'
import type { GraphNavigator } from '../src/providers/graphNavigator'
import { Rect } from '../src/util/data/rect'
import { Vec2 } from '../src/util/data/vec2'
export const graphNavigator: GraphNavigator = {
events: {} as any,
clientToScenePos: () => Vec2.Zero,
clientToSceneRect: () => Rect.Zero,
panAndZoomTo: () => {},
panTo: () => {},
scrollTo: () => {},
stepZoom: () => {},
transform: '',
prescaledTransform: '',
translate: Vec2.Zero,
targetScale: 1,
scale: 1,
sceneMousePos: Vec2.Zero,
viewBox: '',
viewport: Rect.Zero,
}
export function graphNavigatorWith(modifications?: Partial<GraphNavigator>): GraphNavigator {
return Object.assign({}, graphNavigator, modifications)
}
export const graphSelection: GraphSelection = {
events: {} as any,
anchor: undefined,
deselectAll: () => {},
handleSelectionOf: () => {},
setSelection: () => {},
hoveredNode: undefined,
hoveredPort: undefined,
isSelected: () => false,
isChanging: false,
mouseHandler: () => false,
selectAll: () => {},
selected: new Set(),
}
export function graphSelectionWith(modifications?: Partial<GraphSelection>): GraphSelection {
return Object.assign({}, graphSelection, modifications)
}
export const all = {
'graph navigator': graphNavigator,
'graph selection': graphSelection,
}
export function allWith(
modifications: Partial<{ [K in keyof typeof all]: Partial<(typeof all)[K]> }>,
): typeof all {
return {
'graph navigator': graphNavigatorWith(modifications['graph navigator']),
'graph selection': graphSelectionWith(modifications['graph selection']),
}
}

View File

@ -1,10 +1,11 @@
import { createXXHash128 } from 'hash-wasm'
import type { IDataType } from 'hash-wasm/dist/lib/util'
import init, { is_ident_or_operator, parse, parse_doc_to_json } from '../../rust-ffi/pkg/rust_ffi'
import { assertDefined } from '../util/assert'
import { isNode } from '../util/detect'
let xxHasher128: Awaited<ReturnType<typeof createXXHash128>> | undefined
export function xxHash128(input: string) {
export function xxHash128(input: IDataType) {
assertDefined(xxHasher128, 'Module should have been loaded with `initializeFFI`.')
xxHasher128.init()
xxHasher128.update(input)

View File

@ -21,7 +21,6 @@ import type {
VisualizationConfiguration,
response,
} from './languageServerTypes'
import type { AbortScope } from './util/net'
import type { Uuid } from './yjsModel'
const DEBUG_LOG_RPC = false

View File

@ -1,3 +1,4 @@
import * as encoding from 'lib0/encoding'
import type {
SuggestionsDatabaseEntry,
SuggestionsDatabaseUpdate,
@ -363,6 +364,12 @@ export interface LocalCall {
expressionId: ExpressionId
}
export function encodeMethodPointer(enc: encoding.Encoder, ptr: MethodPointer) {
encoding.writeVarString(enc, ptr.module)
encoding.writeVarString(enc, ptr.name)
encoding.writeVarString(enc, ptr.definedOnType)
}
export function stackItemsEqual(left: StackItem, right: StackItem): boolean {
if (left.type !== right.type) return false

View File

@ -12,6 +12,7 @@ import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import { useGraphEditorToasts } from '@/components/GraphEditor/toasts'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import GraphMouse from '@/components/GraphMouse.vue'
import PlusButton from '@/components/PlusButton.vue'
@ -19,6 +20,7 @@ import SceneScroller from '@/components/SceneScroller.vue'
import TopBar from '@/components/TopBar.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
import { useNavigatorStorage } from '@/composables/navigatorStorage'
import { useStackNavigator } from '@/composables/stackNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection'
@ -30,57 +32,64 @@ import type { RequiredImport } from '@/stores/graph/imports'
import { useProjectStore } from '@/stores/project'
import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { assertNever, bail } from '@/util/assert'
import type { AstId, NodeMetadataFields } from '@/util/ast/abstract'
import type { AstId } from '@/util/ast/abstract'
import type { Pattern } from '@/util/ast/match'
import { colorFromString } from '@/util/colors'
import { partition } from '@/util/data/array'
import { filterDefined } from '@/util/data/iterable'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { useToast } from '@/util/toast'
import * as set from 'lib0/set'
import { encoding, set } from 'lib0'
import { encodeMethodPointer } from 'shared/languageServerTypes'
import { computed, onMounted, ref, toRef, watch } from 'vue'
import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/services/ProjectManager'
import { type Usage } from './ComponentBrowser/input'
import { useGraphEditorClipboard } from './GraphEditor/clipboard'
const keyboard = provideKeyboard()
const viewportNode = ref<HTMLElement>()
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
const graphStore = useGraphStore()
const widgetRegistry = provideWidgetRegistry(graphStore.db)
widgetRegistry.loadBuiltins()
const projectStore = useProjectStore()
const componentBrowserVisible = ref(false)
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
// === toasts ===
// === Navigator ===
const toastStartup = useToast.info({ autoClose: false })
const toastConnectionLost = useToast.error({ autoClose: false })
const toastLspError = useToast.error()
const toastConnectionError = useToast.error()
const toastExecutionFailed = useToast.error()
const viewportNode = ref<HTMLElement>()
onMounted(() => viewportNode.value?.focus())
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
useNavigatorStorage(graphNavigator, (enc) => {
// Navigator viewport needs to be stored separately for:
// - each project
// - each function within the project
encoding.writeVarString(enc, projectStore.name)
const methodPtr = graphStore.currentMethodPointer()
if (methodPtr != null) encodeMethodPointer(enc, methodPtr)
})
toastStartup.show('Initializing the project. This can take up to one minute.')
projectStore.firstExecution.then(toastStartup.dismiss)
function zoomToSelected() {
if (!viewportNode.value) return
useEvent(document, ProjectManagerEvents.loadingFailed, () =>
toastConnectionLost.show('Lost connection to Language Server.'),
)
const allNodes = graphStore.db.nodeIdToNode
const validSelected = [...nodeSelection.selected].filter((id) => allNodes.has(id))
const nodesToCenter = validSelected.length === 0 ? allNodes.keys() : validSelected
let bounds = Rect.Bounding()
for (const id of nodesToCenter) {
const rect = graphStore.visibleArea(id)
if (rect) bounds = Rect.Bounding(bounds, rect)
}
if (bounds.isFinite())
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale))
}
projectStore.lsRpcConnection.then(
(ls) => ls.client.onError((e) => toastLspError.show(`Language server error: ${e}`)),
(e) => toastConnectionError.show(`Connection to language server failed: ${JSON.stringify(e)}`),
)
// == Breadcrumbs ==
projectStore.executionContext.on('executionComplete', () => toastExecutionFailed.dismiss())
projectStore.executionContext.on('executionFailed', (e) =>
toastExecutionFailed.show(`Execution Failed: ${JSON.stringify(e)}`),
)
const stackNavigator = useStackNavigator()
// === nodes ===
// === Toasts ===
useGraphEditorToasts()
// === Selection ===
const nodeSelection = provideGraphSelection(
graphNavigator,
@ -93,6 +102,22 @@ const nodeSelection = provideGraphSelection(
},
)
// Clear selection whenever the graph view is switched.
watch(
() => projectStore.executionContext.getStackTop(),
() => nodeSelection.deselectAll(),
)
// === Clipboard Copy/Paste ===
const { copyNodeContent, readNodeFromClipboard } = useGraphEditorClipboard(
nodeSelection,
graphNavigator,
)
// === Interactions ===
const interaction = provideInteractionHandler()
const interactionBindingsHandler = interactionBindings.handler({
cancel: () => interaction.handleCancel(),
})
@ -111,19 +136,7 @@ useEvent(window, 'pointerdown', (e) => interaction.handleClick(e, graphNavigator
capture: true,
})
onMounted(() => viewportNode.value?.focus())
function zoomToSelected() {
if (!viewportNode.value) return
const nodesToCenter =
nodeSelection.selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : nodeSelection.selected
let bounds = Rect.Bounding()
for (const id of nodesToCenter) {
const rect = graphStore.visibleArea(id)
if (rect) bounds = Rect.Bounding(bounds, rect)
}
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.scale))
}
// === Keyboard/Mouse bindings ===
const graphBindingsHandler = graphBindings.handler({
undo() {
@ -256,6 +269,9 @@ const { handleClick } = useDoubleClick(
stackNavigator.exitNode()
},
)
// === Code Editor ===
const codeEditorArea = ref<HTMLElement>()
const showCodeEditor = ref(false)
const toggleCodeEditor = () => {
@ -267,6 +283,8 @@ const codeEditorHandler = codeEditorBindings.handler({
},
})
// === Execution Mode ===
/** Handle record-once button presses. */
function onRecordOnceButtonPress() {
projectStore.lsRpcConnection.then(async () => {
@ -286,13 +304,11 @@ watch(
},
)
const groupColors = computed(() => {
const styles: { [key: string]: string } = {}
for (let group of suggestionDb.groups) {
styles[groupColorVar(group)] = group.color ?? colorFromString(group.name)
}
return styles
})
// === Component Browser ===
const componentBrowserVisible = ref(false)
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserUsage.value = usage
@ -300,6 +316,11 @@ function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserVisible.value = true
}
function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false
}
function editWithComponentBrowser(node: NodeId, cursorPos: number) {
openComponentBrowser(
{ type: 'editNode', node, cursorPos },
@ -307,6 +328,68 @@ function editWithComponentBrowser(node: NodeId, cursorPos: number) {
)
}
function createWithComponentBrowser(options: NewNodeOptions) {
openComponentBrowser(
{ type: 'newNode', sourcePort: options.sourcePort },
placeNode(options.placement),
)
}
function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) {
if (content != null) {
if (graphStore.editedNodeInfo) {
// We finish editing a node.
graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports)
} else if (content != '') {
// We finish creating a new node.
const metadata = undefined
const createdNode = graphStore.createNode(
componentBrowserNodePosition.value,
content,
metadata,
requiredImports,
)
if (createdNode) nodeSelection.setSelection(new Set([createdNode]))
}
}
hideComponentBrowser()
}
// Watch the `editedNode` in the graph store and synchronize component browser display with it.
watch(
() => graphStore.editedNodeInfo,
(editedInfo) => {
if (editedInfo) {
editWithComponentBrowser(editedInfo.id, editedInfo.initialCursorPos)
} else {
hideComponentBrowser()
}
},
)
// === Node Creation ===
interface NewNodeOptions {
placement: PlacementType
sourcePort?: AstId | undefined
}
type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2]
const placeNode = (placement: PlacementType): Vec2 =>
placement === 'viewport' ? nodePlacement().position
: placement[0] === 'source' ?
nodePlacement(filterDefined([graphStore.visibleArea(placement[1])])).position
: placement[0] === 'fixed' ? placement[1]
: assertNever(placement)
/**
* Start creating a node, basing its inputs and position on the current selection, if any;
* or the current viewport, otherwise.
*/
function addNodeAuto() {
createWithComponentBrowser(fromSelection() ?? { placement: 'viewport' })
}
function fromSelection(): NewNodeOptions | undefined {
if (graphStore.editedNodeInfo != null) return undefined
const firstSelectedNode = set.first(nodeSelection.selected)
@ -316,41 +399,10 @@ function fromSelection(): NewNodeOptions | undefined {
}
}
type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2]
function* filterDefined<T>(iterable: Iterable<T | undefined>): IterableIterator<T> {
for (const value of iterable) {
if (value !== undefined) yield value
}
}
const placeNode = (placement: PlacementType): Vec2 =>
placement === 'viewport' ? nodePlacement().position
: placement[0] === 'source' ?
nodePlacement(filterDefined([graphStore.visibleArea(placement[1])])).position
: placement[0] === 'fixed' ? placement[1]
: assertNever(placement)
interface NewNodeOptions {
placement: PlacementType
sourcePort?: AstId | undefined
}
function createWithComponentBrowser(options: NewNodeOptions) {
openComponentBrowser(
{
type: 'newNode',
sourcePort: options.sourcePort,
},
placeNode(options.placement),
)
}
/** Start creating a node, basing its inputs and position on the current selection, if any;
* or the current viewport, otherwise.
*/
function addNodeAuto() {
createWithComponentBrowser(fromSelection() ?? { placement: 'viewport' })
function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) {
const position = placeNode(placement)
const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code()
return graphStore.createNode(position, content, undefined, []) ?? undefined
}
function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
@ -375,48 +427,20 @@ function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[
createWithComponentBrowser({ placement: placementForOptions(options), sourcePort })
}
function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) {
const position = placeNode(placement)
const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code()
return graphStore.createNode(position, content, undefined, []) ?? undefined
}
function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false
}
function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) {
if (content != null) {
if (graphStore.editedNodeInfo) {
// We finish editing a node.
graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports)
} else if (content != '') {
// We finish creating a new node.
const metadata = undefined
const createdNode = graphStore.createNode(
componentBrowserNodePosition.value,
content,
metadata,
requiredImports,
)
if (createdNode) nodeSelection.setSelection(new Set([createdNode]))
}
function handleNodeOutputPortDoubleClick(id: AstId) {
const srcNode = graphStore.db.getPatternExpressionNodeId(id)
if (srcNode == null) {
console.error('Impossible happened: Double click on port not belonging to any node: ', id)
return
}
hideComponentBrowser()
createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id })
}
// Watch the `editedNode` in the graph store
watch(
() => graphStore.editedNodeInfo,
(editedInfo) => {
if (editedInfo) {
editWithComponentBrowser(editedInfo.id, editedInfo.initialCursorPos)
} else {
hideComponentBrowser()
}
},
)
function handleEdgeDrop(source: AstId, position: Vec2) {
createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source })
}
// === Drag and drop ===
async function handleFileDrop(event: DragEvent) {
// A vertical gap between created nodes when multiple files were dropped together.
@ -451,127 +475,6 @@ async function handleFileDrop(event: DragEvent) {
})
}
// === Clipboard ===
const ENSO_MIME_TYPE = 'web application/enso'
/** The data that is copied to the clipboard. */
interface ClipboardData {
nodes: CopiedNode[]
}
/** Node data that is copied to the clipboard. Used for serializing and deserializing the node information. */
interface CopiedNode {
expression: string
metadata: NodeMetadataFields | undefined
}
/** Copy the content of the selected node to the clipboard. */
function copyNodeContent() {
const id = nodeSelection.selected.values().next().value
const node = graphStore.db.nodeIdToNode.get(id)
if (!node) return
const content = node.innerExpr.code()
const nodeMetadata = node.rootExpr.nodeMetadata
const metadata = {
position: nodeMetadata.get('position'),
visualization: nodeMetadata.get('visualization'),
}
const copiedNode: CopiedNode = { expression: content, metadata }
const clipboardData: ClipboardData = { nodes: [copiedNode] }
const jsonItem = new Blob([JSON.stringify(clipboardData)], { type: ENSO_MIME_TYPE })
const textItem = new Blob([content], { type: 'text/plain' })
const clipboardItem = new ClipboardItem({ [jsonItem.type]: jsonItem, [textItem.type]: textItem })
navigator.clipboard.write([clipboardItem])
}
async function retrieveDataFromClipboard(): Promise<ClipboardData | undefined> {
const clipboardItems = await navigator.clipboard.read()
let fallback = undefined
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type === ENSO_MIME_TYPE) {
const blob = await clipboardItem.getType(type)
return JSON.parse(await blob.text())
}
if (type === 'text/html') {
const blob = await clipboardItem.getType(type)
const htmlContent = await blob.text()
const excelPayload = await readNodeFromExcelClipboard(htmlContent, clipboardItem)
if (excelPayload) {
return excelPayload
}
}
if (type === 'text/plain') {
const blob = await clipboardItem.getType(type)
const fallbackExpression = await blob.text()
const fallbackNode = { expression: fallbackExpression, metadata: undefined } as CopiedNode
fallback = { nodes: [fallbackNode] } as ClipboardData
}
}
}
return fallback
}
/// Read the clipboard and if it contains valid data, create a node from the content.
async function readNodeFromClipboard() {
let clipboardData = await retrieveDataFromClipboard()
if (!clipboardData) {
console.warn('No valid data in clipboard.')
return
}
const copiedNode = clipboardData.nodes[0]
if (!copiedNode) {
console.warn('No valid node in clipboard.')
return
}
if (copiedNode.expression == null) {
console.warn('No valid expression in clipboard.')
}
graphStore.createNode(
graphNavigator.sceneMousePos ?? Vec2.Zero,
copiedNode.expression,
copiedNode.metadata,
)
}
async function readNodeFromExcelClipboard(
htmlContent: string,
clipboardItem: ClipboardItem,
): Promise<ClipboardData | undefined> {
// Check we have a valid HTML table
// If it is Excel, we should have a plain-text version of the table with tab separators.
if (
clipboardItem.types.includes('text/plain') &&
htmlContent.startsWith('<table ') &&
htmlContent.endsWith('</table>')
) {
const textData = await clipboardItem.getType('text/plain')
const text = await textData.text()
const payload = JSON.stringify(text).replaceAll(/^"|"$/g, '').replaceAll("'", "\\'")
const expression = `'${payload}'.to Table`
return { nodes: [{ expression: expression, metadata: undefined }] } as ClipboardData
}
return undefined
}
function handleNodeOutputPortDoubleClick(id: AstId) {
const srcNode = graphStore.db.getPatternExpressionNodeId(id)
if (srcNode == null) {
console.error('Impossible happened: Double click on port not belonging to any node: ', id)
return
}
createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id })
}
const stackNavigator = useStackNavigator()
function handleEdgeDrop(source: AstId, position: Vec2) {
createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source })
}
// === Color Picker ===
/** A small offset to keep the color picker slightly away from the nodes. */
@ -616,6 +519,14 @@ const colorPickerStyle = computed(() =>
{ transform: `translate(${colorPickerPos.value.x}px, ${colorPickerPos.value.y}px)` }
: {},
)
const groupColors = computed(() => {
const styles: { [key: string]: string } = {}
for (let group of suggestionDb.groups) {
styles[groupColorVar(group)] = group.color ?? colorFromString(group.name)
}
return styles
})
</script>
<template>
@ -667,7 +578,7 @@ const colorPickerStyle = computed(() =>
:breadcrumbs="stackNavigator.breadcrumbLabels.value"
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
:allowNavigationRight="stackNavigator.allowNavigationRight.value"
:zoomLevel="100.0 * graphNavigator.scale"
:zoomLevel="100.0 * graphNavigator.targetScale"
@breadcrumbClick="stackNavigator.handleBreadcrumbClick"
@back="stackNavigator.exitNode"
@forward="stackNavigator.enterNextNodeFromHistory"

View File

@ -0,0 +1,120 @@
import type { NavigatorComposable } from '@/composables/navigator'
import type { GraphSelection } from '@/providers/graphSelection'
import { useGraphStore } from '@/stores/graph'
import { Vec2 } from '@/util/data/vec2'
import type { NodeMetadataFields } from 'shared/ast'
const ENSO_MIME_TYPE = 'web application/enso'
/** The data that is copied to the clipboard. */
interface ClipboardData {
nodes: CopiedNode[]
}
/** Node data that is copied to the clipboard. Used for serializing and deserializing the node information. */
interface CopiedNode {
expression: string
metadata: NodeMetadataFields | undefined
}
export function useGraphEditorClipboard(
nodeSelection: GraphSelection,
graphNavigator: NavigatorComposable,
) {
const graphStore = useGraphStore()
/** Copy the content of the selected node to the clipboard. */
function copyNodeContent() {
const id = nodeSelection.selected.values().next().value
const node = graphStore.db.nodeIdToNode.get(id)
if (!node) return
const content = node.innerExpr.code()
const nodeMetadata = node.rootExpr.nodeMetadata
const metadata = {
position: nodeMetadata.get('position'),
visualization: nodeMetadata.get('visualization'),
}
const copiedNode: CopiedNode = { expression: content, metadata }
const clipboardData: ClipboardData = { nodes: [copiedNode] }
const jsonItem = new Blob([JSON.stringify(clipboardData)], { type: ENSO_MIME_TYPE })
const textItem = new Blob([content], { type: 'text/plain' })
const clipboardItem = new ClipboardItem({
[jsonItem.type]: jsonItem,
[textItem.type]: textItem,
})
navigator.clipboard.write([clipboardItem])
}
async function retrieveDataFromClipboard(): Promise<ClipboardData | undefined> {
const clipboardItems = await navigator.clipboard.read()
let fallback = undefined
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type === ENSO_MIME_TYPE) {
const blob = await clipboardItem.getType(type)
return JSON.parse(await blob.text())
}
if (type === 'text/html') {
const blob = await clipboardItem.getType(type)
const htmlContent = await blob.text()
const excelPayload = await readNodeFromExcelClipboard(htmlContent, clipboardItem)
if (excelPayload) {
return excelPayload
}
}
if (type === 'text/plain') {
const blob = await clipboardItem.getType(type)
const fallbackExpression = await blob.text()
const fallbackNode = { expression: fallbackExpression, metadata: undefined } as CopiedNode
fallback = { nodes: [fallbackNode] } as ClipboardData
}
}
}
return fallback
}
/// Read the clipboard and if it contains valid data, create a node from the content.
async function readNodeFromClipboard() {
const clipboardData = await retrieveDataFromClipboard()
const copiedNode = clipboardData?.nodes[0]
if (!copiedNode) {
console.warn('No valid node in clipboard.')
return
}
if (copiedNode.expression == null) {
console.warn('No valid expression in clipboard.')
}
graphStore.createNode(
graphNavigator.sceneMousePos ?? Vec2.Zero,
copiedNode.expression,
copiedNode.metadata,
)
}
async function readNodeFromExcelClipboard(
htmlContent: string,
clipboardItem: ClipboardItem,
): Promise<ClipboardData | undefined> {
// Check we have a valid HTML table
// If it is Excel, we should have a plain-text version of the table with tab separators.
if (
clipboardItem.types.includes('text/plain') &&
htmlContent.startsWith('<table ') &&
htmlContent.endsWith('</table>')
) {
const textData = await clipboardItem.getType('text/plain')
const text = await textData.text()
const payload = JSON.stringify(text).replaceAll(/^"|"$/g, '').replaceAll("'", "\\'")
const expression = `'${payload}'.to Table`
return { nodes: [{ expression: expression, metadata: undefined }] } as ClipboardData
}
return undefined
}
return {
copyNodeContent,
readNodeFromClipboard,
}
}

View File

@ -0,0 +1,30 @@
import { useEvent } from '@/composables/events'
import { useProjectStore } from '@/stores/project'
import { useToast } from '@/util/toast'
import { ProjectManagerEvents } from '../../../../ide-desktop/lib/dashboard/src/services/ProjectManager'
export function useGraphEditorToasts() {
const projectStore = useProjectStore()
const toastStartup = useToast.info({ autoClose: false })
const toastConnectionLost = useToast.error({ autoClose: false })
const toastLspError = useToast.error()
const toastConnectionError = useToast.error()
const toastExecutionFailed = useToast.error()
toastStartup.show('Initializing the project. This can take up to one minute.')
projectStore.firstExecution.then(toastStartup.dismiss)
useEvent(document, ProjectManagerEvents.loadingFailed, () =>
toastConnectionLost.show('Lost connection to Language Server.'),
)
projectStore.lsRpcConnection.then(
(ls) => ls.client.onError((e) => toastLspError.show(`Language server error: ${e}`)),
(e) => toastConnectionError.show(`Connection to language server failed: ${JSON.stringify(e)}`),
)
projectStore.executionContext.on('executionComplete', () => toastExecutionFailed.dismiss())
projectStore.executionContext.on('executionFailed', (e) =>
toastExecutionFailed.show(`Execution Failed: ${JSON.stringify(e)}`),
)
}

View File

@ -1,4 +1,12 @@
<script lang="ts">
import { useAutoBlur } from '@/util/autoBlur'
import { VisualizationContainer } from '@/util/visualizationBuiltins'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type { ColumnResizedEvent, ICellRendererParams } from 'ag-grid-community'
import type { ColDef, GridOptions, HeaderValueGetterParams } from 'ag-grid-enterprise'
import { computed, onMounted, onUnmounted, reactive, ref, watchEffect, type Ref } from 'vue'
export const name = 'Table'
export const icon = 'table'
export const inputType =
@ -65,18 +73,14 @@ declare module 'ag-grid-enterprise' {
field: string
}
}
if (typeof import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY !== 'string') {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
}
</script>
<script setup lang="ts">
import { useAutoBlur } from '@/util/autoBlur'
import { VisualizationContainer } from '@/util/visualizationBuiltins'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import { Grid, type ColumnResizedEvent, type ICellRendererParams } from 'ag-grid-community'
import type { ColDef, GridOptions, HeaderValueGetterParams } from 'ag-grid-enterprise'
import { computed, onMounted, onUnmounted, reactive, ref, watchEffect, type Ref } from 'vue'
const { LicenseManager } = await import('ag-grid-enterprise')
const { LicenseManager, Grid } = await import('ag-grid-enterprise')
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
@ -243,6 +247,7 @@ watchEffect(() => {
: {
type: typeof props.data,
json: props.data,
// eslint-disable-next-line camelcase
all_rows_count: 1,
data: undefined,
indices: undefined,
@ -384,8 +389,22 @@ onMounted(() => {
const agGridLicenseKey = import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY
if (typeof agGridLicenseKey === 'string') {
LicenseManager.setLicenseKey(agGridLicenseKey)
} else {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
} else if (import.meta.env.DEV) {
// Hide annoying license validation errors in dev mode when the license is not defined. The
// missing define warning is still displayed to not forget about it, but it isn't as obnoxious.
const origValidateLicense = LicenseManager.prototype.validateLicense
LicenseManager.prototype.validateLicense = function (this) {
if (!('licenseManager' in this))
Object.defineProperty(this, 'licenseManager', {
configurable: true,
set(value: any) {
Object.getPrototypeOf(value).validateLicense = () => {}
delete this.licenseManager
this.licenseManager = value
},
})
origValidateLicense.call(this)
}
}
new Grid(tableNode.value!, agGridOptions.value)
updateColumnWidths()

View File

@ -3,6 +3,7 @@ import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { effectScope, ref } from 'vue'
import { useKeyboard } from '../keyboard'
describe('useNavigator', async () => {
let scope = effectScope()
@ -16,7 +17,8 @@ describe('useNavigator', async () => {
const node = document.createElement('div')
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(new DOMRect(150, 150, 800, 400))
const viewportNode = ref(node)
return useNavigator(viewportNode)
const keyboard = useKeyboard()
return useNavigator(viewportNode, keyboard)
})!
}
@ -34,7 +36,7 @@ describe('useNavigator', async () => {
test('clientToScenePos with scaling', () => {
const navigator = makeTestNavigator()
navigator.scale = 2
navigator.setCenterAndScale(Vec2.Zero, 2)
expect(navigator.clientToScenePos(Vec2.Zero)).toStrictEqual(new Vec2(-275, -175))
expect(navigator.clientToScenePos(new Vec2(150, 150))).toStrictEqual(new Vec2(-200, -100))
expect(navigator.clientToScenePos(new Vec2(550, 350))).toStrictEqual(new Vec2(0, 0))
@ -53,7 +55,7 @@ describe('useNavigator', async () => {
test('clientToSceneRect with scaling', () => {
const navigator = makeTestNavigator()
navigator.scale = 2
navigator.setCenterAndScale(Vec2.Zero, 2)
expect(navigator.clientToSceneRect(Rect.Zero)).toStrictEqual(Rect.XYWH(-275, -175, 0, 0))
expect(navigator.clientToSceneRect(Rect.XYWH(150, 150, 800, 400))).toStrictEqual(
navigator.viewport,

View File

@ -1,7 +1,17 @@
/** @file Vue composables for running a callback on every frame, and smooth interpolation. */
import type { Vec2 } from '@/util/data/vec2'
import { watchSourceToRef } from '@/util/reactivity'
import { onScopeDispose, proxyRefs, ref, watch, type WatchSource } from 'vue'
import {
onScopeDispose,
proxyRefs,
readonly,
ref,
shallowRef,
watch,
type Ref,
type WatchSource,
} from 'vue'
const rafCallbacks: { fn: (t: number, dt: number) => void; priority: number }[] = []
@ -73,8 +83,6 @@ function runRaf() {
}
}
const defaultDiffFn = (a: number, b: number): number => b - a
/**
* Animate value over time using exponential approach.
* http://badladns.com/stories/exp-approach
@ -84,32 +92,59 @@ const defaultDiffFn = (a: number, b: number): number => b - a
* represents a speed of the approach. Lower values means faster animation.
* @param epsilon The approach will stop when the difference between the current value and the
* target value is less than `epsilon`. This is to prevent the animation from running forever.
* @param diffFn Function that will be used to calculate the difference between two values.
* By default, the difference is calculated as simple number difference `b - a`.
* Custom `diffFn` can be used to implement e.g. angle value approach over the shortest arc.
*/
export function useApproach(
to: WatchSource<number>,
timeHorizon: number = 100,
epsilon = 0.005,
diffFn = defaultDiffFn,
export function useApproach(to: WatchSource<number>, timeHorizon: number = 100, epsilon = 0.005) {
return useApproachBase(
to,
(t, c) => t == c,
(targetVal, currentValue, dt) => {
const diff = currentValue - targetVal
if (Math.abs(diff) > epsilon) {
return targetVal + diff / Math.exp(dt / timeHorizon)
} else {
return targetVal
}
},
)
}
/**
* Animate a vector value over time using exponential approach.
* http://badladns.com/stories/exp-approach
*
* @param to Target vector value to approach.
* @param timeHorizon Time at which the approach will be at 63% of the target value. Effectively
* represents a speed of the approach. Lower values means faster animation.
* @param epsilon The approach will stop when the squared distance between the current vector and
* the target value is less than `epsilon`. This is to prevent the animation from running forever.
*/
export function useApproachVec(to: WatchSource<Vec2>, timeHorizon: number = 100, epsilon = 0.003) {
return useApproachBase(
to,
(t, c) => t.equals(c),
(targetVal, currentValue, dt) => {
const diff = currentValue.sub(targetVal)
if (diff.lengthSquared() > epsilon) {
return targetVal.add(diff.scale(1 / Math.exp(dt / timeHorizon)))
} else {
return targetVal
}
},
)
}
function useApproachBase<T>(
to: WatchSource<T>,
stable: (target: T, current: T) => boolean,
update: (target: T, current: T, dt: number) => T,
) {
const target = watchSourceToRef(to)
const current = ref(target.value)
const current: Ref<T> = shallowRef(target.value)
useRaf(
() => target.value != current.value,
() => !stable(target.value, current.value),
(_, dt) => {
const targetVal = target.value
const currentValue = current.value
if (targetVal != currentValue) {
const diff = diffFn(targetVal, currentValue)
if (Math.abs(diff) > epsilon) {
current.value = targetVal + diff / Math.exp(dt / timeHorizon)
} else {
current.value = targetVal
}
}
current.value = update(target.value, current.value, dt)
},
)
@ -117,7 +152,7 @@ export function useApproach(
current.value = target.value
}
return proxyRefs({ value: current, skip })
return readonly(proxyRefs({ value: current, skip }))
}
export function useTransitioning(observedProperties?: Set<string>) {

View File

@ -1,18 +1,18 @@
/** @file A Vue composable for panning and zooming a DOM element. */
import { useApproach } from '@/composables/animation'
import { useApproach, useApproachVec } from '@/composables/animation'
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { computed, proxyRefs, shallowRef, type Ref } from 'vue'
import { computed, proxyRefs, readonly, shallowRef, toRef, type Ref } from 'vue'
type ScaleRange = readonly [number, number]
const DEFAULT_SCALE_RANGE: ScaleRange = [0.1, 10]
const PAN_AND_ZOOM_DEFAULT_SCALE_RANGE: ScaleRange = [0.1, 1]
const ZOOM_LEVELS = [
0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
]
const DEFAULT_SCALE_RANGE: ScaleRange = [Math.min(...ZOOM_LEVELS), Math.max(...ZOOM_LEVELS)]
const ZOOM_LEVELS_REVERSED = [...ZOOM_LEVELS].reverse()
/** The fraction of the next zoom level.
* If we are that close to next zoom level, we should choose the next one instead
@ -29,33 +29,12 @@ export type NavigatorComposable = ReturnType<typeof useNavigator>
export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: KeyboardComposable) {
const size = useResizeObserver(viewportNode)
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const targetX = computed(() => targetCenter.value.x)
const targetY = computed(() => targetCenter.value.y)
const centerX = useApproach(targetX)
const centerY = useApproach(targetY)
const center = computed({
get() {
return new Vec2(centerX.value, centerY.value)
},
set(value) {
targetCenter.value = value
centerX.value = value.x
centerY.value = value.y
},
})
const center = useApproachVec(targetCenter, 100, 0.02)
const targetScale = shallowRef(1)
const animatedScale = useApproach(targetScale)
const scale = computed({
get() {
return animatedScale.value
},
set(value) {
targetScale.value = value
animatedScale.value = value
},
})
const scale = useApproach(targetScale)
const panPointer = usePointer((pos) => {
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
scrollTo(center.value.addScaled(pos.delta, -1 / scale.value))
}, PointerButtonMask.Auxiliary)
function eventScreenPos(e: { clientX: number; clientY: number }): Vec2 {
@ -101,11 +80,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
viewportNode.value.clientWidth / rect.width,
),
)
const centerX =
!Number.isFinite(rect.left) && !Number.isFinite(rect.width) ? 0 : rect.left + rect.width / 2
const centerY =
!Number.isFinite(rect.top) && !Number.isFinite(rect.height) ? 0 : rect.top + rect.height / 2
targetCenter.value = new Vec2(centerX, centerY)
targetCenter.value = rect.center().finiteOrZero()
}
/** Pan to include the given prioritized list of coordinates.
@ -125,7 +100,16 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
/** Pan immediately to center the viewport at the given point, in scene coordinates. */
function scrollTo(newCenter: Vec2) {
center.value = newCenter
targetCenter.value = newCenter
center.skip()
}
/** Set viewport center point and scale value immediately, skipping animations. */
function setCenterAndScale(newCenter: Vec2, newScale: number) {
targetCenter.value = newCenter
targetScale.value = newScale
scale.skip()
center.skip()
}
let zoomPivot = Vec2.Zero
@ -136,10 +120,12 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
const prevScale = scale.value
updateScale((oldValue) => oldValue * Math.exp(-pos.delta.y / 100))
center.value = center.value
.sub(zoomPivot)
.scale(prevScale / scale.value)
.add(zoomPivot)
scrollTo(
center.value
.sub(zoomPivot)
.scale(prevScale / scale.value)
.add(zoomPivot),
)
}, PointerButtonMask.Secondary)
const viewport = computed(() => {
@ -227,28 +213,30 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
function updateScale(f: (value: number) => number, range: ScaleRange = DEFAULT_SCALE_RANGE) {
const oldValue = scale.value
scale.value = directedClamp(oldValue, f(oldValue), range)
targetScale.value = directedClamp(oldValue, f(oldValue), range)
scale.skip()
}
/** Step to the next level from {@link ZOOM_LEVELS}.
* @param zoomStepDelta step direction. If positive select larger zoom level; if negative select smaller.
* If 0, resets zoom level to 1.0. */
function stepZoom(zoomStepDelta: number) {
const oldValue = scale.value
const oldValue = targetScale.value
const insideThreshold = (level: number) =>
Math.abs(oldValue - level) <= level * ZOOM_SKIP_THRESHOLD
if (zoomStepDelta > 0) {
const lastZoomLevel = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!
scale.value =
targetScale.value =
ZOOM_LEVELS.find((level) => level > oldValue && !insideThreshold(level)) ?? lastZoomLevel
} else if (zoomStepDelta < 0) {
const firstZoomLevel = ZOOM_LEVELS[0]!
scale.value =
targetScale.value =
ZOOM_LEVELS_REVERSED.find((level) => level < oldValue && !insideThreshold(level)) ??
firstZoomLevel
} else {
scale.value = 1.0
targetScale.value = 1.0
}
scale.skip()
}
return proxyRefs({
@ -292,7 +280,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
}
} else {
const delta = new Vec2(e.deltaX, e.deltaY)
center.value = center.value.addScaled(delta, 1 / scale.value)
scrollTo(center.value.addScaled(delta, 1 / scale.value))
}
},
contextmenu(e: Event) {
@ -300,8 +288,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
},
},
translate,
targetScale,
scale,
targetCenter: readonly(targetCenter),
targetScale: readonly(targetScale),
scale: readonly(toRef(scale, 'value')),
viewBox,
transform,
/** Use this transform instead, if the element should not be scaled. */
@ -314,5 +303,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
viewport,
stepZoom,
scrollTo,
setCenterAndScale,
})
}

View File

@ -0,0 +1,71 @@
import { Vec2 } from '@/util/data/vec2'
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
import { encoding } from 'lib0'
import { xxHash128 } from 'shared/ast/ffi'
import { computed, watch } from 'vue'
import type { NavigatorComposable } from './navigator'
/**
* Synchronize given navigator's viewport pan and zoom with `localStorage`.
*
* @param navigator The navigator representing a viewport to synchronize.
* @param reactiveStorageKeyEncoder A **reactive** encoder from which a storage key is derived. Data
* that is encoded in this function dictates the effective identity of stored viewport. Whenever the
* encoded data changes, the stored viewport value is restored to navigator.
*/
export function useNavigatorStorage(
navigator: NavigatorComposable,
reactiveStorageKeyEncoder: (enc: encoding.Encoder) => void,
) {
const graphViewportStorageKey = computed(() =>
xxHash128(encoding.encode(reactiveStorageKeyEncoder)),
)
type ViewportStorage = Map<string, { x: number; y: number; s: number }>
const storedViewport = useLocalStorage<ViewportStorage>('enso-viewport', new Map())
/**
* Maximum number of viewports stored in localStorage. When it is exceeded, least recently used
* half of the stored data is removed.
*/
const MAX_STORED_VIEWPORTS = 256
// Save/Load viewports whenever entering a new graph context (i.e. the storage key has changed).
watch(
graphViewportStorageKey,
(key, prevKey) => {
if (key === prevKey) return
if (prevKey != null) storeCurrentViewport(prevKey)
restoreViewport(key)
},
{ immediate: true },
)
// Whenever the viewport was changed and stable for a while, save it in localstorage.
debouncedWatch(
() => [navigator.targetCenter, navigator.targetScale],
() => storeCurrentViewport(graphViewportStorageKey.value),
{ debounce: 200 },
)
function storeCurrentViewport(storageKey: string) {
const pos = navigator.targetCenter
const scale = navigator.targetScale
storedViewport.value.set(storageKey, { x: pos.x, y: pos.y, s: scale })
// Ensure that the storage doesn't grow forever by periodically removing least recently
// written half of entries when we reach a limit.
if (storedViewport.value.size > MAX_STORED_VIEWPORTS) {
let toRemove = storedViewport.value.size - MAX_STORED_VIEWPORTS / 2
for (const key of storedViewport.value.keys()) {
if (toRemove-- <= 0) break
storedViewport.value.delete(key)
}
}
}
function restoreViewport(storageKey: string) {
const restored = storedViewport.value.get(storageKey)
const pos = restored ? Vec2.FromXY(restored).finiteOrZero() : Vec2.Zero
const scale = restored?.s ?? 1
navigator.setCenterAndScale(pos, scale)
}
}

View File

@ -1,12 +1,12 @@
import type { NavigatorComposable } from '@/composables/navigator'
import { useSelection } from '@/composables/selection'
import { useSelection, type SelectionComposable } from '@/composables/selection'
import { createContextStore } from '@/providers'
import { type NodeId } from '@/stores/graph'
import type { Rect } from '@/util/data/rect'
const SELECTION_BRUSH_MARGIN_PX = 6
export type GraphSelection = ReturnType<typeof injectFn>
export type GraphSelection = SelectionComposable<NodeId>
export { injectFn as injectGraphSelection, provideFn as provideGraphSelection }
const { provideFn, injectFn } = createContextStore(
'graph selection',

View File

@ -748,6 +748,12 @@ export const useGraphStore = defineStore('graph', () => {
addMissingImports,
addMissingImportsDisregardConflicts,
isConnectedTarget,
currentMethodPointer() {
const currentMethod = proj.executionContext.getStackTop()
console.log('currentMethod', currentMethod)
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer
return db.getExpressionInfo(currentMethod.expressionId)?.methodCall?.methodPointer
},
}
})

View File

@ -699,6 +699,9 @@ export const useProjectStore = defineStore('project', () => {
setObservedFileName(name: string) {
observedFileName.value = name
},
get observedFileName() {
return observedFileName.value
},
name: projectName,
displayName: projectDisplayName,
isOnLocalBackend,

View File

@ -1 +1,7 @@
export * from 'shared/util/data/iterable'
export function* filterDefined<T>(iterable: Iterable<T | undefined>): IterableIterator<T> {
for (const value of iterable) {
if (value !== undefined) yield value
}
}

View File

@ -52,6 +52,10 @@ export class Rect {
return a.equals(b)
}
isFinite(): boolean {
return this.pos.isFinite() && this.size.isFinite()
}
offsetBy(offset: Vec2): Rect {
return new Rect(this.pos.add(offset), this.size)
}

View File

@ -41,6 +41,14 @@ export class Vec2 {
return this.x === 0 && this.y === 0
}
isFinite(): boolean {
return Number.isFinite(this.x) && Number.isFinite(this.y)
}
finiteOrZero(): Vec2 {
return new Vec2(Number.isFinite(this.x) ? this.x : 0, Number.isFinite(this.y) ? this.y : 0)
}
scale(scalar: number): Vec2 {
return new Vec2(this.x * scalar, this.y * scalar)
}

View File

@ -36,6 +36,8 @@ const pattern = computed(() => Ast.parse(nodeBinding.value))
const node = computed((): Node => {
return {
outerExpr: '' as any,
colorOverride: null,
zIndex: 1,
pattern: pattern.value,
position: position.value,
prefixes: { enableRecording: undefined },