Skip to content

Commit ca13b39

Browse files
authored
feat: highlight hovered nodes (#902)
* WIP * fix * bidirectional marking, expand on click * WIP sourcemap stuff * lint * highlight JS output on editor hover * bidirectional mapping * make it work for CSS too * lint * fix dark mode * only scroll to leaves * highlight on select, scroll into view * better implementation * legacy mode fix * unused * better bidirectional highlighting on AST * typecheck * ignore toggle events when AST output tab is hidden
1 parent 59ff625 commit ca13b39

File tree

8 files changed

+304
-80
lines changed

8 files changed

+304
-80
lines changed

packages/editor/src/lib/Workspace.svelte.ts

+96-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { CompileError, CompileResult } from 'svelte/compiler';
2-
import { Compartment, EditorState } from '@codemirror/state';
2+
import { Compartment, EditorState, StateEffect, StateField } from '@codemirror/state';
33
import { compile_file } from './compile-worker';
44
import { BROWSER } from 'esm-env';
55
import { basicSetup, EditorView } from 'codemirror';
66
import { javascript } from '@codemirror/lang-javascript';
77
import { html } from '@codemirror/lang-html';
88
import { svelte } from '@replit/codemirror-lang-svelte';
99
import { autocomplete_for_svelte } from '@sveltejs/site-kit/codemirror';
10-
import { keymap } from '@codemirror/view';
10+
import { Decoration, keymap, type DecorationSet } from '@codemirror/view';
1111
import { acceptCompletion } from '@codemirror/autocomplete';
1212
import { indentWithTab } from '@codemirror/commands';
1313
import { indentUnit } from '@codemirror/language';
@@ -51,6 +51,32 @@ function file_type(file: Item) {
5151
return file.name.split('.').pop();
5252
}
5353

54+
const set_highlight = StateEffect.define<{ start: number; end: number } | null>();
55+
56+
const highlight_field = StateField.define<DecorationSet>({
57+
create() {
58+
return Decoration.none;
59+
},
60+
update(highlights, tr) {
61+
// Apply the effect
62+
for (let effect of tr.effects) {
63+
if (effect.is(set_highlight)) {
64+
if (effect.value) {
65+
const { start, end } = effect.value;
66+
const deco = Decoration.mark({ class: 'highlight' }).range(start, end);
67+
return Decoration.set([deco]);
68+
} else {
69+
// Clear highlight
70+
return Decoration.none;
71+
}
72+
}
73+
}
74+
// Map decorations for document changes
75+
return highlights.map(tr.changes);
76+
},
77+
provide: (field) => EditorView.decorations.from(field)
78+
});
79+
5480
const tab_behaviour = new Compartment();
5581
const vim_mode = new Compartment();
5682

@@ -60,7 +86,8 @@ const default_extensions = [
6086
tab_behaviour.of(keymap.of([{ key: 'Tab', run: acceptCompletion }])),
6187
indentUnit.of('\t'),
6288
theme,
63-
vim_mode.of([])
89+
vim_mode.of([]),
90+
highlight_field
6491
];
6592

6693
export interface ExposedCompilerOptions {
@@ -86,6 +113,11 @@ export class Workspace {
86113
#files = $state.raw<Item[]>([]);
87114
#current = $state.raw() as File;
88115

116+
#handlers = {
117+
hover: new Set<(pos: number | null) => void>(),
118+
select: new Set<(from: number, to: number) => void>()
119+
};
120+
89121
#onupdate: (file: File) => void;
90122
#onreset: (items: Item[]) => void;
91123

@@ -225,6 +257,20 @@ export class Workspace {
225257
});
226258
}
227259

260+
highlight_range(node: { start: number; end: number } | null, scroll = false) {
261+
if (!this.#view) return;
262+
263+
const effects: StateEffect<any>[] = [set_highlight.of(node)];
264+
265+
if (scroll && node) {
266+
effects.push(EditorView.scrollIntoView(node.start, { y: 'center' }));
267+
}
268+
269+
this.#view.dispatch({
270+
effects
271+
});
272+
}
273+
228274
mark_saved() {
229275
this.modified = {};
230276
}
@@ -261,6 +307,26 @@ export class Workspace {
261307
this.#files = this.#files.slice(0, to_index).concat(from).concat(this.#files.slice(to_index));
262308
}
263309

310+
onhover(fn: (pos: number | null) => void) {
311+
$effect(() => {
312+
this.#handlers.hover.add(fn);
313+
314+
return () => {
315+
this.#handlers.hover.delete(fn);
316+
};
317+
});
318+
}
319+
320+
onselect(fn: (from: number, to: number) => void) {
321+
$effect(() => {
322+
this.#handlers.select.add(fn);
323+
324+
return () => {
325+
this.#handlers.select.delete(fn);
326+
};
327+
});
328+
}
329+
264330
remove(item: Item) {
265331
const index = this.#files.indexOf(item);
266332

@@ -439,9 +505,9 @@ export class Workspace {
439505
EditorState.readOnly.of(this.#readonly),
440506
EditorView.editable.of(!this.#readonly),
441507
EditorView.updateListener.of((update) => {
442-
if (update.docChanged) {
443-
const state = this.#view!.state!;
508+
const state = this.#view!.state!;
444509

510+
if (update.docChanged) {
445511
this.#update_file({
446512
...this.#current,
447513
contents: state.doc.toString()
@@ -450,6 +516,31 @@ export class Workspace {
450516
// preserve undo/redo across files
451517
this.states.set(this.#current.name, state);
452518
}
519+
520+
if (update.selectionSet) {
521+
if (state.selection.ranges.length === 1) {
522+
for (const handler of this.#handlers.select) {
523+
const { from, to } = state.selection.ranges[0];
524+
handler(from, to);
525+
}
526+
}
527+
}
528+
}),
529+
EditorView.domEventObservers({
530+
mousemove: (event, view) => {
531+
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
532+
533+
if (pos !== null) {
534+
for (const handler of this.#handlers.hover) {
535+
handler(pos);
536+
}
537+
}
538+
},
539+
mouseleave: (event, view) => {
540+
for (const handler of this.#handlers.hover) {
541+
handler(null);
542+
}
543+
}
453544
})
454545
];
455546

packages/editor/src/lib/codemirror.css

+5
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,9 @@
304304
}
305305
}
306306
}
307+
308+
.highlight {
309+
background: var(--sk-bg-highlight);
310+
padding: 4px 0;
311+
}
307312
}

packages/repl/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"editor": "workspace:*",
8484
"esm-env": "^1.0.0",
8585
"esrap": "^1.2.2",
86+
"locate-character": "^3.0.0",
8687
"marked": "^14.1.2",
8788
"resolve.exports": "^2.0.2",
8889
"svelte": "5.14.0",

packages/repl/src/lib/Output/AstNode.svelte

+61-55
Original file line numberDiff line numberDiff line change
@@ -10,78 +10,59 @@
1010
key?: string;
1111
value: Ast;
1212
path_nodes?: Ast[];
13-
autoscroll?: boolean;
13+
active?: boolean;
1414
depth?: number;
15+
onhover: (node: { type: string; start: number; end: number } | null) => void;
1516
}
1617
17-
let { key = '', value, path_nodes = [], autoscroll = true, depth = 0 }: Props = $props();
18+
let { key = '', value, path_nodes = [], active = true, onhover, depth = 0 }: Props = $props();
1819
19-
const { toggleable } = get_repl_context();
20+
const { workspace } = get_repl_context();
2021
2122
let root = depth === 0;
2223
let open = $state(root);
2324
24-
let list_item_el = $state() as HTMLLIElement;
25+
let li: HTMLLIElement;
2526
2627
let is_leaf = $derived(path_nodes[path_nodes.length - 1] === value);
28+
let is_marked = $derived(!root && path_nodes.includes(value));
29+
2730
let is_array = $derived(Array.isArray(value));
2831
let is_primitive = $derived(value === null || typeof value !== 'object');
29-
let is_markable = $derived(
30-
!is_primitive &&
31-
'start' in value &&
32-
'end' in value &&
33-
typeof value.start === 'number' &&
34-
typeof value.end === 'number'
35-
);
3632
let key_text = $derived(key ? `${key}:` : '');
3733
3834
$effect(() => {
39-
open = path_nodes.includes(value);
40-
});
35+
if (active && typeof value === 'object' && value !== null) {
36+
workspace.onselect((from, to) => {
37+
// legacy fragments have `children`
38+
const nodes =
39+
value.type === 'Fragment' ? value.nodes ?? value.children : is_array ? value : [value];
4140
42-
$effect(() => {
43-
if (autoscroll && is_leaf && !$toggleable) {
44-
// wait for all nodes to render before scroll
45-
tick().then(() => {
46-
if (list_item_el) {
47-
list_item_el.scrollIntoView();
41+
const start = nodes[0]?.start;
42+
const end = nodes[nodes.length - 1]?.end;
43+
44+
if (typeof start !== 'number' || typeof end !== 'number') {
45+
return;
46+
}
47+
48+
// if node contains the current selection, open
49+
if (start <= from && end >= to) {
50+
open = true;
51+
52+
if (is_leaf) {
53+
tick().then(() => {
54+
li.scrollIntoView({
55+
block: 'center'
56+
});
57+
});
58+
}
4859
}
4960
});
5061
}
5162
});
52-
53-
function handle_mark_text(e: MouseEvent | FocusEvent) {
54-
if (is_markable) {
55-
e.stopPropagation();
56-
57-
if (
58-
'start' in value &&
59-
'end' in value &&
60-
typeof value.start === 'number' &&
61-
typeof value.end === 'number'
62-
) {
63-
// TODO
64-
// $module_editor?.markText({ from: value.start ?? 0, to: value.end ?? 0 });
65-
}
66-
}
67-
}
68-
69-
function handle_unmark_text(e: MouseEvent) {
70-
if (is_markable) {
71-
e.stopPropagation();
72-
// TODO
73-
// $module_editor?.unmarkText();
74-
}
75-
}
7663
</script>
7764

78-
<li
79-
bind:this={list_item_el}
80-
class:marked={!root && is_leaf}
81-
onmouseover={handle_mark_text}
82-
onfocus={handle_mark_text}
83-
onmouseleave={handle_unmark_text}
84-
>
65+
<li bind:this={li} data-marked={is_marked} data-leaf={is_leaf}>
8566
{#if is_primitive || (is_array && value.length === 0)}
8667
<span class="value">
8768
{#if key_text}
@@ -97,7 +78,22 @@
9778
{/if}
9879
</span>
9980
{:else}
100-
<details bind:open>
81+
<!-- svelte-ignore a11y_mouse_events_have_key_events (seems like a false positive) -->
82+
<details
83+
bind:open
84+
onfocusin={(e) => (e.stopPropagation(), onhover(value))}
85+
onfocusout={() => onhover(null)}
86+
onmouseover={(e) => (e.stopPropagation(), onhover(value))}
87+
onmouseleave={() => onhover(null)}
88+
ontoggle={(e) => {
89+
// toggle events can fire even when the AST output tab is hidden
90+
if (!active) return;
91+
92+
if (e.currentTarget.open && value && typeof value.start === 'number') {
93+
workspace.highlight_range(value, true);
94+
}
95+
}}
96+
>
10197
<summary>
10298
{#if key}
10399
<span class="key">{key}</span>:
@@ -116,13 +112,22 @@
116112
{/if}
117113
</summary>
118114

119-
<ul>
115+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
116+
<ul
117+
onclick={(e) => {
118+
if (value && typeof value.start === 'number') {
119+
workspace.highlight_range(value, true);
120+
e.stopPropagation();
121+
}
122+
}}
123+
>
120124
{#each Object.entries(value) as [k, v]}
121125
<AstNode
122126
key={is_array ? undefined : k}
123127
value={v}
124128
{path_nodes}
125-
{autoscroll}
129+
{active}
130+
{onhover}
126131
depth={depth + 1}
127132
/>
128133
{/each}
@@ -144,8 +149,9 @@
144149
list-style-type: none;
145150
}
146151
147-
.marked {
148-
background-color: var(--sk-highlight-color);
152+
[data-marked='true']:not(:has(> [open])),
153+
[data-leaf='true'] {
154+
background-color: var(--sk-bg-highlight);
149155
}
150156
151157
summary {

0 commit comments

Comments
 (0)