svelte/site/src/routes/repl/_components/Output/Viewer.html

386 lines
8.3 KiB

<script>
import { onMount, createEventDispatcher } from 'svelte';
import getLocationFromStack from '../../_utils/getLocationFromStack.js';
import { decode } from 'sourcemap-codec';
const dispatch = createEventDispatcher();
export let bundle;
export let dom;
export let ssr;
export let values_store;
export let props;
export let sourceError;
export let error;
export function setProp(prop, value) {
if (component) {
component[prop] = value;
}
}
let component;
const refs = {};
const importCache = {};
let pendingImports = 0;
let pending = false;
function fetchImport(id, curl) {
return new Promise((fulfil, reject) => {
curl([`https://bundle.run/${id}`]).then(module => {
importCache[id] = module;
fulfil(module);
}, err => {
console.error(err.stack);
reject(new Error(`Error loading ${id} from bundle.run`));
});
});
}
const namespaceSpecifier = /\*\s+as\s+(\w+)/;
const namedSpecifiers = /\{(.+)\}/;
function parseSpecifiers(specifiers) {
specifiers = specifiers.trim();
let match = namespaceSpecifier.exec(specifiers);
if (match) {
return {
namespace: true,
name: match[1]
};
}
let names = [];
specifiers = specifiers.replace(namedSpecifiers, (match, str) => {
names = str.split(',').map(name => {
const split = name.split('as');
const exported = split[0].trim();
const local = (split[1] || exported).trim();
return { local, exported };
});
return '';
});
match = /\w+/.exec(specifiers);
return {
namespace: false,
names,
default: match ? match[0] : null
};
}
let createComponent;
let init;
onMount(() => {
refs.child.addEventListener('load', () => {
const iframe = refs.child;
const body = iframe.contentDocument.body;
const evalInIframe = iframe.contentWindow.eval;
// intercept links, so that we can use #hashes inside the iframe
body.addEventListener('click', event => {
if (event.which !== 1) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.defaultPrevented) return;
// ensure target is a link
let el = event.target;
while (el && el.nodeName !== 'A') el = el.parentNode;
if (!el || el.nodeName !== 'A') return;
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
event.preventDefault();
if (el.href.startsWith(top.location.origin)) {
const url = new URL(el.href);
if (url.hash[0] === '#') {
iframe.contentWindow.location.hash = url.hash;
return;
}
}
window.open(el.href, '_blank');
});
let promise = null;
let updating = false;
let toDestroy = null;
const init = () => {
if (sourceError) return;
const imports = [];
const missingImports = bundle.imports.filter(x => !importCache[x]);
const removeStyles = () => {
const styles = iframe.contentDocument.querySelectorAll('style.svelte');
let i = styles.length;
while (i--) styles[i].parentNode.removeChild(styles[i]);
};
const ready = () => {
error = null;
if (toDestroy) {
removeStyles();
toDestroy.$destroy();
toDestroy = null;
}
bundle.imports.forEach(x => {
const module = importCache[x];
const name = bundle.importMap.get(x);
iframe.contentWindow[name] = module;
});
if (ssr) { // this only gets generated if component uses lifecycle hooks
pending = true;
createHtml();
} else {
pending = false;
createComponent();
}
};
const createHtml = () => {
try {
evalInIframe(`${ssr.code}
var rendered = SvelteComponent.render(${JSON.stringify($values_store)});
if (rendered.css.code) {
var style = document.createElement('style');
style.className = 'svelte';
style.textContent = rendered.css.code;
document.head.appendChild(style);
}
document.body.innerHTML = rendered.html;
`)
} catch (e) {
const loc = getLocationFromStack(e.stack, ssr.map);
if (loc) {
e.filename = loc.source;
e.loc = { line: loc.line, column: loc.column };
}
error = e;
}
};
const createComponent = () => {
// remove leftover styles from SSR renderer
if (ssr) removeStyles();
try {
evalInIframe(`${dom.code}
document.body.innerHTML = '';
window.location.hash = '';
window._svelteTransitionManager = null;
var component = new SvelteComponent({
target: document.body,
props: ${JSON.stringify($values_store)}
});`);
component = window.app = window.component = iframe.contentWindow.component;
// component.on('state', ({ current }) => {
// if (updating) return;
// updating = true;
// this.fire('data', { current });
// updating = false;
// });
} catch (e) {
// TODO show in UI
component = null;
const loc = getLocationFromStack(e.stack, dom.map);
if (loc) {
e.filename = loc.source;
e.loc = { line: loc.line, column: loc.column };
}
error = e;
}
};
pendingImports = missingImports.length;
if (missingImports.length) {
let cancelled = false;
promise = Promise.all(
missingImports.map(id => fetchImport(id, iframe.contentWindow.curl).then(module => {
pendingImports -= 1;
return module;
}))
);
promise.cancel = () => cancelled = true;
promise
.then(() => {
if (cancelled) return;
ready();
})
.catch(e => {
if (cancelled) return;
error = e;
});
} else {
ready();
}
run = () => {
pending = false;
// TODO do we need to clear out SSR HTML?
createComponent();
};
}
bundle_handler = bundle => {
if (!bundle) return; // TODO can this ever happen?
if (promise) promise.cancel();
toDestroy = component;
component = null;
init();
};
props_handler = props => {
if (!component) {
// TODO can this happen?
console.error(`no component to bind to`);
return;
}
props.forEach(prop => {
// TODO should there be a public API for binding?
// e.g. `component.$watch(prop, handler)`?
// (answer: probably)
component.$$.bound[prop] = value => {
dispatch('binding', { prop, value });
values_store.update(values => Object.assign({}, values, {
[prop]: value
}));
};
});
};
});
});
function noop(){}
let run = noop;
let bundle_handler = noop;
let props_handler = noop;
$: bundle_handler(bundle);
$: props_handler(props);
// pending https://github.com/sveltejs/svelte/issues/1889
$: {
$values_store;
}
</script>
<style>
.iframe-container {
background-color: white;
border: none;
height: 100%;
}
iframe {
width: 100%;
height: 100%;
/* height: calc(100vh - var(--nav-h)); */
border: none;
display: block;
}
.greyed-out {
filter: grayscale(50%) blur(1px);
opacity: .25;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
padding: 1em;
pointer-events: none;
}
.overlay p {
pointer-events: all;
}
.pending {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-align: center;
pointer-events: all;
}
.pending button {
position: absolute;
margin-top: 6rem;
}
</style>
<div class="iframe-container">
<iframe title="Result" bind:this={refs.child} class="{error || pending || pendingImports ? 'greyed-out' : ''}" srcdoc='
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/repl-viewer.css">
</head>
<body>
<script src="/curl.js"></script>
<script>curl.config(&#123; dontAddFileExt: /./ });</script>
</body>
</html>
'></iframe>
</div>
<div class="overlay">
{#if error}
<p class="error message">
{#if error.loc}
<strong>
{#if error.filename}
<span class="filename" on:click="{() => dispatch('navigate', { filename: error.filename })}">{error.filename}</span>
{/if}
({error.loc.line}:{error.loc.column})
</strong>
{/if}
{error.message}
</p>
{:elseif pending}
<div class="pending" on:click={run}>
<button class="bg-second white">Click to run</button>
</div>
{:elseif pendingImports}
<p class="info message">loading {pendingImports} {pendingImports === 1 ? 'dependency' : 'dependencies'} from
https://bundle.run</p>
{/if}
</div>