@stevejtrettel/shader-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +391 -0
  2. package/bin/cli.js +389 -0
  3. package/dist-lib/app/App.d.ts +134 -0
  4. package/dist-lib/app/App.d.ts.map +1 -0
  5. package/dist-lib/app/App.js +570 -0
  6. package/dist-lib/app/types.d.ts +32 -0
  7. package/dist-lib/app/types.d.ts.map +1 -0
  8. package/dist-lib/app/types.js +6 -0
  9. package/dist-lib/editor/EditorPanel.d.ts +39 -0
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -0
  11. package/dist-lib/editor/EditorPanel.js +274 -0
  12. package/dist-lib/editor/prism-editor.css +99 -0
  13. package/dist-lib/editor/prism-editor.d.ts +19 -0
  14. package/dist-lib/editor/prism-editor.d.ts.map +1 -0
  15. package/dist-lib/editor/prism-editor.js +96 -0
  16. package/dist-lib/embed.d.ts +17 -0
  17. package/dist-lib/embed.d.ts.map +1 -0
  18. package/dist-lib/embed.js +35 -0
  19. package/dist-lib/engine/ShadertoyEngine.d.ts +160 -0
  20. package/dist-lib/engine/ShadertoyEngine.d.ts.map +1 -0
  21. package/dist-lib/engine/ShadertoyEngine.js +704 -0
  22. package/dist-lib/engine/glHelpers.d.ts +79 -0
  23. package/dist-lib/engine/glHelpers.d.ts.map +1 -0
  24. package/dist-lib/engine/glHelpers.js +298 -0
  25. package/dist-lib/engine/types.d.ts +77 -0
  26. package/dist-lib/engine/types.d.ts.map +1 -0
  27. package/dist-lib/engine/types.js +7 -0
  28. package/dist-lib/index.d.ts +12 -0
  29. package/dist-lib/index.d.ts.map +1 -0
  30. package/dist-lib/index.js +9 -0
  31. package/dist-lib/layouts/DefaultLayout.d.ts +17 -0
  32. package/dist-lib/layouts/DefaultLayout.d.ts.map +1 -0
  33. package/dist-lib/layouts/DefaultLayout.js +27 -0
  34. package/dist-lib/layouts/FullscreenLayout.d.ts +17 -0
  35. package/dist-lib/layouts/FullscreenLayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/FullscreenLayout.js +27 -0
  37. package/dist-lib/layouts/SplitLayout.d.ts +26 -0
  38. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -0
  39. package/dist-lib/layouts/SplitLayout.js +61 -0
  40. package/dist-lib/layouts/TabbedLayout.d.ts +38 -0
  41. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -0
  42. package/dist-lib/layouts/TabbedLayout.js +305 -0
  43. package/dist-lib/layouts/index.d.ts +24 -0
  44. package/dist-lib/layouts/index.d.ts.map +1 -0
  45. package/dist-lib/layouts/index.js +36 -0
  46. package/dist-lib/layouts/split.css +196 -0
  47. package/dist-lib/layouts/tabbed.css +345 -0
  48. package/dist-lib/layouts/types.d.ts +48 -0
  49. package/dist-lib/layouts/types.d.ts.map +1 -0
  50. package/dist-lib/layouts/types.js +4 -0
  51. package/dist-lib/main.d.ts +15 -0
  52. package/dist-lib/main.d.ts.map +1 -0
  53. package/dist-lib/main.js +102 -0
  54. package/dist-lib/project/generatedLoader.d.ts +3 -0
  55. package/dist-lib/project/generatedLoader.d.ts.map +1 -0
  56. package/dist-lib/project/generatedLoader.js +17 -0
  57. package/dist-lib/project/loadProject.d.ts +22 -0
  58. package/dist-lib/project/loadProject.d.ts.map +1 -0
  59. package/dist-lib/project/loadProject.js +350 -0
  60. package/dist-lib/project/loaderHelper.d.ts +7 -0
  61. package/dist-lib/project/loaderHelper.d.ts.map +1 -0
  62. package/dist-lib/project/loaderHelper.js +240 -0
  63. package/dist-lib/project/types.d.ts +192 -0
  64. package/dist-lib/project/types.d.ts.map +1 -0
  65. package/dist-lib/project/types.js +7 -0
  66. package/dist-lib/styles/base.css +29 -0
  67. package/package.json +48 -0
  68. package/src/app/App.ts +699 -0
  69. package/src/app/app.css +208 -0
  70. package/src/app/types.ts +36 -0
  71. package/src/editor/EditorPanel.ts +340 -0
  72. package/src/editor/editor-panel.css +175 -0
  73. package/src/editor/prism-editor.css +99 -0
  74. package/src/editor/prism-editor.ts +124 -0
  75. package/src/embed.ts +55 -0
  76. package/src/engine/ShadertoyEngine.ts +929 -0
  77. package/src/engine/glHelpers.ts +432 -0
  78. package/src/engine/types.ts +118 -0
  79. package/src/index.ts +13 -0
  80. package/src/layouts/DefaultLayout.ts +40 -0
  81. package/src/layouts/FullscreenLayout.ts +40 -0
  82. package/src/layouts/SplitLayout.ts +81 -0
  83. package/src/layouts/TabbedLayout.ts +371 -0
  84. package/src/layouts/default.css +22 -0
  85. package/src/layouts/fullscreen.css +15 -0
  86. package/src/layouts/index.ts +44 -0
  87. package/src/layouts/split.css +196 -0
  88. package/src/layouts/tabbed.css +345 -0
  89. package/src/layouts/types.ts +58 -0
  90. package/src/main.ts +114 -0
  91. package/src/project/generatedLoader.ts +23 -0
  92. package/src/project/loadProject.ts +421 -0
  93. package/src/project/loaderHelper.ts +300 -0
  94. package/src/project/types.ts +243 -0
  95. package/src/styles/base.css +29 -0
  96. package/src/styles/embed.css +14 -0
  97. package/src/vite-env.d.ts +1 -0
  98. package/templates/index.html +28 -0
  99. package/templates/main.ts +126 -0
  100. package/templates/package.json +12 -0
  101. package/templates/shaders/example-buffer/bufferA.glsl +14 -0
  102. package/templates/shaders/example-buffer/config.json +10 -0
  103. package/templates/shaders/example-buffer/image.glsl +5 -0
  104. package/templates/shaders/example-gradient/config.json +4 -0
  105. package/templates/shaders/example-gradient/image.glsl +7 -0
  106. package/templates/vite.config.js +35 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * App Styles - FPS counter and shader error overlay
3
+ */
4
+
5
+ /* ===== FPS Counter ===== */
6
+ .fps-counter {
7
+ position: absolute;
8
+ bottom: 8px;
9
+ left: 8px;
10
+ padding: 6px 10px;
11
+ background: rgba(0, 0, 0, 0.75);
12
+ color: white;
13
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
14
+ font-size: 12px;
15
+ font-weight: 500;
16
+ border-radius: 4px;
17
+ pointer-events: none;
18
+ user-select: none;
19
+ z-index: 1000;
20
+ backdrop-filter: blur(4px);
21
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
22
+ }
23
+
24
+ /* ===== Playback Controls ===== */
25
+ .playback-controls {
26
+ position: absolute;
27
+ bottom: 8px;
28
+ right: 8px;
29
+ display: flex;
30
+ gap: 8px;
31
+ z-index: 1000;
32
+ }
33
+
34
+ .control-button {
35
+ padding: 6px 8px;
36
+ background: rgba(0, 0, 0, 0.75);
37
+ color: white;
38
+ border: none;
39
+ border-radius: 4px;
40
+ cursor: pointer;
41
+ backdrop-filter: blur(4px);
42
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
43
+ transition: all 0.2s ease;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ width: 32px;
48
+ height: 32px;
49
+ }
50
+
51
+ .control-button:hover {
52
+ background: rgba(0, 0, 0, 0.85);
53
+ transform: scale(1.05);
54
+ }
55
+
56
+ .control-button:active {
57
+ transform: scale(0.95);
58
+ }
59
+
60
+ .control-button svg {
61
+ width: 16px;
62
+ height: 16px;
63
+ fill: currentColor;
64
+ }
65
+
66
+ /* ===== Shader Error Overlay ===== */
67
+ .shader-error-overlay {
68
+ position: absolute;
69
+ top: 0;
70
+ left: 0;
71
+ right: 0;
72
+ bottom: 0;
73
+ background: rgba(0, 0, 0, 0.95);
74
+ backdrop-filter: blur(8px);
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ z-index: 2000;
79
+ padding: 60px;
80
+ overflow-y: auto;
81
+ }
82
+
83
+ .error-overlay-content {
84
+ background: #1a1a1a;
85
+ border-radius: 6px;
86
+ max-width: 900px;
87
+ width: 100%;
88
+ display: flex;
89
+ flex-direction: column;
90
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8), 0 0 1px rgba(255, 255, 255, 0.1);
91
+ border: 1px solid #2a2a2a;
92
+ max-height: calc(100vh - 120px);
93
+ }
94
+
95
+ .error-header {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ padding: 18px 24px;
100
+ background: linear-gradient(135deg, #c62828 0%, #b71c1c 100%);
101
+ color: white;
102
+ border-radius: 6px 6px 0 0;
103
+ border-bottom: 1px solid rgba(0, 0, 0, 0.3);
104
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
105
+ }
106
+
107
+ .error-title {
108
+ font-size: 15px;
109
+ font-weight: 600;
110
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 8px;
114
+ letter-spacing: -0.01em;
115
+ }
116
+
117
+ .error-close {
118
+ background: transparent;
119
+ border: none;
120
+ color: rgba(255, 255, 255, 0.9);
121
+ font-size: 24px;
122
+ line-height: 1;
123
+ cursor: pointer;
124
+ padding: 0;
125
+ width: 32px;
126
+ height: 32px;
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: center;
130
+ border-radius: 4px;
131
+ transition: all 0.2s ease;
132
+ opacity: 0.8;
133
+ }
134
+
135
+ .error-close:hover {
136
+ background: rgba(255, 255, 255, 0.15);
137
+ opacity: 1;
138
+ transform: scale(1.05);
139
+ }
140
+
141
+ .error-body {
142
+ padding: 24px;
143
+ overflow-y: auto;
144
+ flex: 1;
145
+ }
146
+
147
+ .error-section {
148
+ margin-bottom: 24px;
149
+ }
150
+
151
+ .error-section:last-child {
152
+ margin-bottom: 0;
153
+ }
154
+
155
+ .error-pass-name {
156
+ font-size: 13px;
157
+ font-weight: 600;
158
+ color: #ffa726;
159
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
160
+ margin-bottom: 10px;
161
+ padding-bottom: 6px;
162
+ border-bottom: 1px solid #2a2a2a;
163
+ letter-spacing: 0.02em;
164
+ }
165
+
166
+ .error-content {
167
+ margin: 0;
168
+ padding: 14px 16px;
169
+ background: #0f0f0f;
170
+ border-radius: 4px;
171
+ color: #ff6b6b;
172
+ font-size: 13px;
173
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
174
+ line-height: 1.6;
175
+ overflow-x: auto;
176
+ border: 1px solid #2a2a2a;
177
+ white-space: pre-wrap;
178
+ word-break: break-word;
179
+ }
180
+
181
+ .error-code-context {
182
+ margin: 12px 0 0 0;
183
+ padding: 14px 16px;
184
+ background: #0d0d0d;
185
+ border-radius: 4px;
186
+ color: #b0b0b0;
187
+ font-size: 12px;
188
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
189
+ line-height: 1.6;
190
+ overflow-x: auto;
191
+ border: 1px solid #2a2a2a;
192
+ white-space: pre;
193
+ }
194
+
195
+ .error-code-context .context-line {
196
+ color: #666;
197
+ display: block;
198
+ }
199
+
200
+ .error-code-context .error-line-highlight {
201
+ color: #ffffff;
202
+ background: rgba(198, 40, 40, 0.25);
203
+ display: block;
204
+ font-weight: 600;
205
+ border-left: 3px solid #c62828;
206
+ margin-left: -16px;
207
+ padding-left: 13px;
208
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * App Layer - Type Definitions
3
+ *
4
+ * Types for the browser runtime coordinator.
5
+ */
6
+
7
+ import type { ShadertoyProject } from '../project/types';
8
+
9
+ /**
10
+ * Options for creating the App.
11
+ */
12
+ export interface AppOptions {
13
+ /**
14
+ * HTML container element (App will create canvas inside).
15
+ */
16
+ container: HTMLElement;
17
+
18
+ /**
19
+ * Loaded Shadertoy project.
20
+ */
21
+ project: ShadertoyProject;
22
+
23
+ /**
24
+ * Canvas pixel ratio (default: window.devicePixelRatio).
25
+ * Set to 1 for performance, or higher for retina displays.
26
+ */
27
+ pixelRatio?: number;
28
+ }
29
+
30
+ /**
31
+ * Mouse state for iMouse uniform.
32
+ * Format: [x, y, clickX, clickY]
33
+ * - x, y: current mouse position in pixels
34
+ * - clickX, clickY: position of last click (or -1, -1 if no click)
35
+ */
36
+ export type MouseState = [number, number, number, number];
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Editor Panel - Shared component for code editing in layouts
3
+ *
4
+ * Provides:
5
+ * - CodeMirror editor (dynamically loaded)
6
+ * - Recompile button with keyboard shortcut
7
+ * - Error display
8
+ * - Tab management for multiple passes
9
+ */
10
+
11
+ import { ShadertoyProject, PassName } from '../project/types';
12
+ import { RecompileHandler } from '../layouts/types';
13
+
14
+ import './editor-panel.css';
15
+
16
+ type CodeTab = { kind: 'code'; name: string; passName: 'common' | PassName; source: string };
17
+ type ImageTab = { kind: 'image'; name: string; url: string };
18
+ type Tab = CodeTab | ImageTab;
19
+
20
+ interface EditorInstance {
21
+ getSource: () => string;
22
+ setSource: (source: string) => void;
23
+ destroy: () => void;
24
+ }
25
+
26
+ export class EditorPanel {
27
+ private container: HTMLElement;
28
+ private project: ShadertoyProject;
29
+ private recompileHandler: RecompileHandler | null = null;
30
+
31
+ private tabBar: HTMLElement;
32
+ private contentArea: HTMLElement;
33
+ private copyButton: HTMLElement;
34
+ private recompileButton: HTMLElement;
35
+ private errorDisplay: HTMLElement;
36
+
37
+ private tabs: Tab[] = [];
38
+ private activeTabIndex: number = 0;
39
+
40
+ // Editor instance (null if not in editor mode or viewing image)
41
+ private editorInstance: EditorInstance | null = null;
42
+
43
+ // Track modified sources (passName -> modified source)
44
+ private modifiedSources: Map<string, string> = new Map();
45
+
46
+ constructor(container: HTMLElement, project: ShadertoyProject) {
47
+ this.container = container;
48
+ this.project = project;
49
+
50
+ // Build tabs
51
+ this.buildTabs();
52
+
53
+ // Create tab bar
54
+ this.tabBar = document.createElement('div');
55
+ this.tabBar.className = 'editor-tab-bar';
56
+ this.buildTabBar();
57
+
58
+ // Create content area
59
+ this.contentArea = document.createElement('div');
60
+ this.contentArea.className = 'editor-content-area';
61
+
62
+ // Create copy button (icon only)
63
+ this.copyButton = document.createElement('button');
64
+ this.copyButton.className = 'editor-copy-button';
65
+ this.copyButton.innerHTML = `
66
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
67
+ <path d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2z" opacity="0.4"/>
68
+ <path d="M2 5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H2zm0 1h7a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/>
69
+ </svg>
70
+ `;
71
+ this.copyButton.title = 'Copy code to clipboard';
72
+ this.copyButton.addEventListener('click', () => this.copyToClipboard());
73
+
74
+ // Create recompile button
75
+ this.recompileButton = document.createElement('button');
76
+ this.recompileButton.className = 'editor-recompile-button';
77
+ this.recompileButton.innerHTML = `
78
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
79
+ <path d="M4 3v10l8-5-8-5z"/>
80
+ </svg>
81
+ Recompile
82
+ `;
83
+ this.recompileButton.title = 'Recompile shader (Ctrl+Enter)';
84
+ this.recompileButton.addEventListener('click', () => this.recompile());
85
+
86
+ // Create error display
87
+ this.errorDisplay = document.createElement('div');
88
+ this.errorDisplay.className = 'editor-error-display';
89
+ this.errorDisplay.style.display = 'none';
90
+
91
+ // Assemble panel
92
+ const toolbar = document.createElement('div');
93
+ toolbar.className = 'editor-toolbar';
94
+ toolbar.appendChild(this.tabBar);
95
+ toolbar.appendChild(this.copyButton);
96
+ toolbar.appendChild(this.recompileButton);
97
+
98
+ this.container.appendChild(toolbar);
99
+ this.container.appendChild(this.contentArea);
100
+ this.container.appendChild(this.errorDisplay);
101
+
102
+ // Set up keyboard shortcut
103
+ this.setupKeyboardShortcut();
104
+
105
+ // Load editor for first tab
106
+ this.showTab(0);
107
+ }
108
+
109
+ setRecompileHandler(handler: RecompileHandler): void {
110
+ this.recompileHandler = handler;
111
+ }
112
+
113
+ dispose(): void {
114
+ if (this.editorInstance) {
115
+ this.editorInstance.destroy();
116
+ this.editorInstance = null;
117
+ }
118
+ this.container.innerHTML = '';
119
+ }
120
+
121
+ private buildTabs(): void {
122
+ this.tabs = [];
123
+
124
+ // 1. Common first (if exists)
125
+ if (this.project.commonSource) {
126
+ this.tabs.push({
127
+ kind: 'code',
128
+ name: 'common.glsl',
129
+ passName: 'common',
130
+ source: this.project.commonSource,
131
+ });
132
+ }
133
+
134
+ // 2. Buffers in order (A, B, C, D)
135
+ const bufferOrder: ('BufferA' | 'BufferB' | 'BufferC' | 'BufferD')[] = [
136
+ 'BufferA', 'BufferB', 'BufferC', 'BufferD',
137
+ ];
138
+ for (const bufferName of bufferOrder) {
139
+ const pass = this.project.passes[bufferName];
140
+ if (pass) {
141
+ this.tabs.push({
142
+ kind: 'code',
143
+ name: `${bufferName.toLowerCase()}.glsl`,
144
+ passName: bufferName,
145
+ source: pass.glslSource,
146
+ });
147
+ }
148
+ }
149
+
150
+ // 3. Image pass
151
+ const imagePass = this.project.passes.Image;
152
+ this.tabs.push({
153
+ kind: 'code',
154
+ name: 'image.glsl',
155
+ passName: 'Image',
156
+ source: imagePass.glslSource,
157
+ });
158
+
159
+ // 4. Textures (images) - not editable
160
+ for (const texture of this.project.textures) {
161
+ this.tabs.push({
162
+ kind: 'image',
163
+ name: texture.filename || texture.name,
164
+ url: texture.source,
165
+ });
166
+ }
167
+ }
168
+
169
+ private buildTabBar(): void {
170
+ this.tabBar.innerHTML = '';
171
+
172
+ this.tabs.forEach((tab, index) => {
173
+ const tabButton = document.createElement('button');
174
+ tabButton.className = 'editor-tab-button';
175
+ if (tab.kind === 'image') {
176
+ tabButton.classList.add('image-tab');
177
+ }
178
+ tabButton.textContent = tab.name;
179
+ if (index === this.activeTabIndex) {
180
+ tabButton.classList.add('active');
181
+ }
182
+
183
+ tabButton.addEventListener('click', () => this.showTab(index));
184
+ this.tabBar.appendChild(tabButton);
185
+ });
186
+ }
187
+
188
+ private async showTab(index: number): Promise<void> {
189
+ // Save current editor content before switching
190
+ this.saveCurrentEditorContent();
191
+
192
+ this.activeTabIndex = index;
193
+ const tab = this.tabs[index];
194
+
195
+ // Update tab bar active state
196
+ this.tabBar.querySelectorAll('.editor-tab-button').forEach((btn, i) => {
197
+ btn.classList.toggle('active', i === index);
198
+ });
199
+
200
+ // Clear content area
201
+ this.contentArea.innerHTML = '';
202
+
203
+ // Destroy previous editor instance
204
+ if (this.editorInstance) {
205
+ this.editorInstance.destroy();
206
+ this.editorInstance = null;
207
+ }
208
+
209
+ if (tab.kind === 'code') {
210
+ // Show buttons
211
+ this.copyButton.style.display = '';
212
+ this.recompileButton.style.display = '';
213
+
214
+ // Get source (use modified if available, otherwise original)
215
+ const source = this.modifiedSources.get(tab.passName) ?? tab.source;
216
+
217
+ // Create editor container
218
+ const editorContainer = document.createElement('div');
219
+ editorContainer.className = 'editor-prism-container';
220
+ this.contentArea.appendChild(editorContainer);
221
+
222
+ // Dynamically load editor and create instance
223
+ try {
224
+ const { createEditor } = await import('./prism-editor');
225
+ this.editorInstance = createEditor(editorContainer, source, (newSource) => {
226
+ // Track modifications
227
+ this.modifiedSources.set(tab.passName, newSource);
228
+ });
229
+ } catch (err) {
230
+ console.error('Failed to load editor:', err);
231
+ // Fallback to textarea
232
+ const textarea = document.createElement('textarea');
233
+ textarea.className = 'editor-fallback-textarea';
234
+ textarea.value = source;
235
+ textarea.addEventListener('input', () => {
236
+ this.modifiedSources.set(tab.passName, textarea.value);
237
+ });
238
+ editorContainer.appendChild(textarea);
239
+ }
240
+ } else {
241
+ // Hide buttons for image tabs
242
+ this.copyButton.style.display = 'none';
243
+ this.recompileButton.style.display = 'none';
244
+
245
+ // Show image
246
+ const imgContainer = document.createElement('div');
247
+ imgContainer.className = 'editor-image-viewer';
248
+
249
+ const img = document.createElement('img');
250
+ img.src = tab.url;
251
+ img.alt = tab.name;
252
+
253
+ imgContainer.appendChild(img);
254
+ this.contentArea.appendChild(imgContainer);
255
+ }
256
+ }
257
+
258
+ private saveCurrentEditorContent(): void {
259
+ if (this.editorInstance) {
260
+ const tab = this.tabs[this.activeTabIndex];
261
+ if (tab.kind === 'code') {
262
+ const source = this.editorInstance.getSource();
263
+ this.modifiedSources.set(tab.passName, source);
264
+ }
265
+ }
266
+ }
267
+
268
+ private recompile(): void {
269
+ if (!this.recompileHandler) {
270
+ console.warn('No recompile handler set');
271
+ return;
272
+ }
273
+
274
+ // Save current content first
275
+ this.saveCurrentEditorContent();
276
+
277
+ const tab = this.tabs[this.activeTabIndex];
278
+ if (tab.kind !== 'code') {
279
+ return;
280
+ }
281
+
282
+ const source = this.modifiedSources.get(tab.passName) ?? tab.source;
283
+ const result = this.recompileHandler(tab.passName, source);
284
+
285
+ if (result.success) {
286
+ this.hideError();
287
+ // Update the original source in the tab
288
+ tab.source = source;
289
+ } else {
290
+ this.showError(result.error || 'Compilation failed');
291
+ }
292
+ }
293
+
294
+ private showError(message: string): void {
295
+ this.errorDisplay.textContent = message;
296
+ this.errorDisplay.style.display = 'block';
297
+ }
298
+
299
+ private hideError(): void {
300
+ this.errorDisplay.style.display = 'none';
301
+ }
302
+
303
+ private async copyToClipboard(): Promise<void> {
304
+ const tab = this.tabs[this.activeTabIndex];
305
+ if (tab.kind !== 'code') return;
306
+
307
+ // Get current source (modified or original)
308
+ const source = this.editorInstance
309
+ ? this.editorInstance.getSource()
310
+ : (this.modifiedSources.get(tab.passName) ?? tab.source);
311
+
312
+ try {
313
+ await navigator.clipboard.writeText(source);
314
+ // Show checkmark feedback
315
+ const originalHTML = this.copyButton.innerHTML;
316
+ this.copyButton.innerHTML = `
317
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
318
+ <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
319
+ </svg>
320
+ `;
321
+ this.copyButton.classList.add('copied');
322
+ setTimeout(() => {
323
+ this.copyButton.innerHTML = originalHTML;
324
+ this.copyButton.classList.remove('copied');
325
+ }, 1500);
326
+ } catch (err) {
327
+ console.error('Failed to copy:', err);
328
+ }
329
+ }
330
+
331
+ private setupKeyboardShortcut(): void {
332
+ // Listen for Ctrl+Enter / Cmd+Enter
333
+ this.container.addEventListener('keydown', (e: KeyboardEvent) => {
334
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
335
+ e.preventDefault();
336
+ this.recompile();
337
+ }
338
+ });
339
+ }
340
+ }