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 { Server } from '@open-rpc/server-js'
import * as random from 'lib0/random.js' import * as random from 'lib0/random'
import { import {
methods as pmMethods, methods as pmMethods,
projects, 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 * as actions from './actions'
import { expect } from './customExpect' import { expect } from './customExpect'
import { mockMethodCallInfo } from './expressionUpdates'
import * as locate from './locate' import * as locate from './locate'
test('Adding new node', async ({ page }) => { test('Adding new node', async ({ page }) => {

View File

@ -9,7 +9,6 @@ import { MockTransport, MockWebSocket } from '@/util/net'
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
import { ref, type App } from 'vue' import { ref, type App } from 'vue'
import { mockDataHandler, mockLSHandler } from './engine' import { mockDataHandler, mockLSHandler } from './engine'
export * as providers from './providers'
export * as vue from './vue' export * as vue from './vue'
export function languageServer() { 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 { 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 init, { is_ident_or_operator, parse, parse_doc_to_json } from '../../rust-ffi/pkg/rust_ffi'
import { assertDefined } from '../util/assert' import { assertDefined } from '../util/assert'
import { isNode } from '../util/detect' import { isNode } from '../util/detect'
let xxHasher128: Awaited<ReturnType<typeof createXXHash128>> | undefined 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`.') assertDefined(xxHasher128, 'Module should have been loaded with `initializeFFI`.')
xxHasher128.init() xxHasher128.init()
xxHasher128.update(input) xxHasher128.update(input)

View File

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

View File

@ -1,3 +1,4 @@
import * as encoding from 'lib0/encoding'
import type { import type {
SuggestionsDatabaseEntry, SuggestionsDatabaseEntry,
SuggestionsDatabaseUpdate, SuggestionsDatabaseUpdate,
@ -363,6 +364,12 @@ export interface LocalCall {
expressionId: ExpressionId 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 { export function stackItemsEqual(left: StackItem, right: StackItem): boolean {
if (left.type !== right.type) return false 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 GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing' import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation' import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import { useGraphEditorToasts } from '@/components/GraphEditor/toasts'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload' import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import GraphMouse from '@/components/GraphMouse.vue' import GraphMouse from '@/components/GraphMouse.vue'
import PlusButton from '@/components/PlusButton.vue' import PlusButton from '@/components/PlusButton.vue'
@ -19,6 +20,7 @@ import SceneScroller from '@/components/SceneScroller.vue'
import TopBar from '@/components/TopBar.vue' import TopBar from '@/components/TopBar.vue'
import { useDoubleClick } from '@/composables/doubleClick' import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events' import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
import { useNavigatorStorage } from '@/composables/navigatorStorage'
import { useStackNavigator } from '@/composables/stackNavigator' import { useStackNavigator } from '@/composables/stackNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection' import { provideGraphSelection } from '@/providers/graphSelection'
@ -30,57 +32,64 @@ import type { RequiredImport } from '@/stores/graph/imports'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase' import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { assertNever, bail } from '@/util/assert' 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 type { Pattern } from '@/util/ast/match'
import { colorFromString } from '@/util/colors' import { colorFromString } from '@/util/colors'
import { partition } from '@/util/data/array' import { partition } from '@/util/data/array'
import { filterDefined } from '@/util/data/iterable'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { useToast } from '@/util/toast' import { encoding, set } from 'lib0'
import * as set from 'lib0/set' import { encodeMethodPointer } from 'shared/languageServerTypes'
import { computed, onMounted, ref, toRef, watch } from 'vue' 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 { type Usage } from './ComponentBrowser/input'
import { useGraphEditorClipboard } from './GraphEditor/clipboard'
const keyboard = provideKeyboard() const keyboard = provideKeyboard()
const viewportNode = ref<HTMLElement>()
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
const graphStore = useGraphStore() const graphStore = useGraphStore()
const widgetRegistry = provideWidgetRegistry(graphStore.db) const widgetRegistry = provideWidgetRegistry(graphStore.db)
widgetRegistry.loadBuiltins() widgetRegistry.loadBuiltins()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const componentBrowserVisible = ref(false)
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
const suggestionDb = useSuggestionDbStore() const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
// === toasts === // === Navigator ===
const toastStartup = useToast.info({ autoClose: false }) const viewportNode = ref<HTMLElement>()
const toastConnectionLost = useToast.error({ autoClose: false }) onMounted(() => viewportNode.value?.focus())
const toastLspError = useToast.error() const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
const toastConnectionError = useToast.error() useNavigatorStorage(graphNavigator, (enc) => {
const toastExecutionFailed = useToast.error() // 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.') function zoomToSelected() {
projectStore.firstExecution.then(toastStartup.dismiss) if (!viewportNode.value) return
useEvent(document, ProjectManagerEvents.loadingFailed, () => const allNodes = graphStore.db.nodeIdToNode
toastConnectionLost.show('Lost connection to Language Server.'), 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( // == Breadcrumbs ==
(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()) const stackNavigator = useStackNavigator()
projectStore.executionContext.on('executionFailed', (e) =>
toastExecutionFailed.show(`Execution Failed: ${JSON.stringify(e)}`),
)
// === nodes === // === Toasts ===
useGraphEditorToasts()
// === Selection ===
const nodeSelection = provideGraphSelection( const nodeSelection = provideGraphSelection(
graphNavigator, 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({ const interactionBindingsHandler = interactionBindings.handler({
cancel: () => interaction.handleCancel(), cancel: () => interaction.handleCancel(),
}) })
@ -111,19 +136,7 @@ useEvent(window, 'pointerdown', (e) => interaction.handleClick(e, graphNavigator
capture: true, capture: true,
}) })
onMounted(() => viewportNode.value?.focus()) // === Keyboard/Mouse bindings ===
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))
}
const graphBindingsHandler = graphBindings.handler({ const graphBindingsHandler = graphBindings.handler({
undo() { undo() {
@ -256,6 +269,9 @@ const { handleClick } = useDoubleClick(
stackNavigator.exitNode() stackNavigator.exitNode()
}, },
) )
// === Code Editor ===
const codeEditorArea = ref<HTMLElement>() const codeEditorArea = ref<HTMLElement>()
const showCodeEditor = ref(false) const showCodeEditor = ref(false)
const toggleCodeEditor = () => { const toggleCodeEditor = () => {
@ -267,6 +283,8 @@ const codeEditorHandler = codeEditorBindings.handler({
}, },
}) })
// === Execution Mode ===
/** Handle record-once button presses. */ /** Handle record-once button presses. */
function onRecordOnceButtonPress() { function onRecordOnceButtonPress() {
projectStore.lsRpcConnection.then(async () => { projectStore.lsRpcConnection.then(async () => {
@ -286,13 +304,11 @@ watch(
}, },
) )
const groupColors = computed(() => { // === Component Browser ===
const styles: { [key: string]: string } = {}
for (let group of suggestionDb.groups) { const componentBrowserVisible = ref(false)
styles[groupColorVar(group)] = group.color ?? colorFromString(group.name) const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
} const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
return styles
})
function openComponentBrowser(usage: Usage, position: Vec2) { function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserUsage.value = usage componentBrowserUsage.value = usage
@ -300,6 +316,11 @@ function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserVisible.value = true componentBrowserVisible.value = true
} }
function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false
}
function editWithComponentBrowser(node: NodeId, cursorPos: number) { function editWithComponentBrowser(node: NodeId, cursorPos: number) {
openComponentBrowser( openComponentBrowser(
{ type: 'editNode', node, cursorPos }, { 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 { function fromSelection(): NewNodeOptions | undefined {
if (graphStore.editedNodeInfo != null) return undefined if (graphStore.editedNodeInfo != null) return undefined
const firstSelectedNode = set.first(nodeSelection.selected) const firstSelectedNode = set.first(nodeSelection.selected)
@ -316,41 +399,10 @@ function fromSelection(): NewNodeOptions | undefined {
} }
} }
type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2] function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) {
const position = placeNode(placement)
function* filterDefined<T>(iterable: Iterable<T | undefined>): IterableIterator<T> { const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code()
for (const value of iterable) { return graphStore.createNode(position, content, undefined, []) ?? undefined
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 createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) { function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
@ -375,48 +427,20 @@ function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[
createWithComponentBrowser({ placement: placementForOptions(options), sourcePort }) createWithComponentBrowser({ placement: placementForOptions(options), sourcePort })
} }
function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) { function handleNodeOutputPortDoubleClick(id: AstId) {
const position = placeNode(placement) const srcNode = graphStore.db.getPatternExpressionNodeId(id)
const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code() if (srcNode == null) {
return graphStore.createNode(position, content, undefined, []) ?? undefined console.error('Impossible happened: Double click on port not belonging to any node: ', id)
} return
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]))
}
} }
hideComponentBrowser() createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id })
} }
// Watch the `editedNode` in the graph store function handleEdgeDrop(source: AstId, position: Vec2) {
watch( createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source })
() => graphStore.editedNodeInfo, }
(editedInfo) => {
if (editedInfo) { // === Drag and drop ===
editWithComponentBrowser(editedInfo.id, editedInfo.initialCursorPos)
} else {
hideComponentBrowser()
}
},
)
async function handleFileDrop(event: DragEvent) { async function handleFileDrop(event: DragEvent) {
// A vertical gap between created nodes when multiple files were dropped together. // 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 === // === Color Picker ===
/** A small offset to keep the color picker slightly away from the nodes. */ /** 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)` } { 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> </script>
<template> <template>
@ -667,7 +578,7 @@ const colorPickerStyle = computed(() =>
:breadcrumbs="stackNavigator.breadcrumbLabels.value" :breadcrumbs="stackNavigator.breadcrumbLabels.value"
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value" :allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
:allowNavigationRight="stackNavigator.allowNavigationRight.value" :allowNavigationRight="stackNavigator.allowNavigationRight.value"
:zoomLevel="100.0 * graphNavigator.scale" :zoomLevel="100.0 * graphNavigator.targetScale"
@breadcrumbClick="stackNavigator.handleBreadcrumbClick" @breadcrumbClick="stackNavigator.handleBreadcrumbClick"
@back="stackNavigator.exitNode" @back="stackNavigator.exitNode"
@forward="stackNavigator.enterNextNodeFromHistory" @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"> <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 name = 'Table'
export const icon = 'table' export const icon = 'table'
export const inputType = export const inputType =
@ -65,18 +73,14 @@ declare module 'ag-grid-enterprise' {
field: string 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>
<script setup lang="ts"> <script setup lang="ts">
import { useAutoBlur } from '@/util/autoBlur' const { LicenseManager, Grid } = await import('ag-grid-enterprise')
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 props = defineProps<{ data: Data }>() const props = defineProps<{ data: Data }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -243,6 +247,7 @@ watchEffect(() => {
: { : {
type: typeof props.data, type: typeof props.data,
json: props.data, json: props.data,
// eslint-disable-next-line camelcase
all_rows_count: 1, all_rows_count: 1,
data: undefined, data: undefined,
indices: undefined, indices: undefined,
@ -384,8 +389,22 @@ onMounted(() => {
const agGridLicenseKey = import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY const agGridLicenseKey = import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY
if (typeof agGridLicenseKey === 'string') { if (typeof agGridLicenseKey === 'string') {
LicenseManager.setLicenseKey(agGridLicenseKey) LicenseManager.setLicenseKey(agGridLicenseKey)
} else { } else if (import.meta.env.DEV) {
console.warn('The AG_GRID_LICENSE_KEY is not defined.') // 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) new Grid(tableNode.value!, agGridOptions.value)
updateColumnWidths() updateColumnWidths()

View File

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

View File

@ -1,7 +1,17 @@
/** @file Vue composables for running a callback on every frame, and smooth interpolation. */ /** @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 { 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 }[] = [] 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. * Animate value over time using exponential approach.
* http://badladns.com/stories/exp-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. * 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 * @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. * 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( export function useApproach(to: WatchSource<number>, timeHorizon: number = 100, epsilon = 0.005) {
to: WatchSource<number>, return useApproachBase(
timeHorizon: number = 100, to,
epsilon = 0.005, (t, c) => t == c,
diffFn = defaultDiffFn, (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 target = watchSourceToRef(to)
const current = ref(target.value) const current: Ref<T> = shallowRef(target.value)
useRaf( useRaf(
() => target.value != current.value, () => !stable(target.value, current.value),
(_, dt) => { (_, dt) => {
const targetVal = target.value current.value = update(target.value, current.value, dt)
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
}
}
}, },
) )
@ -117,7 +152,7 @@ export function useApproach(
current.value = target.value current.value = target.value
} }
return proxyRefs({ value: current, skip }) return readonly(proxyRefs({ value: current, skip }))
} }
export function useTransitioning(observedProperties?: Set<string>) { export function useTransitioning(observedProperties?: Set<string>) {

View File

@ -1,18 +1,18 @@
/** @file A Vue composable for panning and zooming a DOM element. */ /** @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 { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard' import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' 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] 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 PAN_AND_ZOOM_DEFAULT_SCALE_RANGE: ScaleRange = [0.1, 1]
const ZOOM_LEVELS = [ 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, 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() const ZOOM_LEVELS_REVERSED = [...ZOOM_LEVELS].reverse()
/** The fraction of the next zoom level. /** The fraction of the next zoom level.
* If we are that close to next zoom level, we should choose the next one instead * 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) { export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: KeyboardComposable) {
const size = useResizeObserver(viewportNode) const size = useResizeObserver(viewportNode)
const targetCenter = shallowRef<Vec2>(Vec2.Zero) const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const targetX = computed(() => targetCenter.value.x) const center = useApproachVec(targetCenter, 100, 0.02)
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 targetScale = shallowRef(1) const targetScale = shallowRef(1)
const animatedScale = useApproach(targetScale) const scale = useApproach(targetScale)
const scale = computed({
get() {
return animatedScale.value
},
set(value) {
targetScale.value = value
animatedScale.value = value
},
})
const panPointer = usePointer((pos) => { 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) }, PointerButtonMask.Auxiliary)
function eventScreenPos(e: { clientX: number; clientY: number }): Vec2 { 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, viewportNode.value.clientWidth / rect.width,
), ),
) )
const centerX = targetCenter.value = rect.center().finiteOrZero()
!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)
} }
/** Pan to include the given prioritized list of coordinates. /** 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. */ /** Pan immediately to center the viewport at the given point, in scene coordinates. */
function scrollTo(newCenter: Vec2) { 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 let zoomPivot = Vec2.Zero
@ -136,10 +120,12 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
const prevScale = scale.value const prevScale = scale.value
updateScale((oldValue) => oldValue * Math.exp(-pos.delta.y / 100)) updateScale((oldValue) => oldValue * Math.exp(-pos.delta.y / 100))
center.value = center.value scrollTo(
.sub(zoomPivot) center.value
.scale(prevScale / scale.value) .sub(zoomPivot)
.add(zoomPivot) .scale(prevScale / scale.value)
.add(zoomPivot),
)
}, PointerButtonMask.Secondary) }, PointerButtonMask.Secondary)
const viewport = computed(() => { 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) { function updateScale(f: (value: number) => number, range: ScaleRange = DEFAULT_SCALE_RANGE) {
const oldValue = scale.value 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}. /** Step to the next level from {@link ZOOM_LEVELS}.
* @param zoomStepDelta step direction. If positive select larger zoom level; if negative select smaller. * @param zoomStepDelta step direction. If positive select larger zoom level; if negative select smaller.
* If 0, resets zoom level to 1.0. */ * If 0, resets zoom level to 1.0. */
function stepZoom(zoomStepDelta: number) { function stepZoom(zoomStepDelta: number) {
const oldValue = scale.value const oldValue = targetScale.value
const insideThreshold = (level: number) => const insideThreshold = (level: number) =>
Math.abs(oldValue - level) <= level * ZOOM_SKIP_THRESHOLD Math.abs(oldValue - level) <= level * ZOOM_SKIP_THRESHOLD
if (zoomStepDelta > 0) { if (zoomStepDelta > 0) {
const lastZoomLevel = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]! const lastZoomLevel = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!
scale.value = targetScale.value =
ZOOM_LEVELS.find((level) => level > oldValue && !insideThreshold(level)) ?? lastZoomLevel ZOOM_LEVELS.find((level) => level > oldValue && !insideThreshold(level)) ?? lastZoomLevel
} else if (zoomStepDelta < 0) { } else if (zoomStepDelta < 0) {
const firstZoomLevel = ZOOM_LEVELS[0]! const firstZoomLevel = ZOOM_LEVELS[0]!
scale.value = targetScale.value =
ZOOM_LEVELS_REVERSED.find((level) => level < oldValue && !insideThreshold(level)) ?? ZOOM_LEVELS_REVERSED.find((level) => level < oldValue && !insideThreshold(level)) ??
firstZoomLevel firstZoomLevel
} else { } else {
scale.value = 1.0 targetScale.value = 1.0
} }
scale.skip()
} }
return proxyRefs({ return proxyRefs({
@ -292,7 +280,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
} }
} else { } else {
const delta = new Vec2(e.deltaX, e.deltaY) 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) { contextmenu(e: Event) {
@ -300,8 +288,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
}, },
}, },
translate, translate,
targetScale, targetCenter: readonly(targetCenter),
scale, targetScale: readonly(targetScale),
scale: readonly(toRef(scale, 'value')),
viewBox, viewBox,
transform, transform,
/** Use this transform instead, if the element should not be scaled. */ /** 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, viewport,
stepZoom, stepZoom,
scrollTo, 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 type { NavigatorComposable } from '@/composables/navigator'
import { useSelection } from '@/composables/selection' import { useSelection, type SelectionComposable } from '@/composables/selection'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
import { type NodeId } from '@/stores/graph' import { type NodeId } from '@/stores/graph'
import type { Rect } from '@/util/data/rect' import type { Rect } from '@/util/data/rect'
const SELECTION_BRUSH_MARGIN_PX = 6 const SELECTION_BRUSH_MARGIN_PX = 6
export type GraphSelection = ReturnType<typeof injectFn> export type GraphSelection = SelectionComposable<NodeId>
export { injectFn as injectGraphSelection, provideFn as provideGraphSelection } export { injectFn as injectGraphSelection, provideFn as provideGraphSelection }
const { provideFn, injectFn } = createContextStore( const { provideFn, injectFn } = createContextStore(
'graph selection', 'graph selection',

View File

@ -748,6 +748,12 @@ export const useGraphStore = defineStore('graph', () => {
addMissingImports, addMissingImports,
addMissingImportsDisregardConflicts, addMissingImportsDisregardConflicts,
isConnectedTarget, 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) { setObservedFileName(name: string) {
observedFileName.value = name observedFileName.value = name
}, },
get observedFileName() {
return observedFileName.value
},
name: projectName, name: projectName,
displayName: projectDisplayName, displayName: projectDisplayName,
isOnLocalBackend, isOnLocalBackend,

View File

@ -1 +1,7 @@
export * from 'shared/util/data/iterable' 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) return a.equals(b)
} }
isFinite(): boolean {
return this.pos.isFinite() && this.size.isFinite()
}
offsetBy(offset: Vec2): Rect { offsetBy(offset: Vec2): Rect {
return new Rect(this.pos.add(offset), this.size) 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 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 { scale(scalar: number): Vec2 {
return new Vec2(this.x * scalar, this.y * scalar) 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 => { const node = computed((): Node => {
return { return {
outerExpr: '' as any, outerExpr: '' as any,
colorOverride: null,
zIndex: 1,
pattern: pattern.value, pattern: pattern.value,
position: position.value, position: position.value,
prefixes: { enableRecording: undefined }, prefixes: { enableRecording: undefined },