Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 96 additions & 5 deletions packages/editor/src/lib/Workspace.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';
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';
Expand Down Expand Up @@ -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<DecorationSet>({
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();

Expand All @@ -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 {
Expand All @@ -86,6 +113,11 @@ export class Workspace {
#files = $state.raw<Item[]>([]);
#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;

Expand Down Expand Up @@ -225,6 +257,20 @@ export class Workspace {
});
}

highlight_range(node: { start: number; end: number } | null, scroll = false) {
if (!this.#view) return;

const effects: StateEffect<any>[] = [set_highlight.of(node)];

if (scroll && node) {
effects.push(EditorView.scrollIntoView(node.start, { y: 'center' }));
}

this.#view.dispatch({
effects
});
}

mark_saved() {
this.modified = {};
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
}
})
];

Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/lib/codemirror.css
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,9 @@
}
}
}

.highlight {
background: var(--sk-bg-highlight);
padding: 4px 0;
}
}
1 change: 1 addition & 0 deletions packages/repl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 61 additions & 55 deletions packages/repl/src/lib/Output/AstNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
</script>

<li
bind:this={list_item_el}
class:marked={!root && is_leaf}
onmouseover={handle_mark_text}
onfocus={handle_mark_text}
onmouseleave={handle_unmark_text}
>
<li bind:this={li} data-marked={is_marked} data-leaf={is_leaf}>
{#if is_primitive || (is_array && value.length === 0)}
<span class="value">
{#if key_text}
Expand All @@ -97,7 +78,22 @@
{/if}
</span>
{:else}
<details bind:open>
<!-- svelte-ignore a11y_mouse_events_have_key_events (seems like a false positive) -->
<details
bind:open
onfocusin={(e) => (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);
}
}}
>
<summary>
{#if key}
<span class="key">{key}</span>:
Expand All @@ -116,13 +112,22 @@
{/if}
</summary>

<ul>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<ul
onclick={(e) => {
if (value && typeof value.start === 'number') {
workspace.highlight_range(value, true);
e.stopPropagation();
}
}}
>
{#each Object.entries(value) as [k, v]}
<AstNode
key={is_array ? undefined : k}
value={v}
{path_nodes}
{autoscroll}
{active}
{onhover}
depth={depth + 1}
/>
{/each}
Expand All @@ -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 {
Expand Down
Loading
Loading