-
Notifications
You must be signed in to change notification settings - Fork 187
/
playground.ts
191 lines (174 loc) · 6.17 KB
/
playground.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import '@material/mwc-button';
import '@material/mwc-snackbar';
import 'playground-elements/playground-ide.js';
import Tar from 'tarts';
import {Snackbar} from '@material/mwc-snackbar';
// TODO(aomarks) This whole thing should probably be a custom element.
window.addEventListener('DOMContentLoaded', () => {
/**
* Encode the given string to base64url, with support for all UTF-16 code
* points, and '=' padding omitted.
*
* Built-in btoa throws on non-latin code points (>0xFF), so this function
* first converts the input to a binary UTF-8 string.
*
* Outputs base64url (https://tools.ietf.org/html/rfc4648#section-5), where
* '+' and '/' are replaced with '-' and '_' respectively, so that '+' doesn't
* need to be percent-encoded (since it would otherwise be mis-interpreted as
* a space).
*
* TODO(aomarks) Make this a method on <playground-project>? It's likely to be
* needed by other projects too.
*/
const encodeSafeBase64 = (str: string) => {
// Adapted from suggestions in https://stackoverflow.com/a/30106551
//
// Example:
//
// [1] Given UTF-16 input: "😃" {D83D DE03}
// [2] Convert to UTF-8 escape sequences: "%F0%9F%98%83"
// [3] Extract UTF-8 code points, and re-interpret as UTF-16 code points,
// creating a string where all code points are <= 0xFF and hence safe
// to base64 encode: {F0 9F 98 83}
const percentEscaped = encodeURIComponent(str);
const utf8 = percentEscaped.replace(/%([0-9A-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
const base64 = btoa(utf8);
const base64url = base64.replace(/\+/g, '-').replace(/\//g, '_');
// Padding is confirmed optional on Chrome 88, Firefox 85, and Safari 14.
const padIdx = base64url.indexOf('=');
return padIdx >= 0 ? base64url.slice(0, padIdx) : base64url;
};
const decodeSafeBase64 = (base64url: string) => {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const utf8 = atob(base64);
const percentEscaped = utf8
.split('')
.map((char) => '%' + char.charCodeAt(0).toString(16).padStart(2, '0'))
.join('');
const str = decodeURIComponent(percentEscaped);
return str;
};
const $ = document.body.querySelector.bind(document.body);
const project = $('playground-project')!;
const shareButton = $('#shareButton')!;
const shareSnackbar = $('#shareSnackbar')! as Snackbar;
const share = async () => {
// No need to include contentType (inferred) or undefined label (unused).
const files = Object.entries(project.config?.files ?? {}).map(
([name, file]) => ({
name,
content: file.content,
})
);
const base64 = encodeSafeBase64(JSON.stringify(files));
window.location.hash = '#project=' + base64;
await navigator.clipboard.writeText(window.location.toString());
shareSnackbar.open = true;
};
shareButton.addEventListener('click', share);
const downloadButton = $('#downloadButton')!;
downloadButton.addEventListener('click', () => {
const tarFiles = Object.entries(project.config?.files ?? {}).map(
([name, {content}]) => ({
name,
content: content ?? '',
})
);
const tar = Tar(tarFiles);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([tar], {type: 'application/tar'}));
a.download = 'lit-playground.tar';
a.click();
});
const syncStateFromUrlHash = async () => {
const hash = window.location.hash;
const params = new URLSearchParams(hash.slice(1));
let urlFiles: Array<{name: string; content: string}> | undefined;
const base64 = params.get('project');
if (base64) {
try {
const json = decodeSafeBase64(base64);
try {
urlFiles = JSON.parse(json);
} catch {
console.error('Invalid JSON in URL', JSON.stringify(json));
}
} catch {
console.error('Invalid project base64 in URL');
}
}
$('.exampleItem.active')?.classList.remove('active');
if (urlFiles) {
project.config = {
extends: '/samples/base.json',
files: Object.fromEntries(
urlFiles.map(({name, content}) => [name, {content}])
),
};
} else {
let sample = 'examples/hello-world-typescript';
const urlSample = params.get('sample');
if (urlSample?.match(/^[a-zA-Z0-9_\-\/]+$/)) {
sample = urlSample;
}
project.projectSrc = `/samples/${sample}/project.json`;
const link = $(`.exampleItem[data-sample="${sample}"]`);
if (link) {
link.classList.add('active');
// Wait for the drawer to upgrade and render before scrolling.
await customElements.whenDefined('litdev-drawer');
requestAnimationFrame(() => {
scrollToCenter(link, document.querySelector('#exampleContent')!);
});
}
}
};
syncStateFromUrlHash();
window.addEventListener('hashchange', syncStateFromUrlHash);
// Trigger URL sharing when Control-s or Command-s is pressed.
let controlDown = false;
let commandDown = false;
window.addEventListener('keydown', (event) => {
if (event.key === 'Control') {
controlDown = true;
} else if (event.key === 'Meta') {
commandDown = true;
} else if (event.key === 's' && (controlDown || commandDown)) {
share();
event.preventDefault(); // Don't trigger "Save page as"
}
});
window.addEventListener('keyup', (event) => {
if (event.key === 'Control') {
controlDown = false;
} else if (event.key === 'Meta') {
commandDown = false;
}
});
window.addEventListener('blur', () => {
controlDown = false;
commandDown = false;
});
});
/**
* Note we don't use scrollIntoView() because it also steals focus.
*/
const scrollToCenter = (target: Element, parent: Element) => {
const parentRect = parent.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (
targetRect.bottom > parentRect.bottom ||
targetRect.top < parentRect.top
) {
parent.scroll({
top: targetRect.top - parentRect.top - parentRect.height / 2,
});
}
};