mirror of
https://github.com/enso-org/enso.git
synced 2024-12-27 03:52:35 +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
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.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 useVisualizationConfig: typeof import('@/providers/visualizationConfig').useVisualizationConfig
|
||||
export const defineKeybinds: typeof import('@/util/shortcuts').defineKeybinds
|
||||
export const d3: typeof import('d3')
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ const conf = [
|
||||
rules: {
|
||||
camelcase: [1, { ignoreImports: true }],
|
||||
'no-inner-declarations': 0,
|
||||
'vue/attribute-hyphenation': [2, 'never'],
|
||||
'vue/v-on-event-hyphenation': [2, 'never'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
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>
|
||||
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",
|
||||
"hash-sum": "^2.0.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lib0": "^0.2.83",
|
||||
"lib0": "^0.2.85",
|
||||
"magic-string": "^0.30.3",
|
||||
"murmurhash": "^2.0.1",
|
||||
"pinia": "^2.1.6",
|
||||
@ -43,7 +43,6 @@
|
||||
"sha3": "^2.1.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
"y-protocols": "^1.0.5",
|
||||
|
@ -3,6 +3,13 @@ import { defineKeybinds } from 'builtins'
|
||||
|
||||
export const name = 'Scatterplot'
|
||||
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', {
|
||||
zoomIn: ['Mod+Z'],
|
||||
@ -68,7 +75,7 @@ enum ScaleType {
|
||||
|
||||
interface AxisConfiguration {
|
||||
label: string
|
||||
scale: ScaleType
|
||||
scale?: ScaleType
|
||||
}
|
||||
|
||||
interface AxesConfiguration {
|
||||
@ -84,8 +91,10 @@ interface Color {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { d3 } from 'builtins'
|
||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import FindIcon from './icons/find.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 ANIMATION_DURATION_MS = 400
|
||||
const VISIBLE_POINTS = 'visible'
|
||||
const DEFAULT_LIMIT = 1024
|
||||
const ACCENT_COLOR: Color = { red: 78, green: 165, blue: 253 }
|
||||
const SIZE_SCALE_MULTIPLER = 100
|
||||
const FILL_COLOR = `rgba(${ACCENT_COLOR.red * 255},${ACCENT_COLOR.green * 255},${
|
||||
ACCENT_COLOR.blue * 255
|
||||
},0.8)`
|
||||
const FILL_COLOR = `rgba(${ACCENT_COLOR.red},${ACCENT_COLOR.green},${ACCENT_COLOR.blue},0.8)`
|
||||
|
||||
const ZOOM_EXTENT = [0.5, 20] satisfies d3.BrushSelection
|
||||
const RIGHT_BUTTON = 2
|
||||
@ -201,18 +207,18 @@ const margin = computed(() => {
|
||||
return { top: 10, right: 10, bottom: 35, left: 55 }
|
||||
}
|
||||
})
|
||||
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.value.fullscreen
|
||||
const width = computed(() =>
|
||||
config.value.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.value.fullscreen
|
||||
: Math.max(config.value.width ?? 0, config.value.nodeSize.x),
|
||||
)
|
||||
|
||||
const height = computed(() =>
|
||||
config.value.fullscreen
|
||||
? 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 boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||
const xTicks = computed(() => boxWidth.value / 40)
|
||||
@ -229,7 +235,7 @@ const yLabelLeft = computed(
|
||||
)
|
||||
const yLabelTop = computed(() => -margin.value.left + 15)
|
||||
|
||||
function updatePreprocessor() {
|
||||
watchEffect(() => {
|
||||
emit(
|
||||
'update:preprocessor',
|
||||
'Standard.Visualization.Scatter_Plot',
|
||||
@ -237,9 +243,7 @@ function updatePreprocessor() {
|
||||
bounds.value == null ? 'Nothing' : '[' + bounds.value.join(',') + ']',
|
||||
limit.value.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(updatePreprocessor)
|
||||
})
|
||||
|
||||
watchEffect(() => (focus.value = data.value.focus))
|
||||
|
||||
@ -409,7 +413,6 @@ function zoomToSelected() {
|
||||
const yMin = yScale_.invert(yMinRaw)
|
||||
const yMax = yScale_.invert(yMaxRaw)
|
||||
bounds.value = [xMin, yMin, xMax, yMax]
|
||||
updatePreprocessor()
|
||||
xDomain.value = [xMin, xMax]
|
||||
yDomain.value = [yMin, yMax]
|
||||
}
|
||||
@ -435,7 +438,7 @@ function matchShape(d: Point) {
|
||||
* @param axis Axis information as received in the visualization update.
|
||||
* @returns D3 scale. */
|
||||
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(() => {
|
||||
@ -522,7 +525,6 @@ function showAll() {
|
||||
extremesAndDeltas.value.yMin - extremesAndDeltas.value.paddingY,
|
||||
extremesAndDeltas.value.yMax + extremesAndDeltas.value.paddingY,
|
||||
]
|
||||
updatePreprocessor()
|
||||
endBrushing()
|
||||
}
|
||||
|
||||
@ -539,7 +541,7 @@ useEvent(document, 'scroll', endBrushing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<template #toolbar>
|
||||
<button class="image-button active">
|
||||
<img :src="ShowAllIcon" alt="Fit all" @pointerdown="showAll" />
|
||||
|
@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
export const name = '<name here>'
|
||||
export const inputType = '<allowed input type(s) here>'
|
||||
// Optional:
|
||||
export const defaultPreprocessor = [
|
||||
'<module path here>',
|
||||
'<method name here>',
|
||||
'<optional args here>',
|
||||
]
|
||||
|
||||
interface Data {
|
||||
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'
|
||||
|
||||
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
|
||||
request: string
|
||||
params: object
|
||||
constructor(inner: Error, request: string, params: object) {
|
||||
super(`Language server request failed.`)
|
||||
this.cause = inner
|
||||
constructor(cause: Error, request: string, params: object) {
|
||||
super(`Language server request '${request}' failed.`)
|
||||
this.cause = cause
|
||||
this.request = request
|
||||
this.params = params
|
||||
}
|
||||
@ -47,6 +47,9 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
||||
client.onNotification((notification) => {
|
||||
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.
|
||||
@ -62,7 +65,7 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
||||
return await this.client.request({ method, params }, RPC_TIMEOUT_MS)
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
throw new RpcError(e, method, params)
|
||||
throw new LsRpcError(e, method, params)
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
@ -271,12 +274,12 @@ export class LanguageServer extends ObservableV2<Notifications> {
|
||||
detachVisualization(
|
||||
visualizationId: Uuid,
|
||||
expressionId: ExpressionId,
|
||||
executionContextId: ContextId,
|
||||
contextId: ContextId,
|
||||
): Promise<void> {
|
||||
return this.request('executionContext/detachVisualization', {
|
||||
visualizationId,
|
||||
expressionId,
|
||||
executionContextId,
|
||||
contextId,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -258,7 +258,7 @@ interface VisualizationContext {}
|
||||
|
||||
export interface VisualizationConfiguration {
|
||||
/** 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
|
||||
* expression. */
|
||||
visualizationModule: string
|
||||
@ -311,7 +311,7 @@ export type StackItem = ExplicitCall | LocalCall
|
||||
export interface ExplicitCall {
|
||||
type: 'ExplicitCall'
|
||||
methodPointer: MethodPointer
|
||||
thisArgumentExpression?: string
|
||||
thisArgumentExpression?: string | undefined
|
||||
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 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'
|
||||
|
||||
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
||||
@ -8,10 +9,38 @@ declare const brandExprId: unique symbol
|
||||
export type ExprId = Uuid & { [brandExprId]: never }
|
||||
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
||||
|
||||
export type VisualizationModule =
|
||||
| { kind: 'Builtin' }
|
||||
| { 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 {
|
||||
x: number
|
||||
y: number
|
||||
vis?: unknown
|
||||
vis: VisualizationMetadata | null
|
||||
}
|
||||
|
||||
export class DistributedProject {
|
||||
@ -85,6 +114,7 @@ export class ModuleDoc {
|
||||
|
||||
export class DistributedModule {
|
||||
doc: ModuleDoc
|
||||
undoManager: Y.UndoManager
|
||||
|
||||
static async load(ydoc: Y.Doc): Promise<DistributedModule> {
|
||||
ydoc.load()
|
||||
@ -94,6 +124,7 @@ export class DistributedModule {
|
||||
|
||||
constructor(ydoc: Y.Doc) {
|
||||
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 {
|
||||
@ -145,12 +176,14 @@ export class DistributedModule {
|
||||
}
|
||||
|
||||
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 {
|
||||
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0 }
|
||||
this.doc.metadata.set(id, { ...existing, ...meta })
|
||||
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0, vis: null }
|
||||
this.transact(() => {
|
||||
this.doc.metadata.set(id, { ...existing, ...meta })
|
||||
})
|
||||
}
|
||||
|
||||
getIdMap(): IdMap {
|
||||
|
@ -1099,22 +1099,10 @@
|
||||
fill="currentColor" />
|
||||
</g>
|
||||
<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">
|
||||
<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>
|
||||
<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"/>
|
||||
</g>
|
||||
<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="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>
|
||||
<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"/>
|
||||
</g>
|
||||
<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">
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
@ -1,5 +1,17 @@
|
||||
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', {
|
||||
deleteSelected: ['Delete'],
|
||||
selectAll: ['Mod+A'],
|
||||
@ -9,4 +21,5 @@ export const nodeBindings = defineKeybinds('node-selection', {
|
||||
remove: ['Shift+Alt+PointerMain'],
|
||||
toggle: ['Shift+PointerMain'],
|
||||
invert: ['Mod+Shift+Alt+PointerMain'],
|
||||
toggleVisualization: ['Space'],
|
||||
})
|
@ -19,19 +19,19 @@ const emit = defineEmits<{
|
||||
<ToggleIcon
|
||||
icon="no_auto_replay"
|
||||
class="icon-container button no-auto-evaluate-button"
|
||||
:model-value="props.isAutoEvaluationDisabled"
|
||||
:modelValue="props.isAutoEvaluationDisabled"
|
||||
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
|
||||
/>
|
||||
<ToggleIcon
|
||||
icon="docs"
|
||||
class="icon-container button docs-button"
|
||||
:model-value="props.isDocsVisible"
|
||||
:modelValue="props.isDocsVisible"
|
||||
@update:modelValue="emit('update:isDocsVisible', $event)"
|
||||
/>
|
||||
<ToggleIcon
|
||||
icon="eye"
|
||||
class="icon-container button visualization-button"
|
||||
:model-value="props.isVisualizationVisible"
|
||||
:modelValue="props.isVisualizationVisible"
|
||||
@update:modelValue="emit('update:isVisualizationVisible', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,84 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useWindowEvent } from '@/util/events'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { basicSetup } from 'codemirror'
|
||||
import { ref, watchPostEffect } from 'vue'
|
||||
// y-codemirror.next does not provide type information. See https://github.com/yjs/y-codemirror.next/issues/27
|
||||
// @ts-ignore
|
||||
import { yCollab } from 'y-codemirror.next'
|
||||
import { usePointer } from '@/util/events'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||
|
||||
// Use dynamic imports to aid code splitting. The codemirror dependency is quite large.
|
||||
const { minimalSetup, EditorState, EditorView, yCollab } = await import('@/util/codemirror')
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// == Keyboard shortcut to toggle the CodeEditor ==
|
||||
|
||||
const shown = ref(false)
|
||||
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 ==
|
||||
|
||||
const codeMirrorEl = ref(null)
|
||||
const editorView = ref<EditorView>()
|
||||
watchPostEffect((onCleanup) => {
|
||||
const yText = projectStore.module?.doc.contents
|
||||
if (!yText || !codeMirrorEl.value) return
|
||||
const undoManager = projectStore.undoManager
|
||||
const editorView = new EditorView()
|
||||
watchEffect(() => {
|
||||
const module = projectStore.module
|
||||
if (!module) return
|
||||
const yText = module.doc.contents
|
||||
const undoManager = module.undoManager
|
||||
const awareness = projectStore.awareness
|
||||
const view = new EditorView({
|
||||
parent: codeMirrorEl.value,
|
||||
state: EditorState.create({
|
||||
editorView.setState(
|
||||
EditorState.create({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="rootElement"
|
||||
class="CodeEditor"
|
||||
:style="editorStyle"
|
||||
@keydown.enter.stop
|
||||
@wheel.stop.passive
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.CodeEditor {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
width: 50%;
|
||||
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%;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.cm-gutters {
|
||||
display: none !important;
|
||||
.CodeEditor :is(.cm-focused) {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
|
||||
import { Filtering } from '@/components/ComponentBrowser/filtering'
|
||||
import { default as SvgIcon } from '@/components/SvgIcon.vue'
|
||||
import { default as ToggleIcon } from '@/components/ToggleIcon.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { useApproach } from '@/util/animation'
|
||||
import { useResizeObserver } from '@/util/events'
|
||||
|
@ -1,17 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
|
||||
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 { codeEditorBindings, graphBindings, nodeBindings } from '@/bindings'
|
||||
import CodeEditor from '@/components/CodeEditor.vue'
|
||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||
import GraphEdge from '@/components/GraphEdge.vue'
|
||||
@ -19,16 +7,18 @@ import GraphNode from '@/components/GraphNode.vue'
|
||||
import SelectionBrush from '@/components/SelectionBrush.vue'
|
||||
import TopBar from '@/components/TopBar.vue'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { ExecutionContext, useProjectStore } from '@/stores/project'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import type { Rect } from '@/stores/rect'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
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 { Vec2 } from '@/util/vec2'
|
||||
import * as set from 'lib0/set'
|
||||
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 SELECTION_BRUSH_MARGIN_PX = 6
|
||||
|
||||
@ -37,7 +27,6 @@ const viewportNode = ref<HTMLElement>()
|
||||
const navigator = useNavigator(viewportNode)
|
||||
const graphStore = useGraphStore()
|
||||
const projectStore = useProjectStore()
|
||||
const executionCtx = shallowRef<ExecutionContext>()
|
||||
const componentBrowserVisible = ref(false)
|
||||
const componentBrowserPosition = ref(Vec2.Zero())
|
||||
const suggestionDb = useSuggestionDbStore()
|
||||
@ -47,16 +36,6 @@ const exprRects = reactive(new Map<ExprId, Rect>())
|
||||
const selectedNodes = ref(new Set<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) {
|
||||
nodeRects.set(id, rect)
|
||||
}
|
||||
@ -66,10 +45,7 @@ function updateExprRect(id: ExprId, rect: Rect) {
|
||||
}
|
||||
|
||||
useWindowEvent('keydown', (event) => {
|
||||
if (keyboardBusy()) {
|
||||
return
|
||||
}
|
||||
graphBindingsHandler(event) || nodeSelectionHandler(event)
|
||||
graphBindingsHandler(event) || nodeSelectionHandler(event) || codeEditorHandler(event)
|
||||
})
|
||||
|
||||
onMounted(() => viewportNode.value?.focus())
|
||||
@ -84,13 +60,15 @@ function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
|
||||
|
||||
function moveNode(id: ExprId, delta: Vec2) {
|
||||
const scaledDelta = delta.scale(1 / navigator.scale)
|
||||
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
|
||||
const node = graphStore.nodes.get(id_)
|
||||
if (node == null) {
|
||||
continue
|
||||
graphStore.transact(() => {
|
||||
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
|
||||
const node = graphStore.nodes.get(id_)
|
||||
if (node == null) {
|
||||
continue
|
||||
}
|
||||
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
|
||||
}
|
||||
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectionAnchor = shallowRef<Vec2>()
|
||||
@ -168,15 +146,13 @@ function updateLatestSelectedNode(id: ExprId) {
|
||||
|
||||
const graphBindingsHandler = graphBindings.handler({
|
||||
undo() {
|
||||
projectStore.undoManager.undo()
|
||||
projectStore.module?.undoManager.undo()
|
||||
},
|
||||
redo() {
|
||||
projectStore.undoManager.redo()
|
||||
projectStore.module?.undoManager.redo()
|
||||
},
|
||||
openComponentBrowser() {
|
||||
if (keyboardBusy()) {
|
||||
return false
|
||||
}
|
||||
if (keyboardBusy()) return false
|
||||
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
||||
componentBrowserPosition.value = navigator.sceneMousePos
|
||||
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({
|
||||
deleteSelected() {
|
||||
for (const node of selectedNodes.value) {
|
||||
graphStore.deleteNode(node)
|
||||
}
|
||||
graphStore.transact(() => {
|
||||
for (const node of selectedNodes.value) {
|
||||
graphStore.deleteNode(node)
|
||||
}
|
||||
})
|
||||
},
|
||||
selectAll() {
|
||||
if (keyboardBusy()) return
|
||||
for (const id of graphStore.nodes.keys()) {
|
||||
selectedNodes.value.add(id)
|
||||
}
|
||||
@ -203,6 +191,19 @@ const nodeSelectionHandler = nodeBindings.handler({
|
||||
deselectAll() {
|
||||
clearSelection()
|
||||
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"
|
||||
:key="index"
|
||||
:edge="edge"
|
||||
:node-rects="nodeRects"
|
||||
:expr-rects="exprRects"
|
||||
:expr-nodes="graphStore.exprNodes"
|
||||
:nodeRects="nodeRects"
|
||||
:exprRects="exprRects"
|
||||
:exprNodes="graphStore.exprNodes"
|
||||
/>
|
||||
</svg>
|
||||
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
||||
@ -302,7 +303,8 @@ const groupColors = computed(() => {
|
||||
:key="id"
|
||||
:node="node"
|
||||
:selected="selectedNodes.has(id)"
|
||||
:is-latest-selected="id === latestSelectedNode"
|
||||
:isLatestSelected="id === latestSelectedNode"
|
||||
:fullscreenVis="false"
|
||||
@update:selected="setSelected(id, $event), $event && updateLatestSelectedNode(id)"
|
||||
@replaceSelection="
|
||||
selectedNodes.clear(), selectedNodes.add(id), updateLatestSelectedNode(id)
|
||||
@ -311,6 +313,8 @@ const groupColors = computed(() => {
|
||||
@delete="graphStore.deleteNode(id)"
|
||||
@updateExprRect="updateExprRect"
|
||||
@updateContent="updateNodeContent(id, $event)"
|
||||
@setVisualizationId="graphStore.setNodeVisualizationId(id, $event)"
|
||||
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
|
||||
@movePosition="moveNode(id, $event)"
|
||||
/>
|
||||
</div>
|
||||
@ -330,7 +334,13 @@ const groupColors = computed(() => {
|
||||
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
|
||||
@execute="console.log('\'execute\' button clicked.')"
|
||||
/>
|
||||
<CodeEditor ref="codeEditor" />
|
||||
<div ref="codeEditorArea">
|
||||
<Suspense>
|
||||
<Transition>
|
||||
<CodeEditor v-if="showCodeEditor" />
|
||||
</Transition>
|
||||
</Suspense>
|
||||
</div>
|
||||
<SelectionBrush
|
||||
v-if="scaledMousePos"
|
||||
:position="scaledMousePos"
|
||||
|
@ -1,20 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { nodeBindings } from '@/bindings/nodeSelection'
|
||||
import { nodeBindings } from '@/bindings'
|
||||
import CircularMenu from '@/components/CircularMenu.vue'
|
||||
import NodeSpan from '@/components/NodeSpan.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||
import {
|
||||
provideVisualizationConfig,
|
||||
type VisualizationConfig,
|
||||
} from '@/providers/visualizationConfig'
|
||||
import type { Node } from '@/stores/graph'
|
||||
import { Rect } from '@/stores/rect'
|
||||
import { useVisualizationStore, type Visualization } from '@/stores/visualization'
|
||||
import { keyboardBusy, useDocumentEvent, usePointer, useResizeObserver } from '@/util/events'
|
||||
import {
|
||||
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 { 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
|
||||
|
||||
@ -22,6 +29,7 @@ const props = defineProps<{
|
||||
node: Node
|
||||
selected: boolean
|
||||
isLatestSelected: boolean
|
||||
fullscreenVis: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -29,6 +37,8 @@ const emit = defineEmits<{
|
||||
updateExprRect: [id: ExprId, rect: Rect]
|
||||
updateContent: [updates: [range: ContentRange, content: string][]]
|
||||
movePosition: [delta: Vec2]
|
||||
setVisualizationId: [id: Opt<VisualizationIdentifier>]
|
||||
setVisualizationVisible: [visible: boolean]
|
||||
delete: []
|
||||
replaceSelection: []
|
||||
'update:selected': [selected: boolean]
|
||||
@ -40,6 +50,29 @@ const rootNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode)
|
||||
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(() => {
|
||||
const size = nodeSize.value
|
||||
if (!size.isZero()) {
|
||||
@ -259,66 +292,59 @@ onUpdated(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isAutoEvaluationDisabled = ref(false)
|
||||
const isDocsVisible = ref(false)
|
||||
const isVisualizationVisible = ref(false)
|
||||
function updatePreprocessor(
|
||||
visualizationModule: string,
|
||||
expression: string,
|
||||
...positionalArgumentsExpressions: string[]
|
||||
) {
|
||||
visPreprocessor.value = { visualizationModule, expression, positionalArgumentsExpressions }
|
||||
}
|
||||
|
||||
const visualizationType = ref('Scatterplot')
|
||||
const visualization = shallowRef<Visualization>()
|
||||
|
||||
const queuedVisualizationData = computed<{}>(() =>
|
||||
visualizationStore.sampleData(visualizationType.value),
|
||||
)
|
||||
const visualizationData = ref<{}>({})
|
||||
function switchToDefaultPreprocessor() {
|
||||
visPreprocessor.value = DEFAULT_VISUALIZATION_CONFIGURATION
|
||||
}
|
||||
|
||||
const visualizationConfig = ref<VisualizationConfig>({
|
||||
fullscreen: false,
|
||||
types: visualizationStore.types,
|
||||
width: null,
|
||||
height: 150, // FIXME:
|
||||
height: 150,
|
||||
hide() {
|
||||
isVisualizationVisible.value = false
|
||||
},
|
||||
updateType(type) {
|
||||
visualizationType.value = type
|
||||
emit('setVisualizationVisible', false)
|
||||
},
|
||||
updateType: (id) => emit('setVisualizationId', id),
|
||||
isCircularMenuVisible: props.isLatestSelected,
|
||||
get nodeSize() {
|
||||
return nodeSize.value
|
||||
},
|
||||
get currentType() {
|
||||
return props.node.vis ?? DEFAULT_VISUALIZATION_IDENTIFIER
|
||||
},
|
||||
})
|
||||
provideVisualizationConfig(visualizationConfig)
|
||||
|
||||
useDocumentEvent('keydown', (event) => {
|
||||
if (keyboardBusy()) {
|
||||
watchEffect(async () => {
|
||||
if (props.node.vis == null) {
|
||||
return
|
||||
}
|
||||
if (event.key === ' ') {
|
||||
if (event.shiftKey) {
|
||||
if (isVisualizationVisible.value) {
|
||||
visualizationConfig.value.fullscreen = !visualizationConfig.value.fullscreen
|
||||
} else {
|
||||
isVisualizationVisible.value = true
|
||||
visualizationConfig.value.fullscreen = true
|
||||
}
|
||||
|
||||
visualization.value = undefined
|
||||
const module = await visualizationStore.get(props.node.vis)
|
||||
if (module) {
|
||||
if (module.defaultPreprocessor != null) {
|
||||
updatePreprocessor(...module.defaultPreprocessor)
|
||||
} else {
|
||||
isVisualizationVisible.value = !isVisualizationVisible.value
|
||||
switchToDefaultPreprocessor()
|
||||
}
|
||||
visualization.value = module.default
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(async (onCleanup) => {
|
||||
if (isVisualizationVisible.value) {
|
||||
let shouldSwitchVisualization = true
|
||||
onCleanup(() => {
|
||||
shouldSwitchVisualization = false
|
||||
})
|
||||
const component = await visualizationStore.get(visualizationType.value)
|
||||
if (shouldSwitchVisualization) {
|
||||
visualization.value = component
|
||||
visualizationData.value = queuedVisualizationData.value
|
||||
}
|
||||
const effectiveVisualization = computed(() => {
|
||||
if (!visualization.value || visualizationData.value == null) {
|
||||
return LoadingVisualization
|
||||
}
|
||||
return visualization.value
|
||||
})
|
||||
|
||||
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({
|
||||
replace() {
|
||||
emit('replaceSelection')
|
||||
@ -384,7 +402,11 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
ref="rootNode"
|
||||
class="GraphNode"
|
||||
:style="{ transform }"
|
||||
:class="{ dragging: dragPointer.dragging, selected }"
|
||||
:class="{
|
||||
dragging: dragPointer.dragging,
|
||||
selected,
|
||||
visualizationVisible: isVisualizationVisible,
|
||||
}"
|
||||
>
|
||||
<div class="selection" v-on="dragPointer.events"></div>
|
||||
<div class="binding" @pointerdown.stop>
|
||||
@ -394,14 +416,14 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
v-if="isLatestSelected"
|
||||
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
|
||||
v-model:is-docs-visible="isDocsVisible"
|
||||
v-model:is-visualization-visible="isVisualizationVisible"
|
||||
:isVisualizationVisible="isVisualizationVisible"
|
||||
@update:isVisualizationVisible="emit('setVisualizationVisible', $event)"
|
||||
/>
|
||||
<component
|
||||
:is="visualization"
|
||||
v-if="isVisualizationVisible && visualization"
|
||||
:is="effectiveVisualization"
|
||||
v-if="isVisualizationVisible && effectiveVisualization != null"
|
||||
:data="visualizationData"
|
||||
@update:preprocessor="updatePreprocessor"
|
||||
@update:type="visualizationType = $event"
|
||||
/>
|
||||
<div class="node" v-on="dragPointer.events">
|
||||
<SvgIcon class="icon grab-handle" name="number_input"></SvgIcon>
|
||||
@ -412,6 +434,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
spellcheck="false"
|
||||
@beforeinput="editContent"
|
||||
@pointerdown.stop
|
||||
@blur="projectStore.stopCapturingUndo()"
|
||||
>
|
||||
<NodeSpan
|
||||
:content="node.content"
|
||||
@ -426,10 +449,16 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
|
||||
<style scoped>
|
||||
.GraphNode {
|
||||
--node-height: 32px;
|
||||
--node-border-radius: calc(var(--node-height) * 0.5);
|
||||
|
||||
--node-color-primary: #357ab9;
|
||||
position: absolute;
|
||||
border-radius: var(--radius-full);
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
::selection {
|
||||
background-color: rgba(255, 255, 255, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
@ -437,9 +466,10 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
top: 0;
|
||||
left: 0;
|
||||
caret-shape: bar;
|
||||
height: var(--node-height);
|
||||
background: var(--node-color-primary);
|
||||
background-clip: padding-box;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--node-border-radius);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@ -447,17 +477,15 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
padding: 4px 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.GraphNode .selection {
|
||||
position: absolute;
|
||||
inset: calc(0px - var(--selected-node-border-width));
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--node-border-radius);
|
||||
display: block;
|
||||
inset: var(--selected-node-border-width);
|
||||
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 {
|
||||
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 {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.binding {
|
||||
user-select: none;
|
||||
margin-right: 10px;
|
||||
@ -492,6 +519,13 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.GraphNode .selection:hover + .binding,
|
||||
.GraphNode.selected .binding {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editable {
|
||||
@ -511,13 +545,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.visualization {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
background: #222;
|
||||
border-radius: 16px;
|
||||
.CircularMenu {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
@ -10,7 +10,7 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>()
|
||||
<span class="title" v-text="props.title"></span>
|
||||
<ExecutionModeSelector
|
||||
:modes="props.modes"
|
||||
:model-value="props.mode"
|
||||
:modelValue="props.mode"
|
||||
@execute="emit('execute')"
|
||||
@update:modelValue="emit('update:mode', $event)"
|
||||
/>
|
||||
|
@ -127,8 +127,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
<VisualizationSelector
|
||||
v-if="isSelectorVisible"
|
||||
:types="config.types"
|
||||
:modelValue="config.currentType"
|
||||
@hide="isSelectorVisible = false"
|
||||
@update:type="
|
||||
@update:modelValue="
|
||||
(type) => {
|
||||
isSelectorVisible = false
|
||||
config.updateType(type)
|
||||
@ -148,12 +149,11 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
|
||||
<style scoped>
|
||||
.VisualizationContainer {
|
||||
color: var(--color-text);
|
||||
background: var(--color-visualization-bg);
|
||||
position: absolute;
|
||||
min-width: 100%;
|
||||
width: min-content;
|
||||
color: var(--color-text);
|
||||
z-index: -1;
|
||||
border-radius: var(--radius-default);
|
||||
}
|
||||
|
||||
@ -189,11 +189,6 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
transition-property: padding-left;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen .toolbars,
|
||||
.VisualizationContainer:not(.circular-menu-visible) .toolbars {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
@ -1,29 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useDocumentEvent } from '../util/events'
|
||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ types: string[] }>()
|
||||
const emit = defineEmits<{ hide: []; 'update:type': [type: string] }>()
|
||||
const props = defineProps<{
|
||||
types: VisualizationIdentifier[]
|
||||
modelValue: VisualizationIdentifier
|
||||
}>()
|
||||
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
|
||||
useDocumentEvent('pointerdown', (event) => {
|
||||
if (event.target instanceof Node && rootNode.value?.contains(event.target)) {
|
||||
return
|
||||
function visIdLabel(id: VisualizationIdentifier) {
|
||||
switch (id.module.kind) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div ref="rootNode" class="VisualizationSelector">
|
||||
<div ref="rootNode" :tabindex="-1" class="VisualizationSelector" @blur="emit('hide')">
|
||||
<div class="background"></div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="type_ in props.types"
|
||||
:key="type_"
|
||||
@click="emit('update:type', type_)"
|
||||
v-text="type_"
|
||||
:key="visIdKey(type_)"
|
||||
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
|
||||
@pointerdown.stop="emit('update:modelValue', type_)"
|
||||
v-text="visIdLabel(type_)"
|
||||
></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -67,6 +84,10 @@ li {
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.selected {
|
||||
background: var(--color-menu-entry-selected-bg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-menu-entry-hover-bg);
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Heatmap'
|
||||
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
|
||||
|
||||
@ -36,16 +40,14 @@ interface Bucket {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
|
||||
import * as d3 from 'd3'
|
||||
import { computed, onMounted, ref, watchPostEffect } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
import { useVisualizationConfig } from '@/providers/visualizationConfig.ts'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
@ -101,10 +103,6 @@ watchPostEffect(() => {
|
||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||
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 newData = data.value
|
||||
let groups: number[] = []
|
||||
@ -213,7 +211,7 @@ watchPostEffect(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div ref="containerNode" class="HeatmapVisualization">
|
||||
<svg :width="width" :height="height">
|
||||
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">
|
||||
|
@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
|
||||
export const name = 'Histogram'
|
||||
export const inputType =
|
||||
'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'],
|
||||
showAll: ['Mod+A'],
|
||||
})
|
||||
@ -88,8 +93,8 @@ interface AxisConfiguration {
|
||||
}
|
||||
|
||||
interface AxesConfiguration {
|
||||
x: AxisConfiguration
|
||||
y: AxisConfiguration
|
||||
x: AxisConfiguration | undefined
|
||||
y: AxisConfiguration | undefined
|
||||
}
|
||||
|
||||
interface Bin {
|
||||
@ -100,8 +105,9 @@ interface Bin {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
import * as d3 from 'd3'
|
||||
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
@ -130,9 +136,6 @@ const MID_BUTTON_CLICKED = 4
|
||||
const SCROLL_WHEEL = 0
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const config = useVisualizationConfig()
|
||||
|
||||
@ -155,7 +158,7 @@ const points = ref<number[]>([])
|
||||
const rawBins = ref<number[]>()
|
||||
const binCount = ref(DEFAULT_NUMBER_OF_BINS)
|
||||
const axis = ref(DEFAULT_AXES_CONFIGURATION)
|
||||
const rawFocus = ref<Focus>()
|
||||
const focus = ref<Focus>()
|
||||
const brushExtent = ref<d3.BrushSelection>()
|
||||
const zoomLevel = ref(1)
|
||||
const shouldAnimate = ref(false)
|
||||
@ -224,7 +227,7 @@ watchEffect(() => {
|
||||
axis.value = rawData.axis
|
||||
}
|
||||
if (rawData.focus != null) {
|
||||
rawFocus.value = rawData.focus
|
||||
focus.value = rawData.focus
|
||||
}
|
||||
if (rawData.bins != null) {
|
||||
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 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 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 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 startY = 0
|
||||
@ -416,7 +419,7 @@ function zoomToSelected() {
|
||||
if (brushExtent.value == null) {
|
||||
return
|
||||
}
|
||||
rawFocus.value = undefined
|
||||
focus.value = undefined
|
||||
const xScale_ = xScale.value
|
||||
const startRaw = brushExtent.value[0]
|
||||
const endRaw = brushExtent.value[1]
|
||||
@ -468,7 +471,7 @@ const xExtents = computed<[min: number, max: number]>(() => {
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const focus_ = rawFocus.value
|
||||
const focus_ = focus.value
|
||||
if (focus_?.x != null && focus_.zoom != null) {
|
||||
let paddingX = extremesAndDeltas.value.dx / (2 * focus_.zoom)
|
||||
xDomain.value = [focus_.x - paddingX, focus_.x + paddingX]
|
||||
@ -496,14 +499,6 @@ function updateColorLegend(colorScale: d3.ScaleSequential<string>) {
|
||||
.attr('stop-color', (d) => d.color)
|
||||
}
|
||||
|
||||
// =============
|
||||
// === Setup ===
|
||||
// =============
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Histogram', 'process_to_json_text')
|
||||
})
|
||||
|
||||
// ==============
|
||||
// === Update ===
|
||||
// ==============
|
||||
@ -566,7 +561,7 @@ watchPostEffect(() => {
|
||||
// ======================
|
||||
|
||||
function showAll() {
|
||||
rawFocus.value = undefined
|
||||
focus.value = undefined
|
||||
zoomLevel.value = 1
|
||||
xDomain.value = originalXScale.value.domain()
|
||||
shouldAnimate.value = true
|
||||
@ -581,7 +576,7 @@ useEvent(document, 'scroll', endBrushing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<template #toolbar>
|
||||
<button class="image-button active">
|
||||
<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="yAxisNode" class="axis-y"></g>
|
||||
<text
|
||||
v-if="axis.x.label"
|
||||
v-if="axis.x?.label"
|
||||
class="label label-x"
|
||||
text-anchor="end"
|
||||
:x="xLabelLeft"
|
||||
:y="xLabelTop"
|
||||
v-text="axis.x.label"
|
||||
v-text="axis.x?.label"
|
||||
></text>
|
||||
<text
|
||||
v-if="axis.y.label"
|
||||
v-if="axis.y?.label"
|
||||
class="label label-y"
|
||||
text-anchor="end"
|
||||
:x="yLabelLeft"
|
||||
:y="yLabelTop"
|
||||
v-text="axis.y.label"
|
||||
v-text="axis.y?.label"
|
||||
></text>
|
||||
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
|
||||
<g ref="zoomNode" class="zoom">
|
||||
|
@ -23,7 +23,7 @@ const src = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-node="true">
|
||||
<VisualizationContainer :belowNode="true">
|
||||
<div class="ImageVisualization">
|
||||
<img :src="src" />
|
||||
</div>
|
||||
|
@ -4,24 +4,13 @@ export const inputType = 'Any'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
|
||||
type Data = Record<string, unknown>
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.Preprocessor', 'error_preprocessor')
|
||||
})
|
||||
const props = defineProps<{ data: unknown }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="JSONVisualization" v-text="props.data"></div>
|
||||
</VisualizationContainer>
|
||||
</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">
|
||||
export const name = 'SQL Query'
|
||||
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
|
||||
@ -32,7 +36,7 @@ declare const sqlFormatter: typeof import('sql-formatter')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// @ts-expect-error
|
||||
// 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'
|
||||
|
||||
const props = defineProps<{ data: Data }>()
|
||||
const emit = defineEmits<{
|
||||
'update:preprocessor': [module: string, method: string, ...args: string[]]
|
||||
}>()
|
||||
|
||||
const theme: Theme = DEFAULT_THEME
|
||||
|
||||
@ -72,10 +73,6 @@ const TEXT_TYPE = 'Builtins.Main.Text'
|
||||
/** Specifies opacity of interpolation background color. */
|
||||
const INTERPOLATION_BACKGROUND_OPACITY = 0.2
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:preprocessor', 'Standard.Visualization.SQL.Visualization', 'prepare_visualization')
|
||||
})
|
||||
|
||||
// === Handling Colors ===
|
||||
|
||||
/** 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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="sql-visualization scrollable">
|
||||
<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. -->
|
||||
@ -142,32 +139,32 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
||||
<style scoped>
|
||||
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
|
||||
|
||||
.sql-visualization {
|
||||
.SQLVisualization {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.sql-visualization .sql {
|
||||
.SQLVisualization .sql {
|
||||
font-family: 'DejaVu Sans Mono', monospace;
|
||||
font-size: 12px;
|
||||
margin-left: 7px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.sql-visualization .interpolation {
|
||||
.SQLVisualization .interpolation {
|
||||
border-radius: 6px;
|
||||
padding: 1px 2px 1px 2px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch-parent {
|
||||
.SQLVisualization .mismatch-parent {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch-mouse-area {
|
||||
.SQLVisualization .mismatch-mouse-area {
|
||||
display: inline;
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
@ -176,15 +173,15 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sql-visualization .mismatch {
|
||||
.SQLVisualization .mismatch {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sql-visualization .modulepath {
|
||||
.SQLVisualization .modulepath {
|
||||
color: rgba(150, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.sql-visualization .tooltip {
|
||||
.SQLVisualization .tooltip {
|
||||
font-family: DejaVuSansMonoBook, sans-serif;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
|
@ -2,6 +2,11 @@
|
||||
export const name = 'Table'
|
||||
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'
|
||||
export const defaultPreprocessor = [
|
||||
'Standard.Visualization.Table.Visualization',
|
||||
'prepare_visualization',
|
||||
'1000',
|
||||
] as const
|
||||
|
||||
type Data = Error | Matrix | ObjectMatrix | LegacyMatrix | LegacyObjectMatrix | UnknownTable
|
||||
|
||||
@ -408,8 +413,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<div ref="rootNode" class="TableVisualization" @wheel.stop>
|
||||
<VisualizationContainer :belowToolbar="true" :overflow="true">
|
||||
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
|
||||
<div class="table-visualization-status-bar">
|
||||
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
|
||||
<button :disabled="isFirstPage" @click="goToPreviousPage">‹</button>
|
||||
|
@ -1,27 +1,22 @@
|
||||
<script lang="ts">
|
||||
export const name = 'Warnings'
|
||||
export const inputType = 'Any'
|
||||
export const defaultPreprocessor = [
|
||||
'Standard.Visualization.Warnings',
|
||||
'process_to_json_text',
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import VisualizationContainer from '@/components/VisualizationContainer.vue'
|
||||
|
||||
type Data = string[]
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<VisualizationContainer :below-toolbar="true">
|
||||
<VisualizationContainer :belowToolbar="true">
|
||||
<div class="WarningsVisualization">
|
||||
<ul>
|
||||
<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`
|
||||
|
||||
import './assets/main.css'
|
||||
|
||||
import { basicSetup } from 'codemirror'
|
||||
import * as dashboard from 'enso-authentication'
|
||||
import { isMac } from 'lib0/environment'
|
||||
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)
|
||||
|
||||
@ -24,25 +17,29 @@ const config = {
|
||||
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
|
||||
}
|
||||
|
||||
const vueAppEntry = import('./createApp')
|
||||
|
||||
async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) {
|
||||
if (app != null) stopApp()
|
||||
const rootProps = { config, accessToken, metadata }
|
||||
app = createApp(AppRoot, rootProps)
|
||||
app.use(createPinia())
|
||||
app.use(VueCodemirror, { extensions: [basicSetup] })
|
||||
app.mount('#app')
|
||||
runRequested = true
|
||||
const { mountProjectApp } = await vueAppEntry
|
||||
if (runRequested) {
|
||||
unmount?.()
|
||||
const app = mountProjectApp({ config, accessToken, metadata })
|
||||
unmount = () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function stopApp() {
|
||||
if (app == null) return
|
||||
app.unmount()
|
||||
app = null
|
||||
runRequested = false
|
||||
unmount?.()
|
||||
unmount = null
|
||||
}
|
||||
|
||||
const appRunner = { runApp, stopApp }
|
||||
|
@ -1,17 +1,19 @@
|
||||
import type { Vec2 } from '@/util/vec2'
|
||||
import type { VisualizationIdentifier } from 'shared/yjsModel'
|
||||
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||
|
||||
export interface VisualizationConfig {
|
||||
/** Possible visualization types that can be switched to. */
|
||||
background?: string
|
||||
readonly types: string[]
|
||||
readonly types: VisualizationIdentifier[]
|
||||
readonly currentType: VisualizationIdentifier
|
||||
readonly isCircularMenuVisible: boolean
|
||||
readonly nodeSize: Vec2
|
||||
width: number | null
|
||||
height: number | null
|
||||
fullscreen: boolean
|
||||
hide: () => void
|
||||
updateType: (type: string) => void
|
||||
updateType: (type: VisualizationIdentifier) => void
|
||||
}
|
||||
|
||||
const provideKey = Symbol('visualizationConfig') as InjectionKey<Ref<VisualizationConfig>>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { assert, assertNever } from '@/util/assert'
|
||||
import { useObserveYjs } from '@/util/crdt'
|
||||
import { parseEnso } from '@/util/ffi'
|
||||
import { parseEnso, type Ast } from '@/util/ffi'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import * as map from 'lib0/map'
|
||||
@ -8,14 +8,18 @@ import * as set from 'lib0/set'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
rangeIntersects,
|
||||
visMetadataEquals,
|
||||
type ContentRange,
|
||||
type ExprId,
|
||||
type IdMap,
|
||||
type NodeMetadata,
|
||||
type VisualizationIdentifier,
|
||||
type VisualizationMetadata,
|
||||
} from 'shared/yjsModel'
|
||||
import { computed, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
import { useProjectStore } from './project'
|
||||
import { DEFAULT_VISUALIZATION_IDENTIFIER } from './visualization'
|
||||
|
||||
export const useGraphStore = defineStore('graph', () => {
|
||||
const proj = useProjectStore()
|
||||
@ -73,10 +77,8 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
if (value != null) updateState()
|
||||
})
|
||||
|
||||
const _parsed = ref([] as Statement[])
|
||||
const _parsedEnso = ref<any>()
|
||||
|
||||
watchEffect(() => {})
|
||||
const _parsed = ref<Statement[]>([])
|
||||
const _parsedEnso = ref<Ast.Tree>()
|
||||
|
||||
function updateState(affectedRanges?: ContentRange[]) {
|
||||
const module = proj.module
|
||||
@ -107,8 +109,8 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
while (affectedRanges[0]?.[1]! < exprRange[0]) {
|
||||
affectedRanges.shift()
|
||||
}
|
||||
if (affectedRanges.length === 0) break
|
||||
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]!)
|
||||
if (affectedRanges[0] == null) break
|
||||
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
|
||||
if (!nodeAffected) continue
|
||||
}
|
||||
|
||||
@ -132,13 +134,8 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
const data = meta.get(id)
|
||||
const node = nodes.get(id as ExprId)
|
||||
if (data != null && node != null) {
|
||||
const pos = new Vec2(data.x, -data.y)
|
||||
if (!node.position.equals(pos)) {
|
||||
node.position = pos
|
||||
}
|
||||
assignUpdatedMetadata(node, data)
|
||||
}
|
||||
} else {
|
||||
console.log(op)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -152,12 +149,16 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
content,
|
||||
binding: stmt.binding ?? '',
|
||||
rootSpan: stmt.expression,
|
||||
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, -meta.y),
|
||||
position: Vec2.Zero(),
|
||||
vis: undefined,
|
||||
docRange: [
|
||||
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset, -1),
|
||||
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
|
||||
],
|
||||
}
|
||||
if (meta) {
|
||||
assignUpdatedMetadata(node, meta)
|
||||
}
|
||||
identDefinitions.set(node.binding, nodeId)
|
||||
addSpanUsages(nodeId, node)
|
||||
nodes.set(nodeId, node)
|
||||
@ -177,12 +178,22 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
} else {
|
||||
node.rootSpan = stmt.expression
|
||||
}
|
||||
if (meta != null && !node.position.equals(new Vec2(meta.x, -meta.y))) {
|
||||
node.position = new Vec2(meta.x, -meta.y)
|
||||
if (meta != null) {
|
||||
assignUpdatedMetadata(node, meta)
|
||||
}
|
||||
addSpanUsages(nodeId, node)
|
||||
}
|
||||
|
||||
function assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
|
||||
const newPosition = new Vec2(meta.x, -meta.y)
|
||||
if (!node.position.equals(newPosition)) {
|
||||
node.position = newPosition
|
||||
}
|
||||
if (!visMetadataEquals(node.vis, meta.vis)) {
|
||||
node.vis = meta.vis
|
||||
}
|
||||
}
|
||||
|
||||
function addSpanUsages(id: ExprId, node: Node) {
|
||||
for (const [span, offset] of walkSpansBfs(node.rootSpan)) {
|
||||
exprNodes.set(span.id, id)
|
||||
@ -251,6 +262,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
const meta: NodeMetadata = {
|
||||
x: position.x,
|
||||
y: -position.y,
|
||||
vis: null,
|
||||
}
|
||||
const ident = generateUniqueIdent()
|
||||
const content = `${ident} = ${expression}`
|
||||
@ -275,16 +287,50 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
return proj.module?.transact(fn)
|
||||
}
|
||||
|
||||
function replaceNodeSubexpression(id: ExprId, range: ContentRange, content: string) {
|
||||
const node = nodes.get(id)
|
||||
function stopCapturingUndo() {
|
||||
proj.stopCapturingUndo()
|
||||
}
|
||||
|
||||
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
|
||||
const node = nodes.get(nodeId)
|
||||
if (node == null) return
|
||||
proj.module?.replaceExpressionContent(node.rootSpan.id, content, range)
|
||||
}
|
||||
|
||||
function setNodePosition(id: ExprId, position: Vec2) {
|
||||
const node = nodes.get(id)
|
||||
function setNodePosition(nodeId: ExprId, position: Vec2) {
|
||||
const node = nodes.get(nodeId)
|
||||
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 {
|
||||
@ -302,6 +348,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
setExpressionContent,
|
||||
replaceNodeSubexpression,
|
||||
setNodePosition,
|
||||
setNodeVisualizationId,
|
||||
setNodeVisualizationVisible,
|
||||
stopCapturingUndo,
|
||||
}
|
||||
})
|
||||
|
||||
@ -315,6 +364,7 @@ export interface Node {
|
||||
rootSpan: Span
|
||||
position: Vec2
|
||||
docRange: [Y.RelativePosition, Y.RelativePosition]
|
||||
vis: Opt<VisualizationMetadata>
|
||||
}
|
||||
|
||||
export const enum SpanKind {
|
||||
|
@ -1,14 +1,36 @@
|
||||
import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig'
|
||||
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 { computedAsync } from '@vueuse/core'
|
||||
import * as array from 'lib0/array'
|
||||
import * as object from 'lib0/object'
|
||||
import * as random from 'lib0/random'
|
||||
import { defineStore } from 'pinia'
|
||||
import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol'
|
||||
import { DataServer } from 'shared/dataServer'
|
||||
import { LanguageServer } from 'shared/languageServer'
|
||||
import type { ContentRoot, ContextId, MethodPointer } from 'shared/languageServerTypes'
|
||||
import { DistributedProject, type Uuid } from 'shared/yjsModel'
|
||||
import { computed, markRaw, ref, watchEffect } from 'vue'
|
||||
import type {
|
||||
ContentRoot,
|
||||
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 * as Y from 'yjs'
|
||||
|
||||
@ -31,19 +53,71 @@ function resolveLsUrl(config: GuiConfig): LsUrls {
|
||||
throw new Error('Incomplete engine configuration')
|
||||
}
|
||||
|
||||
async function initializeLsRpcConnection(urls: LsUrls): Promise<{
|
||||
async function initializeLsRpcConnection(
|
||||
clientId: Uuid,
|
||||
url: string,
|
||||
): Promise<{
|
||||
connection: LanguageServer
|
||||
contentRoots: ContentRoot[]
|
||||
}> {
|
||||
const transport = new WebSocketTransport(urls.rpcUrl)
|
||||
const transport = new WebSocketTransport(url)
|
||||
const requestManager = new RequestManager([transport])
|
||||
const client = new Client(requestManager)
|
||||
const clientId = random.uuidv4() as Uuid
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
@ -54,31 +128,184 @@ async function initializeLsRpcConnection(urls: LsUrls): Promise<{
|
||||
* run only when the previous call is done.
|
||||
*/
|
||||
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(
|
||||
lsRpc: Promise<LanguageServer>,
|
||||
call: {
|
||||
methodPointer: MethodPointer
|
||||
thisArgumentExpression?: string
|
||||
positionalArgumentsExpressions?: string[]
|
||||
},
|
||||
) {
|
||||
this.state = lsRpc.then(async (lsRpc) => {
|
||||
const { contextId } = await lsRpc.createExecutionContext()
|
||||
await lsRpc.pushExecutionContextItem(contextId, {
|
||||
type: 'ExplicitCall',
|
||||
positionalArgumentsExpressions: call.positionalArgumentsExpressions ?? [],
|
||||
...call,
|
||||
})
|
||||
return { lsRpc, id: contextId }
|
||||
constructor(lsRpc: Promise<LanguageServer>, entryPoint: EntryPoint) {
|
||||
this.queue = new AsyncQueue(
|
||||
lsRpc.then((lsRpc) => ({
|
||||
lsRpc,
|
||||
created: false,
|
||||
visualizations: new Map(),
|
||||
stack: [],
|
||||
})),
|
||||
)
|
||||
this.create()
|
||||
this.pushItem({ type: 'ExplicitCall', ...entryPoint })
|
||||
}
|
||||
|
||||
private withBackoff<T>(f: () => Promise<T>, message: string): Promise<T> {
|
||||
return lsRpcWithRetries(f, {
|
||||
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() {
|
||||
this.state = this.state.then(({ lsRpc, id }) => {
|
||||
lsRpc.destroyExecutionContext(id)
|
||||
return { lsRpc, id }
|
||||
this.abortCtl.abort()
|
||||
this.queue.clear()
|
||||
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
|
||||
if (projectName == null) throw new Error('Missing project name.')
|
||||
|
||||
const clientId = random.uuidv4() as Uuid
|
||||
const lsUrls = resolveLsUrl(config.value)
|
||||
const initializedConnection = initializeLsRpcConnection(lsUrls)
|
||||
const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl)
|
||||
const lsRpcConnection = initializedConnection.then(({ connection }) => connection)
|
||||
const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots)
|
||||
|
||||
const undoManager = new Y.UndoManager([], { doc })
|
||||
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl)
|
||||
|
||||
const name = computed(() => config.value.startup?.project)
|
||||
const namespace = computed(() => config.value.engine?.namespace)
|
||||
@ -142,23 +369,14 @@ export const useProjectStore = defineStore('project', () => {
|
||||
if (guid == null) return null
|
||||
const moduleName = projectModel.findModuleByDocId(guid)
|
||||
if (moduleName == null) return null
|
||||
return await projectModel.openModule(moduleName)
|
||||
const mod = await projectModel.openModule(moduleName)
|
||||
mod?.undoManager.addTrackedOrigin('local')
|
||||
return mod
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
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>> {
|
||||
function createExecutionContextForMain(): ExecutionContext {
|
||||
if (name.value == null) {
|
||||
console.error('Cannot create execution context. Unknown project name.')
|
||||
return
|
||||
throw new Error('Cannot create execution context. Unknown project name.')
|
||||
}
|
||||
if (namespace.value == null) {
|
||||
console.warn(
|
||||
@ -167,28 +385,65 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
const projectName = `${namespace.value ?? 'local'}.${name.value}`
|
||||
const mainModule = `${projectName}.Main`
|
||||
const projectRoot = (await contentRoots).find((root) => root.type === 'Project')
|
||||
if (projectRoot == null) {
|
||||
console.error(
|
||||
'Cannot create execution context. Protocol connection initialization did not return a project root.',
|
||||
)
|
||||
return
|
||||
}
|
||||
const entryPoint = { module: mainModule, definedOnType: mainModule, name: 'main' }
|
||||
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 {
|
||||
setObservedFileName(name: string) {
|
||||
observedFileName.value = name
|
||||
},
|
||||
name: projectName,
|
||||
createExecutionContextForMain,
|
||||
executionContext,
|
||||
module,
|
||||
contentRoots,
|
||||
undoManager,
|
||||
awareness,
|
||||
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 { defineStore } from 'pinia'
|
||||
import { LanguageServer } from 'shared/languageServer'
|
||||
@ -18,17 +19,22 @@ export interface Group {
|
||||
class Synchronizer {
|
||||
entries: SuggestionDb
|
||||
groups: Ref<Group[]>
|
||||
lastUpdate: Promise<{ currentVersion: number }>
|
||||
queue: AsyncQueue<{ currentVersion: number }>
|
||||
|
||||
constructor(entries: SuggestionDb, groups: Ref<Group[]>) {
|
||||
this.entries = entries
|
||||
this.groups = groups
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
this.lastUpdate = projectStore.lsRpcConnection.then(async (lsRpc) => {
|
||||
await lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {})
|
||||
const initState = projectStore.lsRpcConnection.then(async (lsRpc) => {
|
||||
await rpcWithRetries(() =>
|
||||
lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {}),
|
||||
)
|
||||
this.setupUpdateHandler(lsRpc)
|
||||
return Synchronizer.loadDatabase(entries, lsRpc, groups.value)
|
||||
})
|
||||
|
||||
this.queue = new AsyncQueue(initState)
|
||||
}
|
||||
|
||||
static async loadDatabase(
|
||||
@ -51,7 +57,7 @@ class Synchronizer {
|
||||
|
||||
private setupUpdateHandler(lsRpc: LanguageServer) {
|
||||
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
||||
this.lastUpdate = this.lastUpdate.then(async ({ currentVersion }) => {
|
||||
this.queue.pushTask(async ({ currentVersion }) => {
|
||||
if (param.currentVersion <= currentVersion) {
|
||||
console.log(
|
||||
`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 { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
@ -21,33 +15,60 @@ import type {
|
||||
RegisterBuiltinModulesRequest,
|
||||
} from '@/workers/visualizationCompiler'
|
||||
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> = {
|
||||
vue,
|
||||
'@vueuse/core': vueUseCore,
|
||||
builtins: { VisualizationContainer, useVisualizationConfig, defineKeybinds, d3 },
|
||||
get 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
|
||||
// else.
|
||||
window.__visualizationModules = moduleCache
|
||||
|
||||
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 = {
|
||||
default: Visualization
|
||||
name: string
|
||||
inputType: string
|
||||
inputType?: string
|
||||
defaultPreprocessor?: readonly [module: string, method: string, ...args: string[]]
|
||||
scripts?: string[]
|
||||
styles?: string[]
|
||||
}
|
||||
@ -63,7 +84,6 @@ const builtinVisualizationImports: Record<string, () => Promise<VisualizationMod
|
||||
}
|
||||
|
||||
const dynamicVisualizationPaths: Record<string, string> = {
|
||||
Test: '/visualizations/TestVisualization.vue',
|
||||
Scatterplot: '/visualizations/ScatterplotVisualization.vue',
|
||||
'Geo Map': '/visualizations/GeoMapVisualization.vue',
|
||||
}
|
||||
@ -73,7 +93,13 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
||||
const imports = { ...builtinVisualizationImports }
|
||||
const paths = { ...dynamicVisualizationPaths }
|
||||
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 workerMessageId = 0
|
||||
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.
|
||||
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]
|
||||
if (module == null) {
|
||||
module = await imports[type]?.()
|
||||
@ -237,121 +268,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
||||
register(module)
|
||||
await loadScripts(module)
|
||||
cache[type] = module
|
||||
return module.default
|
||||
return module
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cache = {}
|
||||
}
|
||||
|
||||
function sampleData(type: string) {
|
||||
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 }
|
||||
return { types, get, 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>
|
||||
}
|
||||
|
||||
/**
|
||||
* URL query parameters used in gateway server websocket connection.
|
||||
*/
|
||||
/** URL query parameters used in gateway server websocket connection. */
|
||||
export type ProviderParams = {
|
||||
/** URL for the project's language server RPC connection. */
|
||||
ls: string
|
||||
|
@ -11,12 +11,26 @@ import {
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
import type { Opt } from './opt'
|
||||
|
||||
/** Whether an element currently has keyboard focus. */
|
||||
/** Whether any element currently has keyboard focus. */
|
||||
export function keyboardBusy() {
|
||||
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.
|
||||
* @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)
|
||||
}, 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 clientPos = client ?? new Vec2(event.clientX, event.clientY)
|
||||
const canvasPos = clientPos.sub(rect.pos)
|
||||
const v = viewport.value
|
||||
return new Vec2(
|
||||
@ -31,9 +34,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
}
|
||||
|
||||
let zoomPivot = Vec2.Zero()
|
||||
const zoomPointer = usePointer((pos, event, ty) => {
|
||||
const zoomPointer = usePointer((pos, _event, ty) => {
|
||||
if (ty === 'start') {
|
||||
zoomPivot = eventToScenePos(event, pos.initial)
|
||||
zoomPivot = clientToScenePos(pos.initial)
|
||||
}
|
||||
|
||||
const prevScale = scale.value
|
||||
@ -87,17 +90,20 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
{ 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({
|
||||
events: {
|
||||
pointermove(e: PointerEvent) {
|
||||
sceneMousePos.value = eventToScenePos(e)
|
||||
eventMousePos.value = eventScreenPos(e)
|
||||
panPointer.events.pointermove(e)
|
||||
zoomPointer.events.pointermove(e)
|
||||
},
|
||||
pointerleave() {
|
||||
sceneMousePos.value = null
|
||||
eventMousePos.value = null
|
||||
},
|
||||
pointerup(e: PointerEvent) {
|
||||
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)
|
||||
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',
|
||||
'PageDown',
|
||||
'Insert',
|
||||
' ',
|
||||
'Space',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
@ -177,10 +177,11 @@ const allKeys = [
|
||||
type Key = (typeof allKeys)[number]
|
||||
type LowercaseKey = Lowercase<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]),
|
||||
)
|
||||
normalizedKeyboardSegmentLookup[''] = '+'
|
||||
normalizedKeyboardSegmentLookup['space'] = ' '
|
||||
normalizedKeyboardSegmentLookup['osdelete'] = isMacLike ? 'Delete' : 'Backspace'
|
||||
type NormalizeKeybindSegment = {
|
||||
[K in KeybindSegment as Lowercase<K>]: K
|
||||
@ -234,7 +235,7 @@ export function defineKeybinds<
|
||||
BindingName extends keyof T = keyof T,
|
||||
>(namespace: string, bindings: Keybinds<T>) {
|
||||
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 {
|
||||
definedNamespaces.add(namespace)
|
||||
}
|
||||
@ -263,7 +264,9 @@ export function defineKeybinds<
|
||||
}
|
||||
|
||||
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 {
|
||||
return (event) => {
|
||||
const eventModifierFlags = modifierFlagsForEvent(event)
|
||||
@ -283,7 +286,9 @@ export function defineKeybinds<
|
||||
if (handle == null) {
|
||||
return false
|
||||
}
|
||||
handle(event)
|
||||
if (handle(event) === false) {
|
||||
return false
|
||||
}
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
return true
|
||||
@ -317,13 +322,6 @@ function decomposeKeybindString(string: string): ModifierStringDecomposition {
|
||||
|
||||
function parseKeybindString(string: string): Keybind | Mousebind {
|
||||
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)) {
|
||||
return {
|
||||
type: 'mousebind',
|
||||
@ -359,6 +357,10 @@ interface Mousebind {
|
||||
if (import.meta.vitest) {
|
||||
const { test, expect } = import.meta.vitest
|
||||
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: '+' } },
|
||||
// `+` and capitalization
|
||||
|
@ -12,6 +12,9 @@
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"outDir": "../../node_modules/.cache/tsc",
|
||||
"types": ["node", "vitest/importMeta"]
|
||||
}
|
||||
|
@ -3,11 +3,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import postcssNesting from 'postcss-nesting'
|
||||
import tailwindcss from 'tailwindcss'
|
||||
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 * as tailwindConfig from '../ide-desktop/lib/dashboard/tailwind.config'
|
||||
import { createGatewayServer } from './ydoc-server'
|
||||
|
||||
const projectManagerUrl = 'ws://127.0.0.1:30535'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@ -21,16 +20,19 @@ export default defineConfig({
|
||||
alias: {
|
||||
shared: fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
events$: fileURLToPath(new URL('./shared/events.ts', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
|
||||
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
||||
global: 'globalThis',
|
||||
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||
CLOUD_ENV:
|
||||
process.env.ENSO_CLOUD_ENV != null ? JSON.stringify(process.env.ENSO_CLOUD_ENV) : 'undefined',
|
||||
RUNNING_VTEST: false,
|
||||
'import.meta.vitest': false,
|
||||
// Single hardcoded usage of `global` in by aws-amplify.
|
||||
'global.TYPED_ARRAY_SUPPORT': true,
|
||||
},
|
||||
assetsInclude: ['**/*.yaml', '**/*.svg'],
|
||||
css: {
|
||||
@ -41,6 +43,14 @@ export default defineConfig({
|
||||
build: {
|
||||
// dashboard chunk size is larger than the default warning limit
|
||||
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 { Position, TextEdit } from 'shared/languageServerTypes'
|
||||
import { inspect } from 'util'
|
||||
import { describe, expect } from 'vitest'
|
||||
import * as Y from 'yjs'
|
||||
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)
|
||||
} catch (e) {
|
||||
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)
|
||||
|
@ -7,7 +7,12 @@ import diff from 'fast-diff'
|
||||
import * as json from 'lib0/json'
|
||||
import * as Y from 'yjs'
|
||||
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'
|
||||
|
||||
interface AppliedUpdates {
|
||||
@ -16,7 +21,7 @@ interface AppliedUpdates {
|
||||
newMetadata: fileFormat.Metadata
|
||||
}
|
||||
|
||||
const META_TAG = '#### METADATA ####'
|
||||
const META_TAG = '\n\n\n#### METADATA ####'
|
||||
|
||||
export function applyDocumentUpdates(
|
||||
doc: ModuleDoc,
|
||||
@ -66,6 +71,9 @@ export function applyDocumentUpdates(
|
||||
position: {
|
||||
vector: [updatedMeta.x, updatedMeta.y],
|
||||
},
|
||||
visualization: updatedMeta.vis
|
||||
? translateVisualizationToFile(updatedMeta.vis)
|
||||
: undefined,
|
||||
}
|
||||
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(
|
||||
prevText: string,
|
||||
contentDelta: Y.YTextEvent['delta'],
|
||||
|
@ -4,8 +4,20 @@ import z from 'zod'
|
||||
export type Vector = z.infer<typeof vector>
|
||||
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>
|
||||
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 const nodeMetadata = z
|
||||
@ -14,7 +26,7 @@ export const nodeMetadata = z
|
||||
printError(ctx)
|
||||
return { vector: [0, 0] satisfies Vector }
|
||||
}),
|
||||
visualization: visualizationMetadata.catch(() => ({})),
|
||||
visualization: visualizationMetadata.optional().catch(() => undefined),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
@ -11,10 +11,15 @@ import {
|
||||
ExprId,
|
||||
IdMap,
|
||||
ModuleDoc,
|
||||
NodeMetadata,
|
||||
Uuid,
|
||||
type NodeMetadata,
|
||||
type Uuid,
|
||||
} from '../shared/yjsModel'
|
||||
import { applyDocumentUpdates, preParseContent, prettyPrintDiff } from './edits'
|
||||
import {
|
||||
applyDocumentUpdates,
|
||||
preParseContent,
|
||||
prettyPrintDiff,
|
||||
translateVisualizationFromFile,
|
||||
} from './edits'
|
||||
import * as fileFormat from './fileFormat'
|
||||
import { WSSharedDoc } from './ydoc'
|
||||
|
||||
@ -362,6 +367,8 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
||||
}
|
||||
|
||||
this.changeState(LsSyncState.WritingFile)
|
||||
|
||||
const execute = contentDelta != null || idMapKeys != null
|
||||
const apply = this.ls.applyEdit(
|
||||
{
|
||||
path: this.path,
|
||||
@ -369,7 +376,7 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
||||
oldVersion: this.syncedVersion,
|
||||
newVersion,
|
||||
},
|
||||
true,
|
||||
execute,
|
||||
)
|
||||
return (this.lastAction = apply.then(
|
||||
() => {
|
||||
@ -411,9 +418,9 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
||||
for (const [id, meta] of Object.entries(nodeMeta)) {
|
||||
if (typeof id !== 'string') continue
|
||||
const formattedMeta: NodeMetadata = {
|
||||
x: meta?.position?.vector?.[0] ?? 0,
|
||||
y: meta?.position?.vector?.[1] ?? 0,
|
||||
vis: meta?.visualization ?? undefined,
|
||||
x: meta.position.vector[0],
|
||||
y: meta.position.vector[1],
|
||||
vis: (meta.visualization && translateVisualizationFromFile(meta.visualization)) ?? null,
|
||||
}
|
||||
keysToDelete.delete(id)
|
||||
this.doc.metadata.set(id, formattedMeta)
|
||||
|
@ -9,7 +9,7 @@ import * as Y from 'yjs'
|
||||
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { ObservableV2 } from 'lib0/observable.js'
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import { WebSocket } from 'ws'
|
||||
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
|
||||
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",
|
||||
"hash-sum": "^2.0.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lib0": "^0.2.83",
|
||||
"lib0": "^0.2.85",
|
||||
"magic-string": "^0.30.3",
|
||||
"murmurhash": "^2.0.1",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss-inline-svg": "^6.0.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"sha3": "^2.1.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -9906,7 +9906,6 @@
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
@ -9917,7 +9916,6 @@
|
||||
},
|
||||
"node_modules/is-wsl/node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
@ -13518,6 +13516,77 @@
|
||||
"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": {
|
||||
"version": "2.8.2",
|
||||
"dev": true,
|
||||
@ -16572,21 +16641,6 @@
|
||||
"@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": {
|
||||
"version": "1.8.4",
|
||||
"dev": true,
|
||||
|
Loading…
Reference in New Issue
Block a user