diff --git a/docs/public/editor/editor.js b/docs/public/editor/editor.js index 7727a7995e3..119883fbe9b 100644 --- a/docs/public/editor/editor.js +++ b/docs/public/editor/editor.js @@ -2,51 +2,57 @@ // gh-aw Playground - Application Logic // ================================================================ +import { EditorView, basicSetup } from 'https://esm.sh/codemirror@6.0.2'; +import { EditorState, Compartment } from 'https://esm.sh/@codemirror/state@6.5.4'; +import { keymap } from 'https://esm.sh/@codemirror/view@6.39.14'; +import { yaml } from 'https://esm.sh/@codemirror/lang-yaml@6.1.2'; +import { markdown } from 'https://esm.sh/@codemirror/lang-markdown@6.5.0'; +import { indentUnit } from 'https://esm.sh/@codemirror/language@6.12.1'; +import { oneDark } from 'https://esm.sh/@codemirror/theme-one-dark@6.1.3'; import { createWorkerCompiler } from '/gh-aw/wasm/compiler-loader.js'; // --------------------------------------------------------------- // Default workflow content // --------------------------------------------------------------- -const DEFAULT_CONTENT = `--- -name: hello-world -description: A simple hello world workflow -on: - workflow_dispatch: -engine: copilot ---- - -# Mission - -Say hello to the world! Check the current date and time, and greet the user warmly. -`; +const DEFAULT_CONTENT = [ + '---', + 'name: hello-world', + 'description: A simple hello world workflow', + 'on:', + ' workflow_dispatch:', + 'engine: copilot', + '---', + '', + '# Mission', + '', + 'Say hello to the world! Check the current date and time, and greet the user warmly.', + '' +].join('\n'); // --------------------------------------------------------------- // DOM Elements // --------------------------------------------------------------- const $ = (id) => document.getElementById(id); -const editor = $('editor'); -const outputPre = $('outputPre'); +const editorMount = $('editorMount'); const outputPlaceholder = $('outputPlaceholder'); +const outputMount = $('outputMount'); +const outputContainer = $('outputContainer'); const compileBtn = $('compileBtn'); -const copyBtn = $('copyBtn'); const statusBadge = $('statusBadge'); const statusText = $('statusText'); +const statusDot = $('statusDot'); const loadingOverlay = $('loadingOverlay'); const errorBanner = $('errorBanner'); const errorText = $('errorText'); const warningBanner = $('warningBanner'); const warningText = $('warningText'); -const lineNumbers = $('lineNumbers'); -const lineNumbersInner = $('lineNumbersInner'); const themeToggle = $('themeToggle'); const toggleTrack = $('toggleTrack'); const divider = $('divider'); const panelEditor = $('panelEditor'); const panelOutput = $('panelOutput'); const panels = $('panels'); -const tabBar = $('tabBar'); -const tabAdd = $('tabAdd'); // --------------------------------------------------------------- // State @@ -58,36 +64,91 @@ let autoCompile = true; let compileTimer = null; let currentYaml = ''; -// File tabs state: ordered list of { name, content } -const MAIN_FILE = 'workflow.md'; -let files = [{ name: MAIN_FILE, content: DEFAULT_CONTENT }]; -let activeTab = MAIN_FILE; - // --------------------------------------------------------------- -// Theme +// Theme (uses Primer's data-color-mode) // --------------------------------------------------------------- +const editorThemeConfig = new Compartment(); +const outputThemeConfig = new Compartment(); + function getPreferredTheme() { const saved = localStorage.getItem('gh-aw-playground-theme'); if (saved) return saved; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } +function cmThemeFor(theme) { + return theme === 'dark' ? oneDark : []; +} + function setTheme(theme) { - document.documentElement.setAttribute('data-theme', theme); + document.documentElement.setAttribute('data-color-mode', theme); localStorage.setItem('gh-aw-playground-theme', theme); const sunIcon = themeToggle.querySelector('.icon-sun'); const moonIcon = themeToggle.querySelector('.icon-moon'); - sunIcon.style.display = theme === 'dark' ? 'block' : 'none'; - moonIcon.style.display = theme === 'dark' ? 'none' : 'block'; + if (theme === 'dark') { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + } else { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + } + + // Update CodeMirror themes + const cmTheme = cmThemeFor(theme); + editorView.dispatch({ effects: editorThemeConfig.reconfigure(cmTheme) }); + outputView.dispatch({ effects: outputThemeConfig.reconfigure(cmTheme) }); } +// --------------------------------------------------------------- +// CodeMirror: Input Editor (Markdown with YAML frontmatter) +// --------------------------------------------------------------- +const editorView = new EditorView({ + doc: DEFAULT_CONTENT, + extensions: [ + basicSetup, + markdown(), + EditorState.tabSize.of(2), + indentUnit.of(' '), + editorThemeConfig.of(cmThemeFor(getPreferredTheme())), + keymap.of([{ + key: 'Mod-Enter', + run: () => { doCompile(); return true; } + }]), + EditorView.updateListener.of(update => { + if (update.docChanged && autoCompile && isReady) { + scheduleCompile(); + } + }), + ], + parent: editorMount, +}); + +// --------------------------------------------------------------- +// CodeMirror: Output View (YAML, read-only) +// --------------------------------------------------------------- +const outputView = new EditorView({ + doc: '', + extensions: [ + basicSetup, + yaml(), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + outputThemeConfig.of(cmThemeFor(getPreferredTheme())), + ], + parent: outputMount, +}); + +// --------------------------------------------------------------- +// Apply initial theme + listen for changes +// --------------------------------------------------------------- setTheme(getPreferredTheme()); themeToggle.addEventListener('click', () => { - const current = document.documentElement.getAttribute('data-theme'); + const current = document.documentElement.getAttribute('data-color-mode'); setTheme(current === 'dark' ? 'light' : 'dark'); }); +// Listen for OS theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem('gh-aw-playground-theme')) { setTheme(e.matches ? 'dark' : 'light'); @@ -102,150 +163,40 @@ document.querySelectorAll('.kbd-hint-mac').forEach(el => el.style.display = isMa document.querySelectorAll('.kbd-hint-other').forEach(el => el.style.display = isMac ? 'none' : 'inline'); // --------------------------------------------------------------- -// Status +// Status (uses Primer Label component) // --------------------------------------------------------------- +const STATUS_LABEL_MAP = { + loading: 'Label--accent', + ready: 'Label--success', + compiling: 'Label--accent', + error: 'Label--danger' +}; + function setStatus(status, text) { + // Swap Label modifier class + Object.values(STATUS_LABEL_MAP).forEach(cls => statusBadge.classList.remove(cls)); + statusBadge.classList.add(STATUS_LABEL_MAP[status] || 'Label--secondary'); statusBadge.setAttribute('data-status', status); statusText.textContent = text; -} - -// --------------------------------------------------------------- -// Line numbers -// --------------------------------------------------------------- -function updateLineNumbers() { - const lines = editor.value.split('\n').length; - let html = ''; - for (let i = 1; i <= lines; i++) html += '
' + i + '
'; - lineNumbersInner.innerHTML = html; -} - -function syncLineNumberScroll() { - lineNumbers.scrollTop = editor.scrollTop; -} - -// --------------------------------------------------------------- -// File tabs -// --------------------------------------------------------------- -function getFile(name) { - return files.find(f => f.name === name); -} - -function renderTabs() { - tabBar.querySelectorAll('.tab').forEach(el => el.remove()); - - for (const file of files) { - const tab = document.createElement('div'); - tab.className = 'tab' + (file.name === activeTab ? ' active' : ''); - tab.dataset.name = file.name; - - const label = document.createElement('span'); - label.textContent = file.name; - tab.appendChild(label); - - if (file.name !== MAIN_FILE) { - const close = document.createElement('button'); - close.className = 'tab-close'; - close.title = 'Remove file'; - close.innerHTML = ''; - close.addEventListener('click', (e) => { - e.stopPropagation(); - removeTab(file.name); - }); - tab.appendChild(close); - } - - tab.addEventListener('click', () => switchTab(file.name)); - tabBar.insertBefore(tab, tabAdd); - } -} - -function switchTab(name) { - const current = getFile(activeTab); - if (current) current.content = editor.value; - - activeTab = name; - const file = getFile(name); - if (file) { - editor.value = file.content; - updateLineNumbers(); - } - renderTabs(); -} - -function addTab() { - const name = prompt('File path (e.g. shared/my-tools.md):'); - if (!name || !name.trim()) return; - - const trimmed = name.trim(); - if (getFile(trimmed)) { switchTab(trimmed); return; } - - const defaultImportContent = `--- -# Shared workflow component -# This file can define: tools, steps, engine, mcp-servers, etc. -tools: - - name: example_tool - description: An example tool ---- - -# Instructions - -Add your shared workflow instructions here. -`; - files.push({ name: trimmed, content: defaultImportContent }); - switchTab(trimmed); -} - -function removeTab(name) { - if (name === MAIN_FILE) return; - files = files.filter(f => f.name !== name); - if (activeTab === name) { - switchTab(MAIN_FILE); + // Pulse animation for loading/compiling states + if (status === 'loading' || status === 'compiling') { + statusDot.style.animation = 'pulse 1.2s ease-in-out infinite'; } else { - renderTabs(); + statusDot.style.animation = ''; } - if (autoCompile && isReady) scheduleCompile(); } -tabAdd.addEventListener('click', addTab); - -// --------------------------------------------------------------- -// Editor setup -// --------------------------------------------------------------- -editor.value = DEFAULT_CONTENT; -updateLineNumbers(); -renderTabs(); - -editor.addEventListener('input', () => { - updateLineNumbers(); - const file = getFile(activeTab); - if (file) file.content = editor.value; - if (autoCompile && isReady) scheduleCompile(); -}); - -editor.addEventListener('scroll', syncLineNumberScroll); - -editor.addEventListener('keydown', (e) => { - if (e.key === 'Tab') { - e.preventDefault(); - const start = editor.selectionStart; - const end = editor.selectionEnd; - editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end); - editor.selectionStart = editor.selectionEnd = start + 2; - editor.dispatchEvent(new Event('input')); - } - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - doCompile(); - } -}); - // --------------------------------------------------------------- // Auto-compile toggle // --------------------------------------------------------------- $('autoCompileToggle').addEventListener('click', () => { autoCompile = !autoCompile; - toggleTrack.classList.toggle('active', autoCompile); + if (autoCompile) { + toggleTrack.classList.add('active'); + } else { + toggleTrack.classList.remove('active'); + } }); // --------------------------------------------------------------- @@ -256,65 +207,57 @@ function scheduleCompile() { compileTimer = setTimeout(doCompile, 400); } -function getImportFiles() { - const importFiles = {}; - for (const file of files) { - if (file.name !== MAIN_FILE) importFiles[file.name] = file.content; - } - return Object.keys(importFiles).length > 0 ? importFiles : undefined; -} - async function doCompile() { if (!isReady || isCompiling) return; - if (compileTimer) { clearTimeout(compileTimer); compileTimer = null; } - - // Save current editor content - const currentFile = getFile(activeTab); - if (currentFile) currentFile.content = editor.value; + if (compileTimer) { + clearTimeout(compileTimer); + compileTimer = null; + } - // Get the main workflow content - const mainFile = getFile(MAIN_FILE); - const md = mainFile ? mainFile.content : ''; + const md = editorView.state.doc.toString(); if (!md.trim()) { - outputPre.style.display = 'none'; + outputMount.style.display = 'none'; outputPlaceholder.style.display = 'flex'; outputPlaceholder.textContent = 'Compiled YAML will appear here'; currentYaml = ''; - copyBtn.disabled = true; return; } isCompiling = true; setStatus('compiling', 'Compiling...'); compileBtn.disabled = true; - errorBanner.classList.remove('visible'); - warningBanner.classList.remove('visible'); + + // Hide old banners + errorBanner.classList.add('d-none'); + warningBanner.classList.add('d-none'); try { - const importFiles = getImportFiles(); - const result = await compiler.compile(md, importFiles); + const result = await compiler.compile(md); if (result.error) { setStatus('error', 'Error'); errorText.textContent = result.error; - errorBanner.classList.add('visible'); + errorBanner.classList.remove('d-none'); } else { setStatus('ready', 'Ready'); currentYaml = result.yaml; - outputPre.textContent = result.yaml; - outputPre.style.display = 'block'; + + // Update output CodeMirror view + outputView.dispatch({ + changes: { from: 0, to: outputView.state.doc.length, insert: result.yaml } + }); + outputMount.style.display = 'block'; outputPlaceholder.style.display = 'none'; - copyBtn.disabled = false; if (result.warnings && result.warnings.length > 0) { warningText.textContent = result.warnings.join('\n'); - warningBanner.classList.add('visible'); + warningBanner.classList.remove('d-none'); } } } catch (err) { setStatus('error', 'Error'); errorText.textContent = err.message || String(err); - errorBanner.classList.add('visible'); + errorBanner.classList.remove('d-none'); } finally { isCompiling = false; compileBtn.disabled = !isReady; @@ -323,62 +266,41 @@ async function doCompile() { compileBtn.addEventListener('click', doCompile); -// --------------------------------------------------------------- -// Copy YAML -// --------------------------------------------------------------- -function showCopyFeedback() { - const feedback = $('copyFeedback'); - feedback.classList.add('show'); - setTimeout(() => feedback.classList.remove('show'), 2000); -} - -copyBtn.addEventListener('click', async () => { - if (!currentYaml) return; - try { - await navigator.clipboard.writeText(currentYaml); - } catch { - const ta = document.createElement('textarea'); - ta.value = currentYaml; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } - showCopyFeedback(); -}); - // --------------------------------------------------------------- // Banner close // --------------------------------------------------------------- -$('errorClose').addEventListener('click', () => errorBanner.classList.remove('visible')); -$('warningClose').addEventListener('click', () => warningBanner.classList.remove('visible')); +$('errorClose').addEventListener('click', () => errorBanner.classList.add('d-none')); +$('warningClose').addEventListener('click', () => warningBanner.classList.add('d-none')); // --------------------------------------------------------------- // Draggable divider // --------------------------------------------------------------- let isDragging = false; -function resizePanels(clientX, clientY) { - const rect = panels.getBoundingClientRect(); - const isMobile = window.innerWidth < 768; - const pos = isMobile ? clientY - rect.top : clientX - rect.left; - const size = isMobile ? rect.height : rect.width; - const clamped = Math.max(0.2, Math.min(0.8, pos / size)); - panelEditor.style.flex = `0 0 ${clamped * 100}%`; - panelOutput.style.flex = `0 0 ${(1 - clamped) * 100}%`; -} - divider.addEventListener('mousedown', (e) => { isDragging = true; divider.classList.add('dragging'); - const isMobile = window.innerWidth < 768; - document.body.style.cursor = isMobile ? 'row-resize' : 'col-resize'; + document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { - if (isDragging) resizePanels(e.clientX, e.clientY); + if (!isDragging) return; + const rect = panels.getBoundingClientRect(); + const isMobile = window.innerWidth < 768; + + if (isMobile) { + const fraction = (e.clientY - rect.top) / rect.height; + const clamped = Math.max(0.2, Math.min(0.8, fraction)); + panelEditor.style.flex = `0 0 ${clamped * 100}%`; + panelOutput.style.flex = `0 0 ${(1 - clamped) * 100}%`; + } else { + const fraction = (e.clientX - rect.left) / rect.width; + const clamped = Math.max(0.2, Math.min(0.8, fraction)); + panelEditor.style.flex = `0 0 ${clamped * 100}%`; + panelOutput.style.flex = `0 0 ${(1 - clamped) * 100}%`; + } }); document.addEventListener('mouseup', () => { @@ -390,6 +312,7 @@ document.addEventListener('mouseup', () => { } }); +// Touch support for mobile divider divider.addEventListener('touchstart', (e) => { isDragging = true; divider.classList.add('dragging'); @@ -397,7 +320,22 @@ divider.addEventListener('touchstart', (e) => { }); document.addEventListener('touchmove', (e) => { - if (isDragging) resizePanels(e.touches[0].clientX, e.touches[0].clientY); + if (!isDragging) return; + const touch = e.touches[0]; + const rect = panels.getBoundingClientRect(); + const isMobile = window.innerWidth < 768; + + if (isMobile) { + const fraction = (touch.clientY - rect.top) / rect.height; + const clamped = Math.max(0.2, Math.min(0.8, fraction)); + panelEditor.style.flex = `0 0 ${clamped * 100}%`; + panelOutput.style.flex = `0 0 ${(1 - clamped) * 100}%`; + } else { + const fraction = (touch.clientX - rect.left) / rect.width; + const clamped = Math.max(0.2, Math.min(0.8, fraction)); + panelEditor.style.flex = `0 0 ${clamped * 100}%`; + panelOutput.style.flex = `0 0 ${(1 - clamped) * 100}%`; + } }); document.addEventListener('touchend', () => { @@ -422,11 +360,14 @@ async function init() { compileBtn.disabled = false; loadingOverlay.classList.add('hidden'); - if (autoCompile) doCompile(); + // Auto-compile the default content + if (autoCompile) { + doCompile(); + } } catch (err) { setStatus('error', 'Failed to load'); - loadingOverlay.querySelector('.loading-text').textContent = 'Failed to load compiler'; - loadingOverlay.querySelector('.loading-subtext').textContent = err.message; + loadingOverlay.querySelector('.f4').textContent = 'Failed to load compiler'; + loadingOverlay.querySelector('.f6').textContent = err.message; loadingOverlay.querySelector('.loading-spinner').style.display = 'none'; } } diff --git a/docs/public/editor/index.html b/docs/public/editor/index.html index 6ad25d9144b..981e57a6972 100644 --- a/docs/public/editor/index.html +++ b/docs/public/editor/index.html @@ -286,382 +286,6 @@ - +