Add codesandbox

This commit is contained in:
Jim Hamill
2025-04-07 05:55:06 +00:00
parent 5623b9b27c
commit afce97e34c
9 changed files with 866 additions and 29 deletions
@@ -0,0 +1,462 @@
<template>
<div class="code-sandbox" :style="sandboxStyle">
<div class="editor-container" :style="editorContainerStyle">
<v-ace-editor
v-model:value="code"
:style="editorStyle"
lang="javascript"
:theme="isDark ? 'github_dark' : 'github'"
@init="editorInit"
:options="{
showGutter: true,
minLines: 1,
maxLines: props.fullHeight ? null : 20,
showPrintMargin: false
}"
/>
<div class="editor-buttons">
<button v-if="!hideRunButton" @click="executeCode" class="primary-button">Run Code</button>
</div>
</div>
<div v-if="showLogs || fullHeight" class="output-container" :style="outputContainerStyle">
<div class="output-container-buttons">
<button v-if="!fullHeight" @click="showLogs = false" class="secondary-button">Close</button>
</div>
<div class="console-output" :style="consoleOutputStyle">
<div v-for="(log, index) in logs" :key="index" :class="['log-entry', log.type]">
<span class="log-prefix">[{{ log.type }}]:</span> {{ log.content }}
</div>
<div v-if="logs.length === 0" class="empty-state">
No console output yet. Run your code to see results here.
</div>
</div>
</div>
<div class="iframe-container" style="display:none">
<iframe
ref="sandboxIframe"
sandbox="allow-scripts allow-same-origin allow-modals allow-forms allow-popups"
allow="accelerometer; camera; encrypted-media; geolocation; gyroscope; microphone; midi"
class="sandbox-iframe"
></iframe>
</div>
<!-- Hidden slot to capture the default code -->
<!-- We need this because we want to place our code in slots -->
<div ref="codeRef" style="display: none;">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress';
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { VAceEditor } from 'vue3-ace-editor';
import ace from 'ace-builds';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-github_dark';
import 'ace-builds/src-noconflict/mode-javascript';
// Import the iframe HTML template
// NOTE: Having this in an external file is many-fold neater than stashing it all in here.
import iframeTemplateHtml from './CodeSandboxTemplate.html?raw';
const { isDark } = useData();
// Define props for the component
const props = defineProps({
enableNetwork: {
type: Boolean,
default: true
},
fullHeight: {
type: Boolean,
default: false
},
height: {
type: String,
default: 'auto'
},
editorHeight: {
type: String,
default: 'auto'
},
outputHeight: {
type: String,
default: 'auto'
},
hideRunButton: {
type: Boolean,
default: false,
}
});
// Define types for console logs
interface ConsoleLog {
type: 'log' | 'info' | 'warn' | 'error';
content: string;
}
// Define share URL base
const SHARE_BASE_URL = 'https://stack.xo.cash/playground.html';
// Define a unique ID to pass into the IFrame.
// NOTE: We do this so that we can reliably identify where the postMessage came from.
// If we do not do this and have multiple instances of CodeSandbox, then they will cross-chatter.
const thisSandboxID = generateSandboxId();
// State
const codeRef = ref(null);
const code = ref<string>('');
const logs = ref<ConsoleLog[]>([]);
const theme = ref('github_dark');
const showLogs = ref(false);
const sandboxIframe = ref<HTMLIFrameElement | null>(null);
// Computed styles based on props
const sandboxStyle = computed(() => ({
height: props.fullHeight ? '100%' : props.height,
display: 'flex',
flexDirection: 'column'
}));
const editorContainerStyle = computed(() => ({
height: props.fullHeight ? props.editorHeight !== 'auto' ? props.editorHeight : '50%' : 'auto',
flex: props.fullHeight && props.editorHeight === 'auto' ? '1 0 auto' : 'none',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden' // Ensure overflowing content doesn't spill out
}));
const editorStyle = computed(() => ({
width: '100%',
height: '100%', // Fill container height
minHeight: props.fullHeight ? '0' : '3em'
}));
const outputContainerStyle = computed(() => ({
height: props.fullHeight ? props.outputHeight !== 'auto' ? props.outputHeight : 'auto' : 'auto',
flex: props.fullHeight && props.outputHeight === 'auto' ? '1 0 auto' : 'none',
display: 'flex',
flexDirection: 'column'
}));
const consoleOutputStyle = computed(() => ({
flex: props.fullHeight ? '1 1 auto' : 'none',
minHeight: props.fullHeight ? '0' : '120px',
maxHeight: props.fullHeight ? 'none' : '250px'
}));
// Methods
const clearLogs = () => {
logs.value = [];
};
function editorInit(editor: any) {
editor.session.setTabSize(2);
editor.renderer.setScrollMargin(5, 5, 0, 0);
// Make sure the editor properly reflects its container size
editor.renderer.updateFull();
}
// Generate a shareable URL with the current code
const shareCode = () => {
try {
// Convert the code to Base64 to make it more compact
// First, we get a UTF-8 string, then encode to Base64
const base64Code = btoa(unescape(encodeURIComponent(code.value)));
// Generate the shareable URL with the Base64 encoded content in the hash fragment
const shareUrl = `${SHARE_BASE_URL}#code=${encodeURIComponent(base64Code)}`;
// Check URL length - hash fragments can be longer, but still have limits
if (shareUrl.length > 10000) {
logs.value.push({
type: 'warn',
content: `URL is ${shareUrl.length} characters long, which is extremely long and may not work in some environments. Consider shortening your code.`
});
} else if (shareUrl.length > 4000) {
logs.value.push({
type: 'info',
content: `URL is ${shareUrl.length} characters long. While this should work in most modern browsers, it might be problematic in some environments.`
});
}
// Copy to clipboard
navigator.clipboard.writeText(shareUrl)
.then(() => {
logs.value.push({
type: 'info',
content: 'Shareable URL copied to clipboard!'
});
showLogs.value = true;
})
.catch(err => {
console.error('Failed to copy URL: ', err);
logs.value.push({
type: 'error',
content: 'Failed to copy URL to clipboard. URL is: ' + shareUrl
});
showLogs.value = true;
});
} catch (error) {
logs.value.push({
type: 'error',
content: 'Failed to generate shareable URL: ' + error
});
showLogs.value = true;
}
};
// Create HTML content for the iframe by replacing the placeholder with user code
const createIframeContent = (sandboxID: string, userCode: string): string => {
return iframeTemplateHtml.replace('__userCode__;', userCode).replaceAll('__sandboxID__', sandboxID);
};
function generateSandboxId(length: number = 6): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// Execute the code in the iframe
const executeCode = () => {
clearLogs();
showLogs.value = true;
if (sandboxIframe.value) {
// Add an allow-scripts attribute dynamically if network is enabled
if (props.enableNetwork) {
sandboxIframe.value.sandbox.add('allow-scripts');
sandboxIframe.value.sandbox.add('allow-same-origin');
sandboxIframe.value.sandbox.add('allow-modals');
sandboxIframe.value.sandbox.add('allow-forms');
sandboxIframe.value.sandbox.add('allow-popups');
}
// Create the HTML content
const iframeContent = createIframeContent(thisSandboxID, code.value);
// Create a Blob containing the HTML content
const blob = new Blob([iframeContent], { type: 'text/html' });
// Create a URL for the Blob
const blobUrl = URL.createObjectURL(blob);
// Set the iframe's src to the Blob URL
sandboxIframe.value.src = blobUrl;
// Clean up the Blob URL after the iframe has loaded
sandboxIframe.value.onload = () => {
URL.revokeObjectURL(blobUrl);
// Log info about module support
if (props.enableNetwork) {
logs.value.push({
type: 'info',
content: 'ESM imports enabled - you can use external libraries'
});
}
};
// Handle load errors
sandboxIframe.value.onerror = () => {
logs.value.push({
type: 'error',
content: 'Failed to load sandbox environment'
});
};
}
};
// Handle messages from the iframe
const handleMessage = (event: MessageEvent) => {
const { sandboxID, type, content } = event.data;
// Make sure the message has the expected format
if (sandboxID && type && content) {
// Make sure the messsage was targetting this Sandbox specifically.
if(sandboxID !== thisSandboxID) {
return;
}
logs.value.push({
type: type as 'log' | 'info' | 'warn' | 'error',
content
});
}
};
// Lifecycle hooks
onMounted(() => {
// Check if there's code in the URL hash fragment
if (window.location.hash && window.location.hash.startsWith('#code=')) {
const codeParam = window.location.hash.substring(6); // Remove '#code='
// If code exists in URL hash, use it
try {
// Always assume Base64 encoding
code.value = decodeURIComponent(escape(atob(decodeURIComponent(codeParam))));
} catch (error) {
console.error('Failed to decode URL hash parameter:', error);
logs.value.push({
type: 'error',
content: 'Failed to decode URL hash parameter: ' + error
});
// Fallback to slot content if URL decoding fails
loadCodeFromSlot();
}
} else {
// Otherwise check query parameters (for backward compatibility)
const urlParams = new URLSearchParams(window.location.search);
const queryCodeParam = urlParams.get('code');
if (queryCodeParam) {
try {
// Try to decode from Base64
code.value = decodeURIComponent(escape(atob(decodeURIComponent(queryCodeParam))));
} catch (error) {
console.error('Failed to decode URL query parameter:', error);
// Fallback to slot content
loadCodeFromSlot();
}
} else {
// No code in hash or query, load from slot
loadCodeFromSlot();
}
}
// Add event listener for messages from the iframe
window.addEventListener('message', handleMessage);
});
// Function to load code from slot
const loadCodeFromSlot = () => {
if (codeRef.value) {
const template = codeRef.value;
if (template) {
code.value = template.innerText.trim();
}
}
};
onBeforeUnmount(() => {
// Clean up event listener
window.removeEventListener('message', handleMessage);
});
// Expose our functions so that they can be called from parent components.
defineExpose({
executeCode,
shareCode
});
</script>
<style>
.code-sandbox {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
margin-bottom: 1em;
}
.editor-container {
position: relative;
background: var(--vp-code-block-bg);
padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden; /* Contain overflow */
}
.editor-container .ace_editor {
background: transparent !important;
font-family: var(--vp-font-family-mono);
line-height: var(--vp-code-line-height);
font-size: var(--vp-code-font-size);
flex: 1 1 auto; /* Allow editor to grow */
overflow: auto; /* Enable scrolling */
min-height: 3em;
}
.editor-container .ace_editor .ace_gutter {
background: transparent !important;
}
.editor-buttons {
position: absolute;
padding:10px;
bottom: 0px;
right: 0px;
display: flex;
gap: 8px;
}
.output-container {
position: relative;
background-color: var(--vp-code-block-bg);
border-top: 1px solid var(--vp-button-brand-bg);
}
.output-container-buttons {
position: absolute;
padding:10px;
top: 0px;
right: 0px;
}
.console-output {
min-height: 120px;
max-height: 250px;
overflow-y: auto;
padding: 8px;
font-family: var(--vp-font-family-mono);
line-height: var(--vp-code-line-height);
font-size: 12px;
}
.log-entry {
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.log {
color: var(--vp-c-text-1);
}
.info {
color: #0066cc;
}
.warn {
color: #ff9900;
background-color: rgba(255, 153, 0, 0.1);
}
.error {
color: #cc0000;
background-color: rgba(204, 0, 0, 0.1);
}
.log-prefix {
font-weight: bold;
}
.empty-state {
color: #999;
font-style: italic;
text-align: center;
padding: 24px 0;
}
.sandbox-iframe {
width: 0;
height: 0;
border: 0;
}
</style>
@@ -0,0 +1,122 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Code Sandbox</title>
<script>
// Override console methods to capture and post messages to parent
const originalConsole = window.console;
// Define console overrides
window.console = {
log: function (...args) {
originalConsole.log(...args);
window.parent.postMessage(
{
sandboxID: '__sandboxID__',
type: 'log',
content: args
.map((arg) =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg),
)
.join(' '),
},
'*',
);
},
info: function (...args) {
originalConsole.info(...args);
window.parent.postMessage(
{
sandboxID: '__sandboxID__',
type: 'info',
content: args
.map((arg) =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg),
)
.join(' '),
},
'*',
);
},
warn: function (...args) {
originalConsole.warn(...args);
window.parent.postMessage(
{
sandboxID: '__sandboxID__',
type: 'warn',
content: args
.map((arg) =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg),
)
.join(' '),
},
'*',
);
},
error: function (...args) {
originalConsole.error(...args);
window.parent.postMessage(
{
sandboxID: '__sandboxID__',
type: 'error',
content: args
.map((arg) =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg),
)
.join(' '),
},
'*',
);
},
};
// Catch global errors
window.onerror = function (message, source, lineno, colno, error) {
window.parent.postMessage(
{
sandboxID: '__sandboxID__',
type: 'error',
content: 'Error: ' + message,
},
'*',
);
return true; // Prevents the error from appearing in the iframe's console
};
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', function (event) {
window.parent.postMessage(
{
type: 'error',
content: 'Unhandled Promise Rejection: ' + event.reason,
},
'*',
);
});
</script>
</head>
<body>
<!-- The code runs in this sandbox environment -->
<!-- Module script for ESM support -->
<script type="module">
// Helper function to handle both dynamic and static imports
async function runUserCode() {
try {
// Execute the user code
__userCode__;
} catch (error) {
console.error(error.message);
}
}
runUserCode().catch((error) => {
console.error('Error in user code execution:', error.message);
});
</script>
</body>
</html>
@@ -0,0 +1,104 @@
<template>
<div id="playground">
<div class="top">
<div class="logo">
<img class="light-only" src="/logo-light.svg" alt="XO Stack Logo">
<img class="dark-only" src="/logo-dark.svg" alt="XO Stack Logo">
XO Stack - Playground
</div>
<div class="button-container">
<button class="primary-button" @click="shareCode">Share</button>
<button class="primary-button" @click="executeCode">Run Code</button>
</div>
</div>
<CodeSandbox
ref="sandbox"
height="100vh"
editorHeight="80%"
outputHeight="20%"
fullHeight
hideRunButton
>
<slot></slot>
</CodeSandbox>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useData } from 'vitepress';
import CodeSandbox from '@theme/components/CodeSandbox.vue'
const { isDark } = useData();
const sandbox = ref<InstanceType<typeof CodeSandbox> | null>(null);
async function executeCode() {
sandbox.value.executeCode();
}
async function shareCode() {
sandbox.value.shareCode()
}
</script>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
font-family: Arial, sans-serif;
}
#playground {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.top {
display: flex;
align-items: center;
padding: 10px;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-button-brand-bg);
}
.logo {
display: flex; align-items: center;
font-size: 16px;
margin-right: auto;
font-family: var(--vp-font-family-base);
}
.logo img {
margin-right: 10px;
height: 50px;
}
.row {
display: flex;
flex: 1;
overflow: hidden;
}
.left {
width: 80%;
height: 100%;
border-right: 1px solid #ddd;
}
.right {
width: 20%;
height: 100%;
overflow: auto;
padding: 10px;
box-sizing: border-box;
background-color: #1e1e1e;
color: #ddd;
font-family: 'Consolas', monospace;
}
#monaco-editor-container {
width: 100%;
height: 100%;
}
.console-output {
white-space: pre-wrap;
}
</style>