mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 08:52:58 +03:00
[GUI2] Selection and rendering of basic node widgets (#8253)
This commit is contained in:
parent
f86e1b3aa5
commit
febce5dad7
4
app/gui2/env.d.ts
vendored
4
app/gui2/env.d.ts
vendored
@ -2,3 +2,7 @@
|
|||||||
|
|
||||||
declare const PROJECT_MANAGER_URL: string
|
declare const PROJECT_MANAGER_URL: string
|
||||||
declare const RUNNING_VITEST: boolean
|
declare const RUNNING_VITEST: boolean
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
caretPositionFromPoint(x: number, y: number): { offsetNode: Node; offset: number } | null
|
||||||
|
}
|
||||||
|
@ -7,7 +7,6 @@ import * as Y from 'yjs'
|
|||||||
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
||||||
declare const brandExprId: unique symbol
|
declare const brandExprId: unique symbol
|
||||||
export type ExprId = Uuid & { [brandExprId]: never }
|
export type ExprId = Uuid & { [brandExprId]: never }
|
||||||
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
|
||||||
|
|
||||||
export type VisualizationModule =
|
export type VisualizationModule =
|
||||||
| { kind: 'Builtin' }
|
| { kind: 'Builtin' }
|
||||||
@ -230,12 +229,22 @@ export class IdMap {
|
|||||||
if (!(isUuid(expr) && rangeBuffer instanceof Uint8Array)) return
|
if (!(isUuid(expr) && rangeBuffer instanceof Uint8Array)) return
|
||||||
const indices = this.modelToIndices(rangeBuffer)
|
const indices = this.modelToIndices(rangeBuffer)
|
||||||
if (indices == null) return
|
if (indices == null) return
|
||||||
this.rangeToExpr.set(IdMap.keyForRange(indices), expr as ExprId)
|
const key = IdMap.keyForRange(indices)
|
||||||
|
if (!this.rangeToExpr.has(key)) {
|
||||||
|
this.rangeToExpr.set(key, expr as ExprId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.finished = false
|
this.finished = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Mock(): IdMap {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const map = doc.getMap<Uint8Array>('idMap')
|
||||||
|
const text = doc.getText('contents')
|
||||||
|
return new IdMap(map, text)
|
||||||
|
}
|
||||||
|
|
||||||
public static keyForRange(range: readonly [number, number]): string {
|
public static keyForRange(range: readonly [number, number]): string {
|
||||||
return `${range[0].toString(16)}:${range[1].toString(16)}`
|
return `${range[0].toString(16)}:${range[1].toString(16)}`
|
||||||
}
|
}
|
||||||
|
@ -38,5 +38,6 @@ export const selectionMouseBindings = defineKeybinds('selection', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const nodeEditBindings = defineKeybinds('node-edit', {
|
export const nodeEditBindings = defineKeybinds('node-edit', {
|
||||||
selectAll: ['Mod+A'],
|
cancel: ['Escape'],
|
||||||
|
edit: ['Mod+PointerMain'],
|
||||||
})
|
})
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGraphStore, type Node } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import type { Highlighter } from '@/util/codemirror'
|
import type { Highlighter } from '@/util/codemirror'
|
||||||
import { colorFromString } from '@/util/colors'
|
|
||||||
import { usePointer } from '@/util/events'
|
import { usePointer } from '@/util/events'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { rangeEncloses } from 'shared/yjsModel'
|
import { rangeEncloses, type ExprId } from 'shared/yjsModel'
|
||||||
import { computed, onMounted, ref, watchEffect } from 'vue'
|
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||||
import { qnJoin, tryQualifiedName } from '../util/qualifiedName'
|
import { qnJoin, tryQualifiedName } from '../util/qualifiedName'
|
||||||
import { unwrap } from '../util/result'
|
import { unwrap } from '../util/result'
|
||||||
@ -55,21 +54,20 @@ watchEffect(() => {
|
|||||||
hoverTooltip((ast, syn) => {
|
hoverTooltip((ast, syn) => {
|
||||||
const dom = document.createElement('div')
|
const dom = document.createElement('div')
|
||||||
const astSpan = ast.span()
|
const astSpan = ast.span()
|
||||||
let foundNode: Node | undefined
|
let foundNode: ExprId | undefined
|
||||||
for (const node of graphStore.nodes.values()) {
|
for (const [id, node] of graphStore.db.allNodes()) {
|
||||||
if (rangeEncloses(node.rootSpan.span(), astSpan)) {
|
if (rangeEncloses(node.rootSpan.span(), astSpan)) {
|
||||||
foundNode = node
|
foundNode = id
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const expressionInfo = foundNode
|
const expressionInfo = foundNode && graphStore.db.nodeExpressionInfo.lookup(foundNode)
|
||||||
? projectStore.computedValueRegistry.getExpressionInfo(foundNode.rootSpan.astId)
|
const nodeColor = foundNode && graphStore.db.getNodeColorStyle(foundNode)
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (foundNode != null) {
|
if (foundNode != null) {
|
||||||
dom
|
dom
|
||||||
.appendChild(document.createElement('div'))
|
.appendChild(document.createElement('div'))
|
||||||
.appendChild(document.createTextNode(`AST ID: ${foundNode.rootSpan.astId}`))
|
.appendChild(document.createTextNode(`AST ID: ${foundNode}`))
|
||||||
}
|
}
|
||||||
if (expressionInfo != null) {
|
if (expressionInfo != null) {
|
||||||
dom
|
dom
|
||||||
@ -92,11 +90,9 @@ watchEffect(() => {
|
|||||||
groupNode.appendChild(document.createTextNode('Group: '))
|
groupNode.appendChild(document.createTextNode('Group: '))
|
||||||
const groupNameNode = groupNode.appendChild(document.createElement('span'))
|
const groupNameNode = groupNode.appendChild(document.createElement('span'))
|
||||||
groupNameNode.appendChild(document.createTextNode(`${method.module}.${method.name}`))
|
groupNameNode.appendChild(document.createTextNode(`${method.module}.${method.name}`))
|
||||||
groupNameNode.style.color =
|
if (nodeColor) {
|
||||||
suggestionEntry?.groupIndex != null
|
groupNameNode.style.color = nodeColor
|
||||||
? `var(--group-color-${suggestionDbStore.groups[suggestionEntry.groupIndex]
|
}
|
||||||
?.name})`
|
|
||||||
: colorFromString(expressionInfo?.typename ?? 'Unknown')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { dom }
|
return { dom }
|
||||||
|
@ -5,11 +5,12 @@ import { Filtering } from '@/components/ComponentBrowser/filtering'
|
|||||||
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
|
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||||
import { useGraphStore } from '@/stores/graph.ts'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { groupColorStyle, useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||||
import { useApproach } from '@/util/animation'
|
import { useApproach } from '@/util/animation'
|
||||||
|
import { tryGetIndex } from '@/util/array'
|
||||||
import { useEvent, useResizeObserver } from '@/util/events'
|
import { useEvent, useResizeObserver } from '@/util/events'
|
||||||
import type { useNavigator } from '@/util/navigator'
|
import type { useNavigator } from '@/util/navigator'
|
||||||
import type { Opt } from '@/util/opt'
|
import type { Opt } from '@/util/opt'
|
||||||
@ -182,13 +183,7 @@ function componentStyle(index: number) {
|
|||||||
* Group colors are populated in `GraphEditor`, and for each group in suggestion database a CSS variable is created.
|
* Group colors are populated in `GraphEditor`, and for each group in suggestion database a CSS variable is created.
|
||||||
*/
|
*/
|
||||||
function componentColor(component: Component): string {
|
function componentColor(component: Component): string {
|
||||||
const group = suggestionDbStore.groups[component.group ?? -1]
|
return groupColorStyle(tryGetIndex(suggestionDbStore.groups, component.group))
|
||||||
if (group) {
|
|
||||||
const name = group.name.replace(/\s/g, '-')
|
|
||||||
return `var(--group-color-${name})`
|
|
||||||
} else {
|
|
||||||
return 'var(--group-color-fallback)'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Highlight ===
|
// === Highlight ===
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { GraphDb, mockNode } from '@/stores/graph/graphDatabase'
|
||||||
import {
|
import {
|
||||||
makeCon,
|
makeCon,
|
||||||
makeLocal,
|
makeLocal,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
type SuggestionEntry,
|
type SuggestionEntry,
|
||||||
} from '@/stores/suggestionDatabase/entry'
|
} from '@/stores/suggestionDatabase/entry'
|
||||||
import { readAstSpan } from '@/util/ast'
|
import { readAstSpan } from '@/util/ast'
|
||||||
|
import { ComputedValueRegistry } from '@/util/computedValueRegistry'
|
||||||
import type { ExprId } from 'shared/yjsModel'
|
import type { ExprId } from 'shared/yjsModel'
|
||||||
import { expect, test } from 'vitest'
|
import { expect, test } from 'vitest'
|
||||||
import { useComponentBrowserInput } from '../input'
|
import { useComponentBrowserInput } from '../input'
|
||||||
@ -92,24 +94,18 @@ test.each([
|
|||||||
) => {
|
) => {
|
||||||
const operator1Id: ExprId = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as ExprId
|
const operator1Id: ExprId = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as ExprId
|
||||||
const operator2Id: ExprId = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as ExprId
|
const operator2Id: ExprId = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as ExprId
|
||||||
const graphStoreMock = {
|
const computedValueRegistryMock = ComputedValueRegistry.Mock()
|
||||||
identDefinitions: new Map([
|
computedValueRegistryMock.db.set(operator1Id, {
|
||||||
['operator1', operator1Id],
|
typename: 'Standard.Base.Number',
|
||||||
['operator2', operator2Id],
|
methodCall: undefined,
|
||||||
]),
|
payload: { type: 'Value' },
|
||||||
}
|
profilingInfo: [],
|
||||||
const computedValueRegistryMock = {
|
})
|
||||||
getExpressionInfo(id: ExprId) {
|
const mockGraphDb = GraphDb.Mock(computedValueRegistryMock)
|
||||||
if (id === operator1Id)
|
mockGraphDb.nodes.set(operator1Id, mockNode('operator1', operator1Id))
|
||||||
return {
|
mockGraphDb.nodes.set(operator2Id, mockNode('operator2', operator2Id))
|
||||||
typename: 'Standard.Base.Number',
|
|
||||||
methodCall: undefined,
|
const input = useComponentBrowserInput(mockGraphDb)
|
||||||
payload: { type: 'Value' },
|
|
||||||
profilingInfo: [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const input = useComponentBrowserInput(graphStoreMock, computedValueRegistryMock)
|
|
||||||
input.code.value = code
|
input.code.value = code
|
||||||
input.selection.value = { start: cursorPos, end: cursorPos }
|
input.selection.value = { start: cursorPos, end: cursorPos }
|
||||||
const context = input.context.value
|
const context = input.context.value
|
||||||
@ -256,10 +252,8 @@ test.each([
|
|||||||
({ code, cursorPos, suggestion, expected, expectedCursorPos }) => {
|
({ code, cursorPos, suggestion, expected, expectedCursorPos }) => {
|
||||||
cursorPos = cursorPos ?? code.length
|
cursorPos = cursorPos ?? code.length
|
||||||
expectedCursorPos = expectedCursorPos ?? expected.length
|
expectedCursorPos = expectedCursorPos ?? expected.length
|
||||||
const input = useComponentBrowserInput(
|
|
||||||
{ identDefinitions: new Map() },
|
const input = useComponentBrowserInput(GraphDb.Mock())
|
||||||
{ getExpressionInfo: (_id) => undefined },
|
|
||||||
)
|
|
||||||
input.code.value = code
|
input.code.value = code
|
||||||
input.selection.value = { start: cursorPos, end: cursorPos }
|
input.selection.value = { start: cursorPos, end: cursorPos }
|
||||||
input.applySuggestion(suggestion)
|
input.applySuggestion(suggestion)
|
||||||
|
@ -10,7 +10,7 @@ import { chain, map, range } from '@/util/iterable'
|
|||||||
import { Rect } from '@/util/rect'
|
import { Rect } from '@/util/rect'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import { fc, test as fcTest } from '@fast-check/vitest'
|
import { fc, test as fcTest } from '@fast-check/vitest'
|
||||||
import { expect, test, vi } from 'vitest'
|
import { describe, expect, test, vi } from 'vitest'
|
||||||
|
|
||||||
// Vue playground to visually inspect failing fuzz cases:
|
// Vue playground to visually inspect failing fuzz cases:
|
||||||
// https://play.vuejs.org/#eNrNU09PwjAU/ypNNeGCMPFC5jRR40EPatSbNXGMxyiMtmnfYGbZd/e1Y0Ci4Wwv6+/Pa3+v7Wp+Y8xgXQKPeeIyKw0yB1iaa6EyrRwyCxk6dsU+hPoU6pAlsmYFzDBmUZ+hNuG7kVOcx+w8ovkcZD4neRSxRqhk2G5ASxNAWJkiRSCUTOWaOfwu4ErwfU0UmeryYD0PBSc/Y6FifTbTlipCFqnapIKzuFuqZkY7iVKrmPXSidNFidDrbzN/nda+YuBRY6qvbQsdTaBltwE6PsBW6aJ2UotbbZJmy9zqUk1p75NxGKNRj86BXydDir/v41/mHdEYj3/l3c6S4e76eJ+jo0cxk/lg4bSih1R7q+CZXhlZgH02viW6mZgFxWtpUejNY+DQltDv+GwO2fIPfuEqzwn+YsGBXYPgOw1TmwO28v3bE1Q034krPS0Lch8RXyGcNGVsbbd0CBT7wBfSPqyMtihV/u7uKwTluqZ8UO9sgl9w+pvujrS+j3sxuAh1QjW8+QFAeS2/
|
// https://play.vuejs.org/#eNrNU09PwjAU/ypNNeGCMPFC5jRR40EPatSbNXGMxyiMtmnfYGbZd/e1Y0Ci4Wwv6+/Pa3+v7Wp+Y8xgXQKPeeIyKw0yB1iaa6EyrRwyCxk6dsU+hPoU6pAlsmYFzDBmUZ+hNuG7kVOcx+w8ovkcZD4neRSxRqhk2G5ASxNAWJkiRSCUTOWaOfwu4ErwfU0UmeryYD0PBSc/Y6FifTbTlipCFqnapIKzuFuqZkY7iVKrmPXSidNFidDrbzN/nda+YuBRY6qvbQsdTaBltwE6PsBW6aJ2UotbbZJmy9zqUk1p75NxGKNRj86BXydDir/v41/mHdEYj3/l3c6S4e76eJ+jo0cxk/lg4bSih1R7q+CZXhlZgH02viW6mZgFxWtpUejNY+DQltDv+GwO2fIPfuEqzwn+YsGBXYPgOw1TmwO28v3bE1Q034krPS0Lch8RXyGcNGVsbbd0CBT7wBfSPqyMtihV/u7uKwTluqZ8UO9sgl9w+pvujrS+j3sxuAh1QjW8+QFAeS2/
|
||||||
@ -41,284 +41,343 @@ function rectAtY(top: number) {
|
|||||||
return (left: number) => rectAt(left, top)
|
return (left: number) => rectAt(left, top)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nonDictatedEnvironment(nodeRects: Iterable<Rect>): Environment {
|
describe('Non dictated placement', () => {
|
||||||
return {
|
function nonDictatedEnvironment(nodeRects: Iterable<Rect>): Environment {
|
||||||
screenBounds,
|
return {
|
||||||
nodeRects,
|
|
||||||
get selectedNodeRects() {
|
|
||||||
return getSelectedNodeRects()
|
|
||||||
},
|
|
||||||
get mousePosition() {
|
|
||||||
return getMousePosition()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
// === Miscellaneous tests ===
|
|
||||||
// Empty graph
|
|
||||||
{ nodes: [], pos: new Vec2(1050, 690) },
|
|
||||||
|
|
||||||
// === Single node tests ===
|
|
||||||
// Single node
|
|
||||||
{ nodes: [rectAt(1050, 690)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (far enough left that it does not overlap)
|
|
||||||
{ nodes: [rectAt(950, 690)], pos: new Vec2(1050, 690) },
|
|
||||||
// Single node (far enough right that it does not overlap)
|
|
||||||
{ nodes: [rectAt(1150, 690)], pos: new Vec2(1050, 690) },
|
|
||||||
// Single node (overlaps on the left by 1px)
|
|
||||||
{ nodes: [rectAt(951, 690)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (overlaps on the right by 1px)
|
|
||||||
{ nodes: [rectAt(1149, 690)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (BIG gap)
|
|
||||||
{ nodes: [rectAt(1050, 690)], gap: 1000, pos: new Vec2(1050, 1710), pan: new Vec2(0, 1020) },
|
|
||||||
|
|
||||||
// === Multiple node tests ===
|
|
||||||
// Multiple nodes
|
|
||||||
{ nodes: map(range(0, 1001, 20), rectAtX(1050)), pos: new Vec2(1050, 1044) },
|
|
||||||
// Multiple nodes with gap
|
|
||||||
{ nodes: map(range(1000, -1, -20), rectAtX(1050)), pos: new Vec2(1050, 1044) },
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(500, 901, 20), rectAtX(1050)),
|
|
||||||
map(range(1000, 1501, 20), rectAtX(1050)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1050, 944),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap (just big enough)
|
|
||||||
{ nodes: map(range(690, 1500, 88), rectAtX(1050)), pos: new Vec2(1050, 734) },
|
|
||||||
// Multiple nodes with gap (slightly too small)
|
|
||||||
{ nodes: map(range(500, 849, 87), rectAtX(1050)), pos: new Vec2(1050, 892) },
|
|
||||||
// Multiple nodes with smallest gap
|
|
||||||
{
|
|
||||||
nodes: chain(map(range(500, 901, 20), rectAtX(1050)), map(range(988, 1489, 20), rectAtX(1050))),
|
|
||||||
pos: new Vec2(1050, 944),
|
|
||||||
},
|
|
||||||
// Multiple nodes with smallest gap (reverse)
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1488, 987, -20), rectAtX(1050)),
|
|
||||||
map(range(900, 499, -20), rectAtX(1050)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1050, 944),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap that is too small
|
|
||||||
{
|
|
||||||
nodes: chain(map(range(500, 901, 20), rectAtX(1050)), map(range(987, 1488, 20), rectAtX(1050))),
|
|
||||||
// This gap is 1px smaller than the previous test - so, 1px too small.
|
|
||||||
// This position is offscreen (y >= 1000), so we pan so that the new node is centered (1531 - 690).
|
|
||||||
pos: new Vec2(1050, 1531),
|
|
||||||
pan: new Vec2(0, 841),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap that is too small (each range reversed)
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(900, 499, -20), rectAtX(1050)),
|
|
||||||
map(range(1487, 986, -20), rectAtX(1050)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1050, 1531),
|
|
||||||
pan: new Vec2(0, 841),
|
|
||||||
},
|
|
||||||
])('Non dictated placement', ({ nodes, pos, gap, pan }) => {
|
|
||||||
expect(nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes), gap ? { gap } : {})).toEqual(
|
|
||||||
{ position: pos, pan },
|
|
||||||
)
|
|
||||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
|
||||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
fcTest.prop({
|
|
||||||
nodeData: fc.array(
|
|
||||||
fc.record({
|
|
||||||
left: fc.nat(1000),
|
|
||||||
top: fc.nat(1000),
|
|
||||||
width: fc.nat(1000),
|
|
||||||
height: fc.nat(1000),
|
|
||||||
}),
|
|
||||||
{ minLength: 15, maxLength: 25 },
|
|
||||||
),
|
|
||||||
})('Non dictated placement (prop testing)', ({ nodeData }) => {
|
|
||||||
const nodes = nodeData.map(
|
|
||||||
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
|
||||||
)
|
|
||||||
const newNodeRect = new Rect(
|
|
||||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes)).position,
|
|
||||||
nodeSize,
|
|
||||||
)
|
|
||||||
for (const node of nodes) {
|
|
||||||
expect(node.intersects(newNodeRect), {
|
|
||||||
toString() {
|
|
||||||
return generateVueCodeForNonDictatedPlacement(newNodeRect, nodes)
|
|
||||||
},
|
|
||||||
} as string).toBe(false)
|
|
||||||
}
|
|
||||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
|
||||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
function previousNodeDictatedEnvironment(nodeRects: Rect[]): Environment {
|
|
||||||
return {
|
|
||||||
screenBounds,
|
|
||||||
nodeRects,
|
|
||||||
selectedNodeRects: nodeRects.slice(-1),
|
|
||||||
get mousePosition() {
|
|
||||||
return getMousePosition()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Previous node dictated placement throws when there are no selected nodes', () => {
|
|
||||||
expect(() =>
|
|
||||||
previousNodeDictatedPlacement(nodeSize, previousNodeDictatedEnvironment([])),
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
// === Single node tests ===
|
|
||||||
// Single node
|
|
||||||
{ nodes: [], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (far enough up that it does not overlap)
|
|
||||||
{ nodes: [rectAt(1150, 714)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (far enough down that it does not overlap)
|
|
||||||
{ nodes: [rectAt(1150, 754)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (far enough left that it does not overlap)
|
|
||||||
{ nodes: [rectAt(926, 734)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (overlapping on the left by 1px)
|
|
||||||
{ nodes: [rectAt(927, 734)], pos: new Vec2(1051, 734) },
|
|
||||||
// Single node (blocking initial position)
|
|
||||||
{ nodes: [rectAt(1050, 734)], pos: new Vec2(1174, 734) },
|
|
||||||
// Single node (far enough right that it does not overlap)
|
|
||||||
{ nodes: [rectAt(1174, 690)], pos: new Vec2(1050, 734) },
|
|
||||||
// Single node (overlapping on the right by 1px)
|
|
||||||
{ nodes: [rectAt(1173, 734)], pos: new Vec2(1297, 734) },
|
|
||||||
// Single node (overlaps on the top by 1px)
|
|
||||||
{ nodes: [rectAt(1050, 715)], pos: new Vec2(1174, 734) },
|
|
||||||
// Single node (overlaps on the bottom by 1px)
|
|
||||||
{ nodes: [rectAt(1050, 753)], pos: new Vec2(1174, 734) },
|
|
||||||
// Single node (BIG gap)
|
|
||||||
{ nodes: [], gap: 1000, pos: new Vec2(1050, 1710), pan: new Vec2(0, 1020) },
|
|
||||||
// Single node (BIG gap, overlapping on the left by 1px)
|
|
||||||
{
|
|
||||||
nodes: [rectAt(927, 1710)],
|
|
||||||
gap: 1000,
|
|
||||||
pos: new Vec2(2027, 1710),
|
|
||||||
pan: new Vec2(977, 1020),
|
|
||||||
},
|
|
||||||
|
|
||||||
// === Multiple node tests ===
|
|
||||||
// Multiple nodes
|
|
||||||
{
|
|
||||||
nodes: map(range(1000, 2001, 100), rectAtY(734)),
|
|
||||||
pos: new Vec2(2124, 734),
|
|
||||||
pan: new Vec2(1074, 44),
|
|
||||||
},
|
|
||||||
// Multiple nodes (reverse)
|
|
||||||
{
|
|
||||||
nodes: map(range(2000, 999, -100), rectAtY(734)),
|
|
||||||
pos: new Vec2(2124, 734),
|
|
||||||
pan: new Vec2(1074, 44),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1000, 1401, 100), rectAtY(734)),
|
|
||||||
map(range(1700, 2001, 100), rectAtY(734)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1524, 734),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap (just big enough)
|
|
||||||
{
|
|
||||||
nodes: map(range(1050, 2000, 248), rectAtY(734)),
|
|
||||||
pos: new Vec2(1174, 734),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap (slightly too small)
|
|
||||||
{
|
|
||||||
nodes: map(range(1050, 1792, 247), rectAtY(734)),
|
|
||||||
pos: new Vec2(1915, 734),
|
|
||||||
},
|
|
||||||
// Multiple nodes with smallest gap
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1000, 1401, 100), rectAtY(734)),
|
|
||||||
map(range(1648, 1949, 100), rectAtY(734)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1524, 734),
|
|
||||||
},
|
|
||||||
// Multiple nodes with smallest gap (reverse)
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1948, 1647, -100), rectAtY(734)),
|
|
||||||
map(range(1400, 999, -100), rectAtY(734)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(1524, 734),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap that is too small
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1000, 1401, 100), rectAtY(734)),
|
|
||||||
map(range(1647, 1948, 100), rectAtY(734)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(2071, 734),
|
|
||||||
pan: new Vec2(1021, 44),
|
|
||||||
},
|
|
||||||
// Multiple nodes with gap that is too small (each range reversed)
|
|
||||||
{
|
|
||||||
nodes: chain(
|
|
||||||
map(range(1400, 999, -100), rectAtY(734)),
|
|
||||||
map(range(1947, 1646, -100), rectAtY(734)),
|
|
||||||
),
|
|
||||||
pos: new Vec2(2071, 734),
|
|
||||||
pan: new Vec2(1021, 44),
|
|
||||||
},
|
|
||||||
])('Previous node dictated placement', ({ nodes, gap, pos, pan }) => {
|
|
||||||
expect(
|
|
||||||
previousNodeDictatedPlacement(
|
|
||||||
nodeSize,
|
|
||||||
previousNodeDictatedEnvironment([...nodes, rectAt(1050, 690)]),
|
|
||||||
gap != null ? { gap } : {},
|
|
||||||
),
|
|
||||||
).toEqual({ position: pos, pan })
|
|
||||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
fcTest.prop({
|
|
||||||
nodeData: fc.array(
|
|
||||||
fc.record({
|
|
||||||
left: fc.nat(1000),
|
|
||||||
top: fc.nat(1000),
|
|
||||||
width: fc.nat(1000),
|
|
||||||
height: fc.nat(1000),
|
|
||||||
}),
|
|
||||||
{ minLength: 15, maxLength: 25 },
|
|
||||||
),
|
|
||||||
firstSelectedNode: fc.integer({ min: 7, max: 12 }),
|
|
||||||
})('Previous node dictated placement (prop testing)', ({ nodeData, firstSelectedNode }) => {
|
|
||||||
const nodeRects = nodeData.map(
|
|
||||||
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
|
||||||
)
|
|
||||||
const selectedNodeRects = nodeRects.slice(firstSelectedNode)
|
|
||||||
const newNodeRect = new Rect(
|
|
||||||
previousNodeDictatedPlacement(nodeSize, {
|
|
||||||
screenBounds,
|
screenBounds,
|
||||||
nodeRects,
|
nodeRects,
|
||||||
selectedNodeRects,
|
get selectedNodeRects() {
|
||||||
|
return getSelectedNodeRects()
|
||||||
|
},
|
||||||
get mousePosition() {
|
get mousePosition() {
|
||||||
return getMousePosition()
|
return getMousePosition()
|
||||||
},
|
},
|
||||||
}).position,
|
}
|
||||||
nodeSize,
|
}
|
||||||
)
|
|
||||||
expect(newNodeRect.top, {
|
test.each([
|
||||||
toString() {
|
// === Miscellaneous tests ===
|
||||||
return generateVueCodeForPreviousNodeDictatedPlacement(
|
{ desc: 'Empty graph', nodes: [], pos: new Vec2(1050, 690) },
|
||||||
newNodeRect,
|
|
||||||
|
// === Single node tests ===
|
||||||
|
{ desc: 'Single node', nodes: [rectAt(1050, 690)], pos: new Vec2(1050, 734) },
|
||||||
|
//
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough left that it does not overlap)',
|
||||||
|
nodes: [rectAt(950, 690)],
|
||||||
|
pos: new Vec2(1050, 690),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough right that it does not overlap)',
|
||||||
|
nodes: [rectAt(1150, 690)],
|
||||||
|
pos: new Vec2(1050, 690),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlaps on the left by 1px)',
|
||||||
|
nodes: [rectAt(951, 690)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlaps on the right by 1px)',
|
||||||
|
nodes: [rectAt(1149, 690)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (BIG gap)',
|
||||||
|
nodes: [rectAt(1050, 690)],
|
||||||
|
gap: 1000,
|
||||||
|
pos: new Vec2(1050, 1710),
|
||||||
|
pan: new Vec2(0, 1020),
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Multiple node tests ===
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes',
|
||||||
|
nodes: map(range(0, 1001, 20), rectAtX(1050)),
|
||||||
|
pos: new Vec2(1050, 1044),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap',
|
||||||
|
nodes: map(range(1000, -1, -20), rectAtX(1050)),
|
||||||
|
pos: new Vec2(1050, 1044),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap 2',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(500, 901, 20), rectAtX(1050)),
|
||||||
|
map(range(1000, 1501, 20), rectAtX(1050)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1050, 944),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap (just big enough)',
|
||||||
|
nodes: map(range(690, 1500, 88), rectAtX(1050)),
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap (slightly too small)',
|
||||||
|
nodes: map(range(500, 849, 87), rectAtX(1050)),
|
||||||
|
pos: new Vec2(1050, 892),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with smallest gap',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(500, 901, 20), rectAtX(1050)),
|
||||||
|
map(range(988, 1489, 20), rectAtX(1050)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1050, 944),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with smallest gap (reverse)',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1488, 987, -20), rectAtX(1050)),
|
||||||
|
map(range(900, 499, -20), rectAtX(1050)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1050, 944),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap that is too small',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(500, 901, 20), rectAtX(1050)),
|
||||||
|
map(range(987, 1488, 20), rectAtX(1050)),
|
||||||
|
),
|
||||||
|
// This gap is 1px smaller than the previous test - so, 1px too small.
|
||||||
|
// This position is offscreen (y >= 1000), so we pan so that the new node is centered (1531 - 690).
|
||||||
|
pos: new Vec2(1050, 1531),
|
||||||
|
pan: new Vec2(0, 841),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap that is too small (each range reversed)',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(900, 499, -20), rectAtX(1050)),
|
||||||
|
map(range(1487, 986, -20), rectAtX(1050)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1050, 1531),
|
||||||
|
pan: new Vec2(0, 841),
|
||||||
|
},
|
||||||
|
])('$desc', ({ nodes, pos, gap, pan }) => {
|
||||||
|
expect(
|
||||||
|
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes), gap ? { gap } : {}),
|
||||||
|
).toEqual({ position: pos, pan })
|
||||||
|
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||||
|
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
fcTest.prop({
|
||||||
|
nodeData: fc.array(
|
||||||
|
fc.record({
|
||||||
|
left: fc.nat(1000),
|
||||||
|
top: fc.nat(1000),
|
||||||
|
width: fc.nat(1000),
|
||||||
|
height: fc.nat(1000),
|
||||||
|
}),
|
||||||
|
{ minLength: 15, maxLength: 25 },
|
||||||
|
),
|
||||||
|
})('prop testing', ({ nodeData }) => {
|
||||||
|
const nodes = nodeData.map(
|
||||||
|
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
||||||
|
)
|
||||||
|
const newNodeRect = new Rect(
|
||||||
|
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes)).position,
|
||||||
|
nodeSize,
|
||||||
|
)
|
||||||
|
for (const node of nodes) {
|
||||||
|
expect(node.intersects(newNodeRect), {
|
||||||
|
toString() {
|
||||||
|
return generateVueCodeForNonDictatedPlacement(newNodeRect, nodes)
|
||||||
|
},
|
||||||
|
} as string).toBe(false)
|
||||||
|
}
|
||||||
|
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||||
|
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Previous node dictated placement', () => {
|
||||||
|
function previousNodeDictatedEnvironment(nodeRects: Rect[]): Environment {
|
||||||
|
return {
|
||||||
|
screenBounds,
|
||||||
|
nodeRects,
|
||||||
|
selectedNodeRects: nodeRects.slice(-1),
|
||||||
|
get mousePosition() {
|
||||||
|
return getMousePosition()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Previous node dictated placement throws when there are no selected nodes', () => {
|
||||||
|
expect(() =>
|
||||||
|
previousNodeDictatedPlacement(nodeSize, previousNodeDictatedEnvironment([])),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
// === Single node tests ===
|
||||||
|
{ desc: 'Single node', nodes: [], pos: new Vec2(1050, 734) },
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough up that it does not overlap)',
|
||||||
|
nodes: [rectAt(1150, 714)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough down that it does not overlap)',
|
||||||
|
nodes: [rectAt(1150, 754)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough left that it does not overlap)',
|
||||||
|
nodes: [rectAt(926, 734)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlapping on the left by 1px)',
|
||||||
|
nodes: [rectAt(927, 734)],
|
||||||
|
pos: new Vec2(1051, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (blocking initial position)',
|
||||||
|
nodes: [rectAt(1050, 734)],
|
||||||
|
pos: new Vec2(1174, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (far enough right that it does not overlap)',
|
||||||
|
nodes: [rectAt(1174, 690)],
|
||||||
|
pos: new Vec2(1050, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlapping on the right by 1px)',
|
||||||
|
nodes: [rectAt(1173, 734)],
|
||||||
|
pos: new Vec2(1297, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlaps on the top by 1px)',
|
||||||
|
nodes: [rectAt(1050, 715)],
|
||||||
|
pos: new Vec2(1174, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (overlaps on the bottom by 1px)',
|
||||||
|
nodes: [rectAt(1050, 753)],
|
||||||
|
pos: new Vec2(1174, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (BIG gap)',
|
||||||
|
nodes: [],
|
||||||
|
gap: 1000,
|
||||||
|
pos: new Vec2(1050, 1710),
|
||||||
|
pan: new Vec2(0, 1020),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Single node (BIG gap, overlapping on the left by 1px)',
|
||||||
|
nodes: [rectAt(927, 1710)],
|
||||||
|
gap: 1000,
|
||||||
|
pos: new Vec2(2027, 1710),
|
||||||
|
pan: new Vec2(977, 1020),
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Multiple node tests ===
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes',
|
||||||
|
nodes: map(range(1000, 2001, 100), rectAtY(734)),
|
||||||
|
pos: new Vec2(2124, 734),
|
||||||
|
pan: new Vec2(1074, 44),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes (reverse)',
|
||||||
|
nodes: map(range(2000, 999, -100), rectAtY(734)),
|
||||||
|
pos: new Vec2(2124, 734),
|
||||||
|
pan: new Vec2(1074, 44),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1000, 1401, 100), rectAtY(734)),
|
||||||
|
map(range(1700, 2001, 100), rectAtY(734)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1524, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap (just big enough)',
|
||||||
|
nodes: map(range(1050, 2000, 248), rectAtY(734)),
|
||||||
|
pos: new Vec2(1174, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap (slightly too small)',
|
||||||
|
nodes: map(range(1050, 1792, 247), rectAtY(734)),
|
||||||
|
pos: new Vec2(1915, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with smallest gap',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1000, 1401, 100), rectAtY(734)),
|
||||||
|
map(range(1648, 1949, 100), rectAtY(734)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1524, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with smallest gap (reverse)',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1948, 1647, -100), rectAtY(734)),
|
||||||
|
map(range(1400, 999, -100), rectAtY(734)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(1524, 734),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap that is too small',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1000, 1401, 100), rectAtY(734)),
|
||||||
|
map(range(1647, 1948, 100), rectAtY(734)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(2071, 734),
|
||||||
|
pan: new Vec2(1021, 44),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: 'Multiple nodes with gap that is too small (each range reversed)',
|
||||||
|
nodes: chain(
|
||||||
|
map(range(1400, 999, -100), rectAtY(734)),
|
||||||
|
map(range(1947, 1646, -100), rectAtY(734)),
|
||||||
|
),
|
||||||
|
pos: new Vec2(2071, 734),
|
||||||
|
pan: new Vec2(1021, 44),
|
||||||
|
},
|
||||||
|
])('$desc', ({ nodes, gap, pos, pan }) => {
|
||||||
|
expect(
|
||||||
|
previousNodeDictatedPlacement(
|
||||||
|
nodeSize,
|
||||||
|
previousNodeDictatedEnvironment([...nodes, rectAt(1050, 690)]),
|
||||||
|
gap != null ? { gap } : {},
|
||||||
|
),
|
||||||
|
).toEqual({ position: pos, pan })
|
||||||
|
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
fcTest.prop({
|
||||||
|
nodeData: fc.array(
|
||||||
|
fc.record({
|
||||||
|
left: fc.nat(1000),
|
||||||
|
top: fc.nat(1000),
|
||||||
|
width: fc.nat(1000),
|
||||||
|
height: fc.nat(1000),
|
||||||
|
}),
|
||||||
|
{ minLength: 15, maxLength: 25 },
|
||||||
|
),
|
||||||
|
firstSelectedNode: fc.integer({ min: 7, max: 12 }),
|
||||||
|
})('prop testing', ({ nodeData, firstSelectedNode }) => {
|
||||||
|
const nodeRects = nodeData.map(
|
||||||
|
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
||||||
|
)
|
||||||
|
const selectedNodeRects = nodeRects.slice(firstSelectedNode)
|
||||||
|
const newNodeRect = new Rect(
|
||||||
|
previousNodeDictatedPlacement(nodeSize, {
|
||||||
|
screenBounds,
|
||||||
nodeRects,
|
nodeRects,
|
||||||
selectedNodeRects,
|
selectedNodeRects,
|
||||||
)
|
get mousePosition() {
|
||||||
},
|
return getMousePosition()
|
||||||
} as string).toBeGreaterThanOrEqual(Math.max(...selectedNodeRects.map((node) => node.bottom)))
|
},
|
||||||
for (const node of nodeRects) {
|
}).position,
|
||||||
expect(node.intersects(newNodeRect), {
|
nodeSize,
|
||||||
|
)
|
||||||
|
expect(newNodeRect.top, {
|
||||||
toString() {
|
toString() {
|
||||||
return generateVueCodeForPreviousNodeDictatedPlacement(
|
return generateVueCodeForPreviousNodeDictatedPlacement(
|
||||||
newNodeRect,
|
newNodeRect,
|
||||||
@ -326,45 +385,58 @@ fcTest.prop({
|
|||||||
selectedNodeRects,
|
selectedNodeRects,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} as string).toBe(false)
|
} as string).toBeGreaterThanOrEqual(Math.max(...selectedNodeRects.map((node) => node.bottom)))
|
||||||
}
|
for (const node of nodeRects) {
|
||||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
expect(node.intersects(newNodeRect), {
|
||||||
|
toString() {
|
||||||
|
return generateVueCodeForPreviousNodeDictatedPlacement(
|
||||||
|
newNodeRect,
|
||||||
|
nodeRects,
|
||||||
|
selectedNodeRects,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
} as string).toBe(false)
|
||||||
|
}
|
||||||
|
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
fcTest.prop({
|
describe('Mouse dictated placement', () => {
|
||||||
x: fc.nat(1000),
|
fcTest.prop({
|
||||||
y: fc.nat(1000),
|
x: fc.nat(1000),
|
||||||
})('Mouse dictated placement (prop testing)', ({ x, y }) => {
|
y: fc.nat(1000),
|
||||||
expect(
|
})('prop testing', ({ x, y }) => {
|
||||||
mouseDictatedPlacement(
|
expect(
|
||||||
nodeSize,
|
mouseDictatedPlacement(
|
||||||
{
|
nodeSize,
|
||||||
mousePosition: new Vec2(x, y),
|
{
|
||||||
get screenBounds() {
|
mousePosition: new Vec2(x, y),
|
||||||
return getScreenBounds()
|
get screenBounds() {
|
||||||
|
return getScreenBounds()
|
||||||
|
},
|
||||||
|
get nodeRects() {
|
||||||
|
return getNodeRects()
|
||||||
|
},
|
||||||
|
get selectedNodeRects() {
|
||||||
|
return getSelectedNodeRects()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
get nodeRects() {
|
{
|
||||||
return getNodeRects()
|
get gap() {
|
||||||
|
return getGap()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
get selectedNodeRects() {
|
),
|
||||||
return getSelectedNodeRects()
|
).toEqual<Placement>({
|
||||||
},
|
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
|
||||||
},
|
position: new Vec2(x - radius, y - radius),
|
||||||
{
|
})
|
||||||
get gap() {
|
// Non-overlap test omitted, as mouse-dictated node placement MAY overlap existing nodes.
|
||||||
return getGap()
|
expect(getScreenBounds, 'Should not depend on `screenBounds`').not.toHaveBeenCalled()
|
||||||
},
|
expect(getNodeRects, 'Should not depend on `nodeRects`').not.toHaveBeenCalled()
|
||||||
},
|
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||||
),
|
expect(getGap, 'Should not depend on `gap`').not.toHaveBeenCalled()
|
||||||
).toEqual<Placement>({
|
|
||||||
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
|
|
||||||
position: new Vec2(x - radius, y - radius),
|
|
||||||
})
|
})
|
||||||
// Non-overlap test omitted, as mouse-dictated node placement MAY overlap existing nodes.
|
|
||||||
expect(getScreenBounds, 'Should not depend on `screenBounds`').not.toHaveBeenCalled()
|
|
||||||
expect(getNodeRects, 'Should not depend on `nodeRects`').not.toHaveBeenCalled()
|
|
||||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
|
||||||
expect(getGap, 'Should not depend on `gap`').not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// === Helpers for debugging ===
|
// === Helpers for debugging ===
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { Filter } from '@/components/ComponentBrowser/filtering'
|
import type { Filter } from '@/components/ComponentBrowser/filtering'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||||
import {
|
import {
|
||||||
SuggestionKind,
|
SuggestionKind,
|
||||||
type SuggestionEntry,
|
type SuggestionEntry,
|
||||||
@ -16,7 +16,6 @@ import {
|
|||||||
} from '@/util/ast'
|
} from '@/util/ast'
|
||||||
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
|
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
|
||||||
import { GeneralOprApp } from '@/util/ast/opr'
|
import { GeneralOprApp } from '@/util/ast/opr'
|
||||||
import type { ExpressionInfo } from '@/util/computedValueRegistry'
|
|
||||||
import { MappedSet } from '@/util/containers'
|
import { MappedSet } from '@/util/containers'
|
||||||
import {
|
import {
|
||||||
qnLastSegment,
|
qnLastSegment,
|
||||||
@ -25,7 +24,7 @@ import {
|
|||||||
tryQualifiedName,
|
tryQualifiedName,
|
||||||
type QualifiedName,
|
type QualifiedName,
|
||||||
} from '@/util/qualifiedName'
|
} from '@/util/qualifiedName'
|
||||||
import { IdMap, type ExprId } from 'shared/yjsModel'
|
import { IdMap } from 'shared/yjsModel'
|
||||||
import { computed, ref, type ComputedRef } from 'vue'
|
import { computed, ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
/** Input's editing context.
|
/** Input's editing context.
|
||||||
@ -53,12 +52,7 @@ export type EditingContext =
|
|||||||
| { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number }
|
| { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number }
|
||||||
|
|
||||||
/** Component Browser Input Data */
|
/** Component Browser Input Data */
|
||||||
export function useComponentBrowserInput(
|
export function useComponentBrowserInput(graphDb: GraphDb = useGraphStore().db) {
|
||||||
graphStore: { identDefinitions: Map<string, ExprId> } = useGraphStore(),
|
|
||||||
computedValueRegistry: {
|
|
||||||
getExpressionInfo(id: ExprId): ExpressionInfo | undefined
|
|
||||||
} = useProjectStore().computedValueRegistry,
|
|
||||||
) {
|
|
||||||
const code = ref('')
|
const code = ref('')
|
||||||
const selection = ref({ start: 0, end: 0 })
|
const selection = ref({ start: 0, end: 0 })
|
||||||
const ast = computed(() => parseEnso(code.value))
|
const ast = computed(() => parseEnso(code.value))
|
||||||
@ -165,9 +159,9 @@ export function useComponentBrowserInput(
|
|||||||
if (accessOpr.apps.length > 1) return null
|
if (accessOpr.apps.length > 1) return null
|
||||||
if (internalUsages.value.has(parsedTreeRange(accessOpr.lhs))) return { type: 'unknown' }
|
if (internalUsages.value.has(parsedTreeRange(accessOpr.lhs))) return { type: 'unknown' }
|
||||||
const ident = readAstSpan(accessOpr.lhs, code.value)
|
const ident = readAstSpan(accessOpr.lhs, code.value)
|
||||||
const definition = graphStore.identDefinitions.get(ident)
|
const definition = graphDb.getIdentDefiningNode(ident)
|
||||||
if (definition == null) return null
|
if (definition == null) return null
|
||||||
const typename = computedValueRegistry.getExpressionInfo(definition)?.typename
|
const typename = graphDb.getExpressionInfo(definition)?.typename
|
||||||
return typename != null ? { type: 'known', typename } : { type: 'unknown' }
|
return typename != null ? { type: 'known', typename } : { type: 'unknown' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,8 +9,9 @@ import DocsTags from '@/components/DocumentationPanel/DocsTags.vue'
|
|||||||
import { HistoryStack } from '@/components/DocumentationPanel/history'
|
import { HistoryStack } from '@/components/DocumentationPanel/history'
|
||||||
import type { Docs, FunctionDocs, Sections, TypeDocs } from '@/components/DocumentationPanel/ir'
|
import type { Docs, FunctionDocs, Sections, TypeDocs } from '@/components/DocumentationPanel/ir'
|
||||||
import { lookupDocumentation, placeholder } from '@/components/DocumentationPanel/ir'
|
import { lookupDocumentation, placeholder } from '@/components/DocumentationPanel/ir'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { groupColorStyle, useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
|
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||||
|
import { tryGetIndex } from '@/util/array'
|
||||||
import type { Icon as IconName } from '@/util/iconName'
|
import type { Icon as IconName } from '@/util/iconName'
|
||||||
import { type Opt } from '@/util/opt'
|
import { type Opt } from '@/util/opt'
|
||||||
import type { QualifiedName } from '@/util/qualifiedName'
|
import type { QualifiedName } from '@/util/qualifiedName'
|
||||||
@ -56,18 +57,9 @@ const name = computed<Opt<QualifiedName>>(() => {
|
|||||||
|
|
||||||
// === Breadcrumbs ===
|
// === Breadcrumbs ===
|
||||||
|
|
||||||
const color = computed<string>(() => {
|
const color = computed(() => {
|
||||||
const id = props.selectedEntry
|
const groupIndex = db.entries.get(props.selectedEntry)?.groupIndex
|
||||||
if (id) {
|
return groupColorStyle(tryGetIndex(db.groups, groupIndex))
|
||||||
const entry = db.entries.get(id)
|
|
||||||
const groupIndex = entry?.groupIndex ?? -1
|
|
||||||
const group = db.groups[groupIndex]
|
|
||||||
if (group) {
|
|
||||||
const name = group.name.replace(/\s/g, '-')
|
|
||||||
return `var(--group-color-${name})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'var(--group-color-fallback)'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const icon = computed<IconName>(() => {
|
const icon = computed<IconName>(() => {
|
||||||
|
@ -7,16 +7,16 @@ import {
|
|||||||
type Environment,
|
type Environment,
|
||||||
} from '@/components/ComponentBrowser/placement.ts'
|
} from '@/components/ComponentBrowser/placement.ts'
|
||||||
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
|
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
|
||||||
import SelectionBrush from '@/components/SelectionBrush.vue'
|
|
||||||
import TopBar from '@/components/TopBar.vue'
|
import TopBar from '@/components/TopBar.vue'
|
||||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||||
|
import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler'
|
||||||
|
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { colorFromString } from '@/util/colors'
|
import { colorFromString } from '@/util/colors'
|
||||||
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/util/events'
|
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/util/events'
|
||||||
import { Interaction } from '@/util/interaction'
|
|
||||||
import type { Rect } from '@/util/rect.ts'
|
import type { Rect } from '@/util/rect.ts'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import * as set from 'lib0/set'
|
import * as set from 'lib0/set'
|
||||||
@ -24,30 +24,33 @@ import type { ExprId } from 'shared/yjsModel.ts'
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import GraphEdges from './GraphEditor/GraphEdges.vue'
|
import GraphEdges from './GraphEditor/GraphEdges.vue'
|
||||||
import GraphNodes from './GraphEditor/GraphNodes.vue'
|
import GraphNodes from './GraphEditor/GraphNodes.vue'
|
||||||
|
import GraphMouse from './GraphMouse.vue'
|
||||||
|
|
||||||
const EXECUTION_MODES = ['design', 'live']
|
const EXECUTION_MODES = ['design', 'live']
|
||||||
|
|
||||||
const viewportNode = ref<HTMLElement>()
|
const viewportNode = ref<HTMLElement>()
|
||||||
const navigator = provideGraphNavigator(viewportNode)
|
const navigator = provideGraphNavigator(viewportNode)
|
||||||
const graphStore = useGraphStore()
|
const graphStore = useGraphStore()
|
||||||
|
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||||
|
widgetRegistry.loadBuiltins()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const componentBrowserVisible = ref(false)
|
const componentBrowserVisible = ref(false)
|
||||||
const componentBrowserInputContent = ref('')
|
const componentBrowserInputContent = ref('')
|
||||||
const componentBrowserPosition = ref(Vec2.Zero)
|
const componentBrowserPosition = ref(Vec2.Zero)
|
||||||
const suggestionDb = useSuggestionDbStore()
|
const suggestionDb = useSuggestionDbStore()
|
||||||
|
const interaction = provideInteractionHandler()
|
||||||
|
|
||||||
const nodeSelection = provideGraphSelection(navigator, graphStore.nodeRects, {
|
const nodeSelection = provideGraphSelection(navigator, graphStore.nodeRects, {
|
||||||
onSelected(id) {
|
onSelected(id) {
|
||||||
const node = graphStore.nodes.get(id)
|
graphStore.db.moveNodeToTop(id)
|
||||||
if (node) {
|
|
||||||
// When a node is selected, we want to reorder it to be visually at the top. This is done by
|
|
||||||
// reinserting it into the nodes map, which is later iterated over in the template.
|
|
||||||
graphStore.nodes.delete(id)
|
|
||||||
graphStore.nodes.set(id, node)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const interactionBindingsHandler = interactionBindings.handler({
|
||||||
|
cancel: () => interaction.handleCancel(),
|
||||||
|
click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e) : false),
|
||||||
|
})
|
||||||
|
|
||||||
const graphEditorSourceNode = computed(() => {
|
const graphEditorSourceNode = computed(() => {
|
||||||
if (graphStore.editedNodeInfo != null) return undefined
|
if (graphStore.editedNodeInfo != null) return undefined
|
||||||
return nodeSelection.selected.values().next().value
|
return nodeSelection.selected.values().next().value
|
||||||
@ -56,6 +59,7 @@ const graphEditorSourceNode = computed(() => {
|
|||||||
useEvent(window, 'keydown', (event) => {
|
useEvent(window, 'keydown', (event) => {
|
||||||
interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event)
|
interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event)
|
||||||
})
|
})
|
||||||
|
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
|
||||||
|
|
||||||
onMounted(() => viewportNode.value?.focus())
|
onMounted(() => viewportNode.value?.focus())
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
if (keyboardBusy()) return false
|
if (keyboardBusy()) return false
|
||||||
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
||||||
componentBrowserPosition.value = navigator.sceneMousePos
|
componentBrowserPosition.value = navigator.sceneMousePos
|
||||||
startNodeCreation()
|
interaction.setCurrent(new CreatingNode())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
newNode() {
|
newNode() {
|
||||||
@ -92,6 +96,7 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
},
|
},
|
||||||
deselectAll() {
|
deselectAll() {
|
||||||
nodeSelection.deselectAll()
|
nodeSelection.deselectAll()
|
||||||
|
console.log('deselectAll')
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.blur()
|
document.activeElement.blur()
|
||||||
}
|
}
|
||||||
@ -102,7 +107,7 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
graphStore.transact(() => {
|
graphStore.transact(() => {
|
||||||
const allVisible = set
|
const allVisible = set
|
||||||
.toArray(nodeSelection.selected)
|
.toArray(nodeSelection.selected)
|
||||||
.every((id) => !(graphStore.nodes.get(id)?.vis?.visible !== true))
|
.every((id) => !(graphStore.db.getNode(id)?.vis?.visible !== true))
|
||||||
|
|
||||||
for (const nodeId of nodeSelection.selected) {
|
for (const nodeId of nodeSelection.selected) {
|
||||||
graphStore.setNodeVisualizationVisible(nodeId, !allVisible)
|
graphStore.setNodeVisualizationVisible(nodeId, !allVisible)
|
||||||
@ -120,20 +125,6 @@ const codeEditorHandler = codeEditorBindings.handler({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const interactionBindingsHandler = interactionBindings.handler({
|
|
||||||
cancel() {
|
|
||||||
cancelCurrentInteraction()
|
|
||||||
},
|
|
||||||
click(e) {
|
|
||||||
if (e instanceof MouseEvent) return currentInteraction.value?.click(e) ?? false
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
|
|
||||||
|
|
||||||
const scaledMousePos = computed(() => navigator.sceneMousePos?.scale(navigator.scale))
|
|
||||||
const scaledSelectionAnchor = computed(() => nodeSelection.anchor?.scale(navigator.scale))
|
|
||||||
|
|
||||||
/// Track play button presses.
|
/// Track play button presses.
|
||||||
function onPlayButtonPress() {
|
function onPlayButtonPress() {
|
||||||
projectStore.lsRpcConnection.then(async () => {
|
projectStore.lsRpcConnection.then(async () => {
|
||||||
@ -156,52 +147,15 @@ watch(
|
|||||||
const groupColors = computed(() => {
|
const groupColors = computed(() => {
|
||||||
const styles: { [key: string]: string } = {}
|
const styles: { [key: string]: string } = {}
|
||||||
for (let group of suggestionDb.groups) {
|
for (let group of suggestionDb.groups) {
|
||||||
const name = group.name.replace(/\s/g, '-')
|
styles[groupColorVar(group)] = group.color ?? colorFromString(group.name.replace(/\w/g, '-'))
|
||||||
let color = group.color ?? colorFromString(name)
|
|
||||||
styles[`--group-color-${name}`] = color
|
|
||||||
}
|
}
|
||||||
return styles
|
return styles
|
||||||
})
|
})
|
||||||
|
|
||||||
/// === Interaction Handling ===
|
const editingNode: Interaction = {
|
||||||
/// The following code handles some ongoing user interactions within the graph editor. Interactions are used to create
|
cancel: () => (componentBrowserVisible.value = false),
|
||||||
/// new nodes, connect nodes with edges, etc. They are implemented as classes that inherit from
|
|
||||||
/// `Interaction`. The interaction classes are instantiated when the interaction starts and
|
|
||||||
/// destroyed when the interaction ends. The interaction classes are also responsible for
|
|
||||||
/// cancelling the interaction when needed. This is done by calling `cancelCurrentInteraction`.
|
|
||||||
|
|
||||||
const currentInteraction = ref<Interaction>()
|
|
||||||
|
|
||||||
/// Set the current interaction. This will cancel the previous interaction.
|
|
||||||
function setCurrentInteraction(interaction: Interaction) {
|
|
||||||
if (currentInteraction.value?.id === interaction?.id) return
|
|
||||||
cancelCurrentInteraction()
|
|
||||||
currentInteraction.value = interaction
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End the current interaction and run its cancel handler.
|
|
||||||
function cancelCurrentInteraction() {
|
|
||||||
currentInteraction.value?.cancel()
|
|
||||||
currentInteraction.value = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End the current interaction without running its cancel handler.
|
|
||||||
function abortCurrentInteraction() {
|
|
||||||
currentInteraction.value = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
class EdgeDragging extends Interaction {
|
|
||||||
nodeId: ExprId
|
|
||||||
|
|
||||||
constructor(nodeId: ExprId) {
|
|
||||||
super()
|
|
||||||
this.nodeId = nodeId
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
componentBrowserVisible.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
interaction.setWhen(componentBrowserVisible, editingNode)
|
||||||
|
|
||||||
const placementEnvironment = computed(() => {
|
const placementEnvironment = computed(() => {
|
||||||
const mousePosition = navigator.sceneMousePos ?? Vec2.Zero
|
const mousePosition = navigator.sceneMousePos ?? Vec2.Zero
|
||||||
@ -217,12 +171,14 @@ const placementEnvironment = computed(() => {
|
|||||||
|
|
||||||
/// Interaction to create a new node. This will create a temporary node and open the component browser.
|
/// Interaction to create a new node. This will create a temporary node and open the component browser.
|
||||||
/// If the interaction is cancelled, the temporary node will be deleted, otherwise it will be kept.
|
/// If the interaction is cancelled, the temporary node will be deleted, otherwise it will be kept.
|
||||||
class CreatingNode extends Interaction {
|
class CreatingNode implements Interaction {
|
||||||
nodeId: ExprId
|
nodeId: ExprId
|
||||||
|
// Start a node creation interaction. This will create a new node and open the component browser.
|
||||||
|
// For more information about the flow of the interaction, see `CreatingNode`.
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
|
||||||
// We create a temporary node to show the component browser on. This node will be deleted if
|
// We create a temporary node to show the component browser on. This node will be deleted if
|
||||||
// the interaction is cancelled. It can later on be used to have a preview of the node as it is being created.
|
// the interaction is cancelled. It can later on be used to have a preview of the node as it is
|
||||||
|
// being created.
|
||||||
const nodeHeight = 32
|
const nodeHeight = 32
|
||||||
const targetPosition = mouseDictatedPlacement(
|
const targetPosition = mouseDictatedPlacement(
|
||||||
Vec2.FromArr([0, nodeHeight]),
|
Vec2.FromArr([0, nodeHeight]),
|
||||||
@ -242,12 +198,6 @@ class CreatingNode extends Interaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a node creation interaction. This will create a new node and open the component browser.
|
|
||||||
// For more information about the flow of the interaction, see `CreatingNode`.
|
|
||||||
function startNodeCreation() {
|
|
||||||
setCurrentInteraction(new CreatingNode())
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
||||||
const MULTIPLE_FILES_GAP = 50
|
const MULTIPLE_FILES_GAP = 50
|
||||||
@ -293,7 +243,7 @@ function onComponentBrowserCommit(content: string) {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function getNodeContent(id: ExprId): string {
|
function getNodeContent(id: ExprId): string {
|
||||||
const node = graphStore.nodes.get(id)
|
const node = graphStore.db.nodes.get(id)
|
||||||
if (node == null) return ''
|
if (node == null) return ''
|
||||||
return node.rootSpan.repr()
|
return node.rootSpan.repr()
|
||||||
}
|
}
|
||||||
@ -303,7 +253,7 @@ watch(
|
|||||||
() => graphStore.editedNodeInfo,
|
() => graphStore.editedNodeInfo,
|
||||||
(editedInfo) => {
|
(editedInfo) => {
|
||||||
if (editedInfo != null) {
|
if (editedInfo != null) {
|
||||||
const targetNode = graphStore.nodes.get(editedInfo.id)
|
const targetNode = graphStore.db.nodes.get(editedInfo.id)
|
||||||
const targetPos = targetNode?.position ?? Vec2.Zero
|
const targetPos = targetNode?.position ?? Vec2.Zero
|
||||||
const offset = new Vec2(20, 35)
|
const offset = new Vec2(20, 35)
|
||||||
componentBrowserPosition.value = targetPos.add(offset)
|
componentBrowserPosition.value = targetPos.add(offset)
|
||||||
@ -314,13 +264,25 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return projectStore.executionContext.desiredStack.map((frame) => {
|
||||||
|
switch (frame.type) {
|
||||||
|
case 'ExplicitCall':
|
||||||
|
return frame.methodPointer.name
|
||||||
|
case 'LocalCall':
|
||||||
|
return frame.expressionId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/attributes-order -->
|
<!-- eslint-disable vue/attributes-order -->
|
||||||
<div
|
<div
|
||||||
ref="viewportNode"
|
ref="viewportNode"
|
||||||
class="viewport"
|
class="GraphEditor"
|
||||||
|
:class="{ draggingEdge: graphStore.unconnectedEdge != null }"
|
||||||
:style="groupColors"
|
:style="groupColors"
|
||||||
@click="graphBindingsHandler"
|
@click="graphBindingsHandler"
|
||||||
v-on.="navigator.events"
|
v-on.="navigator.events"
|
||||||
@ -329,10 +291,7 @@ watch(
|
|||||||
@drop.prevent="handleFileDrop($event)"
|
@drop.prevent="handleFileDrop($event)"
|
||||||
>
|
>
|
||||||
<svg :viewBox="navigator.viewBox">
|
<svg :viewBox="navigator.viewBox">
|
||||||
<GraphEdges
|
<GraphEdges />
|
||||||
@startInteraction="setCurrentInteraction"
|
|
||||||
@endInteraction="abortCurrentInteraction"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
||||||
<GraphNodes />
|
<GraphNodes />
|
||||||
@ -351,7 +310,7 @@ watch(
|
|||||||
v-model:mode="projectStore.executionMode"
|
v-model:mode="projectStore.executionMode"
|
||||||
:title="projectStore.name"
|
:title="projectStore.name"
|
||||||
:modes="EXECUTION_MODES"
|
:modes="EXECUTION_MODES"
|
||||||
:breadcrumbs="['main', 'ad_analytics']"
|
:breadcrumbs="breadcrumbs"
|
||||||
@breadcrumbClick="console.log(`breadcrumb #${$event + 1} clicked.`)"
|
@breadcrumbClick="console.log(`breadcrumb #${$event + 1} clicked.`)"
|
||||||
@back="console.log('breadcrumbs \'back\' button clicked.')"
|
@back="console.log('breadcrumbs \'back\' button clicked.')"
|
||||||
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
|
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
|
||||||
@ -362,22 +321,18 @@ watch(
|
|||||||
<CodeEditor v-if="showCodeEditor" />
|
<CodeEditor v-if="showCodeEditor" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Transition>
|
</Transition>
|
||||||
<SelectionBrush
|
<GraphMouse />
|
||||||
v-if="scaledMousePos"
|
|
||||||
:position="scaledMousePos"
|
|
||||||
:anchor="scaledSelectionAnchor"
|
|
||||||
:style="{ transform: navigator.prescaledTransform }"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.viewport {
|
.GraphEditor {
|
||||||
position: relative;
|
position: relative;
|
||||||
contain: layout;
|
contain: layout;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
cursor: none;
|
cursor: none;
|
||||||
--group-color-fallback: #006b8a;
|
--group-color-fallback: #006b8a;
|
||||||
|
--node-color-no-type: #596b81;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
@ -1,75 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { injectGraphNavigator } from '@/providers/graphNavigator.ts'
|
import { injectGraphNavigator } from '@/providers/graphNavigator.ts'
|
||||||
import { injectGraphSelection } from '@/providers/graphSelection.ts'
|
import { injectGraphSelection } from '@/providers/graphSelection.ts'
|
||||||
import type { Edge } from '@/stores/graph'
|
import { useGraphStore, type Edge } from '@/stores/graph'
|
||||||
import type { Rect } from '@/util/rect'
|
import { assert } from '@/util/assert'
|
||||||
|
import { Rect } from '@/util/rect'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import { clamp } from '@vueuse/core'
|
import { clamp } from '@vueuse/core'
|
||||||
import type { ExprId } from 'shared/yjsModel'
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const selection = injectGraphSelection(true)
|
const selection = injectGraphSelection(true)
|
||||||
const navigator = injectGraphNavigator(true)
|
const navigator = injectGraphNavigator(true)
|
||||||
|
const graph = useGraphStore()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edge: Edge
|
edge: Edge
|
||||||
nodeRects: Map<ExprId, Rect>
|
|
||||||
exprRects: Map<ExprId, Rect>
|
|
||||||
exprNodes: Map<ExprId, ExprId>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
disconnectSource: []
|
|
||||||
disconnectTarget: []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const base = ref<SVGPathElement>()
|
const base = ref<SVGPathElement>()
|
||||||
|
|
||||||
type PosMaybeSized = { pos: Vec2; size?: Vec2 }
|
const sourceNode = computed(() => {
|
||||||
|
const setSource = props.edge.source
|
||||||
|
// When the source is not set (i.e. edge is dragged), use the currently hovered over expression
|
||||||
|
// as the source, as long as it is not from the same node as the target.
|
||||||
|
if (setSource == null && selection?.hoveredNode != null) {
|
||||||
|
const rawTargetNode = graph.db.getExpressionNodeId(props.edge.target)
|
||||||
|
if (selection.hoveredNode != rawTargetNode) return selection.hoveredNode
|
||||||
|
}
|
||||||
|
return setSource
|
||||||
|
})
|
||||||
|
|
||||||
const targetPos = computed<PosMaybeSized | null>(() => {
|
const targetExpr = computed(() => {
|
||||||
const targetExpr =
|
const setTarget = props.edge.target
|
||||||
props.edge.target ??
|
// When the target is not set (i.e. edge is dragged), use the currently hovered over expression
|
||||||
(selection?.hoveredNode != props.edge.source ? selection?.hoveredExpr : undefined)
|
// as the target, as long as it is not from the same node as the source.
|
||||||
if (targetExpr != null) {
|
if (setTarget == null && selection?.hoveredNode != null) {
|
||||||
const targetNodeId = props.exprNodes.get(targetExpr)
|
if (selection.hoveredNode != props.edge.source) return selection.hoveredPort
|
||||||
if (targetNodeId == null) return null
|
}
|
||||||
const targetNodeRect = props.nodeRects.get(targetNodeId)
|
return setTarget
|
||||||
const targetRect = props.exprRects.get(targetExpr)
|
})
|
||||||
if (targetRect == null || targetNodeRect == null) return null
|
|
||||||
return { pos: targetRect.center().add(targetNodeRect.pos), size: targetRect.size }
|
const targetNode = computed(() => graph.db.getExpressionNodeId(targetExpr.value))
|
||||||
|
const targetNodeRect = computed(() => targetNode.value && graph.nodeRects.get(targetNode.value))
|
||||||
|
|
||||||
|
const targetRect = computed<Rect | null>(() => {
|
||||||
|
const expr = targetExpr.value
|
||||||
|
if (expr != null) {
|
||||||
|
if (targetNode.value == null) return null
|
||||||
|
const targetRectRelative = graph.exprRects.get(expr)
|
||||||
|
if (targetRectRelative == null || targetNodeRect.value == null) return null
|
||||||
|
return targetRectRelative.offsetBy(targetNodeRect.value.pos)
|
||||||
} else if (navigator?.sceneMousePos != null) {
|
} else if (navigator?.sceneMousePos != null) {
|
||||||
return { pos: navigator?.sceneMousePos }
|
return new Rect(navigator.sceneMousePos, Vec2.Zero)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const sourcePos = computed<PosMaybeSized | null>(() => {
|
const sourceRect = computed<Rect | null>(() => {
|
||||||
const targetNode = props.edge.target != null ? props.exprNodes.get(props.edge.target) : undefined
|
if (sourceNode.value != null) {
|
||||||
const sourceNode =
|
return graph.nodeRects.get(sourceNode.value) ?? null
|
||||||
props.edge.source ?? (selection?.hoveredNode != targetNode ? selection?.hoveredNode : undefined)
|
|
||||||
if (sourceNode != null) {
|
|
||||||
const sourceNodeRect = props.nodeRects.get(sourceNode)
|
|
||||||
if (sourceNodeRect == null) return null
|
|
||||||
const pos = sourceNodeRect.center()
|
|
||||||
return { pos, size: sourceNodeRect.size }
|
|
||||||
} else if (navigator?.sceneMousePos != null) {
|
} else if (navigator?.sceneMousePos != null) {
|
||||||
return { pos: navigator?.sceneMousePos }
|
return new Rect(navigator.sceneMousePos, Vec2.Zero)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const edgeColor = computed(
|
||||||
|
() =>
|
||||||
|
(targetNode.value && graph.db.getNodeColorStyle(targetNode.value)) ??
|
||||||
|
(sourceNode.value && graph.db.getNodeColorStyle(sourceNode.value)),
|
||||||
|
)
|
||||||
|
|
||||||
/** The inputs to the edge state computation. */
|
/** The inputs to the edge state computation. */
|
||||||
type Inputs = {
|
type Inputs = {
|
||||||
/** The width and height of the node that originates the edge, if any.
|
/** The width and height of the node that originates the edge, if any.
|
||||||
* The edge may begin anywhere around the bottom half of the node. */
|
* The edge may begin anywhere around the bottom half of the node. */
|
||||||
sourceSize: Vec2 | undefined
|
sourceSize: Vec2
|
||||||
/** The width and height of the port that the edge is attached to, if any. */
|
/** The width and height of the port that the edge is attached to, if any. */
|
||||||
targetSize: Vec2 | undefined
|
targetSize: Vec2
|
||||||
/** The coordinates of the node input port that is the edge's destination, relative to the source position.
|
/** The coordinates of the node input port that is the edge's destination, relative to the source
|
||||||
* The edge enters the port from above. */
|
* position. The edge enters the port from above. */
|
||||||
targetOffset: Vec2
|
targetOffset: Vec2
|
||||||
|
/** The distance between the target port top edge and the target node top edge. It is undefined
|
||||||
|
* when there is no clear target node set, e.g. when the edge is being dragged. */
|
||||||
|
targetPortTopDistanceInNode: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type JunctionPoints = {
|
type JunctionPoints = {
|
||||||
@ -79,7 +93,7 @@ type JunctionPoints = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Minimum height above the target the edge must approach it from. */
|
/** Minimum height above the target the edge must approach it from. */
|
||||||
const MIN_APPROACH_HEIGHT = 32.25
|
const MIN_APPROACH_HEIGHT = 32
|
||||||
const NODE_HEIGHT = 32 // TODO (crate::component::node::HEIGHT)
|
const NODE_HEIGHT = 32 // TODO (crate::component::node::HEIGHT)
|
||||||
const NODE_CORNER_RADIUS = 16 // TODO (crate::component::node::CORNER_RADIUS)
|
const NODE_CORNER_RADIUS = 16 // TODO (crate::component::node::CORNER_RADIUS)
|
||||||
/** The preferred arc radius. */
|
/** The preferred arc radius. */
|
||||||
@ -160,20 +174,16 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
|
|||||||
// The maximum x-distance from the source (our local coordinate origin) for the point where the
|
// The maximum x-distance from the source (our local coordinate origin) for the point where the
|
||||||
// edge will begin.
|
// edge will begin.
|
||||||
const sourceMaxXOffset = Math.max(halfSourceSize.x - NODE_CORNER_RADIUS, 0)
|
const sourceMaxXOffset = Math.max(halfSourceSize.x - NODE_CORNER_RADIUS, 0)
|
||||||
// The maximum y-length of the target-attachment segment. If the layout allows, the
|
|
||||||
// target-attachment segment will fully exit the node before the first corner begins.
|
|
||||||
const targetMaxAttachmentHeight =
|
|
||||||
inputs.targetSize != null ? (NODE_HEIGHT - inputs.targetSize.y) / 2.0 : undefined
|
|
||||||
const attachment =
|
const attachment =
|
||||||
targetMaxAttachmentHeight != null
|
inputs.targetPortTopDistanceInNode != null
|
||||||
? {
|
? {
|
||||||
target: inputs.targetOffset.addScaled(new Vec2(0.0, NODE_HEIGHT), 0.5),
|
target: inputs.targetOffset.add(new Vec2(0, inputs.targetSize.y * -0.5)),
|
||||||
length: targetMaxAttachmentHeight,
|
length: inputs.targetPortTopDistanceInNode,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const targetWellBelowSource =
|
const targetWellBelowSource =
|
||||||
inputs.targetOffset.y - (targetMaxAttachmentHeight ?? 0) >= MIN_APPROACH_HEIGHT
|
inputs.targetOffset.y - (inputs.targetPortTopDistanceInNode ?? 0) >= MIN_APPROACH_HEIGHT
|
||||||
const targetBelowSource = inputs.targetOffset.y > NODE_HEIGHT / 2.0
|
const targetBelowSource = inputs.targetOffset.y > NODE_HEIGHT / 2.0
|
||||||
const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
|
const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
|
||||||
const horizontalRoomFor3Corners =
|
const horizontalRoomFor3Corners =
|
||||||
@ -223,10 +233,10 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
|
|||||||
// The target attachment will extend as far toward the edge of the node as it can without
|
// The target attachment will extend as far toward the edge of the node as it can without
|
||||||
// rising above the source.
|
// rising above the source.
|
||||||
let attachmentHeight =
|
let attachmentHeight =
|
||||||
targetMaxAttachmentHeight != null
|
inputs.targetPortTopDistanceInNode != null
|
||||||
? Math.min(targetMaxAttachmentHeight, Math.abs(inputs.targetOffset.y))
|
? Math.min(inputs.targetPortTopDistanceInNode, Math.abs(inputs.targetOffset.y))
|
||||||
: 0
|
: 0
|
||||||
let attachmentY = inputs.targetOffset.y - attachmentHeight - (inputs.targetSize?.y ?? 0) / 2.0
|
let attachmentY = inputs.targetOffset.y - attachmentHeight - inputs.targetSize.y / 2.0
|
||||||
let targetAttachment = new Vec2(inputs.targetOffset.x, attachmentY)
|
let targetAttachment = new Vec2(inputs.targetOffset.x, attachmentY)
|
||||||
return {
|
return {
|
||||||
points: [source, targetAttachment],
|
points: [source, targetAttachment],
|
||||||
@ -270,7 +280,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
|
|||||||
heightAdjustment = 0
|
heightAdjustment = 0
|
||||||
}
|
}
|
||||||
if (j0x == null || j1x == null || heightAdjustment == null) return null
|
if (j0x == null || j1x == null || heightAdjustment == null) return null
|
||||||
const attachmentHeight = targetMaxAttachmentHeight ?? 0
|
const attachmentHeight = inputs.targetPortTopDistanceInNode ?? 0
|
||||||
const top = Math.min(
|
const top = Math.min(
|
||||||
inputs.targetOffset.y - MIN_APPROACH_HEIGHT - attachmentHeight + heightAdjustment,
|
inputs.targetOffset.y - MIN_APPROACH_HEIGHT - attachmentHeight + heightAdjustment,
|
||||||
0,
|
0,
|
||||||
@ -351,13 +361,15 @@ function render(sourcePos: Vec2, elements: Element[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentJunctionPoints = computed(() => {
|
const currentJunctionPoints = computed(() => {
|
||||||
const target_ = targetPos.value
|
const target = targetRect.value
|
||||||
const source_ = sourcePos.value
|
const targetNode = targetNodeRect.value
|
||||||
if (target_ == null || source_ == null) return null
|
const source = sourceRect.value
|
||||||
const inputs = {
|
if (target == null || source == null) return null
|
||||||
targetOffset: target_.pos.sub(source_.pos),
|
const inputs: Inputs = {
|
||||||
sourceSize: source_.size,
|
targetOffset: target.center().sub(source.center()),
|
||||||
targetSize: target_.size,
|
sourceSize: source.size,
|
||||||
|
targetSize: target.size,
|
||||||
|
targetPortTopDistanceInNode: targetNode != null ? target.top - targetNode.top : undefined,
|
||||||
}
|
}
|
||||||
return junctionPoints(inputs)
|
return junctionPoints(inputs)
|
||||||
})
|
})
|
||||||
@ -367,13 +379,13 @@ const basePath = computed(() => {
|
|||||||
const jp = currentJunctionPoints.value
|
const jp = currentJunctionPoints.value
|
||||||
if (jp == null) return undefined
|
if (jp == null) return undefined
|
||||||
const { start, elements } = pathElements(jp)
|
const { start, elements } = pathElements(jp)
|
||||||
const source_ = sourcePos.value
|
const source_ = sourceRect.value
|
||||||
if (source_ == null) return undefined
|
if (source_ == null) return undefined
|
||||||
return render(source_.pos.add(start), elements)
|
return render(source_.center().add(start), elements)
|
||||||
})
|
})
|
||||||
|
|
||||||
const activePath = computed(() => {
|
const activePath = computed(() => {
|
||||||
if (hovered.value) return basePath.value
|
if (hovered.value && props.edge.source != null && props.edge.target != null) return basePath.value
|
||||||
else return undefined
|
else return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -381,22 +393,10 @@ function lengthTo(pos: Vec2): number | undefined {
|
|||||||
const path = base.value
|
const path = base.value
|
||||||
if (path == null) return undefined
|
if (path == null) return undefined
|
||||||
const totalLength = path.getTotalLength()
|
const totalLength = path.getTotalLength()
|
||||||
let precision = 16
|
|
||||||
let best: number | undefined
|
let best: number | undefined
|
||||||
let bestDist: number | undefined = undefined
|
let bestDist: number | undefined
|
||||||
for (let i = 0; i < totalLength + precision; i += precision) {
|
|
||||||
const len = Math.min(i, totalLength)
|
|
||||||
const p = path.getPointAtLength(len)
|
|
||||||
const dist = pos.distanceSquared(new Vec2(p.x, p.y))
|
|
||||||
if (bestDist == null || dist < bestDist) {
|
|
||||||
best = len
|
|
||||||
bestDist = dist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best == null || bestDist == null) return undefined
|
|
||||||
const tryPos = (len: number) => {
|
const tryPos = (len: number) => {
|
||||||
const point = path.getPointAtLength(len)
|
const dist = pos.distanceSquared(Vec2.FromDomPoint(path.getPointAtLength(len)))
|
||||||
const dist: number = pos.distanceSquared(new Vec2(point.x, point.y))
|
|
||||||
if (bestDist == null || dist < bestDist) {
|
if (bestDist == null || dist < bestDist) {
|
||||||
best = len
|
best = len
|
||||||
bestDist = dist
|
bestDist = dist
|
||||||
@ -404,7 +404,11 @@ function lengthTo(pos: Vec2): number | undefined {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (; precision >= 0.5; precision /= 2) {
|
|
||||||
|
tryPos(0), tryPos(totalLength)
|
||||||
|
assert(best != null && bestDist != null)
|
||||||
|
const precisionTarget = 0.5 / (navigator?.scale ?? 1)
|
||||||
|
for (let precision = totalLength / 2; precision >= precisionTarget; precision /= 2) {
|
||||||
tryPos(best + precision) || tryPos(best - precision)
|
tryPos(best + precision) || tryPos(best - precision)
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
@ -429,26 +433,28 @@ const activeStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' }))
|
||||||
|
|
||||||
function click(_e: PointerEvent) {
|
function click(_e: PointerEvent) {
|
||||||
if (base.value == null) return {}
|
if (base.value == null) return {}
|
||||||
if (navigator?.sceneMousePos == null) return {}
|
if (navigator?.sceneMousePos == null) return {}
|
||||||
const length = base.value.getTotalLength()
|
const length = base.value.getTotalLength()
|
||||||
let offset = lengthTo(navigator?.sceneMousePos)
|
let offset = lengthTo(navigator?.sceneMousePos)
|
||||||
if (offset == null) return {}
|
if (offset == null) return {}
|
||||||
if (offset < length / 2) emit('disconnectTarget')
|
if (offset < length / 2) graph.disconnectTarget(props.edge)
|
||||||
else emit('disconnectSource')
|
else graph.disconnectSource(props.edge)
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrowPosition(): Vec2 | undefined {
|
function arrowPosition(): Vec2 | undefined {
|
||||||
if (props.edge.source == null || props.edge.target == null) return
|
if (props.edge.source == null || props.edge.target == null) return
|
||||||
const points = currentJunctionPoints.value?.points
|
const points = currentJunctionPoints.value?.points
|
||||||
if (points == null || points.length < 3) return
|
if (points == null || points.length < 3) return
|
||||||
const target = targetPos.value
|
const target = targetRect.value
|
||||||
const source = sourcePos.value
|
const source = sourceRect.value
|
||||||
if (target == null || source == null) return
|
if (target == null || source == null) return
|
||||||
if (Math.abs(target.pos.y - source.pos.y) < ThreeCorner.BACKWARD_EDGE_ARROW_THRESHOLD) return
|
if (target.pos.y > source.pos.y - ThreeCorner.BACKWARD_EDGE_ARROW_THRESHOLD) return
|
||||||
if (points[1] == null) return
|
if (points[1] == null) return
|
||||||
return source.pos.add(points[1])
|
return source.center().add(points[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrowTransform = computed(() => {
|
const arrowTransform = computed(() => {
|
||||||
@ -467,12 +473,13 @@ const arrowTransform = computed(() => {
|
|||||||
@pointerenter="hovered = true"
|
@pointerenter="hovered = true"
|
||||||
@pointerleave="hovered = false"
|
@pointerleave="hovered = false"
|
||||||
/>
|
/>
|
||||||
<path ref="base" :d="basePath" class="edge visible base" />
|
<path ref="base" :d="basePath" class="edge visible" :style="baseStyle" />
|
||||||
<polygon
|
<polygon
|
||||||
v-if="arrowTransform"
|
v-if="arrowTransform"
|
||||||
:transform="arrowTransform"
|
:transform="arrowTransform"
|
||||||
points="0,-9.375 -9.375,9.375 9.375,9.375"
|
points="0,-9.375 -9.375,9.375 9.375,9.375"
|
||||||
class="arrow visible"
|
class="arrow visible"
|
||||||
|
:style="baseStyle"
|
||||||
/>
|
/>
|
||||||
<path v-if="activePath" :d="activePath" class="edge visible active" :style="activeStyle" />
|
<path v-if="activePath" :d="activePath" class="edge visible active" :style="activeStyle" />
|
||||||
</template>
|
</template>
|
||||||
@ -481,26 +488,30 @@ const arrowTransform = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.visible {
|
.visible {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
--edge-color: color-mix(in oklab, var(--node-base-color) 85%, white 15%);
|
||||||
}
|
}
|
||||||
.arrow {
|
|
||||||
fill: tan;
|
|
||||||
}
|
|
||||||
.edge {
|
.edge {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-linecap: round;
|
stroke: var(--edge-color);
|
||||||
|
transition: stroke 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
fill: var(--edge-color);
|
||||||
|
transition: fill 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.edge.io {
|
.edge.io {
|
||||||
stroke-width: 14;
|
stroke-width: 14;
|
||||||
stroke: transparent;
|
stroke: transparent;
|
||||||
}
|
}
|
||||||
.edge.visible {
|
.edge.visible {
|
||||||
stroke-width: 4;
|
stroke-width: 4;
|
||||||
}
|
|
||||||
.edge.visible.base {
|
|
||||||
stroke: tan;
|
|
||||||
}
|
|
||||||
.edge.visible.active {
|
|
||||||
stroke: red;
|
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edge.visible.active {
|
||||||
|
stroke: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,84 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GraphEdge from '@/components/GraphEditor/GraphEdge.vue'
|
import GraphEdge from '@/components/GraphEditor/GraphEdge.vue'
|
||||||
import { injectGraphSelection } from '@/providers/graphSelection.ts'
|
import { injectGraphSelection } from '@/providers/graphSelection.ts'
|
||||||
|
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { Interaction } from '@/util/interaction.ts'
|
|
||||||
import type { ExprId } from 'shared/yjsModel.ts'
|
import type { ExprId } from 'shared/yjsModel.ts'
|
||||||
import { watch } from 'vue'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const graph = useGraphStore()
|
||||||
startInteraction: [Interaction]
|
|
||||||
endInteraction: [Interaction]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const graphStore = useGraphStore()
|
|
||||||
const selection = injectGraphSelection(true)
|
const selection = injectGraphSelection(true)
|
||||||
|
const interaction = injectInteractionHandler()
|
||||||
|
|
||||||
class EditingEdge extends Interaction {
|
const editingEdge: Interaction = {
|
||||||
cancel() {
|
cancel() {
|
||||||
const target = graphStore.unconnectedEdge?.disconnectedEdgeTarget
|
const target = graph.unconnectedEdge?.disconnectedEdgeTarget
|
||||||
graphStore.transact(() => {
|
graph.transact(() => {
|
||||||
if (target != null) disconnectEdge(target)
|
if (target != null) disconnectEdge(target)
|
||||||
graphStore.clearUnconnected()
|
graph.clearUnconnected()
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
click(_e: MouseEvent): boolean {
|
click(_e: MouseEvent): boolean {
|
||||||
if (graphStore.unconnectedEdge == null) return false
|
if (graph.unconnectedEdge == null) return false
|
||||||
const source = graphStore.unconnectedEdge.source ?? selection?.hoveredNode
|
const source = graph.unconnectedEdge.source ?? selection?.hoveredNode
|
||||||
const target = graphStore.unconnectedEdge.target ?? selection?.hoveredExpr
|
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
|
||||||
const targetNode = target != null ? graphStore.exprNodes.get(target) : undefined
|
const targetNode = graph.db.getExpressionNodeId(target)
|
||||||
graphStore.transact(() => {
|
graph.transact(() => {
|
||||||
if (source != null && source != targetNode) {
|
if (source != null && source != targetNode) {
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
if (graphStore.unconnectedEdge?.disconnectedEdgeTarget != null)
|
if (graph.unconnectedEdge?.disconnectedEdgeTarget != null)
|
||||||
disconnectEdge(graphStore.unconnectedEdge.disconnectedEdgeTarget)
|
disconnectEdge(graph.unconnectedEdge.disconnectedEdgeTarget)
|
||||||
createNodeFromEdgeDrop(source)
|
createNodeFromEdgeDrop(source)
|
||||||
} else {
|
} else {
|
||||||
createEdge(source, target)
|
createEdge(source, target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
graphStore.clearUnconnected()
|
graph.clearUnconnected()
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
const editingEdge = new EditingEdge()
|
interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge)
|
||||||
|
|
||||||
function disconnectEdge(target: ExprId) {
|
function disconnectEdge(target: ExprId) {
|
||||||
graphStore.setExpressionContent(target, '_')
|
graph.setExpressionContent(target, '_')
|
||||||
}
|
}
|
||||||
function createNodeFromEdgeDrop(source: ExprId) {
|
function createNodeFromEdgeDrop(source: ExprId) {
|
||||||
console.log(`TODO: createNodeFromEdgeDrop(${JSON.stringify(source)})`)
|
console.log(`TODO: createNodeFromEdgeDrop(${JSON.stringify(source)})`)
|
||||||
}
|
}
|
||||||
function createEdge(source: ExprId, target: ExprId) {
|
function createEdge(source: ExprId, target: ExprId) {
|
||||||
const sourceNode = graphStore.nodes.get(source)
|
const sourceNode = graph.db.getNode(source)
|
||||||
if (sourceNode == null) return
|
if (sourceNode == null) return
|
||||||
// TODO: Check alias analysis to see if the binding is shadowed.
|
// TODO: Check alias analysis to see if the binding is shadowed.
|
||||||
graphStore.setExpressionContent(target, sourceNode.binding)
|
graph.setExpressionContent(target, sourceNode.binding)
|
||||||
// TODO: Use alias analysis to ensure declarations are in a dependency order.
|
// TODO: Use alias analysis to ensure declarations are in a dependency order.
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => graphStore.unconnectedEdge,
|
|
||||||
(edge) => {
|
|
||||||
if (edge != null) {
|
|
||||||
emit('startInteraction', editingEdge)
|
|
||||||
} else {
|
|
||||||
emit('endInteraction', editingEdge)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GraphEdge
|
<GraphEdge v-for="(edge, index) in graph.edges" :key="index" :edge="edge" />
|
||||||
v-for="(edge, index) in graphStore.edges"
|
|
||||||
:key="index"
|
|
||||||
:edge="edge"
|
|
||||||
:nodeRects="graphStore.nodeRects"
|
|
||||||
:exprRects="graphStore.exprRects"
|
|
||||||
:exprNodes="graphStore.exprNodes"
|
|
||||||
@disconnectSource="graphStore.disconnectSource(edge)"
|
|
||||||
@disconnectTarget="graphStore.disconnectTarget(edge)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
@ -2,23 +2,18 @@
|
|||||||
import { nodeEditBindings } from '@/bindings'
|
import { nodeEditBindings } from '@/bindings'
|
||||||
import CircularMenu from '@/components/CircularMenu.vue'
|
import CircularMenu from '@/components/CircularMenu.vue'
|
||||||
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
|
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
|
||||||
import NodeTree from '@/components/GraphEditor/NodeTree.vue'
|
import NodeWidgetTree from '@/components/GraphEditor/NodeWidgetTree.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||||
import type { Node } from '@/stores/graph'
|
import { useGraphStore, type Node } from '@/stores/graph'
|
||||||
import { useProjectStore } from '@/stores/project'
|
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
|
||||||
import { useApproach } from '@/util/animation'
|
import { useApproach } from '@/util/animation'
|
||||||
import { colorFromString } from '@/util/colors'
|
|
||||||
import { usePointer, useResizeObserver } from '@/util/events'
|
import { usePointer, useResizeObserver } from '@/util/events'
|
||||||
import { methodNameToIcon, typeNameToIcon } from '@/util/getIconName'
|
import { methodNameToIcon, typeNameToIcon } from '@/util/getIconName'
|
||||||
import type { Opt } from '@/util/opt'
|
import type { Opt } from '@/util/opt'
|
||||||
import { qnJoin, tryQualifiedName } from '@/util/qualifiedName'
|
|
||||||
import { Rect } from '@/util/rect'
|
import { Rect } from '@/util/rect'
|
||||||
import { unwrap } from '@/util/result'
|
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
|
import type { ContentRange, VisualizationIdentifier } from 'shared/yjsModel'
|
||||||
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
|
import { computed, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const MAXIMUM_CLICK_LENGTH_MS = 300
|
const MAXIMUM_CLICK_LENGTH_MS = 300
|
||||||
const MAXIMUM_CLICK_DISTANCE_SQ = 50
|
const MAXIMUM_CLICK_DISTANCE_SQ = 50
|
||||||
@ -30,7 +25,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
updateRect: [rect: Rect]
|
updateRect: [rect: Rect]
|
||||||
updateExprRect: [id: ExprId, rect: Rect]
|
|
||||||
updateContent: [updates: [range: ContentRange, content: string][]]
|
updateContent: [updates: [range: ContentRange, content: string][]]
|
||||||
dragging: [offset: Vec2]
|
dragging: [offset: Vec2]
|
||||||
draggingCommited: []
|
draggingCommited: []
|
||||||
@ -44,11 +38,14 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const nodeSelection = injectGraphSelection(true)
|
const nodeSelection = injectGraphSelection(true)
|
||||||
|
const graph = useGraphStore()
|
||||||
|
const isSourceOfDraggedEdge = computed(
|
||||||
|
() => graph.unconnectedEdge?.source === props.node.rootSpan.astId,
|
||||||
|
)
|
||||||
|
|
||||||
const nodeId = computed(() => props.node.rootSpan.astId)
|
const nodeId = computed(() => props.node.rootSpan.astId)
|
||||||
const rootNode = ref<HTMLElement>()
|
const rootNode = ref<HTMLElement>()
|
||||||
const nodeSize = useResizeObserver(rootNode)
|
const nodeSize = useResizeObserver(rootNode)
|
||||||
const editableRootNode = ref<HTMLElement>()
|
|
||||||
const menuVisible = ref(false)
|
const menuVisible = ref(false)
|
||||||
|
|
||||||
const isSelected = computed(() => nodeSelection?.isSelected(nodeId.value) ?? false)
|
const isSelected = computed(() => nodeSelection?.isSelected(nodeId.value) ?? false)
|
||||||
@ -60,8 +57,6 @@ const isAutoEvaluationDisabled = ref(false)
|
|||||||
const isDocsVisible = ref(false)
|
const isDocsVisible = ref(false)
|
||||||
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
|
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const size = nodeSize.value
|
const size = nodeSize.value
|
||||||
if (!size.isZero()) {
|
if (!size.isZero()) {
|
||||||
@ -70,7 +65,11 @@ watchEffect(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const outputHovered = ref(false)
|
const outputHovered = ref(false)
|
||||||
const hoverAnimation = useApproach(() => (outputHovered.value ? 1 : 0), 50, 0.01)
|
const hoverAnimation = useApproach(
|
||||||
|
() => (outputHovered.value || isSourceOfDraggedEdge.value ? 1 : 0),
|
||||||
|
50,
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
|
||||||
const bgStyleVariables = computed(() => {
|
const bgStyleVariables = computed(() => {
|
||||||
return {
|
return {
|
||||||
@ -85,268 +84,6 @@ const transform = computed(() => {
|
|||||||
return `translate(${pos.x}px, ${pos.y}px)`
|
return `translate(${pos.x}px, ${pos.y}px)`
|
||||||
})
|
})
|
||||||
|
|
||||||
function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): number {
|
|
||||||
if (domNode instanceof HTMLElement && domOffset == 1) {
|
|
||||||
const offsetData = domNode.dataset.spanStart
|
|
||||||
const offset = (offsetData != null && parseInt(offsetData)) || 0
|
|
||||||
const length = domNode.textContent?.length ?? 0
|
|
||||||
return offset + length
|
|
||||||
} else if (domNode instanceof Text) {
|
|
||||||
const siblingEl = domNode.previousElementSibling
|
|
||||||
if (siblingEl instanceof HTMLElement) {
|
|
||||||
const offsetData = siblingEl.dataset.spanStart
|
|
||||||
if (offsetData != null)
|
|
||||||
return parseInt(offsetData) + domOffset + (siblingEl.textContent?.length ?? 0)
|
|
||||||
}
|
|
||||||
const offsetData = domNode.parentElement?.dataset.spanStart
|
|
||||||
if (offsetData != null) return parseInt(offsetData) + domOffset
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRange(range: ContentRange, threhsold: number, adjust: number) {
|
|
||||||
range[0] = updateOffset(range[0], threhsold, adjust)
|
|
||||||
range[1] = updateOffset(range[1], threhsold, adjust)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOffset(offset: number, threhsold: number, adjust: number) {
|
|
||||||
return offset >= threhsold ? offset + adjust : offset
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateExprRect(id: ExprId, rect: Rect) {
|
|
||||||
emit('updateExprRect', id, rect)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextEdit {
|
|
||||||
range: ContentRange
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const editsToApply = reactive<TextEdit[]>([])
|
|
||||||
|
|
||||||
function editContent(e: Event) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!(e instanceof InputEvent)) return
|
|
||||||
|
|
||||||
const domRanges = e.getTargetRanges()
|
|
||||||
const ranges = domRanges.map<ContentRange>((r) => {
|
|
||||||
return [
|
|
||||||
getRelatedSpanOffset(r.startContainer, r.startOffset),
|
|
||||||
getRelatedSpanOffset(r.endContainer, r.endOffset),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
switch (e.inputType) {
|
|
||||||
case 'insertText': {
|
|
||||||
const content = e.data ?? ''
|
|
||||||
for (let range of ranges) {
|
|
||||||
if (range[0] != range[1]) {
|
|
||||||
editsToApply.push({ range, content: '' })
|
|
||||||
}
|
|
||||||
editsToApply.push({ range: [range[1], range[1]], content })
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'insertFromDrop':
|
|
||||||
case 'insertFromPaste': {
|
|
||||||
const content = e.dataTransfer?.getData('text/plain')
|
|
||||||
if (content != null) {
|
|
||||||
for (let range of ranges) {
|
|
||||||
editsToApply.push({ range, content })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'deleteByCut':
|
|
||||||
case 'deleteWordBackward':
|
|
||||||
case 'deleteWordForward':
|
|
||||||
case 'deleteContentBackward':
|
|
||||||
case 'deleteContentForward':
|
|
||||||
case 'deleteByDrag': {
|
|
||||||
for (let range of ranges) {
|
|
||||||
editsToApply.push({ range, content: '' })
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(editsToApply, () => {
|
|
||||||
if (editsToApply.length === 0) return
|
|
||||||
saveSelections()
|
|
||||||
let edit: TextEdit | undefined
|
|
||||||
const updates: [ContentRange, string][] = []
|
|
||||||
while ((edit = editsToApply.shift())) {
|
|
||||||
const range = edit.range
|
|
||||||
const content = edit.content
|
|
||||||
const adjust = content.length - (range[1] - range[0])
|
|
||||||
editsToApply.forEach((e) => updateRange(e.range, range[1], adjust))
|
|
||||||
if (selectionToRecover) {
|
|
||||||
selectionToRecover.ranges.forEach((r) => updateRange(r, range[1], adjust))
|
|
||||||
if (selectionToRecover.anchor != null) {
|
|
||||||
selectionToRecover.anchor = updateOffset(selectionToRecover.anchor, range[1], adjust)
|
|
||||||
}
|
|
||||||
if (selectionToRecover.focus != null) {
|
|
||||||
selectionToRecover.focus = updateOffset(selectionToRecover.focus, range[1], adjust)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updates.push([range, content])
|
|
||||||
}
|
|
||||||
emit('updateContent', updates)
|
|
||||||
})
|
|
||||||
|
|
||||||
interface SavedSelections {
|
|
||||||
anchor: number | null
|
|
||||||
focus: number | null
|
|
||||||
ranges: ContentRange[]
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectionToRecover: SavedSelections | null = null
|
|
||||||
|
|
||||||
function saveSelections() {
|
|
||||||
const root = editableRootNode.value
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (root == null || selection == null || !selection.containsNode(root, true)) return
|
|
||||||
const ranges: ContentRange[] = Array.from({ length: selection.rangeCount }, (_, i) =>
|
|
||||||
selection.getRangeAt(i),
|
|
||||||
)
|
|
||||||
.filter((r) => r.intersectsNode(root))
|
|
||||||
.map((r) => [
|
|
||||||
getRelatedSpanOffset(r.startContainer, r.startOffset),
|
|
||||||
getRelatedSpanOffset(r.endContainer, r.endOffset),
|
|
||||||
])
|
|
||||||
|
|
||||||
let anchor =
|
|
||||||
selection.anchorNode && root.contains(selection.anchorNode)
|
|
||||||
? getRelatedSpanOffset(selection.anchorNode, selection.anchorOffset)
|
|
||||||
: null
|
|
||||||
let focus =
|
|
||||||
selection.focusNode && root.contains(selection.focusNode)
|
|
||||||
? getRelatedSpanOffset(selection.focusNode, selection.focusOffset)
|
|
||||||
: null
|
|
||||||
|
|
||||||
selectionToRecover = {
|
|
||||||
anchor,
|
|
||||||
focus,
|
|
||||||
ranges,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdated(() => {
|
|
||||||
const root = editableRootNode.value
|
|
||||||
|
|
||||||
function findTextNodeAtOffset(offset: number | null): { node: Text; offset: number } | null {
|
|
||||||
if (offset == null) return null
|
|
||||||
for (let textSpan of root?.querySelectorAll<HTMLSpanElement>('span[data-span-start]') ?? []) {
|
|
||||||
if (textSpan.children.length > 0) continue
|
|
||||||
const start = parseInt(textSpan.dataset.spanStart ?? '0')
|
|
||||||
const text = textSpan.textContent ?? ''
|
|
||||||
const end = start + text.length
|
|
||||||
if (start <= offset && offset <= end) {
|
|
||||||
let remainingOffset = offset - start
|
|
||||||
for (let node of textSpan.childNodes) {
|
|
||||||
if (node instanceof Text) {
|
|
||||||
let length = node.data.length
|
|
||||||
if (remainingOffset > length) {
|
|
||||||
remainingOffset -= length
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
node,
|
|
||||||
offset: remainingOffset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionToRecover != null && editableRootNode.value != null) {
|
|
||||||
const saved = selectionToRecover
|
|
||||||
selectionToRecover = null
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection == null) return
|
|
||||||
|
|
||||||
for (let range of saved.ranges) {
|
|
||||||
const start = findTextNodeAtOffset(range[0])
|
|
||||||
const end = findTextNodeAtOffset(range[1])
|
|
||||||
if (start == null || end == null) continue
|
|
||||||
let newRange = document.createRange()
|
|
||||||
newRange.setStart(start.node, start.offset)
|
|
||||||
newRange.setEnd(end.node, end.offset)
|
|
||||||
selection.addRange(newRange)
|
|
||||||
}
|
|
||||||
if (saved.anchor != null || saved.focus != null) {
|
|
||||||
const anchor = findTextNodeAtOffset(saved.anchor) ?? {
|
|
||||||
node: selection.anchorNode,
|
|
||||||
offset: selection.anchorOffset,
|
|
||||||
}
|
|
||||||
const focus = findTextNodeAtOffset(saved.focus) ?? {
|
|
||||||
node: selection.focusNode,
|
|
||||||
offset: selection.focusOffset,
|
|
||||||
}
|
|
||||||
if (anchor.node == null || focus.node == null) return
|
|
||||||
selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [isAutoEvaluationDisabled.value, isDocsVisible.value, isVisualizationVisible.value],
|
|
||||||
() => {
|
|
||||||
rootNode.value?.focus()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const editableKeydownHandler = nodeEditBindings.handler({
|
|
||||||
selectAll() {
|
|
||||||
const element = editableRootNode.value
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (element == null || selection == null) return
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNodeContents(element)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(range)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function startEditingHandler(event: PointerEvent) {
|
|
||||||
let range, textNode, offset
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
if ((document as any).caretPositionFromPoint) {
|
|
||||||
range = (document as any).caretPositionFromPoint(event.clientX, event.clientY)
|
|
||||||
textNode = range.offsetNode
|
|
||||||
offset = range.offset
|
|
||||||
} else if (document.caretRangeFromPoint) {
|
|
||||||
range = document.caretRangeFromPoint(event.clientX, event.clientY)
|
|
||||||
if (range == null) {
|
|
||||||
console.error('Could not find caret position when editing node.')
|
|
||||||
} else {
|
|
||||||
textNode = range.startContainer
|
|
||||||
offset = range.startOffset
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
'Neither caretPositionFromPoint nor caretRangeFromPoint are supported by this browser',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let newRange = document.createRange()
|
|
||||||
newRange.setStart(textNode, offset)
|
|
||||||
|
|
||||||
let selection = window.getSelection()
|
|
||||||
if (selection != null) {
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(newRange)
|
|
||||||
} else {
|
|
||||||
console.error('Could not set selection when editing node.')
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:edited', offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEpochMs = ref(0)
|
const startEpochMs = ref(0)
|
||||||
let startEvent: PointerEvent | null = null
|
let startEvent: PointerEvent | null = null
|
||||||
let startPos = Vec2.Zero
|
let startPos = Vec2.Zero
|
||||||
@ -380,24 +117,11 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestionDbStore = useSuggestionDbStore()
|
const expressionInfo = computed(() => graph.db.nodeExpressionInfo.lookup(nodeId.value))
|
||||||
|
|
||||||
const expressionInfo = computed(() =>
|
|
||||||
projectStore.computedValueRegistry.getExpressionInfo(props.node.rootSpan.astId),
|
|
||||||
)
|
|
||||||
const outputTypeName = computed(() => expressionInfo.value?.typename ?? 'Unknown')
|
const outputTypeName = computed(() => expressionInfo.value?.typename ?? 'Unknown')
|
||||||
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
|
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
|
||||||
const suggestionEntry = computed(() => {
|
const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value))
|
||||||
const method = expressionInfo.value?.methodCall?.methodPointer
|
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
|
||||||
if (method == null) return undefined
|
|
||||||
const typeName = tryQualifiedName(method.definedOnType)
|
|
||||||
const methodName = tryQualifiedName(method.name)
|
|
||||||
if (!typeName.ok || !methodName.ok) return undefined
|
|
||||||
const qualifiedName = qnJoin(unwrap(typeName), unwrap(methodName))
|
|
||||||
const [id] = suggestionDbStore.entries.nameToId.lookup(qualifiedName)
|
|
||||||
if (id == null) return undefined
|
|
||||||
return suggestionDbStore.entries.get(id)
|
|
||||||
})
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
if (suggestionEntry.value?.iconName) {
|
if (suggestionEntry.value?.iconName) {
|
||||||
return suggestionEntry.value.iconName
|
return suggestionEntry.value.iconName
|
||||||
@ -411,14 +135,61 @@ const icon = computed(() => {
|
|||||||
return 'in_out'
|
return 'in_out'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const color = computed(() =>
|
|
||||||
suggestionEntry.value?.groupIndex != null
|
|
||||||
? `var(--group-color-${suggestionDbStore.groups[suggestionEntry.value.groupIndex]?.name})`
|
|
||||||
: colorFromString(expressionInfo.value?.typename ?? 'Unknown'),
|
|
||||||
)
|
|
||||||
|
|
||||||
function hoverExpr(id: ExprId | undefined) {
|
const nodeEditHandler = nodeEditBindings.handler({
|
||||||
if (nodeSelection != null) nodeSelection.hoveredExpr = id
|
cancel(e) {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
e.target.blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
edit(e) {
|
||||||
|
const pos = 'clientX' in e ? new Vec2(e.clientX, e.clientY) : undefined
|
||||||
|
startEditingNode(pos)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function startEditingNode(position: Vec2 | undefined) {
|
||||||
|
let sourceOffset = 0
|
||||||
|
if (position != null) {
|
||||||
|
let domNode, domOffset
|
||||||
|
if ((document as any).caretPositionFromPoint) {
|
||||||
|
const caret = document.caretPositionFromPoint(position.x, position.y)
|
||||||
|
domNode = caret?.offsetNode
|
||||||
|
domOffset = caret?.offset
|
||||||
|
} else if (document.caretRangeFromPoint) {
|
||||||
|
const caret = document.caretRangeFromPoint(position.x, position.y)
|
||||||
|
domNode = caret?.startContainer
|
||||||
|
domOffset = caret?.startOffset
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'Neither caretPositionFromPoint nor caretRangeFromPoint are supported by this browser',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (domNode != null && domOffset != null) {
|
||||||
|
sourceOffset = getRelatedSpanOffset(domNode, domOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:edited', sourceOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): number {
|
||||||
|
if (domNode instanceof HTMLElement && domOffset == 1) {
|
||||||
|
const offsetData = domNode.dataset.spanStart
|
||||||
|
const offset = (offsetData != null && parseInt(offsetData)) || 0
|
||||||
|
const length = domNode.textContent?.length ?? 0
|
||||||
|
return offset + length
|
||||||
|
} else if (domNode instanceof Text) {
|
||||||
|
const siblingEl = domNode.previousElementSibling
|
||||||
|
if (siblingEl instanceof HTMLElement) {
|
||||||
|
const offsetData = siblingEl.dataset.spanStart
|
||||||
|
if (offsetData != null)
|
||||||
|
return parseInt(offsetData) + domOffset + (siblingEl.textContent?.length ?? 0)
|
||||||
|
}
|
||||||
|
const offsetData = domNode.parentElement?.dataset.spanStart
|
||||||
|
if (offsetData != null) return parseInt(offsetData) + domOffset
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -431,6 +202,7 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
'--node-group-color': color,
|
'--node-group-color': color,
|
||||||
}"
|
}"
|
||||||
:class="{
|
:class="{
|
||||||
|
edited: props.edited,
|
||||||
dragging: dragPointer.dragging,
|
dragging: dragPointer.dragging,
|
||||||
selected: nodeSelection?.isSelected(nodeId),
|
selected: nodeSelection?.isSelected(nodeId),
|
||||||
visualizationVisible: isVisualizationVisible,
|
visualizationVisible: isVisualizationVisible,
|
||||||
@ -458,21 +230,14 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
@setVisualizationId="emit('setVisualizationId', $event)"
|
@setVisualizationId="emit('setVisualizationId', $event)"
|
||||||
@setVisualizationVisible="emit('setVisualizationVisible', $event)"
|
@setVisualizationVisible="emit('setVisualizationVisible', $event)"
|
||||||
/>
|
/>
|
||||||
<div class="node" v-on="dragPointer.events">
|
<div
|
||||||
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon
|
class="node"
|
||||||
><span
|
@pointerdown.capture="nodeEditHandler"
|
||||||
ref="editableRootNode"
|
@keydown="nodeEditHandler"
|
||||||
spellcheck="false"
|
v-on="dragPointer.events"
|
||||||
@beforeinput="editContent"
|
>
|
||||||
@keydown="editableKeydownHandler"
|
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon>
|
||||||
@pointerdown.stop.prevent="startEditingHandler"
|
<NodeWidgetTree :ast="node.rootSpan" />
|
||||||
@blur="projectStore.stopCapturingUndo()"
|
|
||||||
><NodeTree
|
|
||||||
:ast="node.rootSpan"
|
|
||||||
:nodeSpanStart="node.rootSpan.span()[0]"
|
|
||||||
@updateExprRect="updateExprRect"
|
|
||||||
@updateHoveredExpr="hoverExpr($event)"
|
|
||||||
/></span>
|
|
||||||
</div>
|
</div>
|
||||||
<svg class="bgPaths" :style="bgStyleVariables">
|
<svg class="bgPaths" :style="bgStyleVariables">
|
||||||
<rect class="bgFill" />
|
<rect class="bgFill" />
|
||||||
@ -480,11 +245,11 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
class="outputPortHoverArea"
|
class="outputPortHoverArea"
|
||||||
@pointerenter="outputHovered = true"
|
@pointerenter="outputHovered = true"
|
||||||
@pointerleave="outputHovered = false"
|
@pointerleave="outputHovered = false"
|
||||||
@pointerdown="emit('outputPortAction')"
|
@pointerdown.stop.prevent="emit('outputPortAction')"
|
||||||
/>
|
/>
|
||||||
<rect class="outputPort" />
|
<rect class="outputPort" />
|
||||||
|
<text class="outputTypeName">{{ outputTypeName }}</text>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="outputTypeName">{{ outputTypeName }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -499,7 +264,7 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
--output-port-max-width: 6px;
|
--output-port-max-width: 6px;
|
||||||
--output-port-overlap: 0.1px;
|
--output-port-overlap: 0.2px;
|
||||||
--output-port-hover-width: 8px;
|
--output-port-hover-width: 8px;
|
||||||
}
|
}
|
||||||
.outputPort,
|
.outputPort,
|
||||||
@ -540,6 +305,16 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outputTypeName {
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
text-anchor: middle;
|
||||||
|
opacity: calc(var(--hover-animation) * var(--hover-animation));
|
||||||
|
fill: var(--node-color-primary);
|
||||||
|
transform: translate(50%, calc(var(--node-height) + var(--output-port-max-width) + 16px));
|
||||||
|
}
|
||||||
|
|
||||||
.bgFill {
|
.bgFill {
|
||||||
width: var(--node-width);
|
width: var(--node-width);
|
||||||
height: var(--node-height);
|
height: var(--node-height);
|
||||||
@ -549,22 +324,16 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
transition: fill 0.2s ease;
|
transition: fill 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgPaths .bgPaths:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.GraphNode {
|
.GraphNode {
|
||||||
--node-height: 32px;
|
--node-height: 32px;
|
||||||
--node-border-radius: 16px;
|
--node-border-radius: 16px;
|
||||||
|
|
||||||
--node-group-color: #357ab9;
|
|
||||||
|
|
||||||
--node-color-primary: color-mix(
|
--node-color-primary: color-mix(
|
||||||
in oklab,
|
in oklab,
|
||||||
var(--node-group-color) 100%,
|
var(--node-group-color) 100%,
|
||||||
var(--node-group-color) 0%
|
var(--node-group-color) 0%
|
||||||
);
|
);
|
||||||
--node-color-port: color-mix(in oklab, var(--node-color-primary) 75%, white 15%);
|
--node-color-port: color-mix(in oklab, var(--node-color-primary) 85%, white 15%);
|
||||||
--node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
|
--node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
|
||||||
|
|
||||||
&.executionState-Unknown,
|
&.executionState-Unknown,
|
||||||
@ -580,6 +349,10 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.GraphNode.edited {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -591,11 +364,12 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 4px 8px;
|
padding: 4px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: outline 0.2s ease;
|
transition: outline 0.2s ease;
|
||||||
outline: 0px solid transparent;
|
outline: 0px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.GraphNode .selection {
|
.GraphNode .selection {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(0px - var(--selected-node-border-width));
|
inset: calc(0px - var(--selected-node-border-width));
|
||||||
@ -653,6 +427,10 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
& :deep(span) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@ -663,27 +441,10 @@ function hoverExpr(id: ExprId | undefined) {
|
|||||||
|
|
||||||
.grab-handle {
|
.grab-handle {
|
||||||
color: white;
|
color: white;
|
||||||
margin-right: 10px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CircularMenu {
|
.CircularMenu {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.outputTypeName {
|
|
||||||
user-select: none;
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 110%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
color: var(--node-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.GraphNode:hover .outputTypeName {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -45,15 +45,13 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GraphNode
|
<GraphNode
|
||||||
v-for="[id, node] in graphStore.nodes"
|
v-for="[id, node] in graphStore.db.allNodes()"
|
||||||
v-show="id != graphStore.editedNodeInfo?.id"
|
|
||||||
:key="id"
|
:key="id"
|
||||||
:node="node"
|
:node="node"
|
||||||
:edited="false"
|
:edited="id === graphStore.editedNodeInfo?.id"
|
||||||
@update:edited="graphStore.setEditedNode(id, $event)"
|
@update:edited="graphStore.setEditedNode(id, $event)"
|
||||||
@updateRect="graphStore.updateNodeRect(id, $event)"
|
@updateRect="graphStore.updateNodeRect(id, $event)"
|
||||||
@delete="graphStore.deleteNode(id)"
|
@delete="graphStore.deleteNode(id)"
|
||||||
@updateExprRect="graphStore.updateExprRect"
|
|
||||||
@pointerenter="hoverNode(id)"
|
@pointerenter="hoverNode(id)"
|
||||||
@pointerleave="hoverNode(undefined)"
|
@pointerleave="hoverNode(undefined)"
|
||||||
@updateContent="updateNodeContent(id, $event)"
|
@updateContent="updateNodeContent(id, $event)"
|
||||||
|
@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Ast, type AstExtended } from '@/util/ast'
|
|
||||||
import { useResizeObserver } from '@/util/events'
|
|
||||||
import { Rect } from '@/util/rect'
|
|
||||||
import { Vec2 } from '@/util/vec2'
|
|
||||||
import type { ExprId } from 'shared/yjsModel'
|
|
||||||
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ nodeSpanStart: number; ast: AstExtended<Ast.Token> }>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
updateExprRect: [expr: ExprId, rect: Rect]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
|
||||||
const nodeSize = useResizeObserver(rootNode, false)
|
|
||||||
const exprRect = shallowRef<Rect>()
|
|
||||||
|
|
||||||
const spanClass = computed(() => Ast.Token.typeNames[props.ast.inner.type])
|
|
||||||
const whitespace = computed(() => ' '.repeat(props.ast.inner.whitespaceLengthInCodeBuffer))
|
|
||||||
|
|
||||||
function updateRect() {
|
|
||||||
let domNode = rootNode.value
|
|
||||||
if (domNode == null) return
|
|
||||||
const pos = new Vec2(domNode.offsetLeft, domNode.offsetTop)
|
|
||||||
const size = nodeSize.value
|
|
||||||
const rect = new Rect(pos, size)
|
|
||||||
if (exprRect.value != null && rect.equals(exprRect.value)) return
|
|
||||||
exprRect.value = rect
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(nodeSize, updateRect)
|
|
||||||
onUpdated(updateRect)
|
|
||||||
watch(exprRect, (rect) => rect && emit('updateExprRect', props.ast.astId, rect))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span
|
|
||||||
ref="rootNode"
|
|
||||||
:class="['Token', spanClass]"
|
|
||||||
:data-span-start="props.ast.span()[0] - nodeSpanStart - whitespace.length"
|
|
||||||
>{{ whitespace }}{{ props.ast.repr() }}</span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.Token {
|
|
||||||
color: white;
|
|
||||||
white-space: pre;
|
|
||||||
align-items: center;
|
|
||||||
color: rgb(255 255 255 / 0.33);
|
|
||||||
|
|
||||||
&.Ident,
|
|
||||||
&.TextSection,
|
|
||||||
&.Digits {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.TextSection,
|
|
||||||
&.Digits {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,173 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import NodeToken from '@/components/GraphEditor/NodeToken.vue'
|
|
||||||
import { Ast, type AstExtended } from '@/util/ast'
|
|
||||||
import { useResizeObserver } from '@/util/events'
|
|
||||||
import { Rect } from '@/util/rect'
|
|
||||||
import { Vec2 } from '@/util/vec2'
|
|
||||||
import type { ExprId } from 'shared/yjsModel'
|
|
||||||
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ nodeSpanStart: number; ast: AstExtended<Ast.Tree> }>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
updateExprRect: [expr: ExprId, rect: Rect]
|
|
||||||
updateHoveredExpr: [id: ExprId | undefined]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
|
||||||
const nodeSize = useResizeObserver(rootNode, false)
|
|
||||||
const exprRect = shallowRef<Rect>()
|
|
||||||
|
|
||||||
const spanClass = computed(() => Ast.Tree.typeNames[props.ast.inner.type])
|
|
||||||
const children = computed(() => [...props.ast.children()])
|
|
||||||
const isOnStart = computed(() => props.nodeSpanStart === props.ast.span()[0])
|
|
||||||
const whitespace = computed(() =>
|
|
||||||
isOnStart.value ? '' : ' '.repeat(props.ast.inner.whitespaceLengthInCodeParsed),
|
|
||||||
)
|
|
||||||
|
|
||||||
const singularToken = computed(() =>
|
|
||||||
whitespace.value.length === 0 && children.value.length === 1 && children.value[0]!.isToken()
|
|
||||||
? children.value[0]
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
|
|
||||||
function updateRect() {
|
|
||||||
let domNode = rootNode.value
|
|
||||||
if (domNode == null) return
|
|
||||||
const pos = new Vec2(domNode.offsetLeft, domNode.offsetTop)
|
|
||||||
const size = nodeSize.value
|
|
||||||
const rect = new Rect(pos, size)
|
|
||||||
if (exprRect.value != null && rect.equals(exprRect.value)) return
|
|
||||||
exprRect.value = rect
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(nodeSize, updateRect)
|
|
||||||
onUpdated(updateRect)
|
|
||||||
watch(exprRect, (rect) => rect && emit('updateExprRect', props.ast.astId, rect))
|
|
||||||
|
|
||||||
// Return whether this node should interact with the mouse, e.g. when seeking an edge target.
|
|
||||||
function isHoverable(): boolean | 'tokensOnly' {
|
|
||||||
switch (props.ast.treeTypeName()) {
|
|
||||||
case 'Invalid':
|
|
||||||
case 'BodyBlock':
|
|
||||||
case 'Ident':
|
|
||||||
case 'Number':
|
|
||||||
case 'Wildcard':
|
|
||||||
case 'TextLiteral':
|
|
||||||
return true
|
|
||||||
case 'DefaultApp':
|
|
||||||
return 'tokensOnly'
|
|
||||||
// Application should not be hoverable; typically their child nodes will be.
|
|
||||||
case 'ArgumentBlockApplication':
|
|
||||||
case 'OperatorBlockApplication':
|
|
||||||
case 'OprApp':
|
|
||||||
case 'UnaryOprApp':
|
|
||||||
case 'MultiSegmentApp':
|
|
||||||
case 'App':
|
|
||||||
case 'NamedApp':
|
|
||||||
return false
|
|
||||||
// Other composite expressions.
|
|
||||||
case 'Group':
|
|
||||||
case 'TypeAnnotated':
|
|
||||||
case 'CaseOf':
|
|
||||||
case 'Lambda':
|
|
||||||
case 'Array':
|
|
||||||
case 'Tuple':
|
|
||||||
case 'Documented':
|
|
||||||
case 'OprSectionBoundary':
|
|
||||||
case 'TemplateFunction':
|
|
||||||
return false
|
|
||||||
// Declarations; we won't generally display these within a node anyway.
|
|
||||||
case 'Private':
|
|
||||||
case 'TypeDef':
|
|
||||||
case 'Assignment':
|
|
||||||
case 'Function':
|
|
||||||
case 'ForeignFunction':
|
|
||||||
case 'Import':
|
|
||||||
case 'Export':
|
|
||||||
case 'TypeSignature':
|
|
||||||
case 'Annotated':
|
|
||||||
case 'AnnotatedBuiltin':
|
|
||||||
case 'ConstructorDefinition':
|
|
||||||
return false
|
|
||||||
// Misc.
|
|
||||||
case 'AutoScope':
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
console.log('Unexpected tree type', props.ast.treeTypeName())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function hover(part: 'tree' | 'token', isHovered: boolean) {
|
|
||||||
const hoverable = isHoverable()
|
|
||||||
if (hoverable == true || (hoverable == 'tokensOnly' && part == 'token'))
|
|
||||||
emit('updateHoveredExpr', isHovered ? props.ast.astId : undefined)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NodeToken
|
|
||||||
v-if="singularToken != null"
|
|
||||||
:ast="singularToken"
|
|
||||||
:nodeSpanStart="props.nodeSpanStart"
|
|
||||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
|
||||||
@pointerenter="hover('token', true)"
|
|
||||||
@pointerleave="hover('token', false)"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
ref="rootNode"
|
|
||||||
:class="['Tree', spanClass]"
|
|
||||||
:data-span-start="props.ast.span()[0] - nodeSpanStart - whitespace.length"
|
|
||||||
>{{ whitespace
|
|
||||||
}}<template v-for="child in children" :key="child.astId">
|
|
||||||
<NodeTree
|
|
||||||
v-if="child.isTree()"
|
|
||||||
:ast="child"
|
|
||||||
:nodeSpanStart="props.nodeSpanStart"
|
|
||||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
|
||||||
@updateHoveredExpr="emit('updateHoveredExpr', $event)"
|
|
||||||
@pointerenter="hover('tree', true)"
|
|
||||||
@pointerleave="hover('tree', false)"
|
|
||||||
/>
|
|
||||||
<NodeToken
|
|
||||||
v-else-if="child.isToken()"
|
|
||||||
:ast="child"
|
|
||||||
:nodeSpanStart="props.nodeSpanStart"
|
|
||||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
|
||||||
@updateHoveredExpr="emit('updateHoveredExpr', $event)"
|
|
||||||
@pointerenter="hover('token', true)"
|
|
||||||
@pointerleave="hover('token', false)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.Tree {
|
|
||||||
color: white;
|
|
||||||
white-space: pre;
|
|
||||||
align-items: center;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
|
|
||||||
&.Root {
|
|
||||||
color: rgb(255 255 255 / 0.33);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Ident,
|
|
||||||
&.Literal {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Literal {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.port {
|
|
||||||
background-color: var(--node-color-port);
|
|
||||||
border-radius: var(--node-border-radius);
|
|
||||||
margin: -2px -4px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
64
app/gui2/src/components/GraphEditor/NodeWidget.vue
Normal file
64
app/gui2/src/components/GraphEditor/NodeWidget.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
injectWidgetRegistry,
|
||||||
|
widgetAst,
|
||||||
|
type WidgetConfiguration,
|
||||||
|
type WidgetInput,
|
||||||
|
} from '@/providers/widgetRegistry'
|
||||||
|
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||||
|
import { injectWidgetUsageInfo, provideWidgetUsageInfo } from '@/providers/widgetUsageInfo'
|
||||||
|
import { computed, proxyRefs, ref, toRef } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ input: WidgetInput; nest?: boolean }>()
|
||||||
|
|
||||||
|
const registry = injectWidgetRegistry()
|
||||||
|
const tree = injectWidgetTree()
|
||||||
|
const parentUsageInfo = injectWidgetUsageInfo(true)
|
||||||
|
const whitespace = computed(() =>
|
||||||
|
parentUsageInfo?.input !== props.input
|
||||||
|
? ' '.repeat(widgetAst(props.input)?.whitespaceLength() ?? 0)
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Fetch dynamic widget config from engine. [#8260]
|
||||||
|
const dynamicConfig = ref<WidgetConfiguration>()
|
||||||
|
const sameInputParentWidgets = computed(() =>
|
||||||
|
parentUsageInfo?.input === props.input ? parentUsageInfo?.previouslyUsed : undefined,
|
||||||
|
)
|
||||||
|
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
|
||||||
|
|
||||||
|
const selectedWidget = computed(() => {
|
||||||
|
return registry.select(
|
||||||
|
{ input: props.input, config: dynamicConfig.value, nesting: nesting.value },
|
||||||
|
sameInputParentWidgets.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
provideWidgetUsageInfo(
|
||||||
|
proxyRefs({
|
||||||
|
input: toRef(props, 'input'),
|
||||||
|
previouslyUsed: computed(() => {
|
||||||
|
const nextSameNodeWidgets = new Set(sameInputParentWidgets.value)
|
||||||
|
if (selectedWidget.value != null) nextSameNodeWidgets.add(selectedWidget.value)
|
||||||
|
return nextSameNodeWidgets
|
||||||
|
}),
|
||||||
|
nesting,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const spanStart = computed(() => {
|
||||||
|
const ast = widgetAst(props.input)
|
||||||
|
return ast && ast.span()[0] - tree.nodeSpanStart - whitespace.value.length
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{ whitespace
|
||||||
|
}}<component
|
||||||
|
:is="selectedWidget"
|
||||||
|
ref="rootNode"
|
||||||
|
:input="props.input"
|
||||||
|
:config="dynamicConfig"
|
||||||
|
:nesting="nesting"
|
||||||
|
:data-span-start="spanStart"
|
||||||
|
:data-nesting="nesting"
|
||||||
|
/>
|
||||||
|
</template>
|
49
app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Normal file
49
app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { provideWidgetTree } from '@/providers/widgetTree'
|
||||||
|
import { useTransitioning } from '@/util/animation'
|
||||||
|
import type { AstExtended } from '@/util/ast'
|
||||||
|
import { toRef } from 'vue'
|
||||||
|
import NodeWidget from './NodeWidget.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ ast: AstExtended }>()
|
||||||
|
|
||||||
|
const observedLayoutTransitions = new Set([
|
||||||
|
'margin-left',
|
||||||
|
'margin-right',
|
||||||
|
'margin-top',
|
||||||
|
'margin-bottom',
|
||||||
|
'padding-left',
|
||||||
|
'padding-right',
|
||||||
|
'padding-top',
|
||||||
|
'padding-bottom',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
])
|
||||||
|
|
||||||
|
const layoutTransitions = useTransitioning(observedLayoutTransitions)
|
||||||
|
provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
|
||||||
|
<NodeWidget :input="ast" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.NodeWidgetTree {
|
||||||
|
color: white;
|
||||||
|
margin-left: 4px;
|
||||||
|
&:has(.WidgetPort.newToConnect) {
|
||||||
|
margin-left: calc(4px - var(--widget-port-extra-pad));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.WidgetPort.newToConnect > .r-24:only-child) {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.GraphEditor.draggingEdge .NodeWidgetTree {
|
||||||
|
transition: margin 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
@ -105,7 +105,7 @@ export function useDragging() {
|
|||||||
function* draggedNodes(): Generator<[ExprId, DraggedNode]> {
|
function* draggedNodes(): Generator<[ExprId, DraggedNode]> {
|
||||||
const ids = selection?.isSelected(movedId) ? selection.selected : [movedId]
|
const ids = selection?.isSelected(movedId) ? selection.selected : [movedId]
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const node = graphStore.nodes.get(id)
|
const node = graphStore.db.nodes.get(id)
|
||||||
if (node != null) yield [id, { initialPos: node.position, currentPos: node.position }]
|
if (node != null) yield [id, { initialPos: node.position, currentPos: node.position }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,7 +125,7 @@ export function useDragging() {
|
|||||||
const rects: Rect[] = []
|
const rects: Rect[] = []
|
||||||
for (const [id, { initialPos }] of this.draggedNodes) {
|
for (const [id, { initialPos }] of this.draggedNodes) {
|
||||||
const rect = graphStore.nodeRects.get(id)
|
const rect = graphStore.nodeRects.get(id)
|
||||||
const node = graphStore.nodes.get(id)
|
const node = graphStore.db.nodes.get(id)
|
||||||
if (rect != null && node != null) rects.push(new Rect(initialPos.add(newOffset), rect.size))
|
if (rect != null && node != null) rects.push(new Rect(initialPos.add(newOffset), rect.size))
|
||||||
}
|
}
|
||||||
const snap = this.grid.snappedMany(rects, DRAG_SNAP_THRESHOLD)
|
const snap = this.grid.snappedMany(rects, DRAG_SNAP_THRESHOLD)
|
||||||
@ -161,7 +161,7 @@ export function useDragging() {
|
|||||||
|
|
||||||
updateNodesPosition() {
|
updateNodesPosition() {
|
||||||
for (const [id, dragged] of this.draggedNodes) {
|
for (const [id, dragged] of this.draggedNodes) {
|
||||||
const node = graphStore.nodes.get(id)
|
const node = graphStore.db.nodes.get(id)
|
||||||
if (node == null) continue
|
if (node == null) continue
|
||||||
// If node was moved in other way than current dragging, we want to stop dragging it.
|
// If node was moved in other way than current dragging, we want to stop dragging it.
|
||||||
if (node.position.distanceSquared(dragged.currentPos) > 1.0) {
|
if (node.position.distanceSquared(dragged.currentPos) > 1.0) {
|
||||||
|
37
app/gui2/src/components/GraphEditor/widgets/WidgetBlank.vue
Normal file
37
app/gui2/src/components/GraphEditor/widgets/WidgetBlank.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { Ast } from '@/util/ast'
|
||||||
|
const _props = defineProps<WidgetProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 10,
|
||||||
|
match: (props) =>
|
||||||
|
widgetAst(props.input)?.isToken(Ast.Token.Type.Wildcard) ? Score.Good : Score.Mismatch,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="rootNode" class="WidgetBlank">_</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.WidgetBlank {
|
||||||
|
color: transparent;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--node-color-port);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue'
|
||||||
|
import { Tree } from '@/generated/ast'
|
||||||
|
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { Ast, type AstExtended } from '@/util/ast'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<WidgetProps>()
|
||||||
|
const graph = useGraphStore()
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return widgetAst(props.input)?.repr().endsWith('True') ?? false
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
const ast = widgetAst(props.input)
|
||||||
|
const node = ast && getRawBoolNode(ast)
|
||||||
|
if (node != null) {
|
||||||
|
graph.setExpressionContent(node.astId, value ? 'True' : 'False')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<script lang="ts">
|
||||||
|
function getRawBoolNode(ast: AstExtended) {
|
||||||
|
const candidate =
|
||||||
|
ast.isTree(Tree.Type.OprApp) && ast.repr().startsWith('Boolean.')
|
||||||
|
? ast.tryMap((t) => t.rhs)
|
||||||
|
: ast
|
||||||
|
if (
|
||||||
|
candidate &&
|
||||||
|
candidate.isTree(Ast.Tree.Type.Ident) &&
|
||||||
|
['True', 'False'].includes(candidate.repr())
|
||||||
|
) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 10,
|
||||||
|
match: (info) => {
|
||||||
|
const ast = widgetAst(info.input)
|
||||||
|
if (ast && getRawBoolNode(ast) != null) {
|
||||||
|
return Score.Perfect
|
||||||
|
}
|
||||||
|
return Score.Mismatch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<CheckboxWidget
|
||||||
|
v-model="value"
|
||||||
|
class="WidgetCheckbox"
|
||||||
|
contenteditable="false"
|
||||||
|
@beforeinput.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||||
|
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { Ast, AstExtended } from '@/util/ast'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<WidgetProps>()
|
||||||
|
|
||||||
|
const spanClass = computed(() => widgetAst(props.input)?.treeTypeName())
|
||||||
|
const children = computed(() => [...(widgetAst(props.input)?.children() ?? [])])
|
||||||
|
|
||||||
|
function shouldNest(child: AstExtended, index: number) {
|
||||||
|
return widgetAst(props.input)!.isTree(Ast.Tree.Type.App) && !child.isTree(Ast.Tree.Type.App)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 1000,
|
||||||
|
match: (info) => (widgetAst(info.input)?.isTree() ? Score.Good : Score.Mismatch),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="['Tree', spanClass]"
|
||||||
|
><NodeWidget
|
||||||
|
v-for="(child, index) in children"
|
||||||
|
:key="child.astId"
|
||||||
|
:input="child"
|
||||||
|
:nest="shouldNest(child, index)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.Tree {
|
||||||
|
white-space: pre;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
min-height: 24px;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&.Literal {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.port {
|
||||||
|
background-color: var(--node-color-port);
|
||||||
|
border-radius: var(--node-border-radius);
|
||||||
|
margin: -2px -4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
49
app/gui2/src/components/GraphEditor/widgets/WidgetNumber.vue
Normal file
49
app/gui2/src/components/GraphEditor/widgets/WidgetNumber.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SliderWidget from '@/components/widgets/SliderWidget.vue'
|
||||||
|
import { Tree } from '@/generated/ast'
|
||||||
|
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<WidgetProps>()
|
||||||
|
const graph = useGraphStore()
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return parseFloat(widgetAst(props.input)?.repr() ?? '')
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
const id = widgetAst(props.input)?.astId
|
||||||
|
if (id) graph.setExpressionContent(id, value.toString())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<script lang="ts">
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 10,
|
||||||
|
match: (info) => {
|
||||||
|
const ast = widgetAst(info.input)
|
||||||
|
if (!ast) return Score.Mismatch
|
||||||
|
if (ast.isTree(Tree.Type.UnaryOprApp)) {
|
||||||
|
if (
|
||||||
|
ast.map((t) => t.opr).repr() === '-' &&
|
||||||
|
ast.tryMap((t) => t.rhs)?.isTree(Tree.Type.Number)
|
||||||
|
) {
|
||||||
|
return Score.Perfect
|
||||||
|
}
|
||||||
|
} else if (ast.isTree(Tree.Type.Number)) {
|
||||||
|
return Score.Perfect
|
||||||
|
}
|
||||||
|
return Score.Mismatch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<SliderWidget v-model="value" class="WidgetNumber r-24" :min="-1000" :max="1000" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.WidgetNumber {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
201
app/gui2/src/components/GraphEditor/widgets/WidgetPort.vue
Normal file
201
app/gui2/src/components/GraphEditor/widgets/WidgetPort.vue
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||||
|
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||||
|
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||||
|
import {
|
||||||
|
Score,
|
||||||
|
defineWidget,
|
||||||
|
widgetAst,
|
||||||
|
type WidgetInput,
|
||||||
|
type WidgetProps,
|
||||||
|
} from '@/providers/widgetRegistry'
|
||||||
|
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||||
|
import { useRaf } from '@/util/animation'
|
||||||
|
import { Ast } from '@/util/ast'
|
||||||
|
import { useResizeObserver } from '@/util/events'
|
||||||
|
import { Rect } from '@/util/rect'
|
||||||
|
import { uuidv4 } from 'lib0/random'
|
||||||
|
import type { ExprId } from 'shared/yjsModel'
|
||||||
|
import { computed, nextTick, onUpdated, ref, shallowRef, toRef, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
const graph = useGraphStore()
|
||||||
|
const props = defineProps<WidgetProps>()
|
||||||
|
const navigator = injectGraphNavigator()
|
||||||
|
const tree = injectWidgetTree()
|
||||||
|
const selection = injectGraphSelection(true)
|
||||||
|
|
||||||
|
const isHovered = ref(false)
|
||||||
|
const hasConnection = computed(() => isConnected(props.input, graph.db))
|
||||||
|
const isCurrentEdgeHoverTarget = computed(
|
||||||
|
() => isHovered.value && graph.unconnectedEdge != null && selection?.hoveredPort === portId.value,
|
||||||
|
)
|
||||||
|
const connected = computed(() => hasConnection.value || isCurrentEdgeHoverTarget.value)
|
||||||
|
|
||||||
|
const rootNode = shallowRef<HTMLElement>()
|
||||||
|
const nodeSize = useResizeObserver(rootNode, false)
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
if (selection != null && isHovered.value === true) {
|
||||||
|
const id = portId.value
|
||||||
|
selection.addHoveredPort(id)
|
||||||
|
onCleanup(() => selection.removeHoveredPort(id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
|
||||||
|
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
|
||||||
|
// do it when the node has any potential for being used as an edge source or target. This is true
|
||||||
|
// when any of following conditions are met:
|
||||||
|
// 1. The expression can be connected to and is currently being hovered.
|
||||||
|
// 2. The expression is already used as an existing edge endpoint.
|
||||||
|
//
|
||||||
|
// TODO: This should part of the `WidgetPort` component. But first, we need to make sure that the
|
||||||
|
// ports are always created when necessary.
|
||||||
|
const portRect = shallowRef<Rect>()
|
||||||
|
const rectUpdateIsUseful = computed(() => isHovered.value || hasConnection.value)
|
||||||
|
|
||||||
|
const randomUuid = uuidv4() as ExprId
|
||||||
|
const portId = computed(() => widgetAst(props.input)?.astId ?? randomUuid)
|
||||||
|
|
||||||
|
watch(nodeSize, updateRect)
|
||||||
|
onUpdated(() => {
|
||||||
|
nextTick(updateRect)
|
||||||
|
})
|
||||||
|
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [portId.value, portRect.value, rectUpdateIsUseful.value] as const,
|
||||||
|
([id, rect, updateUseful], _, onCleanup) => {
|
||||||
|
if (id != null && rect != null && updateUseful) {
|
||||||
|
graph.updateExprRect(id, rect)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (portId.value === id && rect === portRect.value) graph.updateExprRect(id, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateRect() {
|
||||||
|
let domNode = rootNode.value
|
||||||
|
const rootDomNode = domNode?.closest('.node')
|
||||||
|
if (domNode == null || rootDomNode == null) return
|
||||||
|
|
||||||
|
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
||||||
|
const nodeClientRect = Rect.FromDomRect(rootDomNode.getBoundingClientRect())
|
||||||
|
const exprSceneRect = navigator.clientToSceneRect(exprClientRect)
|
||||||
|
const exprNodeRect = navigator.clientToSceneRect(nodeClientRect)
|
||||||
|
const localRect = exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
|
||||||
|
if (portRect.value != null && localRect.equals(portRect.value)) return
|
||||||
|
portRect.value = localRect
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
function canBeConnectedTo(input: WidgetInput): boolean {
|
||||||
|
const ast = widgetAst(input)
|
||||||
|
if (ast == null) return true // placeholders are always connectable
|
||||||
|
if (ast.isToken()) return false
|
||||||
|
switch (ast.inner.type) {
|
||||||
|
case Ast.Tree.Type.Invalid:
|
||||||
|
case Ast.Tree.Type.BodyBlock:
|
||||||
|
case Ast.Tree.Type.Ident:
|
||||||
|
case Ast.Tree.Type.Group:
|
||||||
|
case Ast.Tree.Type.Number:
|
||||||
|
case Ast.Tree.Type.OprApp:
|
||||||
|
case Ast.Tree.Type.UnaryOprApp:
|
||||||
|
case Ast.Tree.Type.Wildcard:
|
||||||
|
case Ast.Tree.Type.TextLiteral:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConnected(input: WidgetInput, db: GraphDb) {
|
||||||
|
const astId = widgetAst(input)?.astId
|
||||||
|
return astId != null && db.connections.reverseLookup(astId).size > 0
|
||||||
|
}
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 1,
|
||||||
|
match: (info) => {
|
||||||
|
if (canBeConnectedTo(info.input)) {
|
||||||
|
return Score.Perfect
|
||||||
|
}
|
||||||
|
return Score.Mismatch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
ref="rootNode"
|
||||||
|
class="WidgetPort"
|
||||||
|
:class="{
|
||||||
|
connected,
|
||||||
|
'r-24': connected,
|
||||||
|
newToConnect: !hasConnection && isCurrentEdgeHoverTarget,
|
||||||
|
primary: props.nesting < 2,
|
||||||
|
}"
|
||||||
|
@pointerenter="isHovered = true"
|
||||||
|
@pointerleave="isHovered = false"
|
||||||
|
><NodeWidget :input="props.input"
|
||||||
|
/></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(:root) {
|
||||||
|
--widget-port-extra-pad: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.WidgetPort {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 var(--widget-port-extra-pad);
|
||||||
|
margin: 0 calc(0px - var(--widget-port-extra-pad));
|
||||||
|
transition:
|
||||||
|
margin 0.2s ease,
|
||||||
|
padding 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.WidgetPort:has(> .r-24:only-child) {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.WidgetPort.connected {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--node-color-port);
|
||||||
|
}
|
||||||
|
|
||||||
|
.GraphEditor.draggingEdge .WidgetPort {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
pointer-events: all;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
inset: 4px var(--widget-port-extra-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand hover area for primary ports. */
|
||||||
|
&.primary::before {
|
||||||
|
top: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.connected::before {
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
42
app/gui2/src/components/GraphEditor/widgets/WidgetToken.vue
Normal file
42
app/gui2/src/components/GraphEditor/widgets/WidgetToken.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<WidgetProps>()
|
||||||
|
|
||||||
|
const rootNode = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const spanClass = computed(() => widgetAst(props.input)?.tokenTypeName())
|
||||||
|
const repr = computed(() => widgetAst(props.input)?.repr())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const widgetDefinition = defineWidget({
|
||||||
|
priority: 1000,
|
||||||
|
match: (info) => (widgetAst(info.input)?.isToken() ? Score.Good : Score.Mismatch),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="rootNode" :class="['Token', spanClass]">{{ repr }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.Token {
|
||||||
|
color: white;
|
||||||
|
white-space: pre;
|
||||||
|
align-items: center;
|
||||||
|
color: rgb(255 255 255 / 0.33);
|
||||||
|
|
||||||
|
&.Ident,
|
||||||
|
&.TextSection,
|
||||||
|
&.Digits {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.TextSection,
|
||||||
|
&.Digits {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
20
app/gui2/src/components/GraphMouse.vue
Normal file
20
app/gui2/src/components/GraphMouse.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||||
|
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import SelectionBrush from './SelectionBrush.vue'
|
||||||
|
|
||||||
|
const navigator = injectGraphNavigator(true)
|
||||||
|
const nodeSelection = injectGraphSelection(true)
|
||||||
|
const scaledMousePos = computed(() => navigator?.sceneMousePos?.scale(navigator?.scale ?? 1))
|
||||||
|
const scaledSelectionAnchor = computed(() => nodeSelection?.anchor?.scale(navigator?.scale ?? 1))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectionBrush
|
||||||
|
v-if="scaledMousePos"
|
||||||
|
:position="scaledMousePos"
|
||||||
|
:anchor="scaledSelectionAnchor"
|
||||||
|
:style="{ transform: navigator?.prescaledTransform }"
|
||||||
|
/>
|
||||||
|
</template>
|
32
app/gui2/src/components/LoadingSpinner.vue
Normal file
32
app/gui2/src/components/LoadingSpinner.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
size?: number
|
||||||
|
}>()
|
||||||
|
const dynStyle = computed(() => {
|
||||||
|
const size = props.size ?? 30
|
||||||
|
return {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="LoadingSpinner" :style="dynStyle"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.LoadingSpinner {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid;
|
||||||
|
border-color: rgba(0, 0, 0, 30%) #0000;
|
||||||
|
animation: s1 0.8s infinite;
|
||||||
|
}
|
||||||
|
@keyframes s1 {
|
||||||
|
to {
|
||||||
|
transform: rotate(0.5turn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,17 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
||||||
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
|
|
||||||
export const name = 'Loading'
|
export const name = 'Loading'
|
||||||
export const inputType = 'Any'
|
export const inputType = 'Any'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
|
||||||
|
|
||||||
const _props = defineProps<{ data: unknown }>()
|
const _props = defineProps<{ data: unknown }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer>
|
<VisualizationContainer>
|
||||||
<div class="LoadingVisualization"></div>
|
<div class="LoadingVisualization">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
</VisualizationContainer>
|
</VisualizationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -24,20 +27,4 @@ const _props = defineProps<{ data: unknown }>()
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LoadingVisualization::before {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4px solid;
|
|
||||||
border-color: rgba(0, 0, 0, 30%) #0000;
|
|
||||||
animation: s1 0.8s infinite;
|
|
||||||
}
|
|
||||||
@keyframes s1 {
|
|
||||||
to {
|
|
||||||
transform: rotate(0.5turn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -4,7 +4,11 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: boolean] }>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="Checkbox" @click="emit('update:modelValue', !props.modelValue)">
|
<div
|
||||||
|
class="Checkbox r-24"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click="emit('update:modelValue', !props.modelValue)"
|
||||||
|
>
|
||||||
<div :class="{ hidden: !props.modelValue }"></div>
|
<div :class="{ hidden: !props.modelValue }"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PointerButtonMask, usePointer } from '@/util/events'
|
import { PointerButtonMask, usePointer, useResizeObserver } from '@/util/events'
|
||||||
|
import { getTextWidth } from '@/util/measurement'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: number; min: number; max: number }>()
|
const props = defineProps<{ modelValue: number; min: number; max: number }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [modelValue: number] }>()
|
const emit = defineEmits<{ 'update:modelValue': [modelValue: number] }>()
|
||||||
|
|
||||||
const sliderNode = ref<HTMLElement>()
|
const dragPointer = usePointer((position, event, eventType) => {
|
||||||
|
const slider = event.target
|
||||||
const dragPointer = usePointer((position) => {
|
if (!(slider instanceof HTMLElement)) {
|
||||||
if (sliderNode.value == null) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rect = sliderNode.value.getBoundingClientRect()
|
|
||||||
|
if (eventType === 'start') {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = slider.getBoundingClientRect()
|
||||||
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
|
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
|
||||||
const fraction = Math.max(0, Math.min(1, fractionRaw))
|
const fraction = Math.max(0, Math.min(1, fractionRaw))
|
||||||
const newValue = props.min + Math.round(fraction * (props.max - props.min))
|
const newValue = props.min + Math.round(fraction * (props.max - props.min))
|
||||||
@ -27,24 +32,75 @@ const inputValue = computed({
|
|||||||
return props.modelValue
|
return props.modelValue
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
emit('update:modelValue', value)
|
if (typeof value === 'string') {
|
||||||
|
value = parseFloat(toNumericOnly(value))
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && !isNaN(value)) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toNumericOnly(value: string) {
|
||||||
|
return value.replace(/,/g, '.').replace(/[^0-9.]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputNode = ref<HTMLInputElement>()
|
||||||
|
const inputSize = useResizeObserver(inputNode)
|
||||||
|
const inputMeasurements = computed(() => {
|
||||||
|
if (inputNode.value == null) return { availableWidth: 0, font: '' }
|
||||||
|
let style = window.getComputedStyle(inputNode.value)
|
||||||
|
let availableWidth =
|
||||||
|
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
|
||||||
|
return { availableWidth, font: style.font }
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputStyle = computed(() => {
|
||||||
|
if (inputNode.value == null) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const value = `${props.modelValue}`
|
||||||
|
const dotIdx = value.indexOf('.')
|
||||||
|
let indent = 0
|
||||||
|
if (dotIdx >= 0) {
|
||||||
|
const textBefore = value.slice(0, dotIdx)
|
||||||
|
const textAfter = value.slice(dotIdx + 1)
|
||||||
|
|
||||||
|
const measurements = inputMeasurements.value
|
||||||
|
const total = getTextWidth(value, measurements.font)
|
||||||
|
const beforeDot = getTextWidth(textBefore, measurements.font)
|
||||||
|
const afterDot = getTextWidth(textAfter, measurements.font)
|
||||||
|
const blankSpace = Math.max(measurements.availableWidth - total, 0)
|
||||||
|
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
textIndent: `${indent}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function fixupInputValue() {
|
||||||
|
if (inputNode.value != null) inputNode.value.value = `${inputValue.value}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="sliderNode" class="Slider" v-on="dragPointer.events">
|
<div class="SliderWidget" v-on="dragPointer.events">
|
||||||
<div class="fraction" :style="{ width: sliderWidth }"></div>
|
<div class="fraction" :style="{ width: sliderWidth }"></div>
|
||||||
<input v-model.number="inputValue" type="number" :size="1" class="value" />
|
<input
|
||||||
|
ref="inputNode"
|
||||||
|
v-model="inputValue"
|
||||||
|
class="value"
|
||||||
|
:style="inputStyle"
|
||||||
|
@blur="fixupInputValue"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.Slider {
|
.SliderWidget {
|
||||||
clip-path: inset(0 round var(--radius-full));
|
clip-path: inset(0 round var(--radius-full));
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
background: var(--color-widget);
|
background: var(--color-widget);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
@ -68,10 +124,19 @@ const inputValue = computed({
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 171.5%;
|
line-height: 171.5%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding-top: 1px;
|
padding: 0px 4px;
|
||||||
padding-bottom: 1px;
|
|
||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background-color: rgba(255, 255, 255, 15%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input::-webkit-outer-spin-button,
|
input::-webkit-outer-spin-button,
|
||||||
|
99
app/gui2/src/providers/__tests__/widgetRegistry.test.ts
Normal file
99
app/gui2/src/providers/__tests__/widgetRegistry.test.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { GraphDb } from '@/stores/graph/graphDatabase'
|
||||||
|
import { AstExtended } from '@/util/ast'
|
||||||
|
import { IdMap, type ExprId } from 'shared/yjsModel'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import {
|
||||||
|
PlaceholderArgument,
|
||||||
|
Score,
|
||||||
|
WidgetRegistry,
|
||||||
|
widgetArg,
|
||||||
|
widgetAst,
|
||||||
|
type WidgetDefinition,
|
||||||
|
type WidgetModule,
|
||||||
|
} from '../widgetRegistry'
|
||||||
|
|
||||||
|
describe('WidgetRegistry', () => {
|
||||||
|
function makeMockWidget(name: string, widgetDefinition: WidgetDefinition): WidgetModule {
|
||||||
|
return {
|
||||||
|
default: defineComponent({ name }),
|
||||||
|
widgetDefinition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetA = makeMockWidget('A', {
|
||||||
|
priority: 1,
|
||||||
|
match: (info) => (widgetAst(info.input) ? Score.Perfect : Score.Mismatch),
|
||||||
|
})
|
||||||
|
|
||||||
|
const widgetB = makeMockWidget('B', {
|
||||||
|
priority: 2,
|
||||||
|
match: (info) => (widgetArg(info.input) ? Score.Perfect : Score.Mismatch),
|
||||||
|
})
|
||||||
|
|
||||||
|
const widgetC = makeMockWidget('C', {
|
||||||
|
priority: 10,
|
||||||
|
match: () => Score.Good,
|
||||||
|
})
|
||||||
|
|
||||||
|
const widgetD = makeMockWidget('D', {
|
||||||
|
priority: 20,
|
||||||
|
match: (info) => (widgetAst(info.input)?.repr() === '_' ? Score.Perfect : Score.Mismatch),
|
||||||
|
})
|
||||||
|
|
||||||
|
const someAst = AstExtended.parse('foo', IdMap.Mock())
|
||||||
|
const blankAst = AstExtended.parse('_', IdMap.Mock())
|
||||||
|
const somePlaceholder = new PlaceholderArgument(
|
||||||
|
'2095503f-c6b3-46e3-848a-be31360aab08' as ExprId,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockGraphDb = GraphDb.Mock()
|
||||||
|
const registry = new WidgetRegistry(mockGraphDb)
|
||||||
|
registry.registerWidgetModule(widgetA)
|
||||||
|
registry.registerWidgetModule(widgetB)
|
||||||
|
registry.registerWidgetModule(widgetC)
|
||||||
|
registry.registerWidgetModule(widgetD)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selects a widget outside of the excluded set', () => {
|
||||||
|
const forAst = registry.select(
|
||||||
|
{ input: someAst, config: undefined, nesting: 0 },
|
||||||
|
new Set([widgetA.default]),
|
||||||
|
)
|
||||||
|
const forArg = registry.select(
|
||||||
|
{ input: somePlaceholder, config: undefined, nesting: 0 },
|
||||||
|
new Set([widgetB.default]),
|
||||||
|
)
|
||||||
|
expect(forAst).toStrictEqual(widgetC.default)
|
||||||
|
expect(forArg).toStrictEqual(widgetC.default)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns undefined when all options are exhausted', () => {
|
||||||
|
const selected = registry.select(
|
||||||
|
{ input: someAst, config: undefined, nesting: 0 },
|
||||||
|
new Set([widgetA.default, widgetC.default]),
|
||||||
|
)
|
||||||
|
expect(selected).to.be.undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefers low priority perfect over good high priority', () => {
|
||||||
|
const selectedFirst = registry.select(
|
||||||
|
{ input: blankAst, config: undefined, nesting: 0 },
|
||||||
|
new Set([widgetA.default]),
|
||||||
|
)
|
||||||
|
const selectedNext = registry.select(
|
||||||
|
{ input: blankAst, config: undefined, nesting: 0 },
|
||||||
|
new Set([widgetA.default, widgetD.default]),
|
||||||
|
)
|
||||||
|
expect(selectedFirst).toStrictEqual(widgetD.default)
|
||||||
|
expect(selectedNext).toStrictEqual(widgetC.default)
|
||||||
|
})
|
||||||
|
})
|
52
app/gui2/src/providers/interactionHandler.ts
Normal file
52
app/gui2/src/providers/interactionHandler.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { watch, type WatchSource } from 'vue'
|
||||||
|
import { createContextStore } from '.'
|
||||||
|
|
||||||
|
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
||||||
|
const { provideFn, injectFn } = createContextStore(
|
||||||
|
'Interaction handler',
|
||||||
|
() => new InteractionHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
export class InteractionHandler {
|
||||||
|
private currentInteraction: Interaction | undefined = undefined
|
||||||
|
|
||||||
|
/** Automatically activate specified interaction any time a specified condition becomes true. */
|
||||||
|
setWhen(active: WatchSource<boolean>, interaction: Interaction) {
|
||||||
|
watch(active, (active) => {
|
||||||
|
if (active) {
|
||||||
|
this.setCurrent(interaction)
|
||||||
|
} else {
|
||||||
|
this.end(interaction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent(interaction: Interaction | undefined) {
|
||||||
|
if (interaction !== this.currentInteraction) {
|
||||||
|
this.currentInteraction?.cancel?.()
|
||||||
|
this.currentInteraction = interaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unset the current interaction, if it is the specified instance. */
|
||||||
|
end(interaction: Interaction) {
|
||||||
|
if (this.currentInteraction === interaction) {
|
||||||
|
this.currentInteraction = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel(): boolean {
|
||||||
|
const hasCurrent = this.currentInteraction != null
|
||||||
|
if (hasCurrent) this.setCurrent(undefined)
|
||||||
|
return hasCurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(event: MouseEvent): boolean | void {
|
||||||
|
return this.currentInteraction?.click ? this.currentInteraction.click(event) : false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interaction {
|
||||||
|
cancel?(): void
|
||||||
|
click?(event: MouseEvent): boolean | void
|
||||||
|
}
|
197
app/gui2/src/providers/widgetRegistry.ts
Normal file
197
app/gui2/src/providers/widgetRegistry.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||||
|
import { AstExtended } from '@/util/ast'
|
||||||
|
import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
|
||||||
|
import type { ExprId } from 'shared/yjsModel'
|
||||||
|
import { computed, shallowReactive, type Component } from 'vue'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createContextStore } from '.'
|
||||||
|
|
||||||
|
export type WidgetComponent = Component<{ input: WidgetInput }>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about an argument that doesn't have an assigned value yet, therefore are not
|
||||||
|
* represented in the AST.
|
||||||
|
*
|
||||||
|
* TODO: Generate placeholders from suggestions and add support for them in various widgets. [#8257]
|
||||||
|
*/
|
||||||
|
export class PlaceholderArgument {
|
||||||
|
/** The call expression tow which the placeholder is attached. */
|
||||||
|
callExpression: ExprId
|
||||||
|
/** The suggestion ID pointing to a method with a list of expected arguments. */
|
||||||
|
methodId: SuggestionId
|
||||||
|
/** The index of relevant argument in the suggestion entry. */
|
||||||
|
index: number
|
||||||
|
|
||||||
|
constructor(callExpression: ExprId, methodId: SuggestionId, index: number) {
|
||||||
|
this.callExpression = callExpression
|
||||||
|
this.methodId = methodId
|
||||||
|
this.index = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type representing any kind of input that can have a widget attached to it. This can be either
|
||||||
|
* an AST node, or a placeholder argument.
|
||||||
|
*/
|
||||||
|
export type WidgetInput = AstExtended | PlaceholderArgument
|
||||||
|
|
||||||
|
export function widgetAst(input: WidgetInput): AstExtended | undefined {
|
||||||
|
return input instanceof AstExtended ? input : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function widgetArg(input: WidgetInput): PlaceholderArgument | undefined {
|
||||||
|
return input instanceof PlaceholderArgument ? input : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Description of how well a widget matches given input. Used to determine which widget should be
|
||||||
|
* used, or whether the applied widget override is valid in given context.
|
||||||
|
*/
|
||||||
|
export enum Score {
|
||||||
|
/**
|
||||||
|
* This widget kind cannot accept the node. It will never be used, even if it was explicitly
|
||||||
|
* requested using an override.
|
||||||
|
*/
|
||||||
|
Mismatch,
|
||||||
|
/**
|
||||||
|
* A bad, but syntactically valid match. Matching widget kind will only be used if it was
|
||||||
|
* explicitly requested using an override. Should be the default choice for cases where
|
||||||
|
* the node is syntactically valid in this widget's context, but no sensible defaults can
|
||||||
|
* be inferred from context.
|
||||||
|
*/
|
||||||
|
OnlyOverride,
|
||||||
|
/**
|
||||||
|
* A good match, but there might be a better one. one. This widget will be used if there is no
|
||||||
|
* better option.
|
||||||
|
*/
|
||||||
|
Good,
|
||||||
|
/** Widget matches perfectly and can be used outright, without checking other kinds. */
|
||||||
|
Perfect,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetProps {
|
||||||
|
input: WidgetInput
|
||||||
|
config: WidgetConfiguration | undefined
|
||||||
|
nesting: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDefinition {
|
||||||
|
/** The priority number determining the order in which the widgets are matched. Smaller numbers
|
||||||
|
* have higher priority, and are matched first.
|
||||||
|
*/
|
||||||
|
priority: number
|
||||||
|
/**
|
||||||
|
* Score how well this widget type matches current {@link WidgetProps}, e.g. checking if AST node
|
||||||
|
* or declaration type matches specific patterns. When this method returns
|
||||||
|
* {@link Score::Mismatch}, this widget component will not be used, even if its type was requested
|
||||||
|
* by an override. The override will be ignored and another best scoring widget will be used.
|
||||||
|
*/
|
||||||
|
match(info: WidgetProps, db: GraphDb): Score
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external configuration for a widget retreived from the language server.
|
||||||
|
*
|
||||||
|
* TODO: Actually implement reading dynamic widget configuration. [#8260]
|
||||||
|
* The expected configuration type is defined as Enso type `Widget` in the following file:
|
||||||
|
* distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso
|
||||||
|
*/
|
||||||
|
export type WidgetConfiguration = z.infer<typeof widgetConfigurationSchema>
|
||||||
|
export const widgetConfigurationSchema = z.object({})
|
||||||
|
|
||||||
|
export interface WidgetModule {
|
||||||
|
default: WidgetComponent
|
||||||
|
widgetDefinition: WidgetDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWidgetModule(module: unknown): module is WidgetModule {
|
||||||
|
return (
|
||||||
|
typeof module === 'object' &&
|
||||||
|
module !== null &&
|
||||||
|
'default' in module &&
|
||||||
|
'widgetDefinition' in module &&
|
||||||
|
isWidgetComponent(module.default) &&
|
||||||
|
isWidgetDefinition(module.widgetDefinition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWidgetComponent(component: unknown): component is WidgetComponent {
|
||||||
|
return typeof component === 'object' && component !== null && 'render' in component
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWidgetDefinition(config: unknown): config is WidgetDefinition {
|
||||||
|
return typeof config === 'object' && config !== null && 'priority' in config && 'match' in config
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineWidget(definition: WidgetDefinition): WidgetDefinition {
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
export { injectFn as injectWidgetRegistry, provideFn as provideWidgetRegistry }
|
||||||
|
const { provideFn, injectFn } = createContextStore(
|
||||||
|
'Widget registry',
|
||||||
|
(db: GraphDb) => new WidgetRegistry(db),
|
||||||
|
)
|
||||||
|
|
||||||
|
export class WidgetRegistry {
|
||||||
|
loadedModules: WidgetModule[] = shallowReactive([])
|
||||||
|
sortedModules = computed(() => {
|
||||||
|
return [...this.loadedModules].sort(
|
||||||
|
(a, b) => a.widgetDefinition.priority - b.widgetDefinition.priority,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
constructor(private db: GraphDb) {}
|
||||||
|
|
||||||
|
loadBuiltins() {
|
||||||
|
const bulitinWidgets = import.meta.glob('@/components/GraphEditor/widgets/*.vue')
|
||||||
|
for (const [path, asyncModule] of Object.entries(bulitinWidgets)) {
|
||||||
|
this.loadAndCheckWidgetModule(asyncModule(), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a known loaded widget module. Once registered, the widget will take part in widget
|
||||||
|
* selection process. Caution: registering a new widget module will trigger re-matching of all
|
||||||
|
* widgets on all nodes in the scene, which can be expensive.
|
||||||
|
*/
|
||||||
|
registerWidgetModule(module: WidgetModule) {
|
||||||
|
this.loadedModules.push(module)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a type of widget to use for given widget input (e.g. AST node). The selection is based
|
||||||
|
* on the widget's `match` function, which returns a score indicating how well the widget matches
|
||||||
|
* the input. The widget with the highest score and priority is selected. Widget kinds that are
|
||||||
|
* present in `alreadyUsed` set are always skipped.
|
||||||
|
*/
|
||||||
|
select(props: WidgetProps, alreadyUsed?: Set<WidgetComponent>): WidgetComponent | undefined {
|
||||||
|
// The type and score of the best widget found so far.
|
||||||
|
let best: WidgetComponent | undefined = undefined
|
||||||
|
let bestScore = Score.Mismatch
|
||||||
|
|
||||||
|
// Iterate over all loaded widget kinds in order of decreasing priority.
|
||||||
|
for (const module of this.sortedModules.value) {
|
||||||
|
// Skip matching widgets that are declared as already used.
|
||||||
|
if (alreadyUsed && alreadyUsed.has(module.default)) continue
|
||||||
|
|
||||||
|
// Perform a match and update the best widget if the match is better than the previous one.
|
||||||
|
const score = module.widgetDefinition.match(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 > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
best = module.default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Once we've checked all widgets, return the best match found, if any.
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add tests for select
|
13
app/gui2/src/providers/widgetTree.ts
Normal file
13
app/gui2/src/providers/widgetTree.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { AstExtended } from '@/util/ast'
|
||||||
|
import { computed, proxyRefs, type Ref } from 'vue'
|
||||||
|
import { createContextStore } from '.'
|
||||||
|
|
||||||
|
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
|
||||||
|
const { provideFn, injectFn } = createContextStore(
|
||||||
|
'Widget tree',
|
||||||
|
(astRoot: Ref<AstExtended>, hasActiveAnimations: Ref<boolean>) => {
|
||||||
|
const nodeId = computed(() => astRoot.value.astId)
|
||||||
|
const nodeSpanStart = computed(() => astRoot.value.span()[0])
|
||||||
|
return proxyRefs({ astRoot, nodeId, nodeSpanStart, hasActiveAnimations })
|
||||||
|
},
|
||||||
|
)
|
18
app/gui2/src/providers/widgetUsageInfo.ts
Normal file
18
app/gui2/src/providers/widgetUsageInfo.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { identity } from '@vueuse/core'
|
||||||
|
import { createContextStore } from '.'
|
||||||
|
import type { WidgetComponent, WidgetInput } from './widgetRegistry'
|
||||||
|
|
||||||
|
export { injectFn as injectWidgetUsageInfo, provideFn as provideWidgetUsageInfo }
|
||||||
|
const { provideFn, injectFn } = createContextStore('Widget usage info', identity<WidgetUsageInfo>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about a widget that can be accessed in its child views. Currently this is used during
|
||||||
|
* widget selection to prevent the same widget type from being rendered multiple times on the same
|
||||||
|
* AST node.
|
||||||
|
*/
|
||||||
|
interface WidgetUsageInfo {
|
||||||
|
input: WidgetInput
|
||||||
|
/** All widget types that were rendered so far using the same AST node. */
|
||||||
|
previouslyUsed: Set<WidgetComponent>
|
||||||
|
nesting: number
|
||||||
|
}
|
214
app/gui2/src/stores/graph/graphDatabase.ts
Normal file
214
app/gui2/src/stores/graph/graphDatabase.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDatabase'
|
||||||
|
import { tryGetIndex } from '@/util/array'
|
||||||
|
import { Ast, AstExtended } from '@/util/ast'
|
||||||
|
import { colorFromString } from '@/util/colors'
|
||||||
|
import { ComputedValueRegistry, type ExpressionInfo } from '@/util/computedValueRegistry'
|
||||||
|
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
|
||||||
|
import type { Opt } from '@/util/opt'
|
||||||
|
import { qnJoin, tryQualifiedName } from '@/util/qualifiedName'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import * as set from 'lib0/set'
|
||||||
|
import {
|
||||||
|
IdMap,
|
||||||
|
visMetadataEquals,
|
||||||
|
type ExprId,
|
||||||
|
type NodeMetadata,
|
||||||
|
type VisualizationMetadata,
|
||||||
|
} from 'shared/yjsModel'
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export class GraphDb {
|
||||||
|
nodes = new ReactiveDb<ExprId, Node>()
|
||||||
|
idents = new ReactiveIndex(this.nodes, (_id, entry) => {
|
||||||
|
const idents: [ExprId, string][] = []
|
||||||
|
entry.rootSpan.visitRecursive((span) => {
|
||||||
|
if (span.isTree(Ast.Tree.Type.Ident)) {
|
||||||
|
idents.push([span.astId, span.repr()])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return idents
|
||||||
|
})
|
||||||
|
private nodeExpressions = new ReactiveIndex(this.nodes, (id, entry) => {
|
||||||
|
const exprs = new Set<ExprId>()
|
||||||
|
for (const ast of entry.rootSpan.walkRecursive()) {
|
||||||
|
exprs.add(ast.astId)
|
||||||
|
}
|
||||||
|
return Array.from(exprs, (expr) => [id, expr])
|
||||||
|
})
|
||||||
|
nodeByBinding = new ReactiveIndex(this.nodes, (id, entry) => [[entry.binding, id]])
|
||||||
|
connections = new ReactiveIndex(this.nodes, (id, entry) => {
|
||||||
|
const usageEntries: [ExprId, ExprId][] = []
|
||||||
|
const usages = this.idents.reverseLookup(entry.binding)
|
||||||
|
for (const usage of usages) {
|
||||||
|
usageEntries.push([id, usage])
|
||||||
|
}
|
||||||
|
return usageEntries
|
||||||
|
})
|
||||||
|
nodeExpressionInfo = new ReactiveMapping(this.nodes, (id, _entry) =>
|
||||||
|
this.valuesRegistry.getExpressionInfo(id),
|
||||||
|
)
|
||||||
|
nodeMainSuggestion = new ReactiveMapping(this.nodes, (id, _entry) => {
|
||||||
|
const expressionInfo = this.nodeExpressionInfo.lookup(id)
|
||||||
|
const method = expressionInfo?.methodCall?.methodPointer
|
||||||
|
if (method == null) return
|
||||||
|
const moduleName = tryQualifiedName(method.definedOnType)
|
||||||
|
const methodName = tryQualifiedName(method.name)
|
||||||
|
if (!moduleName.ok || !methodName.ok) return
|
||||||
|
const qualifiedName = qnJoin(moduleName.value, methodName.value)
|
||||||
|
const [suggestionId] = this.suggestionDb.nameToId.lookup(qualifiedName)
|
||||||
|
if (suggestionId == null) return
|
||||||
|
return this.suggestionDb.get(suggestionId)
|
||||||
|
})
|
||||||
|
private nodeColors = new ReactiveMapping(this.nodes, (id, _entry) => {
|
||||||
|
const index = this.nodeMainSuggestion.lookup(id)?.groupIndex
|
||||||
|
const group = tryGetIndex(this.groups.value, index)
|
||||||
|
if (group == null) {
|
||||||
|
const typename = this.nodeExpressionInfo.lookup(id)?.typename
|
||||||
|
return typename ? colorFromString(typename) : 'var(--node-color-no-type)'
|
||||||
|
}
|
||||||
|
return groupColorStyle(group)
|
||||||
|
})
|
||||||
|
|
||||||
|
getNode(id: ExprId): Node | undefined {
|
||||||
|
return this.nodes.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
allNodes(): IterableIterator<[ExprId, Node]> {
|
||||||
|
return this.nodes.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
allNodeIds(): IterableIterator<ExprId> {
|
||||||
|
return this.nodes.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
getExpressionNodeId(exprId: ExprId | undefined): ExprId | undefined {
|
||||||
|
return exprId && set.first(this.nodeExpressions.reverseLookup(exprId))
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdentDefiningNode(ident: string): ExprId | undefined {
|
||||||
|
return set.first(this.nodeByBinding.lookup(ident))
|
||||||
|
}
|
||||||
|
|
||||||
|
getExpressionInfo(id: ExprId): ExpressionInfo | undefined {
|
||||||
|
return this.valuesRegistry.getExpressionInfo(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeColorStyle(id: ExprId): string {
|
||||||
|
return (id && this.nodeColors.lookup(id)) ?? 'var(--node-color-no-type)'
|
||||||
|
}
|
||||||
|
|
||||||
|
moveNodeToTop(id: ExprId) {
|
||||||
|
this.nodes.moveToLast(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
readFunctionAst(
|
||||||
|
functionAst: AstExtended<Ast.Tree.Function>,
|
||||||
|
getMeta: (id: ExprId) => NodeMetadata | undefined,
|
||||||
|
) {
|
||||||
|
const currentNodeIds = new Set<ExprId>()
|
||||||
|
if (functionAst) {
|
||||||
|
for (const nodeAst of functionAst.visit(getFunctionNodeExpressions)) {
|
||||||
|
const newNode = nodeFromAst(nodeAst)
|
||||||
|
const nodeId = newNode.rootSpan.astId
|
||||||
|
const node = this.nodes.get(nodeId)
|
||||||
|
const nodeMeta = getMeta(nodeId)
|
||||||
|
currentNodeIds.add(nodeId)
|
||||||
|
if (node == null) {
|
||||||
|
this.nodes.set(nodeId, newNode)
|
||||||
|
if (nodeMeta) this.assignUpdatedMetadata(newNode, nodeMeta)
|
||||||
|
} else {
|
||||||
|
if (node.binding !== newNode.binding) {
|
||||||
|
node.binding = newNode.binding
|
||||||
|
}
|
||||||
|
if (node.outerExprId !== newNode.outerExprId) {
|
||||||
|
node.outerExprId = newNode.outerExprId
|
||||||
|
}
|
||||||
|
if (indexedDB.cmp(node.rootSpan.contentHash(), newNode.rootSpan.contentHash()) !== 0) {
|
||||||
|
node.rootSpan = newNode.rootSpan
|
||||||
|
}
|
||||||
|
if (nodeMeta) this.assignUpdatedMetadata(node, nodeMeta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nodeId of this.allNodeIds()) {
|
||||||
|
if (!currentNodeIds.has(nodeId)) {
|
||||||
|
this.nodes.delete(nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
|
||||||
|
const newPosition = new Vec2(meta.x, -meta.y)
|
||||||
|
if (!node.position.equals(newPosition)) {
|
||||||
|
node.position = newPosition
|
||||||
|
}
|
||||||
|
if (!visMetadataEquals(node.vis, meta.vis)) {
|
||||||
|
node.vis = meta.vis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private suggestionDb: SuggestionDb,
|
||||||
|
private groups: Ref<Group[]>,
|
||||||
|
private valuesRegistry: ComputedValueRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static Mock(registry = ComputedValueRegistry.Mock()): GraphDb {
|
||||||
|
return new GraphDb(new SuggestionDb(), ref([]), registry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
outerExprId: ExprId
|
||||||
|
binding: string
|
||||||
|
rootSpan: AstExtended<Ast.Tree>
|
||||||
|
position: Vec2
|
||||||
|
vis: Opt<VisualizationMetadata>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockNode(binding: string, id: ExprId, code?: string): Node {
|
||||||
|
return {
|
||||||
|
outerExprId: id,
|
||||||
|
binding,
|
||||||
|
rootSpan: AstExtended.parse(code ?? '0', IdMap.Mock()),
|
||||||
|
position: Vec2.Zero,
|
||||||
|
vis: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFromAst(ast: AstExtended<Ast.Tree>): Node {
|
||||||
|
if (ast.isTree(Ast.Tree.Type.Assignment)) {
|
||||||
|
return {
|
||||||
|
outerExprId: ast.astId,
|
||||||
|
binding: ast.map((t) => t.pattern).repr(),
|
||||||
|
rootSpan: ast.map((t) => t.expr),
|
||||||
|
position: Vec2.Zero,
|
||||||
|
vis: undefined,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
outerExprId: ast.astId,
|
||||||
|
binding: '',
|
||||||
|
rootSpan: ast,
|
||||||
|
position: Vec2.Zero,
|
||||||
|
vis: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator<Ast.Tree> {
|
||||||
|
if (func.body) {
|
||||||
|
if (func.body.type === Ast.Tree.Type.BodyBlock) {
|
||||||
|
for (const stmt of func.body.statements) {
|
||||||
|
if (stmt.expression && stmt.expression.type !== Ast.Tree.Type.Function) {
|
||||||
|
yield stmt.expression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yield func.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,12 @@
|
|||||||
|
import { GraphDb } from '@/stores/graph/graphDatabase'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { DEFAULT_VISUALIZATION_IDENTIFIER } from '@/stores/visualization'
|
import { DEFAULT_VISUALIZATION_IDENTIFIER } from '@/stores/visualization'
|
||||||
import { Ast, AstExtended, childrenAstNodes, findAstWithRange, readAstSpan } from '@/util/ast'
|
import { Ast, AstExtended, childrenAstNodes, findAstWithRange, readAstSpan } from '@/util/ast'
|
||||||
import { useObserveYjs } from '@/util/crdt'
|
import { useObserveYjs } from '@/util/crdt'
|
||||||
import type { Opt } from '@/util/opt'
|
import type { Opt } from '@/util/opt'
|
||||||
import type { Rect } from '@/util/rect'
|
import type { Rect } from '@/util/rect'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import * as map from 'lib0/map'
|
|
||||||
import * as set from 'lib0/set'
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { StackItem } from 'shared/languageServerTypes'
|
import type { StackItem } from 'shared/languageServerTypes'
|
||||||
import {
|
import {
|
||||||
@ -18,15 +18,18 @@ import {
|
|||||||
type VisualizationIdentifier,
|
type VisualizationIdentifier,
|
||||||
type VisualizationMetadata,
|
type VisualizationMetadata,
|
||||||
} from 'shared/yjsModel'
|
} from 'shared/yjsModel'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, markRaw, reactive, ref, toRef, watch } from 'vue'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
|
export { type Node } from '@/stores/graph/graphDatabase'
|
||||||
|
|
||||||
export interface NodeEditInfo {
|
export interface NodeEditInfo {
|
||||||
id: ExprId
|
id: ExprId
|
||||||
range: ContentRange
|
range: ContentRange
|
||||||
}
|
}
|
||||||
export const useGraphStore = defineStore('graph', () => {
|
export const useGraphStore = defineStore('graph', () => {
|
||||||
const proj = useProjectStore()
|
const proj = useProjectStore()
|
||||||
|
const suggestionDb = useSuggestionDbStore()
|
||||||
|
|
||||||
proj.setObservedFileName('Main.enso')
|
proj.setObservedFileName('Main.enso')
|
||||||
|
|
||||||
@ -34,8 +37,12 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
const metadata = computed(() => proj.module?.doc.metadata)
|
const metadata = computed(() => proj.module?.doc.metadata)
|
||||||
|
|
||||||
const textContent = ref('')
|
const textContent = ref('')
|
||||||
const nodes = reactive(new Map<ExprId, Node>())
|
|
||||||
const exprNodes = reactive(new Map<ExprId, ExprId>())
|
const db = new GraphDb(
|
||||||
|
suggestionDb.entries,
|
||||||
|
toRef(suggestionDb, 'groups'),
|
||||||
|
proj.computedValueRegistry,
|
||||||
|
)
|
||||||
const nodeRects = reactive(new Map<ExprId, Rect>())
|
const nodeRects = reactive(new Map<ExprId, Rect>())
|
||||||
const exprRects = reactive(new Map<ExprId, Rect>())
|
const exprRects = reactive(new Map<ExprId, Rect>())
|
||||||
const editedNodeInfo = ref<NodeEditInfo>()
|
const editedNodeInfo = ref<NodeEditInfo>()
|
||||||
@ -70,8 +77,6 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
if (value != null) updateState()
|
if (value != null) updateState()
|
||||||
})
|
})
|
||||||
|
|
||||||
const _ast = ref<Ast.Tree>()
|
|
||||||
|
|
||||||
function updateState() {
|
function updateState() {
|
||||||
const module = proj.module
|
const module = proj.module
|
||||||
if (module == null) return
|
if (module == null) return
|
||||||
@ -93,25 +98,8 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
const nodeIds = new Set<ExprId>()
|
|
||||||
if (methodAst) {
|
if (methodAst) {
|
||||||
for (const nodeAst of methodAst.visit(getFunctionNodeExpressions)) {
|
db.readFunctionAst(methodAst, (id) => meta.get(id))
|
||||||
const newNode = nodeFromAst(nodeAst)
|
|
||||||
const nodeId = newNode.rootSpan.astId
|
|
||||||
const node = nodes.get(nodeId)
|
|
||||||
nodeIds.add(nodeId)
|
|
||||||
if (node == null) {
|
|
||||||
nodeInserted(newNode, meta.get(nodeId))
|
|
||||||
} else {
|
|
||||||
nodeUpdated(node, newNode, meta.get(nodeId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nodeId of nodes.keys()) {
|
|
||||||
if (!nodeIds.has(nodeId)) {
|
|
||||||
nodeDeleted(nodeId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -121,103 +109,28 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
for (const [id, op] of event.changes.keys) {
|
for (const [id, op] of event.changes.keys) {
|
||||||
if (op.action === 'update' || op.action === 'add') {
|
if (op.action === 'update' || op.action === 'add') {
|
||||||
const data = meta.get(id)
|
const data = meta.get(id)
|
||||||
const node = nodes.get(id as ExprId)
|
const node = db.getNode(id as ExprId)
|
||||||
if (data != null && node != null) {
|
if (data != null && node != null) {
|
||||||
assignUpdatedMetadata(node, data)
|
db.assignUpdatedMetadata(node, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const identDefinitions = reactive(new Map<string, ExprId>())
|
|
||||||
const identUsages = reactive(new Map<string, Set<ExprId>>())
|
|
||||||
|
|
||||||
function nodeInserted(node: Node, meta: Opt<NodeMetadata>) {
|
|
||||||
const nodeId = node.rootSpan.astId
|
|
||||||
|
|
||||||
nodes.set(nodeId, node)
|
|
||||||
identDefinitions.set(node.binding, nodeId)
|
|
||||||
if (meta) assignUpdatedMetadata(node, meta)
|
|
||||||
addSpanUsages(nodeId, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeUpdated(node: Node, newNode: Node, meta: Opt<NodeMetadata>) {
|
|
||||||
const nodeId = node.rootSpan.astId
|
|
||||||
if (node.binding !== newNode.binding) {
|
|
||||||
identDefinitions.delete(node.binding)
|
|
||||||
identDefinitions.set(newNode.binding, nodeId)
|
|
||||||
node.binding = newNode.binding
|
|
||||||
}
|
|
||||||
if (node.outerExprId !== newNode.outerExprId) {
|
|
||||||
node.outerExprId = newNode.outerExprId
|
|
||||||
}
|
|
||||||
node.rootSpan = newNode.rootSpan
|
|
||||||
if (meta) assignUpdatedMetadata(node, meta)
|
|
||||||
addSpanUsages(nodeId, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
|
|
||||||
const newPosition = new Vec2(meta.x, -meta.y)
|
|
||||||
if (!node.position.equals(newPosition)) {
|
|
||||||
node.position = newPosition
|
|
||||||
}
|
|
||||||
if (!visMetadataEquals(node.vis, meta.vis)) {
|
|
||||||
node.vis = meta.vis
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSpanUsages(id: ExprId, node: Node) {
|
|
||||||
node.rootSpan.visitRecursive((span) => {
|
|
||||||
exprNodes.set(span.astId, id)
|
|
||||||
if (span.isTree(Ast.Tree.Type.Ident)) {
|
|
||||||
map.setIfUndefined(identUsages, span.repr(), set.create).add(span.astId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSpanUsages(id: ExprId, node: Node) {
|
|
||||||
node.rootSpan.visitRecursive((span) => {
|
|
||||||
exprNodes.delete(span.astId)
|
|
||||||
if (span.isTree(Ast.Tree.Type.Ident)) {
|
|
||||||
const ident = span.repr()
|
|
||||||
const usages = identUsages.get(ident)
|
|
||||||
if (usages != null) {
|
|
||||||
usages.delete(span.astId)
|
|
||||||
if (usages.size === 0) identUsages.delete(ident)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeDeleted(id: ExprId) {
|
|
||||||
const node = nodes.get(id)
|
|
||||||
nodes.delete(id)
|
|
||||||
if (node != null) {
|
|
||||||
identDefinitions.delete(node.binding)
|
|
||||||
clearSpanUsages(id, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateUniqueIdent() {
|
function generateUniqueIdent() {
|
||||||
let ident: string
|
let ident: string
|
||||||
do {
|
do {
|
||||||
ident = randomString()
|
ident = randomString()
|
||||||
} while (identDefinitions.has(ident))
|
} while (db.idents.hasValue(ident))
|
||||||
return ident
|
return ident
|
||||||
}
|
}
|
||||||
|
|
||||||
const edges = computed(() => {
|
const edges = computed(() => {
|
||||||
const disconnectedEdgeTarget = unconnectedEdge.value?.disconnectedEdgeTarget
|
const disconnectedEdgeTarget = unconnectedEdge.value?.disconnectedEdgeTarget
|
||||||
const edges = []
|
const edges = []
|
||||||
for (const [ident, usages] of identUsages) {
|
for (const [target, sources] of db.connections.allReverse()) {
|
||||||
const source = identDefinitions.get(ident)
|
if (target === disconnectedEdgeTarget) continue
|
||||||
if (source == null) continue
|
for (const source of sources) {
|
||||||
for (const target of usages) {
|
|
||||||
if (target === disconnectedEdgeTarget) continue
|
|
||||||
edges.push({ source, target })
|
edges.push({ source, target })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -258,13 +171,13 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteNode(id: ExprId) {
|
function deleteNode(id: ExprId) {
|
||||||
const node = nodes.get(id)
|
const node = db.getNode(id)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.deleteExpression(node.outerExprId)
|
proj.module?.deleteExpression(node.outerExprId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNodeContent(id: ExprId, content: string) {
|
function setNodeContent(id: ExprId, content: string) {
|
||||||
const node = nodes.get(id)
|
const node = db.getNode(id)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
setExpressionContent(node.rootSpan.astId, content)
|
setExpressionContent(node.rootSpan.astId, content)
|
||||||
}
|
}
|
||||||
@ -282,13 +195,13 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
|
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
|
||||||
const node = nodes.get(nodeId)
|
const node = db.getNode(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range)
|
proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNodePosition(nodeId: ExprId, position: Vec2) {
|
function setNodePosition(nodeId: ExprId, position: Vec2) {
|
||||||
const node = nodes.get(nodeId)
|
const node = db.getNode(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.updateNodeMetadata(nodeId, { x: position.x, y: -position.y })
|
proj.module?.updateNodeMetadata(nodeId, { x: position.x, y: -position.y })
|
||||||
}
|
}
|
||||||
@ -312,13 +225,13 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setNodeVisualizationId(nodeId: ExprId, vis: Opt<VisualizationIdentifier>) {
|
function setNodeVisualizationId(nodeId: ExprId, vis: Opt<VisualizationIdentifier>) {
|
||||||
const node = nodes.get(nodeId)
|
const node = db.getNode(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(vis, node.vis?.visible) })
|
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(vis, node.vis?.visible) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNodeVisualizationVisible(nodeId: ExprId, visible: boolean) {
|
function setNodeVisualizationVisible(nodeId: ExprId, visible: boolean) {
|
||||||
const node = nodes.get(nodeId)
|
const node = db.getNode(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) })
|
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) })
|
||||||
}
|
}
|
||||||
@ -327,8 +240,13 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
nodeRects.set(id, rect)
|
nodeRects.set(id, rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateExprRect(id: ExprId, rect: Rect) {
|
function updateExprRect(id: ExprId, rect: Rect | undefined) {
|
||||||
exprRects.set(id, rect)
|
const current = exprRects.get(id)
|
||||||
|
if (rect) {
|
||||||
|
if (current == null || !current.equals(rect)) exprRects.set(id, rect)
|
||||||
|
} else {
|
||||||
|
if (current != null) exprRects.delete(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditedNode(id: ExprId | null, cursorPosition: number | null) {
|
function setEditedNode(id: ExprId | null, cursorPosition: number | null) {
|
||||||
@ -345,17 +263,13 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNodeBinding(id: ExprId): string {
|
function getNodeBinding(id: ExprId): string {
|
||||||
const node = nodes.get(id)
|
return db.nodes.get(id)?.binding ?? ''
|
||||||
if (node == null) return ''
|
|
||||||
return node.binding
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_ast,
|
|
||||||
transact,
|
transact,
|
||||||
nodes,
|
db: markRaw(db),
|
||||||
editedNodeInfo,
|
editedNodeInfo,
|
||||||
exprNodes,
|
|
||||||
unconnectedEdge,
|
unconnectedEdge,
|
||||||
edges,
|
edges,
|
||||||
nodeRects,
|
nodeRects,
|
||||||
@ -364,8 +278,6 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
disconnectSource,
|
disconnectSource,
|
||||||
disconnectTarget,
|
disconnectTarget,
|
||||||
clearUnconnected,
|
clearUnconnected,
|
||||||
identDefinitions,
|
|
||||||
identUsages,
|
|
||||||
createNode,
|
createNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
setNodeContent,
|
setNodeContent,
|
||||||
@ -386,34 +298,6 @@ function randomString() {
|
|||||||
return 'operator' + Math.round(Math.random() * 100000)
|
return 'operator' + Math.round(Math.random() * 100000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Node {
|
|
||||||
outerExprId: ExprId
|
|
||||||
binding: string
|
|
||||||
rootSpan: AstExtended<Ast.Tree>
|
|
||||||
position: Vec2
|
|
||||||
vis: Opt<VisualizationMetadata>
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeFromAst(ast: AstExtended<Ast.Tree>): Node {
|
|
||||||
if (ast.isTree(Ast.Tree.Type.Assignment)) {
|
|
||||||
return {
|
|
||||||
outerExprId: ast.astId,
|
|
||||||
binding: ast.map((t) => t.pattern).repr(),
|
|
||||||
rootSpan: ast.map((t) => t.expr),
|
|
||||||
position: Vec2.Zero,
|
|
||||||
vis: undefined,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
outerExprId: ast.astId,
|
|
||||||
binding: '',
|
|
||||||
rootSpan: ast,
|
|
||||||
position: Vec2.Zero,
|
|
||||||
vis: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An edge, which may be connected or unconnected. */
|
/** An edge, which may be connected or unconnected. */
|
||||||
export type Edge = {
|
export type Edge = {
|
||||||
source: ExprId | undefined
|
source: ExprId | undefined
|
||||||
@ -451,20 +335,6 @@ function getExecutedMethodAst(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator<Ast.Tree> {
|
|
||||||
if (func.body) {
|
|
||||||
if (func.body.type === Ast.Tree.Type.BodyBlock) {
|
|
||||||
for (const stmt of func.body.statements) {
|
|
||||||
if (stmt.expression && stmt.expression.type !== Ast.Tree.Type.Function) {
|
|
||||||
yield stmt.expression
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
yield func.body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookupIdRange(updatedIdMap: Y.Map<Uint8Array>, id: ExprId): [number, number] | undefined {
|
function lookupIdRange(updatedIdMap: Y.Map<Uint8Array>, id: ExprId): [number, number] | undefined {
|
||||||
const doc = updatedIdMap.doc!
|
const doc = updatedIdMap.doc!
|
||||||
const rangeBuffer = updatedIdMap.get(id)
|
const rangeBuffer = updatedIdMap.get(id)
|
@ -522,7 +522,7 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const executionContext = createExecutionContextForMain()
|
const executionContext = createExecutionContextForMain()
|
||||||
const computedValueRegistry = new ComputedValueRegistry(executionContext)
|
const computedValueRegistry = ComputedValueRegistry.WithExecutionContext(executionContext)
|
||||||
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection)
|
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection)
|
||||||
|
|
||||||
function useVisualizationData(
|
function useVisualizationData(
|
||||||
@ -566,7 +566,7 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
projectModel,
|
projectModel,
|
||||||
contentRoots,
|
contentRoots,
|
||||||
awareness: markRaw(awareness),
|
awareness: markRaw(awareness),
|
||||||
computedValueRegistry,
|
computedValueRegistry: markRaw(computedValueRegistry),
|
||||||
lsRpcConnection: markRaw(lsRpcConnection),
|
lsRpcConnection: markRaw(lsRpcConnection),
|
||||||
dataConnection: markRaw(dataConnection),
|
dataConnection: markRaw(dataConnection),
|
||||||
useVisualizationData,
|
useVisualizationData,
|
||||||
|
@ -7,7 +7,7 @@ import { type Opt } from '@/util/opt'
|
|||||||
import { qnParent, type QualifiedName } from '@/util/qualifiedName'
|
import { qnParent, type QualifiedName } from '@/util/qualifiedName'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { LanguageServer } from 'shared/languageServer'
|
import { LanguageServer } from 'shared/languageServer'
|
||||||
import { reactive, ref, type Ref } from 'vue'
|
import { markRaw, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
export class SuggestionDb {
|
export class SuggestionDb {
|
||||||
_internal = new ReactiveDb<SuggestionId, SuggestionEntry>()
|
_internal = new ReactiveDb<SuggestionId, SuggestionEntry>()
|
||||||
@ -25,11 +25,12 @@ export class SuggestionDb {
|
|||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
set(id: SuggestionId, entry: SuggestionEntry): void {
|
set(id: SuggestionId, entry: SuggestionEntry): void {
|
||||||
this._internal.set(id, reactive(entry))
|
this._internal.set(id, entry)
|
||||||
}
|
}
|
||||||
get(id: SuggestionId): SuggestionEntry | undefined {
|
get(id: SuggestionId | null | undefined): SuggestionEntry | undefined {
|
||||||
return this._internal.get(id)
|
return id != null ? this._internal.get(id) : undefined
|
||||||
}
|
}
|
||||||
delete(id: SuggestionId): boolean {
|
delete(id: SuggestionId): boolean {
|
||||||
return this._internal.delete(id)
|
return this._internal.delete(id)
|
||||||
@ -45,6 +46,19 @@ export interface Group {
|
|||||||
project: QualifiedName
|
project: QualifiedName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function groupColorVar(group: Group | undefined): string {
|
||||||
|
if (group) {
|
||||||
|
const name = group.name.replace(/\s/g, '-')
|
||||||
|
return `--group-color-${name}`
|
||||||
|
} else {
|
||||||
|
return '--group-color-fallback'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupColorStyle(group: Group | undefined): string {
|
||||||
|
return `var(${groupColorVar(group)})`
|
||||||
|
}
|
||||||
|
|
||||||
class Synchronizer {
|
class Synchronizer {
|
||||||
queue: AsyncQueue<{ currentVersion: number }>
|
queue: AsyncQueue<{ currentVersion: number }>
|
||||||
|
|
||||||
@ -85,7 +99,18 @@ class Synchronizer {
|
|||||||
private setupUpdateHandler(lsRpc: LanguageServer) {
|
private setupUpdateHandler(lsRpc: LanguageServer) {
|
||||||
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
||||||
this.queue.pushTask(async ({ currentVersion }) => {
|
this.queue.pushTask(async ({ currentVersion }) => {
|
||||||
if (param.currentVersion <= currentVersion) {
|
// There are rare cases where the database is updated twice in quick succession, with the
|
||||||
|
// second update containing the same version as the first. In this case, we still need to
|
||||||
|
// apply the second set of updates. Skipping it would result in the database then containing
|
||||||
|
// references to entries that don't exist. This might be an engine issue, but accepting the
|
||||||
|
// second updates seems to be harmless, so we do that.
|
||||||
|
if (param.currentVersion == currentVersion) {
|
||||||
|
console.log(
|
||||||
|
`Received multiple consecutive suggestion database updates with version ${param.currentVersion}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.currentVersion < currentVersion) {
|
||||||
console.log(
|
console.log(
|
||||||
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,
|
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,
|
||||||
)
|
)
|
||||||
@ -113,6 +138,7 @@ class Synchronizer {
|
|||||||
export const useSuggestionDbStore = defineStore('suggestionDatabase', () => {
|
export const useSuggestionDbStore = defineStore('suggestionDatabase', () => {
|
||||||
const entries = new SuggestionDb()
|
const entries = new SuggestionDb()
|
||||||
const groups = ref<Group[]>([])
|
const groups = ref<Group[]>([])
|
||||||
|
|
||||||
const _synchronizer = new Synchronizer(entries, groups)
|
const _synchronizer = new Synchronizer(entries, groups)
|
||||||
return { entries, groups, _synchronizer }
|
return { entries: markRaw(entries), groups, _synchronizer }
|
||||||
})
|
})
|
||||||
|
@ -386,8 +386,10 @@ export function applyUpdates(
|
|||||||
const updateResult = applyUpdate(entries, update, groups)
|
const updateResult = applyUpdate(entries, update, groups)
|
||||||
if (!updateResult.ok) {
|
if (!updateResult.ok) {
|
||||||
updateResult.error.log()
|
updateResult.error.log()
|
||||||
console.error(`Removing entry ${update.id}, because its state is unclear`)
|
if (entries.get(update.id) != null) {
|
||||||
entries.delete(update.id)
|
console.error(`Removing entry ${update.id}, because its state is unclear`)
|
||||||
|
entries.delete(update.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
65
app/gui2/src/util/__tests__/navigator.test.ts
Normal file
65
app/gui2/src/util/__tests__/navigator.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useNavigator } from '@/util/navigator'
|
||||||
|
import { Rect } from '@/util/rect'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
|
import { effectScope, ref } from 'vue'
|
||||||
|
|
||||||
|
describe('useNavigator', async () => {
|
||||||
|
let scope = effectScope()
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = effectScope()
|
||||||
|
})
|
||||||
|
afterEach(() => scope.stop())
|
||||||
|
|
||||||
|
function makeTestNavigator() {
|
||||||
|
return scope.run(() => {
|
||||||
|
const node = document.createElement('div')
|
||||||
|
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(new DOMRect(150, 150, 800, 400))
|
||||||
|
const viewportNode = ref(node)
|
||||||
|
return useNavigator(viewportNode)
|
||||||
|
})!
|
||||||
|
}
|
||||||
|
|
||||||
|
test('initializes with centered non-zoomed viewport', () => {
|
||||||
|
const navigator = makeTestNavigator()
|
||||||
|
expect(navigator.viewport).toStrictEqual(Rect.FromBounds(-400, -200, 400, 200))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clientToScenePos without scaling', () => {
|
||||||
|
const navigator = makeTestNavigator()
|
||||||
|
expect(navigator.clientToScenePos(Vec2.Zero)).toStrictEqual(new Vec2(-550, -350))
|
||||||
|
expect(navigator.clientToScenePos(new Vec2(150, 150))).toStrictEqual(new Vec2(-400, -200))
|
||||||
|
expect(navigator.clientToScenePos(new Vec2(550, 350))).toStrictEqual(new Vec2(0, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clientToScenePos with scaling', () => {
|
||||||
|
const navigator = makeTestNavigator()
|
||||||
|
navigator.scale = 2
|
||||||
|
expect(navigator.clientToScenePos(Vec2.Zero)).toStrictEqual(new Vec2(-275, -175))
|
||||||
|
expect(navigator.clientToScenePos(new Vec2(150, 150))).toStrictEqual(new Vec2(-200, -100))
|
||||||
|
expect(navigator.clientToScenePos(new Vec2(550, 350))).toStrictEqual(new Vec2(0, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clientToSceneRect without scaling', () => {
|
||||||
|
const navigator = makeTestNavigator()
|
||||||
|
expect(navigator.clientToSceneRect(Rect.Zero)).toStrictEqual(Rect.XYWH(-550, -350, 0, 0))
|
||||||
|
expect(navigator.clientToSceneRect(Rect.XYWH(150, 150, 800, 400))).toStrictEqual(
|
||||||
|
navigator.viewport,
|
||||||
|
)
|
||||||
|
expect(navigator.clientToSceneRect(Rect.XYWH(100, 150, 200, 900))).toStrictEqual(
|
||||||
|
Rect.XYWH(-450, -200, 200, 900),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clientToSceneRect with scaling', () => {
|
||||||
|
const navigator = makeTestNavigator()
|
||||||
|
navigator.scale = 2
|
||||||
|
expect(navigator.clientToSceneRect(Rect.Zero)).toStrictEqual(Rect.XYWH(-275, -175, 0, 0))
|
||||||
|
expect(navigator.clientToSceneRect(Rect.XYWH(150, 150, 800, 400))).toStrictEqual(
|
||||||
|
navigator.viewport,
|
||||||
|
)
|
||||||
|
expect(navigator.clientToSceneRect(Rect.XYWH(100, 150, 200, 900))).toStrictEqual(
|
||||||
|
Rect.XYWH(-225, -100, 100, 450),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
145
app/gui2/src/util/__tests__/reactivity.test.ts
Normal file
145
app/gui2/src/util/__tests__/reactivity.test.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { expect, test, vi } from 'vitest'
|
||||||
|
import { nextTick, reactive, ref } from 'vue'
|
||||||
|
import { LazySyncEffectSet } from '../reactivity'
|
||||||
|
|
||||||
|
test('LazySyncEffectSet', async () => {
|
||||||
|
const lazySet = new LazySyncEffectSet()
|
||||||
|
|
||||||
|
const key1 = ref(0)
|
||||||
|
const key2 = ref(100)
|
||||||
|
const lazilyUpdatedMap = reactive(new Map<number, string>())
|
||||||
|
|
||||||
|
let runCount = 0
|
||||||
|
const stopA = lazySet.lazyEffect((onCleanup) => {
|
||||||
|
const currentValue = key1.value
|
||||||
|
lazilyUpdatedMap.set(currentValue, 'a' + runCount++)
|
||||||
|
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
lazySet.lazyEffect((onCleanup) => {
|
||||||
|
const currentValue = key2.value
|
||||||
|
lazilyUpdatedMap.set(currentValue, 'b' + runCount++)
|
||||||
|
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dependant effect, notices when -1 key is inserted into the map by another effect.
|
||||||
|
const cleanupSpy = vi.fn()
|
||||||
|
lazySet.lazyEffect((onCleanup) => {
|
||||||
|
const negOne = lazilyUpdatedMap.get(-1)
|
||||||
|
if (negOne != null) {
|
||||||
|
lazilyUpdatedMap.set(-2, `noticed ${negOne}!`)
|
||||||
|
onCleanup(() => {
|
||||||
|
cleanupSpy()
|
||||||
|
lazilyUpdatedMap.delete(-2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lazilyUpdatedMap, 'The effects should not run immediately after registration').toEqual(
|
||||||
|
new Map([]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key1.value = 1
|
||||||
|
expect(lazilyUpdatedMap, 'The effects should not perform any updates until flush').toEqual(
|
||||||
|
new Map([]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key1.value = 2
|
||||||
|
lazySet.flush()
|
||||||
|
expect(
|
||||||
|
lazilyUpdatedMap,
|
||||||
|
'A cleanup and update should run on flush, but only for the updated key',
|
||||||
|
).toEqual(
|
||||||
|
new Map([
|
||||||
|
[2, 'a0'],
|
||||||
|
[100, 'b1'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key1.value = 3
|
||||||
|
key2.value = 103
|
||||||
|
stopA()
|
||||||
|
expect(
|
||||||
|
lazilyUpdatedMap,
|
||||||
|
'Stop should immediately trigger cleanup, but only for stopped effect',
|
||||||
|
).toEqual(new Map([[100, 'b1']]))
|
||||||
|
|
||||||
|
lazySet.flush()
|
||||||
|
expect(
|
||||||
|
lazilyUpdatedMap,
|
||||||
|
'Flush should trigger remaining updates, but not run the stopped effects',
|
||||||
|
).toEqual(new Map([[103, 'b2']]))
|
||||||
|
|
||||||
|
key1.value = 4
|
||||||
|
key2.value = 104
|
||||||
|
lazySet.lazyEffect((onCleanup) => {
|
||||||
|
const currentValue = key1.value
|
||||||
|
console.log('currentValue', currentValue)
|
||||||
|
console.log('lazilyUpdatedMap', lazilyUpdatedMap)
|
||||||
|
|
||||||
|
lazilyUpdatedMap.set(currentValue, 'c' + runCount++)
|
||||||
|
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
lazilyUpdatedMap,
|
||||||
|
'Newly registered effect should not run immediately nor trigger a flush',
|
||||||
|
).toEqual(new Map([[103, 'b2']]))
|
||||||
|
|
||||||
|
key1.value = 5
|
||||||
|
key2.value = 105
|
||||||
|
lazySet.flush()
|
||||||
|
expect(
|
||||||
|
lazilyUpdatedMap,
|
||||||
|
'Flush should trigger both effects when their dependencies change',
|
||||||
|
).toEqual(
|
||||||
|
new Map([
|
||||||
|
[105, 'b3'],
|
||||||
|
[5, 'c4'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
lazySet.flush()
|
||||||
|
expect(lazilyUpdatedMap, 'Flush should have no effect when no dependencies changed').toEqual(
|
||||||
|
new Map([
|
||||||
|
[105, 'b3'],
|
||||||
|
[5, 'c4'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key2.value = -1
|
||||||
|
lazySet.flush()
|
||||||
|
expect(lazilyUpdatedMap, 'Effects depending on one another should run in the same flush').toEqual(
|
||||||
|
new Map([
|
||||||
|
[5, 'c4'],
|
||||||
|
[-1, 'b5'],
|
||||||
|
[-2, 'noticed b5!'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key2.value = 1
|
||||||
|
lazySet.flush()
|
||||||
|
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(lazilyUpdatedMap, 'Dependant effect is cleaned up.').toEqual(
|
||||||
|
new Map([
|
||||||
|
[1, 'b6'],
|
||||||
|
[5, 'c4'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
key2.value = 2
|
||||||
|
lazySet.flush()
|
||||||
|
key2.value = -3
|
||||||
|
lazySet.flush()
|
||||||
|
expect(cleanupSpy, 'Cleanup runs only once.').toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
key1.value = -1
|
||||||
|
key2.value = 456
|
||||||
|
await nextTick()
|
||||||
|
expect(lazilyUpdatedMap, 'Flush should run automatically before the next tick.').toEqual(
|
||||||
|
new Map([
|
||||||
|
[456, 'b10'],
|
||||||
|
[-1, 'c9'],
|
||||||
|
[-2, 'noticed c9!'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
@ -117,3 +117,30 @@ export function useApproach(
|
|||||||
|
|
||||||
return proxyRefs({ value: current, skip })
|
return proxyRefs({ value: current, skip })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTransitioning(observedProperties?: Set<string>) {
|
||||||
|
const hasActiveAnimations = ref(false)
|
||||||
|
let numActiveTransitions = 0
|
||||||
|
function onTransitionStart(e: TransitionEvent) {
|
||||||
|
if (!observedProperties || observedProperties.has(e.propertyName)) {
|
||||||
|
if (numActiveTransitions == 0) hasActiveAnimations.value = true
|
||||||
|
numActiveTransitions += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTransitionEnd(e: TransitionEvent) {
|
||||||
|
if (!observedProperties || observedProperties.has(e.propertyName)) {
|
||||||
|
numActiveTransitions -= 1
|
||||||
|
if (numActiveTransitions == 0) hasActiveAnimations.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: hasActiveAnimations,
|
||||||
|
events: {
|
||||||
|
transitionstart: onTransitionStart,
|
||||||
|
transitionend: onTransitionEnd,
|
||||||
|
transitioncancel: onTransitionEnd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -34,3 +34,8 @@ export function partitionPoint<T>(
|
|||||||
}
|
}
|
||||||
return start
|
return start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Index into an array using specified index. When the index is nullable, returns undefined. */
|
||||||
|
export function tryGetIndex<T>(arr: T[], index: number | undefined | null): T | undefined {
|
||||||
|
return index == null ? undefined : arr[index]
|
||||||
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import * as Ast from '@/generated/ast'
|
import * as Ast from '@/generated/ast'
|
||||||
import { Token, Tree } from '@/generated/ast'
|
import { Token, Tree } from '@/generated/ast'
|
||||||
import { assert } from '@/util/assert'
|
import { assert } from '@/util/assert'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import { digest } from 'lib0/hash/sha256'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
|
||||||
import type { ContentRange, ExprId, IdMap } from 'shared/yjsModel'
|
import type { ContentRange, ExprId, IdMap } from 'shared/yjsModel'
|
||||||
import { markRaw } from 'vue'
|
import { markRaw } from 'vue'
|
||||||
import {
|
import {
|
||||||
@ -43,6 +47,10 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
|
|||||||
return Tree.isInstance(this.inner) ? Tree.typeNames[this.inner.type] : null
|
return Tree.isInstance(this.inner) ? Tree.typeNames[this.inner.type] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenTypeName(): (typeof Token.typeNames)[number] | null {
|
||||||
|
return Token.isInstance(this.inner) ? Token.typeNames[this.inner.type] : null
|
||||||
|
}
|
||||||
|
|
||||||
isToken<T extends Ast.Token.Type>(
|
isToken<T extends Ast.Token.Type>(
|
||||||
type?: T,
|
type?: T,
|
||||||
): this is AstExtended<Extract<Ast.Token, { type: T }>, HasIdMap> {
|
): this is AstExtended<Extract<Ast.Token, { type: T }>, HasIdMap> {
|
||||||
@ -93,6 +101,10 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
|
|||||||
return parsedTreeOrTokenRange(this.inner)
|
return parsedTreeOrTokenRange(this.inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentHash() {
|
||||||
|
return this.ctx.getHash(this)
|
||||||
|
}
|
||||||
|
|
||||||
children(): AstExtended<Tree | Token, HasIdMap>[] {
|
children(): AstExtended<Tree | Token, HasIdMap>[] {
|
||||||
return childrenAstNodesOrTokens(this.inner).map((child) => new AstExtended(child, this.ctx))
|
return childrenAstNodesOrTokens(this.inner).map((child) => new AstExtended(child, this.ctx))
|
||||||
}
|
}
|
||||||
@ -129,8 +141,38 @@ type CondType<T, Cond extends boolean> = Cond extends true
|
|||||||
class AstExtendedCtx<HasIdMap extends boolean> {
|
class AstExtendedCtx<HasIdMap extends boolean> {
|
||||||
parsedCode: string
|
parsedCode: string
|
||||||
idMap: CondType<IdMap, HasIdMap>
|
idMap: CondType<IdMap, HasIdMap>
|
||||||
|
contentHashes: Map<string, Uint8Array>
|
||||||
|
|
||||||
constructor(parsedCode: string, idMap: CondType<IdMap, HasIdMap>) {
|
constructor(parsedCode: string, idMap: CondType<IdMap, HasIdMap>) {
|
||||||
this.parsedCode = parsedCode
|
this.parsedCode = parsedCode
|
||||||
this.idMap = idMap
|
this.idMap = idMap
|
||||||
|
this.contentHashes = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
static getHashKey(ast: AstExtended<Tree | Token, boolean>) {
|
||||||
|
return `${ast.isToken() ? 'T.' : ''}${ast.inner.type}.${ast.span()[0]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getHash(ast: AstExtended<Tree | Token, boolean>) {
|
||||||
|
const key = AstExtendedCtx.getHashKey(ast)
|
||||||
|
return map.setIfUndefined(this.contentHashes, key, () =>
|
||||||
|
digest(
|
||||||
|
encoding.encode((encoder) => {
|
||||||
|
const whitespace = ast.whitespaceLength()
|
||||||
|
encoding.writeUint32(encoder, whitespace)
|
||||||
|
if (ast.isToken()) {
|
||||||
|
encoding.writeUint8(encoder, 0)
|
||||||
|
encoding.writeUint32(encoder, ast.inner.type)
|
||||||
|
encoding.writeVarString(encoder, ast.repr())
|
||||||
|
} else {
|
||||||
|
encoding.writeUint8(encoder, 1)
|
||||||
|
encoding.writeUint32(encoder, ast.inner.type)
|
||||||
|
for (const child of ast.children()) {
|
||||||
|
encoding.writeUint8Array(encoder, this.getHash(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ import type {
|
|||||||
MethodCall,
|
MethodCall,
|
||||||
ProfilingInfo,
|
ProfilingInfo,
|
||||||
} from 'shared/languageServerTypes'
|
} from 'shared/languageServerTypes'
|
||||||
import { reactive } from 'vue'
|
import { markRaw } from 'vue'
|
||||||
|
import { ReactiveDb } from './database/reactiveDb'
|
||||||
|
|
||||||
export interface ExpressionInfo {
|
export interface ExpressionInfo {
|
||||||
typename: string | undefined
|
typename: string | undefined
|
||||||
@ -17,33 +18,46 @@ export interface ExpressionInfo {
|
|||||||
|
|
||||||
/** This class holds the computed values that have been received from the language server. */
|
/** This class holds the computed values that have been received from the language server. */
|
||||||
export class ComputedValueRegistry {
|
export class ComputedValueRegistry {
|
||||||
private expressionMap: Map<ExpressionId, ExpressionInfo>
|
public db: ReactiveDb<ExpressionId, ExpressionInfo> = new ReactiveDb()
|
||||||
private _updateHandler = this.processUpdates.bind(this)
|
private _updateHandler = this.processUpdates.bind(this)
|
||||||
private executionContext
|
private executionContext: ExecutionContext | undefined
|
||||||
|
|
||||||
constructor(executionContext: ExecutionContext) {
|
private constructor() {
|
||||||
this.executionContext = executionContext
|
markRaw(this)
|
||||||
this.expressionMap = reactive(new Map())
|
}
|
||||||
|
|
||||||
executionContext.on('expressionUpdates', this._updateHandler)
|
static WithExecutionContext(executionContext: ExecutionContext): ComputedValueRegistry {
|
||||||
|
const self = new ComputedValueRegistry()
|
||||||
|
executionContext.on('expressionUpdates', self._updateHandler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
static Mock(): ComputedValueRegistry {
|
||||||
|
return new ComputedValueRegistry()
|
||||||
}
|
}
|
||||||
|
|
||||||
processUpdates(updates: ExpressionUpdate[]) {
|
processUpdates(updates: ExpressionUpdate[]) {
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
this.expressionMap.set(update.expressionId, {
|
const info = this.db.get(update.expressionId)
|
||||||
typename: update.type,
|
this.db.set(update.expressionId, combineInfo(info, update))
|
||||||
methodCall: update.methodCall,
|
|
||||||
payload: update.payload,
|
|
||||||
profilingInfo: update.profilingInfo,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getExpressionInfo(exprId: ExpressionId): ExpressionInfo | undefined {
|
getExpressionInfo(exprId: ExpressionId): ExpressionInfo | undefined {
|
||||||
return this.expressionMap.get(exprId)
|
return this.db.get(exprId)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.executionContext.off('expressionUpdates', this._updateHandler)
|
this.executionContext?.off('expressionUpdates', this._updateHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineInfo(info: ExpressionInfo | undefined, update: ExpressionUpdate): ExpressionInfo {
|
||||||
|
const isPending = update.payload.type === 'Pending'
|
||||||
|
return {
|
||||||
|
typename: update.type ?? (isPending ? info?.typename : undefined),
|
||||||
|
methodCall: update.methodCall ?? (isPending ? info?.methodCall : undefined),
|
||||||
|
payload: update.payload,
|
||||||
|
profilingInfo: update.profilingInfo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,12 +24,17 @@ test('Indexing is efficient', () => {
|
|||||||
db.set(2, reactive({ name: 'xyz' }))
|
db.set(2, reactive({ name: 'xyz' }))
|
||||||
db.set(3, reactive({ name: 'abc' }))
|
db.set(3, reactive({ name: 'abc' }))
|
||||||
db.delete(2)
|
db.delete(2)
|
||||||
|
expect(adding).toHaveBeenCalledTimes(0)
|
||||||
|
expect(removing).toHaveBeenCalledTimes(0)
|
||||||
|
index.lookup('x')
|
||||||
|
expect(adding).toHaveBeenCalledTimes(2)
|
||||||
|
expect(removing).toHaveBeenCalledTimes(0)
|
||||||
|
db.set(1, { name: 'qdr' })
|
||||||
|
index.lookup('x')
|
||||||
expect(adding).toHaveBeenCalledTimes(3)
|
expect(adding).toHaveBeenCalledTimes(3)
|
||||||
expect(removing).toHaveBeenCalledTimes(1)
|
expect(removing).toHaveBeenCalledTimes(1)
|
||||||
db.set(1, { name: 'qdr' })
|
|
||||||
expect(adding).toHaveBeenCalledTimes(4)
|
|
||||||
expect(removing).toHaveBeenCalledTimes(2)
|
|
||||||
db.get(3)!.name = 'xyz'
|
db.get(3)!.name = 'xyz'
|
||||||
|
index.lookup('x')
|
||||||
expect(adding).toHaveBeenCalledTimes(4)
|
expect(adding).toHaveBeenCalledTimes(4)
|
||||||
expect(removing).toHaveBeenCalledTimes(2)
|
expect(removing).toHaveBeenCalledTimes(2)
|
||||||
expect(index.lookup('qdr')).toEqual(new Set([1]))
|
expect(index.lookup('qdr')).toEqual(new Set([1]))
|
||||||
@ -41,9 +46,10 @@ test('Error reported when indexer implementation returns non-unique pairs', () =
|
|||||||
console.error = () => {}
|
console.error = () => {}
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
// Invalid index
|
// Invalid index
|
||||||
new ReactiveIndex(db, (_id, _entry) => [[1, 1]])
|
const index = new ReactiveIndex(db, (_id, _entry) => [[1, 1]])
|
||||||
db.set(1, 1)
|
db.set(1, 1)
|
||||||
db.set(2, 2)
|
db.set(2, 2)
|
||||||
|
index.lookup(1)
|
||||||
expect(consoleError).toHaveBeenCalledOnce()
|
expect(consoleError).toHaveBeenCalledOnce()
|
||||||
expect(consoleError).toHaveBeenCalledWith(
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
'Attempt to repeatedly write the same key-value pair (1,1) to the index. Please check your indexer implementation.',
|
'Attempt to repeatedly write the same key-value pair (1,1) to the index. Please check your indexer implementation.',
|
||||||
@ -101,7 +107,7 @@ test('Parent index', async () => {
|
|||||||
expect(parent.reverseLookup(3)).toStrictEqual(new Set())
|
expect(parent.reverseLookup(3)).toStrictEqual(new Set())
|
||||||
expect(adding).toHaveBeenCalledTimes(2)
|
expect(adding).toHaveBeenCalledTimes(2)
|
||||||
expect(removing).toHaveBeenCalledTimes(0)
|
expect(removing).toHaveBeenCalledTimes(0)
|
||||||
expect(lookupQn).toHaveBeenCalledTimes(6)
|
expect(lookupQn).toHaveBeenCalledTimes(5)
|
||||||
|
|
||||||
db.delete(3)
|
db.delete(3)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@ -109,5 +115,5 @@ test('Parent index', async () => {
|
|||||||
expect(parent.reverseLookup(2)).toEqual(new Set([1]))
|
expect(parent.reverseLookup(2)).toEqual(new Set([1]))
|
||||||
expect(adding).toHaveBeenCalledTimes(2)
|
expect(adding).toHaveBeenCalledTimes(2)
|
||||||
expect(removing).toHaveBeenCalledTimes(1)
|
expect(removing).toHaveBeenCalledTimes(1)
|
||||||
expect(lookupQn).toHaveBeenCalledTimes(6)
|
expect(lookupQn).toHaveBeenCalledTimes(5)
|
||||||
})
|
})
|
||||||
|
@ -9,9 +9,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { LazySyncEffectSet } from '@/util/reactivity'
|
import { LazySyncEffectSet } from '@/util/reactivity'
|
||||||
import { setIfUndefined } from 'lib0/map'
|
// eslint-disable-next-line vue/prefer-import-from-vue
|
||||||
|
import * as map from 'lib0/map'
|
||||||
import { ObservableV2 } from 'lib0/observable'
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
import { reactive } from 'vue'
|
import * as set from 'lib0/set'
|
||||||
|
import { computed, reactive, type ComputedRef, type DebuggerOptions } from 'vue'
|
||||||
|
|
||||||
export type OnDelete = (cleanupFn: () => void) => void
|
export type OnDelete = (cleanupFn: () => void) => void
|
||||||
|
|
||||||
@ -31,8 +33,8 @@ export class ReactiveDb<K, V> extends ObservableV2<{
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this._internal = new Map()
|
this._internal = reactive(map.create())
|
||||||
this.onDelete = new Map()
|
this.onDelete = map.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,11 +49,12 @@ export class ReactiveDb<K, V> extends ObservableV2<{
|
|||||||
this.delete(key)
|
this.delete(key)
|
||||||
|
|
||||||
this._internal.set(key, value)
|
this._internal.set(key, value)
|
||||||
|
const reactiveValue = this._internal.get(key) as V
|
||||||
const onDelete: OnDelete = (callback) => {
|
const onDelete: OnDelete = (callback) => {
|
||||||
const callbacks = setIfUndefined(this.onDelete, key, () => new Set())
|
const callbacks = map.setIfUndefined(this.onDelete, key, set.create)
|
||||||
callbacks.add(callback)
|
callbacks.add(callback)
|
||||||
}
|
}
|
||||||
this.emit('entryAdded', [key, value, onDelete])
|
this.emit('entryAdded', [key, reactiveValue, onDelete])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,10 +115,22 @@ export class ReactiveDb<K, V> extends ObservableV2<{
|
|||||||
values(): IterableIterator<V> {
|
values(): IterableIterator<V> {
|
||||||
return this._internal.values()
|
return this._internal.values()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an entry to the bottom of the database, making it the last entry in the iteration order
|
||||||
|
* of `entries()`.
|
||||||
|
*/
|
||||||
|
moveToLast(id: K) {
|
||||||
|
const value = this._internal.get(id)
|
||||||
|
if (value !== undefined) {
|
||||||
|
this._internal.delete(id)
|
||||||
|
this._internal.set(id, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function type representing an indexer for a `ReactiveDb`.
|
* A function type representing an indexer for a `ReactiveIndex`.
|
||||||
*
|
*
|
||||||
* An `Indexer` takes a key-value pair from the `ReactiveDb` and produces an array of index key-value pairs,
|
* An `Indexer` takes a key-value pair from the `ReactiveDb` and produces an array of index key-value pairs,
|
||||||
* defining how the input key and value maps to keys and values in the index.
|
* defining how the input key and value maps to keys and values in the index.
|
||||||
@ -151,8 +166,8 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
* @param indexer - The indexer function defining how db keys and values map to index keys and values.
|
* @param indexer - The indexer function defining how db keys and values map to index keys and values.
|
||||||
*/
|
*/
|
||||||
constructor(db: ReactiveDb<K, V>, indexer: Indexer<K, V, IK, IV>) {
|
constructor(db: ReactiveDb<K, V>, indexer: Indexer<K, V, IK, IV>) {
|
||||||
this.forward = reactive(new Map())
|
this.forward = reactive(map.create())
|
||||||
this.reverse = reactive(new Map())
|
this.reverse = reactive(map.create())
|
||||||
this.effects = new LazySyncEffectSet()
|
this.effects = new LazySyncEffectSet()
|
||||||
db.on('entryAdded', (key, value, onDelete) => {
|
db.on('entryAdded', (key, value, onDelete) => {
|
||||||
const stopEffect = this.effects.lazyEffect((onCleanup) => {
|
const stopEffect = this.effects.lazyEffect((onCleanup) => {
|
||||||
@ -171,7 +186,7 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
* @param value - The value to associate with the key.
|
* @param value - The value to associate with the key.
|
||||||
*/
|
*/
|
||||||
writeToIndex(key: IK, value: IV): void {
|
writeToIndex(key: IK, value: IV): void {
|
||||||
const forward = setIfUndefined(this.forward, key, () => new Set())
|
const forward = map.setIfUndefined(this.forward, key, set.create)
|
||||||
if (forward.has(value)) {
|
if (forward.has(value)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Attempt to repeatedly write the same key-value pair (${[
|
`Attempt to repeatedly write the same key-value pair (${[
|
||||||
@ -179,10 +194,11 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
value,
|
value,
|
||||||
]}) to the index. Please check your indexer implementation.`,
|
]}) to the index. Please check your indexer implementation.`,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
forward.add(value)
|
||||||
|
const reverse = map.setIfUndefined(this.reverse, value, set.create)
|
||||||
|
reverse.add(key)
|
||||||
}
|
}
|
||||||
forward.add(value)
|
|
||||||
const reverse = setIfUndefined(this.reverse, value, () => new Set())
|
|
||||||
reverse.add(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -199,6 +215,16 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
remove(this.reverse, value, key)
|
remove(this.reverse, value, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allForward(): IterableIterator<[IK, Set<IV>]> {
|
||||||
|
this.effects.flush()
|
||||||
|
return this.forward.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
allReverse(): IterableIterator<[IV, Set<IK>]> {
|
||||||
|
this.effects.flush()
|
||||||
|
return this.reverse.entries()
|
||||||
|
}
|
||||||
|
|
||||||
/** Look for key in the forward index.
|
/** Look for key in the forward index.
|
||||||
* Returns a set of values associated with the given index key.
|
* Returns a set of values associated with the given index key.
|
||||||
*
|
*
|
||||||
@ -207,7 +233,7 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
*/
|
*/
|
||||||
lookup(key: IK): Set<IV> {
|
lookup(key: IK): Set<IV> {
|
||||||
this.effects.flush()
|
this.effects.flush()
|
||||||
return this.forward.get(key) ?? new Set()
|
return this.forward.get(key) ?? set.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -218,6 +244,65 @@ export class ReactiveIndex<K, V, IK, IV> {
|
|||||||
*/
|
*/
|
||||||
reverseLookup(value: IV): Set<IK> {
|
reverseLookup(value: IV): Set<IK> {
|
||||||
this.effects.flush()
|
this.effects.flush()
|
||||||
return this.reverse.get(value) ?? new Set()
|
return this.reverse.get(value) ?? set.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValue(value: IV): boolean {
|
||||||
|
return this.reverse.has(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function type representing a mapper function for {@link ReactiveMapping}.
|
||||||
|
*
|
||||||
|
* It takes a key-value pair from the {@link ReactiveDb} and produces a mapped value, which is then stored
|
||||||
|
* and can be looked up by the key.
|
||||||
|
*
|
||||||
|
* @param key - The key from the {@link ReactiveDb}.
|
||||||
|
* @param value - The value from the {@link ReactiveDb}.
|
||||||
|
*
|
||||||
|
* @returns A result of a mapping to store in the {@link ReactiveMapping}.
|
||||||
|
*/
|
||||||
|
export type Mapper<K, V, IV> = (key: K, value: V) => IV | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-to-one mapping for values in a {@link ReactiveDb} instance. Allows only one value per key.
|
||||||
|
* It can be thought of as a collection of `computed` values per each key in the `ReactiveDb`. The
|
||||||
|
* mapping is automatically updated when any of its dependencies change, and is properly cleaned up
|
||||||
|
* when any key is removed from {@link ReactiveDb}. Only accessed keys are ever actually computed.
|
||||||
|
*
|
||||||
|
* @typeParam K - The key type of the ReactiveDb.
|
||||||
|
* @typeParam V - The value type of the ReactiveDb.
|
||||||
|
* @typeParam M - The type of a mapped value.
|
||||||
|
*/
|
||||||
|
export class ReactiveMapping<K, V, M> {
|
||||||
|
/** Forward map from index keys to a mapped computed value */
|
||||||
|
computed: Map<K, ComputedRef<M | undefined>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new {@link ReactiveMapping} for the given {@link ReactiveDb} and an mapper function.
|
||||||
|
*
|
||||||
|
* @param db - The ReactiveDb to map over.
|
||||||
|
* @param indexer - The indexer function defining how db keys and values are mapped.
|
||||||
|
*/
|
||||||
|
constructor(db: ReactiveDb<K, V>, indexer: Mapper<K, V, M>, debugOptions?: DebuggerOptions) {
|
||||||
|
this.computed = reactive(map.create())
|
||||||
|
db.on('entryAdded', (key, value, onDelete) => {
|
||||||
|
this.computed.set(
|
||||||
|
key,
|
||||||
|
computed(() => indexer(key, value), debugOptions),
|
||||||
|
)
|
||||||
|
onDelete(() => this.computed.delete(key))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look for key in the mapping.
|
||||||
|
* Returns a mapped value associated with given key.
|
||||||
|
*
|
||||||
|
* @param key - The index key to look up values for.
|
||||||
|
* @return A mapped value, if the key is present in the mapping.
|
||||||
|
*/
|
||||||
|
lookup(key: K): M | undefined {
|
||||||
|
return this.computed.get(key)?.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,7 +139,20 @@ export function useResizeObserver(
|
|||||||
useContentRect = true,
|
useContentRect = true,
|
||||||
): Ref<Vec2> {
|
): Ref<Vec2> {
|
||||||
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
|
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
|
||||||
if (typeof ResizeObserver === 'undefined') return sizeRef
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
// Fallback implementation for browsers/test environment that do not support ResizeObserver:
|
||||||
|
// Grab the size of the element every time the ref is assigned, or when the page is resized.
|
||||||
|
function refreshSize() {
|
||||||
|
const element = elementRef.value
|
||||||
|
if (element != null) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
sizeRef.value = new Vec2(rect.width, rect.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watchEffect(refreshSize)
|
||||||
|
useEvent(window, 'resize', refreshSize)
|
||||||
|
return sizeRef
|
||||||
|
}
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
let rect: { width: number; height: number } | null = null
|
let rect: { width: number; height: number } | null = null
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
export abstract class Interaction {
|
|
||||||
id: number
|
|
||||||
static nextId: number = 0
|
|
||||||
constructor() {
|
|
||||||
this.id = Interaction.nextId
|
|
||||||
Interaction.nextId += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract cancel(): void
|
|
||||||
click(_e: MouseEvent): boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
@ -34,6 +34,21 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clientToSceneRect(clientRect: Rect): Rect {
|
||||||
|
const rect = elemRect(viewportNode.value)
|
||||||
|
const canvasPos = clientRect.pos.sub(rect.pos)
|
||||||
|
const v = viewport.value
|
||||||
|
const pos = new Vec2(
|
||||||
|
v.pos.x + v.size.x * (canvasPos.x / rect.size.x),
|
||||||
|
v.pos.y + v.size.y * (canvasPos.y / rect.size.y),
|
||||||
|
)
|
||||||
|
const size = new Vec2(
|
||||||
|
v.size.x * (clientRect.size.x / rect.size.x),
|
||||||
|
v.size.y * (clientRect.size.y / rect.size.y),
|
||||||
|
)
|
||||||
|
return new Rect(pos, size)
|
||||||
|
}
|
||||||
|
|
||||||
let zoomPivot = Vec2.Zero
|
let zoomPivot = Vec2.Zero
|
||||||
const zoomPointer = usePointer((pos, _event, ty) => {
|
const zoomPointer = usePointer((pos, _event, ty) => {
|
||||||
if (ty === 'start') {
|
if (ty === 'start') {
|
||||||
@ -131,6 +146,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
|||||||
prescaledTransform,
|
prescaledTransform,
|
||||||
sceneMousePos,
|
sceneMousePos,
|
||||||
clientToScenePos,
|
clientToScenePos,
|
||||||
|
clientToSceneRect,
|
||||||
viewport,
|
viewport,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,7 @@ import {
|
|||||||
effect,
|
effect,
|
||||||
effectScope,
|
effectScope,
|
||||||
isRef,
|
isRef,
|
||||||
reactive,
|
queuePostFlushCb,
|
||||||
ref,
|
|
||||||
type Ref,
|
type Ref,
|
||||||
type WatchSource,
|
type WatchSource,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
@ -32,6 +31,7 @@ export type StopEffect = () => void
|
|||||||
export class LazySyncEffectSet {
|
export class LazySyncEffectSet {
|
||||||
_dirtyRunners = new Set<() => void>()
|
_dirtyRunners = new Set<() => void>()
|
||||||
_scope = effectScope()
|
_scope = effectScope()
|
||||||
|
_boundFlush = this.flush.bind(this)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an effect to the lazy set. The effect will run once immediately, and any subsequent runs
|
* Add an effect to the lazy set. The effect will run once immediately, and any subsequent runs
|
||||||
@ -60,7 +60,9 @@ export class LazySyncEffectSet {
|
|||||||
fn(onCleanup)
|
fn(onCleanup)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
lazy: true,
|
||||||
scheduler: () => {
|
scheduler: () => {
|
||||||
|
if (this._dirtyRunners.size === 0) queuePostFlushCb(this._boundFlush)
|
||||||
this._dirtyRunners.add(runner)
|
this._dirtyRunners.add(runner)
|
||||||
},
|
},
|
||||||
onStop: () => {
|
onStop: () => {
|
||||||
@ -69,6 +71,7 @@ export class LazySyncEffectSet {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
runner.effect.scheduler?.()
|
||||||
return () => runner.effect.stop()
|
return () => runner.effect.stop()
|
||||||
}) ?? nop
|
}) ?? nop
|
||||||
)
|
)
|
||||||
@ -91,149 +94,3 @@ export class LazySyncEffectSet {
|
|||||||
this._scope.stop()
|
this._scope.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.vitest) {
|
|
||||||
const { test, expect, vi } = import.meta.vitest
|
|
||||||
|
|
||||||
test('LazySyncEffectSet', () => {
|
|
||||||
const lazySet = new LazySyncEffectSet()
|
|
||||||
|
|
||||||
const key1 = ref(0)
|
|
||||||
const key2 = ref(100)
|
|
||||||
const lazilyUpdatedMap = reactive(new Map<number, string>())
|
|
||||||
|
|
||||||
let runCount = 0
|
|
||||||
const stopA = lazySet.lazyEffect((onCleanup) => {
|
|
||||||
const currentValue = key1.value
|
|
||||||
lazilyUpdatedMap.set(currentValue, 'a' + runCount++)
|
|
||||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
|
||||||
})
|
|
||||||
|
|
||||||
lazySet.lazyEffect((onCleanup) => {
|
|
||||||
const currentValue = key2.value
|
|
||||||
lazilyUpdatedMap.set(currentValue, 'b' + runCount++)
|
|
||||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Dependant effect, notices when -1 key is inserted into the map by another effect.
|
|
||||||
const cleanupSpy = vi.fn()
|
|
||||||
lazySet.lazyEffect((onCleanup) => {
|
|
||||||
const negOne = lazilyUpdatedMap.get(-1)
|
|
||||||
if (negOne != null) {
|
|
||||||
lazilyUpdatedMap.set(-2, `noticed ${negOne}!`)
|
|
||||||
onCleanup(() => {
|
|
||||||
cleanupSpy()
|
|
||||||
lazilyUpdatedMap.delete(-2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(lazilyUpdatedMap, 'The effects should run immediately after registration').toEqual(
|
|
||||||
new Map([
|
|
||||||
[0, 'a0'],
|
|
||||||
[100, 'b1'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key1.value = 1
|
|
||||||
expect(lazilyUpdatedMap, 'The effects should not perform any updates until flush').toEqual(
|
|
||||||
new Map([
|
|
||||||
[0, 'a0'],
|
|
||||||
[100, 'b1'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key1.value = 2
|
|
||||||
lazySet.flush()
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'A cleanup and update should run on flush, but only for the updated key',
|
|
||||||
).toEqual(
|
|
||||||
new Map([
|
|
||||||
[2, 'a2'],
|
|
||||||
[100, 'b1'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key1.value = 3
|
|
||||||
key2.value = 103
|
|
||||||
stopA()
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'Stop should immediately trigger cleanup, but only for stopped effect',
|
|
||||||
).toEqual(new Map([[100, 'b1']]))
|
|
||||||
|
|
||||||
lazySet.flush()
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'Flush should trigger remaining updates, but not run the stopped effects',
|
|
||||||
).toEqual(new Map([[103, 'b3']]))
|
|
||||||
|
|
||||||
key1.value = 4
|
|
||||||
key2.value = 104
|
|
||||||
lazySet.lazyEffect((onCleanup) => {
|
|
||||||
const currentValue = key1.value
|
|
||||||
lazilyUpdatedMap.set(currentValue, 'c' + runCount++)
|
|
||||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'Newly registered effect should run immediately, but not flush other effects',
|
|
||||||
).toEqual(
|
|
||||||
new Map([
|
|
||||||
[4, 'c4'],
|
|
||||||
[103, 'b3'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key1.value = 5
|
|
||||||
key2.value = 105
|
|
||||||
lazySet.flush()
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'Flush should trigger both effects when their dependencies change',
|
|
||||||
).toEqual(
|
|
||||||
new Map([
|
|
||||||
[105, 'b5'],
|
|
||||||
[5, 'c6'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
lazySet.flush()
|
|
||||||
expect(lazilyUpdatedMap, 'Flush should have no effect when no dependencies changed').toEqual(
|
|
||||||
new Map([
|
|
||||||
[105, 'b5'],
|
|
||||||
[5, 'c6'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key2.value = -1
|
|
||||||
lazySet.flush()
|
|
||||||
expect(
|
|
||||||
lazilyUpdatedMap,
|
|
||||||
'Effects depending on one another should run in the same flush',
|
|
||||||
).toEqual(
|
|
||||||
new Map([
|
|
||||||
[5, 'c6'],
|
|
||||||
[-1, 'b7'],
|
|
||||||
[-2, 'noticed b7!'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key2.value = 1
|
|
||||||
lazySet.flush()
|
|
||||||
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(lazilyUpdatedMap, 'Dependant effect is cleaned up.').toEqual(
|
|
||||||
new Map([
|
|
||||||
[1, 'b8'],
|
|
||||||
[5, 'c6'],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
key2.value = 2
|
|
||||||
lazySet.flush()
|
|
||||||
key2.value = -1
|
|
||||||
lazySet.flush()
|
|
||||||
expect(cleanupSpy, 'Cleanup runs only once.').toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@ -9,11 +9,27 @@ export class Rect {
|
|||||||
readonly size: Vec2,
|
readonly size: Vec2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
static Zero = new Rect(Vec2.Zero, Vec2.Zero)
|
||||||
|
|
||||||
|
static XYWH(x: number, y: number, w: number, h: number): Rect {
|
||||||
|
return new Rect(new Vec2(x, y), new Vec2(w, h))
|
||||||
|
}
|
||||||
|
|
||||||
static FromBounds(left: number, top: number, right: number, bottom: number): Rect {
|
static FromBounds(left: number, top: number, right: number, bottom: number): Rect {
|
||||||
return new Rect(new Vec2(left, top), new Vec2(right - left, bottom - top))
|
return new Rect(new Vec2(left, top), new Vec2(right - left, bottom - top))
|
||||||
}
|
}
|
||||||
|
|
||||||
static Zero = new Rect(Vec2.Zero, Vec2.Zero)
|
static FromCenterSize(center: Vec2, size: Vec2): Rect {
|
||||||
|
return new Rect(center.addScaled(size, -0.5), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
static FromDomRect(domRect: DOMRect): Rect {
|
||||||
|
return new Rect(new Vec2(domRect.x, domRect.y), new Vec2(domRect.width, domRect.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetBy(offset: Vec2): Rect {
|
||||||
|
return new Rect(this.pos.add(offset), this.size)
|
||||||
|
}
|
||||||
|
|
||||||
get left(): number {
|
get left(): number {
|
||||||
return this.pos.x
|
return this.pos.x
|
||||||
|
@ -20,7 +20,8 @@ export function useSelection<T>(
|
|||||||
const initiallySelected = new Set<T>()
|
const initiallySelected = new Set<T>()
|
||||||
const selected = reactive(new Set<T>())
|
const selected = reactive(new Set<T>())
|
||||||
const hoveredNode = ref<ExprId>()
|
const hoveredNode = ref<ExprId>()
|
||||||
const hoveredExpr = ref<ExprId>()
|
const hoveredPorts = reactive(new Set<ExprId>())
|
||||||
|
const hoveredPort = computed(() => [...hoveredPorts].pop())
|
||||||
|
|
||||||
function readInitiallySelected() {
|
function readInitiallySelected() {
|
||||||
initiallySelected.clear()
|
initiallySelected.clear()
|
||||||
@ -129,9 +130,11 @@ export function useSelection<T>(
|
|||||||
isSelected: (element: T) => selected.has(element),
|
isSelected: (element: T) => selected.has(element),
|
||||||
handleSelectionOf,
|
handleSelectionOf,
|
||||||
hoveredNode,
|
hoveredNode,
|
||||||
hoveredExpr,
|
hoveredPort,
|
||||||
mouseHandler: selectionEventHandler,
|
mouseHandler: selectionEventHandler,
|
||||||
events: pointer.events,
|
events: pointer.events,
|
||||||
|
addHoveredPort: (port: ExprId) => hoveredPorts.add(port),
|
||||||
|
removeHoveredPort: (port: ExprId) => hoveredPorts.delete(port),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,10 @@ export class Vec2 {
|
|||||||
return new Vec2(arr[0], arr[1])
|
return new Vec2(arr[0], arr[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static FromDomPoint(point: DOMPoint): Vec2 {
|
||||||
|
return new Vec2(point.x, point.y)
|
||||||
|
}
|
||||||
|
|
||||||
equals(other: Vec2): boolean {
|
equals(other: Vec2): boolean {
|
||||||
return this.x === other.x && this.y === other.y
|
return this.x === other.x && this.y === other.y
|
||||||
}
|
}
|
||||||
@ -32,6 +36,10 @@ export class Vec2 {
|
|||||||
return dx * dx + dy * dy
|
return dx * dx + dy * dy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inverse(): Vec2 {
|
||||||
|
return new Vec2(-this.x, -this.y)
|
||||||
|
}
|
||||||
|
|
||||||
add(other: Vec2): Vec2 {
|
add(other: Vec2): Vec2 {
|
||||||
return new Vec2(this.x + other.x, this.y + other.y)
|
return new Vec2(this.x + other.x, this.y + other.y)
|
||||||
}
|
}
|
||||||
|
@ -396,6 +396,7 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
|||||||
},
|
},
|
||||||
(e) => {
|
(e) => {
|
||||||
console.error('Failed to apply edit:', e)
|
console.error('Failed to apply edit:', e)
|
||||||
|
|
||||||
// Try to recover by reloading the file. Drop the attempted updates, since applying them
|
// Try to recover by reloading the file. Drop the attempted updates, since applying them
|
||||||
// have failed.
|
// have failed.
|
||||||
this.changeState(LsSyncState.WriteError)
|
this.changeState(LsSyncState.WriteError)
|
||||||
|
@ -37,26 +37,29 @@ export // This export declaration must be broken up to satisfy the `require-jsdo
|
|||||||
function run(props: app.AppProps) {
|
function run(props: app.AppProps) {
|
||||||
const { logger, supportsDeepLinks } = props
|
const { logger, supportsDeepLinks } = props
|
||||||
logger.log('Starting authentication/dashboard UI.')
|
logger.log('Starting authentication/dashboard UI.')
|
||||||
sentry.init({
|
if (!detect.IS_DEV_MODE) {
|
||||||
dsn: 'https://0dc7cb80371f466ab88ed01739a7822f@o4504446218338304.ingest.sentry.io/4506070404300800',
|
sentry.init({
|
||||||
environment: config.ENVIRONMENT,
|
dsn: 'https://0dc7cb80371f466ab88ed01739a7822f@o4504446218338304.ingest.sentry.io/4506070404300800',
|
||||||
integrations: [
|
environment: config.ENVIRONMENT,
|
||||||
new sentry.BrowserTracing({
|
integrations: [
|
||||||
routingInstrumentation: sentry.reactRouterV6Instrumentation(
|
new sentry.BrowserTracing({
|
||||||
React.useEffect,
|
routingInstrumentation: sentry.reactRouterV6Instrumentation(
|
||||||
reactRouter.useLocation,
|
React.useEffect,
|
||||||
reactRouter.useNavigationType,
|
reactRouter.useLocation,
|
||||||
reactRouter.createRoutesFromChildren,
|
reactRouter.useNavigationType,
|
||||||
reactRouter.matchRoutes
|
reactRouter.createRoutesFromChildren,
|
||||||
),
|
reactRouter.matchRoutes
|
||||||
}),
|
),
|
||||||
new sentry.Replay(),
|
}),
|
||||||
],
|
new sentry.Replay(),
|
||||||
tracesSampleRate: SENTRY_SAMPLE_RATE,
|
],
|
||||||
tracePropagationTargets: [config.ACTIVE_CONFIG.apiUrl.split('//')[1] ?? ''],
|
tracesSampleRate: SENTRY_SAMPLE_RATE,
|
||||||
replaysSessionSampleRate: SENTRY_SAMPLE_RATE,
|
tracePropagationTargets: [config.ACTIVE_CONFIG.apiUrl.split('//')[1] ?? ''],
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysSessionSampleRate: SENTRY_SAMPLE_RATE,
|
||||||
})
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** The root element into which the authentication/dashboard app will be rendered. */
|
/** The root element into which the authentication/dashboard app will be rendered. */
|
||||||
const root = document.getElementById(ROOT_ELEMENT_ID)
|
const root = document.getElementById(ROOT_ELEMENT_ID)
|
||||||
if (root == null) {
|
if (root == null) {
|
||||||
|
Loading…
Reference in New Issue
Block a user