fix edge dragging and method argument assignment (#8388)

Fixed broken edge dragging and creating nodes from ports. Added basic support for multiple output ports, driven by already existing analysis of the port binding structure. Those constructs are not yet supported by the engine (hence the error in code), but the IDE has easier time already dealing with ports as individual binding expressions, not whole nodes.

<img width="865" alt="image" src="https://github.com/enso-org/enso/assets/919491/73126593-05c0-4553-ba6d-dad97d083c48">

Also improved the ability to interpret applied method arguments. Different cases of dynamic, static calls or partially applied functions are now properly supported.

<img width="961" alt="image" src="https://github.com/enso-org/enso/assets/919491/ffe02d79-841c-411d-a218-de89c2522f7b">
This commit is contained in:
Paweł Grabarz 2023-11-27 17:34:34 +01:00 committed by GitHub
parent 9b7e3d0f16
commit 1ad7a4bf5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 508 additions and 201 deletions

View File

@ -185,6 +185,7 @@ const editorStyle = computed(() => {
@keydown.enter.stop
@wheel.stop.passive
@pointerdown.stop
@contextmenu.stop
>
<div class="resize-handle" v-on="resize.events" @dblclick="resetSize">
<svg viewBox="0 0 16 16">

View File

@ -34,7 +34,7 @@ const props = defineProps<{
navigator: ReturnType<typeof useNavigator>
initialContent: string
initialCaretPosition: ContentRange
sourceNode: Opt<ExprId>
sourcePort: Opt<ExprId>
}>()
const emit = defineEmits<{
@ -44,17 +44,15 @@ const emit = defineEmits<{
}>()
function getInitialContent(): string {
if (props.sourceNode == null) return props.initialContent
const sourceNode = props.sourceNode
const sourceNodeName = graphStore.db.getNodeMainOutputPortIdentifier(sourceNode)
if (props.sourcePort == null) return props.initialContent
const sourceNodeName = graphStore.db.getOutputPortIdentifier(props.sourcePort)
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
return sourceNodeNameWithDot + props.initialContent
}
function getInitialCaret(): ContentRange {
if (props.sourceNode == null) return props.initialCaretPosition
const sourceNode = props.sourceNode
const sourceNodeName = graphStore.db.getNodeMainOutputPortIdentifier(sourceNode)
if (props.sourcePort == null) return props.initialCaretPosition
const sourceNodeName = graphStore.db.getOutputPortIdentifier(props.sourcePort)
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
return [
props.initialCaretPosition[0] + sourceNodeNameWithDot.length,

View File

@ -110,12 +110,13 @@ function targetComponentBrowserPosition() {
/** The current position of the component browser. */
const componentBrowserPosition = ref<Vec2>(Vec2.Zero)
function sourceNodeForSelection() {
function sourcePortForSelection() {
if (graphStore.editedNodeInfo != null) return undefined
return nodeSelection.selected.values().next().value
const firstSelectedNode = set.first(nodeSelection.selected)
return graphStore.db.getNodeFirstOutputPort(firstSelectedNode)
}
const componentBrowserSourceNode = ref<ExprId | undefined>(sourceNodeForSelection())
const componentBrowserSourcePort = ref<ExprId | undefined>(sourcePortForSelection())
useEvent(window, 'keydown', (event) => {
interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event)
@ -231,7 +232,7 @@ interaction.setWhen(nodeIsBeingEdited, editingNode)
const creatingNode: Interaction = {
init: () => {
componentBrowserInputContent.value = ''
componentBrowserSourceNode.value = sourceNodeForSelection()
componentBrowserSourcePort.value = sourcePortForSelection()
componentBrowserPosition.value = targetComponentBrowserPosition()
componentBrowserVisible.value = true
},
@ -419,7 +420,7 @@ async function readNodeFromClipboard() {
}
function handleNodeOutputPortDoubleClick(id: ExprId) {
componentBrowserSourceNode.value = id
componentBrowserSourcePort.value = id
const placementEnvironment = environmentForNodes([id].values())
componentBrowserPosition.value = previousNodeDictatedPlacement(
DEFAULT_NODE_SIZE,
@ -458,7 +459,7 @@ function handleNodeOutputPortDoubleClick(id: ExprId) {
:position="componentBrowserPosition"
:initialContent="componentBrowserInputContent"
:initialCaretPosition="graphStore.editedNodeInfo?.range ?? [0, 0]"
:sourceNode="componentBrowserSourceNode"
:sourcePort="componentBrowserSourcePort"
@accepted="onComponentBrowserCommit"
@closed="onComponentBrowserCancel"
@canceled="onComponentBrowserCancel"

View File

@ -55,7 +55,7 @@ function createNodeFromEdgeDrop(source: ExprId, graphNavigator: GraphNavigator)
}
function createEdge(source: ExprId, target: ExprId) {
const ident = graph.db.getIdentifierOfConnection(source)
const ident = graph.db.getOutputPortIdentifier(source)
if (ident == null) return
// TODO: Check alias analysis to see if the binding is shadowed.
graph.setExpressionContent(target, ident)

View File

@ -13,7 +13,8 @@ import { displayedIconOf } from '@/util/getIconName'
import type { Opt } from '@/util/opt'
import { Rect } from '@/util/rect'
import { Vec2 } from '@/util/vec2'
import type { ContentRange, VisualizationIdentifier } from 'shared/yjsModel'
import { setIfUndefined } from 'lib0/map'
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
import { computed, ref, watch, watchEffect } from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300
@ -34,16 +35,19 @@ const emit = defineEmits<{
delete: []
replaceSelection: []
'update:selected': [selected: boolean]
outputPortClick: []
outputPortDoubleClick: []
outputPortClick: [portId: ExprId]
outputPortDoubleClick: [portId: ExprId]
'update:edited': [cursorPosition: number]
}>()
const nodeSelection = injectGraphSelection(true)
const graph = useGraphStore()
const isSourceOfDraggedEdge = computed(
() => graph.unconnectedEdge?.source === props.node.rootSpan.astId,
)
const outputPortsSet = computed(() => {
const bindings = graph.db.nodeOutputPorts.lookup(nodeId.value)
if (bindings.size === 0) return new Set([nodeId.value])
return bindings
})
const nodeId = computed(() => props.node.rootSpan.astId)
const rootNode = ref<HTMLElement>()
@ -66,18 +70,10 @@ watchEffect(() => {
}
})
const outputHovered = ref(false)
const hoverAnimation = useApproach(
() => (outputHovered.value || isSourceOfDraggedEdge.value ? 1 : 0),
50,
0.01,
)
const bgStyleVariables = computed(() => {
return {
'--node-width': `${nodeSize.value.x}px`,
'--node-height': `${nodeSize.value.y}px`,
'--hover-animation': `${hoverAnimation.value}`,
}
})
@ -120,7 +116,7 @@ const dragPointer = usePointer((pos, event, type) => {
})
const expressionInfo = computed(() => graph.db.getExpressionInfo(nodeId.value))
const outputTypeName = computed(() => expressionInfo.value?.typename ?? 'Unknown')
const outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown')
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value))
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
@ -129,7 +125,7 @@ const icon = computed(() => {
return displayedIconOf(
suggestionEntry.value,
expressionInfo?.methodCall?.methodPointer,
outputTypeName.value,
outputPortLabel.value,
)
})
@ -189,12 +185,56 @@ function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): numb
return 0
}
const handlePortClick = useDoubleClick(
() => emit('outputPortClick'),
() => {
emit('outputPortDoubleClick')
const handlePortClick = useDoubleClick<[portId: ExprId]>(
(portId) => emit('outputPortClick', portId),
(portId) => {
emit('outputPortDoubleClick', portId)
},
).handleClick
interface PortData {
clipRange: [number, number]
label: string
portId: ExprId
}
const outputPorts = computed((): PortData[] => {
const ports = outputPortsSet.value
const numPorts = ports.size
return Array.from(ports, (portId, index) => {
const labelIdent = numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) + ': ' : ''
const labelType = graph.db.getExpressionInfo(portId)?.typename ?? 'Unknown'
return {
clipRange: [index / numPorts, (index + 1) / numPorts],
label: labelIdent + labelType,
portId,
}
})
})
const outputHovered = ref<ExprId>()
const hoverAnimations = new Map<ExprId, ReturnType<typeof useApproach>>()
watchEffect(() => {
const ports = outputPortsSet.value
for (const key of hoverAnimations.keys()) if (!ports.has(key)) hoverAnimations.delete(key)
for (const port of outputPortsSet.value) {
setIfUndefined(hoverAnimations, port, () =>
useApproach(
() => (outputHovered.value === port || graph.unconnectedEdge?.target === port ? 1 : 0),
50,
0.01,
),
)
}
})
function portGroupStyle(port: PortData) {
const [start, end] = port.clipRange
return {
'--hover-animation': hoverAnimations.get(port.portId)?.value ?? 0,
'--port-clip-start': start,
'--port-clip-end': end,
}
}
</script>
<template>
@ -245,14 +285,20 @@ const handlePortClick = useDoubleClick(
</div>
<svg class="bgPaths" :style="bgStyleVariables">
<rect class="bgFill" />
<rect
class="outputPortHoverArea"
@pointerenter="outputHovered = true"
@pointerleave="outputHovered = false"
@pointerdown="handlePortClick()"
/>
<rect class="outputPort" />
<text class="outputTypeName">{{ outputTypeName }}</text>
<template v-for="port of outputPorts" :key="port.portId">
<g :style="portGroupStyle(port)">
<g class="portClip">
<rect
class="outputPortHoverArea"
@pointerenter="outputHovered = port.portId"
@pointerleave="outputHovered = undefined"
@pointerdown.stop.prevent="handlePortClick(port.portId)"
/>
<rect class="outputPort" />
</g>
<text class="outputPortLabel">{{ port.label }}</text>
</g>
</template>
</svg>
</div>
</template>
@ -310,7 +356,14 @@ const handlePortClick = useDoubleClick(
pointer-events: all;
}
.outputTypeName {
.portClip {
clip-path: inset(
0 calc((1 - var(--port-clip-end)) * (100% + 1px) - 0.5px) 0
calc(var(--port-clip-start) * (100% + 1px) + 0.5px)
);
}
.outputPortLabel {
user-select: none;
pointer-events: none;
z-index: 10;
@ -421,6 +474,7 @@ const handlePortClick = useDoubleClick(
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.2s ease-in-out;
white-space: nowrap;
}
.GraphNode .selection:hover + .binding,

View File

@ -19,7 +19,7 @@ const selection = injectGraphSelection(true)
const navigator = injectGraphNavigator(true)
const emit = defineEmits<{
nodeOutputPortDoubleClick: [nodeId: ExprId]
nodeOutputPortDoubleClick: [portId: ExprId]
}>()
function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
@ -55,7 +55,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
:edited="id === graphStore.editedNodeInfo?.id"
@update:edited="graphStore.setEditedNode(id, $event)"
@updateRect="graphStore.updateNodeRect(id, $event)"
@delete="graphStore.deleteNode(id)"
@delete="graphStore.deleteNode"
@pointerenter="hoverNode(id)"
@pointerleave="hoverNode(undefined)"
@updateContent="updateNodeContent(id, $event)"
@ -63,8 +63,8 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
@dragging="nodeIsDragged(id, $event)"
@draggingCommited="dragging.finishDrag()"
@outputPortClick="graphStore.createEdgeFromOutput(id)"
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', id)"
@outputPortClick="graphStore.createEdgeFromOutput"
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)"
/>
<UploadingFile
v-for="(nameAndFile, index) in uploadingFiles"

View File

@ -5,9 +5,13 @@ import {
type WidgetInput,
} from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { injectWidgetUsageInfo, provideWidgetUsageInfo } from '@/providers/widgetUsageInfo'
import {
injectWidgetUsageInfo,
provideWidgetUsageInfo,
usageKeyForInput,
} from '@/providers/widgetUsageInfo'
import { AstExtended } from '@/util/ast'
import { computed, proxyRefs, ref, toRef } from 'vue'
import { computed, proxyRefs, ref } from 'vue'
const props = defineProps<{ input: WidgetInput; nest?: boolean }>()
defineOptions({
@ -17,8 +21,11 @@ defineOptions({
const registry = injectWidgetRegistry()
const tree = injectWidgetTree()
const parentUsageInfo = injectWidgetUsageInfo(true)
const usageKey = computed(() => usageKeyForInput(props.input))
const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value)
const whitespace = computed(() =>
parentUsageInfo?.input !== props.input && props.input instanceof AstExtended
!sameInputAsParent.value && props.input instanceof AstExtended
? ' '.repeat(props.input.whitespaceLength() ?? 0)
: '',
)
@ -26,7 +33,7 @@ const whitespace = computed(() =>
// TODO: Fetch dynamic widget config from engine. [#8260]
const dynamicConfig = ref<WidgetConfiguration>()
const sameInputParentWidgets = computed(() =>
parentUsageInfo?.input === props.input ? parentUsageInfo?.previouslyUsed : undefined,
sameInputAsParent.value ? parentUsageInfo?.previouslyUsed : undefined,
)
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
@ -42,13 +49,19 @@ const selectedWidget = computed(() => {
})
provideWidgetUsageInfo(
proxyRefs({
input: toRef(props, 'input'),
usageKey,
nesting,
previouslyUsed: computed(() => {
const nextSameNodeWidgets = new Set(sameInputParentWidgets.value)
if (selectedWidget.value != null) nextSameNodeWidgets.add(selectedWidget.value)
if (selectedWidget.value != null) {
nextSameNodeWidgets.add(selectedWidget.value.default)
if (selectedWidget.value.widgetDefinition.prevent) {
for (const prevented of selectedWidget.value.widgetDefinition.prevent)
nextSameNodeWidgets.add(prevented)
}
}
return nextSameNodeWidgets
}),
nesting,
}),
)
const spanStart = computed(() => {
@ -60,7 +73,7 @@ const spanStart = computed(() => {
<template>
{{ whitespace
}}<component
:is="selectedWidget"
:is="selectedWidget.default"
v-if="selectedWidget"
ref="rootNode"
:input="props.input"
@ -69,4 +82,11 @@ const spanStart = computed(() => {
:data-span-start="spanStart"
:data-nesting="nesting"
/>
<span
v-else
:title="`No matching widget for input: ${
Object.getPrototypeOf(props.input)?.constructor?.name ?? JSON.stringify(props.input)
}`"
>🚫</span
>
</template>

View File

@ -1,11 +1,20 @@
<script setup lang="ts">
import { Tree } from '@/generated/ast'
import { ForcePort } from '@/providers/portInfo'
import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore } from '@/stores/graph'
import { useTransitioning } from '@/util/animation'
import type { AstExtended } from '@/util/ast'
import { toRef } from 'vue'
import { computed, toRef } from 'vue'
import NodeWidget from './NodeWidget.vue'
const props = defineProps<{ ast: AstExtended }>()
const graph = useGraphStore()
const rootPort = computed(() => {
return props.ast.isTree(Tree.Type.Ident) && !graph.db.isKnownFunctionCall(props.ast.astId)
? new ForcePort(props.ast)
: props.ast
})
const observedLayoutTransitions = new Set([
'margin-left',
@ -26,7 +35,7 @@ provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
<template>
<span class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
<NodeWidget :input="ast" />
<NodeWidget :input="rootPort" />
</span>
</template>

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { ForcePort } from '@/providers/portInfo'
import { defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AstExtended } from '@/util/ast'
import { ArgumentApplication } from '@/util/callTree'
import { computed } from 'vue'
import { ForcePort } from './WidgetPort.vue'
const props = defineProps(widgetProps(widgetDefinition))
const targetMaybePort = computed(() =>

View File

@ -1,24 +1,38 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { Tree } from '@/generated/ast'
import { defineWidget, Score, widgetProps } from '@/providers/widgetRegistry'
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { useGraphStore } from '@/stores/graph'
import { AstExtended } from '@/util/ast'
import { ArgumentApplication } from '@/util/callTree'
import { computed } from 'vue'
import { computed, proxyRefs } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
provideFunctionInfo(
proxyRefs({
callId: computed(() => props.input.astId),
}),
)
const application = computed(() => {
const astId = props.input.astId
if (astId == null) return props.input
const info = graph.db.getMethodCallInfo(astId)
return ArgumentApplication.FromAstWithInfo(
props.input,
info?.suggestion.arguments,
info?.methodCall.notAppliedArguments ?? [],
const interpreted = ArgumentApplication.Interpret(props.input, info == null)
const noArgsCall =
interpreted.kind === 'prefix' ? graph.db.getMethodCall(interpreted.func.astId) : undefined
return ArgumentApplication.FromInterpretedWithInfo(
interpreted,
noArgsCall,
info?.methodCall,
info?.suggestion,
!info?.staticallyApplied,
)
})
</script>
@ -26,11 +40,29 @@ const application = computed(() => {
export const widgetDefinition = defineWidget(
AstExtended.isTree([Tree.Type.App, Tree.Type.NamedApp, Tree.Type.Ident, Tree.Type.OprApp]),
{
priority: 8,
priority: -10,
score: (props, db) => {
const ast = props.input
if (ast.astId == null) return Score.Mismatch
const prevFunctionState = injectFunctionInfo(true)
// It is possible to try to render the same function application twice, e.g. when detected an
// application with no arguments applied yet, but the application target is also an infix call.
// In that case, the reentrant call method info must be ignored to not create an infinite loop,
// and to resolve the infix call as its own application.
if (prevFunctionState?.callId === ast.astId) return Score.Mismatch
if (ast.isTree([Tree.Type.App, Tree.Type.NamedApp, Tree.Type.OprApp])) return Score.Perfect
return ast.astId && db.isMethodCall(ast.astId) ? Score.Perfect : Score.Mismatch
const info = db.getMethodCallInfo(ast.astId)
if (
prevFunctionState != null &&
info?.staticallyApplied === true &&
props.input.isTree(Tree.Type.Ident)
) {
return Score.Mismatch
}
return info != null ? Score.Perfect : Score.Mismatch
},
},
)

View File

@ -2,7 +2,7 @@
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection'
import { injectPortInfo, providePortInfo } from '@/providers/portInfo'
import { ForcePort, injectPortInfo, providePortInfo } from '@/providers/portInfo'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { useGraphStore } from '@/stores/graph'
@ -122,17 +122,6 @@ const innerWidget = computed(() => {
</script>
<script lang="ts">
export class ForcePort {
constructor(public ast: AstExtended) {}
}
declare const ForcePortKey: unique symbol
declare module '@/providers/widgetRegistry' {
interface WidgetInputTypes {
[ForcePortKey]: ForcePort
}
}
export const widgetDefinition = defineWidget(
[
ForcePort,

View File

@ -10,7 +10,7 @@ const props = defineProps(widgetProps(widgetDefinition))
export const widgetDefinition = defineWidget([ArgumentAst, ArgumentPlaceholder], {
priority: -1,
score: (props) =>
props.nesting < 2 && props.input.kind == ApplicationKind.Prefix
props.nesting < 2 && props.input.kind === ApplicationKind.Prefix
? Score.Perfect
: Score.Mismatch,
})

View File

@ -1,19 +1,22 @@
export function useDoubleClick(onClick: Function, onDoubleClick: Function) {
export function useDoubleClick<Args extends any[]>(
onClick: (...args: Args) => void,
onDoubleClick: (...args: Args) => void,
) {
const timeBetweenClicks = 200
let clickCount = 0
let singleClickTimer: ReturnType<typeof setTimeout>
const handleClick = () => {
const handleClick = (...args: Args) => {
clickCount++
if (clickCount === 1) {
onClick()
onClick(...args)
singleClickTimer = setTimeout(() => {
clickCount = 0
}, timeBetweenClicks)
} else if (clickCount === 2) {
clearTimeout(singleClickTimer)
clickCount = 0
onDoubleClick()
onDoubleClick(...args)
}
}
return { handleClick }

View File

@ -77,8 +77,8 @@ describe('WidgetRegistry', () => {
test('selects a widget based on the input type', () => {
const forAst = registry.select({ input: someAst, config: undefined, nesting: 0 })
const forArg = registry.select({ input: somePlaceholder, config: undefined, nesting: 0 })
expect(forAst).toStrictEqual(widgetA.default)
expect(forArg).toStrictEqual(widgetB.default)
expect(forAst).toStrictEqual(widgetA)
expect(forArg).toStrictEqual(widgetB)
})
test('selects a widget outside of the excluded set', () => {
@ -90,8 +90,8 @@ describe('WidgetRegistry', () => {
{ input: somePlaceholder, config: undefined, nesting: 0 },
new Set([widgetB.default]),
)
expect(forAst).toStrictEqual(widgetC.default)
expect(forArg).toStrictEqual(widgetC.default)
expect(forAst).toStrictEqual(widgetC)
expect(forArg).toStrictEqual(widgetC)
})
test('returns undefined when all options are exhausted', () => {
@ -111,7 +111,7 @@ describe('WidgetRegistry', () => {
{ input: blankAst, config: undefined, nesting: 0 },
new Set([widgetA.default, widgetD.default]),
)
expect(selectedFirst).toStrictEqual(widgetD.default)
expect(selectedNext).toStrictEqual(widgetC.default)
expect(selectedFirst).toStrictEqual(widgetD)
expect(selectedNext).toStrictEqual(widgetC)
})
})

View File

@ -0,0 +1,10 @@
import { identity } from '@vueuse/core'
import type { ExprId } from 'shared/yjsModel'
import { createContextStore } from '.'
interface FunctionInfo {
callId: ExprId | undefined
}
export { injectFn as injectFunctionInfo, provideFn as provideFunctionInfo }
const { provideFn, injectFn } = createContextStore('Function info', identity<FunctionInfo>)

View File

@ -1,5 +1,7 @@
import type { AstExtended } from '@/util/ast'
import { identity } from '@vueuse/core'
import { createContextStore } from '.'
import { GetUsageKey } from './widgetUsageInfo'
interface PortInfo {
portId: string
@ -8,3 +10,16 @@ interface PortInfo {
export { injectFn as injectPortInfo, provideFn as providePortInfo }
const { provideFn, injectFn } = createContextStore('Port info', identity<PortInfo>)
/**
* Widget input type that can be used to force a specific AST to be rendered as a port widget,
* even if it wouldn't normally be rendered as such.
*/
export class ForcePort {
constructor(public ast: AstExtended) {
if (ast instanceof ForcePort) throw new Error('ForcePort cannot be nested')
}
[GetUsageKey]() {
return this.ast
}
}

View File

@ -59,7 +59,7 @@ export enum Score {
export interface WidgetProps<T> {
input: T
config: WidgetConfiguration | undefined
config?: WidgetConfiguration | undefined
nesting: number
}
@ -111,6 +111,9 @@ export interface WidgetOptions<T extends WidgetInput> {
* pass the input filter.
*/
score?: ((info: WidgetProps<T>, db: GraphDb) => Score) | Score
// A list of widget kinds that will be prevented from being used on the same node as this widget,
// once this widget is used.
prevent?: WidgetComponent<any>[]
}
export interface WidgetDefinition<T extends WidgetInput> {
@ -133,6 +136,7 @@ export interface WidgetDefinition<T extends WidgetInput> {
* filter. When not provided, the widget will be considered a perfect match for all inputs that
*/
score: (props: WidgetProps<T>, db: GraphDb) => Score
prevent: WidgetComponent<any>[] | undefined
}
/**
@ -195,6 +199,7 @@ export function defineWidget<M extends InputMatcher<any> | InputMatcher<any>[]>(
priority: definition.priority,
match: makeInputMatcher<InputTy<M>>(matchInputs),
score,
prevent: definition.prevent,
}
}
@ -232,15 +237,22 @@ export class WidgetRegistry {
loadBuiltins() {
const bulitinWidgets = import.meta.glob('@/components/GraphEditor/widgets/*.vue')
for (const [path, asyncModule] of Object.entries(bulitinWidgets)) {
this.loadAndCheckWidgetModule(asyncModule(), path)
}
this.loadAndCheckWidgetModules(Object.entries(bulitinWidgets))
}
async loadAndCheckWidgetModule(asyncModule: Promise<unknown>, path: string) {
const m = await asyncModule
if (isWidgetModule(m)) this.registerWidgetModule(m)
else console.error('Invalid widget module:', path, m)
async loadAndCheckWidgetModules(
asyncModules: [path: string, asyncModule: () => Promise<unknown>][],
) {
const modules = await Promise.allSettled(
asyncModules.map(([path, mod]) => mod().then((m) => [path, m] as const)),
)
for (const result of modules) {
if (result.status === 'fulfilled') {
const [path, mod] = result.value
if (isWidgetModule(mod)) this.registerWidgetModule(mod)
else console.error('Invalid widget module:', path, mod)
}
}
}
/**
@ -261,26 +273,26 @@ export class WidgetRegistry {
select<T extends WidgetInput>(
props: WidgetProps<T>,
alreadyUsed?: Set<WidgetComponent<any>>,
): WidgetComponent<T> | undefined {
): WidgetModule<T> | undefined {
// The type and score of the best widget found so far.
let best: WidgetComponent<T> | undefined = undefined
let best: WidgetModule<T> | undefined = undefined
let bestScore = Score.Mismatch
// Iterate over all loaded widget kinds in order of decreasing priority.
for (const module of this.sortedModules.value) {
for (const widgetModule of this.sortedModules.value) {
// Skip matching widgets that are declared as already used.
if (alreadyUsed && alreadyUsed.has(module.default)) continue
if (alreadyUsed && alreadyUsed.has(widgetModule.default)) continue
// Skip widgets that don't match the input type.
if (!module.widgetDefinition.match(props.input)) continue
if (!widgetModule.widgetDefinition.match(props.input)) continue
// Perform a match and update the best widget if the match is better than the previous one.
const score = module.widgetDefinition.score(props, this.db)
const score = widgetModule.widgetDefinition.score(props, this.db)
// If we found a perfect match, we can return immediately, as there can be no better match.
if (score === Score.Perfect) return module.default
if (score === Score.Perfect) return widgetModule
if (score > bestScore) {
bestScore = score
best = module.default
best = widgetModule
}
}
// Once we've checked all widgets, return the best match found, if any.

View File

@ -11,8 +11,31 @@ const { provideFn, injectFn } = createContextStore('Widget usage info', identity
* AST node.
*/
interface WidgetUsageInfo {
input: WidgetInput
/**
* An object which is used to distinguish between distinct nodes in a widget tree. When selecting
* a widget type for an input value with the same `usageKey` as in parent widget, the widget types
* that were previously used for this input value are not considered for selection. The key is
* determined by the widget input's method defined on {@link GetUsageKey} symbol key. When no such
* method is defined, the input value itself is used as the key.
*/
usageKey: unknown
/** All widget types that were rendered so far using the same AST node. */
previouslyUsed: Set<WidgetComponent<any>>
nesting: number
}
/**
* A symbol key used for defining a widget input method's usage key. A method with this key can be
* declared for widget input types that are not unique by themselves, but are just a thin wrapper
* around another input value, and don't want to be considered as a completely separate entity for
* the purposes of widget type selection.
*/
export const GetUsageKey = Symbol('GetUsageKey')
export function usageKeyForInput(widget: WidgetInput): unknown {
if (GetUsageKey in widget && typeof widget[GetUsageKey] === 'function') {
return widget[GetUsageKey]()
} else {
return widget
}
}

View File

@ -54,14 +54,14 @@ test('Reading graph from definition', () => {
expect(db.getIdentDefiningNode('node1')).toBe(id04)
expect(db.getIdentDefiningNode('node2')).toBe(id08)
expect(db.getIdentDefiningNode('function')).toBeUndefined()
expect(db.getNodeMainOutputPortIdentifier(id04)).toBe('node1')
expect(db.getNodeMainOutputPortIdentifier(id08)).toBe('node2')
expect(db.getNodeMainOutputPortIdentifier(id03)).toBeUndefined()
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id04))).toBe('node1')
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id08))).toBe('node2')
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id03))).toBe('node1')
// Commented the connection from input node, as we don't support them yet.
expect(Array.from(db.connections.allForward(), ([key]) => key)).toEqual([id03])
// expect(Array.from(db.connections.lookup(id02))).toEqual([id05])
expect(Array.from(db.connections.lookup(id03))).toEqual([id09])
// expect(db.getIdentifierOfConnection(id02)).toBe('a')
expect(db.getIdentifierOfConnection(id03)).toBe('node1')
// expect(db.getOutputPortIdentifier(id02)).toBe('a')
expect(db.getOutputPortIdentifier(id03)).toBe('node1')
})

View File

@ -1,6 +1,6 @@
import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDatabase'
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { byteArraysEqual, tryGetIndex } from '@/util/array'
import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/array'
import { Ast, AstExtended } from '@/util/ast'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { colorFromString } from '@/util/colors'
@ -10,7 +10,7 @@ import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reac
import type { Opt } from '@/util/opt'
import { Vec2 } from '@/util/vec2'
import * as set from 'lib0/set'
import type { MethodCall } from 'shared/languageServerTypes'
import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes'
import {
IdMap,
visMetadataEquals,
@ -150,17 +150,18 @@ export class GraphDb {
return Array.from(allTargets(this))
})
/** First output port of the node.
*
* When the node will be marked as source node for a new one (i.e. the node will be selected
* when adding), the resulting connection's source will be the main port.
*/
nodeMainOutputPort = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
/** Output port bindings of the node. Lists all bindings that can be dragged out from a node. */
nodeOutputPorts = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
if (entry.pattern == null) return []
for (const ast of entry.pattern.walkRecursive()) {
if (this.bindings.bindings.has(ast.astId)) return [[id, ast.astId]]
}
return []
const ports = new Set<ExprId>()
entry.pattern.visitRecursive((ast) => {
if (this.bindings.bindings.has(ast.astId)) {
ports.add(ast.astId)
return false
}
return true
})
return Array.from(ports, (port) => [id, port])
})
nodeMainSuggestion = new ReactiveMapping(this.nodeIdToNode, (id, _entry) => {
@ -182,9 +183,8 @@ export class GraphDb {
return groupColorStyle(group)
})
getNodeMainOutputPortIdentifier(id: ExprId): string | undefined {
const mainPort = set.first(this.nodeMainOutputPort.lookup(id))
return mainPort != null ? this.bindings.bindings.get(mainPort)?.identifier : undefined
getNodeFirstOutputPort(id: ExprId): ExprId {
return set.first(this.nodeOutputPorts.lookup(id)) ?? id
}
getExpressionNodeId(exprId: ExprId | undefined): ExprId | undefined {
@ -204,7 +204,7 @@ export class GraphDb {
return this.valuesRegistry.getExpressionInfo(id)
}
getIdentifierOfConnection(source: ExprId): string | undefined {
getOutputPortIdentifier(source: ExprId): string | undefined {
return this.bindings.bindings.get(source)?.identifier
}
@ -212,20 +212,35 @@ export class GraphDb {
return this.bindings.identifierToBindingId.hasKey(ident)
}
isMethodCall(id: ExprId): boolean {
return this.getExpressionInfo(id)?.methodCall != null
isKnownFunctionCall(id: ExprId): boolean {
return this.getMethodCallInfo(id) != null
}
getMethodCall(id: ExprId): MethodCall | undefined {
const info = this.getExpressionInfo(id)
if (info == null) return
return (
info.methodCall ?? (info.payload.type === 'Value' ? info.payload.functionSchema : undefined)
)
}
getMethodCallInfo(
id: ExprId,
): { methodCall: MethodCall; suggestion: SuggestionEntry } | undefined {
const methodCall = this.getExpressionInfo(id)?.methodCall
):
| { methodCall: MethodCall; suggestion: SuggestionEntry; staticallyApplied: boolean }
| undefined {
const info = this.getExpressionInfo(id)
if (info == null) return
const payloadFuncSchema =
info.payload.type === 'Value' ? info.payload.functionSchema : undefined
const methodCall = info.methodCall ?? payloadFuncSchema
if (methodCall == null) return
const suggestionId = this.suggestionDb.findByMethodPointer(methodCall.methodPointer)
if (suggestionId == null) return
const suggestion = this.suggestionDb.get(suggestionId)
if (suggestion == null) return
return { methodCall, suggestion }
const staticallyApplied = mathodCallEquals(methodCall, payloadFuncSchema)
return { methodCall, suggestion, staticallyApplied }
}
getNodeColorStyle(id: ExprId): string {
@ -345,3 +360,13 @@ function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator<Ast.Tre
}
}
}
function mathodCallEquals(a: MethodCall | undefined, b: MethodCall | undefined): boolean {
return (
a === b ||
(a != null &&
b != null &&
methodPointerEquals(a.methodPointer, b.methodPointer) &&
arrayEquals(a.notAppliedArguments, b.notAppliedArguments))
)
}

View File

@ -219,9 +219,9 @@ export const useGraphStore = defineStore('graph', () => {
// Create a node from a source expression, and insert it into the graph. The return value will be
// the new node's ID, or `null` if the node creation fails.
function createNodeFromSource(position: Vec2, source: ExprId): Opt<ExprId> {
const sourceNodeName = db.getNodeMainOutputPortIdentifier(source)
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
return createNode(position, sourceNodeNameWithDot)
const sourcePortName = db.getOutputPortIdentifier(source)
const sourcePortNameWithDot = sourcePortName ? sourcePortName + '.' : ''
return createNode(position, sourcePortNameWithDot)
}
function deleteNode(id: ExprId) {

View File

@ -38,7 +38,7 @@ export function partitionPoint<T>(
}
/** Index into an array using specified index. When the index is nullable, returns undefined. */
export function tryGetIndex<T>(arr: Opt<T[]>, index: Opt<number>): T | undefined {
export function tryGetIndex<T>(arr: Opt<readonly T[]>, index: Opt<number>): T | undefined {
return index == null ? undefined : arr?.[index]
}
@ -49,3 +49,7 @@ export function tryGetIndex<T>(arr: Opt<T[]>, index: Opt<number>): T | undefined
export function byteArraysEqual(a: Opt<Uint8Array>, b: Opt<Uint8Array>): boolean {
return a === b || (a != null && b != null && indexedDB.cmp(a, b) === 0)
}
export function arrayEquals<T>(a: T[], b: T[]): boolean {
return a === b || (a.length === b.length && a.every((v, i) => v === b[i]))
}

View File

@ -1,23 +1,39 @@
import { Tree } from '@/generated/ast'
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { AstExtended } from '@/util/ast'
import { ArgumentApplication, ArgumentPlaceholder } from '@/util/callTree'
import { isSome } from '@/util/opt'
import type { SuggestionEntryArgument } from 'shared/languageServerTypes/suggestions'
import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
import type { MethodCall } from 'shared/languageServerTypes'
import { IdMap } from 'shared/yjsModel'
import { assert, expect, test } from 'vitest'
const knownArguments: SuggestionEntryArgument[] = [
{ name: 'a', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'b', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'c', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'd', type: 'Any', isSuspended: false, hasDefault: false },
]
const mockSuggestion: SuggestionEntry = {
kind: SuggestionKind.Method,
name: 'func' as Identifier,
definedIn: 'Foo.Bar' as QualifiedName,
selfType: 'Foo.Bar',
returnType: 'Any',
arguments: [
{ name: 'self', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'a', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'b', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'c', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'd', type: 'Any', isSuspended: false, hasDefault: false },
],
documentation: [],
isPrivate: false,
isUnstable: false,
aliases: [],
}
function testArgs(paddedExpression: string, pattern: string) {
const expression = paddedExpression.trim()
const notAppliedArguments = pattern
.split(' ')
.map((p) => (p.startsWith('?') ? knownArguments.findIndex((k) => p.endsWith(k.name)) : null))
.map((p) =>
p.startsWith('?') ? mockSuggestion.arguments.findIndex((k) => p.slice(1) === k.name) : null,
)
.filter(isSome)
test(`argument list: ${paddedExpression} ${pattern}`, () => {
@ -30,7 +46,31 @@ function testArgs(paddedExpression: string, pattern: string) {
return first.value.expression
})
const call = ArgumentApplication.FromAstWithInfo(ast, knownArguments, notAppliedArguments)
const methodCall: MethodCall = {
methodPointer: {
name: 'func',
definedOnType: 'Foo.Bar',
module: 'Foo.Bar',
},
notAppliedArguments,
}
const funcMethodCall: MethodCall = {
methodPointer: {
name: 'func',
definedOnType: 'Foo.Bar',
module: 'Foo.Bar',
},
notAppliedArguments: [1, 2, 3, 4],
}
const interpreted = ArgumentApplication.Interpret(ast, false)
const call = ArgumentApplication.FromInterpretedWithInfo(
interpreted,
funcMethodCall,
methodCall,
mockSuggestion,
)
assert(call instanceof ArgumentApplication)
expect(printArgPattern(call)).toEqual(pattern)
})
@ -46,7 +86,8 @@ function printArgPattern(application: ArgumentApplication | AstExtended<Tree>) {
: current.appTree?.isTree(Tree.Type.NamedApp)
? '='
: '@'
parts.push(sigil + (current.argument.info?.name ?? '_'))
const argInfo = 'info' in current.argument ? current.argument.info : undefined
parts.push(sigil + (argInfo?.name ?? '_'))
current = current.target
}
return parts.reverse().join(' ')

View File

@ -1,5 +1,7 @@
import { Token, Tree } from '@/generated/ast'
import type { SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
import type { ForcePort } from '@/providers/portInfo'
import type { SuggestionEntry, SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
import type { MethodCall } from 'shared/languageServerTypes'
import { tryGetIndex } from './array'
import type { AstExtended } from './ast'
@ -29,63 +31,120 @@ export class ArgumentAst {
) {}
}
type InterpretedCall = AnalyzedInfix | AnalyzedPrefix
interface AnalyzedInfix {
kind: 'infix'
appTree: AstExtended<Tree.OprApp>
operator: AstExtended<Token.Operator> | undefined
lhs: AstExtended<Tree> | undefined
rhs: AstExtended<Tree> | undefined
}
interface AnalyzedPrefix {
kind: 'prefix'
func: AstExtended<Tree>
args: FoundApplication[]
}
interface FoundApplication {
appTree: AstExtended<Tree.App | Tree.NamedApp>
argument: AstExtended<Tree>
argName: string | undefined
}
export class ArgumentApplication {
private constructor(
public appTree: AstExtended<Tree> | undefined,
public target: ArgumentApplication | AstExtended<Tree> | ArgumentPlaceholder | ArgumentAst,
public infixOperator: AstExtended<Token.Operator> | undefined,
public argument: ArgumentAst | ArgumentPlaceholder,
public argument: AstExtended<Tree> | ArgumentAst | ArgumentPlaceholder,
) {}
static FromAstWithInfo(
callRoot: AstExtended<Tree>,
knownArguments: SuggestionEntryArgument[] | undefined,
notAppliedArguments: number[],
): ArgumentApplication | AstExtended<Tree> {
interface FoundApplication {
appTree: AstExtended<Tree.App | Tree.NamedApp>
argument: AstExtended<Tree>
argName: string | undefined
static Interpret(callRoot: AstExtended<Tree>, allowInterpretAsInfix: boolean): InterpretedCall {
if (allowInterpretAsInfix && callRoot.isTree([Tree.Type.OprApp])) {
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
return {
kind: 'infix',
appTree: callRoot,
operator: callRoot.tryMap((t) => (t.opr.ok ? t.opr.value : undefined)),
lhs: callRoot.tryMap((t) => t.lhs),
rhs: callRoot.tryMap((t) => t.rhs),
}
} else {
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
const foundApplications: FoundApplication[] = []
let nextApplication = callRoot
// Traverse the AST and find all arguments applied in sequence to the same function.
while (nextApplication.isTree([Tree.Type.App, Tree.Type.NamedApp])) {
const argument = nextApplication.map((t) => t.arg)
const argName = nextApplication.isTree(Tree.Type.NamedApp)
? nextApplication.map((t) => t.name).repr()
: undefined
foundApplications.push({
appTree: nextApplication,
argument,
argName,
})
nextApplication = nextApplication.map((t) => t.func)
}
return {
kind: 'prefix',
func: nextApplication,
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
// need to reverse them back to match them with the order in suggestion entry.
args: foundApplications.reverse(),
}
}
}
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
if (callRoot.isTree([Tree.Type.OprApp])) {
const oprApp = callRoot
const infixOperator = callRoot.tryMap((t) => (t.opr.ok ? t.opr.value : undefined))
static FromInterpretedWithInfo(
interpreted: InterpretedCall,
noArgsCall: MethodCall | undefined,
appMethodCall: MethodCall | undefined,
suggestion: SuggestionEntry | undefined,
stripSelfArgument: boolean = false,
): ArgumentApplication | AstExtended<Tree> {
const knownArguments = suggestion?.arguments
if (interpreted.kind === 'infix') {
const isAccess = isAccessOperator(interpreted.operator)
const argFor = (key: 'lhs' | 'rhs', index: number) => {
const tree = oprApp.tryMap((t) => t[key])
const tree = interpreted[key]
const info = tryGetIndex(knownArguments, index) ?? unknownArgInfoNamed(key)
return tree != null
? new ArgumentAst(tree, 0, info, ApplicationKind.Infix)
? isAccess
? tree
: new ArgumentAst(tree, 0, info, ApplicationKind.Infix)
: new ArgumentPlaceholder(0, info, ApplicationKind.Infix)
}
return new ArgumentApplication(callRoot, argFor('lhs', 0), infixOperator, argFor('rhs', 1))
return new ArgumentApplication(
interpreted.appTree,
argFor('lhs', 0),
interpreted.operator,
argFor('rhs', 1),
)
}
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
const foundApplications: FoundApplication[] = []
let nextApplication = callRoot
// Traverse the AST and find all arguments applied in sequence to the same function.
while (nextApplication.isTree([Tree.Type.App, Tree.Type.NamedApp])) {
const argument = nextApplication.map((t) => t.arg)
const argName = nextApplication.isTree(Tree.Type.NamedApp)
? nextApplication.map((t) => t.name).repr()
: undefined
foundApplications.push({
appTree: nextApplication,
argument,
argName,
})
nextApplication = nextApplication.map((t) => t.func)
}
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
// need to reverse them back to match them with the order in suggestion entry.
const foundArgs = foundApplications.reverse()
const notAppliedArguments = appMethodCall?.notAppliedArguments ?? []
const placeholdersToInsert = notAppliedArguments.slice()
const notAppliedSet = new Set(notAppliedArguments)
const argumentsLeftToMatch = Array.from(knownArguments ?? [], (_, i) => i).filter(
(_, i) => !notAppliedSet.has(i),
const allPossiblePrefixArguments = Array.from(knownArguments ?? [], (_, i) => i)
// when this is a method application with applied 'self', the subject of the access operator is
// treated as a 'self' argument.
if (
stripSelfArgument &&
knownArguments?.[0]?.name === 'self' &&
getAccessOprSubject(interpreted.func) != null
) {
allPossiblePrefixArguments.shift()
}
const notAppliedOriginally = new Set(
noArgsCall?.notAppliedArguments ?? allPossiblePrefixArguments,
)
const argumentsLeftToMatch = allPossiblePrefixArguments.filter(
(i) => !notAppliedSet.has(i) && notAppliedOriginally.has(i),
)
const prefixArgsToDisplay: Array<{
@ -94,8 +153,8 @@ export class ArgumentApplication {
}> = []
function insertPlaceholdersUpto(index: number, appTree: AstExtended<Tree>) {
while (notAppliedArguments[0] != null && notAppliedArguments[0] < index) {
const argIndex = notAppliedArguments.shift()
while (placeholdersToInsert[0] != null && placeholdersToInsert[0] < index) {
const argIndex = placeholdersToInsert.shift()
const argInfo = tryGetIndex(knownArguments, argIndex)
if (argIndex != null && argInfo != null)
prefixArgsToDisplay.push({
@ -105,7 +164,7 @@ export class ArgumentApplication {
}
}
for (const realArg of foundArgs) {
for (const realArg of interpreted.args) {
if (realArg.argName == null) {
const argIndex = argumentsLeftToMatch.shift()
if (argIndex != null) insertPlaceholdersUpto(argIndex, realArg.appTree)
@ -139,12 +198,12 @@ export class ArgumentApplication {
}
}
insertPlaceholdersUpto(Infinity, nextApplication)
insertPlaceholdersUpto(Infinity, interpreted.func)
return prefixArgsToDisplay.reduce(
(target: ArgumentApplication | AstExtended<Tree>, toDisplay) =>
new ArgumentApplication(toDisplay.appTree, target, undefined, toDisplay.argument),
nextApplication,
interpreted.func,
)
}
}
@ -156,13 +215,28 @@ const unknownArgInfoNamed = (name: string) => ({
hasDefault: false,
})
function getAccessOprSubject(app: AstExtended): AstExtended<Tree> | undefined {
if (
app.isTree([Tree.Type.OprApp]) &&
isAccessOperator(app.tryMap((t) => (t.opr.ok ? t.opr.value : undefined)))
) {
return app.tryMap((t) => t.lhs)
}
}
function isAccessOperator(opr: AstExtended<Token.Operator> | undefined): boolean {
return opr != null && opr.repr() === '.'
}
declare const ArgumentApplicationKey: unique symbol
declare const ArgumentPlaceholderKey: unique symbol
declare const ArgumentAstKey: unique symbol
declare const ForcePortKey: unique symbol
declare module '@/providers/widgetRegistry' {
export interface WidgetInputTypes {
[ArgumentApplicationKey]: ArgumentApplication
[ArgumentPlaceholderKey]: ArgumentPlaceholder
[ArgumentAstKey]: ArgumentAst
[ForcePortKey]: ForcePort
}
}

View File

@ -1,5 +1,6 @@
import type { Opt } from '@/util/opt'
import { Vec2 } from '@/util/vec2'
import type { ObservableV2 } from 'lib0/observable'
import {
computed,
onScopeDispose,
@ -11,6 +12,7 @@ import {
type Ref,
type WatchSource,
} from 'vue'
import { ReactiveDb } from './database/reactiveDb'
export function isClick(e: MouseEvent | PointerEvent) {
if (e instanceof PointerEvent) return e.pointerId !== -1

View File

@ -94,15 +94,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
() => `translate(${translate.value.x * scale.value}px, ${translate.value.y * scale.value}px)`,
)
useEvent(
window,
'contextmenu',
(e) => {
e.preventDefault()
},
{ capture: true },
)
let isPointerDown = false
let scrolledThisFrame = false
const eventMousePos = ref<Vec2 | null>(null)
@ -179,6 +170,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
center.value = center.value.addScaled(delta, 1 / scale.value)
}
},
contextmenu(e: Event) {
e.preventDefault()
},
},
translate,
scale,