mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
[GUI2] Selection and rendering of basic node widgets (#8253)
This commit is contained in:
parent
f86e1b3aa5
commit
febce5dad7
4
app/gui2/env.d.ts
vendored
4
app/gui2/env.d.ts
vendored
@ -2,3 +2,7 @@
|
||||
|
||||
declare const PROJECT_MANAGER_URL: string
|
||||
declare const RUNNING_VITEST: boolean
|
||||
|
||||
interface Document {
|
||||
caretPositionFromPoint(x: number, y: number): { offsetNode: Node; offset: number } | null
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import * as Y from 'yjs'
|
||||
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
||||
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)}`
|
||||
}
|
||||
|
@ -38,5 +38,6 @@ export const selectionMouseBindings = defineKeybinds('selection', {
|
||||
})
|
||||
|
||||
export const nodeEditBindings = defineKeybinds('node-edit', {
|
||||
selectAll: ['Mod+A'],
|
||||
cancel: ['Escape'],
|
||||
edit: ['Mod+PointerMain'],
|
||||
})
|
||||
|
@ -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 }
|
||||
|
@ -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 ===
|
||||
|
@ -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)
|
||||
|
@ -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 ===
|
||||
|
@ -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' }
|
||||
}
|
||||
|
||||
|
@ -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>(() => {
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)"
|
||||
|
@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Ast, type AstExtended } from '@/util/ast'
|
||||
import { useResizeObserver } from '@/util/events'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import type { ExprId } from 'shared/yjsModel'
|
||||
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ nodeSpanStart: number; ast: AstExtended<Ast.Token> }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateExprRect: [expr: ExprId, rect: Rect]
|
||||
}>()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode, false)
|
||||
const exprRect = shallowRef<Rect>()
|
||||
|
||||
const spanClass = computed(() => Ast.Token.typeNames[props.ast.inner.type])
|
||||
const whitespace = computed(() => ' '.repeat(props.ast.inner.whitespaceLengthInCodeBuffer))
|
||||
|
||||
function updateRect() {
|
||||
let domNode = rootNode.value
|
||||
if (domNode == null) return
|
||||
const pos = new Vec2(domNode.offsetLeft, domNode.offsetTop)
|
||||
const size = nodeSize.value
|
||||
const rect = new Rect(pos, size)
|
||||
if (exprRect.value != null && rect.equals(exprRect.value)) return
|
||||
exprRect.value = rect
|
||||
}
|
||||
|
||||
watch(nodeSize, updateRect)
|
||||
onUpdated(updateRect)
|
||||
watch(exprRect, (rect) => rect && emit('updateExprRect', props.ast.astId, rect))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="rootNode"
|
||||
:class="['Token', spanClass]"
|
||||
:data-span-start="props.ast.span()[0] - nodeSpanStart - whitespace.length"
|
||||
>{{ whitespace }}{{ props.ast.repr() }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Token {
|
||||
color: white;
|
||||
white-space: pre;
|
||||
align-items: center;
|
||||
color: rgb(255 255 255 / 0.33);
|
||||
|
||||
&.Ident,
|
||||
&.TextSection,
|
||||
&.Digits {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.TextSection,
|
||||
&.Digits {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,173 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import NodeToken from '@/components/GraphEditor/NodeToken.vue'
|
||||
import { Ast, type AstExtended } from '@/util/ast'
|
||||
import { useResizeObserver } from '@/util/events'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import type { ExprId } from 'shared/yjsModel'
|
||||
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ nodeSpanStart: number; ast: AstExtended<Ast.Tree> }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateExprRect: [expr: ExprId, rect: Rect]
|
||||
updateHoveredExpr: [id: ExprId | undefined]
|
||||
}>()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode, false)
|
||||
const exprRect = shallowRef<Rect>()
|
||||
|
||||
const spanClass = computed(() => Ast.Tree.typeNames[props.ast.inner.type])
|
||||
const children = computed(() => [...props.ast.children()])
|
||||
const isOnStart = computed(() => props.nodeSpanStart === props.ast.span()[0])
|
||||
const whitespace = computed(() =>
|
||||
isOnStart.value ? '' : ' '.repeat(props.ast.inner.whitespaceLengthInCodeParsed),
|
||||
)
|
||||
|
||||
const singularToken = computed(() =>
|
||||
whitespace.value.length === 0 && children.value.length === 1 && children.value[0]!.isToken()
|
||||
? children.value[0]
|
||||
: null,
|
||||
)
|
||||
|
||||
function updateRect() {
|
||||
let domNode = rootNode.value
|
||||
if (domNode == null) return
|
||||
const pos = new Vec2(domNode.offsetLeft, domNode.offsetTop)
|
||||
const size = nodeSize.value
|
||||
const rect = new Rect(pos, size)
|
||||
if (exprRect.value != null && rect.equals(exprRect.value)) return
|
||||
exprRect.value = rect
|
||||
}
|
||||
|
||||
watch(nodeSize, updateRect)
|
||||
onUpdated(updateRect)
|
||||
watch(exprRect, (rect) => rect && emit('updateExprRect', props.ast.astId, rect))
|
||||
|
||||
// Return whether this node should interact with the mouse, e.g. when seeking an edge target.
|
||||
function isHoverable(): boolean | 'tokensOnly' {
|
||||
switch (props.ast.treeTypeName()) {
|
||||
case 'Invalid':
|
||||
case 'BodyBlock':
|
||||
case 'Ident':
|
||||
case 'Number':
|
||||
case 'Wildcard':
|
||||
case 'TextLiteral':
|
||||
return true
|
||||
case 'DefaultApp':
|
||||
return 'tokensOnly'
|
||||
// Application should not be hoverable; typically their child nodes will be.
|
||||
case 'ArgumentBlockApplication':
|
||||
case 'OperatorBlockApplication':
|
||||
case 'OprApp':
|
||||
case 'UnaryOprApp':
|
||||
case 'MultiSegmentApp':
|
||||
case 'App':
|
||||
case 'NamedApp':
|
||||
return false
|
||||
// Other composite expressions.
|
||||
case 'Group':
|
||||
case 'TypeAnnotated':
|
||||
case 'CaseOf':
|
||||
case 'Lambda':
|
||||
case 'Array':
|
||||
case 'Tuple':
|
||||
case 'Documented':
|
||||
case 'OprSectionBoundary':
|
||||
case 'TemplateFunction':
|
||||
return false
|
||||
// Declarations; we won't generally display these within a node anyway.
|
||||
case 'Private':
|
||||
case 'TypeDef':
|
||||
case 'Assignment':
|
||||
case 'Function':
|
||||
case 'ForeignFunction':
|
||||
case 'Import':
|
||||
case 'Export':
|
||||
case 'TypeSignature':
|
||||
case 'Annotated':
|
||||
case 'AnnotatedBuiltin':
|
||||
case 'ConstructorDefinition':
|
||||
return false
|
||||
// Misc.
|
||||
case 'AutoScope':
|
||||
return false
|
||||
}
|
||||
console.log('Unexpected tree type', props.ast.treeTypeName())
|
||||
return true
|
||||
}
|
||||
|
||||
function hover(part: 'tree' | 'token', isHovered: boolean) {
|
||||
const hoverable = isHoverable()
|
||||
if (hoverable == true || (hoverable == 'tokensOnly' && part == 'token'))
|
||||
emit('updateHoveredExpr', isHovered ? props.ast.astId : undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NodeToken
|
||||
v-if="singularToken != null"
|
||||
:ast="singularToken"
|
||||
:nodeSpanStart="props.nodeSpanStart"
|
||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
||||
@pointerenter="hover('token', true)"
|
||||
@pointerleave="hover('token', false)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
ref="rootNode"
|
||||
:class="['Tree', spanClass]"
|
||||
:data-span-start="props.ast.span()[0] - nodeSpanStart - whitespace.length"
|
||||
>{{ whitespace
|
||||
}}<template v-for="child in children" :key="child.astId">
|
||||
<NodeTree
|
||||
v-if="child.isTree()"
|
||||
:ast="child"
|
||||
:nodeSpanStart="props.nodeSpanStart"
|
||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
||||
@updateHoveredExpr="emit('updateHoveredExpr', $event)"
|
||||
@pointerenter="hover('tree', true)"
|
||||
@pointerleave="hover('tree', false)"
|
||||
/>
|
||||
<NodeToken
|
||||
v-else-if="child.isToken()"
|
||||
:ast="child"
|
||||
:nodeSpanStart="props.nodeSpanStart"
|
||||
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)"
|
||||
@updateHoveredExpr="emit('updateHoveredExpr', $event)"
|
||||
@pointerenter="hover('token', true)"
|
||||
@pointerleave="hover('token', false)"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Tree {
|
||||
color: white;
|
||||
white-space: pre;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&.Root {
|
||||
color: rgb(255 255 255 / 0.33);
|
||||
}
|
||||
|
||||
&.Ident,
|
||||
&.Literal {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.Literal {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.port {
|
||||
background-color: var(--node-color-port);
|
||||
border-radius: var(--node-border-radius);
|
||||
margin: -2px -4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
64
app/gui2/src/components/GraphEditor/NodeWidget.vue
Normal file
64
app/gui2/src/components/GraphEditor/NodeWidget.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectWidgetRegistry,
|
||||
widgetAst,
|
||||
type WidgetConfiguration,
|
||||
type WidgetInput,
|
||||
} from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { injectWidgetUsageInfo, provideWidgetUsageInfo } from '@/providers/widgetUsageInfo'
|
||||
import { computed, proxyRefs, ref, toRef } from 'vue'
|
||||
|
||||
const props = defineProps<{ input: WidgetInput; nest?: boolean }>()
|
||||
|
||||
const registry = injectWidgetRegistry()
|
||||
const tree = injectWidgetTree()
|
||||
const parentUsageInfo = injectWidgetUsageInfo(true)
|
||||
const whitespace = computed(() =>
|
||||
parentUsageInfo?.input !== props.input
|
||||
? ' '.repeat(widgetAst(props.input)?.whitespaceLength() ?? 0)
|
||||
: '',
|
||||
)
|
||||
|
||||
// TODO: Fetch dynamic widget config from engine. [#8260]
|
||||
const dynamicConfig = ref<WidgetConfiguration>()
|
||||
const sameInputParentWidgets = computed(() =>
|
||||
parentUsageInfo?.input === props.input ? parentUsageInfo?.previouslyUsed : undefined,
|
||||
)
|
||||
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
|
||||
|
||||
const selectedWidget = computed(() => {
|
||||
return registry.select(
|
||||
{ input: props.input, config: dynamicConfig.value, nesting: nesting.value },
|
||||
sameInputParentWidgets.value,
|
||||
)
|
||||
})
|
||||
provideWidgetUsageInfo(
|
||||
proxyRefs({
|
||||
input: toRef(props, 'input'),
|
||||
previouslyUsed: computed(() => {
|
||||
const nextSameNodeWidgets = new Set(sameInputParentWidgets.value)
|
||||
if (selectedWidget.value != null) nextSameNodeWidgets.add(selectedWidget.value)
|
||||
return nextSameNodeWidgets
|
||||
}),
|
||||
nesting,
|
||||
}),
|
||||
)
|
||||
const spanStart = computed(() => {
|
||||
const ast = widgetAst(props.input)
|
||||
return ast && ast.span()[0] - tree.nodeSpanStart - whitespace.value.length
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ whitespace
|
||||
}}<component
|
||||
:is="selectedWidget"
|
||||
ref="rootNode"
|
||||
:input="props.input"
|
||||
:config="dynamicConfig"
|
||||
:nesting="nesting"
|
||||
:data-span-start="spanStart"
|
||||
:data-nesting="nesting"
|
||||
/>
|
||||
</template>
|
49
app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Normal file
49
app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { provideWidgetTree } from '@/providers/widgetTree'
|
||||
import { useTransitioning } from '@/util/animation'
|
||||
import type { AstExtended } from '@/util/ast'
|
||||
import { toRef } from 'vue'
|
||||
import NodeWidget from './NodeWidget.vue'
|
||||
|
||||
const props = defineProps<{ ast: AstExtended }>()
|
||||
|
||||
const observedLayoutTransitions = new Set([
|
||||
'margin-left',
|
||||
'margin-right',
|
||||
'margin-top',
|
||||
'margin-bottom',
|
||||
'padding-left',
|
||||
'padding-right',
|
||||
'padding-top',
|
||||
'padding-bottom',
|
||||
'width',
|
||||
'height',
|
||||
])
|
||||
|
||||
const layoutTransitions = useTransitioning(observedLayoutTransitions)
|
||||
provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
|
||||
<NodeWidget :input="ast" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.NodeWidgetTree {
|
||||
color: white;
|
||||
margin-left: 4px;
|
||||
&:has(.WidgetPort.newToConnect) {
|
||||
margin-left: calc(4px - var(--widget-port-extra-pad));
|
||||
}
|
||||
|
||||
&:has(.WidgetPort.newToConnect > .r-24:only-child) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.GraphEditor.draggingEdge .NodeWidgetTree {
|
||||
transition: margin 0.2s ease;
|
||||
}
|
||||
</style>
|
@ -105,7 +105,7 @@ export function useDragging() {
|
||||
function* draggedNodes(): Generator<[ExprId, DraggedNode]> {
|
||||
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) {
|
||||
|
37
app/gui2/src/components/GraphEditor/widgets/WidgetBlank.vue
Normal file
37
app/gui2/src/components/GraphEditor/widgets/WidgetBlank.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||
import { Ast } from '@/util/ast'
|
||||
const _props = defineProps<WidgetProps>()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 10,
|
||||
match: (props) =>
|
||||
widgetAst(props.input)?.isToken(Ast.Token.Type.Wildcard) ? Score.Good : Score.Mismatch,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="rootNode" class="WidgetBlank">_</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WidgetBlank {
|
||||
color: transparent;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 20px;
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
bottom: 0;
|
||||
background-color: var(--node-color-port);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue'
|
||||
import { Tree } from '@/generated/ast'
|
||||
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast, type AstExtended } from '@/util/ast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<WidgetProps>()
|
||||
const graph = useGraphStore()
|
||||
const value = computed({
|
||||
get() {
|
||||
return widgetAst(props.input)?.repr().endsWith('True') ?? false
|
||||
},
|
||||
set(value) {
|
||||
const ast = widgetAst(props.input)
|
||||
const node = ast && getRawBoolNode(ast)
|
||||
if (node != null) {
|
||||
graph.setExpressionContent(node.astId, value ? 'True' : 'False')
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<script lang="ts">
|
||||
function getRawBoolNode(ast: AstExtended) {
|
||||
const candidate =
|
||||
ast.isTree(Tree.Type.OprApp) && ast.repr().startsWith('Boolean.')
|
||||
? ast.tryMap((t) => t.rhs)
|
||||
: ast
|
||||
if (
|
||||
candidate &&
|
||||
candidate.isTree(Ast.Tree.Type.Ident) &&
|
||||
['True', 'False'].includes(candidate.repr())
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 10,
|
||||
match: (info) => {
|
||||
const ast = widgetAst(info.input)
|
||||
if (ast && getRawBoolNode(ast) != null) {
|
||||
return Score.Perfect
|
||||
}
|
||||
return Score.Mismatch
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<CheckboxWidget
|
||||
v-model="value"
|
||||
class="WidgetCheckbox"
|
||||
contenteditable="false"
|
||||
@beforeinput.stop
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||
import { Ast, AstExtended } from '@/util/ast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<WidgetProps>()
|
||||
|
||||
const spanClass = computed(() => widgetAst(props.input)?.treeTypeName())
|
||||
const children = computed(() => [...(widgetAst(props.input)?.children() ?? [])])
|
||||
|
||||
function shouldNest(child: AstExtended, index: number) {
|
||||
return widgetAst(props.input)!.isTree(Ast.Tree.Type.App) && !child.isTree(Ast.Tree.Type.App)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 1000,
|
||||
match: (info) => (widgetAst(info.input)?.isTree() ? Score.Good : Score.Mismatch),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['Tree', spanClass]"
|
||||
><NodeWidget
|
||||
v-for="(child, index) in children"
|
||||
:key="child.astId"
|
||||
:input="child"
|
||||
:nest="shouldNest(child, index)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Tree {
|
||||
white-space: pre;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease;
|
||||
min-height: 24px;
|
||||
display: inline-block;
|
||||
|
||||
&.Literal {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.port {
|
||||
background-color: var(--node-color-port);
|
||||
border-radius: var(--node-border-radius);
|
||||
margin: -2px -4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
49
app/gui2/src/components/GraphEditor/widgets/WidgetNumber.vue
Normal file
49
app/gui2/src/components/GraphEditor/widgets/WidgetNumber.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import SliderWidget from '@/components/widgets/SliderWidget.vue'
|
||||
import { Tree } from '@/generated/ast'
|
||||
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<WidgetProps>()
|
||||
const graph = useGraphStore()
|
||||
const value = computed({
|
||||
get() {
|
||||
return parseFloat(widgetAst(props.input)?.repr() ?? '')
|
||||
},
|
||||
set(value) {
|
||||
const id = widgetAst(props.input)?.astId
|
||||
if (id) graph.setExpressionContent(id, value.toString())
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<script lang="ts">
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 10,
|
||||
match: (info) => {
|
||||
const ast = widgetAst(info.input)
|
||||
if (!ast) return Score.Mismatch
|
||||
if (ast.isTree(Tree.Type.UnaryOprApp)) {
|
||||
if (
|
||||
ast.map((t) => t.opr).repr() === '-' &&
|
||||
ast.tryMap((t) => t.rhs)?.isTree(Tree.Type.Number)
|
||||
) {
|
||||
return Score.Perfect
|
||||
}
|
||||
} else if (ast.isTree(Tree.Type.Number)) {
|
||||
return Score.Perfect
|
||||
}
|
||||
return Score.Mismatch
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<SliderWidget v-model="value" class="WidgetNumber r-24" :min="-1000" :max="1000" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WidgetNumber {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
201
app/gui2/src/components/GraphEditor/widgets/WidgetPort.vue
Normal file
201
app/gui2/src/components/GraphEditor/widgets/WidgetPort.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import {
|
||||
Score,
|
||||
defineWidget,
|
||||
widgetAst,
|
||||
type WidgetInput,
|
||||
type WidgetProps,
|
||||
} from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import { useRaf } from '@/util/animation'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { useResizeObserver } from '@/util/events'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { uuidv4 } from 'lib0/random'
|
||||
import type { ExprId } from 'shared/yjsModel'
|
||||
import { computed, nextTick, onUpdated, ref, shallowRef, toRef, watch, watchEffect } from 'vue'
|
||||
|
||||
const graph = useGraphStore()
|
||||
const props = defineProps<WidgetProps>()
|
||||
const navigator = injectGraphNavigator()
|
||||
const tree = injectWidgetTree()
|
||||
const selection = injectGraphSelection(true)
|
||||
|
||||
const isHovered = ref(false)
|
||||
const hasConnection = computed(() => isConnected(props.input, graph.db))
|
||||
const isCurrentEdgeHoverTarget = computed(
|
||||
() => isHovered.value && graph.unconnectedEdge != null && selection?.hoveredPort === portId.value,
|
||||
)
|
||||
const connected = computed(() => hasConnection.value || isCurrentEdgeHoverTarget.value)
|
||||
|
||||
const rootNode = shallowRef<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode, false)
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
if (selection != null && isHovered.value === true) {
|
||||
const id = portId.value
|
||||
selection.addHoveredPort(id)
|
||||
onCleanup(() => selection.removeHoveredPort(id))
|
||||
}
|
||||
})
|
||||
|
||||
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
|
||||
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
|
||||
// do it when the node has any potential for being used as an edge source or target. This is true
|
||||
// when any of following conditions are met:
|
||||
// 1. The expression can be connected to and is currently being hovered.
|
||||
// 2. The expression is already used as an existing edge endpoint.
|
||||
//
|
||||
// TODO: This should part of the `WidgetPort` component. But first, we need to make sure that the
|
||||
// ports are always created when necessary.
|
||||
const portRect = shallowRef<Rect>()
|
||||
const rectUpdateIsUseful = computed(() => isHovered.value || hasConnection.value)
|
||||
|
||||
const randomUuid = uuidv4() as ExprId
|
||||
const portId = computed(() => widgetAst(props.input)?.astId ?? randomUuid)
|
||||
|
||||
watch(nodeSize, updateRect)
|
||||
onUpdated(() => {
|
||||
nextTick(updateRect)
|
||||
})
|
||||
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
||||
|
||||
watch(
|
||||
() => [portId.value, portRect.value, rectUpdateIsUseful.value] as const,
|
||||
([id, rect, updateUseful], _, onCleanup) => {
|
||||
if (id != null && rect != null && updateUseful) {
|
||||
graph.updateExprRect(id, rect)
|
||||
onCleanup(() => {
|
||||
if (portId.value === id && rect === portRect.value) graph.updateExprRect(id, undefined)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function updateRect() {
|
||||
let domNode = rootNode.value
|
||||
const rootDomNode = domNode?.closest('.node')
|
||||
if (domNode == null || rootDomNode == null) return
|
||||
|
||||
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
||||
const nodeClientRect = Rect.FromDomRect(rootDomNode.getBoundingClientRect())
|
||||
const exprSceneRect = navigator.clientToSceneRect(exprClientRect)
|
||||
const exprNodeRect = navigator.clientToSceneRect(nodeClientRect)
|
||||
const localRect = exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
|
||||
if (portRect.value != null && localRect.equals(portRect.value)) return
|
||||
portRect.value = localRect
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
function canBeConnectedTo(input: WidgetInput): boolean {
|
||||
const ast = widgetAst(input)
|
||||
if (ast == null) return true // placeholders are always connectable
|
||||
if (ast.isToken()) return false
|
||||
switch (ast.inner.type) {
|
||||
case Ast.Tree.Type.Invalid:
|
||||
case Ast.Tree.Type.BodyBlock:
|
||||
case Ast.Tree.Type.Ident:
|
||||
case Ast.Tree.Type.Group:
|
||||
case Ast.Tree.Type.Number:
|
||||
case Ast.Tree.Type.OprApp:
|
||||
case Ast.Tree.Type.UnaryOprApp:
|
||||
case Ast.Tree.Type.Wildcard:
|
||||
case Ast.Tree.Type.TextLiteral:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isConnected(input: WidgetInput, db: GraphDb) {
|
||||
const astId = widgetAst(input)?.astId
|
||||
return astId != null && db.connections.reverseLookup(astId).size > 0
|
||||
}
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 1,
|
||||
match: (info) => {
|
||||
if (canBeConnectedTo(info.input)) {
|
||||
return Score.Perfect
|
||||
}
|
||||
return Score.Mismatch
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<span
|
||||
ref="rootNode"
|
||||
class="WidgetPort"
|
||||
:class="{
|
||||
connected,
|
||||
'r-24': connected,
|
||||
newToConnect: !hasConnection && isCurrentEdgeHoverTarget,
|
||||
primary: props.nesting < 2,
|
||||
}"
|
||||
@pointerenter="isHovered = true"
|
||||
@pointerleave="isHovered = false"
|
||||
><NodeWidget :input="props.input"
|
||||
/></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(:root) {
|
||||
--widget-port-extra-pad: 6px;
|
||||
}
|
||||
|
||||
.WidgetPort {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--widget-port-extra-pad);
|
||||
margin: 0 calc(0px - var(--widget-port-extra-pad));
|
||||
transition:
|
||||
margin 0.2s ease,
|
||||
padding 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.WidgetPort:has(> .r-24:only-child) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.WidgetPort.connected {
|
||||
margin: 0;
|
||||
background-color: var(--node-color-port);
|
||||
}
|
||||
|
||||
.GraphEditor.draggingEdge .WidgetPort {
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
pointer-events: all;
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
inset: 4px var(--widget-port-extra-pad);
|
||||
}
|
||||
|
||||
/* Expand hover area for primary ports. */
|
||||
&.primary::before {
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&.connected::before {
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
42
app/gui2/src/components/GraphEditor/widgets/WidgetToken.vue
Normal file
42
app/gui2/src/components/GraphEditor/widgets/WidgetToken.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { Score, defineWidget, widgetAst, type WidgetProps } from '@/providers/widgetRegistry'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<WidgetProps>()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
|
||||
const spanClass = computed(() => widgetAst(props.input)?.tokenTypeName())
|
||||
const repr = computed(() => widgetAst(props.input)?.repr())
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const widgetDefinition = defineWidget({
|
||||
priority: 1000,
|
||||
match: (info) => (widgetAst(info.input)?.isToken() ? Score.Good : Score.Mismatch),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="rootNode" :class="['Token', spanClass]">{{ repr }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Token {
|
||||
color: white;
|
||||
white-space: pre;
|
||||
align-items: center;
|
||||
color: rgb(255 255 255 / 0.33);
|
||||
|
||||
&.Ident,
|
||||
&.TextSection,
|
||||
&.Digits {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.TextSection,
|
||||
&.Digits {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
20
app/gui2/src/components/GraphMouse.vue
Normal file
20
app/gui2/src/components/GraphMouse.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { computed } from 'vue'
|
||||
import SelectionBrush from './SelectionBrush.vue'
|
||||
|
||||
const navigator = injectGraphNavigator(true)
|
||||
const nodeSelection = injectGraphSelection(true)
|
||||
const scaledMousePos = computed(() => navigator?.sceneMousePos?.scale(navigator?.scale ?? 1))
|
||||
const scaledSelectionAnchor = computed(() => nodeSelection?.anchor?.scale(navigator?.scale ?? 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectionBrush
|
||||
v-if="scaledMousePos"
|
||||
:position="scaledMousePos"
|
||||
:anchor="scaledSelectionAnchor"
|
||||
:style="{ transform: navigator?.prescaledTransform }"
|
||||
/>
|
||||
</template>
|
32
app/gui2/src/components/LoadingSpinner.vue
Normal file
32
app/gui2/src/components/LoadingSpinner.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
size?: number
|
||||
}>()
|
||||
const dynStyle = computed(() => {
|
||||
const size = props.size ?? 30
|
||||
return {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="LoadingSpinner" :style="dynStyle"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.LoadingSpinner {
|
||||
border-radius: 50%;
|
||||
border: 4px solid;
|
||||
border-color: rgba(0, 0, 0, 30%) #0000;
|
||||
animation: s1 0.8s infinite;
|
||||
}
|
||||
@keyframes s1 {
|
||||
to {
|
||||
transform: rotate(0.5turn);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
99
app/gui2/src/providers/__tests__/widgetRegistry.test.ts
Normal file
99
app/gui2/src/providers/__tests__/widgetRegistry.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import { IdMap, type ExprId } from 'shared/yjsModel'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import {
|
||||
PlaceholderArgument,
|
||||
Score,
|
||||
WidgetRegistry,
|
||||
widgetArg,
|
||||
widgetAst,
|
||||
type WidgetDefinition,
|
||||
type WidgetModule,
|
||||
} from '../widgetRegistry'
|
||||
|
||||
describe('WidgetRegistry', () => {
|
||||
function makeMockWidget(name: string, widgetDefinition: WidgetDefinition): WidgetModule {
|
||||
return {
|
||||
default: defineComponent({ name }),
|
||||
widgetDefinition,
|
||||
}
|
||||
}
|
||||
|
||||
const widgetA = makeMockWidget('A', {
|
||||
priority: 1,
|
||||
match: (info) => (widgetAst(info.input) ? Score.Perfect : Score.Mismatch),
|
||||
})
|
||||
|
||||
const widgetB = makeMockWidget('B', {
|
||||
priority: 2,
|
||||
match: (info) => (widgetArg(info.input) ? Score.Perfect : Score.Mismatch),
|
||||
})
|
||||
|
||||
const widgetC = makeMockWidget('C', {
|
||||
priority: 10,
|
||||
match: () => Score.Good,
|
||||
})
|
||||
|
||||
const widgetD = makeMockWidget('D', {
|
||||
priority: 20,
|
||||
match: (info) => (widgetAst(info.input)?.repr() === '_' ? Score.Perfect : Score.Mismatch),
|
||||
})
|
||||
|
||||
const someAst = AstExtended.parse('foo', IdMap.Mock())
|
||||
const blankAst = AstExtended.parse('_', IdMap.Mock())
|
||||
const somePlaceholder = new PlaceholderArgument(
|
||||
'2095503f-c6b3-46e3-848a-be31360aab08' as ExprId,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
const mockGraphDb = GraphDb.Mock()
|
||||
const registry = new WidgetRegistry(mockGraphDb)
|
||||
registry.registerWidgetModule(widgetA)
|
||||
registry.registerWidgetModule(widgetB)
|
||||
registry.registerWidgetModule(widgetC)
|
||||
registry.registerWidgetModule(widgetD)
|
||||
|
||||
test('selects a widget based on the input type', () => {
|
||||
const forAst = registry.select({ input: someAst, config: undefined, nesting: 0 })
|
||||
const forArg = registry.select({ input: somePlaceholder, config: undefined, nesting: 0 })
|
||||
expect(forAst).toStrictEqual(widgetA.default)
|
||||
expect(forArg).toStrictEqual(widgetB.default)
|
||||
})
|
||||
|
||||
test('selects a widget outside of the excluded set', () => {
|
||||
const forAst = registry.select(
|
||||
{ input: someAst, config: undefined, nesting: 0 },
|
||||
new Set([widgetA.default]),
|
||||
)
|
||||
const forArg = registry.select(
|
||||
{ input: somePlaceholder, config: undefined, nesting: 0 },
|
||||
new Set([widgetB.default]),
|
||||
)
|
||||
expect(forAst).toStrictEqual(widgetC.default)
|
||||
expect(forArg).toStrictEqual(widgetC.default)
|
||||
})
|
||||
|
||||
test('returns undefined when all options are exhausted', () => {
|
||||
const selected = registry.select(
|
||||
{ input: someAst, config: undefined, nesting: 0 },
|
||||
new Set([widgetA.default, widgetC.default]),
|
||||
)
|
||||
expect(selected).to.be.undefined
|
||||
})
|
||||
|
||||
test('prefers low priority perfect over good high priority', () => {
|
||||
const selectedFirst = registry.select(
|
||||
{ input: blankAst, config: undefined, nesting: 0 },
|
||||
new Set([widgetA.default]),
|
||||
)
|
||||
const selectedNext = registry.select(
|
||||
{ input: blankAst, config: undefined, nesting: 0 },
|
||||
new Set([widgetA.default, widgetD.default]),
|
||||
)
|
||||
expect(selectedFirst).toStrictEqual(widgetD.default)
|
||||
expect(selectedNext).toStrictEqual(widgetC.default)
|
||||
})
|
||||
})
|
52
app/gui2/src/providers/interactionHandler.ts
Normal file
52
app/gui2/src/providers/interactionHandler.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { watch, type WatchSource } from 'vue'
|
||||
import { createContextStore } from '.'
|
||||
|
||||
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
||||
const { provideFn, injectFn } = createContextStore(
|
||||
'Interaction handler',
|
||||
() => new InteractionHandler(),
|
||||
)
|
||||
|
||||
export class InteractionHandler {
|
||||
private currentInteraction: Interaction | undefined = undefined
|
||||
|
||||
/** Automatically activate specified interaction any time a specified condition becomes true. */
|
||||
setWhen(active: WatchSource<boolean>, interaction: Interaction) {
|
||||
watch(active, (active) => {
|
||||
if (active) {
|
||||
this.setCurrent(interaction)
|
||||
} else {
|
||||
this.end(interaction)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setCurrent(interaction: Interaction | undefined) {
|
||||
if (interaction !== this.currentInteraction) {
|
||||
this.currentInteraction?.cancel?.()
|
||||
this.currentInteraction = interaction
|
||||
}
|
||||
}
|
||||
|
||||
/** Unset the current interaction, if it is the specified instance. */
|
||||
end(interaction: Interaction) {
|
||||
if (this.currentInteraction === interaction) {
|
||||
this.currentInteraction = undefined
|
||||
}
|
||||
}
|
||||
|
||||
handleCancel(): boolean {
|
||||
const hasCurrent = this.currentInteraction != null
|
||||
if (hasCurrent) this.setCurrent(undefined)
|
||||
return hasCurrent
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent): boolean | void {
|
||||
return this.currentInteraction?.click ? this.currentInteraction.click(event) : false
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interaction {
|
||||
cancel?(): void
|
||||
click?(event: MouseEvent): boolean | void
|
||||
}
|
197
app/gui2/src/providers/widgetRegistry.ts
Normal file
197
app/gui2/src/providers/widgetRegistry.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
|
||||
import type { ExprId } from 'shared/yjsModel'
|
||||
import { computed, shallowReactive, type Component } from 'vue'
|
||||
import { z } from 'zod'
|
||||
import { createContextStore } from '.'
|
||||
|
||||
export type WidgetComponent = Component<{ input: WidgetInput }>
|
||||
|
||||
/**
|
||||
* Information about an argument that doesn't have an assigned value yet, therefore are not
|
||||
* represented in the AST.
|
||||
*
|
||||
* TODO: Generate placeholders from suggestions and add support for them in various widgets. [#8257]
|
||||
*/
|
||||
export class PlaceholderArgument {
|
||||
/** The call expression tow which the placeholder is attached. */
|
||||
callExpression: ExprId
|
||||
/** The suggestion ID pointing to a method with a list of expected arguments. */
|
||||
methodId: SuggestionId
|
||||
/** The index of relevant argument in the suggestion entry. */
|
||||
index: number
|
||||
|
||||
constructor(callExpression: ExprId, methodId: SuggestionId, index: number) {
|
||||
this.callExpression = callExpression
|
||||
this.methodId = methodId
|
||||
this.index = index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type representing any kind of input that can have a widget attached to it. This can be either
|
||||
* an AST node, or a placeholder argument.
|
||||
*/
|
||||
export type WidgetInput = AstExtended | PlaceholderArgument
|
||||
|
||||
export function widgetAst(input: WidgetInput): AstExtended | undefined {
|
||||
return input instanceof AstExtended ? input : undefined
|
||||
}
|
||||
|
||||
export function widgetArg(input: WidgetInput): PlaceholderArgument | undefined {
|
||||
return input instanceof PlaceholderArgument ? input : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Description of how well a widget matches given input. Used to determine which widget should be
|
||||
* used, or whether the applied widget override is valid in given context.
|
||||
*/
|
||||
export enum Score {
|
||||
/**
|
||||
* This widget kind cannot accept the node. It will never be used, even if it was explicitly
|
||||
* requested using an override.
|
||||
*/
|
||||
Mismatch,
|
||||
/**
|
||||
* A bad, but syntactically valid match. Matching widget kind will only be used if it was
|
||||
* explicitly requested using an override. Should be the default choice for cases where
|
||||
* the node is syntactically valid in this widget's context, but no sensible defaults can
|
||||
* be inferred from context.
|
||||
*/
|
||||
OnlyOverride,
|
||||
/**
|
||||
* A good match, but there might be a better one. one. This widget will be used if there is no
|
||||
* better option.
|
||||
*/
|
||||
Good,
|
||||
/** Widget matches perfectly and can be used outright, without checking other kinds. */
|
||||
Perfect,
|
||||
}
|
||||
|
||||
export interface WidgetProps {
|
||||
input: WidgetInput
|
||||
config: WidgetConfiguration | undefined
|
||||
nesting: number
|
||||
}
|
||||
|
||||
export interface WidgetDefinition {
|
||||
/** The priority number determining the order in which the widgets are matched. Smaller numbers
|
||||
* have higher priority, and are matched first.
|
||||
*/
|
||||
priority: number
|
||||
/**
|
||||
* Score how well this widget type matches current {@link WidgetProps}, e.g. checking if AST node
|
||||
* or declaration type matches specific patterns. When this method returns
|
||||
* {@link Score::Mismatch}, this widget component will not be used, even if its type was requested
|
||||
* by an override. The override will be ignored and another best scoring widget will be used.
|
||||
*/
|
||||
match(info: WidgetProps, db: GraphDb): Score
|
||||
}
|
||||
|
||||
/**
|
||||
* An external configuration for a widget retreived from the language server.
|
||||
*
|
||||
* TODO: Actually implement reading dynamic widget configuration. [#8260]
|
||||
* The expected configuration type is defined as Enso type `Widget` in the following file:
|
||||
* distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso
|
||||
*/
|
||||
export type WidgetConfiguration = z.infer<typeof widgetConfigurationSchema>
|
||||
export const widgetConfigurationSchema = z.object({})
|
||||
|
||||
export interface WidgetModule {
|
||||
default: WidgetComponent
|
||||
widgetDefinition: WidgetDefinition
|
||||
}
|
||||
|
||||
function isWidgetModule(module: unknown): module is WidgetModule {
|
||||
return (
|
||||
typeof module === 'object' &&
|
||||
module !== null &&
|
||||
'default' in module &&
|
||||
'widgetDefinition' in module &&
|
||||
isWidgetComponent(module.default) &&
|
||||
isWidgetDefinition(module.widgetDefinition)
|
||||
)
|
||||
}
|
||||
|
||||
function isWidgetComponent(component: unknown): component is WidgetComponent {
|
||||
return typeof component === 'object' && component !== null && 'render' in component
|
||||
}
|
||||
|
||||
function isWidgetDefinition(config: unknown): config is WidgetDefinition {
|
||||
return typeof config === 'object' && config !== null && 'priority' in config && 'match' in config
|
||||
}
|
||||
|
||||
export function defineWidget(definition: WidgetDefinition): WidgetDefinition {
|
||||
return definition
|
||||
}
|
||||
|
||||
export { injectFn as injectWidgetRegistry, provideFn as provideWidgetRegistry }
|
||||
const { provideFn, injectFn } = createContextStore(
|
||||
'Widget registry',
|
||||
(db: GraphDb) => new WidgetRegistry(db),
|
||||
)
|
||||
|
||||
export class WidgetRegistry {
|
||||
loadedModules: WidgetModule[] = shallowReactive([])
|
||||
sortedModules = computed(() => {
|
||||
return [...this.loadedModules].sort(
|
||||
(a, b) => a.widgetDefinition.priority - b.widgetDefinition.priority,
|
||||
)
|
||||
})
|
||||
constructor(private db: GraphDb) {}
|
||||
|
||||
loadBuiltins() {
|
||||
const bulitinWidgets = import.meta.glob('@/components/GraphEditor/widgets/*.vue')
|
||||
for (const [path, asyncModule] of Object.entries(bulitinWidgets)) {
|
||||
this.loadAndCheckWidgetModule(asyncModule(), path)
|
||||
}
|
||||
}
|
||||
|
||||
async loadAndCheckWidgetModule(asyncModule: Promise<unknown>, path: string) {
|
||||
const m = await asyncModule
|
||||
if (isWidgetModule(m)) this.registerWidgetModule(m)
|
||||
else console.error('Invalid widget module:', path, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a known loaded widget module. Once registered, the widget will take part in widget
|
||||
* selection process. Caution: registering a new widget module will trigger re-matching of all
|
||||
* widgets on all nodes in the scene, which can be expensive.
|
||||
*/
|
||||
registerWidgetModule(module: WidgetModule) {
|
||||
this.loadedModules.push(module)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a type of widget to use for given widget input (e.g. AST node). The selection is based
|
||||
* on the widget's `match` function, which returns a score indicating how well the widget matches
|
||||
* the input. The widget with the highest score and priority is selected. Widget kinds that are
|
||||
* present in `alreadyUsed` set are always skipped.
|
||||
*/
|
||||
select(props: WidgetProps, alreadyUsed?: Set<WidgetComponent>): WidgetComponent | undefined {
|
||||
// The type and score of the best widget found so far.
|
||||
let best: WidgetComponent | undefined = undefined
|
||||
let bestScore = Score.Mismatch
|
||||
|
||||
// Iterate over all loaded widget kinds in order of decreasing priority.
|
||||
for (const module of this.sortedModules.value) {
|
||||
// Skip matching widgets that are declared as already used.
|
||||
if (alreadyUsed && alreadyUsed.has(module.default)) continue
|
||||
|
||||
// Perform a match and update the best widget if the match is better than the previous one.
|
||||
const score = module.widgetDefinition.match(props, this.db)
|
||||
// If we found a perfect match, we can return immediately, as there can be no better match.
|
||||
if (score === Score.Perfect) return module.default
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = module.default
|
||||
}
|
||||
}
|
||||
// Once we've checked all widgets, return the best match found, if any.
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add tests for select
|
13
app/gui2/src/providers/widgetTree.ts
Normal file
13
app/gui2/src/providers/widgetTree.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { AstExtended } from '@/util/ast'
|
||||
import { computed, proxyRefs, type Ref } from 'vue'
|
||||
import { createContextStore } from '.'
|
||||
|
||||
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
|
||||
const { provideFn, injectFn } = createContextStore(
|
||||
'Widget tree',
|
||||
(astRoot: Ref<AstExtended>, hasActiveAnimations: Ref<boolean>) => {
|
||||
const nodeId = computed(() => astRoot.value.astId)
|
||||
const nodeSpanStart = computed(() => astRoot.value.span()[0])
|
||||
return proxyRefs({ astRoot, nodeId, nodeSpanStart, hasActiveAnimations })
|
||||
},
|
||||
)
|
18
app/gui2/src/providers/widgetUsageInfo.ts
Normal file
18
app/gui2/src/providers/widgetUsageInfo.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { identity } from '@vueuse/core'
|
||||
import { createContextStore } from '.'
|
||||
import type { WidgetComponent, WidgetInput } from './widgetRegistry'
|
||||
|
||||
export { injectFn as injectWidgetUsageInfo, provideFn as provideWidgetUsageInfo }
|
||||
const { provideFn, injectFn } = createContextStore('Widget usage info', identity<WidgetUsageInfo>)
|
||||
|
||||
/**
|
||||
* Information about a widget that can be accessed in its child views. Currently this is used during
|
||||
* widget selection to prevent the same widget type from being rendered multiple times on the same
|
||||
* AST node.
|
||||
*/
|
||||
interface WidgetUsageInfo {
|
||||
input: WidgetInput
|
||||
/** All widget types that were rendered so far using the same AST node. */
|
||||
previouslyUsed: Set<WidgetComponent>
|
||||
nesting: number
|
||||
}
|
214
app/gui2/src/stores/graph/graphDatabase.ts
Normal file
214
app/gui2/src/stores/graph/graphDatabase.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDatabase'
|
||||
import { tryGetIndex } from '@/util/array'
|
||||
import { Ast, AstExtended } from '@/util/ast'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
import { ComputedValueRegistry, type ExpressionInfo } from '@/util/computedValueRegistry'
|
||||
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { qnJoin, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import * as set from 'lib0/set'
|
||||
import {
|
||||
IdMap,
|
||||
visMetadataEquals,
|
||||
type ExprId,
|
||||
type NodeMetadata,
|
||||
type VisualizationMetadata,
|
||||
} from 'shared/yjsModel'
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export class GraphDb {
|
||||
nodes = new ReactiveDb<ExprId, Node>()
|
||||
idents = new ReactiveIndex(this.nodes, (_id, entry) => {
|
||||
const idents: [ExprId, string][] = []
|
||||
entry.rootSpan.visitRecursive((span) => {
|
||||
if (span.isTree(Ast.Tree.Type.Ident)) {
|
||||
idents.push([span.astId, span.repr()])
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return idents
|
||||
})
|
||||
private nodeExpressions = new ReactiveIndex(this.nodes, (id, entry) => {
|
||||
const exprs = new Set<ExprId>()
|
||||
for (const ast of entry.rootSpan.walkRecursive()) {
|
||||
exprs.add(ast.astId)
|
||||
}
|
||||
return Array.from(exprs, (expr) => [id, expr])
|
||||
})
|
||||
nodeByBinding = new ReactiveIndex(this.nodes, (id, entry) => [[entry.binding, id]])
|
||||
connections = new ReactiveIndex(this.nodes, (id, entry) => {
|
||||
const usageEntries: [ExprId, ExprId][] = []
|
||||
const usages = this.idents.reverseLookup(entry.binding)
|
||||
for (const usage of usages) {
|
||||
usageEntries.push([id, usage])
|
||||
}
|
||||
return usageEntries
|
||||
})
|
||||
nodeExpressionInfo = new ReactiveMapping(this.nodes, (id, _entry) =>
|
||||
this.valuesRegistry.getExpressionInfo(id),
|
||||
)
|
||||
nodeMainSuggestion = new ReactiveMapping(this.nodes, (id, _entry) => {
|
||||
const expressionInfo = this.nodeExpressionInfo.lookup(id)
|
||||
const method = expressionInfo?.methodCall?.methodPointer
|
||||
if (method == null) return
|
||||
const moduleName = tryQualifiedName(method.definedOnType)
|
||||
const methodName = tryQualifiedName(method.name)
|
||||
if (!moduleName.ok || !methodName.ok) return
|
||||
const qualifiedName = qnJoin(moduleName.value, methodName.value)
|
||||
const [suggestionId] = this.suggestionDb.nameToId.lookup(qualifiedName)
|
||||
if (suggestionId == null) return
|
||||
return this.suggestionDb.get(suggestionId)
|
||||
})
|
||||
private nodeColors = new ReactiveMapping(this.nodes, (id, _entry) => {
|
||||
const index = this.nodeMainSuggestion.lookup(id)?.groupIndex
|
||||
const group = tryGetIndex(this.groups.value, index)
|
||||
if (group == null) {
|
||||
const typename = this.nodeExpressionInfo.lookup(id)?.typename
|
||||
return typename ? colorFromString(typename) : 'var(--node-color-no-type)'
|
||||
}
|
||||
return groupColorStyle(group)
|
||||
})
|
||||
|
||||
getNode(id: ExprId): Node | undefined {
|
||||
return this.nodes.get(id)
|
||||
}
|
||||
|
||||
allNodes(): IterableIterator<[ExprId, Node]> {
|
||||
return this.nodes.entries()
|
||||
}
|
||||
|
||||
allNodeIds(): IterableIterator<ExprId> {
|
||||
return this.nodes.keys()
|
||||
}
|
||||
|
||||
getExpressionNodeId(exprId: ExprId | undefined): ExprId | undefined {
|
||||
return exprId && set.first(this.nodeExpressions.reverseLookup(exprId))
|
||||
}
|
||||
|
||||
getIdentDefiningNode(ident: string): ExprId | undefined {
|
||||
return set.first(this.nodeByBinding.lookup(ident))
|
||||
}
|
||||
|
||||
getExpressionInfo(id: ExprId): ExpressionInfo | undefined {
|
||||
return this.valuesRegistry.getExpressionInfo(id)
|
||||
}
|
||||
|
||||
getNodeColorStyle(id: ExprId): string {
|
||||
return (id && this.nodeColors.lookup(id)) ?? 'var(--node-color-no-type)'
|
||||
}
|
||||
|
||||
moveNodeToTop(id: ExprId) {
|
||||
this.nodes.moveToLast(id)
|
||||
}
|
||||
|
||||
readFunctionAst(
|
||||
functionAst: AstExtended<Ast.Tree.Function>,
|
||||
getMeta: (id: ExprId) => NodeMetadata | undefined,
|
||||
) {
|
||||
const currentNodeIds = new Set<ExprId>()
|
||||
if (functionAst) {
|
||||
for (const nodeAst of functionAst.visit(getFunctionNodeExpressions)) {
|
||||
const newNode = nodeFromAst(nodeAst)
|
||||
const nodeId = newNode.rootSpan.astId
|
||||
const node = this.nodes.get(nodeId)
|
||||
const nodeMeta = getMeta(nodeId)
|
||||
currentNodeIds.add(nodeId)
|
||||
if (node == null) {
|
||||
this.nodes.set(nodeId, newNode)
|
||||
if (nodeMeta) this.assignUpdatedMetadata(newNode, nodeMeta)
|
||||
} else {
|
||||
if (node.binding !== newNode.binding) {
|
||||
node.binding = newNode.binding
|
||||
}
|
||||
if (node.outerExprId !== newNode.outerExprId) {
|
||||
node.outerExprId = newNode.outerExprId
|
||||
}
|
||||
if (indexedDB.cmp(node.rootSpan.contentHash(), newNode.rootSpan.contentHash()) !== 0) {
|
||||
node.rootSpan = newNode.rootSpan
|
||||
}
|
||||
if (nodeMeta) this.assignUpdatedMetadata(node, nodeMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of this.allNodeIds()) {
|
||||
if (!currentNodeIds.has(nodeId)) {
|
||||
this.nodes.delete(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
|
||||
const newPosition = new Vec2(meta.x, -meta.y)
|
||||
if (!node.position.equals(newPosition)) {
|
||||
node.position = newPosition
|
||||
}
|
||||
if (!visMetadataEquals(node.vis, meta.vis)) {
|
||||
node.vis = meta.vis
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
private suggestionDb: SuggestionDb,
|
||||
private groups: Ref<Group[]>,
|
||||
private valuesRegistry: ComputedValueRegistry,
|
||||
) {}
|
||||
|
||||
static Mock(registry = ComputedValueRegistry.Mock()): GraphDb {
|
||||
return new GraphDb(new SuggestionDb(), ref([]), registry)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
outerExprId: ExprId
|
||||
binding: string
|
||||
rootSpan: AstExtended<Ast.Tree>
|
||||
position: Vec2
|
||||
vis: Opt<VisualizationMetadata>
|
||||
}
|
||||
|
||||
export function mockNode(binding: string, id: ExprId, code?: string): Node {
|
||||
return {
|
||||
outerExprId: id,
|
||||
binding,
|
||||
rootSpan: AstExtended.parse(code ?? '0', IdMap.Mock()),
|
||||
position: Vec2.Zero,
|
||||
vis: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function nodeFromAst(ast: AstExtended<Ast.Tree>): Node {
|
||||
if (ast.isTree(Ast.Tree.Type.Assignment)) {
|
||||
return {
|
||||
outerExprId: ast.astId,
|
||||
binding: ast.map((t) => t.pattern).repr(),
|
||||
rootSpan: ast.map((t) => t.expr),
|
||||
position: Vec2.Zero,
|
||||
vis: undefined,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
outerExprId: ast.astId,
|
||||
binding: '',
|
||||
rootSpan: ast,
|
||||
position: Vec2.Zero,
|
||||
vis: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator<Ast.Tree> {
|
||||
if (func.body) {
|
||||
if (func.body.type === Ast.Tree.Type.BodyBlock) {
|
||||
for (const stmt of func.body.statements) {
|
||||
if (stmt.expression && stmt.expression.type !== Ast.Tree.Type.Function) {
|
||||
yield stmt.expression
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yield func.body
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { 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)
|
@ -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,
|
||||
|
@ -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 }
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
65
app/gui2/src/util/__tests__/navigator.test.ts
Normal file
65
app/gui2/src/util/__tests__/navigator.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useNavigator } from '@/util/navigator'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
describe('useNavigator', async () => {
|
||||
let scope = effectScope()
|
||||
beforeEach(() => {
|
||||
scope = effectScope()
|
||||
})
|
||||
afterEach(() => scope.stop())
|
||||
|
||||
function makeTestNavigator() {
|
||||
return scope.run(() => {
|
||||
const node = document.createElement('div')
|
||||
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(new DOMRect(150, 150, 800, 400))
|
||||
const viewportNode = ref(node)
|
||||
return useNavigator(viewportNode)
|
||||
})!
|
||||
}
|
||||
|
||||
test('initializes with centered non-zoomed viewport', () => {
|
||||
const navigator = makeTestNavigator()
|
||||
expect(navigator.viewport).toStrictEqual(Rect.FromBounds(-400, -200, 400, 200))
|
||||
})
|
||||
|
||||
test('clientToScenePos without scaling', () => {
|
||||
const navigator = makeTestNavigator()
|
||||
expect(navigator.clientToScenePos(Vec2.Zero)).toStrictEqual(new Vec2(-550, -350))
|
||||
expect(navigator.clientToScenePos(new Vec2(150, 150))).toStrictEqual(new Vec2(-400, -200))
|
||||
expect(navigator.clientToScenePos(new Vec2(550, 350))).toStrictEqual(new Vec2(0, 0))
|
||||
})
|
||||
|
||||
test('clientToScenePos with scaling', () => {
|
||||
const navigator = makeTestNavigator()
|
||||
navigator.scale = 2
|
||||
expect(navigator.clientToScenePos(Vec2.Zero)).toStrictEqual(new Vec2(-275, -175))
|
||||
expect(navigator.clientToScenePos(new Vec2(150, 150))).toStrictEqual(new Vec2(-200, -100))
|
||||
expect(navigator.clientToScenePos(new Vec2(550, 350))).toStrictEqual(new Vec2(0, 0))
|
||||
})
|
||||
|
||||
test('clientToSceneRect without scaling', () => {
|
||||
const navigator = makeTestNavigator()
|
||||
expect(navigator.clientToSceneRect(Rect.Zero)).toStrictEqual(Rect.XYWH(-550, -350, 0, 0))
|
||||
expect(navigator.clientToSceneRect(Rect.XYWH(150, 150, 800, 400))).toStrictEqual(
|
||||
navigator.viewport,
|
||||
)
|
||||
expect(navigator.clientToSceneRect(Rect.XYWH(100, 150, 200, 900))).toStrictEqual(
|
||||
Rect.XYWH(-450, -200, 200, 900),
|
||||
)
|
||||
})
|
||||
|
||||
test('clientToSceneRect with scaling', () => {
|
||||
const navigator = makeTestNavigator()
|
||||
navigator.scale = 2
|
||||
expect(navigator.clientToSceneRect(Rect.Zero)).toStrictEqual(Rect.XYWH(-275, -175, 0, 0))
|
||||
expect(navigator.clientToSceneRect(Rect.XYWH(150, 150, 800, 400))).toStrictEqual(
|
||||
navigator.viewport,
|
||||
)
|
||||
expect(navigator.clientToSceneRect(Rect.XYWH(100, 150, 200, 900))).toStrictEqual(
|
||||
Rect.XYWH(-225, -100, 100, 450),
|
||||
)
|
||||
})
|
||||
})
|
145
app/gui2/src/util/__tests__/reactivity.test.ts
Normal file
145
app/gui2/src/util/__tests__/reactivity.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import { LazySyncEffectSet } from '../reactivity'
|
||||
|
||||
test('LazySyncEffectSet', async () => {
|
||||
const lazySet = new LazySyncEffectSet()
|
||||
|
||||
const key1 = ref(0)
|
||||
const key2 = ref(100)
|
||||
const lazilyUpdatedMap = reactive(new Map<number, string>())
|
||||
|
||||
let runCount = 0
|
||||
const stopA = lazySet.lazyEffect((onCleanup) => {
|
||||
const currentValue = key1.value
|
||||
lazilyUpdatedMap.set(currentValue, 'a' + runCount++)
|
||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||
})
|
||||
|
||||
lazySet.lazyEffect((onCleanup) => {
|
||||
const currentValue = key2.value
|
||||
lazilyUpdatedMap.set(currentValue, 'b' + runCount++)
|
||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||
})
|
||||
|
||||
// Dependant effect, notices when -1 key is inserted into the map by another effect.
|
||||
const cleanupSpy = vi.fn()
|
||||
lazySet.lazyEffect((onCleanup) => {
|
||||
const negOne = lazilyUpdatedMap.get(-1)
|
||||
if (negOne != null) {
|
||||
lazilyUpdatedMap.set(-2, `noticed ${negOne}!`)
|
||||
onCleanup(() => {
|
||||
cleanupSpy()
|
||||
lazilyUpdatedMap.delete(-2)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(lazilyUpdatedMap, 'The effects should not run immediately after registration').toEqual(
|
||||
new Map([]),
|
||||
)
|
||||
|
||||
key1.value = 1
|
||||
expect(lazilyUpdatedMap, 'The effects should not perform any updates until flush').toEqual(
|
||||
new Map([]),
|
||||
)
|
||||
|
||||
key1.value = 2
|
||||
lazySet.flush()
|
||||
expect(
|
||||
lazilyUpdatedMap,
|
||||
'A cleanup and update should run on flush, but only for the updated key',
|
||||
).toEqual(
|
||||
new Map([
|
||||
[2, 'a0'],
|
||||
[100, 'b1'],
|
||||
]),
|
||||
)
|
||||
|
||||
key1.value = 3
|
||||
key2.value = 103
|
||||
stopA()
|
||||
expect(
|
||||
lazilyUpdatedMap,
|
||||
'Stop should immediately trigger cleanup, but only for stopped effect',
|
||||
).toEqual(new Map([[100, 'b1']]))
|
||||
|
||||
lazySet.flush()
|
||||
expect(
|
||||
lazilyUpdatedMap,
|
||||
'Flush should trigger remaining updates, but not run the stopped effects',
|
||||
).toEqual(new Map([[103, 'b2']]))
|
||||
|
||||
key1.value = 4
|
||||
key2.value = 104
|
||||
lazySet.lazyEffect((onCleanup) => {
|
||||
const currentValue = key1.value
|
||||
console.log('currentValue', currentValue)
|
||||
console.log('lazilyUpdatedMap', lazilyUpdatedMap)
|
||||
|
||||
lazilyUpdatedMap.set(currentValue, 'c' + runCount++)
|
||||
onCleanup(() => lazilyUpdatedMap.delete(currentValue))
|
||||
})
|
||||
expect(
|
||||
lazilyUpdatedMap,
|
||||
'Newly registered effect should not run immediately nor trigger a flush',
|
||||
).toEqual(new Map([[103, 'b2']]))
|
||||
|
||||
key1.value = 5
|
||||
key2.value = 105
|
||||
lazySet.flush()
|
||||
expect(
|
||||
lazilyUpdatedMap,
|
||||
'Flush should trigger both effects when their dependencies change',
|
||||
).toEqual(
|
||||
new Map([
|
||||
[105, 'b3'],
|
||||
[5, 'c4'],
|
||||
]),
|
||||
)
|
||||
|
||||
lazySet.flush()
|
||||
expect(lazilyUpdatedMap, 'Flush should have no effect when no dependencies changed').toEqual(
|
||||
new Map([
|
||||
[105, 'b3'],
|
||||
[5, 'c4'],
|
||||
]),
|
||||
)
|
||||
|
||||
key2.value = -1
|
||||
lazySet.flush()
|
||||
expect(lazilyUpdatedMap, 'Effects depending on one another should run in the same flush').toEqual(
|
||||
new Map([
|
||||
[5, 'c4'],
|
||||
[-1, 'b5'],
|
||||
[-2, 'noticed b5!'],
|
||||
]),
|
||||
)
|
||||
|
||||
key2.value = 1
|
||||
lazySet.flush()
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
||||
expect(lazilyUpdatedMap, 'Dependant effect is cleaned up.').toEqual(
|
||||
new Map([
|
||||
[1, 'b6'],
|
||||
[5, 'c4'],
|
||||
]),
|
||||
)
|
||||
|
||||
key2.value = 2
|
||||
lazySet.flush()
|
||||
key2.value = -3
|
||||
lazySet.flush()
|
||||
expect(cleanupSpy, 'Cleanup runs only once.').toHaveBeenCalledTimes(1)
|
||||
|
||||
key1.value = -1
|
||||
key2.value = 456
|
||||
await nextTick()
|
||||
expect(lazilyUpdatedMap, 'Flush should run automatically before the next tick.').toEqual(
|
||||
new Map([
|
||||
[456, 'b10'],
|
||||
[-1, 'c9'],
|
||||
[-2, 'noticed c9!'],
|
||||
]),
|
||||
)
|
||||
})
|
@ -117,3 +117,30 @@ export function useApproach(
|
||||
|
||||
return proxyRefs({ value: current, skip })
|
||||
}
|
||||
|
||||
export function useTransitioning(observedProperties?: Set<string>) {
|
||||
const hasActiveAnimations = ref(false)
|
||||
let numActiveTransitions = 0
|
||||
function onTransitionStart(e: TransitionEvent) {
|
||||
if (!observedProperties || observedProperties.has(e.propertyName)) {
|
||||
if (numActiveTransitions == 0) hasActiveAnimations.value = true
|
||||
numActiveTransitions += 1
|
||||
}
|
||||
}
|
||||
|
||||
function onTransitionEnd(e: TransitionEvent) {
|
||||
if (!observedProperties || observedProperties.has(e.propertyName)) {
|
||||
numActiveTransitions -= 1
|
||||
if (numActiveTransitions == 0) hasActiveAnimations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
active: hasActiveAnimations,
|
||||
events: {
|
||||
transitionstart: onTransitionStart,
|
||||
transitionend: onTransitionEnd,
|
||||
transitioncancel: onTransitionEnd,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -34,3 +34,8 @@ export function partitionPoint<T>(
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
/** Index into an array using specified index. When the index is nullable, returns undefined. */
|
||||
export function tryGetIndex<T>(arr: T[], index: number | undefined | null): T | undefined {
|
||||
return index == null ? undefined : arr[index]
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
import * as Ast from '@/generated/ast'
|
||||
import { 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))
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -1,13 +0,0 @@
|
||||
export abstract class Interaction {
|
||||
id: number
|
||||
static nextId: number = 0
|
||||
constructor() {
|
||||
this.id = Interaction.nextId
|
||||
Interaction.nextId += 1
|
||||
}
|
||||
|
||||
abstract cancel(): void
|
||||
click(_e: MouseEvent): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
@ -34,6 +34,21 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
)
|
||||
}
|
||||
|
||||
function clientToSceneRect(clientRect: Rect): Rect {
|
||||
const rect = elemRect(viewportNode.value)
|
||||
const canvasPos = clientRect.pos.sub(rect.pos)
|
||||
const v = viewport.value
|
||||
const pos = new Vec2(
|
||||
v.pos.x + v.size.x * (canvasPos.x / rect.size.x),
|
||||
v.pos.y + v.size.y * (canvasPos.y / rect.size.y),
|
||||
)
|
||||
const size = new Vec2(
|
||||
v.size.x * (clientRect.size.x / rect.size.x),
|
||||
v.size.y * (clientRect.size.y / rect.size.y),
|
||||
)
|
||||
return new Rect(pos, size)
|
||||
}
|
||||
|
||||
let zoomPivot = Vec2.Zero
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user