-
Notifications
You must be signed in to change notification settings - Fork 60
Expand file tree
/
Copy pathplayground-ide.ts
More file actions
437 lines (393 loc) · 12.9 KB
/
playground-ide.ts
File metadata and controls
437 lines (393 loc) · 12.9 KB
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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import {LitElement, html, css, PropertyValues, nothing} from 'lit';
import {customElement, query, property} from 'lit/decorators.js';
import {Extension} from '@codemirror/state';
import './playground-project.js';
import './playground-tab-bar.js';
import './playground-file-editor.js';
import './playground-preview.js';
import {PlaygroundProject} from './playground-project.js';
import {ProjectManifest} from './shared/worker-api.js';
import {npmVersion, serviceWorkerHash} from './shared/version.js';
import {refireEvent} from './shared/util.js';
/**
* A multi-file code editor component with live preview that works without a
* server.
*
* <playground-ide> loads a project configuration file and the set of source
* files it describes from the network. The source files can be edited locally.
* To serve the locally edited files to the live preview, <playground-ide>
* registers a service worker to serve files to the preview from the main UI
* thread directly, without a network roundtrip.
*
* The project manifest is a JSON file with a "files" property. "files" is an
* object with properties for each file. The key is the filename, relative to
* the project manifest.
*
* Example project manifest:
* ```json
* {
* "files": {
* "./index.html": {},
* "./my-element.js": {},
* }
* }
* ```
*
* Files can also be given as <script> tag children of <playground-ide>. The
* type attribute must start with "sample/" and then the type of the file, one
* of: "js", "ts", "html", or "css". The <script> must also have a "filename"
* attribute.
*
* Example inline files:
* ```html
* <playground-ide>
* <script type="sample/html" filename="index.html">
* <script type="module" src="index.js"><script>
* <h1>Hello World</h1>
* </script>
* <script type="sample/js" filename="index.js">
* document.body.append('<h2>Hello from JS</h2>');
* </script>
* </playground>
* ```
*/
@customElement('playground-ide')
export class PlaygroundIde extends LitElement {
static override styles = css`
:host {
display: flex;
height: 350px;
min-width: 200px;
border: var(--playground-border, solid 1px #ddd);
/* The invisible resize bar has a high z-index so that it's above
CodeMirror. But we don't want it also above other elements on the page.
Force a new stacking context. */
isolation: isolate;
}
#lhs {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-width: 100px;
border-radius: inherit;
border-right: var(--playground-border, solid 1px #ddd);
}
playground-tab-bar {
border-start-start-radius: inherit;
flex-shrink: 0;
}
playground-file-editor {
flex: 1;
height: calc(100% - var(--playground-bar-height, 40px));
}
#rhs {
height: 100%;
width: max(100px, var(--playground-preview-width, 30%));
position: relative;
border-radius: inherit;
}
playground-preview {
height: 100%;
width: 100%;
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
slot {
display: none;
}
#resizeBar {
position: absolute;
top: 0;
left: -5px;
width: 10px;
height: 100%;
z-index: 9;
cursor: col-resize;
}
#resizeOverlay {
display: none;
}
#resizeOverlay.resizing {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 99999;
cursor: col-resize;
}
`;
/**
* A document-relative path to a project configuration file.
*
* When both `projectSrc` and `files` are set, the one set most recently wins.
* Slotted children win only if both `projectSrc` and `files` are undefined.
*/
@property({attribute: 'project-src', hasChanged: () => false})
get projectSrc(): string | undefined {
// To minimize synchronization complexity, we delegate the `projectSrc` and
// `files` getters/setters directly to our <playground-project>. The only
// case we need to handle is properties set before upgrade or before we
// first render the <playground-project>.
//
// Note we set `hasChanged: () => false` because we don't need to trigger
// `update` when this property changes. (Why be a lit property at all?
// Because we want [1] to respond to attribute changes, and [2] to inherit
// property values set before upgrade).
//
// TODO(aomarks) Maybe a "delegate" decorator for this pattern?
return this._project?.projectSrc ?? this._projectSrcSetBeforeRender;
}
set projectSrc(src: string | undefined) {
const project = this._project;
if (project) {
project.projectSrc = src;
} else {
this._projectSrcSetBeforeRender = src;
}
}
/**
* Get or set the project config.
*
* When both `projectSrc` and `config` are set, the one set most recently
* wins. Slotted children win only if both `projectSrc` and `config` are
* undefined.
*/
@property({attribute: false, hasChanged: () => false})
get config(): ProjectManifest | undefined {
// Note this is declared a @property only to capture properties set before
// upgrade. Attribute reflection and update lifecycle disabled because they
// are not needed in this case.
return this._project?.config ?? this._configSetBeforeRender;
}
set config(config: ProjectManifest | undefined) {
const project = this._project;
if (project) {
project.config = config;
} else {
this._configSetBeforeRender = config;
}
}
/**
* Base URL for script execution sandbox.
*
* It is highly advised to change this property to a URL on a separate origin
* which has no privileges to perform sensitive actions or access sensitive
* data. This is because this element will execute arbitrary JavaScript, and
* does not have the ability to sanitize or sandbox it.
*
* This URL must host the following files from the playground-elements
* package:
* 1. playground-service-worker.js
* 2. playground-service-worker-proxy.html
*
* Defaults to the directory containing the script that defines this element
* on the same origin (typically something like
* "/node_modules/playground-elements/").
*/
@property({attribute: 'sandbox-base-url'})
sandboxBaseUrl = `https://unpkg.com/playground-elements@${npmVersion}/`;
/**
* The service worker scope to register on
*/
// TODO: generate this?
@property({attribute: 'sandbox-scope'})
sandboxScope = `__playground_swfs_${serviceWorkerHash}/`;
/**
* Base URL for the CDN used to resolve bare module specifiers.
*
* Examples:
* - "" (default, but resolves to "https://unpkg.com")
* - "https://unpkg.com"
* - "https://cdn.jsdelivr.net/npm"
*/
@property({attribute: 'cdn-base-url'})
cdnBaseUrl = '';
/**
* Allow the user to add, remove, and rename files in the project's virtual
* filesystem. Disabled by default.
*/
@property({type: Boolean, attribute: 'editable-file-system'})
editableFileSystem = false;
/**
* If true, display a left-hand-side gutter with line numbers. Default false
* (hidden).
*/
@property({type: Boolean, attribute: 'line-numbers'})
lineNumbers = false;
/**
* If true, wrap for long lines. Default false
*/
@property({type: Boolean, attribute: 'line-wrapping'})
lineWrapping = false;
/**
* If true, allow the user to change the relative size of the LHS editor and
* RHS preview by clicking and dragging in the space between them.
*/
@property({type: Boolean})
resizable = false;
/**
* How to handle `playground-hide` and `playground-fold` comments.
*
* See https://github.com/google/playground-elements#hiding--folding for
* more details.
*
* Options:
* - on: Hide and fold regions, and hide the special comments.
* - off: Don't hide or fold regions, but still hide the special comments.
* - off-visible: Don't hide or fold regions, and show the special comments as
* literal text.
*/
@property()
pragmas: 'on' | 'off' | 'off-visible' = 'on';
/**
* The HTML file used in the preview.
*/
@property({attribute: 'html-file'})
htmlFile = 'index.html';
/**
* If true, will disable code completions in the code-editor.
*/
@property({type: Boolean, attribute: 'no-completions'})
noCompletions = false;
/**
* A CodeMirror extension or extensions to apply to the editor.
*/
@property({attribute: false})
extensions?: Extension | Extension[];
/**
* Indicates whether the user has modified, added, or removed any project
* files. Resets whenever a new project is loaded.
*/
get modified(): boolean {
return this._project?.modified ?? false;
}
@query('playground-project')
private _project!: PlaygroundProject | null;
@query('#resizeBar')
private _resizeBar!: HTMLDivElement;
@query('#rhs')
private _rhs!: HTMLDivElement;
private _configSetBeforeRender?: ProjectManifest;
private _projectSrcSetBeforeRender?: string;
override render() {
const projectId = 'project';
const editorId = 'editor';
return html`
<playground-project
id=${projectId}
.sandboxBaseUrl=${this.sandboxBaseUrl}
.sandboxScope=${this.sandboxScope}
.cdnBaseUrl=${this.cdnBaseUrl}
>
<slot></slot>
</playground-project>
<div id="lhs">
<playground-tab-bar
part="tab-bar"
.project=${projectId}
.editor=${editorId}
.editableFileSystem=${this.editableFileSystem}
@tabchange=${this._onTabChange}
>
</playground-tab-bar>
<playground-file-editor
id=${editorId}
part="editor"
.lineNumbers=${this.lineNumbers}
.lineWrapping=${this.lineWrapping}
.project=${projectId}
.pragmas=${this.pragmas}
.noCompletions=${this.noCompletions}
.extensions=${this.extensions}
@change=${this._onChange}
>
<slot name="extensions" slot="extensions"></slot>
</playground-file-editor>
</div>
<div id="rhs">
${this.resizable
? html`<div
id="resizeBar"
@pointerdown=${this._onResizeBarPointerdown}
></div>`
: nothing}
<playground-preview
part="preview"
exportparts="preview-toolbar,
preview-location,
preview-reload-button,
preview-loading-indicator,
diagnostic-tooltip,
dialog"
.htmlFile=${this.htmlFile}
.project=${projectId}
></playground-preview>
</div>
`;
}
override firstUpdated() {
if (this._configSetBeforeRender) {
this._project!.config = this._configSetBeforeRender;
this._configSetBeforeRender = undefined;
}
if (this._projectSrcSetBeforeRender) {
this._project!.projectSrc = this._projectSrcSetBeforeRender;
this._projectSrcSetBeforeRender = undefined;
}
}
override async update(changedProperties: PropertyValues<this>) {
if (changedProperties.has('resizable') && this.resizable === false) {
// Note we set this property on the RHS element instead of the host so
// that when "resizable" is toggled, we don't reset a host value that the
// user might have set.
this._rhs?.style.removeProperty('--playground-preview-width');
}
super.update(changedProperties);
}
private _onTabChange(event: Event) {
// Re-fire the tabchange event on the host element for external consumers
refireEvent(this, event);
}
private _onChange(event: Event) {
// Re-fire the change event on the host element for external consumers
refireEvent(this, event);
}
private _onResizeBarPointerdown({pointerId}: PointerEvent) {
const bar = this._resizeBar;
bar.setPointerCapture(pointerId);
const rhsStyle = this._rhs.style;
const {left: hostLeft, right: hostRight} = this.getBoundingClientRect();
const hostWidth = hostRight - hostLeft;
const rhsMinWidth = 100;
const rhsMaxWidth = hostWidth - 100;
const onPointermove = (event: PointerEvent) => {
const rhsWidth = Math.min(
rhsMaxWidth,
Math.max(rhsMinWidth, hostRight - event.clientX)
);
const percent = (rhsWidth / hostWidth) * 100;
rhsStyle.setProperty('--playground-preview-width', `${percent}%`);
};
bar.addEventListener('pointermove', onPointermove);
const onPointerup = () => {
bar.releasePointerCapture(pointerId);
bar.removeEventListener('pointermove', onPointermove);
bar.removeEventListener('pointerup', onPointerup);
};
bar.addEventListener('pointerup', onPointerup);
}
}
declare global {
interface HTMLElementTagNameMap {
'playground-ide': PlaygroundIde;
}
}