Skip to content

Commit f2cc2d8

Browse files
authored
[feat] add context menu (sveltejs#110)
Not the prettiest, but it works. Allows renaming/editing/adding/removing files. No drag&drop yet.
1 parent 309c287 commit f2cc2d8

File tree

5 files changed

+395
-43
lines changed

5 files changed

+395
-43
lines changed

src/lib/types/index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Writable } from 'svelte/store';
2+
13
export interface FileStub {
24
type: 'file';
35
name: string;
@@ -76,3 +78,11 @@ export interface PartStub {
7678
title: string;
7779
chapters: ChapterStub[];
7880
}
81+
82+
export interface FileTreeContext {
83+
select: (file: FileStub) => void;
84+
add: (stubs: Stub[]) => Promise<void>;
85+
edit: (stub: Stub, name: string) => Promise<void>;
86+
remove: (stub: Stub) => Promise<void>;
87+
selected: Writable<FileStub | null>;
88+
}

src/routes/tutorial/[slug]/+page.svelte

Lines changed: 124 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { Icon } from '@sveltejs/site-kit';
1313
import Loading from './Loading.svelte';
1414
import { PUBLIC_USE_FILESYSTEM } from '$env/static/public';
15+
import ContextMenu from './ContextMenu.svelte';
1516
1617
/** @type {import('./$types').PageData} */
1718
export let data;
@@ -29,6 +30,8 @@
2930
3031
/** @type {import('monaco-editor').editor.ITextModel} */
3132
let current_model;
33+
/** @type {import('$lib/types').Stub[]}*/
34+
let current_stubs = [];
3235
3336
/** @type {HTMLIFrameElement} */
3437
let iframe;
@@ -46,13 +49,81 @@
4649
4750
$: b = { ...data.section.a, ...data.section.b };
4851
52+
/** @type {import('$lib/types').FileTreeContext} */
4953
const { select } = setContext('filetree', {
50-
/** @param {import('$lib/types').FileStub} file */
5154
select: (file) => {
5255
$selected = file;
5356
current_model = /** @type {import('monaco-editor').editor.ITextModel} */ (models.get(file));
5457
},
5558
59+
add: async (stubs) => {
60+
current_stubs = [...current_stubs, ...stubs];
61+
62+
const { monaco } = await import('$lib/client/monaco/monaco.js');
63+
for (const stub of stubs) {
64+
create_monaco_file(stub, monaco);
65+
}
66+
67+
await load_files(current_stubs);
68+
69+
if (stubs[0].type === 'file') {
70+
select(stubs[0]);
71+
}
72+
},
73+
74+
edit: async (to_rename, new_name) => {
75+
/** @type {Array<[import('$lib/types').Stub, import('$lib/types').Stub]>}*/
76+
const changed = [];
77+
current_stubs = current_stubs.map((s) => {
78+
if (!s.name.startsWith(to_rename.name)) {
79+
return s;
80+
}
81+
82+
const name =
83+
s.name.slice(0, to_rename.name.length - to_rename.basename.length) +
84+
new_name +
85+
s.name.slice(to_rename.name.length);
86+
const basename = s === to_rename ? new_name : s.basename;
87+
const new_stub = { ...s, name, basename };
88+
89+
changed.push([s, new_stub]);
90+
return new_stub;
91+
});
92+
93+
const { monaco } = await import('$lib/client/monaco/monaco.js');
94+
for (const [old_s, new_s] of changed) {
95+
if (old_s.type === 'file') {
96+
models.get(old_s)?.dispose();
97+
models.delete(old_s);
98+
create_monaco_file(new_s, monaco);
99+
}
100+
}
101+
102+
await load_files(current_stubs);
103+
104+
if (to_rename.type === 'file') {
105+
select(/** @type {any} */ (changed.find(([old_s]) => old_s === to_rename))[1]);
106+
}
107+
},
108+
109+
remove: async (stub) => {
110+
const out = current_stubs.filter((s) => s.name.startsWith(stub.name));
111+
current_stubs = current_stubs.filter((s) => !out.includes(s));
112+
113+
for (const s of out) {
114+
if (s.type === 'file') {
115+
models.get(s)?.dispose();
116+
models.delete(s);
117+
}
118+
}
119+
120+
if ($selected && out.includes($selected)) {
121+
$selected = null;
122+
}
123+
124+
await load_files(current_stubs);
125+
},
126+
56127
selected
57128
});
58129
@@ -84,38 +155,17 @@
84155
models.clear();
85156
86157
complete_states = {};
87-
88-
const stubs = Object.values(data.section.a);
158+
current_stubs = Object.values(data.section.a);
89159
90160
const { monaco } = await import('$lib/client/monaco/monaco.js');
91161
92-
stubs.forEach((stub) => {
93-
if (stub.type === 'file') {
94-
const type = /** @type {string} */ (stub.basename.split('.').pop());
95-
96-
const model = monaco.editor.createModel(
97-
stub.contents,
98-
types[type] || type,
99-
new monaco.Uri().with({ path: stub.name })
100-
);
101-
102-
model.updateOptions({ tabSize: 2 });
103-
104-
model.onDidChangeContent(() => {
105-
const contents = model.getValue();
106-
107-
if (!completing) {
108-
adapter?.update([{ ...stub, contents }]);
109-
}
110-
});
111-
112-
models.set(stub, model);
113-
}
162+
current_stubs.forEach((stub) => {
163+
create_monaco_file(stub, monaco);
114164
});
115165
116166
select(
117167
/** @type {import('$lib/types').FileStub} */ (
118-
stubs.find((stub) => stub.name === data.section.focus)
168+
current_stubs.find((stub) => stub.name === data.section.focus)
119169
)
120170
);
121171
@@ -124,6 +174,35 @@
124174
load_exercise();
125175
});
126176
177+
/**
178+
* @param {import('$lib/types').Stub} stub
179+
* @param {import('monaco-editor')} monaco
180+
*/
181+
function create_monaco_file(stub, monaco) {
182+
if (stub.type === 'file') {
183+
const type = /** @type {string} */ (stub.basename.split('.').pop());
184+
185+
const model = monaco.editor.createModel(
186+
stub.contents,
187+
types[type] || type,
188+
new monaco.Uri().with({ path: stub.name })
189+
);
190+
191+
model.updateOptions({ tabSize: 2 });
192+
193+
model.onDidChangeContent(() => {
194+
const contents = model.getValue();
195+
196+
if (!completing) {
197+
stub.contents = contents;
198+
adapter?.update([stub]);
199+
}
200+
});
201+
202+
models.set(stub, model);
203+
}
204+
}
205+
127206
/**
128207
* Loads the adapter initially or resets it. This method can throw.
129208
* @param {import('$lib/types').Stub[]} stubs
@@ -178,18 +257,11 @@
178257
loading = true;
179258
180259
// Load expected output first so we can compare it to the actual output to determine when it's completed
181-
let adapter = await reset_adapter(Object.values(b));
260+
await reset_adapter(Object.values(b));
182261
expected = await get_transformed_modules(data.section.scope.prefix, Object.values(b));
183262
184263
const stubs = Object.values(data.section.a);
185-
adapter = await reset_adapter(stubs);
186-
const actual = await get_transformed_modules(data.section.scope.prefix, stubs);
187-
188-
for (const [name, transformed] of expected.entries()) {
189-
complete_states[name] = transformed === actual.get(name);
190-
}
191-
192-
set_iframe_src(adapter.base);
264+
await load_files(stubs);
193265
194266
loading = false;
195267
initial = false;
@@ -200,6 +272,20 @@
200272
}
201273
}
202274
275+
/**
276+
* @param {import('$lib/types').Stub[]} stubs
277+
*/
278+
async function load_files(stubs) {
279+
adapter = await reset_adapter(stubs);
280+
const actual = await get_transformed_modules(data.section.scope.prefix, stubs);
281+
282+
for (const [name, transformed] of expected.entries()) {
283+
complete_states[name] = transformed === actual.get(name);
284+
}
285+
286+
set_iframe_src(adapter.base);
287+
}
288+
203289
/** @type {NodeJS.Timeout} */
204290
let timeout;
205291
@@ -324,6 +410,8 @@
324410
<title>{data.section.chapter.title} / {data.section.title} • Svelte Tutorial</title>
325411
</svelte:head>
326412
413+
<ContextMenu />
414+
327415
<div class="container">
328416
<SplitPane type="horizontal" min="360px" max="50%" pos="33%">
329417
<section class="content" slot="a">
@@ -344,7 +432,7 @@
344432
<div class="filetree">
345433
<Folder
346434
{...data.section.scope}
347-
files={Object.values(data.section.a).filter((stub) => !hidden.has(stub.basename))}
435+
files={current_stubs.filter((stub) => !hidden.has(stub.basename))}
348436
expanded
349437
/>
350438
</div>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!-- @component
2+
A context menu for the tutorial's file tree
3+
-->
4+
<script context="module">
5+
import { writable } from 'svelte/store';
6+
7+
/**
8+
* @typedef {Array<{ name: string, action: () => void }>} MenuItems
9+
*/
10+
11+
/**
12+
* @type {import("svelte/store").Writable<{x: number; y: number; items: MenuItems} | null>}
13+
*/
14+
let menu_items = writable(null);
15+
16+
/**
17+
* @param {number} x
18+
* @param {number} y
19+
* @param {MenuItems} items
20+
*/
21+
export function open(x, y, items) {
22+
menu_items.set({ x, y, items });
23+
}
24+
</script>
25+
26+
{#if $menu_items}
27+
<nav style="position: fixed; z-index: 1; top:{$menu_items.y}px; left:{$menu_items.x}px">
28+
<div class="navbar" id="navbar">
29+
<ul>
30+
{#each $menu_items.items as item}
31+
<li>
32+
<button on:click={item.action}>{item.name}</button>
33+
</li>
34+
{/each}
35+
</ul>
36+
</div>
37+
</nav>
38+
{/if}
39+
40+
<svelte:window on:click={() => menu_items.set(null)} />
41+
42+
<style>
43+
.navbar {
44+
display: inline-flex;
45+
border: 1px var(--text) solid;
46+
width: 170px;
47+
background-color: var(--back);
48+
border-radius: 5px;
49+
overflow: hidden;
50+
flex-direction: column;
51+
}
52+
ul {
53+
margin: 1rem 0;
54+
}
55+
li {
56+
display: block;
57+
list-style-type: none;
58+
width: 1fr;
59+
}
60+
button {
61+
color: var(--text);
62+
width: 100%;
63+
height: 25px;
64+
text-align: left;
65+
border: 0px;
66+
padding: 0.5rem 1rem;
67+
}
68+
button:hover {
69+
text-align: left;
70+
background-color: var(--back-api);
71+
}
72+
</style>

0 commit comments

Comments
 (0)