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

Lines changed: 96 additions & 5 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 61 additions & 55 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy