mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 18:34:03 +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 { 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,
|
||||||
|
@ -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 }) => {
|
||||||
|
@ -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() {
|
||||||
|
@ -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 { 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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
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">
|
<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()
|
||||||
|
@ -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,
|
||||||
|
@ -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>) {
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
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 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',
|
||||||
|
@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 },
|
||||||
|
Loading…
Reference in New Issue
Block a user