diff --git a/packages/editor/src/lib/Workspace.svelte.ts b/packages/editor/src/lib/Workspace.svelte.ts index d73f4f65ea..36d0902e46 100644 --- a/packages/editor/src/lib/Workspace.svelte.ts +++ b/packages/editor/src/lib/Workspace.svelte.ts @@ -1,5 +1,5 @@ import type { CompileError, CompileResult } from 'svelte/compiler'; -import { Compartment, EditorState } from '@codemirror/state'; +import { Compartment, EditorState, StateEffect, StateField } from '@codemirror/state'; import { compile_file } from './compile-worker'; import { BROWSER } from 'esm-env'; import { basicSetup, EditorView } from 'codemirror'; @@ -7,7 +7,7 @@ import { javascript } from '@codemirror/lang-javascript'; import { html } from '@codemirror/lang-html'; import { svelte } from '@replit/codemirror-lang-svelte'; import { autocomplete_for_svelte } from '@sveltejs/site-kit/codemirror'; -import { keymap } from '@codemirror/view'; +import { Decoration, keymap, type DecorationSet } from '@codemirror/view'; import { acceptCompletion } from '@codemirror/autocomplete'; import { indentWithTab } from '@codemirror/commands'; import { indentUnit } from '@codemirror/language'; @@ -51,6 +51,32 @@ function file_type(file: Item) { return file.name.split('.').pop(); } +const set_highlight = StateEffect.define<{ start: number; end: number } | null>(); + +const highlight_field = StateField.define({ + create() { + return Decoration.none; + }, + update(highlights, tr) { + // Apply the effect + for (let effect of tr.effects) { + if (effect.is(set_highlight)) { + if (effect.value) { + const { start, end } = effect.value; + const deco = Decoration.mark({ class: 'highlight' }).range(start, end); + return Decoration.set([deco]); + } else { + // Clear highlight + return Decoration.none; + } + } + } + // Map decorations for document changes + return highlights.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field) +}); + const tab_behaviour = new Compartment(); const vim_mode = new Compartment(); @@ -60,7 +86,8 @@ const default_extensions = [ tab_behaviour.of(keymap.of([{ key: 'Tab', run: acceptCompletion }])), indentUnit.of('\t'), theme, - vim_mode.of([]) + vim_mode.of([]), + highlight_field ]; export interface ExposedCompilerOptions { @@ -86,6 +113,11 @@ export class Workspace { #files = $state.raw([]); #current = $state.raw() as File; + #handlers = { + hover: new Set<(pos: number | null) => void>(), + select: new Set<(from: number, to: number) => void>() + }; + #onupdate: (file: File) => void; #onreset: (items: Item[]) => void; @@ -225,6 +257,20 @@ export class Workspace { }); } + highlight_range(node: { start: number; end: number } | null, scroll = false) { + if (!this.#view) return; + + const effects: StateEffect[] = [set_highlight.of(node)]; + + if (scroll && node) { + effects.push(EditorView.scrollIntoView(node.start, { y: 'center' })); + } + + this.#view.dispatch({ + effects + }); + } + mark_saved() { this.modified = {}; } @@ -261,6 +307,26 @@ export class Workspace { this.#files = this.#files.slice(0, to_index).concat(from).concat(this.#files.slice(to_index)); } + onhover(fn: (pos: number | null) => void) { + $effect(() => { + this.#handlers.hover.add(fn); + + return () => { + this.#handlers.hover.delete(fn); + }; + }); + } + + onselect(fn: (from: number, to: number) => void) { + $effect(() => { + this.#handlers.select.add(fn); + + return () => { + this.#handlers.select.delete(fn); + }; + }); + } + remove(item: Item) { const index = this.#files.indexOf(item); @@ -439,9 +505,9 @@ export class Workspace { EditorState.readOnly.of(this.#readonly), EditorView.editable.of(!this.#readonly), EditorView.updateListener.of((update) => { - if (update.docChanged) { - const state = this.#view!.state!; + const state = this.#view!.state!; + if (update.docChanged) { this.#update_file({ ...this.#current, contents: state.doc.toString() @@ -450,6 +516,31 @@ export class Workspace { // preserve undo/redo across files this.states.set(this.#current.name, state); } + + if (update.selectionSet) { + if (state.selection.ranges.length === 1) { + for (const handler of this.#handlers.select) { + const { from, to } = state.selection.ranges[0]; + handler(from, to); + } + } + } + }), + EditorView.domEventObservers({ + mousemove: (event, view) => { + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + + if (pos !== null) { + for (const handler of this.#handlers.hover) { + handler(pos); + } + } + }, + mouseleave: (event, view) => { + for (const handler of this.#handlers.hover) { + handler(null); + } + } }) ]; diff --git a/packages/editor/src/lib/codemirror.css b/packages/editor/src/lib/codemirror.css index 32d00e83e0..847197aaa9 100644 --- a/packages/editor/src/lib/codemirror.css +++ b/packages/editor/src/lib/codemirror.css @@ -304,4 +304,9 @@ } } } + + .highlight { + background: var(--sk-bg-highlight); + padding: 4px 0; + } } diff --git a/packages/repl/package.json b/packages/repl/package.json index 99d8b8185d..f7a2297bd6 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -83,6 +83,7 @@ "editor": "workspace:*", "esm-env": "^1.0.0", "esrap": "^1.2.2", + "locate-character": "^3.0.0", "marked": "^14.1.2", "resolve.exports": "^2.0.2", "svelte": "5.14.0", diff --git a/packages/repl/src/lib/Output/AstNode.svelte b/packages/repl/src/lib/Output/AstNode.svelte index 381f19625e..ae1b53ec07 100644 --- a/packages/repl/src/lib/Output/AstNode.svelte +++ b/packages/repl/src/lib/Output/AstNode.svelte @@ -10,78 +10,59 @@ key?: string; value: Ast; path_nodes?: Ast[]; - autoscroll?: boolean; + active?: boolean; depth?: number; + onhover: (node: { type: string; start: number; end: number } | null) => void; } - let { key = '', value, path_nodes = [], autoscroll = true, depth = 0 }: Props = $props(); + let { key = '', value, path_nodes = [], active = true, onhover, depth = 0 }: Props = $props(); - const { toggleable } = get_repl_context(); + const { workspace } = get_repl_context(); let root = depth === 0; let open = $state(root); - let list_item_el = $state() as HTMLLIElement; + let li: HTMLLIElement; let is_leaf = $derived(path_nodes[path_nodes.length - 1] === value); + let is_marked = $derived(!root && path_nodes.includes(value)); + let is_array = $derived(Array.isArray(value)); let is_primitive = $derived(value === null || typeof value !== 'object'); - let is_markable = $derived( - !is_primitive && - 'start' in value && - 'end' in value && - typeof value.start === 'number' && - typeof value.end === 'number' - ); let key_text = $derived(key ? `${key}:` : ''); $effect(() => { - open = path_nodes.includes(value); - }); + if (active && typeof value === 'object' && value !== null) { + workspace.onselect((from, to) => { + // legacy fragments have `children` + const nodes = + value.type === 'Fragment' ? value.nodes ?? value.children : is_array ? value : [value]; - $effect(() => { - if (autoscroll && is_leaf && !$toggleable) { - // wait for all nodes to render before scroll - tick().then(() => { - if (list_item_el) { - list_item_el.scrollIntoView(); + const start = nodes[0]?.start; + const end = nodes[nodes.length - 1]?.end; + + if (typeof start !== 'number' || typeof end !== 'number') { + return; + } + + // if node contains the current selection, open + if (start <= from && end >= to) { + open = true; + + if (is_leaf) { + tick().then(() => { + li.scrollIntoView({ + block: 'center' + }); + }); + } } }); } }); - - function handle_mark_text(e: MouseEvent | FocusEvent) { - if (is_markable) { - e.stopPropagation(); - - if ( - 'start' in value && - 'end' in value && - typeof value.start === 'number' && - typeof value.end === 'number' - ) { - // TODO - // $module_editor?.markText({ from: value.start ?? 0, to: value.end ?? 0 }); - } - } - } - - function handle_unmark_text(e: MouseEvent) { - if (is_markable) { - e.stopPropagation(); - // TODO - // $module_editor?.unmarkText(); - } - } -
  • +
  • {#if is_primitive || (is_array && value.length === 0)} {#if key_text} @@ -97,7 +78,22 @@ {/if} {:else} -
    + +
    (e.stopPropagation(), onhover(value))} + onfocusout={() => onhover(null)} + onmouseover={(e) => (e.stopPropagation(), onhover(value))} + onmouseleave={() => onhover(null)} + ontoggle={(e) => { + // toggle events can fire even when the AST output tab is hidden + if (!active) return; + + if (e.currentTarget.open && value && typeof value.start === 'number') { + workspace.highlight_range(value, true); + } + }} + > {#if key} {key}: @@ -116,13 +112,22 @@ {/if} -
      + +
        { + if (value && typeof value.start === 'number') { + workspace.highlight_range(value, true); + e.stopPropagation(); + } + }} + > {#each Object.entries(value) as [k, v]} {/each} @@ -144,8 +149,9 @@ list-style-type: none; } - .marked { - background-color: var(--sk-highlight-color); + [data-marked='true']:not(:has(> [open])), + [data-leaf='true'] { + background-color: var(--sk-bg-highlight); } summary { diff --git a/packages/repl/src/lib/Output/AstView.svelte b/packages/repl/src/lib/Output/AstView.svelte index 70540f2cf2..b14d998c6f 100644 --- a/packages/repl/src/lib/Output/AstView.svelte +++ b/packages/repl/src/lib/Output/AstView.svelte @@ -10,20 +10,17 @@ interface Props { workspace: Workspace; ast: Ast; - autoscroll?: boolean; + active?: boolean; } - let { workspace, ast, autoscroll = true }: Props = $props(); + let { workspace, ast, active = true }: Props = $props(); - // $cursor_index may go over the max since ast computation is usually slower. - // clamping this helps prevent the collapse view flashing - // TODO reimplement - let max_cursor_index = 0; - // $: max_cursor_index = !ast ? $cursorIndex : Math.min($cursorIndex, get_ast_max_end(ast)); + let cursor = $state(0); - let path_nodes = $derived(find_deepest_path(max_cursor_index, [ast]) || []); + let path_nodes = $derived(find_deepest_path(cursor, [ast]) || []); - function find_deepest_path(cursor: number, paths: Ast[]): Ast[] | undefined { + function find_deepest_path(cursor: number | null, paths: Ast[]): Ast[] | undefined { + if (cursor === null) return; const value = paths[paths.length - 1]; if (!value) return; @@ -47,17 +44,24 @@ } } - function get_ast_max_end(ast: Ast) { - let max_end = 0; + $effect(() => { + if (active) { + workspace.onhover((pos) => { + cursor = pos; + }); + } + }); - for (const node of Object.values(ast) as any[]) { - if (node && typeof node.end === 'number' && node.end > max_end) { - max_end = node.end; - } + $effect(() => { + if (active) { + const leaf = path_nodes.at(-1) ?? null; + workspace.highlight_range(leaf); } - return max_end; - } + return () => { + workspace.highlight_range(null); + }; + });
        @@ -65,7 +69,20 @@ {#if typeof ast === 'object'}
          - + { + if ( + node === null || + (node.type !== undefined && node.start !== undefined && node.end !== undefined) + ) { + cursor = node && node.start + 1; + workspace.highlight_range(node); + } + }} + />
        {:else}

        No AST available

        diff --git a/packages/repl/src/lib/Output/Output.svelte b/packages/repl/src/lib/Output/Output.svelte index ad2cd0e00d..9d29e4c73a 100644 --- a/packages/repl/src/lib/Output/Output.svelte +++ b/packages/repl/src/lib/Output/Output.svelte @@ -1,11 +1,13 @@
        @@ -132,7 +231,7 @@ {#if current?.result}
        - +
        {/if} diff --git a/packages/site-kit/src/lib/styles/tokens/colours.css b/packages/site-kit/src/lib/styles/tokens/colours.css index bfabccf0f3..c4b0d3ebe3 100644 --- a/packages/site-kit/src/lib/styles/tokens/colours.css +++ b/packages/site-kit/src/lib/styles/tokens/colours.css @@ -22,6 +22,7 @@ --sk-bg-4: hsl(0, 0%, 95%); /* hover states and highlights */ --sk-bg-accent: var(--sk-fg-accent); --sk-bg-selection: hsla(204, 100%, 63%, 0.3); + --sk-bg-highlight: hsl(60, 100%, 80%); /* Border color — use this for all borders, except 'active' borders (e.g. current nav) which use `--sk-fg-accent` */ --sk-border: hsl(0, 0%, 92%); @@ -62,6 +63,7 @@ --sk-bg-3: hsl(var(--sk-bg-hue), 14%, 16%); --sk-bg-4: hsl(var(--sk-bg-hue), 15%, 21%); --sk-bg-accent: hsl(15, 100%, 35%); + --sk-bg-highlight: hsl(60, 100%, 15%); /* Border colour */ --sk-border: hsl(var(--sk-bg-hue), 15%, 22%); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc127e1957..4e2bcc7489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,9 @@ importers: esrap: specifier: ^1.2.2 version: 1.2.3 + locate-character: + specifier: ^3.0.0 + version: 3.0.0 marked: specifier: ^14.1.2 version: 14.1.2