[GUI2] Selection and rendering of basic node widgets (#8253)

This commit is contained in:
Paweł Grabarz 2023-11-15 17:26:18 +01:00 committed by GitHub
parent f86e1b3aa5
commit febce5dad7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2593 additions and 1625 deletions

4
app/gui2/env.d.ts vendored
View File

@ -2,3 +2,7 @@
declare const PROJECT_MANAGER_URL: string
declare const RUNNING_VITEST: boolean
interface Document {
caretPositionFromPoint(x: number, y: number): { offsetNode: Node; offset: number } | null
}

View File

@ -7,7 +7,6 @@ import * as Y from 'yjs'
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
declare const brandExprId: unique symbol
export type ExprId = Uuid & { [brandExprId]: never }
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
export type VisualizationModule =
| { kind: 'Builtin' }
@ -230,12 +229,22 @@ export class IdMap {
if (!(isUuid(expr) && rangeBuffer instanceof Uint8Array)) return
const indices = this.modelToIndices(rangeBuffer)
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
}
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 {
return `${range[0].toString(16)}:${range[1].toString(16)}`
}

View File

@ -38,5 +38,6 @@ export const selectionMouseBindings = defineKeybinds('selection', {
})
export const nodeEditBindings = defineKeybinds('node-edit', {
selectAll: ['Mod+A'],
cancel: ['Escape'],
edit: ['Mod+PointerMain'],
})

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { useGraphStore, type Node } from '@/stores/graph'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { Highlighter } from '@/util/codemirror'
import { colorFromString } from '@/util/colors'
import { usePointer } from '@/util/events'
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 { qnJoin, tryQualifiedName } from '../util/qualifiedName'
import { unwrap } from '../util/result'
@ -55,21 +54,20 @@ watchEffect(() => {
hoverTooltip((ast, syn) => {
const dom = document.createElement('div')
const astSpan = ast.span()
let foundNode: Node | undefined
for (const node of graphStore.nodes.values()) {
let foundNode: ExprId | undefined
for (const [id, node] of graphStore.db.allNodes()) {
if (rangeEncloses(node.rootSpan.span(), astSpan)) {
foundNode = node
foundNode = id
break
}
}
const expressionInfo = foundNode
? projectStore.computedValueRegistry.getExpressionInfo(foundNode.rootSpan.astId)
: undefined
const expressionInfo = foundNode && graphStore.db.nodeExpressionInfo.lookup(foundNode)
const nodeColor = foundNode && graphStore.db.getNodeColorStyle(foundNode)
if (foundNode != null) {
dom
.appendChild(document.createElement('div'))
.appendChild(document.createTextNode(`AST ID: ${foundNode.rootSpan.astId}`))
.appendChild(document.createTextNode(`AST ID: ${foundNode}`))
}
if (expressionInfo != null) {
dom
@ -92,11 +90,9 @@ watchEffect(() => {
groupNode.appendChild(document.createTextNode('Group: '))
const groupNameNode = groupNode.appendChild(document.createElement('span'))
groupNameNode.appendChild(document.createTextNode(`${method.module}.${method.name}`))
groupNameNode.style.color =
suggestionEntry?.groupIndex != null
? `var(--group-color-${suggestionDbStore.groups[suggestionEntry.groupIndex]
?.name})`
: colorFromString(expressionInfo?.typename ?? 'Unknown')
if (nodeColor) {
groupNameNode.style.color = nodeColor
}
}
}
return { dom }

View File

@ -5,11 +5,12 @@ import { Filtering } from '@/components/ComponentBrowser/filtering'
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { useGraphStore } from '@/stores/graph.ts'
import { useGraphStore } from '@/stores/graph'
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 { useApproach } from '@/util/animation'
import { tryGetIndex } from '@/util/array'
import { useEvent, useResizeObserver } from '@/util/events'
import type { useNavigator } from '@/util/navigator'
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.
*/
function componentColor(component: Component): string {
const group = suggestionDbStore.groups[component.group ?? -1]
if (group) {
const name = group.name.replace(/\s/g, '-')
return `var(--group-color-${name})`
} else {
return 'var(--group-color-fallback)'
}
return groupColorStyle(tryGetIndex(suggestionDbStore.groups, component.group))
}
// === Highlight ===

View File

@ -1,3 +1,4 @@
import { GraphDb, mockNode } from '@/stores/graph/graphDatabase'
import {
makeCon,
makeLocal,
@ -8,6 +9,7 @@ import {
type SuggestionEntry,
} from '@/stores/suggestionDatabase/entry'
import { readAstSpan } from '@/util/ast'
import { ComputedValueRegistry } from '@/util/computedValueRegistry'
import type { ExprId } from 'shared/yjsModel'
import { expect, test } from 'vitest'
import { useComponentBrowserInput } from '../input'
@ -92,24 +94,18 @@ test.each([
) => {
const operator1Id: ExprId = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as ExprId
const operator2Id: ExprId = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as ExprId
const graphStoreMock = {
identDefinitions: new Map([
['operator1', operator1Id],
['operator2', operator2Id],
]),
}
const computedValueRegistryMock = {
getExpressionInfo(id: ExprId) {
if (id === operator1Id)
return {
typename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },
profilingInfo: [],
}
},
}
const input = useComponentBrowserInput(graphStoreMock, computedValueRegistryMock)
const computedValueRegistryMock = ComputedValueRegistry.Mock()
computedValueRegistryMock.db.set(operator1Id, {
typename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },
profilingInfo: [],
})
const mockGraphDb = GraphDb.Mock(computedValueRegistryMock)
mockGraphDb.nodes.set(operator1Id, mockNode('operator1', operator1Id))
mockGraphDb.nodes.set(operator2Id, mockNode('operator2', operator2Id))
const input = useComponentBrowserInput(mockGraphDb)
input.code.value = code
input.selection.value = { start: cursorPos, end: cursorPos }
const context = input.context.value
@ -256,10 +252,8 @@ test.each([
({ code, cursorPos, suggestion, expected, expectedCursorPos }) => {
cursorPos = cursorPos ?? code.length
expectedCursorPos = expectedCursorPos ?? expected.length
const input = useComponentBrowserInput(
{ identDefinitions: new Map() },
{ getExpressionInfo: (_id) => undefined },
)
const input = useComponentBrowserInput(GraphDb.Mock())
input.code.value = code
input.selection.value = { start: cursorPos, end: cursorPos }
input.applySuggestion(suggestion)

View File

@ -10,7 +10,7 @@ import { chain, map, range } from '@/util/iterable'
import { Rect } from '@/util/rect'
import { Vec2 } from '@/util/vec2'
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:
// 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)
}
function nonDictatedEnvironment(nodeRects: Iterable<Rect>): Environment {
return {
screenBounds,
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, {
describe('Non dictated placement', () => {
function nonDictatedEnvironment(nodeRects: Iterable<Rect>): Environment {
return {
screenBounds,
nodeRects,
selectedNodeRects,
get selectedNodeRects() {
return getSelectedNodeRects()
},
get mousePosition() {
return getMousePosition()
},
}).position,
nodeSize,
)
expect(newNodeRect.top, {
toString() {
return generateVueCodeForPreviousNodeDictatedPlacement(
newNodeRect,
}
}
test.each([
// === Miscellaneous tests ===
{ desc: 'Empty graph', nodes: [], pos: new Vec2(1050, 690) },
// === 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,
selectedNodeRects,
)
},
} as string).toBeGreaterThanOrEqual(Math.max(...selectedNodeRects.map((node) => node.bottom)))
for (const node of nodeRects) {
expect(node.intersects(newNodeRect), {
get mousePosition() {
return getMousePosition()
},
}).position,
nodeSize,
)
expect(newNodeRect.top, {
toString() {
return generateVueCodeForPreviousNodeDictatedPlacement(
newNodeRect,
@ -326,45 +385,58 @@ fcTest.prop({
selectedNodeRects,
)
},
} as string).toBe(false)
}
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
} as string).toBeGreaterThanOrEqual(Math.max(...selectedNodeRects.map((node) => node.bottom)))
for (const node of nodeRects) {
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({
x: fc.nat(1000),
y: fc.nat(1000),
})('Mouse dictated placement (prop testing)', ({ x, y }) => {
expect(
mouseDictatedPlacement(
nodeSize,
{
mousePosition: new Vec2(x, y),
get screenBounds() {
return getScreenBounds()
describe('Mouse dictated placement', () => {
fcTest.prop({
x: fc.nat(1000),
y: fc.nat(1000),
})('prop testing', ({ x, y }) => {
expect(
mouseDictatedPlacement(
nodeSize,
{
mousePosition: new Vec2(x, y),
get screenBounds() {
return getScreenBounds()
},
get nodeRects() {
return getNodeRects()
},
get selectedNodeRects() {
return getSelectedNodeRects()
},
},
get nodeRects() {
return getNodeRects()
{
get gap() {
return getGap()
},
},
get selectedNodeRects() {
return getSelectedNodeRects()
},
},
{
get gap() {
return getGap()
},
},
),
).toEqual<Placement>({
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
position: new Vec2(x - radius, y - radius),
),
).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()
})
// 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 ===

View File

@ -1,6 +1,6 @@
import type { Filter } from '@/components/ComponentBrowser/filtering'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import type { GraphDb } from '@/stores/graph/graphDatabase'
import {
SuggestionKind,
type SuggestionEntry,
@ -16,7 +16,6 @@ import {
} from '@/util/ast'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { GeneralOprApp } from '@/util/ast/opr'
import type { ExpressionInfo } from '@/util/computedValueRegistry'
import { MappedSet } from '@/util/containers'
import {
qnLastSegment,
@ -25,7 +24,7 @@ import {
tryQualifiedName,
type QualifiedName,
} from '@/util/qualifiedName'
import { IdMap, type ExprId } from 'shared/yjsModel'
import { IdMap } from 'shared/yjsModel'
import { computed, ref, type ComputedRef } from 'vue'
/** Input's editing context.
@ -53,12 +52,7 @@ export type EditingContext =
| { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number }
/** Component Browser Input Data */
export function useComponentBrowserInput(
graphStore: { identDefinitions: Map<string, ExprId> } = useGraphStore(),
computedValueRegistry: {
getExpressionInfo(id: ExprId): ExpressionInfo | undefined
} = useProjectStore().computedValueRegistry,
) {
export function useComponentBrowserInput(graphDb: GraphDb = useGraphStore().db) {
const code = ref('')
const selection = ref({ start: 0, end: 0 })
const ast = computed(() => parseEnso(code.value))
@ -165,9 +159,9 @@ export function useComponentBrowserInput(
if (accessOpr.apps.length > 1) return null
if (internalUsages.value.has(parsedTreeRange(accessOpr.lhs))) return { type: 'unknown' }
const ident = readAstSpan(accessOpr.lhs, code.value)
const definition = graphStore.identDefinitions.get(ident)
const definition = graphDb.getIdentDefiningNode(ident)
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' }
}

View File

@ -9,8 +9,9 @@ import DocsTags from '@/components/DocumentationPanel/DocsTags.vue'
import { HistoryStack } from '@/components/DocumentationPanel/history'
import type { Docs, FunctionDocs, Sections, TypeDocs } 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 { tryGetIndex } from '@/util/array'
import type { Icon as IconName } from '@/util/iconName'
import { type Opt } from '@/util/opt'
import type { QualifiedName } from '@/util/qualifiedName'
@ -56,18 +57,9 @@ const name = computed<Opt<QualifiedName>>(() => {
// === Breadcrumbs ===
const color = computed<string>(() => {
const id = props.selectedEntry
if (id) {
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 color = computed(() => {
const groupIndex = db.entries.get(props.selectedEntry)?.groupIndex
return groupColorStyle(tryGetIndex(db.groups, groupIndex))
})
const icon = computed<IconName>(() => {

View File

@ -7,16 +7,16 @@ import {
type Environment,
} from '@/components/ComponentBrowser/placement.ts'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import SelectionBrush from '@/components/SelectionBrush.vue'
import TopBar from '@/components/TopBar.vue'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection'
import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { colorFromString } from '@/util/colors'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/util/events'
import { Interaction } from '@/util/interaction'
import type { Rect } from '@/util/rect.ts'
import { Vec2 } from '@/util/vec2'
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 GraphEdges from './GraphEditor/GraphEdges.vue'
import GraphNodes from './GraphEditor/GraphNodes.vue'
import GraphMouse from './GraphMouse.vue'
const EXECUTION_MODES = ['design', 'live']
const viewportNode = ref<HTMLElement>()
const navigator = provideGraphNavigator(viewportNode)
const graphStore = useGraphStore()
const widgetRegistry = provideWidgetRegistry(graphStore.db)
widgetRegistry.loadBuiltins()
const projectStore = useProjectStore()
const componentBrowserVisible = ref(false)
const componentBrowserInputContent = ref('')
const componentBrowserPosition = ref(Vec2.Zero)
const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
const nodeSelection = provideGraphSelection(navigator, graphStore.nodeRects, {
onSelected(id) {
const node = graphStore.nodes.get(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)
}
graphStore.db.moveNodeToTop(id)
},
})
const interactionBindingsHandler = interactionBindings.handler({
cancel: () => interaction.handleCancel(),
click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e) : false),
})
const graphEditorSourceNode = computed(() => {
if (graphStore.editedNodeInfo != null) return undefined
return nodeSelection.selected.values().next().value
@ -56,6 +59,7 @@ const graphEditorSourceNode = computed(() => {
useEvent(window, 'keydown', (event) => {
interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event)
})
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
onMounted(() => viewportNode.value?.focus())
@ -70,7 +74,7 @@ const graphBindingsHandler = graphBindings.handler({
if (keyboardBusy()) return false
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
componentBrowserPosition.value = navigator.sceneMousePos
startNodeCreation()
interaction.setCurrent(new CreatingNode())
}
},
newNode() {
@ -92,6 +96,7 @@ const graphBindingsHandler = graphBindings.handler({
},
deselectAll() {
nodeSelection.deselectAll()
console.log('deselectAll')
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
@ -102,7 +107,7 @@ const graphBindingsHandler = graphBindings.handler({
graphStore.transact(() => {
const allVisible = set
.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) {
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.
function onPlayButtonPress() {
projectStore.lsRpcConnection.then(async () => {
@ -156,52 +147,15 @@ watch(
const groupColors = computed(() => {
const styles: { [key: string]: string } = {}
for (let group of suggestionDb.groups) {
const name = group.name.replace(/\s/g, '-')
let color = group.color ?? colorFromString(name)
styles[`--group-color-${name}`] = color
styles[groupColorVar(group)] = group.color ?? colorFromString(group.name.replace(/\w/g, '-'))
}
return styles
})
/// === Interaction Handling ===
/// The following code handles some ongoing user interactions within the graph editor. Interactions are used to create
/// 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
}
const editingNode: Interaction = {
cancel: () => (componentBrowserVisible.value = false),
}
interaction.setWhen(componentBrowserVisible, editingNode)
const placementEnvironment = computed(() => {
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.
/// 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
// 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() {
super()
// 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 targetPosition = mouseDictatedPlacement(
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) {
// A vertical gap between created nodes when multiple files were dropped together.
const MULTIPLE_FILES_GAP = 50
@ -293,7 +243,7 @@ function onComponentBrowserCommit(content: string) {
*
*/
function getNodeContent(id: ExprId): string {
const node = graphStore.nodes.get(id)
const node = graphStore.db.nodes.get(id)
if (node == null) return ''
return node.rootSpan.repr()
}
@ -303,7 +253,7 @@ watch(
() => graphStore.editedNodeInfo,
(editedInfo) => {
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 offset = new Vec2(20, 35)
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>
<template>
<!-- eslint-disable vue/attributes-order -->
<div
ref="viewportNode"
class="viewport"
class="GraphEditor"
:class="{ draggingEdge: graphStore.unconnectedEdge != null }"
:style="groupColors"
@click="graphBindingsHandler"
v-on.="navigator.events"
@ -329,10 +291,7 @@ watch(
@drop.prevent="handleFileDrop($event)"
>
<svg :viewBox="navigator.viewBox">
<GraphEdges
@startInteraction="setCurrentInteraction"
@endInteraction="abortCurrentInteraction"
/>
<GraphEdges />
</svg>
<div :style="{ transform: navigator.transform }" class="htmlLayer">
<GraphNodes />
@ -351,7 +310,7 @@ watch(
v-model:mode="projectStore.executionMode"
:title="projectStore.name"
:modes="EXECUTION_MODES"
:breadcrumbs="['main', 'ad_analytics']"
:breadcrumbs="breadcrumbs"
@breadcrumbClick="console.log(`breadcrumb #${$event + 1} clicked.`)"
@back="console.log('breadcrumbs \'back\' button clicked.')"
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
@ -362,22 +321,18 @@ watch(
<CodeEditor v-if="showCodeEditor" />
</Suspense>
</Transition>
<SelectionBrush
v-if="scaledMousePos"
:position="scaledMousePos"
:anchor="scaledSelectionAnchor"
:style="{ transform: navigator.prescaledTransform }"
/>
<GraphMouse />
</div>
</template>
<style scoped>
.viewport {
.GraphEditor {
position: relative;
contain: layout;
overflow: clip;
cursor: none;
--group-color-fallback: #006b8a;
--node-color-no-type: #596b81;
}
svg {

View File

@ -1,75 +1,89 @@
<script setup lang="ts">
import { injectGraphNavigator } from '@/providers/graphNavigator.ts'
import { injectGraphSelection } from '@/providers/graphSelection.ts'
import type { Edge } from '@/stores/graph'
import type { Rect } from '@/util/rect'
import { useGraphStore, type Edge } from '@/stores/graph'
import { assert } from '@/util/assert'
import { Rect } from '@/util/rect'
import { Vec2 } from '@/util/vec2'
import { clamp } from '@vueuse/core'
import type { ExprId } from 'shared/yjsModel'
import { computed, ref } from 'vue'
const selection = injectGraphSelection(true)
const navigator = injectGraphNavigator(true)
const graph = useGraphStore()
const props = defineProps<{
edge: Edge
nodeRects: Map<ExprId, Rect>
exprRects: Map<ExprId, Rect>
exprNodes: Map<ExprId, ExprId>
}>()
const emit = defineEmits<{
disconnectSource: []
disconnectTarget: []
}>()
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 =
props.edge.target ??
(selection?.hoveredNode != props.edge.source ? selection?.hoveredExpr : undefined)
if (targetExpr != null) {
const targetNodeId = props.exprNodes.get(targetExpr)
if (targetNodeId == null) return null
const targetNodeRect = props.nodeRects.get(targetNodeId)
const targetRect = props.exprRects.get(targetExpr)
if (targetRect == null || targetNodeRect == null) return null
return { pos: targetRect.center().add(targetNodeRect.pos), size: targetRect.size }
const targetExpr = computed(() => {
const setTarget = props.edge.target
// When the target is not set (i.e. edge is dragged), use the currently hovered over expression
// as the target, as long as it is not from the same node as the source.
if (setTarget == null && selection?.hoveredNode != null) {
if (selection.hoveredNode != props.edge.source) return selection.hoveredPort
}
return setTarget
})
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) {
return { pos: navigator?.sceneMousePos }
return new Rect(navigator.sceneMousePos, Vec2.Zero)
} else {
return null
}
})
const sourcePos = computed<PosMaybeSized | null>(() => {
const targetNode = props.edge.target != null ? props.exprNodes.get(props.edge.target) : undefined
const sourceNode =
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 }
const sourceRect = computed<Rect | null>(() => {
if (sourceNode.value != null) {
return graph.nodeRects.get(sourceNode.value) ?? null
} else if (navigator?.sceneMousePos != null) {
return { pos: navigator?.sceneMousePos }
return new Rect(navigator.sceneMousePos, Vec2.Zero)
} else {
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. */
type Inputs = {
/** 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. */
sourceSize: Vec2 | undefined
sourceSize: Vec2
/** The width and height of the port that the edge is attached to, if any. */
targetSize: Vec2 | undefined
/** The coordinates of the node input port that is the edge's destination, relative to the source position.
* The edge enters the port from above. */
targetSize: Vec2
/** The coordinates of the node input port that is the edge's destination, relative to the source
* position. The edge enters the port from above. */
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 = {
@ -79,7 +93,7 @@ type JunctionPoints = {
}
/** 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_CORNER_RADIUS = 16 // TODO (crate::component::node::CORNER_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
// edge will begin.
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 =
targetMaxAttachmentHeight != null
inputs.targetPortTopDistanceInNode != null
? {
target: inputs.targetOffset.addScaled(new Vec2(0.0, NODE_HEIGHT), 0.5),
length: targetMaxAttachmentHeight,
target: inputs.targetOffset.add(new Vec2(0, inputs.targetSize.y * -0.5)),
length: inputs.targetPortTopDistanceInNode,
}
: undefined
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 targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
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
// rising above the source.
let attachmentHeight =
targetMaxAttachmentHeight != null
? Math.min(targetMaxAttachmentHeight, Math.abs(inputs.targetOffset.y))
inputs.targetPortTopDistanceInNode != null
? Math.min(inputs.targetPortTopDistanceInNode, Math.abs(inputs.targetOffset.y))
: 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)
return {
points: [source, targetAttachment],
@ -270,7 +280,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
heightAdjustment = 0
}
if (j0x == null || j1x == null || heightAdjustment == null) return null
const attachmentHeight = targetMaxAttachmentHeight ?? 0
const attachmentHeight = inputs.targetPortTopDistanceInNode ?? 0
const top = Math.min(
inputs.targetOffset.y - MIN_APPROACH_HEIGHT - attachmentHeight + heightAdjustment,
0,
@ -351,13 +361,15 @@ function render(sourcePos: Vec2, elements: Element[]): string {
}
const currentJunctionPoints = computed(() => {
const target_ = targetPos.value
const source_ = sourcePos.value
if (target_ == null || source_ == null) return null
const inputs = {
targetOffset: target_.pos.sub(source_.pos),
sourceSize: source_.size,
targetSize: target_.size,
const target = targetRect.value
const targetNode = targetNodeRect.value
const source = sourceRect.value
if (target == null || source == null) return null
const inputs: Inputs = {
targetOffset: target.center().sub(source.center()),
sourceSize: source.size,
targetSize: target.size,
targetPortTopDistanceInNode: targetNode != null ? target.top - targetNode.top : undefined,
}
return junctionPoints(inputs)
})
@ -367,13 +379,13 @@ const basePath = computed(() => {
const jp = currentJunctionPoints.value
if (jp == null) return undefined
const { start, elements } = pathElements(jp)
const source_ = sourcePos.value
const source_ = sourceRect.value
if (source_ == null) return undefined
return render(source_.pos.add(start), elements)
return render(source_.center().add(start), elements)
})
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
})
@ -381,22 +393,10 @@ function lengthTo(pos: Vec2): number | undefined {
const path = base.value
if (path == null) return undefined
const totalLength = path.getTotalLength()
let precision = 16
let best: number | undefined
let bestDist: number | undefined = 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
let bestDist: number | undefined
const tryPos = (len: number) => {
const point = path.getPointAtLength(len)
const dist: number = pos.distanceSquared(new Vec2(point.x, point.y))
const dist = pos.distanceSquared(Vec2.FromDomPoint(path.getPointAtLength(len)))
if (bestDist == null || dist < bestDist) {
best = len
bestDist = dist
@ -404,7 +404,11 @@ function lengthTo(pos: Vec2): number | undefined {
}
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)
}
return best
@ -429,26 +433,28 @@ const activeStyle = computed(() => {
}
})
const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' }))
function click(_e: PointerEvent) {
if (base.value == null) return {}
if (navigator?.sceneMousePos == null) return {}
const length = base.value.getTotalLength()
let offset = lengthTo(navigator?.sceneMousePos)
if (offset == null) return {}
if (offset < length / 2) emit('disconnectTarget')
else emit('disconnectSource')
if (offset < length / 2) graph.disconnectTarget(props.edge)
else graph.disconnectSource(props.edge)
}
function arrowPosition(): Vec2 | undefined {
if (props.edge.source == null || props.edge.target == null) return
const points = currentJunctionPoints.value?.points
if (points == null || points.length < 3) return
const target = targetPos.value
const source = sourcePos.value
const target = targetRect.value
const source = sourceRect.value
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
return source.pos.add(points[1])
return source.center().add(points[1])
}
const arrowTransform = computed(() => {
@ -467,12 +473,13 @@ const arrowTransform = computed(() => {
@pointerenter="hovered = true"
@pointerleave="hovered = false"
/>
<path ref="base" :d="basePath" class="edge visible base" />
<path ref="base" :d="basePath" class="edge visible" :style="baseStyle" />
<polygon
v-if="arrowTransform"
:transform="arrowTransform"
points="0,-9.375 -9.375,9.375 9.375,9.375"
class="arrow visible"
:style="baseStyle"
/>
<path v-if="activePath" :d="activePath" class="edge visible active" :style="activeStyle" />
</template>
@ -481,26 +488,30 @@ const arrowTransform = computed(() => {
<style scoped>
.visible {
pointer-events: none;
--edge-color: color-mix(in oklab, var(--node-base-color) 85%, white 15%);
}
.arrow {
fill: tan;
}
.edge {
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 {
stroke-width: 14;
stroke: transparent;
}
.edge.visible {
stroke-width: 4;
}
.edge.visible.base {
stroke: tan;
}
.edge.visible.active {
stroke: red;
stroke-linecap: round;
}
.edge.visible.active {
stroke: rgba(255, 255, 255, 0.4);
}
</style>

View File

@ -1,84 +1,59 @@
<script setup lang="ts">
import GraphEdge from '@/components/GraphEditor/GraphEdge.vue'
import { injectGraphSelection } from '@/providers/graphSelection.ts'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { useGraphStore } from '@/stores/graph'
import { Interaction } from '@/util/interaction.ts'
import type { ExprId } from 'shared/yjsModel.ts'
import { watch } from 'vue'
const emit = defineEmits<{
startInteraction: [Interaction]
endInteraction: [Interaction]
}>()
const graphStore = useGraphStore()
const graph = useGraphStore()
const selection = injectGraphSelection(true)
const interaction = injectInteractionHandler()
class EditingEdge extends Interaction {
const editingEdge: Interaction = {
cancel() {
const target = graphStore.unconnectedEdge?.disconnectedEdgeTarget
graphStore.transact(() => {
const target = graph.unconnectedEdge?.disconnectedEdgeTarget
graph.transact(() => {
if (target != null) disconnectEdge(target)
graphStore.clearUnconnected()
graph.clearUnconnected()
})
}
},
click(_e: MouseEvent): boolean {
if (graphStore.unconnectedEdge == null) return false
const source = graphStore.unconnectedEdge.source ?? selection?.hoveredNode
const target = graphStore.unconnectedEdge.target ?? selection?.hoveredExpr
const targetNode = target != null ? graphStore.exprNodes.get(target) : undefined
graphStore.transact(() => {
if (graph.unconnectedEdge == null) return false
const source = graph.unconnectedEdge.source ?? selection?.hoveredNode
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
const targetNode = graph.db.getExpressionNodeId(target)
graph.transact(() => {
if (source != null && source != targetNode) {
if (target == null) {
if (graphStore.unconnectedEdge?.disconnectedEdgeTarget != null)
disconnectEdge(graphStore.unconnectedEdge.disconnectedEdgeTarget)
if (graph.unconnectedEdge?.disconnectedEdgeTarget != null)
disconnectEdge(graph.unconnectedEdge.disconnectedEdgeTarget)
createNodeFromEdgeDrop(source)
} else {
createEdge(source, target)
}
}
graphStore.clearUnconnected()
graph.clearUnconnected()
})
return true
}
},
}
const editingEdge = new EditingEdge()
interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge)
function disconnectEdge(target: ExprId) {
graphStore.setExpressionContent(target, '_')
graph.setExpressionContent(target, '_')
}
function createNodeFromEdgeDrop(source: ExprId) {
console.log(`TODO: createNodeFromEdgeDrop(${JSON.stringify(source)})`)
}
function createEdge(source: ExprId, target: ExprId) {
const sourceNode = graphStore.nodes.get(source)
const sourceNode = graph.db.getNode(source)
if (sourceNode == null) return
// 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.
}
watch(
() => graphStore.unconnectedEdge,
(edge) => {
if (edge != null) {
emit('startInteraction', editingEdge)
} else {
emit('endInteraction', editingEdge)
}
},
)
</script>
<template>
<GraphEdge
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)"
/>
<GraphEdge v-for="(edge, index) in graph.edges" :key="index" :edge="edge" />
</template>

View File

@ -2,23 +2,18 @@
import { nodeEditBindings } from '@/bindings'
import CircularMenu from '@/components/CircularMenu.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 { injectGraphSelection } from '@/providers/graphSelection'
import type { Node } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { useGraphStore, type Node } from '@/stores/graph'
import { useApproach } from '@/util/animation'
import { colorFromString } from '@/util/colors'
import { usePointer, useResizeObserver } from '@/util/events'
import { methodNameToIcon, typeNameToIcon } from '@/util/getIconName'
import type { Opt } from '@/util/opt'
import { qnJoin, tryQualifiedName } from '@/util/qualifiedName'
import { Rect } from '@/util/rect'
import { unwrap } from '@/util/result'
import { Vec2 } from '@/util/vec2'
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
import type { ContentRange, VisualizationIdentifier } from 'shared/yjsModel'
import { computed, ref, watch, watchEffect } from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300
const MAXIMUM_CLICK_DISTANCE_SQ = 50
@ -30,7 +25,6 @@ const props = defineProps<{
const emit = defineEmits<{
updateRect: [rect: Rect]
updateExprRect: [id: ExprId, rect: Rect]
updateContent: [updates: [range: ContentRange, content: string][]]
dragging: [offset: Vec2]
draggingCommited: []
@ -44,11 +38,14 @@ const emit = defineEmits<{
}>()
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 rootNode = ref<HTMLElement>()
const nodeSize = useResizeObserver(rootNode)
const editableRootNode = ref<HTMLElement>()
const menuVisible = ref(false)
const isSelected = computed(() => nodeSelection?.isSelected(nodeId.value) ?? false)
@ -60,8 +57,6 @@ const isAutoEvaluationDisabled = ref(false)
const isDocsVisible = ref(false)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
const projectStore = useProjectStore()
watchEffect(() => {
const size = nodeSize.value
if (!size.isZero()) {
@ -70,7 +65,11 @@ watchEffect(() => {
})
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(() => {
return {
@ -85,268 +84,6 @@ const transform = computed(() => {
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)
let startEvent: PointerEvent | null = null
let startPos = Vec2.Zero
@ -380,24 +117,11 @@ const dragPointer = usePointer((pos, event, type) => {
}
})
const suggestionDbStore = useSuggestionDbStore()
const expressionInfo = computed(() =>
projectStore.computedValueRegistry.getExpressionInfo(props.node.rootSpan.astId),
)
const expressionInfo = computed(() => graph.db.nodeExpressionInfo.lookup(nodeId.value))
const outputTypeName = computed(() => expressionInfo.value?.typename ?? 'Unknown')
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
const suggestionEntry = computed(() => {
const method = expressionInfo.value?.methodCall?.methodPointer
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 suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value))
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
const icon = computed(() => {
if (suggestionEntry.value?.iconName) {
return suggestionEntry.value.iconName
@ -411,14 +135,61 @@ const icon = computed(() => {
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) {
if (nodeSelection != null) nodeSelection.hoveredExpr = id
const nodeEditHandler = nodeEditBindings.handler({
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>
@ -431,6 +202,7 @@ function hoverExpr(id: ExprId | undefined) {
'--node-group-color': color,
}"
:class="{
edited: props.edited,
dragging: dragPointer.dragging,
selected: nodeSelection?.isSelected(nodeId),
visualizationVisible: isVisualizationVisible,
@ -458,21 +230,14 @@ function hoverExpr(id: ExprId | undefined) {
@setVisualizationId="emit('setVisualizationId', $event)"
@setVisualizationVisible="emit('setVisualizationVisible', $event)"
/>
<div class="node" v-on="dragPointer.events">
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon
><span
ref="editableRootNode"
spellcheck="false"
@beforeinput="editContent"
@keydown="editableKeydownHandler"
@pointerdown.stop.prevent="startEditingHandler"
@blur="projectStore.stopCapturingUndo()"
><NodeTree
:ast="node.rootSpan"
:nodeSpanStart="node.rootSpan.span()[0]"
@updateExprRect="updateExprRect"
@updateHoveredExpr="hoverExpr($event)"
/></span>
<div
class="node"
@pointerdown.capture="nodeEditHandler"
@keydown="nodeEditHandler"
v-on="dragPointer.events"
>
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon>
<NodeWidgetTree :ast="node.rootSpan" />
</div>
<svg class="bgPaths" :style="bgStyleVariables">
<rect class="bgFill" />
@ -480,11 +245,11 @@ function hoverExpr(id: ExprId | undefined) {
class="outputPortHoverArea"
@pointerenter="outputHovered = true"
@pointerleave="outputHovered = false"
@pointerdown="emit('outputPortAction')"
@pointerdown.stop.prevent="emit('outputPortAction')"
/>
<rect class="outputPort" />
<text class="outputTypeName">{{ outputTypeName }}</text>
</svg>
<div class="outputTypeName">{{ outputTypeName }}</div>
</div>
</template>
@ -499,7 +264,7 @@ function hoverExpr(id: ExprId | undefined) {
display: flex;
--output-port-max-width: 6px;
--output-port-overlap: 0.1px;
--output-port-overlap: 0.2px;
--output-port-hover-width: 8px;
}
.outputPort,
@ -540,6 +305,16 @@ function hoverExpr(id: ExprId | undefined) {
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 {
width: var(--node-width);
height: var(--node-height);
@ -549,22 +324,16 @@ function hoverExpr(id: ExprId | undefined) {
transition: fill 0.2s ease;
}
.bgPaths .bgPaths:hover {
opacity: 1;
}
.GraphNode {
--node-height: 32px;
--node-border-radius: 16px;
--node-group-color: #357ab9;
--node-color-primary: color-mix(
in oklab,
var(--node-group-color) 100%,
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%);
&.executionState-Unknown,
@ -580,6 +349,10 @@ function hoverExpr(id: ExprId | undefined) {
}
}
.GraphNode.edited {
display: none;
}
.node {
position: relative;
top: 0;
@ -591,11 +364,12 @@ function hoverExpr(id: ExprId | undefined) {
flex-direction: row;
align-items: center;
white-space: nowrap;
padding: 4px 8px;
padding: 4px;
z-index: 2;
transition: outline 0.2s ease;
outline: 0px solid transparent;
}
.GraphNode .selection {
position: absolute;
inset: calc(0px - var(--selected-node-border-width));
@ -653,6 +427,10 @@ function hoverExpr(id: ExprId | undefined) {
height: 24px;
display: inline-flex;
align-items: center;
& :deep(span) {
vertical-align: middle;
}
}
.container {
@ -663,27 +441,10 @@ function hoverExpr(id: ExprId | undefined) {
.grab-handle {
color: white;
margin-right: 10px;
margin: 0 4px;
}
.CircularMenu {
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>

View File

@ -45,15 +45,13 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
<template>
<GraphNode
v-for="[id, node] in graphStore.nodes"
v-show="id != graphStore.editedNodeInfo?.id"
v-for="[id, node] in graphStore.db.allNodes()"
:key="id"
:node="node"
:edited="false"
:edited="id === graphStore.editedNodeInfo?.id"
@update:edited="graphStore.setEditedNode(id, $event)"
@updateRect="graphStore.updateNodeRect(id, $event)"
@delete="graphStore.deleteNode(id)"
@updateExprRect="graphStore.updateExprRect"
@pointerenter="hoverNode(id)"
@pointerleave="hoverNode(undefined)"
@updateContent="updateNodeContent(id, $event)"

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -105,7 +105,7 @@ export function useDragging() {
function* draggedNodes(): Generator<[ExprId, DraggedNode]> {
const ids = selection?.isSelected(movedId) ? selection.selected : [movedId]
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 }]
}
}
@ -125,7 +125,7 @@ export function useDragging() {
const rects: Rect[] = []
for (const [id, { initialPos }] of this.draggedNodes) {
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))
}
const snap = this.grid.snappedMany(rects, DRAG_SNAP_THRESHOLD)
@ -161,7 +161,7 @@ export function useDragging() {
updateNodesPosition() {
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 was moved in other way than current dragging, we want to stop dragging it.
if (node.position.distanceSquared(dragged.currentPos) > 1.0) {

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -1,17 +1,20 @@
<script lang="ts">
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
export const name = 'Loading'
export const inputType = 'Any'
</script>
<script setup lang="ts">
import VisualizationContainer from '@/components/VisualizationContainer.vue'
const _props = defineProps<{ data: unknown }>()
</script>
<template>
<VisualizationContainer>
<div class="LoadingVisualization"></div>
<div class="LoadingVisualization">
<LoadingSpinner />
</div>
</VisualizationContainer>
</template>
@ -24,20 +27,4 @@ const _props = defineProps<{ data: unknown }>()
place-items: center;
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>

View File

@ -4,7 +4,11 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: boolean] }>()
</script>
<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>
</template>

View File

@ -1,17 +1,22 @@
<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'
const props = defineProps<{ modelValue: number; min: number; max: number }>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: number] }>()
const sliderNode = ref<HTMLElement>()
const dragPointer = usePointer((position) => {
if (sliderNode.value == null) {
const dragPointer = usePointer((position, event, eventType) => {
const slider = event.target
if (!(slider instanceof HTMLElement)) {
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 fraction = Math.max(0, Math.min(1, fractionRaw))
const newValue = props.min + Math.round(fraction * (props.max - props.min))
@ -27,24 +32,75 @@ const inputValue = computed({
return props.modelValue
},
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>
<template>
<div ref="sliderNode" class="Slider" v-on="dragPointer.events">
<div class="SliderWidget" v-on="dragPointer.events">
<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>
</template>
<style scoped>
.Slider {
.SliderWidget {
clip-path: inset(0 round var(--radius-full));
position: relative;
user-select: none;
display: flex;
justify-content: space-around;
background: var(--color-widget);
border-radius: var(--radius-full);
@ -68,10 +124,19 @@ const inputValue = computed({
font-weight: 800;
line-height: 171.5%;
height: 24px;
padding-top: 1px;
padding-bottom: 1px;
padding: 0px 4px;
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,

View 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)
})
})

View 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
}

View 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

View 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 })
},
)

View 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
}

View 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
}
}
}

View File

@ -1,12 +1,12 @@
import { GraphDb } from '@/stores/graph/graphDatabase'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { DEFAULT_VISUALIZATION_IDENTIFIER } from '@/stores/visualization'
import { Ast, AstExtended, childrenAstNodes, findAstWithRange, readAstSpan } from '@/util/ast'
import { useObserveYjs } from '@/util/crdt'
import type { Opt } from '@/util/opt'
import type { Rect } from '@/util/rect'
import { Vec2 } from '@/util/vec2'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import { defineStore } from 'pinia'
import type { StackItem } from 'shared/languageServerTypes'
import {
@ -18,15 +18,18 @@ import {
type VisualizationIdentifier,
type VisualizationMetadata,
} from 'shared/yjsModel'
import { computed, reactive, ref, watch } from 'vue'
import { computed, markRaw, reactive, ref, toRef, watch } from 'vue'
import * as Y from 'yjs'
export { type Node } from '@/stores/graph/graphDatabase'
export interface NodeEditInfo {
id: ExprId
range: ContentRange
}
export const useGraphStore = defineStore('graph', () => {
const proj = useProjectStore()
const suggestionDb = useSuggestionDbStore()
proj.setObservedFileName('Main.enso')
@ -34,8 +37,12 @@ export const useGraphStore = defineStore('graph', () => {
const metadata = computed(() => proj.module?.doc.metadata)
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 exprRects = reactive(new Map<ExprId, Rect>())
const editedNodeInfo = ref<NodeEditInfo>()
@ -70,8 +77,6 @@ export const useGraphStore = defineStore('graph', () => {
if (value != null) updateState()
})
const _ast = ref<Ast.Tree>()
function updateState() {
const module = proj.module
if (module == null) return
@ -93,25 +98,8 @@ export const useGraphStore = defineStore('graph', () => {
),
)
: undefined
const nodeIds = new Set<ExprId>()
if (methodAst) {
for (const nodeAst of methodAst.visit(getFunctionNodeExpressions)) {
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)
}
db.readFunctionAst(methodAst, (id) => meta.get(id))
}
})
}
@ -121,103 +109,28 @@ export const useGraphStore = defineStore('graph', () => {
for (const [id, op] of event.changes.keys) {
if (op.action === 'update' || op.action === 'add') {
const data = meta.get(id)
const node = nodes.get(id as ExprId)
const node = db.getNode(id as ExprId)
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() {
let ident: string
do {
ident = randomString()
} while (identDefinitions.has(ident))
} while (db.idents.hasValue(ident))
return ident
}
const edges = computed(() => {
const disconnectedEdgeTarget = unconnectedEdge.value?.disconnectedEdgeTarget
const edges = []
for (const [ident, usages] of identUsages) {
const source = identDefinitions.get(ident)
if (source == null) continue
for (const target of usages) {
if (target === disconnectedEdgeTarget) continue
for (const [target, sources] of db.connections.allReverse()) {
if (target === disconnectedEdgeTarget) continue
for (const source of sources) {
edges.push({ source, target })
}
}
@ -258,13 +171,13 @@ export const useGraphStore = defineStore('graph', () => {
}
function deleteNode(id: ExprId) {
const node = nodes.get(id)
const node = db.getNode(id)
if (node == null) return
proj.module?.deleteExpression(node.outerExprId)
}
function setNodeContent(id: ExprId, content: string) {
const node = nodes.get(id)
const node = db.getNode(id)
if (node == null) return
setExpressionContent(node.rootSpan.astId, content)
}
@ -282,13 +195,13 @@ export const useGraphStore = defineStore('graph', () => {
}
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
const node = nodes.get(nodeId)
const node = db.getNode(nodeId)
if (node == null) return
proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range)
}
function setNodePosition(nodeId: ExprId, position: Vec2) {
const node = nodes.get(nodeId)
const node = db.getNode(nodeId)
if (node == null) return
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>) {
const node = nodes.get(nodeId)
const node = db.getNode(nodeId)
if (node == null) return
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(vis, node.vis?.visible) })
}
function setNodeVisualizationVisible(nodeId: ExprId, visible: boolean) {
const node = nodes.get(nodeId)
const node = db.getNode(nodeId)
if (node == null) return
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) })
}
@ -327,8 +240,13 @@ export const useGraphStore = defineStore('graph', () => {
nodeRects.set(id, rect)
}
function updateExprRect(id: ExprId, rect: Rect) {
exprRects.set(id, rect)
function updateExprRect(id: ExprId, rect: Rect | undefined) {
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) {
@ -345,17 +263,13 @@ export const useGraphStore = defineStore('graph', () => {
}
function getNodeBinding(id: ExprId): string {
const node = nodes.get(id)
if (node == null) return ''
return node.binding
return db.nodes.get(id)?.binding ?? ''
}
return {
_ast,
transact,
nodes,
db: markRaw(db),
editedNodeInfo,
exprNodes,
unconnectedEdge,
edges,
nodeRects,
@ -364,8 +278,6 @@ export const useGraphStore = defineStore('graph', () => {
disconnectSource,
disconnectTarget,
clearUnconnected,
identDefinitions,
identUsages,
createNode,
deleteNode,
setNodeContent,
@ -386,34 +298,6 @@ function randomString() {
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. */
export type Edge = {
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 {
const doc = updatedIdMap.doc!
const rangeBuffer = updatedIdMap.get(id)

View File

@ -522,7 +522,7 @@ export const useProjectStore = defineStore('project', () => {
}
const executionContext = createExecutionContextForMain()
const computedValueRegistry = new ComputedValueRegistry(executionContext)
const computedValueRegistry = ComputedValueRegistry.WithExecutionContext(executionContext)
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection)
function useVisualizationData(
@ -566,7 +566,7 @@ export const useProjectStore = defineStore('project', () => {
projectModel,
contentRoots,
awareness: markRaw(awareness),
computedValueRegistry,
computedValueRegistry: markRaw(computedValueRegistry),
lsRpcConnection: markRaw(lsRpcConnection),
dataConnection: markRaw(dataConnection),
useVisualizationData,

View File

@ -7,7 +7,7 @@ import { type Opt } from '@/util/opt'
import { qnParent, type QualifiedName } from '@/util/qualifiedName'
import { defineStore } from 'pinia'
import { LanguageServer } from 'shared/languageServer'
import { reactive, ref, type Ref } from 'vue'
import { markRaw, ref, type Ref } from 'vue'
export class SuggestionDb {
_internal = new ReactiveDb<SuggestionId, SuggestionEntry>()
@ -25,11 +25,12 @@ export class SuggestionDb {
}
return []
})
set(id: SuggestionId, entry: SuggestionEntry): void {
this._internal.set(id, reactive(entry))
this._internal.set(id, entry)
}
get(id: SuggestionId): SuggestionEntry | undefined {
return this._internal.get(id)
get(id: SuggestionId | null | undefined): SuggestionEntry | undefined {
return id != null ? this._internal.get(id) : undefined
}
delete(id: SuggestionId): boolean {
return this._internal.delete(id)
@ -45,6 +46,19 @@ export interface Group {
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 {
queue: AsyncQueue<{ currentVersion: number }>
@ -85,7 +99,18 @@ class Synchronizer {
private setupUpdateHandler(lsRpc: LanguageServer) {
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
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(
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,
)
@ -113,6 +138,7 @@ class Synchronizer {
export const useSuggestionDbStore = defineStore('suggestionDatabase', () => {
const entries = new SuggestionDb()
const groups = ref<Group[]>([])
const _synchronizer = new Synchronizer(entries, groups)
return { entries, groups, _synchronizer }
return { entries: markRaw(entries), groups, _synchronizer }
})

View File

@ -386,8 +386,10 @@ export function applyUpdates(
const updateResult = applyUpdate(entries, update, groups)
if (!updateResult.ok) {
updateResult.error.log()
console.error(`Removing entry ${update.id}, because its state is unclear`)
entries.delete(update.id)
if (entries.get(update.id) != null) {
console.error(`Removing entry ${update.id}, because its state is unclear`)
entries.delete(update.id)
}
}
}
}

View 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),
)
})
})

View 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!'],
]),
)
})

View File

@ -117,3 +117,30 @@ export function useApproach(
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,
},
}
}

View File

@ -34,3 +34,8 @@ export function partitionPoint<T>(
}
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]
}

View File

@ -1,6 +1,10 @@
import * as Ast from '@/generated/ast'
import { Token, Tree } from '@/generated/ast'
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 { markRaw } from 'vue'
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
}
tokenTypeName(): (typeof Token.typeNames)[number] | null {
return Token.isInstance(this.inner) ? Token.typeNames[this.inner.type] : null
}
isToken<T extends Ast.Token.Type>(
type?: T,
): 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)
}
contentHash() {
return this.ctx.getHash(this)
}
children(): AstExtended<Tree | Token, HasIdMap>[] {
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> {
parsedCode: string
idMap: CondType<IdMap, HasIdMap>
contentHashes: Map<string, Uint8Array>
constructor(parsedCode: string, idMap: CondType<IdMap, HasIdMap>) {
this.parsedCode = parsedCode
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))
}
}
}),
),
)
}
}

View File

@ -6,7 +6,8 @@ import type {
MethodCall,
ProfilingInfo,
} from 'shared/languageServerTypes'
import { reactive } from 'vue'
import { markRaw } from 'vue'
import { ReactiveDb } from './database/reactiveDb'
export interface ExpressionInfo {
typename: string | undefined
@ -17,33 +18,46 @@ export interface ExpressionInfo {
/** This class holds the computed values that have been received from the language server. */
export class ComputedValueRegistry {
private expressionMap: Map<ExpressionId, ExpressionInfo>
public db: ReactiveDb<ExpressionId, ExpressionInfo> = new ReactiveDb()
private _updateHandler = this.processUpdates.bind(this)
private executionContext
private executionContext: ExecutionContext | undefined
constructor(executionContext: ExecutionContext) {
this.executionContext = executionContext
this.expressionMap = reactive(new Map())
private constructor() {
markRaw(this)
}
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[]) {
for (const update of updates) {
this.expressionMap.set(update.expressionId, {
typename: update.type,
methodCall: update.methodCall,
payload: update.payload,
profilingInfo: update.profilingInfo,
})
const info = this.db.get(update.expressionId)
this.db.set(update.expressionId, combineInfo(info, update))
}
}
getExpressionInfo(exprId: ExpressionId): ExpressionInfo | undefined {
return this.expressionMap.get(exprId)
return this.db.get(exprId)
}
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,
}
}

View File

@ -24,12 +24,17 @@ test('Indexing is efficient', () => {
db.set(2, reactive({ name: 'xyz' }))
db.set(3, reactive({ name: 'abc' }))
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(removing).toHaveBeenCalledTimes(1)
db.set(1, { name: 'qdr' })
expect(adding).toHaveBeenCalledTimes(4)
expect(removing).toHaveBeenCalledTimes(2)
db.get(3)!.name = 'xyz'
index.lookup('x')
expect(adding).toHaveBeenCalledTimes(4)
expect(removing).toHaveBeenCalledTimes(2)
expect(index.lookup('qdr')).toEqual(new Set([1]))
@ -41,9 +46,10 @@ test('Error reported when indexer implementation returns non-unique pairs', () =
console.error = () => {}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// Invalid index
new ReactiveIndex(db, (_id, _entry) => [[1, 1]])
const index = new ReactiveIndex(db, (_id, _entry) => [[1, 1]])
db.set(1, 1)
db.set(2, 2)
index.lookup(1)
expect(consoleError).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith(
'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(adding).toHaveBeenCalledTimes(2)
expect(removing).toHaveBeenCalledTimes(0)
expect(lookupQn).toHaveBeenCalledTimes(6)
expect(lookupQn).toHaveBeenCalledTimes(5)
db.delete(3)
await nextTick()
@ -109,5 +115,5 @@ test('Parent index', async () => {
expect(parent.reverseLookup(2)).toEqual(new Set([1]))
expect(adding).toHaveBeenCalledTimes(2)
expect(removing).toHaveBeenCalledTimes(1)
expect(lookupQn).toHaveBeenCalledTimes(6)
expect(lookupQn).toHaveBeenCalledTimes(5)
})

View File

@ -9,9 +9,11 @@
*/
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 { reactive } from 'vue'
import * as set from 'lib0/set'
import { computed, reactive, type ComputedRef, type DebuggerOptions } from 'vue'
export type OnDelete = (cleanupFn: () => void) => void
@ -31,8 +33,8 @@ export class ReactiveDb<K, V> extends ObservableV2<{
constructor() {
super()
this._internal = new Map()
this.onDelete = new Map()
this._internal = reactive(map.create())
this.onDelete = map.create()
}
/**
@ -47,11 +49,12 @@ export class ReactiveDb<K, V> extends ObservableV2<{
this.delete(key)
this._internal.set(key, value)
const reactiveValue = this._internal.get(key) as V
const onDelete: OnDelete = (callback) => {
const callbacks = setIfUndefined(this.onDelete, key, () => new Set())
const callbacks = map.setIfUndefined(this.onDelete, key, set.create)
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> {
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,
* 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.
*/
constructor(db: ReactiveDb<K, V>, indexer: Indexer<K, V, IK, IV>) {
this.forward = reactive(new Map())
this.reverse = reactive(new Map())
this.forward = reactive(map.create())
this.reverse = reactive(map.create())
this.effects = new LazySyncEffectSet()
db.on('entryAdded', (key, value, onDelete) => {
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.
*/
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)) {
console.error(
`Attempt to repeatedly write the same key-value pair (${[
@ -179,10 +194,11 @@ export class ReactiveIndex<K, V, IK, IV> {
value,
]}) 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)
}
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.
* 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> {
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> {
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
}
}

View File

@ -139,7 +139,20 @@ export function useResizeObserver(
useContentRect = true,
): Ref<Vec2> {
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) => {
let rect: { width: number; height: number } | null = null
for (const entry of entries) {

View File

@ -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
}
}

View File

@ -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
const zoomPointer = usePointer((pos, _event, ty) => {
if (ty === 'start') {
@ -131,6 +146,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
prescaledTransform,
sceneMousePos,
clientToScenePos,
clientToSceneRect,
viewport,
})
}

View File

@ -5,8 +5,7 @@ import {
effect,
effectScope,
isRef,
reactive,
ref,
queuePostFlushCb,
type Ref,
type WatchSource,
} from 'vue'
@ -32,6 +31,7 @@ export type StopEffect = () => void
export class LazySyncEffectSet {
_dirtyRunners = new Set<() => void>()
_scope = effectScope()
_boundFlush = this.flush.bind(this)
/**
* 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)
},
{
lazy: true,
scheduler: () => {
if (this._dirtyRunners.size === 0) queuePostFlushCb(this._boundFlush)
this._dirtyRunners.add(runner)
},
onStop: () => {
@ -69,6 +71,7 @@ export class LazySyncEffectSet {
},
},
)
runner.effect.scheduler?.()
return () => runner.effect.stop()
}) ?? nop
)
@ -91,149 +94,3 @@ export class LazySyncEffectSet {
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)
})
}

View File

@ -9,11 +9,27 @@ export class Rect {
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 {
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 {
return this.pos.x

View File

@ -20,7 +20,8 @@ export function useSelection<T>(
const initiallySelected = new Set<T>()
const selected = reactive(new Set<T>())
const hoveredNode = ref<ExprId>()
const hoveredExpr = ref<ExprId>()
const hoveredPorts = reactive(new Set<ExprId>())
const hoveredPort = computed(() => [...hoveredPorts].pop())
function readInitiallySelected() {
initiallySelected.clear()
@ -129,9 +130,11 @@ export function useSelection<T>(
isSelected: (element: T) => selected.has(element),
handleSelectionOf,
hoveredNode,
hoveredExpr,
hoveredPort,
mouseHandler: selectionEventHandler,
events: pointer.events,
addHoveredPort: (port: ExprId) => hoveredPorts.add(port),
removeHoveredPort: (port: ExprId) => hoveredPorts.delete(port),
})
}

View File

@ -14,6 +14,10 @@ export class Vec2 {
return new Vec2(arr[0], arr[1])
}
static FromDomPoint(point: DOMPoint): Vec2 {
return new Vec2(point.x, point.y)
}
equals(other: Vec2): boolean {
return this.x === other.x && this.y === other.y
}
@ -32,6 +36,10 @@ export class Vec2 {
return dx * dx + dy * dy
}
inverse(): Vec2 {
return new Vec2(-this.x, -this.y)
}
add(other: Vec2): Vec2 {
return new Vec2(this.x + other.x, this.y + other.y)
}

View File

@ -396,6 +396,7 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
},
(e) => {
console.error('Failed to apply edit:', e)
// Try to recover by reloading the file. Drop the attempted updates, since applying them
// have failed.
this.changeState(LsSyncState.WriteError)

View File

@ -37,26 +37,29 @@ export // This export declaration must be broken up to satisfy the `require-jsdo
function run(props: app.AppProps) {
const { logger, supportsDeepLinks } = props
logger.log('Starting authentication/dashboard UI.')
sentry.init({
dsn: 'https://0dc7cb80371f466ab88ed01739a7822f@o4504446218338304.ingest.sentry.io/4506070404300800',
environment: config.ENVIRONMENT,
integrations: [
new sentry.BrowserTracing({
routingInstrumentation: sentry.reactRouterV6Instrumentation(
React.useEffect,
reactRouter.useLocation,
reactRouter.useNavigationType,
reactRouter.createRoutesFromChildren,
reactRouter.matchRoutes
),
}),
new sentry.Replay(),
],
tracesSampleRate: SENTRY_SAMPLE_RATE,
tracePropagationTargets: [config.ACTIVE_CONFIG.apiUrl.split('//')[1] ?? ''],
replaysSessionSampleRate: SENTRY_SAMPLE_RATE,
replaysOnErrorSampleRate: 1.0,
})
if (!detect.IS_DEV_MODE) {
sentry.init({
dsn: 'https://0dc7cb80371f466ab88ed01739a7822f@o4504446218338304.ingest.sentry.io/4506070404300800',
environment: config.ENVIRONMENT,
integrations: [
new sentry.BrowserTracing({
routingInstrumentation: sentry.reactRouterV6Instrumentation(
React.useEffect,
reactRouter.useLocation,
reactRouter.useNavigationType,
reactRouter.createRoutesFromChildren,
reactRouter.matchRoutes
),
}),
new sentry.Replay(),
],
tracesSampleRate: SENTRY_SAMPLE_RATE,
tracePropagationTargets: [config.ACTIVE_CONFIG.apiUrl.split('//')[1] ?? ''],
replaysSessionSampleRate: SENTRY_SAMPLE_RATE,
replaysOnErrorSampleRate: 1.0,
})
}
/** The root element into which the authentication/dashboard app will be rendered. */
const root = document.getElementById(ROOT_ELEMENT_ID)
if (root == null) {