mirror of
https://github.com/enso-org/enso.git
synced 2024-12-27 20:33:30 +03:00
[gui2] Language Server binary protocol, and loading of visualization data (#7873)
- Depends on #7773. - Implements binary WebSocket protocol (data protocol) - Performs some editor initialization (the bare minimum so that visualizations work) - Adds event handlers to receive visualization data updates # Important Notes None
This commit is contained in:
parent
a90af9f844
commit
44f2f425c0
1
app/gui2/.gitignore
vendored
1
app/gui2/.gitignore
vendored
@ -13,6 +13,7 @@ coverage
|
|||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
*.suo
|
*.suo
|
||||||
|
3
app/gui2/.vscode/settings.json
vendored
Normal file
3
app/gui2/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"prettier.prettierPath": "../../node_modules/prettier/index.cjs"
|
||||||
|
}
|
1
app/gui2/env.d.ts
vendored
1
app/gui2/env.d.ts
vendored
@ -13,5 +13,4 @@ declare module 'builtins' {
|
|||||||
export const VisualizationContainer: typeof import('@/components/VisualizationContainer.vue').default
|
export const VisualizationContainer: typeof import('@/components/VisualizationContainer.vue').default
|
||||||
export const useVisualizationConfig: typeof import('@/providers/visualizationConfig').useVisualizationConfig
|
export const useVisualizationConfig: typeof import('@/providers/visualizationConfig').useVisualizationConfig
|
||||||
export const defineKeybinds: typeof import('@/util/shortcuts').defineKeybinds
|
export const defineKeybinds: typeof import('@/util/shortcuts').defineKeybinds
|
||||||
export const d3: typeof import('d3')
|
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,7 @@ const conf = [
|
|||||||
rules: {
|
rules: {
|
||||||
camelcase: [1, { ignoreImports: true }],
|
camelcase: [1, { ignoreImports: true }],
|
||||||
'no-inner-declarations': 0,
|
'no-inner-declarations': 0,
|
||||||
|
'vue/attribute-hyphenation': [2, 'never'],
|
||||||
'vue/v-on-event-hyphenation': [2, 'never'],
|
'vue/v-on-event-hyphenation': [2, 'never'],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
1,
|
1,
|
||||||
|
6
app/gui2/node.env.d.ts
vendored
6
app/gui2/node.env.d.ts
vendored
@ -3,9 +3,3 @@ module 'tailwindcss/nesting' {
|
|||||||
declare const plugin: PluginCreator<unknown>
|
declare const plugin: PluginCreator<unknown>
|
||||||
export default plugin
|
export default plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an augmentation to the built-in `ImportMeta` interface.
|
|
||||||
// This file MUST NOT contain any top-level imports.
|
|
||||||
interface ImportMeta {
|
|
||||||
vitest: typeof import('vitest') | undefined
|
|
||||||
}
|
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
"hash-sum": "^2.0.0",
|
"hash-sum": "^2.0.0",
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"lib0": "^0.2.83",
|
"lib0": "^0.2.85",
|
||||||
"magic-string": "^0.30.3",
|
"magic-string": "^0.30.3",
|
||||||
"murmurhash": "^2.0.1",
|
"murmurhash": "^2.0.1",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
@ -43,7 +43,6 @@
|
|||||||
"sha3": "^2.1.4",
|
"sha3": "^2.1.4",
|
||||||
"sucrase": "^3.34.0",
|
"sucrase": "^3.34.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-codemirror": "^6.1.1",
|
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.13.0",
|
||||||
"y-codemirror.next": "^0.3.2",
|
"y-codemirror.next": "^0.3.2",
|
||||||
"y-protocols": "^1.0.5",
|
"y-protocols": "^1.0.5",
|
||||||
|
@ -3,6 +3,13 @@ import { defineKeybinds } from 'builtins'
|
|||||||
|
|
||||||
export const name = 'Scatterplot'
|
export const name = 'Scatterplot'
|
||||||
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
||||||
|
const DEFAULT_LIMIT = 1024
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.Scatter_Plot',
|
||||||
|
'process_to_json_text',
|
||||||
|
'Nothing',
|
||||||
|
DEFAULT_LIMIT.toString(),
|
||||||
|
]
|
||||||
|
|
||||||
const bindings = defineKeybinds('scatterplot-visualization', {
|
const bindings = defineKeybinds('scatterplot-visualization', {
|
||||||
zoomIn: ['Mod+Z'],
|
zoomIn: ['Mod+Z'],
|
||||||
@ -68,7 +75,7 @@ enum ScaleType {
|
|||||||
|
|
||||||
interface AxisConfiguration {
|
interface AxisConfiguration {
|
||||||
label: string
|
label: string
|
||||||
scale: ScaleType
|
scale?: ScaleType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AxesConfiguration {
|
interface AxesConfiguration {
|
||||||
@ -84,8 +91,10 @@ interface Color {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { d3 } from 'builtins'
|
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
import FindIcon from './icons/find.svg'
|
import FindIcon from './icons/find.svg'
|
||||||
import ShowAllIcon from './icons/show_all.svg'
|
import ShowAllIcon from './icons/show_all.svg'
|
||||||
|
|
||||||
@ -106,12 +115,9 @@ const POINT_LABEL_PADDING_X_PX = 7
|
|||||||
const POINT_LABEL_PADDING_Y_PX = 2
|
const POINT_LABEL_PADDING_Y_PX = 2
|
||||||
const ANIMATION_DURATION_MS = 400
|
const ANIMATION_DURATION_MS = 400
|
||||||
const VISIBLE_POINTS = 'visible'
|
const VISIBLE_POINTS = 'visible'
|
||||||
const DEFAULT_LIMIT = 1024
|
|
||||||
const ACCENT_COLOR: Color = { red: 78, green: 165, blue: 253 }
|
const ACCENT_COLOR: Color = { red: 78, green: 165, blue: 253 }
|
||||||
const SIZE_SCALE_MULTIPLER = 100
|
const SIZE_SCALE_MULTIPLER = 100
|
||||||
const FILL_COLOR = `rgba(${ACCENT_COLOR.red * 255},${ACCENT_COLOR.green * 255},${
|
const FILL_COLOR = `rgba(${ACCENT_COLOR.red},${ACCENT_COLOR.green},${ACCENT_COLOR.blue},0.8)`
|
||||||
ACCENT_COLOR.blue * 255
|
|
||||||
},0.8)`
|
|
||||||
|
|
||||||
const ZOOM_EXTENT = [0.5, 20] satisfies d3.BrushSelection
|
const ZOOM_EXTENT = [0.5, 20] satisfies d3.BrushSelection
|
||||||
const RIGHT_BUTTON = 2
|
const RIGHT_BUTTON = 2
|
||||||
@ -201,18 +207,18 @@ const margin = computed(() => {
|
|||||||
return { top: 10, right: 10, bottom: 35, left: 55 }
|
return { top: 10, right: 10, bottom: 35, left: 55 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
|
const width = computed(() =>
|
||||||
watchPostEffect(() => {
|
config.value.fullscreen
|
||||||
width.value = config.value.fullscreen
|
|
||||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
|
: Math.max(config.value.width ?? 0, config.value.nodeSize.x),
|
||||||
})
|
)
|
||||||
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
|
|
||||||
watchPostEffect(() => {
|
const height = computed(() =>
|
||||||
height.value = config.value.fullscreen
|
config.value.fullscreen
|
||||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||||
: config.value.height ?? (config.value.nodeSize.x * 3) / 4
|
: config.value.height ?? (config.value.nodeSize.x * 3) / 4,
|
||||||
})
|
)
|
||||||
|
|
||||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||||
const xTicks = computed(() => boxWidth.value / 40)
|
const xTicks = computed(() => boxWidth.value / 40)
|
||||||
@ -229,7 +235,7 @@ const yLabelLeft = computed(
|
|||||||
)
|
)
|
||||||
const yLabelTop = computed(() => -margin.value.left + 15)
|
const yLabelTop = computed(() => -margin.value.left + 15)
|
||||||
|
|
||||||
function updatePreprocessor() {
|
watchEffect(() => {
|
||||||
emit(
|
emit(
|
||||||
'update:preprocessor',
|
'update:preprocessor',
|
||||||
'Standard.Visualization.Scatter_Plot',
|
'Standard.Visualization.Scatter_Plot',
|
||||||
@ -237,9 +243,7 @@ function updatePreprocessor() {
|
|||||||
bounds.value == null ? 'Nothing' : '[' + bounds.value.join(',') + ']',
|
bounds.value == null ? 'Nothing' : '[' + bounds.value.join(',') + ']',
|
||||||
limit.value.toString(),
|
limit.value.toString(),
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
onMounted(updatePreprocessor)
|
|
||||||
|
|
||||||
watchEffect(() => (focus.value = data.value.focus))
|
watchEffect(() => (focus.value = data.value.focus))
|
||||||
|
|
||||||
@ -409,7 +413,6 @@ function zoomToSelected() {
|
|||||||
const yMin = yScale_.invert(yMinRaw)
|
const yMin = yScale_.invert(yMinRaw)
|
||||||
const yMax = yScale_.invert(yMaxRaw)
|
const yMax = yScale_.invert(yMaxRaw)
|
||||||
bounds.value = [xMin, yMin, xMax, yMax]
|
bounds.value = [xMin, yMin, xMax, yMax]
|
||||||
updatePreprocessor()
|
|
||||||
xDomain.value = [xMin, xMax]
|
xDomain.value = [xMin, xMax]
|
||||||
yDomain.value = [yMin, yMax]
|
yDomain.value = [yMin, yMax]
|
||||||
}
|
}
|
||||||
@ -435,7 +438,7 @@ function matchShape(d: Point) {
|
|||||||
* @param axis Axis information as received in the visualization update.
|
* @param axis Axis information as received in the visualization update.
|
||||||
* @returns D3 scale. */
|
* @returns D3 scale. */
|
||||||
function axisD3Scale(axis: AxisConfiguration | undefined) {
|
function axisD3Scale(axis: AxisConfiguration | undefined) {
|
||||||
return axis != null ? SCALE_TO_D3_SCALE[axis.scale]() : d3.scaleLinear()
|
return axis?.scale != null ? SCALE_TO_D3_SCALE[axis.scale]() : d3.scaleLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@ -522,7 +525,6 @@ function showAll() {
|
|||||||
extremesAndDeltas.value.yMin - extremesAndDeltas.value.paddingY,
|
extremesAndDeltas.value.yMin - extremesAndDeltas.value.paddingY,
|
||||||
extremesAndDeltas.value.yMax + extremesAndDeltas.value.paddingY,
|
extremesAndDeltas.value.yMax + extremesAndDeltas.value.paddingY,
|
||||||
]
|
]
|
||||||
updatePreprocessor()
|
|
||||||
endBrushing()
|
endBrushing()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,7 +541,7 @@ useEvent(document, 'scroll', endBrushing)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<button class="image-button active">
|
<button class="image-button active">
|
||||||
<img :src="ShowAllIcon" alt="Fit all" @pointerdown="showAll" />
|
<img :src="ShowAllIcon" alt="Fit all" @pointerdown="showAll" />
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export const name = '<name here>'
|
export const name = '<name here>'
|
||||||
export const inputType = '<allowed input type(s) here>'
|
export const inputType = '<allowed input type(s) here>'
|
||||||
|
// Optional:
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'<module path here>',
|
||||||
|
'<method name here>',
|
||||||
|
'<optional args here>',
|
||||||
|
]
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
dataType: 'here'
|
dataType: 'here'
|
||||||
|
2219
app/gui2/shared/binaryProtocol.ts
Normal file
2219
app/gui2/shared/binaryProtocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
198
app/gui2/shared/dataServer.ts
Normal file
198
app/gui2/shared/dataServer.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
import * as random from 'lib0/random'
|
||||||
|
import {
|
||||||
|
Builder,
|
||||||
|
ByteBuffer,
|
||||||
|
ChecksumBytesCommand,
|
||||||
|
ChecksumBytesReply,
|
||||||
|
EnsoUUID,
|
||||||
|
Error,
|
||||||
|
FileContentsReply,
|
||||||
|
FileSegment,
|
||||||
|
InboundMessage,
|
||||||
|
InboundPayload,
|
||||||
|
InitSessionCommand,
|
||||||
|
None,
|
||||||
|
OutboundMessage,
|
||||||
|
OutboundPayload,
|
||||||
|
ReadBytesCommand,
|
||||||
|
ReadBytesReply,
|
||||||
|
ReadFileCommand,
|
||||||
|
Success,
|
||||||
|
VisualizationUpdate,
|
||||||
|
WriteBytesCommand,
|
||||||
|
WriteBytesReply,
|
||||||
|
WriteFileCommand,
|
||||||
|
type Table,
|
||||||
|
} from './binaryProtocol'
|
||||||
|
import type { WebsocketClient } from './websocket'
|
||||||
|
import type { Uuid } from './yjsModel'
|
||||||
|
|
||||||
|
export function uuidFromBits(leastSigBits: bigint, mostSigBits: bigint): string {
|
||||||
|
const bits = (mostSigBits << 64n) | leastSigBits
|
||||||
|
const string = bits.toString(16).padStart(32, '0')
|
||||||
|
return string.replace(/(........)(....)(....)(....)(............)/, '$1-$2-$3-$4-$5')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuidToBits(uuid: string): [leastSigBits: bigint, mostSigBits: bigint] {
|
||||||
|
const bits = BigInt('0x' + uuid.replace(/-/g, ''))
|
||||||
|
return [bits & 0xffff_ffff_ffff_ffffn, bits >> 64n]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_CONSTRUCTOR = {
|
||||||
|
[OutboundPayload.NONE]: None,
|
||||||
|
[OutboundPayload.ERROR]: Error,
|
||||||
|
[OutboundPayload.SUCCESS]: Success,
|
||||||
|
[OutboundPayload.VISUALIZATION_UPDATE]: VisualizationUpdate,
|
||||||
|
[OutboundPayload.FILE_CONTENTS_REPLY]: FileContentsReply,
|
||||||
|
[OutboundPayload.WRITE_BYTES_REPLY]: WriteBytesReply,
|
||||||
|
[OutboundPayload.READ_BYTES_REPLY]: ReadBytesReply,
|
||||||
|
[OutboundPayload.CHECKSUM_BYTES_REPLY]: ChecksumBytesReply,
|
||||||
|
} satisfies Record<OutboundPayload, new () => Table>
|
||||||
|
|
||||||
|
export type DataServerEvents = {
|
||||||
|
[K in keyof typeof PAYLOAD_CONSTRUCTOR as K | `${K}:${string}`]: (
|
||||||
|
arg: InstanceType<(typeof PAYLOAD_CONSTRUCTOR)[K]>,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataServer extends ObservableV2<DataServerEvents> {
|
||||||
|
initialized = false
|
||||||
|
ready: Promise<void>
|
||||||
|
clientId!: string
|
||||||
|
resolveCallbacks = new Map<string, (data: any) => void>()
|
||||||
|
|
||||||
|
/** `websocket.binaryType` should be `ArrayBuffer`. */
|
||||||
|
constructor(public websocket: WebsocketClient) {
|
||||||
|
super()
|
||||||
|
if (websocket.connected) {
|
||||||
|
this.ready = Promise.resolve()
|
||||||
|
} else {
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
websocket.on('connect', () => resolve())
|
||||||
|
websocket.on('disconnect', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
websocket.on('message', (rawPayload) => {
|
||||||
|
if (!(rawPayload instanceof ArrayBuffer)) {
|
||||||
|
console.warn('Data Server: Data type was invalid:', rawPayload)
|
||||||
|
// Ignore all non-binary messages. If the messages are `Blob`s instead, this is a
|
||||||
|
// misconfiguration and should also be ignored.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const binaryMessage = OutboundMessage.getRootAsOutboundMessage(new ByteBuffer(rawPayload))
|
||||||
|
const payloadType = binaryMessage.payloadType()
|
||||||
|
const payload = binaryMessage.payload(new PAYLOAD_CONSTRUCTOR[payloadType]())
|
||||||
|
if (payload != null) {
|
||||||
|
this.emit(`${payloadType}`, [payload])
|
||||||
|
const id = binaryMessage.correlationId()
|
||||||
|
if (id != null) {
|
||||||
|
const uuid = uuidFromBits(id.leastSigBits(), id.mostSigBits())
|
||||||
|
this.emit(`${payloadType}:${uuid}`, [payload])
|
||||||
|
const callback = this.resolveCallbacks.get(uuid)
|
||||||
|
callback?.(payload)
|
||||||
|
} else if (payload instanceof VisualizationUpdate) {
|
||||||
|
const id = payload.visualizationContext()?.visualizationId()
|
||||||
|
if (id != null) {
|
||||||
|
const uuid = uuidFromBits(id.leastSigBits(), id.mostSigBits())
|
||||||
|
this.emit(`${payloadType}:${uuid}`, [payload])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(clientId: Uuid) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
this.clientId = clientId
|
||||||
|
await this.ready
|
||||||
|
await this.initSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected send<T = void>(
|
||||||
|
builder: Builder,
|
||||||
|
payloadType: InboundPayload,
|
||||||
|
payloadOffset: number,
|
||||||
|
): Promise<T> {
|
||||||
|
const messageUuid = random.uuidv4()
|
||||||
|
const messageId = this.createUUID(builder, messageUuid)
|
||||||
|
const rootTable = InboundMessage.createInboundMessage(
|
||||||
|
builder,
|
||||||
|
messageId,
|
||||||
|
0 /* correlation id */,
|
||||||
|
payloadType,
|
||||||
|
payloadOffset,
|
||||||
|
)
|
||||||
|
const promise = new Promise<T>((resolve) => {
|
||||||
|
this.resolveCallbacks.set(messageUuid, resolve)
|
||||||
|
})
|
||||||
|
this.websocket.send(builder.finish(rootTable).toArrayBuffer())
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createUUID(builder: Builder, uuid: string) {
|
||||||
|
const [leastSigBits, mostSigBits] = uuidToBits(uuid)
|
||||||
|
const identifier = EnsoUUID.createEnsoUUID(builder, leastSigBits, mostSigBits)
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
initSession(): Promise<Success> {
|
||||||
|
const builder = new Builder()
|
||||||
|
const identifierOffset = this.createUUID(builder, this.clientId)
|
||||||
|
const commandOffset = InitSessionCommand.createInitSessionCommand(builder, identifierOffset)
|
||||||
|
return this.send<Success>(builder, InboundPayload.INIT_SESSION_CMD, commandOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, contents: string | ArrayBuffer | Uint8Array) {
|
||||||
|
const builder = new Builder()
|
||||||
|
const contentsOffset = builder.createString(contents)
|
||||||
|
const pathOffset = builder.createString(path)
|
||||||
|
const command = WriteFileCommand.createWriteFileCommand(builder, pathOffset, contentsOffset)
|
||||||
|
return await this.send(builder, InboundPayload.WRITE_FILE_CMD, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string) {
|
||||||
|
const builder = new Builder()
|
||||||
|
const pathOffset = builder.createString(path)
|
||||||
|
const command = ReadFileCommand.createReadFileCommand(builder, pathOffset)
|
||||||
|
return await this.send(builder, InboundPayload.READ_FILE_CMD, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeBytes(
|
||||||
|
path: string,
|
||||||
|
index: bigint,
|
||||||
|
overwriteExisting: boolean,
|
||||||
|
contents: string | ArrayBuffer | Uint8Array,
|
||||||
|
): Promise<WriteBytesReply> {
|
||||||
|
const builder = new Builder()
|
||||||
|
const bytesOffset = builder.createString(contents)
|
||||||
|
const pathOffset = builder.createString(path)
|
||||||
|
const command = WriteBytesCommand.createWriteBytesCommand(
|
||||||
|
builder,
|
||||||
|
pathOffset,
|
||||||
|
index,
|
||||||
|
overwriteExisting,
|
||||||
|
bytesOffset,
|
||||||
|
)
|
||||||
|
return await this.send<WriteBytesReply>(builder, InboundPayload.WRITE_BYTES_CMD, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readBytes(path: string, index: bigint, length: bigint): Promise<ReadBytesReply> {
|
||||||
|
const builder = new Builder()
|
||||||
|
const pathOffset = builder.createString(path)
|
||||||
|
const segmentOffset = FileSegment.createFileSegment(builder, pathOffset, index, length)
|
||||||
|
const command = ReadBytesCommand.createReadBytesCommand(builder, segmentOffset)
|
||||||
|
return await this.send<ReadBytesReply>(builder, InboundPayload.READ_BYTES_CMD, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
async checksumBytes(path: string, index: bigint, length: bigint): Promise<ChecksumBytesReply> {
|
||||||
|
const builder = new Builder()
|
||||||
|
const pathOffset = builder.createString(path)
|
||||||
|
const segmentOffset = FileSegment.createFileSegment(builder, pathOffset, index, length)
|
||||||
|
const command = ChecksumBytesCommand.createChecksumBytesCommand(builder, segmentOffset)
|
||||||
|
return await this.send<ChecksumBytesReply>(builder, InboundPayload.WRITE_BYTES_CMD, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check whether any of these may send an "error" message instead
|
||||||
|
}
|
25
app/gui2/shared/events.ts
Normal file
25
app/gui2/shared/events.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
|
||||||
|
type EventHandler = (...arg0: any[]) => void
|
||||||
|
|
||||||
|
type EventMap = {
|
||||||
|
[key: string]: EventHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventEmitter extends ObservableV2<EventMap> {
|
||||||
|
emit(name: string, ...args: unknown[]): void {
|
||||||
|
super.emit(name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener(name: string, f: EventHandler) {
|
||||||
|
this.on(name, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener(name: string, f: EventHandler) {
|
||||||
|
this.off(name, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners() {
|
||||||
|
this.destroy()
|
||||||
|
}
|
||||||
|
}
|
@ -20,15 +20,15 @@ import type {
|
|||||||
import type { Uuid } from './yjsModel'
|
import type { Uuid } from './yjsModel'
|
||||||
|
|
||||||
const DEBUG_LOG_RPC = false
|
const DEBUG_LOG_RPC = false
|
||||||
const RPC_TIMEOUT_MS = 10000
|
const RPC_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
class RpcError extends Error {
|
export class LsRpcError extends Error {
|
||||||
cause: Error
|
cause: Error
|
||||||
request: string
|
request: string
|
||||||
params: object
|
params: object
|
||||||
constructor(inner: Error, request: string, params: object) {
|
constructor(cause: Error, request: string, params: object) {
|
||||||
super(`Language server request failed.`)
|
super(`Language server request '${request}' failed.`)
|
||||||
this.cause = inner
|
this.cause = cause
|
||||||
this.request = request
|
this.request = request
|
||||||
this.params = params
|
this.params = params
|
||||||
}
|
}
|
||||||
@ -47,6 +47,9 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
|||||||
client.onNotification((notification) => {
|
client.onNotification((notification) => {
|
||||||
this.emit(notification.method as keyof Notifications, [notification.params])
|
this.emit(notification.method as keyof Notifications, [notification.params])
|
||||||
})
|
})
|
||||||
|
client.onError((error) => {
|
||||||
|
console.error(`Unexpected LS connection error:`, error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "magic bag of holding" generic that is only present in the return type is UNSOUND.
|
// The "magic bag of holding" generic that is only present in the return type is UNSOUND.
|
||||||
@ -62,7 +65,7 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
|||||||
return await this.client.request({ method, params }, RPC_TIMEOUT_MS)
|
return await this.client.request({ method, params }, RPC_TIMEOUT_MS)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
throw new RpcError(e, method, params)
|
throw new LsRpcError(e, method, params)
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
@ -271,12 +274,12 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
|||||||
detachVisualization(
|
detachVisualization(
|
||||||
visualizationId: Uuid,
|
visualizationId: Uuid,
|
||||||
expressionId: ExpressionId,
|
expressionId: ExpressionId,
|
||||||
executionContextId: ContextId,
|
contextId: ContextId,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.request('executionContext/detachVisualization', {
|
return this.request('executionContext/detachVisualization', {
|
||||||
visualizationId,
|
visualizationId,
|
||||||
expressionId,
|
expressionId,
|
||||||
executionContextId,
|
contextId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,7 +258,7 @@ interface VisualizationContext {}
|
|||||||
|
|
||||||
export interface VisualizationConfiguration {
|
export interface VisualizationConfiguration {
|
||||||
/** An execution context of the visualization. */
|
/** An execution context of the visualization. */
|
||||||
executionContextId: Uuid
|
executionContextId: ContextId
|
||||||
/** A qualified name of the module to be used to evaluate the arguments for the visualization
|
/** A qualified name of the module to be used to evaluate the arguments for the visualization
|
||||||
* expression. */
|
* expression. */
|
||||||
visualizationModule: string
|
visualizationModule: string
|
||||||
@ -311,7 +311,7 @@ export type StackItem = ExplicitCall | LocalCall
|
|||||||
export interface ExplicitCall {
|
export interface ExplicitCall {
|
||||||
type: 'ExplicitCall'
|
type: 'ExplicitCall'
|
||||||
methodPointer: MethodPointer
|
methodPointer: MethodPointer
|
||||||
thisArgumentExpression?: string
|
thisArgumentExpression?: string | undefined
|
||||||
positionalArgumentsExpressions: string[]
|
positionalArgumentsExpressions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
188
app/gui2/shared/websocket.ts
Normal file
188
app/gui2/shared/websocket.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/* eslint-env browser */
|
||||||
|
|
||||||
|
/* The MIT License (MIT)
|
||||||
|
*
|
||||||
|
* Copyright (c) 2019 Kevin Jahns <kevin.jahns@protonmail.com>.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny websocket connection handler.
|
||||||
|
*
|
||||||
|
* Implements exponential backoff reconnects, ping/pong, and a nice event system using [lib0/observable].
|
||||||
|
*
|
||||||
|
* @module websocket
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
import * as time from 'lib0/time'
|
||||||
|
|
||||||
|
const reconnectTimeoutBase = 1200
|
||||||
|
const maxReconnectTimeout = 2500
|
||||||
|
// @todo - this should depend on awareness.outdatedTime
|
||||||
|
const messageReconnectTimeout = 30000
|
||||||
|
|
||||||
|
const setupWS = (wsclient: WebsocketClient) => {
|
||||||
|
if (wsclient.shouldConnect && wsclient.ws === null) {
|
||||||
|
// @ts-ignore I don't know why `lib` is misconfigured.
|
||||||
|
const websocket = new WebSocket(wsclient.url)
|
||||||
|
const binaryType = wsclient.binaryType
|
||||||
|
let pingTimeout: any = null
|
||||||
|
if (binaryType) {
|
||||||
|
websocket.binaryType = binaryType
|
||||||
|
}
|
||||||
|
wsclient.ws = websocket
|
||||||
|
wsclient.connecting = true
|
||||||
|
wsclient.connected = false
|
||||||
|
websocket.onmessage = (event: { data: string | ArrayBuffer | Blob }) => {
|
||||||
|
wsclient.lastMessageReceived = time.getUnixTime()
|
||||||
|
const data = event.data
|
||||||
|
const message = typeof data === 'string' ? JSON.parse(data) : data
|
||||||
|
if (wsclient.sendPings && message && message.type === 'pong') {
|
||||||
|
clearTimeout(pingTimeout)
|
||||||
|
pingTimeout = setTimeout(sendPing, messageReconnectTimeout / 2)
|
||||||
|
}
|
||||||
|
wsclient.emit('message', [message, wsclient])
|
||||||
|
}
|
||||||
|
const onclose = (error: unknown) => {
|
||||||
|
if (wsclient.ws !== null) {
|
||||||
|
wsclient.ws = null
|
||||||
|
wsclient.connecting = false
|
||||||
|
if (wsclient.connected) {
|
||||||
|
wsclient.connected = false
|
||||||
|
wsclient.emit('disconnect', [{ type: 'disconnect', error }, wsclient])
|
||||||
|
} else {
|
||||||
|
wsclient.unsuccessfulReconnects++
|
||||||
|
}
|
||||||
|
// Start with no reconnect timeout and increase timeout by
|
||||||
|
// log10(wsUnsuccessfulReconnects).
|
||||||
|
// The idea is to increase reconnect timeout slowly and have no reconnect
|
||||||
|
// timeout at the beginning (log(1) = 0)
|
||||||
|
setTimeout(
|
||||||
|
setupWS,
|
||||||
|
math.min(
|
||||||
|
math.log10(wsclient.unsuccessfulReconnects + 1) * reconnectTimeoutBase,
|
||||||
|
maxReconnectTimeout,
|
||||||
|
),
|
||||||
|
wsclient,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearTimeout(pingTimeout)
|
||||||
|
}
|
||||||
|
const sendPing = () => {
|
||||||
|
if (wsclient.sendPings && wsclient.ws === websocket) {
|
||||||
|
wsclient.send({
|
||||||
|
type: 'ping',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
websocket.onclose = () => onclose(null)
|
||||||
|
websocket.onerror = (error: unknown) => onclose(error)
|
||||||
|
websocket.onopen = () => {
|
||||||
|
wsclient.lastMessageReceived = time.getUnixTime()
|
||||||
|
wsclient.connecting = false
|
||||||
|
wsclient.connected = true
|
||||||
|
wsclient.unsuccessfulReconnects = 0
|
||||||
|
wsclient.emit('connect', [{ type: 'connect' }, wsclient])
|
||||||
|
// set ping
|
||||||
|
pingTimeout = setTimeout(sendPing, messageReconnectTimeout / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsocketEvents = {
|
||||||
|
connect: (payload: { type: 'connect' }, self: WebsocketClient) => void
|
||||||
|
disconnect: (payload: { type: 'disconnect'; error: unknown }, self: WebsocketClient) => void
|
||||||
|
message: (payload: {} | ArrayBuffer | Blob, self: WebsocketClient) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebsocketClient extends ObservableV2<WebsocketEvents> {
|
||||||
|
ws: any
|
||||||
|
binaryType
|
||||||
|
sendPings
|
||||||
|
connected
|
||||||
|
connecting
|
||||||
|
unsuccessfulReconnects
|
||||||
|
lastMessageReceived
|
||||||
|
shouldConnect
|
||||||
|
protected _checkInterval
|
||||||
|
constructor(
|
||||||
|
public url: string,
|
||||||
|
{
|
||||||
|
binaryType,
|
||||||
|
sendPings,
|
||||||
|
}: { binaryType?: 'arraybuffer' | 'blob' | null; sendPings?: boolean } = {},
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.ws = null
|
||||||
|
this.binaryType = binaryType || null
|
||||||
|
this.sendPings = sendPings ?? true
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.unsuccessfulReconnects = 0
|
||||||
|
this.lastMessageReceived = 0
|
||||||
|
/** Whether to connect to other peers or not */
|
||||||
|
this.shouldConnect = true
|
||||||
|
this._checkInterval = this.sendPings
|
||||||
|
? setInterval(() => {
|
||||||
|
if (
|
||||||
|
this.connected &&
|
||||||
|
messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived
|
||||||
|
) {
|
||||||
|
// no message received in a long time - not even your own awareness
|
||||||
|
// updates (which are updated every 15 seconds)
|
||||||
|
this.ws.close()
|
||||||
|
}
|
||||||
|
}, messageReconnectTimeout / 2)
|
||||||
|
: 0
|
||||||
|
setupWS(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: {} | ArrayBuffer | Blob) {
|
||||||
|
if (this.ws) {
|
||||||
|
const encoded =
|
||||||
|
message instanceof ArrayBuffer || message instanceof Blob
|
||||||
|
? message
|
||||||
|
: JSON.stringify(message)
|
||||||
|
this.ws.send(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
clearInterval(this._checkInterval)
|
||||||
|
this.disconnect()
|
||||||
|
super.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.shouldConnect = false
|
||||||
|
if (this.ws !== null) {
|
||||||
|
this.ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.shouldConnect = true
|
||||||
|
if (!this.connected && this.ws === null) {
|
||||||
|
setupWS(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import * as decoding from 'lib0/decoding'
|
import * as decoding from 'lib0/decoding'
|
||||||
import * as encoding from 'lib0/encoding'
|
import * as encoding from 'lib0/encoding'
|
||||||
import * as random from 'lib0/random.js'
|
import * as object from 'lib0/object'
|
||||||
|
import * as random from 'lib0/random'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
||||||
@ -8,10 +9,38 @@ declare const brandExprId: unique symbol
|
|||||||
export type ExprId = Uuid & { [brandExprId]: never }
|
export type ExprId = Uuid & { [brandExprId]: never }
|
||||||
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
||||||
|
|
||||||
|
export type VisualizationModule =
|
||||||
|
| { kind: 'Builtin' }
|
||||||
|
| { kind: 'CurrentProject' }
|
||||||
|
| { kind: 'Library'; name: string }
|
||||||
|
|
||||||
|
export interface VisualizationIdentifier {
|
||||||
|
module: VisualizationModule
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualizationMetadata extends VisualizationIdentifier {
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visMetadataEquals(
|
||||||
|
a: VisualizationMetadata | null | undefined,
|
||||||
|
b: VisualizationMetadata | null | undefined,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
(a == null && b == null) ||
|
||||||
|
(a != null && b != null && a.visible === b.visible && visIdentifierEquals(a, b))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visIdentifierEquals(a: VisualizationIdentifier, b: VisualizationIdentifier) {
|
||||||
|
return a.name === b.name && object.equalFlat(a.module, b.module)
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeMetadata {
|
export interface NodeMetadata {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
vis?: unknown
|
vis: VisualizationMetadata | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DistributedProject {
|
export class DistributedProject {
|
||||||
@ -85,6 +114,7 @@ export class ModuleDoc {
|
|||||||
|
|
||||||
export class DistributedModule {
|
export class DistributedModule {
|
||||||
doc: ModuleDoc
|
doc: ModuleDoc
|
||||||
|
undoManager: Y.UndoManager
|
||||||
|
|
||||||
static async load(ydoc: Y.Doc): Promise<DistributedModule> {
|
static async load(ydoc: Y.Doc): Promise<DistributedModule> {
|
||||||
ydoc.load()
|
ydoc.load()
|
||||||
@ -94,6 +124,7 @@ export class DistributedModule {
|
|||||||
|
|
||||||
constructor(ydoc: Y.Doc) {
|
constructor(ydoc: Y.Doc) {
|
||||||
this.doc = new ModuleDoc(ydoc)
|
this.doc = new ModuleDoc(ydoc)
|
||||||
|
this.undoManager = new Y.UndoManager([this.doc.contents, this.doc.idMap, this.doc.metadata])
|
||||||
}
|
}
|
||||||
|
|
||||||
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
|
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
|
||||||
@ -145,12 +176,14 @@ export class DistributedModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transact<T>(fn: () => T): T {
|
transact<T>(fn: () => T): T {
|
||||||
return this.doc.ydoc.transact(fn)
|
return this.doc.ydoc.transact(fn, 'local')
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
|
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
|
||||||
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0 }
|
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0, vis: null }
|
||||||
this.doc.metadata.set(id, { ...existing, ...meta })
|
this.transact(() => {
|
||||||
|
this.doc.metadata.set(id, { ...existing, ...meta })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getIdMap(): IdMap {
|
getIdMap(): IdMap {
|
||||||
|
@ -1099,22 +1099,10 @@
|
|||||||
fill="currentColor" />
|
fill="currentColor" />
|
||||||
</g>
|
</g>
|
||||||
<g id="docs" fill="none">
|
<g id="docs" fill="none">
|
||||||
<svg width="16" height="16" viewBox="0.5 0.5 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.04419 0.95581H14.0442V2.95581H2.04419V0.95581ZM2.04419 4.95581H10.0442V6.95581H2.04419V4.95581ZM12.0442 8.95581H4.04419V10.9558H12.0442V8.95581ZM2.04419 12.9558H14.0442V14.9558H2.04419V12.9558Z" fill="black" fill-opacity="0.6"/>
|
||||||
<rect x="2.54419" y="1.45581" width="12" height="2" fill="black" fill-opacity="0.6" />
|
|
||||||
<rect x="2.54419" y="5.45581" width="8" height="2" fill="black" fill-opacity="0.6" />
|
|
||||||
<rect x="4.54419" y="9.45581" width="8" height="2" fill="black" fill-opacity="0.6" />
|
|
||||||
<rect x="2.54419" y="13.4558" width="12" height="2" fill="black" fill-opacity="0.6" />
|
|
||||||
</svg>
|
|
||||||
</g>
|
</g>
|
||||||
<g id="eye" fill="none">
|
<g id="eye" fill="none">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C14 5.25 11.5 2.5 8 2.5C4.5 2.5 2 5.25 0 8C2 10.75 4.5 13.5 8 13.5C11.5 13.5 14 10.75 16 8ZM5.5 8C5.5 9.38071 6.61929 10.5 8 10.5C9.38071 10.5 10.5 9.38071 10.5 8C10.5 6.61929 9.38071 5.5 8 5.5C6.61929 5.5 5.5 6.61929 5.5 8Z" fill="black" fill-opacity="0.6"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M7.99988 2.5C11.4999 2.5 13.9999 5.25 15.9999 8L10.4999 8C10.4999 6.61929 9.38059 5.5 7.99988 5.5C6.61917 5.5 5.49988 6.61929 5.49988 8L-0.000123032 8C1.99988 5.25 4.49988 2.5 7.99988 2.5Z"
|
|
||||||
fill="black" fill-opacity="0.6" />
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M7.99988 13.5C4.49988 13.5 1.99988 10.75 -0.000120628 8L5.49988 8C5.49988 9.38071 6.61917 10.5 7.99988 10.5C9.38059 10.5 10.4999 9.38071 10.4999 8L15.9999 8C13.9999 10.75 11.4999 13.5 7.99988 13.5Z"
|
|
||||||
fill="black" fill-opacity="0.6" />
|
|
||||||
</svg>
|
|
||||||
</g>
|
</g>
|
||||||
<g id="no_auto_replay" fill="none">
|
<g id="no_auto_replay" fill="none">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
@ -1,5 +1,17 @@
|
|||||||
import { defineKeybinds } from '@/util/shortcuts'
|
import { defineKeybinds } from '@/util/shortcuts'
|
||||||
|
|
||||||
|
export const codeEditorBindings = defineKeybinds('code-editor', {
|
||||||
|
toggle: ['`'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const graphBindings = defineKeybinds('graph-editor', {
|
||||||
|
undo: ['Mod+Z'],
|
||||||
|
redo: ['Mod+Y', 'Mod+Shift+Z'],
|
||||||
|
dragScene: ['PointerAux', 'Mod+PointerMain'],
|
||||||
|
openComponentBrowser: ['Enter'],
|
||||||
|
newNode: ['N'],
|
||||||
|
})
|
||||||
|
|
||||||
export const nodeBindings = defineKeybinds('node-selection', {
|
export const nodeBindings = defineKeybinds('node-selection', {
|
||||||
deleteSelected: ['Delete'],
|
deleteSelected: ['Delete'],
|
||||||
selectAll: ['Mod+A'],
|
selectAll: ['Mod+A'],
|
||||||
@ -9,4 +21,5 @@ export const nodeBindings = defineKeybinds('node-selection', {
|
|||||||
remove: ['Shift+Alt+PointerMain'],
|
remove: ['Shift+Alt+PointerMain'],
|
||||||
toggle: ['Shift+PointerMain'],
|
toggle: ['Shift+PointerMain'],
|
||||||
invert: ['Mod+Shift+Alt+PointerMain'],
|
invert: ['Mod+Shift+Alt+PointerMain'],
|
||||||
|
toggleVisualization: ['Space'],
|
||||||
})
|
})
|
@ -19,19 +19,19 @@ const emit = defineEmits<{
|
|||||||
<ToggleIcon
|
<ToggleIcon
|
||||||
icon="no_auto_replay"
|
icon="no_auto_replay"
|
||||||
class="icon-container button no-auto-evaluate-button"
|
class="icon-container button no-auto-evaluate-button"
|
||||||
:model-value="props.isAutoEvaluationDisabled"
|
:modelValue="props.isAutoEvaluationDisabled"
|
||||||
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
|
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
|
||||||
/>
|
/>
|
||||||
<ToggleIcon
|
<ToggleIcon
|
||||||
icon="docs"
|
icon="docs"
|
||||||
class="icon-container button docs-button"
|
class="icon-container button docs-button"
|
||||||
:model-value="props.isDocsVisible"
|
:modelValue="props.isDocsVisible"
|
||||||
@update:modelValue="emit('update:isDocsVisible', $event)"
|
@update:modelValue="emit('update:isDocsVisible', $event)"
|
||||||
/>
|
/>
|
||||||
<ToggleIcon
|
<ToggleIcon
|
||||||
icon="eye"
|
icon="eye"
|
||||||
class="icon-container button visualization-button"
|
class="icon-container button visualization-button"
|
||||||
:model-value="props.isVisualizationVisible"
|
:modelValue="props.isVisualizationVisible"
|
||||||
@update:modelValue="emit('update:isVisualizationVisible', $event)"
|
@update:modelValue="emit('update:isVisualizationVisible', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,84 +1,148 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { useWindowEvent } from '@/util/events'
|
import { usePointer } from '@/util/events'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||||
import { basicSetup } from 'codemirror'
|
|
||||||
import { ref, watchPostEffect } from 'vue'
|
// Use dynamic imports to aid code splitting. The codemirror dependency is quite large.
|
||||||
// y-codemirror.next does not provide type information. See https://github.com/yjs/y-codemirror.next/issues/27
|
const { minimalSetup, EditorState, EditorView, yCollab } = await import('@/util/codemirror')
|
||||||
// @ts-ignore
|
|
||||||
import { yCollab } from 'y-codemirror.next'
|
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
// == Keyboard shortcut to toggle the CodeEditor ==
|
|
||||||
|
|
||||||
const shown = ref(false)
|
|
||||||
const rootElement = ref<HTMLElement>()
|
const rootElement = ref<HTMLElement>()
|
||||||
|
|
||||||
useWindowEvent('keydown', (e) => {
|
|
||||||
const graphEditorInFocus = document.activeElement === document.body
|
|
||||||
const codeEditorInFocus = rootElement.value?.contains(document.activeElement)
|
|
||||||
const validFocus = graphEditorInFocus || codeEditorInFocus
|
|
||||||
const targetKeyPressed = e.key == `\``
|
|
||||||
if (validFocus && targetKeyPressed) {
|
|
||||||
e.preventDefault()
|
|
||||||
shown.value = !shown.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// == CodeMirror editor setup ==
|
// == CodeMirror editor setup ==
|
||||||
|
|
||||||
const codeMirrorEl = ref(null)
|
const editorView = new EditorView()
|
||||||
const editorView = ref<EditorView>()
|
watchEffect(() => {
|
||||||
watchPostEffect((onCleanup) => {
|
const module = projectStore.module
|
||||||
const yText = projectStore.module?.doc.contents
|
if (!module) return
|
||||||
if (!yText || !codeMirrorEl.value) return
|
const yText = module.doc.contents
|
||||||
const undoManager = projectStore.undoManager
|
const undoManager = module.undoManager
|
||||||
const awareness = projectStore.awareness
|
const awareness = projectStore.awareness
|
||||||
const view = new EditorView({
|
editorView.setState(
|
||||||
parent: codeMirrorEl.value,
|
EditorState.create({
|
||||||
state: EditorState.create({
|
|
||||||
doc: yText.toString(),
|
doc: yText.toString(),
|
||||||
extensions: [basicSetup, yCollab(yText, awareness, { undoManager })],
|
extensions: [minimalSetup, yCollab(yText, awareness, { undoManager })],
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
onCleanup(() => view.destroy())
|
})
|
||||||
editorView.value = view
|
|
||||||
|
onMounted(() => {
|
||||||
|
editorView.focus()
|
||||||
|
rootElement.value?.prepend(editorView.dom)
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorSize = useLocalStorage<{ width: number | null; height: number | null }>(
|
||||||
|
'code-editor-size',
|
||||||
|
{ width: null, height: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
let initSize = { width: 0, height: 0 }
|
||||||
|
const resize = usePointer((pos, _, type) => {
|
||||||
|
if (rootElement.value == null) return
|
||||||
|
if (type == 'start') initSize = rootElement.value.getBoundingClientRect()
|
||||||
|
editorSize.value.width = initSize.width + pos.relative.x
|
||||||
|
editorSize.value.height = initSize.height - pos.relative.y
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetSize() {
|
||||||
|
editorSize.value.width = null
|
||||||
|
editorSize.value.height = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
width: editorSize.value.width ? `${editorSize.value.width}px` : '50%',
|
||||||
|
height: editorSize.value.height ? `${editorSize.value.height}px` : '30%',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-show="shown"
|
|
||||||
ref="rootElement"
|
ref="rootElement"
|
||||||
class="CodeEditor"
|
class="CodeEditor"
|
||||||
|
:style="editorStyle"
|
||||||
@keydown.enter.stop
|
@keydown.enter.stop
|
||||||
@wheel.stop.passive
|
@wheel.stop.passive
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
>
|
>
|
||||||
<div ref="codeMirrorEl" class="codemirror-container"></div>
|
<div class="resize-handle" v-on="resize.events" @dblclick="resetSize">
|
||||||
|
<svg viewBox="0 0 16 16">
|
||||||
|
<circle cx="2" cy="2" r="1.5" />
|
||||||
|
<circle cx="8" cy="2" r="1.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
<circle cx="14" cy="2" r="1.5" />
|
||||||
|
<circle cx="14" cy="8" r="1.5" />
|
||||||
|
<circle cx="14" cy="14" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.CodeEditor {
|
.CodeEditor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 5px;
|
||||||
left: 0;
|
left: 5px;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 30%;
|
height: 30%;
|
||||||
|
max-width: calc(100% - 10px);
|
||||||
|
max-height: calc(100% - 10px);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from,
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: scale(95%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.codemirror-container {
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
/* cursor: nesw-resize; */
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: black;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.1;
|
||||||
|
transition: opacity 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeEditor :is(.cm-editor) {
|
||||||
|
color: white;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.35);
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 1;
|
||||||
|
color: black;
|
||||||
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: 1px solid transparent;
|
||||||
|
transition: outline 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
.cm-editor {
|
.CodeEditor :is(.cm-focused) {
|
||||||
width: 100%;
|
outline: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.cm-gutters {
|
|
||||||
display: none !important;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
|
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
|
||||||
import { Filtering } from '@/components/ComponentBrowser/filtering'
|
import { Filtering } from '@/components/ComponentBrowser/filtering'
|
||||||
import { default as SvgIcon } from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import { default as ToggleIcon } from '@/components/ToggleIcon.vue'
|
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { useApproach } from '@/util/animation'
|
import { useApproach } from '@/util/animation'
|
||||||
import { useResizeObserver } from '@/util/events'
|
import { useResizeObserver } from '@/util/events'
|
||||||
|
@ -1,17 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineKeybinds } from '@/util/shortcuts'
|
import { codeEditorBindings, graphBindings, nodeBindings } from '@/bindings'
|
||||||
|
|
||||||
const graphBindings = defineKeybinds('graph-editor', {
|
|
||||||
undo: ['Mod+Z'],
|
|
||||||
redo: ['Mod+Y', 'Mod+Shift+Z'],
|
|
||||||
dragScene: ['PointerAux', 'Mod+PointerMain'],
|
|
||||||
openComponentBrowser: ['Enter'],
|
|
||||||
newNode: ['N'],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { nodeBindings } from '@/bindings/nodeSelection'
|
|
||||||
import CodeEditor from '@/components/CodeEditor.vue'
|
import CodeEditor from '@/components/CodeEditor.vue'
|
||||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||||
import GraphEdge from '@/components/GraphEdge.vue'
|
import GraphEdge from '@/components/GraphEdge.vue'
|
||||||
@ -19,16 +7,18 @@ import GraphNode from '@/components/GraphNode.vue'
|
|||||||
import SelectionBrush from '@/components/SelectionBrush.vue'
|
import SelectionBrush from '@/components/SelectionBrush.vue'
|
||||||
import TopBar from '@/components/TopBar.vue'
|
import TopBar from '@/components/TopBar.vue'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { ExecutionContext, useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import type { Rect } from '@/stores/rect'
|
import type { Rect } from '@/stores/rect'
|
||||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { colorFromString } from '@/util/colors'
|
import { colorFromString } from '@/util/colors'
|
||||||
import { keyboardBusy, usePointer, useWindowEvent } from '@/util/events'
|
import { keyboardBusy, keyboardBusyExceptIn, usePointer, useWindowEvent } from '@/util/events'
|
||||||
import { useNavigator } from '@/util/navigator'
|
import { useNavigator } from '@/util/navigator'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import * as set from 'lib0/set'
|
||||||
import type { ContentRange, ExprId } from 'shared/yjsModel'
|
import type { ContentRange, ExprId } from 'shared/yjsModel'
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
|
||||||
|
</script>
|
||||||
|
<script setup lang="ts">
|
||||||
const EXECUTION_MODES = ['design', 'live']
|
const EXECUTION_MODES = ['design', 'live']
|
||||||
const SELECTION_BRUSH_MARGIN_PX = 6
|
const SELECTION_BRUSH_MARGIN_PX = 6
|
||||||
|
|
||||||
@ -37,7 +27,6 @@ const viewportNode = ref<HTMLElement>()
|
|||||||
const navigator = useNavigator(viewportNode)
|
const navigator = useNavigator(viewportNode)
|
||||||
const graphStore = useGraphStore()
|
const graphStore = useGraphStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const executionCtx = shallowRef<ExecutionContext>()
|
|
||||||
const componentBrowserVisible = ref(false)
|
const componentBrowserVisible = ref(false)
|
||||||
const componentBrowserPosition = ref(Vec2.Zero())
|
const componentBrowserPosition = ref(Vec2.Zero())
|
||||||
const suggestionDb = useSuggestionDbStore()
|
const suggestionDb = useSuggestionDbStore()
|
||||||
@ -47,16 +36,6 @@ const exprRects = reactive(new Map<ExprId, Rect>())
|
|||||||
const selectedNodes = ref(new Set<ExprId>())
|
const selectedNodes = ref(new Set<ExprId>())
|
||||||
const latestSelectedNode = ref<ExprId>()
|
const latestSelectedNode = ref<ExprId>()
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const executionCtxPromise = projectStore.createExecutionContextForMain()
|
|
||||||
onUnmounted(async () => {
|
|
||||||
executionCtx.value = undefined
|
|
||||||
const ctx = await executionCtxPromise
|
|
||||||
if (ctx != null) ctx.destroy()
|
|
||||||
})
|
|
||||||
executionCtx.value = (await executionCtxPromise) ?? undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateNodeRect(id: ExprId, rect: Rect) {
|
function updateNodeRect(id: ExprId, rect: Rect) {
|
||||||
nodeRects.set(id, rect)
|
nodeRects.set(id, rect)
|
||||||
}
|
}
|
||||||
@ -66,10 +45,7 @@ function updateExprRect(id: ExprId, rect: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useWindowEvent('keydown', (event) => {
|
useWindowEvent('keydown', (event) => {
|
||||||
if (keyboardBusy()) {
|
graphBindingsHandler(event) || nodeSelectionHandler(event) || codeEditorHandler(event)
|
||||||
return
|
|
||||||
}
|
|
||||||
graphBindingsHandler(event) || nodeSelectionHandler(event)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => viewportNode.value?.focus())
|
onMounted(() => viewportNode.value?.focus())
|
||||||
@ -84,13 +60,15 @@ function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
|
|||||||
|
|
||||||
function moveNode(id: ExprId, delta: Vec2) {
|
function moveNode(id: ExprId, delta: Vec2) {
|
||||||
const scaledDelta = delta.scale(1 / navigator.scale)
|
const scaledDelta = delta.scale(1 / navigator.scale)
|
||||||
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
|
graphStore.transact(() => {
|
||||||
const node = graphStore.nodes.get(id_)
|
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
|
||||||
if (node == null) {
|
const node = graphStore.nodes.get(id_)
|
||||||
continue
|
if (node == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
|
||||||
}
|
}
|
||||||
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionAnchor = shallowRef<Vec2>()
|
const selectionAnchor = shallowRef<Vec2>()
|
||||||
@ -168,15 +146,13 @@ function updateLatestSelectedNode(id: ExprId) {
|
|||||||
|
|
||||||
const graphBindingsHandler = graphBindings.handler({
|
const graphBindingsHandler = graphBindings.handler({
|
||||||
undo() {
|
undo() {
|
||||||
projectStore.undoManager.undo()
|
projectStore.module?.undoManager.undo()
|
||||||
},
|
},
|
||||||
redo() {
|
redo() {
|
||||||
projectStore.undoManager.redo()
|
projectStore.module?.undoManager.redo()
|
||||||
},
|
},
|
||||||
openComponentBrowser() {
|
openComponentBrowser() {
|
||||||
if (keyboardBusy()) {
|
if (keyboardBusy()) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
||||||
componentBrowserPosition.value = navigator.sceneMousePos
|
componentBrowserPosition.value = navigator.sceneMousePos
|
||||||
componentBrowserVisible.value = true
|
componentBrowserVisible.value = true
|
||||||
@ -189,13 +165,25 @@ const graphBindingsHandler = graphBindings.handler({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const codeEditorArea = ref<HTMLElement>()
|
||||||
|
const showCodeEditor = ref(false)
|
||||||
|
const codeEditorHandler = codeEditorBindings.handler({
|
||||||
|
toggle() {
|
||||||
|
if (keyboardBusyExceptIn(codeEditorArea.value)) return false
|
||||||
|
showCodeEditor.value = !showCodeEditor.value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const nodeSelectionHandler = nodeBindings.handler({
|
const nodeSelectionHandler = nodeBindings.handler({
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
for (const node of selectedNodes.value) {
|
graphStore.transact(() => {
|
||||||
graphStore.deleteNode(node)
|
for (const node of selectedNodes.value) {
|
||||||
}
|
graphStore.deleteNode(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
selectAll() {
|
selectAll() {
|
||||||
|
if (keyboardBusy()) return
|
||||||
for (const id of graphStore.nodes.keys()) {
|
for (const id of graphStore.nodes.keys()) {
|
||||||
selectedNodes.value.add(id)
|
selectedNodes.value.add(id)
|
||||||
}
|
}
|
||||||
@ -203,6 +191,19 @@ const nodeSelectionHandler = nodeBindings.handler({
|
|||||||
deselectAll() {
|
deselectAll() {
|
||||||
clearSelection()
|
clearSelection()
|
||||||
selectedNodes.value.clear()
|
selectedNodes.value.clear()
|
||||||
|
graphStore.stopCapturingUndo()
|
||||||
|
},
|
||||||
|
toggleVisualization() {
|
||||||
|
if (keyboardBusy()) return false
|
||||||
|
graphStore.transact(() => {
|
||||||
|
const allHidden = set
|
||||||
|
.toArray(selectedNodes.value)
|
||||||
|
.every((id) => graphStore.nodes.get(id)?.vis?.visible !== true)
|
||||||
|
|
||||||
|
for (const nodeId of selectedNodes.value) {
|
||||||
|
graphStore.setNodeVisualizationVisible(nodeId, allHidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -291,9 +292,9 @@ const groupColors = computed(() => {
|
|||||||
v-for="(edge, index) in graphStore.edges"
|
v-for="(edge, index) in graphStore.edges"
|
||||||
:key="index"
|
:key="index"
|
||||||
:edge="edge"
|
:edge="edge"
|
||||||
:node-rects="nodeRects"
|
:nodeRects="nodeRects"
|
||||||
:expr-rects="exprRects"
|
:exprRects="exprRects"
|
||||||
:expr-nodes="graphStore.exprNodes"
|
:exprNodes="graphStore.exprNodes"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
||||||
@ -302,7 +303,8 @@ const groupColors = computed(() => {
|
|||||||
:key="id"
|
:key="id"
|
||||||
:node="node"
|
:node="node"
|
||||||
:selected="selectedNodes.has(id)"
|
:selected="selectedNodes.has(id)"
|
||||||
:is-latest-selected="id === latestSelectedNode"
|
:isLatestSelected="id === latestSelectedNode"
|
||||||
|
:fullscreenVis="false"
|
||||||
@update:selected="setSelected(id, $event), $event && updateLatestSelectedNode(id)"
|
@update:selected="setSelected(id, $event), $event && updateLatestSelectedNode(id)"
|
||||||
@replaceSelection="
|
@replaceSelection="
|
||||||
selectedNodes.clear(), selectedNodes.add(id), updateLatestSelectedNode(id)
|
selectedNodes.clear(), selectedNodes.add(id), updateLatestSelectedNode(id)
|
||||||
@ -311,6 +313,8 @@ const groupColors = computed(() => {
|
|||||||
@delete="graphStore.deleteNode(id)"
|
@delete="graphStore.deleteNode(id)"
|
||||||
@updateExprRect="updateExprRect"
|
@updateExprRect="updateExprRect"
|
||||||
@updateContent="updateNodeContent(id, $event)"
|
@updateContent="updateNodeContent(id, $event)"
|
||||||
|
@setVisualizationId="graphStore.setNodeVisualizationId(id, $event)"
|
||||||
|
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
|
||||||
@movePosition="moveNode(id, $event)"
|
@movePosition="moveNode(id, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -330,7 +334,13 @@ const groupColors = computed(() => {
|
|||||||
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
|
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
|
||||||
@execute="console.log('\'execute\' button clicked.')"
|
@execute="console.log('\'execute\' button clicked.')"
|
||||||
/>
|
/>
|
||||||
<CodeEditor ref="codeEditor" />
|
<div ref="codeEditorArea">
|
||||||
|
<Suspense>
|
||||||
|
<Transition>
|
||||||
|
<CodeEditor v-if="showCodeEditor" />
|
||||||
|
</Transition>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
<SelectionBrush
|
<SelectionBrush
|
||||||
v-if="scaledMousePos"
|
v-if="scaledMousePos"
|
||||||
:position="scaledMousePos"
|
:position="scaledMousePos"
|
||||||
|
@ -1,20 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
|
import { nodeBindings } from '@/bindings'
|
||||||
|
|
||||||
import { nodeBindings } from '@/bindings/nodeSelection'
|
|
||||||
import CircularMenu from '@/components/CircularMenu.vue'
|
import CircularMenu from '@/components/CircularMenu.vue'
|
||||||
import NodeSpan from '@/components/NodeSpan.vue'
|
import NodeSpan from '@/components/NodeSpan.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||||
import {
|
import {
|
||||||
provideVisualizationConfig,
|
provideVisualizationConfig,
|
||||||
type VisualizationConfig,
|
type VisualizationConfig,
|
||||||
} from '@/providers/visualizationConfig'
|
} from '@/providers/visualizationConfig'
|
||||||
import type { Node } from '@/stores/graph'
|
import type { Node } from '@/stores/graph'
|
||||||
import { Rect } from '@/stores/rect'
|
import { Rect } from '@/stores/rect'
|
||||||
import { useVisualizationStore, type Visualization } from '@/stores/visualization'
|
import {
|
||||||
import { keyboardBusy, useDocumentEvent, usePointer, useResizeObserver } from '@/util/events'
|
DEFAULT_VISUALIZATION_CONFIGURATION,
|
||||||
|
DEFAULT_VISUALIZATION_IDENTIFIER,
|
||||||
|
useVisualizationStore,
|
||||||
|
type Visualization,
|
||||||
|
} from '@/stores/visualization'
|
||||||
|
import { usePointer, useResizeObserver } from '@/util/events'
|
||||||
|
import type { Opt } from '@/util/opt'
|
||||||
import type { Vec2 } from '@/util/vec2'
|
import type { Vec2 } from '@/util/vec2'
|
||||||
import type { ContentRange, ExprId } from 'shared/yjsModel'
|
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
|
||||||
|
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||||
|
import { useProjectStore } from '../stores/project'
|
||||||
|
|
||||||
const MAXIMUM_CLICK_LENGTH_MS = 300
|
const MAXIMUM_CLICK_LENGTH_MS = 300
|
||||||
|
|
||||||
@ -22,6 +29,7 @@ const props = defineProps<{
|
|||||||
node: Node
|
node: Node
|
||||||
selected: boolean
|
selected: boolean
|
||||||
isLatestSelected: boolean
|
isLatestSelected: boolean
|
||||||
|
fullscreenVis: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -29,6 +37,8 @@ const emit = defineEmits<{
|
|||||||
updateExprRect: [id: ExprId, rect: Rect]
|
updateExprRect: [id: ExprId, rect: Rect]
|
||||||
updateContent: [updates: [range: ContentRange, content: string][]]
|
updateContent: [updates: [range: ContentRange, content: string][]]
|
||||||
movePosition: [delta: Vec2]
|
movePosition: [delta: Vec2]
|
||||||
|
setVisualizationId: [id: Opt<VisualizationIdentifier>]
|
||||||
|
setVisualizationVisible: [visible: boolean]
|
||||||
delete: []
|
delete: []
|
||||||
replaceSelection: []
|
replaceSelection: []
|
||||||
'update:selected': [selected: boolean]
|
'update:selected': [selected: boolean]
|
||||||
@ -40,6 +50,29 @@ const rootNode = ref<HTMLElement>()
|
|||||||
const nodeSize = useResizeObserver(rootNode)
|
const nodeSize = useResizeObserver(rootNode)
|
||||||
const editableRootNode = ref<HTMLElement>()
|
const editableRootNode = ref<HTMLElement>()
|
||||||
|
|
||||||
|
type PreprocessorDef = {
|
||||||
|
visualizationModule: string
|
||||||
|
expression: string
|
||||||
|
positionalArgumentsExpressions: string[]
|
||||||
|
}
|
||||||
|
const visPreprocessor = ref<PreprocessorDef>(DEFAULT_VISUALIZATION_CONFIGURATION)
|
||||||
|
|
||||||
|
const isAutoEvaluationDisabled = ref(false)
|
||||||
|
const isDocsVisible = ref(false)
|
||||||
|
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
|
||||||
|
|
||||||
|
const visualization = shallowRef<Visualization>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
const visualizationData = projectStore.useVisualizationData(() => {
|
||||||
|
if (!isVisualizationVisible.value || !visPreprocessor.value) return
|
||||||
|
return {
|
||||||
|
...visPreprocessor.value,
|
||||||
|
expressionId: props.node.rootSpan.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const size = nodeSize.value
|
const size = nodeSize.value
|
||||||
if (!size.isZero()) {
|
if (!size.isZero()) {
|
||||||
@ -259,66 +292,59 @@ onUpdated(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const isAutoEvaluationDisabled = ref(false)
|
function updatePreprocessor(
|
||||||
const isDocsVisible = ref(false)
|
visualizationModule: string,
|
||||||
const isVisualizationVisible = ref(false)
|
expression: string,
|
||||||
|
...positionalArgumentsExpressions: string[]
|
||||||
|
) {
|
||||||
|
visPreprocessor.value = { visualizationModule, expression, positionalArgumentsExpressions }
|
||||||
|
}
|
||||||
|
|
||||||
const visualizationType = ref('Scatterplot')
|
function switchToDefaultPreprocessor() {
|
||||||
const visualization = shallowRef<Visualization>()
|
visPreprocessor.value = DEFAULT_VISUALIZATION_CONFIGURATION
|
||||||
|
}
|
||||||
const queuedVisualizationData = computed<{}>(() =>
|
|
||||||
visualizationStore.sampleData(visualizationType.value),
|
|
||||||
)
|
|
||||||
const visualizationData = ref<{}>({})
|
|
||||||
|
|
||||||
const visualizationConfig = ref<VisualizationConfig>({
|
const visualizationConfig = ref<VisualizationConfig>({
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
types: visualizationStore.types,
|
types: visualizationStore.types,
|
||||||
width: null,
|
width: null,
|
||||||
height: 150, // FIXME:
|
height: 150,
|
||||||
hide() {
|
hide() {
|
||||||
isVisualizationVisible.value = false
|
emit('setVisualizationVisible', false)
|
||||||
},
|
|
||||||
updateType(type) {
|
|
||||||
visualizationType.value = type
|
|
||||||
},
|
},
|
||||||
|
updateType: (id) => emit('setVisualizationId', id),
|
||||||
isCircularMenuVisible: props.isLatestSelected,
|
isCircularMenuVisible: props.isLatestSelected,
|
||||||
get nodeSize() {
|
get nodeSize() {
|
||||||
return nodeSize.value
|
return nodeSize.value
|
||||||
},
|
},
|
||||||
|
get currentType() {
|
||||||
|
return props.node.vis ?? DEFAULT_VISUALIZATION_IDENTIFIER
|
||||||
|
},
|
||||||
})
|
})
|
||||||
provideVisualizationConfig(visualizationConfig)
|
provideVisualizationConfig(visualizationConfig)
|
||||||
|
|
||||||
useDocumentEvent('keydown', (event) => {
|
watchEffect(async () => {
|
||||||
if (keyboardBusy()) {
|
if (props.node.vis == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === ' ') {
|
|
||||||
if (event.shiftKey) {
|
visualization.value = undefined
|
||||||
if (isVisualizationVisible.value) {
|
const module = await visualizationStore.get(props.node.vis)
|
||||||
visualizationConfig.value.fullscreen = !visualizationConfig.value.fullscreen
|
if (module) {
|
||||||
} else {
|
if (module.defaultPreprocessor != null) {
|
||||||
isVisualizationVisible.value = true
|
updatePreprocessor(...module.defaultPreprocessor)
|
||||||
visualizationConfig.value.fullscreen = true
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isVisualizationVisible.value = !isVisualizationVisible.value
|
switchToDefaultPreprocessor()
|
||||||
}
|
}
|
||||||
|
visualization.value = module.default
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(async (onCleanup) => {
|
const effectiveVisualization = computed(() => {
|
||||||
if (isVisualizationVisible.value) {
|
if (!visualization.value || visualizationData.value == null) {
|
||||||
let shouldSwitchVisualization = true
|
return LoadingVisualization
|
||||||
onCleanup(() => {
|
|
||||||
shouldSwitchVisualization = false
|
|
||||||
})
|
|
||||||
const component = await visualizationStore.get(visualizationType.value)
|
|
||||||
if (shouldSwitchVisualization) {
|
|
||||||
visualization.value = component
|
|
||||||
visualizationData.value = queuedVisualizationData.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return visualization.value
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -328,14 +354,6 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function updatePreprocessor(module: string, method: string, ...args: string[]) {
|
|
||||||
console.log(
|
|
||||||
`preprocessor changed. node id: ${
|
|
||||||
props.node.rootSpan.id
|
|
||||||
} module: ${module}, method: ${method}, args: [${args.join(', ')}]`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mouseHandler = nodeBindings.handler({
|
const mouseHandler = nodeBindings.handler({
|
||||||
replace() {
|
replace() {
|
||||||
emit('replaceSelection')
|
emit('replaceSelection')
|
||||||
@ -384,7 +402,11 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
ref="rootNode"
|
ref="rootNode"
|
||||||
class="GraphNode"
|
class="GraphNode"
|
||||||
:style="{ transform }"
|
:style="{ transform }"
|
||||||
:class="{ dragging: dragPointer.dragging, selected }"
|
:class="{
|
||||||
|
dragging: dragPointer.dragging,
|
||||||
|
selected,
|
||||||
|
visualizationVisible: isVisualizationVisible,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="selection" v-on="dragPointer.events"></div>
|
<div class="selection" v-on="dragPointer.events"></div>
|
||||||
<div class="binding" @pointerdown.stop>
|
<div class="binding" @pointerdown.stop>
|
||||||
@ -394,14 +416,14 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
v-if="isLatestSelected"
|
v-if="isLatestSelected"
|
||||||
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
|
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
|
||||||
v-model:is-docs-visible="isDocsVisible"
|
v-model:is-docs-visible="isDocsVisible"
|
||||||
v-model:is-visualization-visible="isVisualizationVisible"
|
:isVisualizationVisible="isVisualizationVisible"
|
||||||
|
@update:isVisualizationVisible="emit('setVisualizationVisible', $event)"
|
||||||
/>
|
/>
|
||||||
<component
|
<component
|
||||||
:is="visualization"
|
:is="effectiveVisualization"
|
||||||
v-if="isVisualizationVisible && visualization"
|
v-if="isVisualizationVisible && effectiveVisualization != null"
|
||||||
:data="visualizationData"
|
:data="visualizationData"
|
||||||
@update:preprocessor="updatePreprocessor"
|
@update:preprocessor="updatePreprocessor"
|
||||||
@update:type="visualizationType = $event"
|
|
||||||
/>
|
/>
|
||||||
<div class="node" v-on="dragPointer.events">
|
<div class="node" v-on="dragPointer.events">
|
||||||
<SvgIcon class="icon grab-handle" name="number_input"></SvgIcon>
|
<SvgIcon class="icon grab-handle" name="number_input"></SvgIcon>
|
||||||
@ -412,6 +434,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@beforeinput="editContent"
|
@beforeinput="editContent"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
|
@blur="projectStore.stopCapturingUndo()"
|
||||||
>
|
>
|
||||||
<NodeSpan
|
<NodeSpan
|
||||||
:content="node.content"
|
:content="node.content"
|
||||||
@ -426,10 +449,16 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.GraphNode {
|
.GraphNode {
|
||||||
|
--node-height: 32px;
|
||||||
|
--node-border-radius: calc(var(--node-height) * 0.5);
|
||||||
|
|
||||||
--node-color-primary: #357ab9;
|
--node-color-primary: #357ab9;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
transition: box-shadow 0.2s ease-in-out;
|
transition: box-shadow 0.2s ease-in-out;
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(255, 255, 255, 20%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
@ -437,9 +466,10 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
caret-shape: bar;
|
caret-shape: bar;
|
||||||
|
height: var(--node-height);
|
||||||
background: var(--node-color-primary);
|
background: var(--node-color-primary);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--node-border-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -447,17 +477,15 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.GraphNode .selection {
|
.GraphNode .selection {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(0px - var(--selected-node-border-width));
|
inset: calc(0px - var(--selected-node-border-width));
|
||||||
border-radius: var(--radius-full);
|
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
content: '';
|
content: '';
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--node-border-radius);
|
||||||
display: block;
|
display: block;
|
||||||
inset: var(--selected-node-border-width);
|
inset: var(--selected-node-border-width);
|
||||||
box-shadow: 0 0 0 0 var(--node-color-primary);
|
box-shadow: 0 0 0 0 var(--node-color-primary);
|
||||||
@ -468,7 +496,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.GraphNode.selected .selection:before,
|
.GraphNode:is(:hover, .selected) .selection:before,
|
||||||
.GraphNode .selection:hover:before {
|
.GraphNode .selection:hover:before {
|
||||||
box-shadow: 0 0 0 var(--selected-node-border-width) var(--node-color-primary);
|
box-shadow: 0 0 0 var(--selected-node-border-width) var(--node-color-primary);
|
||||||
}
|
}
|
||||||
@ -483,7 +511,6 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
.GraphNode.selected .selection:hover:before {
|
.GraphNode.selected .selection:hover:before {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.binding {
|
.binding {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
@ -492,6 +519,13 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
right: 100%;
|
right: 100%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.GraphNode .selection:hover + .binding,
|
||||||
|
.GraphNode.selected .binding {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable {
|
.editable {
|
||||||
@ -511,13 +545,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visualization {
|
.CircularMenu {
|
||||||
position: absolute;
|
z-index: 1;
|
||||||
top: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 4px;
|
|
||||||
background: #222;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -10,7 +10,7 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>()
|
|||||||
<span class="title" v-text="props.title"></span>
|
<span class="title" v-text="props.title"></span>
|
||||||
<ExecutionModeSelector
|
<ExecutionModeSelector
|
||||||
:modes="props.modes"
|
:modes="props.modes"
|
||||||
:model-value="props.mode"
|
:modelValue="props.mode"
|
||||||
@execute="emit('execute')"
|
@execute="emit('execute')"
|
||||||
@update:modelValue="emit('update:mode', $event)"
|
@update:modelValue="emit('update:mode', $event)"
|
||||||
/>
|
/>
|
||||||
|
@ -127,8 +127,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
|||||||
<VisualizationSelector
|
<VisualizationSelector
|
||||||
v-if="isSelectorVisible"
|
v-if="isSelectorVisible"
|
||||||
:types="config.types"
|
:types="config.types"
|
||||||
|
:modelValue="config.currentType"
|
||||||
@hide="isSelectorVisible = false"
|
@hide="isSelectorVisible = false"
|
||||||
@update:type="
|
@update:modelValue="
|
||||||
(type) => {
|
(type) => {
|
||||||
isSelectorVisible = false
|
isSelectorVisible = false
|
||||||
config.updateType(type)
|
config.updateType(type)
|
||||||
@ -148,12 +149,11 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.VisualizationContainer {
|
.VisualizationContainer {
|
||||||
|
color: var(--color-text);
|
||||||
background: var(--color-visualization-bg);
|
background: var(--color-visualization-bg);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
width: min-content;
|
width: min-content;
|
||||||
color: var(--color-text);
|
|
||||||
z-index: -1;
|
|
||||||
border-radius: var(--radius-default);
|
border-radius: var(--radius-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,11 +189,6 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
|||||||
transition-property: padding-left;
|
transition-property: padding-left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.VisualizationContainer.fullscreen .toolbars,
|
|
||||||
.VisualizationContainer:not(.circular-menu-visible) .toolbars {
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
|
||||||
import { useDocumentEvent } from '../util/events'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ types: string[] }>()
|
const props = defineProps<{
|
||||||
const emit = defineEmits<{ hide: []; 'update:type': [type: string] }>()
|
types: VisualizationIdentifier[]
|
||||||
|
modelValue: VisualizationIdentifier
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
|
||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
const rootNode = ref<HTMLElement>()
|
||||||
|
|
||||||
useDocumentEvent('pointerdown', (event) => {
|
function visIdLabel(id: VisualizationIdentifier) {
|
||||||
if (event.target instanceof Node && rootNode.value?.contains(event.target)) {
|
switch (id.module.kind) {
|
||||||
return
|
case 'Builtin':
|
||||||
|
return id.name
|
||||||
|
case 'Library':
|
||||||
|
return `${id.name} (from library ${id.module.name})`
|
||||||
|
case 'CurrentProject':
|
||||||
|
return `${id.name} (from project)`
|
||||||
}
|
}
|
||||||
emit('hide')
|
}
|
||||||
|
|
||||||
|
function visIdKey(id: VisualizationIdentifier) {
|
||||||
|
const kindKey = id.module.kind === 'Library' ? `Library::${id.module.name}` : id.module.kind
|
||||||
|
return `${kindKey}::${id.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => rootNode.value?.focus(), 0)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootNode" class="VisualizationSelector">
|
<div ref="rootNode" :tabindex="-1" class="VisualizationSelector" @blur="emit('hide')">
|
||||||
<div class="background"></div>
|
<div class="background"></div>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="type_ in props.types"
|
v-for="type_ in props.types"
|
||||||
:key="type_"
|
:key="visIdKey(type_)"
|
||||||
@click="emit('update:type', type_)"
|
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
|
||||||
v-text="type_"
|
@pointerdown.stop="emit('update:modelValue', type_)"
|
||||||
|
v-text="visIdLabel(type_)"
|
||||||
></li>
|
></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -67,6 +84,10 @@ li {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--color-menu-entry-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-menu-entry-hover-bg);
|
background: var(--color-menu-entry-hover-bg);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export const name = 'Heatmap'
|
export const name = 'Heatmap'
|
||||||
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.Table.Visualization',
|
||||||
|
'prepare_visualization',
|
||||||
|
] as const
|
||||||
|
|
||||||
type Data = HeatmapData | HeatmapArrayData | HeatmapJSONData | HeatmapUpdate
|
type Data = HeatmapData | HeatmapArrayData | HeatmapJSONData | HeatmapUpdate
|
||||||
|
|
||||||
@ -36,16 +40,14 @@ interface Bucket {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
import { computed, onMounted, ref, watchPostEffect } from 'vue'
|
|
||||||
|
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig.ts'
|
import { useVisualizationConfig } from '@/providers/visualizationConfig.ts'
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
@ -101,10 +103,6 @@ watchPostEffect(() => {
|
|||||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||||
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:preprocessor', 'Standard.Visualization.Table.Visualization', 'prepare_visualization')
|
|
||||||
})
|
|
||||||
|
|
||||||
const buckets = computed(() => {
|
const buckets = computed(() => {
|
||||||
const newData = data.value
|
const newData = data.value
|
||||||
let groups: number[] = []
|
let groups: number[] = []
|
||||||
@ -213,7 +211,7 @@ watchPostEffect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<div ref="containerNode" class="HeatmapVisualization">
|
<div ref="containerNode" class="HeatmapVisualization">
|
||||||
<svg :width="width" :height="height">
|
<svg :width="width" :height="height">
|
||||||
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineKeybinds } from '@/util/shortcuts'
|
import { defineKeybinds } from '@/util/shortcuts'
|
||||||
|
|
||||||
export const name = 'Histogram'
|
export const name = 'Histogram'
|
||||||
export const inputType =
|
export const inputType =
|
||||||
'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector | Standard.Image.Data.Histogram.Histogram'
|
'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector | Standard.Image.Data.Histogram.Histogram'
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.Histogram',
|
||||||
|
'process_to_json_text',
|
||||||
|
] as const
|
||||||
|
|
||||||
const bindings = defineKeybinds('scatterplot-visualization', {
|
const bindings = defineKeybinds('histogram-visualization', {
|
||||||
zoomIn: ['Mod+Z'],
|
zoomIn: ['Mod+Z'],
|
||||||
showAll: ['Mod+A'],
|
showAll: ['Mod+A'],
|
||||||
})
|
})
|
||||||
@ -88,8 +93,8 @@ interface AxisConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AxesConfiguration {
|
interface AxesConfiguration {
|
||||||
x: AxisConfiguration
|
x: AxisConfiguration | undefined
|
||||||
y: AxisConfiguration
|
y: AxisConfiguration | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Bin {
|
interface Bin {
|
||||||
@ -100,8 +105,9 @@ interface Bin {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
|
||||||
|
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
@ -130,9 +136,6 @@ const MID_BUTTON_CLICKED = 4
|
|||||||
const SCROLL_WHEEL = 0
|
const SCROLL_WHEEL = 0
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const config = useVisualizationConfig()
|
const config = useVisualizationConfig()
|
||||||
|
|
||||||
@ -155,7 +158,7 @@ const points = ref<number[]>([])
|
|||||||
const rawBins = ref<number[]>()
|
const rawBins = ref<number[]>()
|
||||||
const binCount = ref(DEFAULT_NUMBER_OF_BINS)
|
const binCount = ref(DEFAULT_NUMBER_OF_BINS)
|
||||||
const axis = ref(DEFAULT_AXES_CONFIGURATION)
|
const axis = ref(DEFAULT_AXES_CONFIGURATION)
|
||||||
const rawFocus = ref<Focus>()
|
const focus = ref<Focus>()
|
||||||
const brushExtent = ref<d3.BrushSelection>()
|
const brushExtent = ref<d3.BrushSelection>()
|
||||||
const zoomLevel = ref(1)
|
const zoomLevel = ref(1)
|
||||||
const shouldAnimate = ref(false)
|
const shouldAnimate = ref(false)
|
||||||
@ -224,7 +227,7 @@ watchEffect(() => {
|
|||||||
axis.value = rawData.axis
|
axis.value = rawData.axis
|
||||||
}
|
}
|
||||||
if (rawData.focus != null) {
|
if (rawData.focus != null) {
|
||||||
rawFocus.value = rawData.focus
|
focus.value = rawData.focus
|
||||||
}
|
}
|
||||||
if (rawData.bins != null) {
|
if (rawData.bins != null) {
|
||||||
binCount.value = Math.max(1, rawData.bins)
|
binCount.value = Math.max(1, rawData.bins)
|
||||||
@ -263,9 +266,9 @@ watchPostEffect(() => {
|
|||||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||||
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
||||||
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x.label) / 2)
|
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x?.label) / 2)
|
||||||
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
|
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
|
||||||
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y.label) / 2)
|
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y?.label) / 2)
|
||||||
|
|
||||||
let startX = 0
|
let startX = 0
|
||||||
let startY = 0
|
let startY = 0
|
||||||
@ -416,7 +419,7 @@ function zoomToSelected() {
|
|||||||
if (brushExtent.value == null) {
|
if (brushExtent.value == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rawFocus.value = undefined
|
focus.value = undefined
|
||||||
const xScale_ = xScale.value
|
const xScale_ = xScale.value
|
||||||
const startRaw = brushExtent.value[0]
|
const startRaw = brushExtent.value[0]
|
||||||
const endRaw = brushExtent.value[1]
|
const endRaw = brushExtent.value[1]
|
||||||
@ -468,7 +471,7 @@ const xExtents = computed<[min: number, max: number]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const focus_ = rawFocus.value
|
const focus_ = focus.value
|
||||||
if (focus_?.x != null && focus_.zoom != null) {
|
if (focus_?.x != null && focus_.zoom != null) {
|
||||||
let paddingX = extremesAndDeltas.value.dx / (2 * focus_.zoom)
|
let paddingX = extremesAndDeltas.value.dx / (2 * focus_.zoom)
|
||||||
xDomain.value = [focus_.x - paddingX, focus_.x + paddingX]
|
xDomain.value = [focus_.x - paddingX, focus_.x + paddingX]
|
||||||
@ -496,14 +499,6 @@ function updateColorLegend(colorScale: d3.ScaleSequential<string>) {
|
|||||||
.attr('stop-color', (d) => d.color)
|
.attr('stop-color', (d) => d.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Setup ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:preprocessor', 'Standard.Visualization.Histogram', 'process_to_json_text')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==============
|
// ==============
|
||||||
// === Update ===
|
// === Update ===
|
||||||
// ==============
|
// ==============
|
||||||
@ -566,7 +561,7 @@ watchPostEffect(() => {
|
|||||||
// ======================
|
// ======================
|
||||||
|
|
||||||
function showAll() {
|
function showAll() {
|
||||||
rawFocus.value = undefined
|
focus.value = undefined
|
||||||
zoomLevel.value = 1
|
zoomLevel.value = 1
|
||||||
xDomain.value = originalXScale.value.domain()
|
xDomain.value = originalXScale.value.domain()
|
||||||
shouldAnimate.value = true
|
shouldAnimate.value = true
|
||||||
@ -581,7 +576,7 @@ useEvent(document, 'scroll', endBrushing)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<button class="image-button active">
|
<button class="image-button active">
|
||||||
<SvgIcon name="show_all" alt="Fit all" @click="showAll" />
|
<SvgIcon name="show_all" alt="Fit all" @click="showAll" />
|
||||||
@ -616,20 +611,20 @@ useEvent(document, 'scroll', endBrushing)
|
|||||||
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
|
||||||
<g ref="yAxisNode" class="axis-y"></g>
|
<g ref="yAxisNode" class="axis-y"></g>
|
||||||
<text
|
<text
|
||||||
v-if="axis.x.label"
|
v-if="axis.x?.label"
|
||||||
class="label label-x"
|
class="label label-x"
|
||||||
text-anchor="end"
|
text-anchor="end"
|
||||||
:x="xLabelLeft"
|
:x="xLabelLeft"
|
||||||
:y="xLabelTop"
|
:y="xLabelTop"
|
||||||
v-text="axis.x.label"
|
v-text="axis.x?.label"
|
||||||
></text>
|
></text>
|
||||||
<text
|
<text
|
||||||
v-if="axis.y.label"
|
v-if="axis.y?.label"
|
||||||
class="label label-y"
|
class="label label-y"
|
||||||
text-anchor="end"
|
text-anchor="end"
|
||||||
:x="yLabelLeft"
|
:x="yLabelLeft"
|
||||||
:y="yLabelTop"
|
:y="yLabelTop"
|
||||||
v-text="axis.y.label"
|
v-text="axis.y?.label"
|
||||||
></text>
|
></text>
|
||||||
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
|
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
|
||||||
<g ref="zoomNode" class="zoom">
|
<g ref="zoomNode" class="zoom">
|
||||||
|
@ -23,7 +23,7 @@ const src = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-node="true">
|
<VisualizationContainer :belowNode="true">
|
||||||
<div class="ImageVisualization">
|
<div class="ImageVisualization">
|
||||||
<img :src="src" />
|
<img :src="src" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,24 +4,13 @@ export const inputType = 'Any'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
|
||||||
|
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
|
|
||||||
type Data = Record<string, unknown>
|
const props = defineProps<{ data: unknown }>()
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:preprocessor', 'Standard.Visualization.Preprocessor', 'error_preprocessor')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<div class="JSONVisualization" v-text="props.data"></div>
|
<div class="JSONVisualization" v-text="props.data"></div>
|
||||||
</VisualizationContainer>
|
</VisualizationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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>
|
||||||
|
</VisualizationContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.LoadingVisualization {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 30px;
|
||||||
|
place-content: center;
|
||||||
|
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>
|
@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export const name = 'SQL Query'
|
export const name = 'SQL Query'
|
||||||
export const inputType = 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
|
export const inputType = 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.SQL.Visualization',
|
||||||
|
'prepare_visualization',
|
||||||
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A visualization that pretty-prints generated SQL code and displays type hints related to
|
* A visualization that pretty-prints generated SQL code and displays type hints related to
|
||||||
@ -32,7 +36,7 @@ declare const sqlFormatter: typeof import('sql-formatter')
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
// eslint-disable-next-line no-redeclare
|
// eslint-disable-next-line no-redeclare
|
||||||
@ -42,9 +46,6 @@ import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
|||||||
import { DEFAULT_THEME, type RGBA, type Theme } from './builtins.ts'
|
import { DEFAULT_THEME, type RGBA, type Theme } from './builtins.ts'
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const theme: Theme = DEFAULT_THEME
|
const theme: Theme = DEFAULT_THEME
|
||||||
|
|
||||||
@ -72,10 +73,6 @@ const TEXT_TYPE = 'Builtins.Main.Text'
|
|||||||
/** Specifies opacity of interpolation background color. */
|
/** Specifies opacity of interpolation background color. */
|
||||||
const INTERPOLATION_BACKGROUND_OPACITY = 0.2
|
const INTERPOLATION_BACKGROUND_OPACITY = 0.2
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:preprocessor', 'Standard.Visualization.SQL.Visualization', 'prepare_visualization')
|
|
||||||
})
|
|
||||||
|
|
||||||
// === Handling Colors ===
|
// === Handling Colors ===
|
||||||
|
|
||||||
/** Render a 4-element array representing a color into a CSS-compatible rgba string. */
|
/** Render a 4-element array representing a color into a CSS-compatible rgba string. */
|
||||||
@ -130,7 +127,7 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<div class="sql-visualization scrollable">
|
<div class="sql-visualization scrollable">
|
||||||
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
|
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html This is SAFE, beause it is not user input. -->
|
<!-- eslint-disable-next-line vue/no-v-html This is SAFE, beause it is not user input. -->
|
||||||
@ -142,32 +139,32 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||||
|
|
||||||
.sql-visualization {
|
.SQLVisualization {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sql-visualization .sql {
|
.SQLVisualization .sql {
|
||||||
font-family: 'DejaVu Sans Mono', monospace;
|
font-family: 'DejaVu Sans Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 7px;
|
margin-left: 7px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .interpolation {
|
.SQLVisualization .interpolation {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 1px 2px 1px 2px;
|
padding: 1px 2px 1px 2px;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .mismatch-parent {
|
.SQLVisualization .mismatch-parent {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .mismatch-mouse-area {
|
.SQLVisualization .mismatch-mouse-area {
|
||||||
display: inline;
|
display: inline;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 150%;
|
width: 150%;
|
||||||
@ -176,15 +173,15 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .mismatch {
|
.SQLVisualization .mismatch {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .modulepath {
|
.SQLVisualization .modulepath {
|
||||||
color: rgba(150, 150, 150, 0.9);
|
color: rgba(150, 150, 150, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sql-visualization .tooltip {
|
.SQLVisualization .tooltip {
|
||||||
font-family: DejaVuSansMonoBook, sans-serif;
|
font-family: DejaVuSansMonoBook, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
@ -2,6 +2,11 @@
|
|||||||
export const name = 'Table'
|
export const name = 'Table'
|
||||||
export const inputType =
|
export const inputType =
|
||||||
'Standard.Table.Data.Table.Table | Standard.Table.Data.Column.Column | Standard.Table.Data.Row.Row |Standard.Base.Data.Vector.Vector | Standard.Base.Data.Array.Array | Standard.Base.Data.Map.Map | Any'
|
'Standard.Table.Data.Table.Table | Standard.Table.Data.Column.Column | Standard.Table.Data.Row.Row |Standard.Base.Data.Vector.Vector | Standard.Base.Data.Array.Array | Standard.Base.Data.Map.Map | Any'
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.Table.Visualization',
|
||||||
|
'prepare_visualization',
|
||||||
|
'1000',
|
||||||
|
] as const
|
||||||
|
|
||||||
type Data = Error | Matrix | ObjectMatrix | LegacyMatrix | LegacyObjectMatrix | UnknownTable
|
type Data = Error | Matrix | ObjectMatrix | LegacyMatrix | LegacyObjectMatrix | UnknownTable
|
||||||
|
|
||||||
@ -408,8 +413,8 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true" :overflow="true">
|
||||||
<div ref="rootNode" class="TableVisualization" @wheel.stop>
|
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
|
||||||
<div class="table-visualization-status-bar">
|
<div class="table-visualization-status-bar">
|
||||||
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
|
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
|
||||||
<button :disabled="isFirstPage" @click="goToPreviousPage">‹</button>
|
<button :disabled="isFirstPage" @click="goToPreviousPage">‹</button>
|
||||||
|
@ -1,27 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export const name = 'Warnings'
|
export const name = 'Warnings'
|
||||||
export const inputType = 'Any'
|
export const inputType = 'Any'
|
||||||
|
export const defaultPreprocessor = [
|
||||||
|
'Standard.Visualization.Warnings',
|
||||||
|
'process_to_json_text',
|
||||||
|
] as const
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
|
||||||
|
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
|
|
||||||
type Data = string[]
|
type Data = string[]
|
||||||
|
|
||||||
const props = defineProps<{ data: Data }>()
|
const props = defineProps<{ data: Data }>()
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:preprocessor', 'Standard.Visualization.Warnings', 'process_to_json_text')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualizationContainer :below-toolbar="true">
|
<VisualizationContainer :belowToolbar="true">
|
||||||
<div class="WarningsVisualization">
|
<div class="WarningsVisualization">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-if="props.data.length === 0">There are no warnings.</li>
|
<li v-if="props.data.length === 0">There are no warnings.</li>
|
||||||
|
16
app/gui2/src/createApp.ts
Normal file
16
app/gui2/src/createApp.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import AppRoot from './App.vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
import type { StringConfig } from './main'
|
||||||
|
|
||||||
|
export function mountProjectApp(rootProps: {
|
||||||
|
config: StringConfig | null
|
||||||
|
accessToken: string | null
|
||||||
|
metadata?: object | undefined
|
||||||
|
}) {
|
||||||
|
const app = createApp(AppRoot, rootProps)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.mount('#app')
|
||||||
|
return app
|
||||||
|
}
|
@ -2,16 +2,9 @@ import 'enso-dashboard/src/tailwind.css'
|
|||||||
|
|
||||||
const INITIAL_URL_KEY = `Enso-initial-url`
|
const INITIAL_URL_KEY = `Enso-initial-url`
|
||||||
|
|
||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
import { basicSetup } from 'codemirror'
|
|
||||||
import * as dashboard from 'enso-authentication'
|
import * as dashboard from 'enso-authentication'
|
||||||
import { isMac } from 'lib0/environment'
|
import { isMac } from 'lib0/environment'
|
||||||
import { decodeQueryParams } from 'lib0/url'
|
import { decodeQueryParams } from 'lib0/url'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import { createApp, type App } from 'vue'
|
|
||||||
import VueCodemirror from 'vue-codemirror'
|
|
||||||
import AppRoot from './App.vue'
|
|
||||||
|
|
||||||
const params = decodeQueryParams(location.href)
|
const params = decodeQueryParams(location.href)
|
||||||
|
|
||||||
@ -24,25 +17,29 @@ const config = {
|
|||||||
initialProjectName: params.project ?? null,
|
initialProjectName: params.project ?? null,
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App | null = null
|
let unmount: null | (() => void) = null
|
||||||
|
let runRequested = false
|
||||||
|
|
||||||
interface StringConfig {
|
export interface StringConfig {
|
||||||
[key: string]: StringConfig | string
|
[key: string]: StringConfig | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vueAppEntry = import('./createApp')
|
||||||
|
|
||||||
async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) {
|
async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) {
|
||||||
if (app != null) stopApp()
|
runRequested = true
|
||||||
const rootProps = { config, accessToken, metadata }
|
const { mountProjectApp } = await vueAppEntry
|
||||||
app = createApp(AppRoot, rootProps)
|
if (runRequested) {
|
||||||
app.use(createPinia())
|
unmount?.()
|
||||||
app.use(VueCodemirror, { extensions: [basicSetup] })
|
const app = mountProjectApp({ config, accessToken, metadata })
|
||||||
app.mount('#app')
|
unmount = () => app.unmount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopApp() {
|
function stopApp() {
|
||||||
if (app == null) return
|
runRequested = false
|
||||||
app.unmount()
|
unmount?.()
|
||||||
app = null
|
unmount = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const appRunner = { runApp, stopApp }
|
const appRunner = { runApp, stopApp }
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
import type { Vec2 } from '@/util/vec2'
|
import type { Vec2 } from '@/util/vec2'
|
||||||
|
import type { VisualizationIdentifier } from 'shared/yjsModel'
|
||||||
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||||
|
|
||||||
export interface VisualizationConfig {
|
export interface VisualizationConfig {
|
||||||
/** Possible visualization types that can be switched to. */
|
/** Possible visualization types that can be switched to. */
|
||||||
background?: string
|
background?: string
|
||||||
readonly types: string[]
|
readonly types: VisualizationIdentifier[]
|
||||||
|
readonly currentType: VisualizationIdentifier
|
||||||
readonly isCircularMenuVisible: boolean
|
readonly isCircularMenuVisible: boolean
|
||||||
readonly nodeSize: Vec2
|
readonly nodeSize: Vec2
|
||||||
width: number | null
|
width: number | null
|
||||||
height: number | null
|
height: number | null
|
||||||
fullscreen: boolean
|
fullscreen: boolean
|
||||||
hide: () => void
|
hide: () => void
|
||||||
updateType: (type: string) => void
|
updateType: (type: VisualizationIdentifier) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const provideKey = Symbol('visualizationConfig') as InjectionKey<Ref<VisualizationConfig>>
|
const provideKey = Symbol('visualizationConfig') as InjectionKey<Ref<VisualizationConfig>>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { assert, assertNever } from '@/util/assert'
|
import { assert, assertNever } from '@/util/assert'
|
||||||
import { useObserveYjs } from '@/util/crdt'
|
import { useObserveYjs } from '@/util/crdt'
|
||||||
import { parseEnso } from '@/util/ffi'
|
import { parseEnso, type Ast } from '@/util/ffi'
|
||||||
import type { Opt } from '@/util/opt'
|
import type { Opt } from '@/util/opt'
|
||||||
import { Vec2 } from '@/util/vec2'
|
import { Vec2 } from '@/util/vec2'
|
||||||
import * as map from 'lib0/map'
|
import * as map from 'lib0/map'
|
||||||
@ -8,14 +8,18 @@ import * as set from 'lib0/set'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import {
|
import {
|
||||||
rangeIntersects,
|
rangeIntersects,
|
||||||
|
visMetadataEquals,
|
||||||
type ContentRange,
|
type ContentRange,
|
||||||
type ExprId,
|
type ExprId,
|
||||||
type IdMap,
|
type IdMap,
|
||||||
type NodeMetadata,
|
type NodeMetadata,
|
||||||
|
type VisualizationIdentifier,
|
||||||
|
type VisualizationMetadata,
|
||||||
} from 'shared/yjsModel'
|
} from 'shared/yjsModel'
|
||||||
import { computed, reactive, ref, watch, watchEffect } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import { useProjectStore } from './project'
|
import { useProjectStore } from './project'
|
||||||
|
import { DEFAULT_VISUALIZATION_IDENTIFIER } from './visualization'
|
||||||
|
|
||||||
export const useGraphStore = defineStore('graph', () => {
|
export const useGraphStore = defineStore('graph', () => {
|
||||||
const proj = useProjectStore()
|
const proj = useProjectStore()
|
||||||
@ -73,10 +77,8 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
if (value != null) updateState()
|
if (value != null) updateState()
|
||||||
})
|
})
|
||||||
|
|
||||||
const _parsed = ref([] as Statement[])
|
const _parsed = ref<Statement[]>([])
|
||||||
const _parsedEnso = ref<any>()
|
const _parsedEnso = ref<Ast.Tree>()
|
||||||
|
|
||||||
watchEffect(() => {})
|
|
||||||
|
|
||||||
function updateState(affectedRanges?: ContentRange[]) {
|
function updateState(affectedRanges?: ContentRange[]) {
|
||||||
const module = proj.module
|
const module = proj.module
|
||||||
@ -107,8 +109,8 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
while (affectedRanges[0]?.[1]! < exprRange[0]) {
|
while (affectedRanges[0]?.[1]! < exprRange[0]) {
|
||||||
affectedRanges.shift()
|
affectedRanges.shift()
|
||||||
}
|
}
|
||||||
if (affectedRanges.length === 0) break
|
if (affectedRanges[0] == null) break
|
||||||
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]!)
|
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
|
||||||
if (!nodeAffected) continue
|
if (!nodeAffected) continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,13 +134,8 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
const data = meta.get(id)
|
const data = meta.get(id)
|
||||||
const node = nodes.get(id as ExprId)
|
const node = nodes.get(id as ExprId)
|
||||||
if (data != null && node != null) {
|
if (data != null && node != null) {
|
||||||
const pos = new Vec2(data.x, -data.y)
|
assignUpdatedMetadata(node, data)
|
||||||
if (!node.position.equals(pos)) {
|
|
||||||
node.position = pos
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(op)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -152,12 +149,16 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
content,
|
content,
|
||||||
binding: stmt.binding ?? '',
|
binding: stmt.binding ?? '',
|
||||||
rootSpan: stmt.expression,
|
rootSpan: stmt.expression,
|
||||||
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, -meta.y),
|
position: Vec2.Zero(),
|
||||||
|
vis: undefined,
|
||||||
docRange: [
|
docRange: [
|
||||||
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset, -1),
|
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset, -1),
|
||||||
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
|
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
if (meta) {
|
||||||
|
assignUpdatedMetadata(node, meta)
|
||||||
|
}
|
||||||
identDefinitions.set(node.binding, nodeId)
|
identDefinitions.set(node.binding, nodeId)
|
||||||
addSpanUsages(nodeId, node)
|
addSpanUsages(nodeId, node)
|
||||||
nodes.set(nodeId, node)
|
nodes.set(nodeId, node)
|
||||||
@ -177,12 +178,22 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
} else {
|
} else {
|
||||||
node.rootSpan = stmt.expression
|
node.rootSpan = stmt.expression
|
||||||
}
|
}
|
||||||
if (meta != null && !node.position.equals(new Vec2(meta.x, -meta.y))) {
|
if (meta != null) {
|
||||||
node.position = new Vec2(meta.x, -meta.y)
|
assignUpdatedMetadata(node, meta)
|
||||||
}
|
}
|
||||||
addSpanUsages(nodeId, node)
|
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) {
|
function addSpanUsages(id: ExprId, node: Node) {
|
||||||
for (const [span, offset] of walkSpansBfs(node.rootSpan)) {
|
for (const [span, offset] of walkSpansBfs(node.rootSpan)) {
|
||||||
exprNodes.set(span.id, id)
|
exprNodes.set(span.id, id)
|
||||||
@ -251,6 +262,7 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
const meta: NodeMetadata = {
|
const meta: NodeMetadata = {
|
||||||
x: position.x,
|
x: position.x,
|
||||||
y: -position.y,
|
y: -position.y,
|
||||||
|
vis: null,
|
||||||
}
|
}
|
||||||
const ident = generateUniqueIdent()
|
const ident = generateUniqueIdent()
|
||||||
const content = `${ident} = ${expression}`
|
const content = `${ident} = ${expression}`
|
||||||
@ -275,16 +287,50 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
return proj.module?.transact(fn)
|
return proj.module?.transact(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceNodeSubexpression(id: ExprId, range: ContentRange, content: string) {
|
function stopCapturingUndo() {
|
||||||
const node = nodes.get(id)
|
proj.stopCapturingUndo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
|
||||||
|
const node = nodes.get(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.replaceExpressionContent(node.rootSpan.id, content, range)
|
proj.module?.replaceExpressionContent(node.rootSpan.id, content, range)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNodePosition(id: ExprId, position: Vec2) {
|
function setNodePosition(nodeId: ExprId, position: Vec2) {
|
||||||
const node = nodes.get(id)
|
const node = nodes.get(nodeId)
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
proj.module?.updateNodeMetadata(id, { x: position.x, y: -position.y })
|
proj.module?.updateNodeMetadata(nodeId, { x: position.x, y: -position.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVisMetadata(
|
||||||
|
id: Opt<VisualizationIdentifier>,
|
||||||
|
visible?: boolean,
|
||||||
|
): VisualizationMetadata | null {
|
||||||
|
const vis: VisualizationMetadata = {
|
||||||
|
...(id ?? DEFAULT_VISUALIZATION_IDENTIFIER),
|
||||||
|
visible: visible ?? false,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
visMetadataEquals(vis, {
|
||||||
|
...DEFAULT_VISUALIZATION_IDENTIFIER,
|
||||||
|
visible: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return vis
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodeVisualizationId(nodeId: ExprId, vis: Opt<VisualizationIdentifier>) {
|
||||||
|
const node = nodes.get(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)
|
||||||
|
if (node == null) return
|
||||||
|
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -302,6 +348,9 @@ export const useGraphStore = defineStore('graph', () => {
|
|||||||
setExpressionContent,
|
setExpressionContent,
|
||||||
replaceNodeSubexpression,
|
replaceNodeSubexpression,
|
||||||
setNodePosition,
|
setNodePosition,
|
||||||
|
setNodeVisualizationId,
|
||||||
|
setNodeVisualizationVisible,
|
||||||
|
stopCapturingUndo,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -315,6 +364,7 @@ export interface Node {
|
|||||||
rootSpan: Span
|
rootSpan: Span
|
||||||
position: Vec2
|
position: Vec2
|
||||||
docRange: [Y.RelativePosition, Y.RelativePosition]
|
docRange: [Y.RelativePosition, Y.RelativePosition]
|
||||||
|
vis: Opt<VisualizationMetadata>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum SpanKind {
|
export const enum SpanKind {
|
||||||
|
@ -1,14 +1,36 @@
|
|||||||
import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig'
|
import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig'
|
||||||
import { attachProvider } from '@/util/crdt'
|
import { attachProvider } from '@/util/crdt'
|
||||||
import type { Opt } from '@/util/opt'
|
import { AsyncQueue, rpcWithRetries as lsRpcWithRetries } from '@/util/net'
|
||||||
|
import { isSome, type Opt } from '@/util/opt'
|
||||||
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
|
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
|
||||||
import { computedAsync } from '@vueuse/core'
|
import { computedAsync } from '@vueuse/core'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
import * as object from 'lib0/object'
|
||||||
import * as random from 'lib0/random'
|
import * as random from 'lib0/random'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol'
|
||||||
|
import { DataServer } from 'shared/dataServer'
|
||||||
import { LanguageServer } from 'shared/languageServer'
|
import { LanguageServer } from 'shared/languageServer'
|
||||||
import type { ContentRoot, ContextId, MethodPointer } from 'shared/languageServerTypes'
|
import type {
|
||||||
import { DistributedProject, type Uuid } from 'shared/yjsModel'
|
ContentRoot,
|
||||||
import { computed, markRaw, ref, watchEffect } from 'vue'
|
ContextId,
|
||||||
|
ExplicitCall,
|
||||||
|
ExpressionId,
|
||||||
|
StackItem,
|
||||||
|
VisualizationConfiguration,
|
||||||
|
} from 'shared/languageServerTypes'
|
||||||
|
import { WebsocketClient } from 'shared/websocket'
|
||||||
|
import { DistributedProject, type ExprId, type Uuid } from 'shared/yjsModel'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
markRaw,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
watchEffect,
|
||||||
|
type ShallowRef,
|
||||||
|
type WatchSource,
|
||||||
|
} from 'vue'
|
||||||
import { Awareness } from 'y-protocols/awareness'
|
import { Awareness } from 'y-protocols/awareness'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
@ -31,19 +53,71 @@ function resolveLsUrl(config: GuiConfig): LsUrls {
|
|||||||
throw new Error('Incomplete engine configuration')
|
throw new Error('Incomplete engine configuration')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initializeLsRpcConnection(urls: LsUrls): Promise<{
|
async function initializeLsRpcConnection(
|
||||||
|
clientId: Uuid,
|
||||||
|
url: string,
|
||||||
|
): Promise<{
|
||||||
connection: LanguageServer
|
connection: LanguageServer
|
||||||
contentRoots: ContentRoot[]
|
contentRoots: ContentRoot[]
|
||||||
}> {
|
}> {
|
||||||
const transport = new WebSocketTransport(urls.rpcUrl)
|
const transport = new WebSocketTransport(url)
|
||||||
const requestManager = new RequestManager([transport])
|
const requestManager = new RequestManager([transport])
|
||||||
const client = new Client(requestManager)
|
const client = new Client(requestManager)
|
||||||
const clientId = random.uuidv4() as Uuid
|
|
||||||
const connection = new LanguageServer(client)
|
const connection = new LanguageServer(client)
|
||||||
const contentRoots = (await connection.initProtocolConnection(clientId)).contentRoots
|
|
||||||
|
const initialization = await lsRpcWithRetries(() => connection.initProtocolConnection(clientId), {
|
||||||
|
onBeforeRetry: (error, _, delay) => {
|
||||||
|
console.warn(
|
||||||
|
`Failed to initialize language server connection, retrying after ${delay}ms...\n`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const contentRoots = initialization.contentRoots
|
||||||
return { connection, contentRoots }
|
return { connection, contentRoots }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initializeDataConnection(clientId: Uuid, url: string) {
|
||||||
|
const client = new WebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false })
|
||||||
|
const connection = new DataServer(client)
|
||||||
|
await connection.initialize(clientId)
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeVisualizationConfiguration = Omit<
|
||||||
|
VisualizationConfiguration,
|
||||||
|
'executionContextId'
|
||||||
|
> & {
|
||||||
|
expressionId: ExprId
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionContextState {
|
||||||
|
lsRpc: LanguageServer
|
||||||
|
created: boolean
|
||||||
|
visualizations: Map<Uuid, NodeVisualizationConfiguration>
|
||||||
|
stack: StackItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualizationConfigEqual(
|
||||||
|
a: NodeVisualizationConfiguration,
|
||||||
|
b: NodeVisualizationConfiguration,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a === b ||
|
||||||
|
(a.visualizationModule === b.visualizationModule &&
|
||||||
|
(a.positionalArgumentsExpressions === b.positionalArgumentsExpressions ||
|
||||||
|
(Array.isArray(a.positionalArgumentsExpressions) &&
|
||||||
|
Array.isArray(b.positionalArgumentsExpressions) &&
|
||||||
|
array.equalFlat(a.positionalArgumentsExpressions, b.positionalArgumentsExpressions))) &&
|
||||||
|
(a.expression === b.expression ||
|
||||||
|
(typeof a.expression === 'object' &&
|
||||||
|
typeof b.expression === 'object' &&
|
||||||
|
object.equalFlat(a.expression, b.expression))))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryPoint = Omit<ExplicitCall, 'type'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execution Context
|
* Execution Context
|
||||||
*
|
*
|
||||||
@ -54,31 +128,184 @@ async function initializeLsRpcConnection(urls: LsUrls): Promise<{
|
|||||||
* run only when the previous call is done.
|
* run only when the previous call is done.
|
||||||
*/
|
*/
|
||||||
export class ExecutionContext {
|
export class ExecutionContext {
|
||||||
state: Promise<{ lsRpc: LanguageServer; id: ContextId }>
|
id: ContextId = random.uuidv4() as ContextId
|
||||||
|
queue: AsyncQueue<ExecutionContextState>
|
||||||
|
taskRunning = false
|
||||||
|
visSyncScheduled = false
|
||||||
|
visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
|
||||||
|
abortCtl = new AbortController()
|
||||||
|
|
||||||
constructor(
|
constructor(lsRpc: Promise<LanguageServer>, entryPoint: EntryPoint) {
|
||||||
lsRpc: Promise<LanguageServer>,
|
this.queue = new AsyncQueue(
|
||||||
call: {
|
lsRpc.then((lsRpc) => ({
|
||||||
methodPointer: MethodPointer
|
lsRpc,
|
||||||
thisArgumentExpression?: string
|
created: false,
|
||||||
positionalArgumentsExpressions?: string[]
|
visualizations: new Map(),
|
||||||
},
|
stack: [],
|
||||||
) {
|
})),
|
||||||
this.state = lsRpc.then(async (lsRpc) => {
|
)
|
||||||
const { contextId } = await lsRpc.createExecutionContext()
|
this.create()
|
||||||
await lsRpc.pushExecutionContextItem(contextId, {
|
this.pushItem({ type: 'ExplicitCall', ...entryPoint })
|
||||||
type: 'ExplicitCall',
|
}
|
||||||
positionalArgumentsExpressions: call.positionalArgumentsExpressions ?? [],
|
|
||||||
...call,
|
private withBackoff<T>(f: () => Promise<T>, message: string): Promise<T> {
|
||||||
})
|
return lsRpcWithRetries(f, {
|
||||||
return { lsRpc, id: contextId }
|
onBeforeRetry: (error, _, delay) => {
|
||||||
|
if (this.abortCtl.signal.aborted) return false
|
||||||
|
console.warn(
|
||||||
|
`${message}: ${error.payload.cause.message}. Retrying after ${delay}ms...\n`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncVisualizations() {
|
||||||
|
if (this.visSyncScheduled) return
|
||||||
|
this.visSyncScheduled = true
|
||||||
|
this.queue.pushTask(async (state) => {
|
||||||
|
this.visSyncScheduled = false
|
||||||
|
if (!state.created) return state
|
||||||
|
const promises: Promise<void>[] = []
|
||||||
|
|
||||||
|
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||||
|
return this.withBackoff(
|
||||||
|
() =>
|
||||||
|
state.lsRpc.attachVisualization(id, config.expressionId, {
|
||||||
|
executionContextId: this.id,
|
||||||
|
expression: config.expression,
|
||||||
|
visualizationModule: config.visualizationModule,
|
||||||
|
...(config.positionalArgumentsExpressions
|
||||||
|
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
'Failed to attach visualization',
|
||||||
|
).then(() => {
|
||||||
|
state.visualizations.set(id, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||||
|
return this.withBackoff(
|
||||||
|
() =>
|
||||||
|
state.lsRpc.modifyVisualization(id, {
|
||||||
|
executionContextId: this.id,
|
||||||
|
expression: config.expression,
|
||||||
|
visualizationModule: config.visualizationModule,
|
||||||
|
...(config.positionalArgumentsExpressions
|
||||||
|
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
'Failed to modify visualization',
|
||||||
|
).then(() => {
|
||||||
|
state.visualizations.set(id, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||||
|
return this.withBackoff(
|
||||||
|
() => state.lsRpc.detachVisualization(id, config.expressionId, this.id),
|
||||||
|
'Failed to detach visualization',
|
||||||
|
).then(() => {
|
||||||
|
state.visualizations.delete(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach new and update existing visualizations.
|
||||||
|
for (const [id, config] of this.visualizationConfigs) {
|
||||||
|
const previousConfig = state.visualizations.get(id)
|
||||||
|
if (previousConfig == null) {
|
||||||
|
promises.push(attach(id, config))
|
||||||
|
} else if (!visualizationConfigEqual(previousConfig, config)) {
|
||||||
|
if (previousConfig.expressionId === config.expressionId) {
|
||||||
|
promises.push(modify(id, config))
|
||||||
|
} else {
|
||||||
|
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach removed visualizations.
|
||||||
|
for (const [id, config] of state.visualizations) {
|
||||||
|
if (this.visualizationConfigs.get(id) == undefined) {
|
||||||
|
promises.push(detach(id, config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settled = await Promise.allSettled(promises)
|
||||||
|
|
||||||
|
// Emit errors for failed requests.
|
||||||
|
const errors = settled
|
||||||
|
.map((result) => (result.status === 'rejected' ? result.reason : null))
|
||||||
|
.filter(isSome)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error('Failed to synchronize visualizations:', errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State object was updated in-place in each successful promise.
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushItem(item: StackItem) {
|
||||||
|
this.queue.pushTask(async (state) => {
|
||||||
|
if (!state.created) return state
|
||||||
|
await this.withBackoff(
|
||||||
|
() => state.lsRpc.pushExecutionContextItem(this.id, item),
|
||||||
|
'Failed to push item to execution context stack',
|
||||||
|
)
|
||||||
|
state.stack.push(item)
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
push(expressionId: ExpressionId) {
|
||||||
|
this.pushItem({ type: 'LocalCall', expressionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
pop() {
|
||||||
|
this.queue.pushTask(async (state) => {
|
||||||
|
if (!state.created) return state
|
||||||
|
if (state.stack.length === 0) {
|
||||||
|
throw new Error('Cannot pop from empty execution context stack')
|
||||||
|
}
|
||||||
|
await this.withBackoff(
|
||||||
|
() => state.lsRpc.popExecutionContextItem(this.id),
|
||||||
|
'Failed to pop item from execution context stack',
|
||||||
|
)
|
||||||
|
state.stack.pop()
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
|
||||||
|
if (configuration == null) {
|
||||||
|
this.visualizationConfigs.delete(id)
|
||||||
|
} else {
|
||||||
|
this.visualizationConfigs.set(id, configuration)
|
||||||
|
}
|
||||||
|
this.syncVisualizations()
|
||||||
|
}
|
||||||
|
|
||||||
|
private create() {
|
||||||
|
this.queue.pushTask(async (state) => {
|
||||||
|
if (state.created) return state
|
||||||
|
return this.withBackoff(async () => {
|
||||||
|
const result = await state.lsRpc.createExecutionContext(this.id)
|
||||||
|
if (result.contextId !== this.id) {
|
||||||
|
throw new Error('Unexpected Context ID returned by the language server.')
|
||||||
|
}
|
||||||
|
return { ...state, created: true }
|
||||||
|
}, 'Failed to create execution context')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.state = this.state.then(({ lsRpc, id }) => {
|
this.abortCtl.abort()
|
||||||
lsRpc.destroyExecutionContext(id)
|
this.queue.clear()
|
||||||
return { lsRpc, id }
|
this.queue.pushTask(async (state) => {
|
||||||
|
if (!state.created) return state
|
||||||
|
await state.lsRpc.destroyExecutionContext(this.id)
|
||||||
|
return { ...state, created: false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,12 +325,12 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
const projectName = config.value.startup?.project
|
const projectName = config.value.startup?.project
|
||||||
if (projectName == null) throw new Error('Missing project name.')
|
if (projectName == null) throw new Error('Missing project name.')
|
||||||
|
|
||||||
|
const clientId = random.uuidv4() as Uuid
|
||||||
const lsUrls = resolveLsUrl(config.value)
|
const lsUrls = resolveLsUrl(config.value)
|
||||||
const initializedConnection = initializeLsRpcConnection(lsUrls)
|
const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl)
|
||||||
const lsRpcConnection = initializedConnection.then(({ connection }) => connection)
|
const lsRpcConnection = initializedConnection.then(({ connection }) => connection)
|
||||||
const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots)
|
const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots)
|
||||||
|
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl)
|
||||||
const undoManager = new Y.UndoManager([], { doc })
|
|
||||||
|
|
||||||
const name = computed(() => config.value.startup?.project)
|
const name = computed(() => config.value.startup?.project)
|
||||||
const namespace = computed(() => config.value.engine?.namespace)
|
const namespace = computed(() => config.value.engine?.namespace)
|
||||||
@ -142,23 +369,14 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
if (guid == null) return null
|
if (guid == null) return null
|
||||||
const moduleName = projectModel.findModuleByDocId(guid)
|
const moduleName = projectModel.findModuleByDocId(guid)
|
||||||
if (moduleName == null) return null
|
if (moduleName == null) return null
|
||||||
return await projectModel.openModule(moduleName)
|
const mod = await projectModel.openModule(moduleName)
|
||||||
|
mod?.undoManager.addTrackedOrigin('local')
|
||||||
|
return mod
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect((onCleanup) => {
|
function createExecutionContextForMain(): ExecutionContext {
|
||||||
const mod = module.value
|
|
||||||
if (mod == null) return
|
|
||||||
const scope: typeof undoManager.scope = [mod.doc.contents, mod.doc.idMap]
|
|
||||||
undoManager.scope.push(...scope)
|
|
||||||
onCleanup(() => {
|
|
||||||
undoManager.scope = undoManager.scope.filter((s) => !scope.includes(s))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function createExecutionContextForMain(): Promise<Opt<ExecutionContext>> {
|
|
||||||
if (name.value == null) {
|
if (name.value == null) {
|
||||||
console.error('Cannot create execution context. Unknown project name.')
|
throw new Error('Cannot create execution context. Unknown project name.')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (namespace.value == null) {
|
if (namespace.value == null) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -167,28 +385,65 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
}
|
}
|
||||||
const projectName = `${namespace.value ?? 'local'}.${name.value}`
|
const projectName = `${namespace.value ?? 'local'}.${name.value}`
|
||||||
const mainModule = `${projectName}.Main`
|
const mainModule = `${projectName}.Main`
|
||||||
const projectRoot = (await contentRoots).find((root) => root.type === 'Project')
|
const entryPoint = { module: mainModule, definedOnType: mainModule, name: 'main' }
|
||||||
if (projectRoot == null) {
|
|
||||||
console.error(
|
|
||||||
'Cannot create execution context. Protocol connection initialization did not return a project root.',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return new ExecutionContext(lsRpcConnection, {
|
return new ExecutionContext(lsRpcConnection, {
|
||||||
methodPointer: { module: mainModule, definedOnType: mainModule, name: 'main' },
|
methodPointer: entryPoint,
|
||||||
|
positionalArgumentsExpressions: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executionContext = createExecutionContextForMain()
|
||||||
|
const dataConnectionRef = computedAsync<DataServer | undefined>(() => dataConnection)
|
||||||
|
|
||||||
|
function useVisualizationData(
|
||||||
|
configuration: WatchSource<Opt<NodeVisualizationConfiguration>>,
|
||||||
|
): ShallowRef<{} | undefined> {
|
||||||
|
const id = random.uuidv4() as Uuid
|
||||||
|
const visualizationData = shallowRef<{}>()
|
||||||
|
|
||||||
|
watch(configuration, async (config, _, onCleanup) => {
|
||||||
|
executionContext.setVisualization(id, config)
|
||||||
|
onCleanup(() => {
|
||||||
|
executionContext.setVisualization(id, null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const connection = dataConnectionRef.value
|
||||||
|
const dataEvent = `${OutboundPayload.VISUALIZATION_UPDATE}:${id}`
|
||||||
|
if (connection == null) return
|
||||||
|
connection.on(dataEvent, onVisualizationUpdate)
|
||||||
|
onCleanup(() => {
|
||||||
|
connection.off(dataEvent, onVisualizationUpdate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function onVisualizationUpdate(vizUpdate: VisualizationUpdate) {
|
||||||
|
const json = vizUpdate.dataString()
|
||||||
|
const newData = json != null ? JSON.parse(json) : undefined
|
||||||
|
visualizationData.value = newData
|
||||||
|
}
|
||||||
|
|
||||||
|
return visualizationData
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCapturingUndo() {
|
||||||
|
module.value?.undoManager.stopCapturing()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setObservedFileName(name: string) {
|
setObservedFileName(name: string) {
|
||||||
observedFileName.value = name
|
observedFileName.value = name
|
||||||
},
|
},
|
||||||
name: projectName,
|
name: projectName,
|
||||||
createExecutionContextForMain,
|
createExecutionContextForMain,
|
||||||
|
executionContext,
|
||||||
module,
|
module,
|
||||||
contentRoots,
|
contentRoots,
|
||||||
undoManager,
|
|
||||||
awareness,
|
awareness,
|
||||||
lsRpcConnection: markRaw(lsRpcConnection),
|
lsRpcConnection: markRaw(lsRpcConnection),
|
||||||
|
dataConnection: markRaw(dataConnection),
|
||||||
|
useVisualizationData,
|
||||||
|
stopCapturingUndo,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AsyncQueue, rpcWithRetries } from '@/util/net'
|
||||||
import { type QualifiedName } from '@/util/qualifiedName'
|
import { type QualifiedName } from '@/util/qualifiedName'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { LanguageServer } from 'shared/languageServer'
|
import { LanguageServer } from 'shared/languageServer'
|
||||||
@ -18,17 +19,22 @@ export interface Group {
|
|||||||
class Synchronizer {
|
class Synchronizer {
|
||||||
entries: SuggestionDb
|
entries: SuggestionDb
|
||||||
groups: Ref<Group[]>
|
groups: Ref<Group[]>
|
||||||
lastUpdate: Promise<{ currentVersion: number }>
|
queue: AsyncQueue<{ currentVersion: number }>
|
||||||
|
|
||||||
constructor(entries: SuggestionDb, groups: Ref<Group[]>) {
|
constructor(entries: SuggestionDb, groups: Ref<Group[]>) {
|
||||||
this.entries = entries
|
this.entries = entries
|
||||||
this.groups = groups
|
this.groups = groups
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
this.lastUpdate = projectStore.lsRpcConnection.then(async (lsRpc) => {
|
const initState = projectStore.lsRpcConnection.then(async (lsRpc) => {
|
||||||
await lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {})
|
await rpcWithRetries(() =>
|
||||||
|
lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {}),
|
||||||
|
)
|
||||||
this.setupUpdateHandler(lsRpc)
|
this.setupUpdateHandler(lsRpc)
|
||||||
return Synchronizer.loadDatabase(entries, lsRpc, groups.value)
|
return Synchronizer.loadDatabase(entries, lsRpc, groups.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.queue = new AsyncQueue(initState)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async loadDatabase(
|
static async loadDatabase(
|
||||||
@ -51,7 +57,7 @@ class Synchronizer {
|
|||||||
|
|
||||||
private setupUpdateHandler(lsRpc: LanguageServer) {
|
private setupUpdateHandler(lsRpc: LanguageServer) {
|
||||||
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
||||||
this.lastUpdate = this.lastUpdate.then(async ({ currentVersion }) => {
|
this.queue.pushTask(async ({ currentVersion }) => {
|
||||||
if (param.currentVersion <= currentVersion) {
|
if (param.currentVersion <= currentVersion) {
|
||||||
console.log(
|
console.log(
|
||||||
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,
|
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
import * as vue from 'vue'
|
|
||||||
import { type DefineComponent } from 'vue'
|
|
||||||
|
|
||||||
import * as vueUseCore from '@vueuse/core'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||||
import { defineKeybinds } from '@/util/shortcuts'
|
import { defineKeybinds } from '@/util/shortcuts'
|
||||||
@ -21,33 +15,60 @@ import type {
|
|||||||
RegisterBuiltinModulesRequest,
|
RegisterBuiltinModulesRequest,
|
||||||
} from '@/workers/visualizationCompiler'
|
} from '@/workers/visualizationCompiler'
|
||||||
import Compiler from '@/workers/visualizationCompiler?worker'
|
import Compiler from '@/workers/visualizationCompiler?worker'
|
||||||
import * as d3 from 'd3'
|
import { defineStore } from 'pinia'
|
||||||
|
import type { VisualizationConfiguration } from 'shared/languageServerTypes'
|
||||||
|
import type { VisualizationIdentifier } from 'shared/yjsModel'
|
||||||
|
import * as vue from 'vue'
|
||||||
|
import { type DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
/** A module containing the default visualization function. */
|
||||||
|
const DEFAULT_VISUALIZATION_MODULE = 'Standard.Visualization.Preprocessor'
|
||||||
|
/** A name of the default visualization function. */
|
||||||
|
const DEFAULT_VISUALIZATION_FUNCTION = 'default_preprocessor'
|
||||||
|
/** A list of arguments passed to the default visualization function. */
|
||||||
|
const DEFAULT_VISUALIZATION_ARGUMENTS: string[] = []
|
||||||
|
|
||||||
|
export const DEFAULT_VISUALIZATION_CONFIGURATION = {
|
||||||
|
visualizationModule: DEFAULT_VISUALIZATION_MODULE,
|
||||||
|
expression: DEFAULT_VISUALIZATION_FUNCTION,
|
||||||
|
positionalArgumentsExpressions: DEFAULT_VISUALIZATION_ARGUMENTS,
|
||||||
|
} satisfies Partial<VisualizationConfiguration>
|
||||||
|
|
||||||
|
export const DEFAULT_VISUALIZATION_IDENTIFIER: VisualizationIdentifier = {
|
||||||
|
module: { kind: 'Builtin' },
|
||||||
|
name: 'JSON',
|
||||||
|
}
|
||||||
|
|
||||||
const moduleCache: Record<string, any> = {
|
const moduleCache: Record<string, any> = {
|
||||||
vue,
|
vue,
|
||||||
'@vueuse/core': vueUseCore,
|
get d3() {
|
||||||
builtins: { VisualizationContainer, useVisualizationConfig, defineKeybinds, d3 },
|
return import('d3')
|
||||||
|
},
|
||||||
|
builtins: { VisualizationContainer, useVisualizationConfig, defineKeybinds },
|
||||||
}
|
}
|
||||||
// @ts-expect-error Intentionally not defined in `env.d.ts` as it is a mistake to access anywhere
|
// @ts-expect-error Intentionally not defined in `env.d.ts` as it is a mistake to access anywhere
|
||||||
// else.
|
// else.
|
||||||
window.__visualizationModules = moduleCache
|
window.__visualizationModules = moduleCache
|
||||||
|
|
||||||
export type Visualization = DefineComponent<
|
export type Visualization = DefineComponent<
|
||||||
{ data: {} },
|
// Props
|
||||||
{},
|
{ data: { type: vue.PropType<unknown>; required: true } },
|
||||||
{},
|
{},
|
||||||
|
unknown,
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
|
// Emits
|
||||||
{
|
{
|
||||||
'update:preprocessor': (module: string, method: string, ...args: string[]) => void
|
'update:preprocessor'?: (module: string, method: string, ...args: string[]) => void
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
type VisualizationModule = {
|
type VisualizationModule = {
|
||||||
default: Visualization
|
default: Visualization
|
||||||
name: string
|
name: string
|
||||||
inputType: string
|
inputType?: string
|
||||||
|
defaultPreprocessor?: readonly [module: string, method: string, ...args: string[]]
|
||||||
scripts?: string[]
|
scripts?: string[]
|
||||||
styles?: string[]
|
styles?: string[]
|
||||||
}
|
}
|
||||||
@ -63,7 +84,6 @@ const builtinVisualizationImports: Record<string, () => Promise<VisualizationMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dynamicVisualizationPaths: Record<string, string> = {
|
const dynamicVisualizationPaths: Record<string, string> = {
|
||||||
Test: '/visualizations/TestVisualization.vue',
|
|
||||||
Scatterplot: '/visualizations/ScatterplotVisualization.vue',
|
Scatterplot: '/visualizations/ScatterplotVisualization.vue',
|
||||||
'Geo Map': '/visualizations/GeoMapVisualization.vue',
|
'Geo Map': '/visualizations/GeoMapVisualization.vue',
|
||||||
}
|
}
|
||||||
@ -73,7 +93,13 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
|||||||
const imports = { ...builtinVisualizationImports }
|
const imports = { ...builtinVisualizationImports }
|
||||||
const paths = { ...dynamicVisualizationPaths }
|
const paths = { ...dynamicVisualizationPaths }
|
||||||
let cache: Record<string, VisualizationModule> = {}
|
let cache: Record<string, VisualizationModule> = {}
|
||||||
const types = [...Object.keys(imports), ...Object.keys(paths)]
|
const builtinTypes = [...Object.keys(imports), ...Object.keys(paths)]
|
||||||
|
const types = builtinTypes.map(
|
||||||
|
(name): VisualizationIdentifier => ({
|
||||||
|
module: { kind: 'Builtin' },
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
let worker: Worker | undefined
|
let worker: Worker | undefined
|
||||||
let workerMessageId = 0
|
let workerMessageId = 0
|
||||||
const workerCallbacks: Record<
|
const workerCallbacks: Record<
|
||||||
@ -220,7 +246,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Because visualization scripts are cached, they are not guaranteed to be up to date.
|
// NOTE: Because visualization scripts are cached, they are not guaranteed to be up to date.
|
||||||
async function get(type: string) {
|
async function get(meta: VisualizationIdentifier) {
|
||||||
|
if (meta.module.kind !== 'Builtin') {
|
||||||
|
console.warn('Custom visualization module support is not yet implemented:', meta.module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const type = meta.name
|
||||||
let module = cache[type]
|
let module = cache[type]
|
||||||
if (module == null) {
|
if (module == null) {
|
||||||
module = await imports[type]?.()
|
module = await imports[type]?.()
|
||||||
@ -237,121 +268,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
|||||||
register(module)
|
register(module)
|
||||||
await loadScripts(module)
|
await loadScripts(module)
|
||||||
cache[type] = module
|
cache[type] = module
|
||||||
return module.default
|
return module
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
cache = {}
|
cache = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleData(type: string) {
|
return { types, get, clear }
|
||||||
switch (type) {
|
|
||||||
case 'Warnings': {
|
|
||||||
return ['warning 1', "warning 2!!&<>;'\x22"]
|
|
||||||
}
|
|
||||||
case 'Image': {
|
|
||||||
return {
|
|
||||||
mediaType: 'image/svg+xml',
|
|
||||||
base64: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0\
|
|
||||||
MCI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMjAuMDUgMEEyMCAyMCAwIDAgMCAwIDIwLjA1IDIwLjA2IDIwLjA\
|
|
||||||
2IDAgMSAwIDIwLjA1IDBabTAgMzYuMDVjLTguOTMgMC0xNi4xLTcuMTctMTYuMS0xNi4xIDAtOC45NCA3LjE3LTE2LjEgMTYuMS\
|
|
||||||
0xNi4xIDguOTQgMCAxNi4xIDcuMTYgMTYuMSAxNi4xYTE2LjE4IDE2LjE4IDAgMCAxLTE2LjEgMTYuMVoiLz48cGF0aCBkPSJNM\
|
|
||||||
jcuMTIgMTcuNzdhNC42OCA0LjY4IDAgMCAxIDIuMzkgNS45MiAxMC4yMiAxMC4yMiAwIDAgMS05LjU2IDYuODZBMTAuMiAxMC4y\
|
|
||||||
IDAgMCAxIDkuNzcgMjAuMzZzMS41NSAyLjA4IDQuNTcgMi4wOGMzLjAxIDAgNC4zNi0xLjE0IDUuNi0yLjA4IDEuMjUtLjkzIDI\
|
|
||||||
uMDktMyA1LjItMyAuNzMgMCAxLjQ2LjIgMS45OC40WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9Ii\
|
|
||||||
NmZmYiIGQ9Ik0wIDBoNDB2NDBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'JSON':
|
|
||||||
case 'Scatterplot':
|
|
||||||
case 'Scatterplot 2': {
|
|
||||||
return {
|
|
||||||
axis: {
|
|
||||||
x: { label: 'x-axis label', scale: 'linear' },
|
|
||||||
y: { label: 'y-axis label', scale: 'logarithmic' },
|
|
||||||
},
|
|
||||||
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
|
|
||||||
points: { labels: 'visible' },
|
|
||||||
data: [
|
|
||||||
{ x: 0.1, y: 0.7, label: 'foo', color: 'FF0000', shape: 'circle', size: 0.2 },
|
|
||||||
{ x: 0.4, y: 0.2, label: 'baz', color: '0000FF', shape: 'square', size: 0.3 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'Geo Map':
|
|
||||||
case 'Geo Map 2': {
|
|
||||||
return {
|
|
||||||
latitude: 37.8,
|
|
||||||
longitude: -122.45,
|
|
||||||
zoom: 15,
|
|
||||||
controller: true,
|
|
||||||
showingLabels: true, // Enables presenting labels when hovering over a point.
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
type: 'Scatterplot_Layer',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
latitude: 37.8,
|
|
||||||
longitude: -122.45,
|
|
||||||
color: [255, 0, 0],
|
|
||||||
radius: 100,
|
|
||||||
label: 'an example label',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'Heatmap': {
|
|
||||||
return [
|
|
||||||
['a', 'thing', 'c', 'd', 'a'],
|
|
||||||
[1, 2, 3, 2, 3],
|
|
||||||
[50, 25, 40, 20, 10],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'Histogram': {
|
|
||||||
return {
|
|
||||||
axis: {
|
|
||||||
x: { label: 'x-axis label', scale: 'linear' },
|
|
||||||
y: { label: 'y-axis label', scale: 'logarithmic' },
|
|
||||||
},
|
|
||||||
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
|
|
||||||
color: 'rgb(1.0,0.0,0.0)',
|
|
||||||
bins: 10,
|
|
||||||
data: {
|
|
||||||
values: [0.1, 0.2, 0.1, 0.15, 0.7],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'Table': {
|
|
||||||
return {
|
|
||||||
type: 'Matrix',
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
column_count: 5,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
all_rows_count: 10,
|
|
||||||
json: Array.from({ length: 10 }, (_, i) =>
|
|
||||||
Array.from({ length: 5 }, (_, j) => `${i},${j}`),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'SQL Query': {
|
|
||||||
return {
|
|
||||||
dialect: 'sql',
|
|
||||||
code: `SELECT * FROM \`foo\` WHERE \`a\` = ? AND b LIKE ?;`,
|
|
||||||
interpolations: [
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
{ enso_type: 'Data.Numbers.Number', value: '123' },
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
{ enso_type: 'Builtins.Main.Text', value: "a'bcd" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { types, get, sampleData, clear }
|
|
||||||
})
|
})
|
||||||
|
10
app/gui2/src/util/codemirror.ts
Normal file
10
app/gui2/src/util/codemirror.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file This module is a collection of codemirror related imports that are intended to be loaded
|
||||||
|
* asynchronously using a single dynamic import, allowing for code splitting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { defaultKeymap } from '@codemirror/commands'
|
||||||
|
export { EditorState } from '@codemirror/state'
|
||||||
|
export { EditorView } from '@codemirror/view'
|
||||||
|
export { minimalSetup } from 'codemirror'
|
||||||
|
export { yCollab } from 'y-codemirror.next'
|
@ -40,9 +40,7 @@ interface SubdocsEvent {
|
|||||||
removed: Set<Y.Doc>
|
removed: Set<Y.Doc>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** URL query parameters used in gateway server websocket connection. */
|
||||||
* URL query parameters used in gateway server websocket connection.
|
|
||||||
*/
|
|
||||||
export type ProviderParams = {
|
export type ProviderParams = {
|
||||||
/** URL for the project's language server RPC connection. */
|
/** URL for the project's language server RPC connection. */
|
||||||
ls: string
|
ls: string
|
||||||
|
@ -11,12 +11,26 @@ import {
|
|||||||
type Ref,
|
type Ref,
|
||||||
type WatchSource,
|
type WatchSource,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
|
import type { Opt } from './opt'
|
||||||
|
|
||||||
/** Whether an element currently has keyboard focus. */
|
/** Whether any element currently has keyboard focus. */
|
||||||
export function keyboardBusy() {
|
export function keyboardBusy() {
|
||||||
return document.activeElement != document.body
|
return document.activeElement != document.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether focused element is within given element's subtree. */
|
||||||
|
export function focusIsIn(el: Element) {
|
||||||
|
return el.contains(document.activeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any element currently has keyboard focus, except for elements within given subtree.
|
||||||
|
* When `el` is `null` or `undefined`, the function behaves as `keyboardBusy()`.
|
||||||
|
*/
|
||||||
|
export function keyboardBusyExceptIn(el: Opt<Element>) {
|
||||||
|
return keyboardBusy() && (el == null || !focusIsIn(el))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime.
|
* Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime.
|
||||||
* @param target element on which to register the event
|
* @param target element on which to register the event
|
||||||
|
@ -19,9 +19,12 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
|||||||
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
|
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
|
||||||
}, PointerButtonMask.Auxiliary)
|
}, PointerButtonMask.Auxiliary)
|
||||||
|
|
||||||
function eventToScenePos(event: PointerEvent, client?: Vec2): Vec2 {
|
function eventScreenPos(e: PointerEvent): Vec2 {
|
||||||
|
return new Vec2(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientToScenePos(clientPos: Vec2): Vec2 {
|
||||||
const rect = elemRect(viewportNode.value)
|
const rect = elemRect(viewportNode.value)
|
||||||
const clientPos = client ?? new Vec2(event.clientX, event.clientY)
|
|
||||||
const canvasPos = clientPos.sub(rect.pos)
|
const canvasPos = clientPos.sub(rect.pos)
|
||||||
const v = viewport.value
|
const v = viewport.value
|
||||||
return new Vec2(
|
return new Vec2(
|
||||||
@ -31,9 +34,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let zoomPivot = Vec2.Zero()
|
let zoomPivot = Vec2.Zero()
|
||||||
const zoomPointer = usePointer((pos, event, ty) => {
|
const zoomPointer = usePointer((pos, _event, ty) => {
|
||||||
if (ty === 'start') {
|
if (ty === 'start') {
|
||||||
zoomPivot = eventToScenePos(event, pos.initial)
|
zoomPivot = clientToScenePos(pos.initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevScale = scale.value
|
const prevScale = scale.value
|
||||||
@ -87,17 +90,20 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
|||||||
{ capture: true },
|
{ capture: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const sceneMousePos = ref<Vec2 | null>(null)
|
const eventMousePos = ref<Vec2 | null>(null)
|
||||||
|
const sceneMousePos = computed(() =>
|
||||||
|
eventMousePos.value ? clientToScenePos(eventMousePos.value) : null,
|
||||||
|
)
|
||||||
|
|
||||||
return proxyRefs({
|
return proxyRefs({
|
||||||
events: {
|
events: {
|
||||||
pointermove(e: PointerEvent) {
|
pointermove(e: PointerEvent) {
|
||||||
sceneMousePos.value = eventToScenePos(e)
|
eventMousePos.value = eventScreenPos(e)
|
||||||
panPointer.events.pointermove(e)
|
panPointer.events.pointermove(e)
|
||||||
zoomPointer.events.pointermove(e)
|
zoomPointer.events.pointermove(e)
|
||||||
},
|
},
|
||||||
pointerleave() {
|
pointerleave() {
|
||||||
sceneMousePos.value = null
|
eventMousePos.value = null
|
||||||
},
|
},
|
||||||
pointerup(e: PointerEvent) {
|
pointerup(e: PointerEvent) {
|
||||||
panPointer.events.pointerup(e)
|
panPointer.events.pointerup(e)
|
||||||
|
233
app/gui2/src/util/net.ts
Normal file
233
app/gui2/src/util/net.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { wait } from 'lib0/promise'
|
||||||
|
import { LsRpcError } from 'shared/languageServer'
|
||||||
|
import { Err, Error, Ok, rejectionToResult, type Result } from './result'
|
||||||
|
|
||||||
|
export interface BackoffOptions<E> {
|
||||||
|
maxRetries?: number
|
||||||
|
retryDelay?: number
|
||||||
|
retryDelayMultiplier?: number
|
||||||
|
retryDelayMax?: number
|
||||||
|
/**
|
||||||
|
* Called when the promise return an error result, and the next retry is about to be attempted.
|
||||||
|
* When this function returns `false`, the backoff is immediately aborted. When this function is
|
||||||
|
* not provided, the backoff will always continue until the maximum number of retries is reached.
|
||||||
|
*/
|
||||||
|
onBeforeRetry?: (error: Error<E>, retryCount: number, delay: number) => boolean | void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBackoffOptions: Required<BackoffOptions<any>> = {
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 1000,
|
||||||
|
retryDelayMultiplier: 2,
|
||||||
|
retryDelayMax: 10000,
|
||||||
|
onBeforeRetry: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failing promise function with exponential backoff.
|
||||||
|
*/
|
||||||
|
export async function exponentialBackoff<T, E>(
|
||||||
|
f: () => Promise<Result<T, E>>,
|
||||||
|
backoffOptions?: BackoffOptions<E>,
|
||||||
|
): Promise<Result<T, E>> {
|
||||||
|
const options = { ...defaultBackoffOptions, ...backoffOptions }
|
||||||
|
for (let retries = 0; ; retries += 1) {
|
||||||
|
const result = await f()
|
||||||
|
const delay = Math.min(
|
||||||
|
options.retryDelayMax,
|
||||||
|
options.retryDelay * options.retryDelayMultiplier ** retries,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
result.ok ||
|
||||||
|
retries >= options.maxRetries ||
|
||||||
|
options.onBeforeRetry(result.error, retries, delay) === false
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
await wait(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lsRequestResult = rejectionToResult(LsRpcError)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failing Language Server RPC call with exponential backoff. The provided async function is
|
||||||
|
* called on each retry.
|
||||||
|
*/
|
||||||
|
export async function rpcWithRetries<T>(
|
||||||
|
f: () => Promise<T>,
|
||||||
|
backoffOptions?: BackoffOptions<LsRpcError>,
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await exponentialBackoff(() => lsRequestResult(f()), backoffOptions)
|
||||||
|
if (result.ok) return result.value
|
||||||
|
else {
|
||||||
|
console.error('Too many failed retries.')
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueTask<State> = (state: State) => Promise<State>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A serializing queue of asynchronous tasks transforming a state. Each task is a function that
|
||||||
|
* takes the current state and produces a promise to the transformed state. Each task waits for the
|
||||||
|
* previous task to finish before starting.
|
||||||
|
*/
|
||||||
|
export class AsyncQueue<State> {
|
||||||
|
lastTask: Promise<State>
|
||||||
|
taskRunning = false
|
||||||
|
queuedTasks: QueueTask<State>[] = []
|
||||||
|
|
||||||
|
constructor(initTask: Promise<State>) {
|
||||||
|
this.lastTask = initTask
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
if (this.taskRunning) return
|
||||||
|
const task = this.queuedTasks.shift()
|
||||||
|
if (task == null) return
|
||||||
|
this.taskRunning = true
|
||||||
|
this.lastTask = this.lastTask
|
||||||
|
.then((state) => task(state))
|
||||||
|
.finally(() => {
|
||||||
|
this.taskRunning = false
|
||||||
|
this.run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTask(f: QueueTask<State>) {
|
||||||
|
this.queuedTasks.push(f)
|
||||||
|
this.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.queuedTasks.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForCompletion(): Promise<State> {
|
||||||
|
let lastState: State
|
||||||
|
do {
|
||||||
|
console.log('this.lastTask', this.lastTask)
|
||||||
|
lastState = await this.lastTask
|
||||||
|
console.log('lastState', lastState)
|
||||||
|
} while (this.taskRunning)
|
||||||
|
return lastState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.vitest) {
|
||||||
|
const { describe, test, expect, beforeEach, afterEach, vi } = import.meta.vitest
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AsyncQueue', () => {
|
||||||
|
test('sets initial state', async () => {
|
||||||
|
const queue = new AsyncQueue(Promise.resolve(1))
|
||||||
|
expect(await queue.waitForCompletion()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs tasks in sequence', async () => {
|
||||||
|
const queue = new AsyncQueue(Promise.resolve(1))
|
||||||
|
queue.pushTask(async (state) => {
|
||||||
|
expect(state).toBe(1)
|
||||||
|
await wait(100)
|
||||||
|
return 2
|
||||||
|
})
|
||||||
|
queue.pushTask(async (state) => {
|
||||||
|
expect(state).toBe(2)
|
||||||
|
return 3
|
||||||
|
})
|
||||||
|
vi.runAllTimersAsync()
|
||||||
|
expect(await queue.waitForCompletion()).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clear removes all not yet started tasks', async () => {
|
||||||
|
const queue = new AsyncQueue(Promise.resolve(1))
|
||||||
|
queue.pushTask(async (state) => {
|
||||||
|
expect(state).toBe(1)
|
||||||
|
await wait(100)
|
||||||
|
return 2
|
||||||
|
})
|
||||||
|
queue.pushTask(async (state) => {
|
||||||
|
expect(state).toBe(2)
|
||||||
|
return 3
|
||||||
|
})
|
||||||
|
queue.clear()
|
||||||
|
queue.pushTask(async (state) => {
|
||||||
|
expect(state).toBe(2)
|
||||||
|
return 5
|
||||||
|
})
|
||||||
|
vi.runAllTimersAsync()
|
||||||
|
expect(await queue.waitForCompletion()).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exponentialBackoff', () => {
|
||||||
|
test('runs successful task once', async () => {
|
||||||
|
const task = vi.fn(async () => Ok(1))
|
||||||
|
const result = await exponentialBackoff(task)
|
||||||
|
expect(result).toEqual({ ok: true, value: 1 })
|
||||||
|
expect(task).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry failing task up to a limit', async () => {
|
||||||
|
const task = vi.fn(async () => Err(1))
|
||||||
|
const promise = exponentialBackoff(task, { maxRetries: 4 })
|
||||||
|
vi.runAllTimersAsync()
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toEqual({ ok: false, error: new Error(1) })
|
||||||
|
expect(task).toHaveBeenCalledTimes(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wait before retrying', async () => {
|
||||||
|
const task = vi.fn(async () => Err(null))
|
||||||
|
exponentialBackoff(task, {
|
||||||
|
maxRetries: 10,
|
||||||
|
retryDelay: 100,
|
||||||
|
retryDelayMultiplier: 3,
|
||||||
|
retryDelayMax: 1000,
|
||||||
|
})
|
||||||
|
expect(task).toHaveBeenCalledTimes(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
expect(task).toHaveBeenCalledTimes(2)
|
||||||
|
await vi.advanceTimersByTimeAsync(300)
|
||||||
|
expect(task).toHaveBeenCalledTimes(3)
|
||||||
|
await vi.advanceTimersByTimeAsync(900)
|
||||||
|
expect(task).toHaveBeenCalledTimes(4)
|
||||||
|
await vi.advanceTimersByTimeAsync(5000)
|
||||||
|
expect(task).toHaveBeenCalledTimes(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry task until success', async () => {
|
||||||
|
const task = vi.fn()
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Err(3)))
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Err(2)))
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Ok(1)))
|
||||||
|
const promise = exponentialBackoff(task)
|
||||||
|
vi.runAllTimersAsync()
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toEqual({ ok: true, value: 1 })
|
||||||
|
expect(task).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('call retry callback', async () => {
|
||||||
|
const task = vi.fn()
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Err(3)))
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Err(2)))
|
||||||
|
task.mockReturnValueOnce(Promise.resolve(Ok(1)))
|
||||||
|
const onBeforeRetry = vi.fn()
|
||||||
|
|
||||||
|
const promise = exponentialBackoff(task, { onBeforeRetry })
|
||||||
|
vi.runAllTimersAsync()
|
||||||
|
await promise
|
||||||
|
expect(onBeforeRetry).toHaveBeenCalledTimes(2)
|
||||||
|
expect(onBeforeRetry).toHaveBeenNthCalledWith(1, new Error(3), 0, 1000)
|
||||||
|
expect(onBeforeRetry).toHaveBeenNthCalledWith(2, new Error(2), 1, 2000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
@ -48,3 +48,22 @@ export function withContext<T, E>(context: () => string, f: () => Result<T, E>):
|
|||||||
if (!result.ok) result.error.context.splice(0, 0, context)
|
if (!result.ok) result.error.context.splice(0, 0, context)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch promise rejection of provided types and convert them to a Result type.
|
||||||
|
*/
|
||||||
|
export function rejectionToResult<ErrorKind extends new (...args: any[]) => any>(
|
||||||
|
errorKinds: ErrorKind | ErrorKind[],
|
||||||
|
): <T>(promise: Promise<T>) => Promise<Result<T, InstanceType<ErrorKind>>> {
|
||||||
|
const errorKindArray = Array.isArray(errorKinds) ? errorKinds : [errorKinds]
|
||||||
|
return async (promise) => {
|
||||||
|
try {
|
||||||
|
return Ok(await promise)
|
||||||
|
} catch (error) {
|
||||||
|
for (const errorKind of errorKindArray) {
|
||||||
|
if (error instanceof errorKind) return Err(error)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -92,7 +92,7 @@ const allKeys = [
|
|||||||
'PageUp',
|
'PageUp',
|
||||||
'PageDown',
|
'PageDown',
|
||||||
'Insert',
|
'Insert',
|
||||||
' ',
|
'Space',
|
||||||
'A',
|
'A',
|
||||||
'B',
|
'B',
|
||||||
'C',
|
'C',
|
||||||
@ -177,10 +177,11 @@ const allKeys = [
|
|||||||
type Key = (typeof allKeys)[number]
|
type Key = (typeof allKeys)[number]
|
||||||
type LowercaseKey = Lowercase<Key>
|
type LowercaseKey = Lowercase<Key>
|
||||||
type KeybindSegment = Modifier | Pointer | Key
|
type KeybindSegment = Modifier | Pointer | Key
|
||||||
const normalizedKeyboardSegmentLookup = Object.fromEntries<KeybindSegment | undefined>(
|
const normalizedKeyboardSegmentLookup = Object.fromEntries<string>(
|
||||||
[...allModifiers, ...allPointers, ...allKeys].map((entry) => [entry.toLowerCase(), entry]),
|
[...allModifiers, ...allPointers, ...allKeys].map((entry) => [entry.toLowerCase(), entry]),
|
||||||
)
|
)
|
||||||
normalizedKeyboardSegmentLookup[''] = '+'
|
normalizedKeyboardSegmentLookup[''] = '+'
|
||||||
|
normalizedKeyboardSegmentLookup['space'] = ' '
|
||||||
normalizedKeyboardSegmentLookup['osdelete'] = isMacLike ? 'Delete' : 'Backspace'
|
normalizedKeyboardSegmentLookup['osdelete'] = isMacLike ? 'Delete' : 'Backspace'
|
||||||
type NormalizeKeybindSegment = {
|
type NormalizeKeybindSegment = {
|
||||||
[K in KeybindSegment as Lowercase<K>]: K
|
[K in KeybindSegment as Lowercase<K>]: K
|
||||||
@ -234,7 +235,7 @@ export function defineKeybinds<
|
|||||||
BindingName extends keyof T = keyof T,
|
BindingName extends keyof T = keyof T,
|
||||||
>(namespace: string, bindings: Keybinds<T>) {
|
>(namespace: string, bindings: Keybinds<T>) {
|
||||||
if (definedNamespaces.has(namespace)) {
|
if (definedNamespaces.has(namespace)) {
|
||||||
console.error(`The keybind namespace '${namespace}' has already been defined.`)
|
console.warn(`The keybind namespace '${namespace}' has already been defined.`)
|
||||||
} else {
|
} else {
|
||||||
definedNamespaces.add(namespace)
|
definedNamespaces.add(namespace)
|
||||||
}
|
}
|
||||||
@ -263,7 +264,9 @@ export function defineKeybinds<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handler<Event_ extends KeyboardEvent | MouseEvent | PointerEvent>(
|
function handler<Event_ extends KeyboardEvent | MouseEvent | PointerEvent>(
|
||||||
handlers: Partial<Record<BindingName | typeof DefaultHandler, (event: Event_) => void>>,
|
handlers: Partial<
|
||||||
|
Record<BindingName | typeof DefaultHandler, (event: Event_) => boolean | void>
|
||||||
|
>,
|
||||||
): (event: Event_) => boolean {
|
): (event: Event_) => boolean {
|
||||||
return (event) => {
|
return (event) => {
|
||||||
const eventModifierFlags = modifierFlagsForEvent(event)
|
const eventModifierFlags = modifierFlagsForEvent(event)
|
||||||
@ -283,7 +286,9 @@ export function defineKeybinds<
|
|||||||
if (handle == null) {
|
if (handle == null) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
handle(event)
|
if (handle(event) === false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return true
|
return true
|
||||||
@ -317,13 +322,6 @@ function decomposeKeybindString(string: string): ModifierStringDecomposition {
|
|||||||
|
|
||||||
function parseKeybindString(string: string): Keybind | Mousebind {
|
function parseKeybindString(string: string): Keybind | Mousebind {
|
||||||
const decomposed = decomposeKeybindString(string)
|
const decomposed = decomposeKeybindString(string)
|
||||||
const normalized =
|
|
||||||
decomposed.modifiers.length === 0
|
|
||||||
? decomposed.key
|
|
||||||
: `${decomposed.modifiers.join('+')}+${decomposed.key}`
|
|
||||||
if (normalized !== string) {
|
|
||||||
console.warn(`Modifier string '${string}' should be '${normalized}'`)
|
|
||||||
}
|
|
||||||
if (isPointer(decomposed.key)) {
|
if (isPointer(decomposed.key)) {
|
||||||
return {
|
return {
|
||||||
type: 'mousebind',
|
type: 'mousebind',
|
||||||
@ -359,6 +357,10 @@ interface Mousebind {
|
|||||||
if (import.meta.vitest) {
|
if (import.meta.vitest) {
|
||||||
const { test, expect } = import.meta.vitest
|
const { test, expect } = import.meta.vitest
|
||||||
test.each([
|
test.each([
|
||||||
|
{ keybind: 'A', expected: { modifiers: [], key: 'A' } },
|
||||||
|
{ keybind: 'b', expected: { modifiers: [], key: 'B' } },
|
||||||
|
{ keybind: 'Space', expected: { modifiers: [], key: ' ' } },
|
||||||
|
{ keybind: 'Mod+Space', expected: { modifiers: ['Mod'], key: ' ' } },
|
||||||
// `+`
|
// `+`
|
||||||
{ keybind: 'Mod++', expected: { modifiers: ['Mod'], key: '+' } },
|
{ keybind: 'Mod++', expected: { modifiers: ['Mod'], key: '+' } },
|
||||||
// `+` and capitalization
|
// `+` and capitalization
|
||||||
|
@ -12,6 +12,9 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
"outDir": "../../node_modules/.cache/tsc",
|
"outDir": "../../node_modules/.cache/tsc",
|
||||||
"types": ["node", "vitest/importMeta"]
|
"types": ["node", "vitest/importMeta"]
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,10 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import postcssNesting from 'postcss-nesting'
|
import postcssNesting from 'postcss-nesting'
|
||||||
import tailwindcss from 'tailwindcss'
|
import tailwindcss from 'tailwindcss'
|
||||||
import tailwindcssNesting from 'tailwindcss/nesting'
|
import tailwindcssNesting from 'tailwindcss/nesting'
|
||||||
import { defineConfig, Plugin } from 'vite'
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
import topLevelAwait from 'vite-plugin-top-level-await'
|
import topLevelAwait from 'vite-plugin-top-level-await'
|
||||||
import * as tailwindConfig from '../ide-desktop/lib/dashboard/tailwind.config'
|
import * as tailwindConfig from '../ide-desktop/lib/dashboard/tailwind.config'
|
||||||
import { createGatewayServer } from './ydoc-server'
|
import { createGatewayServer } from './ydoc-server'
|
||||||
|
|
||||||
const projectManagerUrl = 'ws://127.0.0.1:30535'
|
const projectManagerUrl = 'ws://127.0.0.1:30535'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@ -21,16 +20,19 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
shared: fileURLToPath(new URL('./shared', import.meta.url)),
|
shared: fileURLToPath(new URL('./shared', import.meta.url)),
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
events$: fileURLToPath(new URL('./shared/events.ts', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
|
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
|
||||||
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
||||||
global: 'globalThis',
|
|
||||||
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||||
CLOUD_ENV:
|
CLOUD_ENV:
|
||||||
process.env.ENSO_CLOUD_ENV != null ? JSON.stringify(process.env.ENSO_CLOUD_ENV) : 'undefined',
|
process.env.ENSO_CLOUD_ENV != null ? JSON.stringify(process.env.ENSO_CLOUD_ENV) : 'undefined',
|
||||||
RUNNING_VTEST: false,
|
RUNNING_VTEST: false,
|
||||||
|
'import.meta.vitest': false,
|
||||||
|
// Single hardcoded usage of `global` in by aws-amplify.
|
||||||
|
'global.TYPED_ARRAY_SUPPORT': true,
|
||||||
},
|
},
|
||||||
assetsInclude: ['**/*.yaml', '**/*.svg'],
|
assetsInclude: ['**/*.yaml', '**/*.svg'],
|
||||||
css: {
|
css: {
|
||||||
@ -41,6 +43,14 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
// dashboard chunk size is larger than the default warning limit
|
// dashboard chunk size is larger than the default warning limit
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
fontawesome: ['@fortawesome/react-fontawesome', '@fortawesome/free-brands-svg-icons'],
|
||||||
|
'aws-amplify': ['@aws-amplify/core', '@aws-amplify/auth'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { fc, test } from '@fast-check/vitest'
|
import { fc, test } from '@fast-check/vitest'
|
||||||
import { Position, TextEdit } from 'shared/languageServerTypes'
|
import { Position, TextEdit } from 'shared/languageServerTypes'
|
||||||
import { inspect } from 'util'
|
|
||||||
import { describe, expect } from 'vitest'
|
import { describe, expect } from 'vitest'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import { applyDiffAsTextEdits, convertDeltaToTextEdits } from '../edits'
|
import { applyDiffAsTextEdits, convertDeltaToTextEdits } from '../edits'
|
||||||
@ -21,7 +20,7 @@ export function applyTextEdits(content: string, edits: TextEdit[]): string {
|
|||||||
return c.slice(0, startOffset) + edit.text + c.slice(endOffset)
|
return c.slice(0, startOffset) + edit.text + c.slice(endOffset)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to apply edit ${inspect(edit)} to content:\n${inspect(c)}\n${String(e)}`,
|
`Failed to apply edit ${JSON.stringify(edit)} to content:\n${JSON.stringify(c)}\n${e}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, content)
|
}, content)
|
||||||
|
@ -7,7 +7,12 @@ import diff from 'fast-diff'
|
|||||||
import * as json from 'lib0/json'
|
import * as json from 'lib0/json'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import { TextEdit } from '../shared/languageServerTypes'
|
import { TextEdit } from '../shared/languageServerTypes'
|
||||||
import { ModuleDoc, NodeMetadata, decodeRange } from '../shared/yjsModel'
|
import {
|
||||||
|
ModuleDoc,
|
||||||
|
decodeRange,
|
||||||
|
type NodeMetadata,
|
||||||
|
type VisualizationMetadata,
|
||||||
|
} from '../shared/yjsModel'
|
||||||
import * as fileFormat from './fileFormat'
|
import * as fileFormat from './fileFormat'
|
||||||
|
|
||||||
interface AppliedUpdates {
|
interface AppliedUpdates {
|
||||||
@ -16,7 +21,7 @@ interface AppliedUpdates {
|
|||||||
newMetadata: fileFormat.Metadata
|
newMetadata: fileFormat.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
const META_TAG = '#### METADATA ####'
|
const META_TAG = '\n\n\n#### METADATA ####'
|
||||||
|
|
||||||
export function applyDocumentUpdates(
|
export function applyDocumentUpdates(
|
||||||
doc: ModuleDoc,
|
doc: ModuleDoc,
|
||||||
@ -66,6 +71,9 @@ export function applyDocumentUpdates(
|
|||||||
position: {
|
position: {
|
||||||
vector: [updatedMeta.x, updatedMeta.y],
|
vector: [updatedMeta.x, updatedMeta.y],
|
||||||
},
|
},
|
||||||
|
visualization: updatedMeta.vis
|
||||||
|
? translateVisualizationToFile(updatedMeta.vis)
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -92,6 +100,54 @@ export function applyDocumentUpdates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function translateVisualizationToFile(
|
||||||
|
vis: VisualizationMetadata,
|
||||||
|
): fileFormat.VisualizationMetadata | undefined {
|
||||||
|
let project = undefined
|
||||||
|
switch (vis.module.kind) {
|
||||||
|
case 'Builtin':
|
||||||
|
project = { project: 'Builtin' } as const
|
||||||
|
break
|
||||||
|
case 'CurrentProject':
|
||||||
|
project = { project: 'CurrentProject' } as const
|
||||||
|
break
|
||||||
|
case 'Library':
|
||||||
|
project = { project: 'Library', contents: vis.module.name } as const
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: vis.name,
|
||||||
|
show: vis.visible,
|
||||||
|
project,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateVisualizationFromFile(
|
||||||
|
vis: fileFormat.VisualizationMetadata,
|
||||||
|
): VisualizationMetadata | undefined {
|
||||||
|
let module
|
||||||
|
switch (vis.project.project) {
|
||||||
|
case 'Builtin':
|
||||||
|
module = { kind: 'Builtin' } as const
|
||||||
|
break
|
||||||
|
case 'CurrentProject':
|
||||||
|
module = { kind: 'CurrentProject' } as const
|
||||||
|
break
|
||||||
|
case 'Library':
|
||||||
|
module = { kind: 'Library', name: vis.project.contents } as const
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: vis.name,
|
||||||
|
visible: vis.show,
|
||||||
|
module,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function convertDeltaToTextEdits(
|
export function convertDeltaToTextEdits(
|
||||||
prevText: string,
|
prevText: string,
|
||||||
contentDelta: Y.YTextEvent['delta'],
|
contentDelta: Y.YTextEvent['delta'],
|
||||||
|
@ -4,8 +4,20 @@ import z from 'zod'
|
|||||||
export type Vector = z.infer<typeof vector>
|
export type Vector = z.infer<typeof vector>
|
||||||
export const vector = z.tuple([z.number(), z.number()])
|
export const vector = z.tuple([z.number(), z.number()])
|
||||||
|
|
||||||
|
const visualizationProject = z.discriminatedUnion('project', [
|
||||||
|
z.object({ project: z.literal('Builtin') }),
|
||||||
|
z.object({ project: z.literal('CurrentProject') }),
|
||||||
|
z.object({ project: z.literal('Library'), contents: z.string() }),
|
||||||
|
])
|
||||||
|
|
||||||
export type VisualizationMetadata = z.infer<typeof visualizationMetadata>
|
export type VisualizationMetadata = z.infer<typeof visualizationMetadata>
|
||||||
const visualizationMetadata = z.object({}).passthrough()
|
const visualizationMetadata = z
|
||||||
|
.object({
|
||||||
|
show: z.boolean().default(true),
|
||||||
|
project: visualizationProject,
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
export type NodeMetadata = z.infer<typeof nodeMetadata>
|
export type NodeMetadata = z.infer<typeof nodeMetadata>
|
||||||
export const nodeMetadata = z
|
export const nodeMetadata = z
|
||||||
@ -14,7 +26,7 @@ export const nodeMetadata = z
|
|||||||
printError(ctx)
|
printError(ctx)
|
||||||
return { vector: [0, 0] satisfies Vector }
|
return { vector: [0, 0] satisfies Vector }
|
||||||
}),
|
}),
|
||||||
visualization: visualizationMetadata.catch(() => ({})),
|
visualization: visualizationMetadata.optional().catch(() => undefined),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
@ -11,10 +11,15 @@ import {
|
|||||||
ExprId,
|
ExprId,
|
||||||
IdMap,
|
IdMap,
|
||||||
ModuleDoc,
|
ModuleDoc,
|
||||||
NodeMetadata,
|
type NodeMetadata,
|
||||||
Uuid,
|
type Uuid,
|
||||||
} from '../shared/yjsModel'
|
} from '../shared/yjsModel'
|
||||||
import { applyDocumentUpdates, preParseContent, prettyPrintDiff } from './edits'
|
import {
|
||||||
|
applyDocumentUpdates,
|
||||||
|
preParseContent,
|
||||||
|
prettyPrintDiff,
|
||||||
|
translateVisualizationFromFile,
|
||||||
|
} from './edits'
|
||||||
import * as fileFormat from './fileFormat'
|
import * as fileFormat from './fileFormat'
|
||||||
import { WSSharedDoc } from './ydoc'
|
import { WSSharedDoc } from './ydoc'
|
||||||
|
|
||||||
@ -362,6 +367,8 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.changeState(LsSyncState.WritingFile)
|
this.changeState(LsSyncState.WritingFile)
|
||||||
|
|
||||||
|
const execute = contentDelta != null || idMapKeys != null
|
||||||
const apply = this.ls.applyEdit(
|
const apply = this.ls.applyEdit(
|
||||||
{
|
{
|
||||||
path: this.path,
|
path: this.path,
|
||||||
@ -369,7 +376,7 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
|||||||
oldVersion: this.syncedVersion,
|
oldVersion: this.syncedVersion,
|
||||||
newVersion,
|
newVersion,
|
||||||
},
|
},
|
||||||
true,
|
execute,
|
||||||
)
|
)
|
||||||
return (this.lastAction = apply.then(
|
return (this.lastAction = apply.then(
|
||||||
() => {
|
() => {
|
||||||
@ -411,9 +418,9 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
|||||||
for (const [id, meta] of Object.entries(nodeMeta)) {
|
for (const [id, meta] of Object.entries(nodeMeta)) {
|
||||||
if (typeof id !== 'string') continue
|
if (typeof id !== 'string') continue
|
||||||
const formattedMeta: NodeMetadata = {
|
const formattedMeta: NodeMetadata = {
|
||||||
x: meta?.position?.vector?.[0] ?? 0,
|
x: meta.position.vector[0],
|
||||||
y: meta?.position?.vector?.[1] ?? 0,
|
y: meta.position.vector[1],
|
||||||
vis: meta?.visualization ?? undefined,
|
vis: (meta.visualization && translateVisualizationFromFile(meta.visualization)) ?? null,
|
||||||
}
|
}
|
||||||
keysToDelete.delete(id)
|
keysToDelete.delete(id)
|
||||||
this.doc.metadata.set(id, formattedMeta)
|
this.doc.metadata.set(id, formattedMeta)
|
||||||
|
@ -9,7 +9,7 @@ import * as Y from 'yjs'
|
|||||||
|
|
||||||
import * as decoding from 'lib0/decoding'
|
import * as decoding from 'lib0/decoding'
|
||||||
import * as encoding from 'lib0/encoding'
|
import * as encoding from 'lib0/encoding'
|
||||||
import { ObservableV2 } from 'lib0/observable.js'
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
import { WebSocket } from 'ws'
|
import { WebSocket } from 'ws'
|
||||||
import { LanguageServerSession } from './languageServerSession'
|
import { LanguageServerSession } from './languageServerSession'
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ export function setupGatewayClient(ws: WebSocket, lsUrl: string, docName: string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
class YjsConnection extends ObservableV2<{ close: () => void }> {
|
class YjsConnection extends ObservableV2<{ close(): void }> {
|
||||||
ws: WebSocket
|
ws: WebSocket
|
||||||
wsDoc: WSSharedDoc
|
wsDoc: WSSharedDoc
|
||||||
constructor(ws: WebSocket, wsDoc: WSSharedDoc) {
|
constructor(ws: WebSocket, wsDoc: WSSharedDoc) {
|
||||||
|
92
package-lock.json
generated
92
package-lock.json
generated
@ -39,16 +39,16 @@
|
|||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
"hash-sum": "^2.0.0",
|
"hash-sum": "^2.0.0",
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"lib0": "^0.2.83",
|
"lib0": "^0.2.85",
|
||||||
"magic-string": "^0.30.3",
|
"magic-string": "^0.30.3",
|
||||||
"murmurhash": "^2.0.1",
|
"murmurhash": "^2.0.1",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
"postcss-inline-svg": "^6.0.0",
|
"postcss-inline-svg": "^6.0.0",
|
||||||
"postcss-nesting": "^12.0.1",
|
"postcss-nesting": "^12.0.1",
|
||||||
|
"rollup-plugin-visualizer": "^5.9.2",
|
||||||
"sha3": "^2.1.4",
|
"sha3": "^2.1.4",
|
||||||
"sucrase": "^3.34.0",
|
"sucrase": "^3.34.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-codemirror": "^6.1.1",
|
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.13.0",
|
||||||
"y-codemirror.next": "^0.3.2",
|
"y-codemirror.next": "^0.3.2",
|
||||||
"y-protocols": "^1.0.5",
|
"y-protocols": "^1.0.5",
|
||||||
@ -9906,7 +9906,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/is-wsl": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-docker": "^2.0.0"
|
"is-docker": "^2.0.0"
|
||||||
@ -9917,7 +9916,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/is-wsl/node_modules/is-docker": {
|
"node_modules/is-wsl/node_modules/is-docker": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"is-docker": "cli.js"
|
"is-docker": "cli.js"
|
||||||
@ -13518,6 +13516,77 @@
|
|||||||
"rollup-plugin-inject": "^3.0.0"
|
"rollup-plugin-inject": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rollup-plugin-visualizer": {
|
||||||
|
"version": "5.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.9.2.tgz",
|
||||||
|
"integrity": "sha512-waHktD5mlWrYFrhOLbti4YgQCn1uR24nYsNuXxg7LkPH8KdTXVWR9DNY1WU0QqokyMixVXJS4J04HNrVTMP01A==",
|
||||||
|
"dependencies": {
|
||||||
|
"open": "^8.4.0",
|
||||||
|
"picomatch": "^2.3.1",
|
||||||
|
"source-map": "^0.7.4",
|
||||||
|
"yargs": "^17.5.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rollup-plugin-visualizer": "dist/bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "2.x || 3.x"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rollup-plugin-visualizer/node_modules/define-lazy-prop": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rollup-plugin-visualizer/node_modules/is-docker": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||||
|
"bin": {
|
||||||
|
"is-docker": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rollup-plugin-visualizer/node_modules/open": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"define-lazy-prop": "^2.0.0",
|
||||||
|
"is-docker": "^2.1.1",
|
||||||
|
"is-wsl": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rollup-plugin-visualizer/node_modules/source-map": {
|
||||||
|
"version": "0.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
|
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup-pluginutils": {
|
"node_modules/rollup-pluginutils": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -16572,21 +16641,6 @@
|
|||||||
"@vue/shared": "3.3.4"
|
"@vue/shared": "3.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-codemirror": {
|
|
||||||
"version": "6.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-6.1.1.tgz",
|
|
||||||
"integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/commands": "6.x",
|
|
||||||
"@codemirror/language": "6.x",
|
|
||||||
"@codemirror/state": "6.x",
|
|
||||||
"@codemirror/view": "6.x"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"codemirror": "6.x",
|
|
||||||
"vue": "3.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-component-type-helpers": {
|
"node_modules/vue-component-type-helpers": {
|
||||||
"version": "1.8.4",
|
"version": "1.8.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
Loading…
Reference in New Issue
Block a user