Skip to content

Commit 35d22bf

Browse files
committed
editor page refactoring
1 parent db44f2b commit 35d22bf

File tree

15 files changed

+914
-1038
lines changed

15 files changed

+914
-1038
lines changed

frontend/rollup.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export default {
144144
'@codemirror/language',
145145
'@codemirror/autocomplete',
146146
'@codemirror/lang-python',
147+
'@codemirror/lang-javascript',
148+
'@codemirror/lang-go',
147149
'@codemirror/theme-one-dark',
148150
'@uiw/codemirror-theme-github'
149151
]
@@ -182,7 +184,7 @@ export default {
182184
// Prefer ES modules
183185
mainFields: ['svelte', 'module', 'browser', 'main'],
184186
exportConditions: ['svelte'],
185-
extensions: ['.mjs', '.js', '.json', '.node', '.svelte']
187+
extensions: ['.mjs', '.js', '.ts', '.json', '.node', '.svelte']
186188
}),
187189
commonjs(),
188190
!production && {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<script lang="ts">
2+
import { onMount, onDestroy } from 'svelte';
3+
import { Compartment, EditorState } from '@codemirror/state';
4+
import { EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers } from '@codemirror/view';
5+
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
6+
import { bracketMatching } from '@codemirror/language';
7+
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
8+
import { oneDark } from '@codemirror/theme-one-dark';
9+
import { githubLight } from '@uiw/codemirror-theme-github';
10+
import { theme as appTheme } from '../../stores/theme';
11+
import { getLanguageExtension } from '../../lib/editor/languages';
12+
import type { EditorSettings } from '../../lib/api';
13+
14+
interface Props {
15+
content: string;
16+
lang: string;
17+
settings: EditorSettings;
18+
onchange?: (content: string) => void;
19+
}
20+
21+
let { content = $bindable(''), lang, settings, onchange }: Props = $props();
22+
23+
let container: HTMLElement;
24+
let view: EditorView | null = null;
25+
26+
const themeCompartment = new Compartment();
27+
const fontSizeCompartment = new Compartment();
28+
const tabSizeCompartment = new Compartment();
29+
const lineNumbersCompartment = new Compartment();
30+
const lineWrappingCompartment = new Compartment();
31+
const languageCompartment = new Compartment();
32+
33+
function getThemeExtension() {
34+
// Only use dark theme if explicitly set OR auto + dark mode active
35+
const useDark = settings.theme === 'one-dark' ||
36+
(settings.theme !== 'github' && document.documentElement.classList.contains('dark'));
37+
return useDark ? oneDark : githubLight;
38+
}
39+
40+
function getStaticExtensions() {
41+
return [
42+
lineNumbersCompartment.of(settings.show_line_numbers ? lineNumbers() : []),
43+
highlightActiveLineGutter(),
44+
highlightActiveLine(),
45+
history(),
46+
bracketMatching(),
47+
autocompletion(),
48+
EditorState.allowMultipleSelections.of(true),
49+
tabSizeCompartment.of(EditorState.tabSize.of(settings.tab_size ?? 4)),
50+
keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap, indentWithTab]),
51+
languageCompartment.of(getLanguageExtension(lang)),
52+
lineWrappingCompartment.of(settings.word_wrap ? EditorView.lineWrapping : []),
53+
fontSizeCompartment.of(EditorView.theme({ ".cm-content": { fontSize: `${settings.font_size ?? 14}px` } })),
54+
EditorView.theme({
55+
"&": { height: "100%", maxHeight: "100%" },
56+
".cm-content": { minHeight: "100%" },
57+
".cm-scroller": { overflow: "auto", maxHeight: "100%" }
58+
}),
59+
EditorView.updateListener.of(update => {
60+
if (update.docChanged) {
61+
const newContent = update.state.doc.toString();
62+
content = newContent;
63+
onchange?.(newContent);
64+
}
65+
}),
66+
];
67+
}
68+
69+
function applySettings() {
70+
if (!view) return;
71+
view.dispatch({ effects: themeCompartment.reconfigure(getThemeExtension()) });
72+
view.dispatch({ effects: fontSizeCompartment.reconfigure(EditorView.theme({ ".cm-content": { fontSize: `${settings.font_size ?? 14}px` } })) });
73+
view.dispatch({ effects: tabSizeCompartment.reconfigure(EditorState.tabSize.of(settings.tab_size ?? 4)) });
74+
view.dispatch({ effects: lineNumbersCompartment.reconfigure(settings.show_line_numbers ? lineNumbers() : []) });
75+
view.dispatch({ effects: lineWrappingCompartment.reconfigure(settings.word_wrap ? EditorView.lineWrapping : []) });
76+
}
77+
78+
let unsubscribeTheme: (() => void) | null = null;
79+
80+
onMount(() => {
81+
if (!container) return;
82+
83+
const startState = EditorState.create({
84+
doc: content,
85+
extensions: [...getStaticExtensions(), themeCompartment.of(getThemeExtension())]
86+
});
87+
88+
view = new EditorView({ state: startState, parent: container });
89+
90+
unsubscribeTheme = appTheme.subscribe(() => {
91+
if (view && (settings.theme === 'auto' || !settings.theme)) {
92+
view.dispatch({ effects: themeCompartment.reconfigure(getThemeExtension()) });
93+
}
94+
});
95+
});
96+
97+
onDestroy(() => {
98+
view?.destroy();
99+
view = null;
100+
unsubscribeTheme?.();
101+
});
102+
103+
$effect(() => {
104+
if (view) {
105+
view.dispatch({ effects: languageCompartment.reconfigure(getLanguageExtension(lang)) });
106+
}
107+
});
108+
109+
$effect(() => {
110+
void settings;
111+
applySettings();
112+
});
113+
114+
export function setContent(newContent: string) {
115+
if (!view) return;
116+
view.dispatch({
117+
changes: { from: 0, to: view.state.doc.length, insert: newContent },
118+
selection: { anchor: 0 }
119+
});
120+
}
121+
122+
export function getView(): EditorView | null {
123+
return view;
124+
}
125+
</script>
126+
127+
<div bind:this={container} class="editor-wrapper h-full w-full"></div>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script lang="ts">
2+
import { Lightbulb } from '@lucide/svelte';
3+
4+
interface Props {
5+
name: string;
6+
onexample: () => void;
7+
onchange: (name: string) => void;
8+
}
9+
10+
let { name, onexample, onchange }: Props = $props();
11+
</script>
12+
13+
<div class="editor-toolbar flex items-center justify-between px-3 py-1 bg-bg-default dark:bg-dark-bg-default border-b border-border-default dark:border-dark-border-default shrink-0">
14+
<div>
15+
<label for="scriptNameInput" class="sr-only">Script Name</label>
16+
<input id="scriptNameInput" type="text" class="form-input-bare"
17+
placeholder="Unnamed Script" value={name} oninput={(e) => onchange(e.currentTarget.value)} />
18+
</div>
19+
<div class="flex items-center space-x-2">
20+
<button class="btn btn-secondary-outline btn-sm inline-flex items-center space-x-1.5"
21+
onclick={onexample} title="Load an example script for the selected language">
22+
<Lightbulb class="w-4 h-4" />
23+
<span class="hidden sm:inline">Example</span>
24+
</button>
25+
</div>
26+
</div>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts">
2+
import { fly } from 'svelte/transition';
3+
import { ChevronDown, ChevronRight } from '@lucide/svelte';
4+
import type { LanguageInfo } from '../../lib/api';
5+
6+
interface Props {
7+
runtimes: Record<string, LanguageInfo>;
8+
lang: string;
9+
version: string;
10+
onselect: (lang: string, version: string) => void;
11+
}
12+
13+
let { runtimes, lang, version, onselect }: Props = $props();
14+
15+
let showOptions = $state(false);
16+
let hoveredLang = $state<string | null>(null);
17+
18+
const available = $derived(Object.keys(runtimes).length > 0);
19+
</script>
20+
21+
<div class="relative">
22+
<button onclick={() => showOptions = !showOptions}
23+
disabled={!available}
24+
class="btn btn-secondary-outline btn-sm w-36 flex items-center justify-between text-left"
25+
class:opacity-50={!available}
26+
class:cursor-not-allowed={!available}>
27+
<span class="capitalize truncate">{available ? `${lang} ${version}` : "Unavailable"}</span>
28+
<span class="ml-2 shrink-0 text-fg-muted dark:text-dark-fg-muted transform transition-transform" class:-rotate-180={showOptions}>
29+
<ChevronDown class="w-5 h-5" />
30+
</span>
31+
</button>
32+
33+
{#if showOptions && available}
34+
<div transition:fly={{ y: -5, duration: 150 }}
35+
class="absolute bottom-full mb-2 w-36 bg-bg-alt dark:bg-dark-bg-alt rounded-lg shadow-xl ring-1 ring-black/5 dark:ring-white/10 z-30">
36+
<ul class="py-1" onmouseleave={() => hoveredLang = null}>
37+
{#each Object.entries(runtimes) as [l, info] (l)}
38+
<li class="relative" onmouseenter={() => hoveredLang = l}>
39+
<div class="flex justify-between items-center w-full px-3 py-2 text-sm text-fg-default dark:text-dark-fg-default">
40+
<span class="capitalize font-medium">{l}</span>
41+
<ChevronRight class="w-4 h-4 text-fg-muted dark:text-dark-fg-muted" />
42+
</div>
43+
44+
{#if hoveredLang === l && info.versions.length > 0}
45+
<div class="absolute left-full top-0 -mt-1 ml-1 w-20 bg-bg-alt dark:bg-dark-bg-alt rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 z-40"
46+
transition:fly={{ x: 5, duration: 100 }}>
47+
<ul class="py-1 max-h-60 overflow-y-auto custom-scrollbar">
48+
{#each info.versions as v (v)}
49+
<li>
50+
<button onclick={() => { onselect(l, v); showOptions = false; hoveredLang = null; }}
51+
class="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700/60 transition-colors duration-100"
52+
class:text-primary={l === lang && v === version}
53+
class:dark:text-primary-light={l === lang && v === version}
54+
class:font-semibold={l === lang && v === version}
55+
class:text-fg-default={l !== lang || v !== version}
56+
class:dark:text-dark-fg-default={l !== lang || v !== version}>
57+
{v}
58+
</button>
59+
</li>
60+
{/each}
61+
</ul>
62+
</div>
63+
{/if}
64+
</li>
65+
{/each}
66+
</ul>
67+
</div>
68+
{/if}
69+
</div>

0 commit comments

Comments
 (0)