mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 15:12:15 +03:00
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:
parent
5650c7aed2
commit
4bf79776c5
@ -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,
|
||||
|
@ -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 }) => {
|
||||
|
@ -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() {
|
||||
|
@ -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']),
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
120
app/gui2/src/components/GraphEditor/clipboard.ts
Normal file
120
app/gui2/src/components/GraphEditor/clipboard.ts
Normal 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,
|
||||
}
|
||||
}
|
30
app/gui2/src/components/GraphEditor/toasts.ts
Normal file
30
app/gui2/src/components/GraphEditor/toasts.ts
Normal 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)}`),
|
||||
)
|
||||
}
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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>) {
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
71
app/gui2/src/composables/navigatorStorage.ts
Normal file
71
app/gui2/src/composables/navigatorStorage.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 },
|
||||
|
Loading…
Reference in New Issue
Block a user