diff --git a/CHANGELOG.md b/CHANGELOG.md index 363b6caa9d6b..0c242d4d9b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -962,6 +962,7 @@ - [Support runtime checks of intersection types][7769] - [Merge `Small_Integer` and `Big_Integer` types][7636] - [Inline type ascriptions][7796] +- [Always persist `TRACE` level logs to a file][7825] - [Downloadable VSCode extension][7861] - [New `project/status` route for reporting LS state][7801] @@ -1106,6 +1107,7 @@ [7636]: https://github.com/enso-org/enso/pull/7636 [7796]: https://github.com/enso-org/enso/pull/7796 [7801]: https://github.com/enso-org/enso/pull/7801 +[7825]: https://github.com/enso-org/enso/pull/7825 [7861]: https://github.com/enso-org/enso/pull/7861 # Enso 2.0.0-alpha.18 (2021-10-12) diff --git a/app/gui/language/span-tree/src/action.rs b/app/gui/language/span-tree/src/action.rs index e3eda3377f85..67f9da70d037 100644 --- a/app/gui/language/span-tree/src/action.rs +++ b/app/gui/language/span-tree/src/action.rs @@ -125,16 +125,21 @@ impl<'a> Implementation for node::Ref<'a> { let mut inserted_positional_placeholder_at = None; let new_ast = modify_preserving_id(ast, |ast| { if let Some(mut infix) = extended_infix { - let item = ArgWithOffset { arg: new, offset: DEFAULT_OFFSET }; - let has_target = infix.target.is_some(); let has_arg = infix.args.last().unwrap().operand.is_some(); + let offset = infix + .enumerate_non_empty_operands() + .last() + .map_or(DEFAULT_OFFSET, |op| op.offset); let last_elem = infix.args.last_mut().unwrap(); + let item = ArgWithOffset { arg: new, offset }; + let has_target = infix.target.is_some(); last_elem.offset = DEFAULT_OFFSET; match kind { + ExpectedTarget => infix.target = Some(item), BeforeArgument(0 | 1) if !has_target => infix.target = Some(item), BeforeArgument(idx) => infix.insert_operand(*idx, item), Append if has_arg => infix.push_operand(item), - Append => last_elem.operand = Some(item), + Append | ExpectedOperand => last_elem.operand = Some(item), ExpectedArgument { .. } => unreachable!( "Expected arguments should be filtered out before this if block" ), @@ -183,6 +188,8 @@ impl<'a> Implementation for node::Ref<'a> { }; prefix.args.push(item) } + ExpectedTarget | ExpectedOperand => + unreachable!("Wrong insertion point in method call"), } Ok(prefix.into_ast()) } diff --git a/app/gui/language/span-tree/src/generate.rs b/app/gui/language/span-tree/src/generate.rs index db1ebbcf4188..0f9de627b399 100644 --- a/app/gui/language/span-tree/src/generate.rs +++ b/app/gui/language/span-tree/src/generate.rs @@ -437,7 +437,12 @@ fn generate_node_for_opr_chain( let node = target.arg.generate_node(kind, context)?; Ok((node, target.offset)) } - None => Ok((Node::new().with_kind(InsertionPointType::BeforeArgument(0)), 0)), + None => { + let application_id = this.args.first().and_then(|app| app.infix_id); + let port_id = + application_id.map(|application| PortId::ArgPlaceholder { application, index: 0 }); + Ok((Node::new().with_kind(InsertionPointType::ExpectedTarget).with_port_id(port_id), 0)) + } }; // In this fold we pass last generated node and offset after it, wrapped in Result. @@ -448,6 +453,7 @@ fn generate_node_for_opr_chain( let is_first = i == 0; let is_last = i + 1 == this.args.len(); let has_left = !node.is_insertion_point(); + let has_right = elem.operand.is_some(); let opr_crumbs = elem.crumb_to_operator(has_left); let opr_ast = Located::new(opr_crumbs, elem.operator.ast()); let left_crumbs = if has_left { vec![elem.crumb_to_previous()] } else { vec![] }; @@ -477,7 +483,7 @@ fn generate_node_for_opr_chain( } } - let infix_right_argument_info = if !app_base.uses_method_notation { + let mut infix_right_argument_info = if !app_base.uses_method_notation { app_base.set_call_id(elem.infix_id); app_base.resolve(context).and_then(|mut resolved| { // For resolved infix arguments, the arity should always be 2. First always @@ -515,13 +521,21 @@ fn generate_node_for_opr_chain( }; let argument = gen.generate_ast_node(arg_ast, argument_kind, context)?; - if let Some((index, info)) = infix_right_argument_info { + if let Some((index, info)) = infix_right_argument_info.take() { + argument.node.set_argument_info(info); + argument.node.set_definition_index(index); + } + } else if !app_base.uses_method_notation { + let argument = gen.generate_empty_node(InsertionPointType::ExpectedOperand); + argument.port_id = + elem.infix_id.map(|application| PortId::ArgPlaceholder { application, index: 1 }); + if let Some((index, info)) = infix_right_argument_info.take() { argument.node.set_argument_info(info); argument.node.set_definition_index(index); } } - if is_last && !app_base.uses_method_notation { + if is_last && has_right && !app_base.uses_method_notation { gen.generate_empty_node(InsertionPointType::Append); } @@ -1387,6 +1401,75 @@ mod test { .build(); clear_expression_ids(&mut tree.root); clear_parameter_infos(&mut tree.root); + assert_eq!(tree, expected) + } + + #[test] + fn generate_span_tree_for_unfinished_infix() { + let parser = Parser::new(); + let this_param = |call_id| ArgumentInfo { + name: Some("self".to_owned()), + tp: Some("Any".to_owned()), + call_id, + ..default() + }; + let param1 = |call_id| ArgumentInfo { + name: Some("arg1".to_owned()), + tp: Some("Number".to_owned()), + call_id, + ..default() + }; + + + // === SectionLeft === + let mut id_map = IdMap::default(); + let call_id = id_map.generate(0..2); + let ast = parser.parse_line_ast_with_id_map("2+", id_map).unwrap(); + let invocation_info = + CalledMethodInfo { parameters: vec![this_param(ast.id), param1(ast.id)], ..default() }; + let ctx = MockContext::new_single(ast.id.unwrap(), invocation_info); + let mut tree: SpanTree = SpanTree::new(&ast, &ctx).unwrap(); + match tree.root_ref().leaf_iter().collect_vec().as_slice() { + [_before, arg0, _opr, arg1] => { + assert_eq!(arg0.argument_info(), Some(&this_param(Some(call_id)))); + assert_eq!(arg1.argument_info(), Some(¶m1(Some(call_id)))); + } + sth_else => panic!("There should be 4 leaves, found: {}", sth_else.len()), + } + let expected = TreeBuilder::new(2) + .add_empty_child(0, BeforeArgument(0)) + .add_leaf(0, 1, node::Kind::argument().indexed(0), SectionLeftCrumb::Arg) + .add_leaf(1, 1, node::Kind::Operation, SectionLeftCrumb::Opr) + .add_empty_child(2, ExpectedOperand) + .build(); + clear_expression_ids(&mut tree.root); + clear_parameter_infos(&mut tree.root); + assert_eq!(tree, expected); + + + // === SectionRight === + let mut id_map = IdMap::default(); + let call_id = id_map.generate(0..2); + let ast = parser.parse_line_ast_with_id_map("+2", id_map).unwrap(); + let invocation_info = + CalledMethodInfo { parameters: vec![this_param(ast.id), param1(ast.id)], ..default() }; + let ctx = MockContext::new_single(ast.id.unwrap(), invocation_info); + let mut tree: SpanTree = SpanTree::new(&ast, &ctx).unwrap(); + match tree.root_ref().leaf_iter().collect_vec().as_slice() { + [arg0, _opr, arg1, _append] => { + assert_eq!(arg0.argument_info(), Some(&this_param(Some(call_id)))); + assert_eq!(arg1.argument_info(), Some(¶m1(Some(call_id)))); + } + sth_else => panic!("There should be 4 leaves, found: {}", sth_else.len()), + } + let expected = TreeBuilder::new(2) + .add_empty_child(0, ExpectedTarget) + .add_leaf(0, 1, node::Kind::Operation, SectionRightCrumb::Opr) + .add_leaf(1, 1, node::Kind::argument().indexed(1), SectionRightCrumb::Arg) + .add_empty_child(2, Append) + .build(); + clear_expression_ids(&mut tree.root); + clear_parameter_infos(&mut tree.root); assert_eq!(tree, expected); } diff --git a/app/gui/language/span-tree/src/node.rs b/app/gui/language/span-tree/src/node.rs index d993d4099730..1f8760e3a89c 100644 --- a/app/gui/language/span-tree/src/node.rs +++ b/app/gui/language/span-tree/src/node.rs @@ -101,9 +101,18 @@ impl Node { pub fn is_positional_insertion_point(&self) -> bool { self.kind.is_positional_insertion_point() } + pub fn is_expected_argument(&self) -> bool { self.kind.is_expected_argument() } + + pub fn is_expected_operand(&self) -> bool { + self.kind.is_expected_operand() + } + + pub fn is_placeholder(&self) -> bool { + self.is_expected_argument() || self.is_expected_operand() + } } diff --git a/app/gui/language/span-tree/src/node/kind.rs b/app/gui/language/span-tree/src/node/kind.rs index c6820b787467..c8c005aadf53 100644 --- a/app/gui/language/span-tree/src/node/kind.rs +++ b/app/gui/language/span-tree/src/node/kind.rs @@ -99,7 +99,7 @@ impl Kind { /// Match the value with `Kind::InsertionPoint{..}` but not /// `Kind::InsertionPoint(ExpectedArgument(_))`. pub fn is_positional_insertion_point(&self) -> bool { - self.is_insertion_point() && !self.is_expected_argument() + self.is_insertion_point() && !self.is_expected_argument() && !self.is_expected_operand() } /// Match the value with `Kind::InsertionPoint(ExpectedArgument(_))`. @@ -107,6 +107,17 @@ impl Kind { matches!(self, Self::InsertionPoint(t) if t.kind.is_expected_argument()) } + /// Check if given kind is an insertino point for expected operand of an unfinished infix. + pub fn is_expected_operand(&self) -> bool { + matches!( + self, + Self::InsertionPoint(InsertionPoint { + kind: InsertionPointType::ExpectedOperand | InsertionPointType::ExpectedTarget, + .. + }) + ) + } + /// Match the argument in a prefix method application. pub fn is_prefix_argument(&self) -> bool { matches!(self, Self::Argument(a) if a.in_prefix_chain) @@ -374,6 +385,10 @@ pub enum InsertionPointType { index: usize, named: bool, }, + /// Expected target of unfinished infix expression. + ExpectedTarget, + /// Expected operand of unfinished infix expression. + ExpectedOperand, } // === Matchers === diff --git a/app/gui/view/graph-editor/src/component/node/input/widget.rs b/app/gui/view/graph-editor/src/component/node/input/widget.rs index a3de4cfe9824..e99eaead7e20 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget.rs @@ -1651,7 +1651,7 @@ impl<'a> TreeBuilder<'a> { let ptr_usage = self.pointer_usage.entry(main_ptr).or_default(); let widget_id = main_ptr.to_identity(ptr_usage); - let is_placeholder = span_node.is_expected_argument(); + let is_placeholder = span_node.is_expected_argument() || span_node.is_expected_operand(); let sibling_offset = span_node.sibling_offset.as_usize(); let usage_type = span_node.ast_id.and_then(|id| self.usage_type_map.get(&id)).cloned(); diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/label.rs b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs index cda44503bf10..83c66f1f647c 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/label.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs @@ -128,7 +128,7 @@ impl SpanWidget for Widget { } fn configure(&mut self, _: &Config, ctx: ConfigContext) { - let is_placeholder = ctx.span_node.is_expected_argument(); + let is_placeholder = ctx.span_node.is_placeholder(); let expr = ctx.span_expression(); let content = if is_placeholder || ctx.info.connection.is_some() { diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs index de9a990226c0..91d9d590dd69 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs @@ -574,7 +574,7 @@ impl SpanWidget for Widget { type Config = Config; fn match_node(ctx: &ConfigContext) -> Score { - let is_placeholder = ctx.span_node.is_expected_argument(); + let is_placeholder = ctx.span_node.is_placeholder(); let decl_type = ctx.span_node.kind.tp().map(|t| t.as_str()); let first_decl_is_vector = diff --git a/app/gui2/.gitignore b/app/gui2/.gitignore index 0c02498e7692..71964f63cabb 100644 --- a/app/gui2/.gitignore +++ b/app/gui2/.gitignore @@ -9,6 +9,7 @@ dist dist-ssr coverage *.local +*.tsbuildinfo # Editor directories and files .vscode/* diff --git a/app/gui2/env.d.ts b/app/gui2/env.d.ts index d9394cc17246..08eb639e7240 100644 --- a/app/gui2/env.d.ts +++ b/app/gui2/env.d.ts @@ -1,3 +1,9 @@ /// declare const PROJECT_MANAGER_URL: string + +// 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 +} diff --git a/app/gui2/index.html b/app/gui2/index.html index 36f22655d191..dce1cc9575e8 100644 --- a/app/gui2/index.html +++ b/app/gui2/index.html @@ -4,7 +4,7 @@ - Vite App + Enso GUI
diff --git a/app/gui2/node.env.d.ts b/app/gui2/node.env.d.ts index 940588d51398..fe84ae93c4dc 100644 --- a/app/gui2/node.env.d.ts +++ b/app/gui2/node.env.d.ts @@ -3,3 +3,9 @@ module 'tailwindcss/nesting' { declare const plugin: PluginCreator 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 +} diff --git a/app/gui2/package.json b/app/gui2/package.json index 97552b9c1e8f..a7f95c18f7b2 100644 --- a/app/gui2/package.json +++ b/app/gui2/package.json @@ -22,28 +22,41 @@ "preinstall": "npm run build-rust-ffi" }, "dependencies": { + "@babel/parser": "^7.22.16", "@open-rpc/client-js": "^1.8.1", "@vueuse/core": "^10.4.1", + "codemirror": "^6.0.1", "enso-authentication": "^1.0.0", + "install": "^0.13.0", + "hash-sum": "^2.0.0", "isomorphic-ws": "^5.0.0", "lib0": "^0.2.83", + "magic-string": "^0.30.3", "pinia": "^2.1.6", "postcss-inline-svg": "^6.0.0", + "postcss-nesting": "^12.0.1", "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", "y-textarea": "^1.0.0", "y-websocket": "^1.5.0", "yjs": "^13.6.7" }, "devDependencies": { + "@danmarshall/deckgl-typings": "^4.9.28", "@eslint/eslintrc": "^2.1.2", "@eslint/js": "^8.49.0", "@playwright/test": "^1.37.0", "@rushstack/eslint-patch": "^1.3.2", "@tsconfig/node18": "^18.2.0", + "@types/d3": "^7.4.0", + "@types/hash-sum": "^1.0.0", "@types/jsdom": "^21.1.1", + "@types/mapbox-gl": "^2.7.13", "@types/node": "^18.17.5", "@types/shuffle-seed": "^1.1.0", "@types/ws": "^8.5.5", @@ -54,6 +67,8 @@ "@vue/eslint-config-typescript": "^12.0.0", "@vue/test-utils": "^2.4.1", "@vue/tsconfig": "^0.4.0", + "ag-grid-community": "^30.1.0", + "ag-grid-enterprise": "^30.1.0", "esbuild": "^0.19.3", "eslint": "^8.49.0", "eslint-plugin-vue": "^9.16.1", @@ -62,6 +77,7 @@ "prettier": "^3.0.0", "prettier-plugin-organize-imports": "^3.2.3", "shuffle-seed": "^1.1.6", + "sql-formatter": "^13.0.0", "tailwindcss": "^3.2.7", "typescript": "~5.2.2", "vite": "^4.4.9", diff --git a/app/gui2/public/visualizations/GeoMapVisualization.vue b/app/gui2/public/visualizations/GeoMapVisualization.vue new file mode 100644 index 000000000000..178af1edf246 --- /dev/null +++ b/app/gui2/public/visualizations/GeoMapVisualization.vue @@ -0,0 +1,429 @@ + + + + + + + + + diff --git a/app/gui2/public/visualizations/ScatterplotVisualization.vue b/app/gui2/public/visualizations/ScatterplotVisualization.vue new file mode 100644 index 000000000000..9f602f6da2e2 --- /dev/null +++ b/app/gui2/public/visualizations/ScatterplotVisualization.vue @@ -0,0 +1,627 @@ + + + + + + + diff --git a/app/gui2/public/visualizations/builtins.ts b/app/gui2/public/visualizations/builtins.ts new file mode 100644 index 000000000000..a3bbf84ffafc --- /dev/null +++ b/app/gui2/public/visualizations/builtins.ts @@ -0,0 +1,31 @@ +export interface Vec2 { + readonly x: number + readonly y: number +} + +export interface RGBA { + red: number + green: number + blue: number + alpha: number +} + +export interface Theme { + getColorForType(type: string): RGBA +} + +export const DEFAULT_THEME: Theme = { + getColorForType(type) { + let hash = 0 + for (const c of type) { + hash = 0 | (hash * 31 + c.charCodeAt(0)) + } + if (hash < 0) { + hash += 0x80000000 + } + const red = (hash >> 24) / 0x180 + const green = ((hash >> 16) & 0xff) / 0x180 + const blue = ((hash >> 8) & 0xff) / 0x180 + return { red, green, blue, alpha: 1 } + }, +} diff --git a/app/gui2/public/visualizations/dependencyTypesPatches.ts b/app/gui2/public/visualizations/dependencyTypesPatches.ts new file mode 100644 index 000000000000..19cf190c976a --- /dev/null +++ b/app/gui2/public/visualizations/dependencyTypesPatches.ts @@ -0,0 +1,36 @@ +// Fixes and extensions for dependencies' type definitions. + +import type * as d3Types from 'd3' + +declare module 'd3' { + // d3 treats `null` and `undefined` as a selection of 0 elements, so they are a valid selection + // for any element type. + function select( + node: GElement | null | undefined, + ): d3Types.Selection + + // These type parameters are present on the original type. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ScaleSequential { + // This field exists in the code but not in the typings. + ticks(): number[] + } +} + +import {} from 'ag-grid-community' + +declare module 'ag-grid-community' { + // These type parameters are present on the original type. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColDef { + /** Custom user-defined value. */ + manuallySized: boolean + } + + // These type parameters are present on the original type. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface AbstractColDef { + // This field exists in the code but not in the typings. + field: string + } +} diff --git a/app/gui2/public/visualizations/events.ts b/app/gui2/public/visualizations/events.ts new file mode 100644 index 000000000000..388ff76da7da --- /dev/null +++ b/app/gui2/public/visualizations/events.ts @@ -0,0 +1,280 @@ +import { + computed, + onMounted, + onUnmounted, + proxyRefs, + ref, + shallowRef, + watch, + watchEffect, + type Ref, + type WatchSource, +} from 'vue' + +/** + * Add an event listener for the duration of the component's lifetime. + * @param target element on which to register the event + * @param event name of event to register + * @param handler event handler + */ +export function useEvent( + target: Document, + event: K, + handler: (e: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: Window, + event: K, + handler: (e: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: Element, + event: K, + handler: (event: ElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: EventTarget, + event: string, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void { + onMounted(() => { + target.addEventListener(event, handler, options) + }) + onUnmounted(() => { + target.removeEventListener(event, handler, options) + }) +} + +/** + * Add an event listener for the duration of condition being true. + * @param target element on which to register the event + * @param condition the condition that determines if event is bound + * @param event name of event to register + * @param handler event handler + */ +export function useEventConditional( + target: Document, + event: K, + condition: WatchSource, + handler: (e: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: Window, + event: K, + condition: WatchSource, + handler: (e: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: Element, + event: K, + condition: WatchSource, + handler: (event: ElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: EventTarget, + event: string, + condition: WatchSource, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: EventTarget, + event: string, + condition: WatchSource, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void { + watch(condition, (conditionMet, _, onCleanup) => { + if (conditionMet) { + target.addEventListener(event, handler, options) + onCleanup(() => target.removeEventListener(event, handler, options)) + } + }) +} + +interface Position { + x: number + y: number +} + +interface Size { + width: number + height: number +} + +/** + * Get DOM node size and keep it up to date. + * + * # Warning: + * Updating DOM node layout based on values derived from their size can introduce unwanted feedback + * loops across the script and layout reflow. Avoid doing that. + * + * @param elementRef DOM node to observe. + * @returns Reactive value with the DOM node size. + */ +export function useResizeObserver( + elementRef: Ref, + useContentRect = true, +): Ref { + const sizeRef = shallowRef({ width: 0, height: 0 }) + const observer = new ResizeObserver((entries) => { + let rect: Size | null = null + for (const entry of entries) { + if (entry.target === elementRef.value) { + if (useContentRect) { + rect = entry.contentRect + } else { + rect = entry.target.getBoundingClientRect() + } + } + } + if (rect != null) { + sizeRef.value = { width: rect.width, height: rect.height } + } + }) + + watchEffect((onCleanup) => { + const element = elementRef.value + if (element != null) { + observer.observe(element) + onCleanup(() => { + if (elementRef.value != null) { + observer.unobserve(element) + } + }) + } + }) + + return sizeRef +} + +export interface EventPosition { + /** The event position at the initialization of the drag. */ + initial: Position + /** Absolute event position, equivalent to clientX/Y. */ + absolute: Position + /** Event position relative to the initial position. Total movement of the drag so far. */ + relative: Position + /** Difference of the event position since last event. */ + delta: Position +} + +type PointerEventType = 'start' | 'move' | 'stop' + +/** + * A mask of all available pointer buttons. The values are compatible with DOM's `PointerEvent.buttons` value. The mask values + * can be ORed together to create a mask of multiple buttons. + */ +export const enum PointerButtonMask { + /** No buttons are pressed. */ + Empty = 0, + /** Main mouse button, usually left. */ + Main = 1, + /** Secondary mouse button, usually right. */ + Secondary = 2, + /** Auxiliary mouse button, usually middle or wheel press. */ + Auxiliary = 4, + /** Additional fourth mouse button, usually assigned to "browser back" action. */ + ExtBack = 8, + /** Additional fifth mouse button, usually assigned to "browser forward" action. */ + ExtForward = 16, +} + +/** + * Register for a pointer dragging events. + * + * @param handler callback on any pointer event + * @param requiredButtonMask declare which buttons to look for. The value represents a `PointerEvent.buttons` mask. + * @returns + */ +export function usePointer( + handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void, + requiredButtonMask: number = PointerButtonMask.Main, +) { + const trackedPointer: Ref = ref(null) + let trackedElement: Element | null = null + let initialGrabPos: Position | null = null + let lastPos: Position | null = null + + const isTracking = () => trackedPointer.value != null + + function doStop(e: PointerEvent) { + if (trackedElement != null && trackedPointer.value != null) { + trackedElement.releasePointerCapture(trackedPointer.value) + } + + trackedPointer.value = null + + if (trackedElement != null && initialGrabPos != null && lastPos != null) { + handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') + lastPos = null + trackedElement = null + } + } + + function doMove(e: PointerEvent) { + if (trackedElement != null && initialGrabPos != null && lastPos != null) { + handler(computePosition(e, initialGrabPos, lastPos), e, 'move') + lastPos = { x: e.clientX, y: e.clientY } + } + } + + useEventConditional(window, 'pointerup', isTracking, (e: PointerEvent) => { + if (trackedPointer.value === e.pointerId) { + e.preventDefault() + doStop(e) + } + }) + + useEventConditional(window, 'pointermove', isTracking, (e: PointerEvent) => { + if (trackedPointer.value === e.pointerId) { + e.preventDefault() + // handle release of all masked buttons as stop + if ((e.buttons & requiredButtonMask) != 0) { + doMove(e) + } else { + doStop(e) + } + } + }) + + const events = { + pointerdown(e: PointerEvent) { + // pointers should not respond to unmasked mouse buttons + if ((e.buttons & requiredButtonMask) == 0) { + return + } + + if (trackedPointer.value == null && e.currentTarget instanceof Element) { + e.preventDefault() + trackedPointer.value = e.pointerId + trackedElement = e.currentTarget + trackedElement.setPointerCapture(e.pointerId) + initialGrabPos = { x: e.clientX, y: e.clientY } + lastPos = initialGrabPos + handler(computePosition(e, initialGrabPos, lastPos), e, 'start') + } + }, + } + + return proxyRefs({ + events, + dragging: computed(() => trackedPointer.value != null), + }) +} + +function computePosition(event: PointerEvent, initial: Position, last: Position): EventPosition { + return { + initial, + absolute: { x: event.clientX, y: event.clientY }, + relative: { x: event.clientX - initial.x, y: event.clientY - initial.y }, + delta: { x: event.clientX - last.x, y: event.clientY - last.y }, + } +} diff --git a/app/gui2/public/visualizations/icons/find.svg b/app/gui2/public/visualizations/icons/find.svg new file mode 100644 index 000000000000..074b7cc476aa --- /dev/null +++ b/app/gui2/public/visualizations/icons/find.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/gui2/public/visualizations/icons/geo_map_distance.svg b/app/gui2/public/visualizations/icons/geo_map_distance.svg new file mode 100644 index 000000000000..0b9206e78a41 --- /dev/null +++ b/app/gui2/public/visualizations/icons/geo_map_distance.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/gui2/public/visualizations/icons/geo_map_pin.svg b/app/gui2/public/visualizations/icons/geo_map_pin.svg new file mode 100644 index 000000000000..644b907d47c6 --- /dev/null +++ b/app/gui2/public/visualizations/icons/geo_map_pin.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/gui2/public/visualizations/icons/path2.svg b/app/gui2/public/visualizations/icons/path2.svg new file mode 100644 index 000000000000..52229d31ced4 --- /dev/null +++ b/app/gui2/public/visualizations/icons/path2.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/gui2/public/visualizations/icons/show_all.svg b/app/gui2/public/visualizations/icons/show_all.svg new file mode 100644 index 000000000000..083a1243d2fc --- /dev/null +++ b/app/gui2/public/visualizations/icons/show_all.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/gui2/public/visualizations/measurement.ts b/app/gui2/public/visualizations/measurement.ts new file mode 100644 index 000000000000..87e4239c03c6 --- /dev/null +++ b/app/gui2/public/visualizations/measurement.ts @@ -0,0 +1,25 @@ +function error(message: string): never { + throw new Error(message) +} + +let _measureContext: CanvasRenderingContext2D | undefined +function getMeasureContext() { + return (_measureContext ??= + document.createElement('canvas').getContext('2d') ?? error('Could not get canvas 2D context.')) +} + +/** Helper function to get text width to make sure that labels on the x axis do not overlap, + * and keeps it readable. */ +export function getTextWidth( + text: string | null | undefined, + fontSize = '11.5px', + fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", +) { + if (text == null) { + return 0 + } + const context = getMeasureContext() + context.font = `${fontSize} ${fontFamily}` + const metrics = context.measureText(' ' + text) + return metrics.width +} diff --git a/app/gui2/public/visualizations/template.vue b/app/gui2/public/visualizations/template.vue new file mode 100644 index 000000000000..63cf56e15a24 --- /dev/null +++ b/app/gui2/public/visualizations/template.vue @@ -0,0 +1,44 @@ + + + + + + + diff --git a/app/gui2/shared/yjsModel.ts b/app/gui2/shared/yjsModel.ts index a45207bf0e0a..fe6c8665a1ab 100644 --- a/app/gui2/shared/yjsModel.ts +++ b/app/gui2/shared/yjsModel.ts @@ -1,5 +1,6 @@ import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' +import * as random from 'lib0/random.js' import * as Y from 'yjs' export type Uuid = `${string}-${string}-${string}-${string}-${string}` @@ -90,11 +91,11 @@ export class DistributedModule { insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId { const range = [offset, offset + content.length] - const newId = crypto.randomUUID() as ExprId + const newId = random.uuidv4() as ExprId this.doc.transact(() => { this.contents.insert(offset, content + '\n') - const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0]) - const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1]) + const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0]!) + const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1]!) this.idMap.set(newId, encodeRange([start, end])) this.metadata.set(newId, meta) }) @@ -208,7 +209,7 @@ export class IdMap { this.accessed.add(val) return val } else { - const newId = crypto.randomUUID() as ExprId + const newId = random.uuidv4() as ExprId this.rangeToExpr.set(key, newId) this.accessed.add(newId) return newId diff --git a/app/gui2/src/assets/base.css b/app/gui2/src/assets/base.css index cd0937644d17..d7e508f94834 100644 --- a/app/gui2/src/assets/base.css +++ b/app/gui2/src/assets/base.css @@ -37,25 +37,26 @@ --color-text: var(--vt-c-text-light-1); --color-text-light: rgba(255, 255, 255, 0.7); - - --color-widget: rgba(255, 255, 255, 0.12); - --color-widget-selected: rgba(255, 255, 255, 0.58); - - --color-port-connected: rgba(255, 255, 255, 0.15); - - --color-frame-bg: rgba(255, 255, 255, 0.3); - - --color-dim: rgba(0, 0, 0, 0.25); + --color-app-bg: rgba(255 255 255 / 0.8); + --color-menu-entry-hover-bg: rgba(0 0 0 / 0.1); + --color-visualization-bg: rgb(255 242 242); + --color-dim: rgba(0 0 0 / 0.25); + --color-frame-bg: rgba(255 255 255 / 0.3); + --color-widget: rgba(255 255 255 / 0.12); + --color-widget-selected: rgba(255 255 255 / 0.58); + --color-port-connected: rgba(255 255 255 / 0.15); } /* non-color variables */ :root { + /* The z-index of fullscreen elements that should display over the entire GUI. */ + --z-fullscreen: 1; + --blur-app-bg: blur(64px); --disabled-opacity: 40%; - /* A `border-radius` higher than all possible element sizes. * A `border-radius` of 100% does not work because the element becomes an ellipse. */ --radius-full: 9999px; - + --radius-default: 16px; --section-gap: 160px; } @@ -68,8 +69,6 @@ body { min-height: 100vh; - /* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */ - background: #e4d4be; color: var(--color-text); /* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */ background: #e4d4be; @@ -94,10 +93,6 @@ body { cursor: pointer; } -:focus { - outline: none; -} - .hidden { display: none; } @@ -105,3 +100,44 @@ body { .button.disabled { cursor: default; } + +/* Scrollbar style definitions for textual visualizations which need support for scrolling. + * + * The 11px width/height (depending on scrollbar orientation) + * is set so that it resembles macOS default scrollbar. + */ + +.scrollable { + scrollbar-color: rgba(190 190 190 / 50%) transparent; +} + +.scrollable::-webkit-scrollbar { + -webkit-appearance: none; +} + +.scrollable::-webkit-scrollbar-track { + -webkit-box-shadow: none; +} + +.scrollable::-webkit-scrollbar:vertical { + width: 11px; +} + +.scrollable::-webkit-scrollbar:horizontal { + height: 11px; +} + +.scrollable::-webkit-scrollbar-thumb { + border-radius: 8px; + border: 1px solid rgba(220, 220, 220, 0.5); + background-color: rgba(190, 190, 190, 0.5); +} + +.scrollable::-webkit-scrollbar-corner { + background: rgba(0, 0, 0, 0); +} + +.scrollable::-webkit-scrollbar-button { + height: 8px; + width: 8px; +} diff --git a/app/gui2/src/assets/icons.svg b/app/gui2/src/assets/icons.svg index 99a1210b2538..cb693da4ea70 100644 --- a/app/gui2/src/assets/icons.svg +++ b/app/gui2/src/assets/icons.svg @@ -1,936 +1,1042 @@ - + - + - - - - - - - - + + + + + + + + - + - - - - - - - - + + + + + + + + - + - - - - - - - - + + + + + + + + - + - - - - - - - - - + + + + + + + + + - + - - + + - + - - - - + + + + - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - + + + + + + + - + - - - - - - - + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - - + + - + - - + + - + - + - - + + - + - + - - + + - + - + - + - + - + - - - - + + + + - + - - - - + + + + - + - - + + - - + + - + - - + + - - + + - + - - + + - + - - - + + + - + - - - + + + - - - + + + - + - + - - - - - - - - - + + + + + + + + + - + - - + + - + - - - - - - - - - + + + + + + + + + - + - - + + - + - + - + - + - + - - - - - - - + + + + + + + - + - - - - + + + + - + - - - - + + + + - - - - + + + + - + - - - + + + - + - - + + - - - - + + + + - + - - + + - - - - + + + + - + - - + + - - - - + + + + - + - - + + - - + + - - - + + + - + - - + + - - - - + + + + - + - - + + - + - + - + - - + + - + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - + + + + + + + + + - + - - + + - + - - - + + + - + - - - + + + - + - - + + - - + + - + - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - + + + + + + + + + - + - - - - - + + + + + - + - - - - + + + + - - + + - + - - - - - + + + + + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - - - - + + + + - + - - - - + + + + @@ -938,42 +1044,84 @@ - + - + - + - + - + - + - + - + - + - - - - + + + + - + + + + + + + + + + + + + + + + + + + + @@ -1008,4 +1156,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/gui2/src/assets/icons/sort_descending.svg b/app/gui2/src/assets/icons/sort_descending.svg deleted file mode 100644 index 167721b0c4f9..000000000000 --- a/app/gui2/src/assets/icons/sort_descending.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/gui2/src/components/CircularMenu.vue b/app/gui2/src/components/CircularMenu.vue new file mode 100644 index 000000000000..d685d381a0e8 --- /dev/null +++ b/app/gui2/src/components/CircularMenu.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/app/gui2/src/components/CodeEditor.vue b/app/gui2/src/components/CodeEditor.vue new file mode 100644 index 000000000000..58912d64845b --- /dev/null +++ b/app/gui2/src/components/CodeEditor.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/app/gui2/src/components/ComponentBrowser/component.ts b/app/gui2/src/components/ComponentBrowser/component.ts index c27cece4449a..9c60042d4ea2 100644 --- a/app/gui2/src/components/ComponentBrowser/component.ts +++ b/app/gui2/src/components/ComponentBrowser/component.ts @@ -14,7 +14,7 @@ export interface Component { icon: string label: string match: MatchResult - group?: number + group?: number | undefined } export function labelOfEntry(entry: SuggestionEntry, filtering: Filtering) { @@ -55,7 +55,7 @@ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Compo } const matched: MatchedSuggestion[] = Array.from(matchSuggestions()) matched.sort(compareSuggestions) - return Array.from(matched, ({ id, entry, match }) => { + return Array.from(matched, ({ id, entry, match }): Component => { return { suggestionId: id, icon: entry.iconName ?? 'marketplace', diff --git a/app/gui2/src/components/ComponentBrowser/filtering.ts b/app/gui2/src/components/ComponentBrowser/filtering.ts index 12c591abb25d..b269313bb5af 100644 --- a/app/gui2/src/components/ComponentBrowser/filtering.ts +++ b/app/gui2/src/components/ComponentBrowser/filtering.ts @@ -183,7 +183,7 @@ class FilteringQualifiedName { */ export class Filtering { pattern?: FilteringWithPattern - selfType?: QualifiedName + selfType?: QualifiedName | undefined qualifiedName?: FilteringQualifiedName showUnstable: boolean = false showLocal: boolean = false diff --git a/app/gui2/src/components/ExecutionModeSelector.vue b/app/gui2/src/components/ExecutionModeSelector.vue index 2f5c0bb659b0..8df0bb05c1c2 100644 --- a/app/gui2/src/components/ExecutionModeSelector.vue +++ b/app/gui2/src/components/ExecutionModeSelector.vue @@ -137,7 +137,8 @@ span { overflow: hidden; > span { - padding: 0; + padding-top: 0; + padding-bottom: 0; max-height: 0; } } diff --git a/app/gui2/src/components/GraphEdge.vue b/app/gui2/src/components/GraphEdge.vue index c3c1cb5ff7cf..05fd0f57ef8f 100644 --- a/app/gui2/src/components/GraphEdge.vue +++ b/app/gui2/src/components/GraphEdge.vue @@ -15,11 +15,11 @@ const props = defineProps<{ const edgePath = computed(() => { let edge = props.edge const targetNodeId = props.exprNodes.get(edge.target) - if (targetNodeId == null) return + if (targetNodeId == null) return '' let sourceNodeRect = props.nodeRects.get(edge.source) let targetNodeRect = props.nodeRects.get(targetNodeId) let targetRect = props.exprRects.get(edge.target) - if (sourceNodeRect == null || targetRect == null || targetNodeRect == null) return + if (sourceNodeRect == null || targetRect == null || targetNodeRect == null) return '' let sourcePos = sourceNodeRect.center() let targetPos = targetRect.center().add(targetNodeRect.pos) diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 2e2fbf2989dc..cd221c34b792 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -1,4 +1,5 @@ diff --git a/app/gui2/src/components/NodeSpan.vue b/app/gui2/src/components/NodeSpan.vue index 8aee5a002bae..04a75f948d82 100644 --- a/app/gui2/src/components/NodeSpan.vue +++ b/app/gui2/src/components/NodeSpan.vue @@ -72,7 +72,7 @@ watch(exprRect, (rect) => { :key="child.id" :content="props.content" :span="child" - :offset="childOffsets[index]" + :offset="childOffsets[index]!" @updateExprRect="(id, rect) => emit('updateExprRect', id, rect)" /> diff --git a/app/gui2/src/components/VisualizationContainer.vue b/app/gui2/src/components/VisualizationContainer.vue new file mode 100644 index 000000000000..3c93ad69c42d --- /dev/null +++ b/app/gui2/src/components/VisualizationContainer.vue @@ -0,0 +1,315 @@ + + + + + + + diff --git a/app/gui2/src/components/VisualizationSelector.vue b/app/gui2/src/components/VisualizationSelector.vue new file mode 100644 index 000000000000..78c43ce341fb --- /dev/null +++ b/app/gui2/src/components/VisualizationSelector.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/app/gui2/src/components/visualizations/HeatmapVisualization.vue b/app/gui2/src/components/visualizations/HeatmapVisualization.vue new file mode 100644 index 000000000000..4c1b7374ad2a --- /dev/null +++ b/app/gui2/src/components/visualizations/HeatmapVisualization.vue @@ -0,0 +1,252 @@ + + + + + + + + + diff --git a/app/gui2/src/components/visualizations/HistogramVisualization.vue b/app/gui2/src/components/visualizations/HistogramVisualization.vue new file mode 100644 index 000000000000..e441cc2641b4 --- /dev/null +++ b/app/gui2/src/components/visualizations/HistogramVisualization.vue @@ -0,0 +1,672 @@ + + + + + + + diff --git a/app/gui2/src/components/visualizations/ImageBase64Visualization.vue b/app/gui2/src/components/visualizations/ImageBase64Visualization.vue new file mode 100644 index 000000000000..e6f1b6202c5c --- /dev/null +++ b/app/gui2/src/components/visualizations/ImageBase64Visualization.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/app/gui2/src/components/visualizations/JSONVisualization.vue b/app/gui2/src/components/visualizations/JSONVisualization.vue new file mode 100644 index 000000000000..e2bd05984df4 --- /dev/null +++ b/app/gui2/src/components/visualizations/JSONVisualization.vue @@ -0,0 +1,37 @@ + + + + + + + diff --git a/app/gui2/src/components/visualizations/SQLVisualization.vue b/app/gui2/src/components/visualizations/SQLVisualization.vue new file mode 100644 index 000000000000..d1f3bff55fe2 --- /dev/null +++ b/app/gui2/src/components/visualizations/SQLVisualization.vue @@ -0,0 +1,203 @@ + + + + + + + + + diff --git a/app/gui2/src/components/visualizations/TableVisualization.vue b/app/gui2/src/components/visualizations/TableVisualization.vue new file mode 100644 index 000000000000..6dd8c78e6955 --- /dev/null +++ b/app/gui2/src/components/visualizations/TableVisualization.vue @@ -0,0 +1,479 @@ + + + + + + + + + diff --git a/app/gui2/src/components/visualizations/WarningsVisualization.vue b/app/gui2/src/components/visualizations/WarningsVisualization.vue new file mode 100644 index 000000000000..c4a29b5ba2a3 --- /dev/null +++ b/app/gui2/src/components/visualizations/WarningsVisualization.vue @@ -0,0 +1,47 @@ + + + + + + + diff --git a/app/gui2/src/components/visualizations/builtins.ts b/app/gui2/src/components/visualizations/builtins.ts new file mode 100644 index 000000000000..a3bbf84ffafc --- /dev/null +++ b/app/gui2/src/components/visualizations/builtins.ts @@ -0,0 +1,31 @@ +export interface Vec2 { + readonly x: number + readonly y: number +} + +export interface RGBA { + red: number + green: number + blue: number + alpha: number +} + +export interface Theme { + getColorForType(type: string): RGBA +} + +export const DEFAULT_THEME: Theme = { + getColorForType(type) { + let hash = 0 + for (const c of type) { + hash = 0 | (hash * 31 + c.charCodeAt(0)) + } + if (hash < 0) { + hash += 0x80000000 + } + const red = (hash >> 24) / 0x180 + const green = ((hash >> 16) & 0xff) / 0x180 + const blue = ((hash >> 8) & 0xff) / 0x180 + return { red, green, blue, alpha: 1 } + }, +} diff --git a/app/gui2/src/components/visualizations/dependencyTypesPatches.ts b/app/gui2/src/components/visualizations/dependencyTypesPatches.ts new file mode 100644 index 000000000000..09c4bfc4ac6e --- /dev/null +++ b/app/gui2/src/components/visualizations/dependencyTypesPatches.ts @@ -0,0 +1,32 @@ +// Fixes and extensions for dependencies' type definitions. + +import type * as d3Types from 'd3' + +declare module 'd3' { + function select( + node: GElement | null | undefined, + ): d3Types.Selection + + // These type parameters are defined on the original interface. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ScaleSequential { + ticks(): number[] + } +} + +import {} from 'ag-grid-community' + +declare module 'ag-grid-community' { + // These type parameters are defined on the original interface. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColDef { + /** Custom user-defined value. */ + manuallySized: boolean + } + + // These type parameters are defined on the original interface. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface AbstractColDef { + field: string + } +} diff --git a/app/gui2/src/components/visualizations/events.ts b/app/gui2/src/components/visualizations/events.ts new file mode 100644 index 000000000000..388ff76da7da --- /dev/null +++ b/app/gui2/src/components/visualizations/events.ts @@ -0,0 +1,280 @@ +import { + computed, + onMounted, + onUnmounted, + proxyRefs, + ref, + shallowRef, + watch, + watchEffect, + type Ref, + type WatchSource, +} from 'vue' + +/** + * Add an event listener for the duration of the component's lifetime. + * @param target element on which to register the event + * @param event name of event to register + * @param handler event handler + */ +export function useEvent( + target: Document, + event: K, + handler: (e: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: Window, + event: K, + handler: (e: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: Element, + event: K, + handler: (event: ElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEvent( + target: EventTarget, + event: string, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void { + onMounted(() => { + target.addEventListener(event, handler, options) + }) + onUnmounted(() => { + target.removeEventListener(event, handler, options) + }) +} + +/** + * Add an event listener for the duration of condition being true. + * @param target element on which to register the event + * @param condition the condition that determines if event is bound + * @param event name of event to register + * @param handler event handler + */ +export function useEventConditional( + target: Document, + event: K, + condition: WatchSource, + handler: (e: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: Window, + event: K, + condition: WatchSource, + handler: (e: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: Element, + event: K, + condition: WatchSource, + handler: (event: ElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: EventTarget, + event: string, + condition: WatchSource, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void +export function useEventConditional( + target: EventTarget, + event: string, + condition: WatchSource, + handler: (event: unknown) => void, + options?: boolean | AddEventListenerOptions, +): void { + watch(condition, (conditionMet, _, onCleanup) => { + if (conditionMet) { + target.addEventListener(event, handler, options) + onCleanup(() => target.removeEventListener(event, handler, options)) + } + }) +} + +interface Position { + x: number + y: number +} + +interface Size { + width: number + height: number +} + +/** + * Get DOM node size and keep it up to date. + * + * # Warning: + * Updating DOM node layout based on values derived from their size can introduce unwanted feedback + * loops across the script and layout reflow. Avoid doing that. + * + * @param elementRef DOM node to observe. + * @returns Reactive value with the DOM node size. + */ +export function useResizeObserver( + elementRef: Ref, + useContentRect = true, +): Ref { + const sizeRef = shallowRef({ width: 0, height: 0 }) + const observer = new ResizeObserver((entries) => { + let rect: Size | null = null + for (const entry of entries) { + if (entry.target === elementRef.value) { + if (useContentRect) { + rect = entry.contentRect + } else { + rect = entry.target.getBoundingClientRect() + } + } + } + if (rect != null) { + sizeRef.value = { width: rect.width, height: rect.height } + } + }) + + watchEffect((onCleanup) => { + const element = elementRef.value + if (element != null) { + observer.observe(element) + onCleanup(() => { + if (elementRef.value != null) { + observer.unobserve(element) + } + }) + } + }) + + return sizeRef +} + +export interface EventPosition { + /** The event position at the initialization of the drag. */ + initial: Position + /** Absolute event position, equivalent to clientX/Y. */ + absolute: Position + /** Event position relative to the initial position. Total movement of the drag so far. */ + relative: Position + /** Difference of the event position since last event. */ + delta: Position +} + +type PointerEventType = 'start' | 'move' | 'stop' + +/** + * A mask of all available pointer buttons. The values are compatible with DOM's `PointerEvent.buttons` value. The mask values + * can be ORed together to create a mask of multiple buttons. + */ +export const enum PointerButtonMask { + /** No buttons are pressed. */ + Empty = 0, + /** Main mouse button, usually left. */ + Main = 1, + /** Secondary mouse button, usually right. */ + Secondary = 2, + /** Auxiliary mouse button, usually middle or wheel press. */ + Auxiliary = 4, + /** Additional fourth mouse button, usually assigned to "browser back" action. */ + ExtBack = 8, + /** Additional fifth mouse button, usually assigned to "browser forward" action. */ + ExtForward = 16, +} + +/** + * Register for a pointer dragging events. + * + * @param handler callback on any pointer event + * @param requiredButtonMask declare which buttons to look for. The value represents a `PointerEvent.buttons` mask. + * @returns + */ +export function usePointer( + handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void, + requiredButtonMask: number = PointerButtonMask.Main, +) { + const trackedPointer: Ref = ref(null) + let trackedElement: Element | null = null + let initialGrabPos: Position | null = null + let lastPos: Position | null = null + + const isTracking = () => trackedPointer.value != null + + function doStop(e: PointerEvent) { + if (trackedElement != null && trackedPointer.value != null) { + trackedElement.releasePointerCapture(trackedPointer.value) + } + + trackedPointer.value = null + + if (trackedElement != null && initialGrabPos != null && lastPos != null) { + handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') + lastPos = null + trackedElement = null + } + } + + function doMove(e: PointerEvent) { + if (trackedElement != null && initialGrabPos != null && lastPos != null) { + handler(computePosition(e, initialGrabPos, lastPos), e, 'move') + lastPos = { x: e.clientX, y: e.clientY } + } + } + + useEventConditional(window, 'pointerup', isTracking, (e: PointerEvent) => { + if (trackedPointer.value === e.pointerId) { + e.preventDefault() + doStop(e) + } + }) + + useEventConditional(window, 'pointermove', isTracking, (e: PointerEvent) => { + if (trackedPointer.value === e.pointerId) { + e.preventDefault() + // handle release of all masked buttons as stop + if ((e.buttons & requiredButtonMask) != 0) { + doMove(e) + } else { + doStop(e) + } + } + }) + + const events = { + pointerdown(e: PointerEvent) { + // pointers should not respond to unmasked mouse buttons + if ((e.buttons & requiredButtonMask) == 0) { + return + } + + if (trackedPointer.value == null && e.currentTarget instanceof Element) { + e.preventDefault() + trackedPointer.value = e.pointerId + trackedElement = e.currentTarget + trackedElement.setPointerCapture(e.pointerId) + initialGrabPos = { x: e.clientX, y: e.clientY } + lastPos = initialGrabPos + handler(computePosition(e, initialGrabPos, lastPos), e, 'start') + } + }, + } + + return proxyRefs({ + events, + dragging: computed(() => trackedPointer.value != null), + }) +} + +function computePosition(event: PointerEvent, initial: Position, last: Position): EventPosition { + return { + initial, + absolute: { x: event.clientX, y: event.clientY }, + relative: { x: event.clientX - initial.x, y: event.clientY - initial.y }, + delta: { x: event.clientX - last.x, y: event.clientY - last.y }, + } +} diff --git a/app/gui2/src/components/visualizations/measurement.ts b/app/gui2/src/components/visualizations/measurement.ts new file mode 100644 index 000000000000..87e4239c03c6 --- /dev/null +++ b/app/gui2/src/components/visualizations/measurement.ts @@ -0,0 +1,25 @@ +function error(message: string): never { + throw new Error(message) +} + +let _measureContext: CanvasRenderingContext2D | undefined +function getMeasureContext() { + return (_measureContext ??= + document.createElement('canvas').getContext('2d') ?? error('Could not get canvas 2D context.')) +} + +/** Helper function to get text width to make sure that labels on the x axis do not overlap, + * and keeps it readable. */ +export function getTextWidth( + text: string | null | undefined, + fontSize = '11.5px', + fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", +) { + if (text == null) { + return 0 + } + const context = getMeasureContext() + context.font = `${fontSize} ${fontFamily}` + const metrics = context.measureText(' ' + text) + return metrics.width +} diff --git a/app/gui2/src/main.ts b/app/gui2/src/main.ts index f00e8136c893..ee805d010f07 100644 --- a/app/gui2/src/main.ts +++ b/app/gui2/src/main.ts @@ -4,10 +4,12 @@ const INITIAL_URL_KEY = `Enso-initial-url` import './assets/main.css' +import { basicSetup } from 'codemirror' 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) @@ -32,6 +34,7 @@ async function runApp(config: StringConfig | null, accessToken: string | null, m const rootProps = { config, accessToken, metadata } app = createApp(AppRoot, rootProps) app.use(createPinia()) + app.use(VueCodemirror, { extensions: [basicSetup] }) app.mount('#app') } diff --git a/app/gui2/src/providers/useVisualizationConfig.ts b/app/gui2/src/providers/useVisualizationConfig.ts new file mode 100644 index 000000000000..1314a14fe362 --- /dev/null +++ b/app/gui2/src/providers/useVisualizationConfig.ts @@ -0,0 +1 @@ +export { useVisualizationConfig } from '@/providers/visualizationConfig.ts' diff --git a/app/gui2/src/providers/visualizationConfig.ts b/app/gui2/src/providers/visualizationConfig.ts new file mode 100644 index 000000000000..4a6f107b143f --- /dev/null +++ b/app/gui2/src/providers/visualizationConfig.ts @@ -0,0 +1,27 @@ +import type { Vec2 } from '@/util/vec2' +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 isCircularMenuVisible: boolean + readonly nodeSize: Vec2 + width: number | null + height: number | null + fullscreen: boolean + hide: () => void + updateType: (type: string) => void +} + +const provideKey = Symbol('visualizationConfig') as InjectionKey> + +export function useVisualizationConfig(): Ref { + const injected = inject(provideKey) + if (injected == null) throw new Error('AppConfig not provided') + return injected +} + +export function provideVisualizationConfig(visualizationConfig: Ref) { + provide(provideKey, visualizationConfig) +} diff --git a/app/gui2/src/stores/counter.ts b/app/gui2/src/stores/counter.ts deleted file mode 100644 index 69a97a25af41..000000000000 --- a/app/gui2/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineStore } from 'pinia' -import { computed, ref } from 'vue' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/app/gui2/src/stores/graph.ts b/app/gui2/src/stores/graph.ts index ca7b08a5a798..52e047da2f8e 100644 --- a/app/gui2/src/stores/graph.ts +++ b/app/gui2/src/stores/graph.ts @@ -104,11 +104,11 @@ export const useGraphStore = defineStore('graph', () => { const exprRange: ContentRange = [stmt.exprOffset, stmt.exprOffset + stmt.expression.length] if (affectedRanges != null) { - while (affectedRanges[0]?.[1] < exprRange[0]) { + while (affectedRanges[0]?.[1]! < exprRange[0]) { affectedRanges.shift() } if (affectedRanges.length === 0) break - const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]) + const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]!) if (!nodeAffected) continue } @@ -298,6 +298,7 @@ export const useGraphStore = defineStore('graph', () => { return { _parsed, _parsedEnso: _parsedEnso, + proj, nodes, exprNodes, edges, @@ -374,7 +375,7 @@ function walkSpansBfs( if (visitChildren?.(span, spanOffset) !== false) { let offset = spanOffset for (let i = 0; i < span.children.length; i++) { - const child = span.children[i] + const child = span.children[i]! stack.push([child, offset]) offset += child.length } diff --git a/app/gui2/src/stores/project.ts b/app/gui2/src/stores/project.ts index 5066272316d6..ce62f3af6b5e 100644 --- a/app/gui2/src/stores/project.ts +++ b/app/gui2/src/stores/project.ts @@ -105,6 +105,7 @@ export const useProjectStore = defineStore('project', () => { }, module, undoManager, + awareness, lsRpcConnection: markRaw(lsRpcConnection), } }) diff --git a/app/gui2/src/stores/suggestionDatabase/entry.ts b/app/gui2/src/stores/suggestionDatabase/entry.ts index 5bd7ed74d36a..94880eea21ba 100644 --- a/app/gui2/src/stores/suggestionDatabase/entry.ts +++ b/app/gui2/src/stores/suggestionDatabase/entry.ts @@ -92,7 +92,7 @@ export interface SuggestionEntry { /// A name of a custom icon to use when displaying the entry. iconName?: string /// A name of a group this entry belongs to. - groupIndex?: number + groupIndex?: number | undefined } function makeSimpleEntry( diff --git a/app/gui2/src/stores/visualization.ts b/app/gui2/src/stores/visualization.ts new file mode 100644 index 000000000000..f1cc85cd467e --- /dev/null +++ b/app/gui2/src/stores/visualization.ts @@ -0,0 +1,356 @@ +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 * as useVisualizationConfig from '@/providers/useVisualizationConfig' +import type { + AddImportNotification, + AddRawImportNotification, + AddStyleNotification, + AddURLImportNotification, + CompilationErrorResponse, + CompilationResultResponse, + CompileError, + CompileRequest, + FetchError, + InvalidMimetypeError, + RegisterBuiltinModulesRequest, +} from '@/workers/visualizationCompiler' +import Compiler from '@/workers/visualizationCompiler?worker' + +const moduleCache: Record = { + vue, + '@vueuse/core': vueUseCore, + 'builtins/VisualizationContainer.vue': { default: VisualizationContainer }, + 'builtins/useVisualizationConfig.ts': useVisualizationConfig, +} +// @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: {} }, + {}, + {}, + {}, + {}, + {}, + {}, + { + 'update:preprocessor': (module: string, method: string, ...args: string[]) => void + } +> +type VisualizationModule = { + default: Visualization + name: string + inputType: string + scripts?: string[] + styles?: string[] +} + +const builtinVisualizationImports: Record Promise> = { + JSON: () => import('@/components/visualizations/JSONVisualization.vue') as any, + Table: () => import('@/components/visualizations/TableVisualization.vue') as any, + Histogram: () => import('@/components/visualizations/HistogramVisualization.vue') as any, + Heatmap: () => import('@/components/visualizations/HeatmapVisualization.vue') as any, + 'SQL Query': () => import('@/components/visualizations/SQLVisualization.vue') as any, + Image: () => import('@/components/visualizations/ImageBase64Visualization.vue') as any, + Warnings: () => import('@/components/visualizations/WarningsVisualization.vue') as any, +} + +const dynamicVisualizationPaths: Record = { + Test: '/visualizations/TestVisualization.vue', + Scatterplot: '/visualizations/ScatterplotVisualization.vue', + 'Geo Map': '/visualizations/GeoMapVisualization.vue', +} + +export const useVisualizationStore = defineStore('visualization', () => { + // TODO [sb]: Figure out how to list visualizations defined by a project. + const imports = { ...builtinVisualizationImports } + const paths = { ...dynamicVisualizationPaths } + let cache: Record = {} + const types = [...Object.keys(imports), ...Object.keys(paths)] + let worker: Worker | undefined + let workerMessageId = 0 + const workerCallbacks: Record< + string, + { resolve: (result: VisualizationModule) => void; reject: () => void } + > = {} + + function register(module: VisualizationModule) { + console.log(`registering visualization: name=${module.name}, inputType=${module.inputType}`) + } + + function postMessage(worker: Worker, message: T) { + worker.postMessage(message) + } + + async function compile(path: string) { + if (worker == null) { + worker = new Compiler() + postMessage(worker, { + type: 'register-builtin-modules-request', + modules: Object.keys(moduleCache), + }) + worker.addEventListener( + 'message', + async ( + event: MessageEvent< + // === Responses === + | CompilationResultResponse + | CompilationErrorResponse + // === Notifications === + | AddStyleNotification + | AddRawImportNotification + | AddURLImportNotification + | AddImportNotification + // === Errors === + | FetchError + | InvalidMimetypeError + | CompileError + >, + ) => { + switch (event.data.type) { + // === Responses === + case 'compilation-result-response': { + workerCallbacks[event.data.id]?.resolve(moduleCache[event.data.path]) + break + } + case 'compilation-error-response': { + console.error(`Error compiling visualization '${event.data.path}':`, event.data.error) + workerCallbacks[event.data.id]?.reject() + break + } + // === Notifications === + case 'add-style-notification': { + const styleNode = document.createElement('style') + styleNode.innerHTML = event.data.code + document.head.appendChild(styleNode) + break + } + case 'add-raw-import-notification': { + moduleCache[event.data.path] = event.data.value + break + } + case 'add-url-import-notification': { + moduleCache[event.data.path] = { + default: URL.createObjectURL( + new Blob([event.data.value], { type: event.data.mimeType }), + ), + } + break + } + case 'add-import-notification': { + const module = import( + /* @vite-ignore */ + URL.createObjectURL(new Blob([event.data.code], { type: 'text/javascript' })) + ) + moduleCache[event.data.path] = module + moduleCache[event.data.path] = await module + break + } + // === Errors === + case 'fetch-error': { + console.error(`Error fetching '${event.data.path}':`, event.data.error) + break + } + case 'invalid-mimetype-error': { + console.error( + `Expected mimetype of '${event.data.path}' to be '${event.data.expected}', ` + + `but received '${event.data.actual}' instead`, + ) + break + } + case 'compile-error': { + console.error(`Error compiling '${event.data.path}':`, event.data.error) + break + } + } + }, + ) + worker.addEventListener('error', (event) => { + console.error(event.error) + }) + } + const id = workerMessageId + workerMessageId += 1 + const promise = new Promise((resolve, reject) => { + workerCallbacks[id] = { resolve, reject } + }) + postMessage(worker, { type: 'compile-request', id, path }) + return await promise + } + + const scriptsNode = document.head.appendChild(document.createElement('div')) + scriptsNode.classList.add('visualization-scripts') + const loadedScripts = new Set() + function loadScripts(module: VisualizationModule) { + const promises: Promise[] = [] + if ('scripts' in module && module.scripts) { + if (!Array.isArray(module.scripts)) { + console.warn('Visualiation scripts should be an array:', module.scripts) + } + const scripts = Array.isArray(module.scripts) ? module.scripts : [module.scripts] + for (const url of scripts) { + if (typeof url !== 'string') { + console.warn('Visualization script should be a string, skipping URL:', url) + } else if (!loadedScripts.has(url)) { + loadedScripts.add(url) + const node = document.createElement('script') + node.src = url + promises.push( + new Promise((resolve, reject) => { + node.addEventListener('load', () => { + resolve() + }) + node.addEventListener('error', () => { + reject() + }) + }), + ) + scriptsNode.appendChild(node) + } + } + } + return Promise.allSettled(promises) + } + + // NOTE: Because visualization scripts are cached, they are not guaranteed to be up to date. + async function get(type: string) { + let module = cache[type] + if (module == null) { + module = await imports[type]?.() + } + if (module == null) { + const path = paths[type] + if (path != null) { + module = await compile(path) + } + } + if (module == null) { + return + } + register(module) + await loadScripts(module) + cache[type] = module + return module.default + } + + 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 } +}) diff --git a/app/gui2/src/util/animation.ts b/app/gui2/src/util/animation.ts index 7775a7c0d37a..35491d02b06e 100644 --- a/app/gui2/src/util/animation.ts +++ b/app/gui2/src/util/animation.ts @@ -1,5 +1,5 @@ +import { watchSourceToRef } from '@/util/reactivity' import { onUnmounted, proxyRefs, ref, watch, type WatchSource } from 'vue' -import { watchSourceToRef } from './reactivity' const rafCallbacks: { fn: (t: number, dt: number) => void; priority: number }[] = [] diff --git a/app/gui2/src/util/crdt.ts b/app/gui2/src/util/crdt.ts index c9e280f2f8ac..57a794650cbd 100644 --- a/app/gui2/src/util/crdt.ts +++ b/app/gui2/src/util/crdt.ts @@ -1,8 +1,8 @@ +import type { Opt } from '@/util/opt' import { watchEffect, type Ref } from 'vue' import type { Awareness } from 'y-protocols/awareness' import { WebsocketProvider } from 'y-websocket' import * as Y from 'yjs' -import type { Opt } from './opt' export function useObserveYjs( typeRef: Ref>>, diff --git a/app/gui2/src/util/events.ts b/app/gui2/src/util/events.ts index 6291ba422f0d..82237de3a5a8 100644 --- a/app/gui2/src/util/events.ts +++ b/app/gui2/src/util/events.ts @@ -1,3 +1,4 @@ +import { Vec2 } from '@/util/vec2' import { computed, onMounted, @@ -10,7 +11,6 @@ import { type Ref, type WatchSource, } from 'vue' -import { Vec2 } from './vec2' /** * Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime. diff --git a/app/gui2/src/util/ffi.ts b/app/gui2/src/util/ffi.ts index 17cb6c2b68ff..cf01ddd94711 100644 --- a/app/gui2/src/util/ffi.ts +++ b/app/gui2/src/util/ffi.ts @@ -1,6 +1,6 @@ +import type { NonEmptyArray } from '@/util/array' +import type { Opt } from '@/util/opt' import init, { parse_to_json } from '../../rust-ffi/pkg/rust_ffi' -import type { NonEmptyArray } from './array' -import type { Opt } from './opt' const _wasm = await init() diff --git a/app/gui2/src/workers/visualizationCompiler.ts b/app/gui2/src/workers/visualizationCompiler.ts new file mode 100644 index 000000000000..887cedbdcfe0 --- /dev/null +++ b/app/gui2/src/workers/visualizationCompiler.ts @@ -0,0 +1,425 @@ +/** + * This Web Worker compiles visualizations in a background thread. + * + * # High-Level Overview + * Imports are recursively compiled. + * - Unknown imports, are preserved as-is. + * - Compiled imports are added to a cache on the main thread. + * - Compiled imports are re-written into an object destructure, so that the cache can be used. + * - Uses `compiler-sfc` to compile Vue files into TypeScript + CSS, then `sucrase` to compile + * the resulting TypeScript into JavaScript. + * - Uses `sucrase` to compile TypeScript files into JavaScript. + * - Converts SVG files into imports in which the `default` export is the raw text of the SVG image. + * + * # Typical Request Lifetime + * See the "Protocol" section below for details on specific messages. + * - A `CompileRequest` is sent with id `1` and path `/Viz.vue`. + * - (begin `importVue`) The Worker `fetch`es the path. + * - The CSS styles are compiled using `vue/compiler-sfc`, then sent as `AddStyleNotification`s. + * - The Vue script is compiled using `vue/compiler-sfc` into TypeScript. + * - The TypeScript is compiled using `sucrase` into JavaScript. + * - (`rewriteImports`) Imports are analyzed and rewritten as required: + * - (`importSvg`) SVG imports are fetched and sent using an `AddRawImportNotification`. + * - (`importVue`) Vue imports are recursively compiled as described in this process. + * - (`importTS`) TypeScript imports are recursively compiled as described in this process, + * excluding the style and script compilation steps. + * - (end `importVue`) An `AddUrlNotification` with path `/Viz.vue` is sent to the main + * thread. + * - A `CompilationResultResponse` with id `1` and path `/Viz.vue` is sent to the main thread. */ + +import { parse as babelParse } from '@babel/parser' +import hash from 'hash-sum' +import MagicString from 'magic-string' +import { transform } from 'sucrase' +import { compileScript, compileStyle, parse } from 'vue/compiler-sfc' + +// ======================================== +// === Requests (Main Thread to Worker) === +// ======================================== + +/** A request to compile a visualization module. The Worker MUST reply with a + * {@link CompilationResultResponse} when compilation is done, or a {@link CompilationErrorResponse} + * when compilation fails. The `id` is an arbitrary number that uniquely identifies the request. + * The `path` is either an absolute URL (`http://doma.in/path/to/TheScript.vue`), or a root-relative + * URL (`/visualizations/TheScript.vue`). Relative URLs (`./TheScript.vue`) are NOT valid. + * + * Note that compiling files other than Vue files (TypeScript, SVG etc.) are currently NOT + * supported. */ +export interface CompileRequest { + type: 'compile-request' + id: number + path: string +} + +/** A request to mark modules as built-in, indicating that the compiler should re-write the imports + * into object destructures. */ +export interface RegisterBuiltinModulesRequest { + type: 'register-builtin-modules-request' + modules: string[] +} + +// ========================================= +// === Responses (Worker to Main Thread) === +// ========================================= + +// These are messages sent in response to a query. They contain the `id` of the original query. + +/** Sent in response to a {@link CompileRequest}, with an `id` matching the `id` of the original + * request. Contains only the `path` of the resulting file (which should have also been sent in the + * {@link CompileRequest}). + * The content itself will have been sent earlier as an {@link AddImportNotification}. */ +export interface CompilationResultResponse { + type: 'compilation-result-response' + id: number + path: string +} + +/** Sent in response to a {@link CompileRequest}, with an `id` matching the `id` of the original + * request. Contains the `path` of the resulting file (which should have also been sent in the + * {@link CompileRequest}), and the `error` thrown during compilation. */ +export interface CompilationErrorResponse { + type: 'compilation-error-response' + id: number + path: string + error: Error +} + +// ============================================= +// === Notifications (Worker to Main Thread) === +// ============================================= + +// These are sent when a subtask successfully completes execution. + +/** Sent after compiling `