workflow: sfc playground

This commit is contained in:
Evan You
2021-03-28 01:35:45 -04:00
parent 2424768808
commit f76ddc5ac3
28 changed files with 1654 additions and 23 deletions

View File

@@ -0,0 +1,57 @@
<template>
<div class="tab-buttons">
<button v-for="m of modes" :class="{ active: mode === m }" @click="mode = m">{{ m }}</button>
</div>
<div class="output-container">
<Preview v-if="mode === 'preview'" :code="store.compiled.executed" />
<CodeMirror
v-else
readonly
:mode="mode === 'css' ? 'css' : 'javascript'"
:value="store.compiled[mode]"
/>
</div>
</template>
<script setup lang="ts">
import Preview from './Preview.vue'
import CodeMirror from '../codemirror/CodeMirror.vue'
import { store } from '../store'
import { ref } from 'vue'
type Modes = 'preview' | 'executed' | 'js' | 'css' | 'template'
const modes: Modes[] = ['preview', 'js', 'css', 'template', 'executed']
const mode = ref<Modes>('preview')
</script>
<style scoped>
.output-container {
height: calc(100% - 35px);
overflow: hidden;
position: relative;
}
.tab-buttons {
box-sizing: border-box;
border-bottom: 1px solid #ddd;
}
.tab-buttons button {
margin: 0;
font-size: 13px;
font-family: 'Source Code Pro', monospace;
border: none;
outline: none;
background-color: #f8f8f8;
padding: 8px 16px 6px;
text-transform: uppercase;
cursor: pointer;
color: #999;
box-sizing: border-box;
}
button.active {
color: #42b983;
border-bottom: 3px solid #42b983;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<iframe
id="preview"
ref="iframe"
sandbox="allow-forms allow-modals allow-pointer-lock allow-popups allow-same-origin allow-scripts allow-top-navigation-by-user-activation"
:srcdoc="srcdoc"
></iframe>
<Message :err="runtimeError" />
<Message v-if="!runtimeError" :warn="runtimeWarning" />
</template>
<script setup lang="ts">
import Message from '../Message.vue'
import { ref, onMounted, onUnmounted, watchEffect, defineProps } from 'vue'
import srcdoc from './srcdoc.html?raw'
import { PreviewProxy } from './PreviewProxy'
import { sandboxVueURL } from '../store'
const props = defineProps<{ code: string }>()
const iframe = ref()
const runtimeError = ref()
const runtimeWarning = ref()
let proxy: PreviewProxy
async function updatePreview() {
if (!props.code?.trim()) {
return
}
try {
proxy.eval(`
${props.code}
if (window.vueApp) {
window.vueApp.unmount()
}
const container = document.getElementById('app')
container.innerHTML = ''
import { createApp as _createApp } from "${sandboxVueURL}"
const app = window.vueApp = _createApp(__comp)
app.config.errorHandler = e => console.error(e)
app.mount(container)
`)
} catch (e) {
runtimeError.value = e.message
return
}
runtimeError.value = null
runtimeWarning.value = null
}
onMounted(() => {
proxy = new PreviewProxy(iframe.value, {
on_fetch_progress: (progress: any) => {
// pending_imports = progress;
},
on_error: (event: any) => {
// push_logs({ level: 'error', args: [event.value] });
runtimeError.value = event.value
},
on_unhandled_rejection: (event: any) => {
let error = event.value
if (typeof error === 'string') error = { message: error }
runtimeError.value = 'Uncaught (in promise): ' + error.message
},
on_console: (log: any) => {
if (log.level === 'error') {
runtimeError.value = log.args.join('')
} else if (log.level === 'warn') {
if (log.args[0].toString().includes('[Vue warn]')) {
runtimeWarning.value = log.args.join('').replace(/\[Vue warn\]:/, '').trim()
}
}
},
on_console_group: (action: any) => {
// group_logs(action.label, false);
},
on_console_group_end: () => {
// ungroup_logs();
},
on_console_group_collapsed: (action: any) => {
// group_logs(action.label, true);
}
})
iframe.value.addEventListener('load', () => {
proxy.handle_links();
watchEffect(updatePreview)
});
})
onUnmounted(() => {
proxy.destroy()
})
</script>
<style>
iframe {
width: 100%;
height: 100%;
border: none;
background-color: #fff;
}
</style>

View File

@@ -0,0 +1,96 @@
// ReplProxy and srcdoc implementation from Svelte REPL
// MIT License https://github.com/sveltejs/svelte-repl/blob/master/LICENSE
let uid = 1
export class PreviewProxy {
iframe: HTMLIFrameElement
handlers: Record<string, Function>
pending_cmds: Map<
number,
{ resolve: (value: unknown) => void; reject: (reason?: any) => void }
>
handle_event: (e: any) => void
constructor(iframe: HTMLIFrameElement, handlers: Record<string, Function>) {
this.iframe = iframe
this.handlers = handlers
this.pending_cmds = new Map()
this.handle_event = e => this.handle_repl_message(e)
window.addEventListener('message', this.handle_event, false)
}
destroy() {
window.removeEventListener('message', this.handle_event)
}
iframe_command(action: string, args: any) {
return new Promise((resolve, reject) => {
const cmd_id = uid++
this.pending_cmds.set(cmd_id, { resolve, reject })
this.iframe.contentWindow!.postMessage({ action, cmd_id, args }, '*')
})
}
handle_command_message(cmd_data: any) {
let action = cmd_data.action
let id = cmd_data.cmd_id
let handler = this.pending_cmds.get(id)
if (handler) {
this.pending_cmds.delete(id)
if (action === 'cmd_error') {
let { message, stack } = cmd_data
let e = new Error(message)
e.stack = stack
handler.reject(e)
}
if (action === 'cmd_ok') {
handler.resolve(cmd_data.args)
}
} else {
console.error('command not found', id, cmd_data, [
...this.pending_cmds.keys()
])
}
}
handle_repl_message(event: any) {
if (event.source !== this.iframe.contentWindow) return
const { action, args } = event.data
switch (action) {
case 'cmd_error':
case 'cmd_ok':
return this.handle_command_message(event.data)
case 'fetch_progress':
return this.handlers.on_fetch_progress(args.remaining)
case 'error':
return this.handlers.on_error(event.data)
case 'unhandledrejection':
return this.handlers.on_unhandled_rejection(event.data)
case 'console':
return this.handlers.on_console(event.data)
case 'console_group':
return this.handlers.on_console_group(event.data)
case 'console_group_collapsed':
return this.handlers.on_console_group_collapsed(event.data)
case 'console_group_end':
return this.handlers.on_console_group_end(event.data)
}
}
eval(script: string) {
return this.iframe_command('eval', { script })
}
handle_links() {
return this.iframe_command('catch_clicks', {})
}
}

View File

@@ -0,0 +1,201 @@
<!doctype html>
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
</style>
<style id="__sfc-styles"></style>
<script>
(function(){
let scriptEl
function handle_message(ev) {
let { action, cmd_id } = ev.data;
const send_message = (payload) => parent.postMessage( { ...payload }, ev.origin);
const send_reply = (payload) => send_message({ ...payload, cmd_id });
const send_ok = () => send_reply({ action: 'cmd_ok' });
const send_error = (message, stack) => send_reply({ action: 'cmd_error', message, stack });
if (action === 'eval') {
try {
if (scriptEl) {
document.head.removeChild(scriptEl)
}
scriptEl = document.createElement('script')
scriptEl.setAttribute('type', 'module')
scriptEl.innerHTML = ev.data.args.script
document.head.appendChild(scriptEl)
send_ok();
} catch (e) {
send_error(e.message, e.stack);
}
}
if (action === 'catch_clicks') {
try {
const top_origin = ev.origin;
document.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_origin)) {
const url = new URL(el.href);
if (url.hash[0] === '#') {
window.location.hash = url.hash;
return;
}
}
window.open(el.href, '_blank');
});
send_ok();
} catch(e) {
send_error(e.message, e.stack);
}
}
}
window.addEventListener('message', handle_message, false);
window.onerror = function (msg, url, lineNo, columnNo, error) {
parent.postMessage({ action: 'error', value: error }, '*');
}
window.addEventListener("unhandledrejection", event => {
parent.postMessage({ action: 'unhandledrejection', value: event.reason }, '*');
});
}).call(this);
let previous = { level: null, args: null };
['clear', 'log', 'info', 'dir', 'warn', 'error', 'table'].forEach((level) => {
const original = console[level];
console[level] = (...args) => {
if (String(args[0]).includes('You are running a development build of Vue')) {
return
}
const stringifiedArgs = stringify(args);
if (
previous.level === level &&
previous.args &&
previous.args === stringifiedArgs
) {
parent.postMessage({ action: 'console', level, duplicate: true }, '*');
} else {
previous = { level, args: stringifiedArgs };
try {
parent.postMessage({ action: 'console', level, args }, '*');
} catch (err) {
parent.postMessage({ action: 'console', level: 'unclonable' }, '*');
}
}
original(...args);
}
});
[
{ method: 'group', action: 'console_group' },
{ method: 'groupEnd', action: 'console_group_end' },
{ method: 'groupCollapsed', action: 'console_group_collapsed' },
].forEach((group_action) => {
const original = console[group_action.method];
console[group_action.method] = (label) => {
parent.postMessage({ action: group_action.action, label }, '*');
original(label);
};
});
const timers = new Map();
const original_time = console.time;
const original_timelog = console.timeLog;
const original_timeend = console.timeEnd;
console.time = (label = 'default') => {
original_time(label);
timers.set(label, performance.now());
}
console.timeLog = (label = 'default') => {
original_timelog(label);
const now = performance.now();
if (timers.has(label)) {
parent.postMessage({ action: 'console', level: 'system-log', args: [`${label}: ${now - timers.get(label)}ms`] }, '*');
} else {
parent.postMessage({ action: 'console', level: 'system-warn', args: [`Timer '${label}' does not exist`] }, '*');
}
}
console.timeEnd = (label = 'default') => {
original_timeend(label);
const now = performance.now();
if (timers.has(label)) {
parent.postMessage({ action: 'console', level: 'system-log', args: [`${label}: ${now - timers.get(label)}ms`] }, '*');
} else {
parent.postMessage({ action: 'console', level: 'system-warn', args: [`Timer '${label}' does not exist`] }, '*');
}
timers.delete(label);
};
const original_assert = console.assert;
console.assert = (condition, ...args) => {
if (condition) {
const stack = new Error().stack;
parent.postMessage({ action: 'console', level: 'assert', args, stack }, '*');
}
original_assert(condition, ...args);
};
const counter = new Map();
const original_count = console.count;
const original_countreset = console.countReset;
console.count = (label = 'default') => {
counter.set(label, (counter.get(label) || 0) + 1);
parent.postMessage({ action: 'console', level: 'system-log', args: `${label}: ${counter.get(label)}` }, '*');
original_count(label);
};
console.countReset = (label = 'default') => {
if (counter.has(label)) {
counter.set(label, 0);
} else {
parent.postMessage({ action: 'console', level: 'system-warn', args: `Count for '${label}' does not exist` }, '*');
}
original_countreset(label);
};
const original_trace = console.trace;
console.trace = (...args) => {
const stack = new Error().stack;
parent.postMessage({ action: 'console', level: 'trace', args, stack }, '*');
original_trace(...args);
};
function stringify(args) {
try {
return JSON.stringify(args);
} catch (error) {
return null;
}
}
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>