Skip to content

Commit 5289503

Browse files
feat: add hash-based REPL state on top of id-based state (#275)
- when present, the hash takes precedence - when saving, the hash is removed for a clean url (also indicates that you're now in fact sharing what you saved) - to not pollute the browser history, changes are only written to the hash when the editor loses focus Co-authored-by: Rich Harris <[email protected]>
1 parent dffdeb6 commit 5289503

File tree

11 files changed

+163
-91
lines changed

11 files changed

+163
-91
lines changed

apps/svelte.dev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"@supabase/supabase-js": "^2.43.4",
6565
"@sveltejs/adapter-vercel": "^5.4.3",
6666
"@sveltejs/enhanced-img": "^0.3.4",
67-
"@sveltejs/kit": "^2.5.25",
67+
"@sveltejs/kit": "^2.6.3",
6868
"@sveltejs/site-kit": "workspace:*",
6969
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
7070
"@types/cookie": "^0.6.0",

apps/svelte.dev/src/routes/(authed)/playground/[id]/+page.server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ export async function load({ fetch, params, url }) {
1111

1212
return {
1313
gist,
14-
version: url.searchParams.get('version') || 'next'
14+
version: url.searchParams.get('version') || 'next' // TODO replace with 'latest' when 5.0 is released
1515
};
1616
}

apps/svelte.dev/src/routes/(authed)/playground/[id]/+page.svelte

Lines changed: 83 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,95 @@
11
<script lang="ts">
22
import { browser } from '$app/environment';
33
import { afterNavigate, goto, replaceState } from '$app/navigation';
4-
import { Repl } from '@sveltejs/repl';
4+
import type { Gist } from '$lib/db/types';
5+
import { Repl, type File } from '@sveltejs/repl';
56
import { theme } from '@sveltejs/site-kit/stores';
67
import { onMount } from 'svelte';
78
import { mapbox_setup } from '../../../../config.js';
89
import AppControls from './AppControls.svelte';
10+
import { compress_and_encode_text, decode_and_decompress_text } from './gzip.js';
911
1012
let { data } = $props();
1113
12-
let version = $state(data.version);
1314
let repl = $state() as Repl;
1415
let name = $state(data.gist.name);
1516
let zen_mode = $state(false);
1617
let modified_count = $state(0);
17-
18-
$effect(() => {
19-
const params = [];
20-
21-
if (version !== 'latest') {
22-
params.push(`version=${version}`);
23-
}
24-
25-
const url =
26-
params.length > 0
27-
? `/playground/${data.gist.id}?${params.join('&')}`
28-
: `/playground/${data.gist.id}`;
29-
30-
history.replaceState({}, 'x', url);
31-
});
18+
let version = data.version;
19+
let setting_hash: any = null;
3220
3321
onMount(() => {
34-
if (data.version !== 'local') {
35-
fetch(`https://unpkg.com/svelte@${data.version || 'next'}/package.json`)
22+
if (version !== 'local') {
23+
fetch(`https://unpkg.com/svelte@${version}/package.json`)
3624
.then((r) => r.json())
3725
.then((pkg) => {
38-
version = pkg.version;
26+
if (pkg.version !== version) {
27+
version = pkg.version;
28+
29+
let url = `/playground/${data.gist.id}?version=${version}`;
30+
if (location.hash) {
31+
url += location.hash;
32+
}
33+
replaceState(url, {});
34+
}
3935
});
4036
}
4137
});
4238
43-
afterNavigate(() => {
44-
repl?.set({
45-
// TODO move the snapshotting elsewhere (but also... this shouldn't really be necessary?)
46-
files: $state.snapshot(data.gist.components)
47-
});
48-
});
39+
afterNavigate(set_files);
40+
41+
async function set_files() {
42+
const hash = location.hash.slice(1);
43+
44+
if (!hash) {
45+
repl?.set({
46+
files: data.gist.components
47+
});
4948
50-
function handle_fork(event: CustomEvent) {
51-
console.log('> handle_fork', event);
52-
goto(`/playground/${event.detail.gist.id}?version=${version}`);
49+
return;
50+
}
51+
52+
try {
53+
const files = JSON.parse(await decode_and_decompress_text(hash)).files;
54+
repl.set({ files });
55+
} catch {
56+
alert(`Couldn't load the code from the URL. Make sure you copied the link correctly.`);
57+
}
5358
}
5459
55-
function handle_change(event: CustomEvent) {
56-
modified_count = event.detail.files.filter((c: any) => c.modified).length;
60+
function handle_fork({ gist }: { gist: Gist }) {
61+
goto(`/playground/${gist.id}?version=${version}`);
5762
}
5863
59-
const svelteUrl = $derived(
64+
function handle_save() {
65+
// Hide hash from URL
66+
const hash = location.hash.slice(1);
67+
if (hash) {
68+
change_hash();
69+
}
70+
}
71+
72+
async function change_hash(hash?: string) {
73+
let url = `${location.pathname}${location.search}`;
74+
if (hash) {
75+
url += `#${await compress_and_encode_text(hash)}`;
76+
}
77+
78+
clearTimeout(setting_hash);
79+
replaceState(url, {});
80+
setting_hash = setTimeout(() => {
81+
setting_hash = null;
82+
}, 500);
83+
}
84+
85+
function handle_change({ files }: { files: File[] }) {
86+
modified_count = files.filter((c) => c.modified).length;
87+
}
88+
89+
const svelteUrl =
6090
browser && version === 'local'
6191
? `${location.origin}/playground/local`
62-
: `https://unpkg.com/svelte@${version}`
63-
);
92+
: `https://unpkg.com/svelte@${version}`;
6493
6594
const relaxed = $derived(data.gist.relaxed || (data.user && data.user.id === data.gist.owner));
6695
</script>
@@ -73,15 +102,24 @@
73102
<meta name="Description" content="Interactive Svelte playground" />
74103
</svelte:head>
75104

105+
<svelte:window
106+
on:hashchange={() => {
107+
if (!setting_hash) {
108+
set_files();
109+
}
110+
}}
111+
/>
112+
76113
<div class="repl-outer {zen_mode ? 'zen-mode' : ''}">
77114
<AppControls
78115
user={data.user}
79116
gist={data.gist}
117+
forked={handle_fork}
118+
saved={handle_save}
80119
{repl}
81120
bind:name
82121
bind:zen_mode
83122
bind:modified_count
84-
on:forked={handle_fork}
85123
/>
86124

87125
{#if browser}
@@ -93,9 +131,16 @@
93131
injectedJS={mapbox_setup}
94132
showModified
95133
showAst
96-
on:change={handle_change}
97-
on:add={handle_change}
98-
on:remove={handle_change}
134+
change={handle_change}
135+
add={handle_change}
136+
remove={handle_change}
137+
blur={() => {
138+
// Only change hash on editor blur to not pollute everyone's browser history
139+
if (modified_count !== 0) {
140+
const json = JSON.stringify({ files: repl.toJSON().files });
141+
change_hash(json);
142+
}
143+
}}
99144
previewTheme={$theme.current}
100145
/>
101146
{/if}

apps/svelte.dev/src/routes/(authed)/playground/[id]/AppControls.svelte

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import { createEventDispatcher } from 'svelte';
32
import UserMenu from './UserMenu.svelte';
43
import { Icon } from '@sveltejs/site-kit/components';
54
import * as doNotZip from 'do-not-zip';
@@ -18,6 +17,8 @@
1817
name: string;
1918
zen_mode: boolean;
2019
modified_count: number;
20+
forked: (value: { gist: Gist }) => void;
21+
saved: () => void;
2122
}
2223
2324
let {
@@ -26,10 +27,11 @@
2627
modified_count = $bindable(),
2728
user,
2829
repl,
29-
gist
30+
gist,
31+
forked,
32+
saved
3033
}: Props = $props();
3134
32-
const dispatch = createEventDispatcher();
3335
const { login } = get_app_context();
3436
3537
let saving = $state(false);
@@ -77,7 +79,7 @@
7779
}
7880
7981
const gist = await r.json();
80-
dispatch('forked', { gist });
82+
forked({ gist });
8183
8284
modified_count = 0;
8385
repl.markSaved();
@@ -143,6 +145,7 @@
143145
144146
modified_count = 0;
145147
repl.markSaved();
148+
saved();
146149
justSaved = true;
147150
await wait(600);
148151
justSaved = false;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/** @param {string} input */
2+
export async function compress_and_encode_text(input) {
3+
const reader = new Blob([input]).stream().pipeThrough(new CompressionStream('gzip')).getReader();
4+
let buffer = '';
5+
for (;;) {
6+
const { done, value } = await reader.read();
7+
if (done) {
8+
reader.releaseLock();
9+
return btoa(buffer).replaceAll('+', '-').replaceAll('/', '_');
10+
} else {
11+
for (let i = 0; i < value.length; i++) {
12+
// decoding as utf-8 will make btoa reject the string
13+
buffer += String.fromCharCode(value[i]);
14+
}
15+
}
16+
}
17+
}
18+
19+
/** @param {string} input */
20+
export async function decode_and_decompress_text(input) {
21+
const decoded = atob(input.replaceAll('-', '+').replaceAll('_', '/'));
22+
// putting it directly into the blob gives a corrupted file
23+
const u8 = new Uint8Array(decoded.length);
24+
for (let i = 0; i < decoded.length; i++) {
25+
u8[i] = decoded.charCodeAt(i);
26+
}
27+
const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip'));
28+
return new Response(stream).text();
29+
}

packages/repl/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"devDependencies": {
5353
"@lezer/common": "^1.2.1",
5454
"@sveltejs/adapter-auto": "^3.0.0",
55-
"@sveltejs/kit": "^2.0.0",
55+
"@sveltejs/kit": "^2.6.3",
5656
"@sveltejs/package": "^2.0.0",
5757
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
5858
"@types/estree": "^1.0.5",

packages/repl/src/lib/Input/ComponentSelector.svelte

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
<script lang="ts">
22
import { get_repl_context } from '../context';
33
import { get_full_filename } from '../utils';
4-
import { createEventDispatcher, tick } from 'svelte';
4+
import { tick } from 'svelte';
55
import RunesInfo from './RunesInfo.svelte';
66
import Migrate from './Migrate.svelte';
77
import type { File } from '../types';
88
99
export let show_modified: boolean;
1010
export let runes: boolean;
11-
12-
const dispatch: ReturnType<
13-
typeof createEventDispatcher<{
14-
remove: { files: File[]; diff: File };
15-
add: { files: File[]; diff: File };
16-
}>
17-
> = createEventDispatcher();
11+
export let remove: (value: { files: File[]; diff: File }) => void;
12+
export let add: (value: { files: File[]; diff: File }) => void;
1813
1914
const {
2015
files,
@@ -105,7 +100,7 @@
105100
rebundle();
106101
}
107102
108-
function remove(filename: string) {
103+
function remove_file(filename: string) {
109104
const file = $files.find((val) => get_full_filename(val) === filename);
110105
const idx = $files.findIndex((val) => get_full_filename(val) === filename);
111106
@@ -117,7 +112,7 @@
117112
118113
$files = $files.filter((file) => get_full_filename(file) !== filename);
119114
120-
dispatch('remove', { files: $files, diff: file });
115+
remove({ files: $files, diff: file });
121116
122117
EDITOR_STATE_MAP.delete(get_full_filename(file));
123118
@@ -151,7 +146,7 @@
151146
152147
rebundle();
153148
154-
dispatch('add', { files: $files, diff: file });
149+
add({ files: $files, diff: file });
155150
156151
$files = $files;
157152
}
@@ -262,8 +257,8 @@
262257

263258
<span
264259
class="remove"
265-
on:click={() => remove(filename)}
266-
on:keyup={(e) => e.key === ' ' && remove(filename)}
260+
on:click={() => remove_file(filename)}
261+
on:keyup={(e) => e.key === ' ' && remove_file(filename)}
267262
>
268263
<svg width="12" height="12" viewBox="0 0 24 24">
269264
<line stroke="#999" x1="18" y1="6" x2="6" y2="18" />

packages/repl/src/lib/Input/ModuleEditor.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export let error: CompileError | undefined;
77
export let warnings: Warning[];
88
export let vim: boolean;
9+
export let blur: () => void;
910
1011
export function focus() {
1112
$module_editor?.focus();
@@ -14,7 +15,7 @@
1415
const { handle_change, module_editor } = get_repl_context();
1516
</script>
1617

17-
<div class="editor-wrapper">
18+
<div class="editor-wrapper" onblurcapture={blur}>
1819
<div class="editor notranslate" translate="no">
1920
<CodeMirror
2021
bind:this={$module_editor}

0 commit comments

Comments
 (0)