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,37 @@
<template>
<Header />
<div class="wrapper">
<SplitPane>
<template #left>
<Editor />
</template>
<template #right>
<Output />
</template>
</SplitPane>
</div>
</template>
<script setup lang="ts">
import Header from './Header.vue'
import SplitPane from './SplitPane.vue'
import Editor from './editor/Editor.vue'
import Output from './output/Output.vue'
</script>
<style>
body {
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
color: #444;
margin: 0;
background-color: #f8f8f8;
--nav-height: 50px;
--font-code: 'Source Code Pro', monospace;
}
.wrapper {
height: calc(100vh - var(--nav-height));
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<nav>
<h1>Vue SFC Playground</h1>
</nav>
</template>
<style>
nav {
height: var(--nav-height);
box-sizing: border-box;
padding: 0 1em;
background-color: #fff;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.33);
position: relative;
z-index: 999;
}
h1 {
margin: 0;
line-height: var(--nav-height);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<Transition name="fade">
<pre v-if="err || warn"
class="msg"
:class="err ? 'err' : 'warn'">{{ formatMessage(err || warn) }}</pre>
</Transition>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
import type { CompilerError } from '@vue/compiler-sfc'
defineProps(['err', 'warn'])
function formatMessage(err: string | Error): string {
if (typeof err === 'string') {
return err
} else {
let msg = err.message
const loc = (err as CompilerError).loc
if (loc && loc.start) {
msg = `(${loc.start.line}:${loc.start.column}) ` + msg
}
return msg
}
}
</script>
<style scoped>
.msg {
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
z-index: 10;
padding: 14px 20px;
border: 2px solid transparent;
border-radius: 6px;
font-family: var(--font-code);
white-space: pre-wrap;
}
.msg.err {
color: red;
border-color: red;
background-color: #ffd7d7;
}
.msg.warn {
--color: rgb(105, 95, 27);
color: var(--color);
border-color: var(--color);
background-color: rgb(247, 240, 205);
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.15s ease-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translate(0, 10px);
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div
ref="container"
class="split-pane"
:class="{ dragging: state.dragging }"
@mousemove="dragMove"
@mouseup="dragEnd"
@mouseleave="dragEnd"
>
<div class="left" :style="{ width: boundSplit() + '%' }">
<slot name="left" />
<div class="dragger" @mousedown.prevent="dragStart" />
</div>
<div class="right" :style="{ width: (100 - boundSplit()) + '%' }">
<slot name="right" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
const container = ref()
const state = reactive({
dragging: false,
split: 50
})
function boundSplit() {
const { split } = state
return split < 20
? 20
: split > 80
? 80
: split
}
let startPosition = 0
let startSplit = 0
function dragStart(e: MouseEvent) {
state.dragging = true
startPosition = e.pageX
startSplit = boundSplit()
}
function dragMove(e: MouseEvent) {
if (state.dragging) {
const position = e.pageX
const totalSize = container.value.offsetWidth
const dp = position - startPosition
state.split = startSplit + ~~(dp / totalSize * 100)
}
}
function dragEnd() {
state.dragging = false
}
</script>
<style scoped>
.split-pane {
display: flex;
height: 100%;
}
.split-pane.dragging {
cursor: ew-resize;
}
.dragging .left,
.dragging .right {
pointer-events: none;
}
.left,
.right {
position: relative;
height: 100%;
}
.left {
border-right: 1px solid #ccc;
}
.dragger {
position: absolute;
z-index: 99;
top: 0;
bottom: 0;
right: -5px;
width: 10px;
cursor: ew-resize;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="editor" ref="el"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, defineProps, defineEmit, watchEffect } from 'vue'
import { debounce } from '../utils'
import CodeMirror from './codemirror'
const el = ref()
const props = defineProps({
mode: {
type: String,
default: 'htmlmixed'
},
value: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false
}
})
const emit = defineEmit(['change'])
onMounted(() => {
const addonOptions = {
autoCloseBrackets: true,
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
}
const editor = CodeMirror(el.value!, {
value: '',
mode: props.mode,
readOnly: props.readonly,
tabSize: 2,
lineWrapping: true,
lineNumbers: true,
...addonOptions
})
editor.on('change', () => {
emit('change', editor.getValue())
})
watchEffect(() => {
editor.setValue(props.value)
})
watchEffect(() => {
editor.setOption('mode', props.mode)
})
window.addEventListener('resize', debounce(() => {
editor.refresh()
}))
})
</script>
<style>
.editor {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
}
.CodeMirror {
font-family: "Source Code Pro", monospace;
height: 100%;
}
</style>

View File

@@ -0,0 +1,506 @@
/* BASICS */
.CodeMirror {
--base: #545281;
--comment: hsl(210, 25%, 60%);
--keyword: #af4ab1;
--variable: #0055d1;
--function: #c25205;
--string: #2ba46d;
--number: #c25205;
--tags: #dd0000;
--qualifier: #ff6032;
--important: var(--string);
direction: ltr;
font-family: var(--font-code);
height: auto;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: transparent;
white-space: nowrap;
}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: var(--comment);
white-space: nowrap;
opacity: 0.6;
}
.CodeMirror-guttermarker {
color: black;
}
.CodeMirror-guttermarker-subtle {
color: #999;
}
/* FOLD GUTTER */
.CodeMirror-foldmarker {
color: #414141;
text-shadow: #ff9966 1px 1px 2px, #ff9966 -1px -1px 2px, #ff9966 1px -1px 2px,
#ff9966 -1px 1px 2px;
font-family: arial;
line-height: 0.3;
cursor: pointer;
}
.CodeMirror-foldgutter {
width: 0.7em;
}
.CodeMirror-foldgutter-open,
.CodeMirror-foldgutter-folded {
cursor: pointer;
}
.CodeMirror-foldgutter-open:after,
.CodeMirror-foldgutter-folded:after {
content: '>';
font-size: 0.8em;
opacity: 0.8;
transition: transform 0.2s;
display: inline-block;
top: -0.1em;
position: relative;
transform: rotate(90deg);
}
.CodeMirror-foldgutter-folded:after {
transform: none;
}
/* CURSOR */
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
border: 0 !important;
background: #7e7;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor-mark {
background-color: rgba(20, 255, 20, 0.5);
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
}
.cm-animate-fat-cursor {
width: auto;
border: 0;
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
background-color: #7e7;
}
@-moz-keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
@-webkit-keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
@keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
.cm-tab {
display: inline-block;
text-decoration: inherit;
}
.CodeMirror-rulers {
position: absolute;
left: 0;
right: 0;
top: -50px;
bottom: -20px;
overflow: hidden;
}
.CodeMirror-ruler {
border-left: 1px solid #ccc;
top: 0;
bottom: 0;
position: absolute;
}
/* DEFAULT THEME */
.cm-s-default.CodeMirror {
background-color: transparent;
}
.cm-s-default .cm-header {
color: blue;
}
.cm-s-default .cm-quote {
color: #090;
}
.cm-negative {
color: #d44;
}
.cm-positive {
color: #292;
}
.cm-header,
.cm-strong {
font-weight: bold;
}
.cm-em {
font-style: italic;
}
.cm-link {
text-decoration: underline;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-s-default .cm-atom,
.cm-s-default .cm-def,
.cm-s-default .cm-property,
.cm-s-default .cm-variable-2,
.cm-s-default .cm-variable-3,
.cm-s-default .cm-punctuation {
color: var(--base);
}
.cm-s-default .cm-hr,
.cm-s-default .cm-comment {
color: var(--comment);
}
.cm-s-default .cm-attribute,
.cm-s-default .cm-keyword {
color: var(--keyword);
}
.cm-s-default .cm-variable {
color: var(--variable);
}
.cm-s-default .cm-bracket,
.cm-s-default .cm-tag {
color: var(--tags);
}
.cm-s-default .cm-number {
color: var(--number);
}
.cm-s-default .cm-string,
.cm-s-default .cm-string-2 {
color: var(--string);
}
.cm-s-default .cm-type {
color: #085;
}
.cm-s-default .cm-meta {
color: #555;
}
.cm-s-default .cm-qualifier {
color: var(--qualifier);
}
.cm-s-default .cm-builtin {
color: #7539ff;
}
.cm-s-default .cm-link {
color: var(--flash);
}
.cm-s-default .cm-error {
color: #ff008c;
}
.cm-invalidchar {
color: #ff008c;
}
.CodeMirror-composing {
border-bottom: 2px solid;
}
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {
color: #0b0;
}
div.CodeMirror span.CodeMirror-nonmatchingbracket {
color: #a22;
}
.CodeMirror-matchingtag {
background: rgba(255, 150, 0, 0.3);
}
.CodeMirror-activeline-background {
background: #e8f2ff;
}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
position: relative;
overflow: hidden;
background: white;
}
.CodeMirror-scroll {
overflow: scroll !important; /* Things will break if this is overridden */
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px;
margin-right: -30px;
padding-bottom: 30px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
}
.CodeMirror-sizer {
position: relative;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
right: 0;
top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0;
left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0;
bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0;
bottom: 0;
}
.CodeMirror-gutters {
position: absolute;
left: 0;
top: 0;
min-height: 100%;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -30px;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0;
bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-gutter-wrapper ::selection {
background-color: transparent;
}
.CodeMirror-gutter-wrapper ::-moz-selection {
background-color: transparent;
}
.CodeMirror-lines {
cursor: text;
min-height: 1px; /* prevents collapsing before first draw */
}
.CodeMirror pre {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0;
-webkit-border-radius: 0;
border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-linebackground {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-rtl pre {
direction: rtl;
}
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-cursor {
position: absolute;
pointer-events: none;
}
.CodeMirror-measure pre {
position: static;
}
div.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-focused .CodeMirror-selected {
background: #d7d4f0;
}
.CodeMirror-crosshair {
cursor: crosshair;
}
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: #d7d4f0;
}
.CodeMirror-line::-moz-selection,
.CodeMirror-line > span::-moz-selection,
.CodeMirror-line > span > span::-moz-selection {
background: #d7d4f0;
}
.cm-searching {
background-color: #ffa;
background-color: rgba(255, 255, 0, 0.4);
}
/* Used to force a border model for a node */
.cm-force-border {
padding-right: 0.1px;
}
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack:after {
content: '';
}
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext {
background: none;
}

View File

@@ -0,0 +1,19 @@
import CodeMirror from 'codemirror'
import './codemirror.css'
// modes
import 'codemirror/mode/javascript/javascript.js'
import 'codemirror/mode/css/css.js'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
// addons
import 'codemirror/addon/edit/closebrackets.js'
import 'codemirror/addon/edit/closetag.js'
import 'codemirror/addon/comment/comment.js'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/addon/fold/indent-fold.js'
import 'codemirror/addon/fold/comment-fold.js'
export default CodeMirror

View File

@@ -0,0 +1,17 @@
<template>
<CodeMirror @change="onChange" :value="initialCode" />
<Message :err="store.errors[0]" />
</template>
<script setup lang="ts">
import CodeMirror from '../codemirror/CodeMirror.vue'
import Message from '../Message.vue'
import { store } from '../store'
import { debounce } from '../utils'
const onChange = debounce((code: string) => {
store.code = code
}, 250)
const initialCode = store.code
</script>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

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>

View File

@@ -0,0 +1,168 @@
import { reactive, watchEffect } from 'vue'
import {
parse,
compileTemplate,
compileStyleAsync,
compileScript,
rewriteDefault,
CompilerError
} from '@vue/compiler-sfc'
const storeKey = 'sfc-code'
const saved = localStorage.getItem(storeKey) || ''
// @ts-ignore
export const sandboxVueURL = import.meta.env.PROD
? '/vue.runtime.esm-browser.js' // to be copied on build
: '/src/vue-dev-proxy'
export const store = reactive({
code: saved,
compiled: {
executed: '',
js: '',
css: '',
template: ''
},
errors: [] as (string | CompilerError | SyntaxError)[]
})
const filename = 'Playground.vue'
const id = 'scope-id'
const compIdentifier = `__comp`
watchEffect(async () => {
const { code, compiled } = store
if (!code.trim()) {
return
}
localStorage.setItem(storeKey, code)
const { errors, descriptor } = parse(code, { filename, sourceMap: true })
if (errors.length) {
store.errors = errors
return
}
const hasScoped = descriptor.styles.some(s => s.scoped)
let finalCode = ''
if (
(descriptor.script && descriptor.script.lang) ||
(descriptor.scriptSetup && descriptor.scriptSetup.lang) ||
descriptor.styles.some(s => s.lang) ||
(descriptor.template && descriptor.template.lang)
) {
store.errors = [
'lang="x" pre-processors are not supported in the in-browser playground.'
]
return
}
// script
if (descriptor.script || descriptor.scriptSetup) {
try {
const compiledScript = compileScript(descriptor, {
id,
refSugar: true,
inlineTemplate: true
})
compiled.js = compiledScript.content.trim()
finalCode +=
`\n` +
rewriteDefault(
rewriteVueImports(compiledScript.content),
compIdentifier
)
} catch (e) {
store.errors = [e]
return
}
} else {
compiled.js = ''
finalCode += `\nconst ${compIdentifier} = {}`
}
// template
if (descriptor.template && !descriptor.scriptSetup) {
const templateResult = compileTemplate({
source: descriptor.template.content,
filename,
id,
scoped: hasScoped,
slotted: descriptor.slotted,
isProd: false
})
if (templateResult.errors.length) {
store.errors = templateResult.errors
return
}
compiled.template = templateResult.code.trim()
finalCode += rewriteVueImports(templateResult.code).replace(
/\nexport (function|const) render/,
'$1 render'
)
finalCode += `\n${compIdentifier}.render = render`
} else {
compiled.template = descriptor.scriptSetup
? '/* inlined in JS (script setup) */'
: '/* no template present */'
}
if (hasScoped) {
finalCode += `\n${compIdentifier}.__scopeId = ${JSON.stringify(
`data-v-${id}`
)}`
}
// styles
let css = ''
for (const style of descriptor.styles) {
if (style.module) {
// TODO error
continue
}
const styleResult = await compileStyleAsync({
source: style.content,
filename,
id,
scoped: style.scoped,
modules: !!style.module
})
if (styleResult.errors.length) {
// postcss uses pathToFileURL which isn't polyfilled in the browser
// ignore these errors for now
if (!styleResult.errors[0].message.includes('pathToFileURL')) {
store.errors = styleResult.errors
}
// proceed even if css compile errors
} else {
css += styleResult.code + '\n'
}
}
if (css) {
compiled.css = css.trim()
finalCode += `\ndocument.getElementById('__sfc-styles').innerHTML = ${JSON.stringify(
css
)}`
} else {
compiled.css = ''
}
store.errors = []
if (finalCode) {
compiled.executed =
`/* Exact code being executed in the preview iframe (different from production bundler output) */\n` +
finalCode
}
})
// TODO use proper parser
function rewriteVueImports(code: string): string {
return code.replace(
/\b(import \{.*?\}\s+from\s+)(?:"vue"|'vue')/g,
`$1"${sandboxVueURL}"`
)
}

View File

@@ -0,0 +1,9 @@
export function debounce(fn: Function, n = 100) {
let handle: any
return (...args: any[]) => {
if (handle) clearTimeout(handle)
handle = setTimeout(() => {
fn(...args)
}, n)
}
}

View File

@@ -0,0 +1,2 @@
// serve vue to the iframe sandbox during dev.
export * from 'vue'