tjs-lang 0.5.4 → 0.6.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.
- package/CLAUDE.md +33 -13
- package/README.md +4 -4
- package/bin/dev.ts +5 -1
- package/demo/docs.json +14 -2
- package/demo/index.html +2 -2
- package/demo/src/capabilities.ts +109 -2
- package/demo/src/demo-nav.ts +137 -203
- package/demo/src/imports.ts +43 -9
- package/demo/src/index.ts +179 -29
- package/demo/src/playground-shared.ts +11 -4
- package/demo/src/playground.ts +2 -2
- package/demo/src/tjs-playground.ts +294 -11
- package/demo/src/ts-playground.ts +239 -0
- package/dist/index.js +135 -127
- package/dist/index.js.map +6 -5
- package/dist/src/cli/commands/emit.d.ts +3 -0
- package/dist/src/lang/emitters/dts.d.ts +48 -0
- package/dist/src/lang/emitters/from-ts.d.ts +2 -0
- package/dist/src/lang/index.d.ts +1 -0
- package/dist/tjs-batteries.js +3 -3
- package/dist/tjs-batteries.js.map +2 -2
- package/dist/tjs-full.js +135 -127
- package/dist/tjs-full.js.map +6 -5
- package/dist/tjs-transpiler.js +2 -349
- package/dist/tjs-transpiler.js.map +4 -19
- package/package.json +1 -1
- package/src/cli/commands/emit.ts +26 -0
- package/src/cli/tjs.ts +4 -1
- package/src/lang/codegen.test.ts +55 -0
- package/src/lang/emitters/dts.test.ts +406 -0
- package/src/lang/emitters/dts.ts +588 -0
- package/src/lang/emitters/from-ts.ts +244 -20
- package/src/lang/index.ts +5 -0
- package/src/lang/typescript-syntax.test.ts +358 -0
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
// Available modules for autocomplete introspection
|
|
24
24
|
// These are the actual runtime values that can be introspected
|
|
25
25
|
import { codeMirror, CodeMirror } from '../../editors/codemirror/component'
|
|
26
|
-
import { tjs, type TJSTranspileOptions } from '../../src/lang'
|
|
26
|
+
import { tjs, generateDTS, type TJSTranspileOptions } from '../../src/lang'
|
|
27
27
|
import { generateDocsMarkdown } from './docs-utils'
|
|
28
28
|
import { extractImports, generateImportMap, resolveImports } from './imports'
|
|
29
29
|
import {
|
|
@@ -112,7 +112,8 @@ interface TJSPlaygroundParts extends PartsMap {
|
|
|
112
112
|
cssEditor: CodeMirror
|
|
113
113
|
inputTabs: TabSelector
|
|
114
114
|
outputTabs: TabSelector
|
|
115
|
-
jsOutput:
|
|
115
|
+
jsOutput: CodeMirror
|
|
116
|
+
typesOutput: CodeMirror
|
|
116
117
|
previewFrame: HTMLIFrameElement
|
|
117
118
|
docsOutput: MarkdownViewer
|
|
118
119
|
testsOutput: HTMLElement
|
|
@@ -128,6 +129,7 @@ interface TJSPlaygroundParts extends PartsMap {
|
|
|
128
129
|
debugToggle: HTMLInputElement
|
|
129
130
|
safetyToggle: HTMLInputElement
|
|
130
131
|
wasmToggle: HTMLInputElement
|
|
132
|
+
splitBtn: HTMLButtonElement
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
@@ -140,6 +142,11 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
140
142
|
private originalCode: string = DEFAULT_TJS
|
|
141
143
|
private editorCache: Map<string, string> = new Map()
|
|
142
144
|
|
|
145
|
+
// Split mode
|
|
146
|
+
private _splitMode: null | 'code' | 'output' = null
|
|
147
|
+
private _splitChannel: BroadcastChannel | null = null
|
|
148
|
+
private _splitSessionId: string = ''
|
|
149
|
+
|
|
143
150
|
// Build flags state
|
|
144
151
|
private buildFlags = {
|
|
145
152
|
tests: true, // Run tests at transpile time
|
|
@@ -212,6 +219,229 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
212
219
|
}
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Set split mode and manage BroadcastChannel.
|
|
224
|
+
* sessionId ties code+output windows together so stale windows are ignored.
|
|
225
|
+
*/
|
|
226
|
+
setSplitMode = (mode: null | 'code' | 'output', sessionId?: string) => {
|
|
227
|
+
const prev = this._splitMode
|
|
228
|
+
this._splitMode = mode
|
|
229
|
+
if (sessionId) this._splitSessionId = sessionId
|
|
230
|
+
|
|
231
|
+
// Update button appearance
|
|
232
|
+
this.updateSplitButton()
|
|
233
|
+
|
|
234
|
+
// Toggle visibility of input/output panes directly
|
|
235
|
+
const input = this.querySelector('.tjs-input') as HTMLElement | null
|
|
236
|
+
const output = this.querySelector('.tjs-output') as HTMLElement | null
|
|
237
|
+
const consoleEl = this.querySelector('.tjs-console') as HTMLElement | null
|
|
238
|
+
const flags = this.querySelector('.build-flags') as HTMLElement | null
|
|
239
|
+
const toolbar = this.querySelector('.tjs-toolbar') as HTMLElement | null
|
|
240
|
+
|
|
241
|
+
if (mode === 'code') {
|
|
242
|
+
if (output) output.style.display = 'none'
|
|
243
|
+
if (consoleEl) consoleEl.style.display = 'none'
|
|
244
|
+
if (input) input.style.flex = '1 1 100%'
|
|
245
|
+
} else if (mode === 'output') {
|
|
246
|
+
if (input) input.style.display = 'none'
|
|
247
|
+
if (flags) flags.style.display = 'none'
|
|
248
|
+
if (toolbar) toolbar.style.display = 'none'
|
|
249
|
+
if (output) output.style.flex = '1 1 100%'
|
|
250
|
+
} else {
|
|
251
|
+
// Restore normal layout
|
|
252
|
+
if (input) {
|
|
253
|
+
input.style.display = ''
|
|
254
|
+
input.style.flex = ''
|
|
255
|
+
}
|
|
256
|
+
if (output) {
|
|
257
|
+
output.style.display = ''
|
|
258
|
+
output.style.flex = ''
|
|
259
|
+
}
|
|
260
|
+
if (consoleEl) consoleEl.style.display = ''
|
|
261
|
+
if (flags) flags.style.display = ''
|
|
262
|
+
if (toolbar) toolbar.style.display = ''
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Tear down previous channel, notifying partner
|
|
266
|
+
if (this._splitChannel && prev) {
|
|
267
|
+
this._splitChannel.postMessage({
|
|
268
|
+
type: 'closed',
|
|
269
|
+
mode: prev,
|
|
270
|
+
sid: this._splitSessionId,
|
|
271
|
+
})
|
|
272
|
+
this._splitChannel.close()
|
|
273
|
+
this._splitChannel = null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!mode) {
|
|
277
|
+
this._splitSessionId = ''
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sid = this._splitSessionId
|
|
282
|
+
const channel = new BroadcastChannel('tjs-playground')
|
|
283
|
+
this._splitChannel = channel
|
|
284
|
+
|
|
285
|
+
if (mode === 'output') {
|
|
286
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
287
|
+
const msg = e.data
|
|
288
|
+
if (msg.sid !== sid) return // ignore messages from other sessions
|
|
289
|
+
if (msg.type === 'ping') {
|
|
290
|
+
channel.postMessage({ type: 'pong', sid })
|
|
291
|
+
} else if (msg.type === 'code-change' && msg.view === 'tjs') {
|
|
292
|
+
this.parts.tjsEditor.value = msg.source
|
|
293
|
+
this.transpileAndRun()
|
|
294
|
+
} else if (msg.type === 'run' && msg.view === 'tjs') {
|
|
295
|
+
this.run()
|
|
296
|
+
} else if (msg.type === 'closed' && msg.mode === 'code') {
|
|
297
|
+
this.setSplitMode(null)
|
|
298
|
+
this.dispatchEvent(
|
|
299
|
+
new CustomEvent('split-mode-change', {
|
|
300
|
+
detail: null,
|
|
301
|
+
bubbles: true,
|
|
302
|
+
})
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Request current source from code window
|
|
307
|
+
channel.postMessage({ type: 'request-source', view: 'tjs', sid })
|
|
308
|
+
} else if (mode === 'code') {
|
|
309
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
310
|
+
const msg = e.data
|
|
311
|
+
if (msg.sid !== sid) return // ignore messages from other sessions
|
|
312
|
+
if (msg.type === 'ping') {
|
|
313
|
+
channel.postMessage({ type: 'pong', sid })
|
|
314
|
+
} else if (msg.type === 'request-source' && msg.view === 'tjs') {
|
|
315
|
+
// Output window is asking for current source — send it
|
|
316
|
+
this.broadcastSource()
|
|
317
|
+
} else if (msg.type === 'closed' && msg.mode === 'output') {
|
|
318
|
+
this.setSplitMode(null)
|
|
319
|
+
this.dispatchEvent(
|
|
320
|
+
new CustomEvent('split-mode-change', {
|
|
321
|
+
detail: null,
|
|
322
|
+
bubbles: true,
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Send initial source to output window immediately
|
|
328
|
+
setTimeout(() => this.broadcastSource(), 50)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Broadcast editor content to output window (called in code mode)
|
|
334
|
+
*/
|
|
335
|
+
private broadcastSource = () => {
|
|
336
|
+
if (this._splitMode === 'code' && this._splitChannel) {
|
|
337
|
+
this._splitChannel.postMessage({
|
|
338
|
+
type: 'code-change',
|
|
339
|
+
view: 'tjs',
|
|
340
|
+
source: this.parts.tjsEditor.value,
|
|
341
|
+
sid: this._splitSessionId,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Broadcast run command to output window
|
|
348
|
+
*/
|
|
349
|
+
broadcastRun = () => {
|
|
350
|
+
if (this._splitMode === 'code' && this._splitChannel) {
|
|
351
|
+
this._splitChannel.postMessage({
|
|
352
|
+
type: 'run',
|
|
353
|
+
view: 'tjs',
|
|
354
|
+
sid: this._splitSessionId,
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Notify partner window that this window is closing
|
|
361
|
+
*/
|
|
362
|
+
notifyClose = () => {
|
|
363
|
+
if (this._splitChannel && this._splitMode) {
|
|
364
|
+
this._splitChannel.postMessage({
|
|
365
|
+
type: 'closed',
|
|
366
|
+
mode: this._splitMode,
|
|
367
|
+
sid: this._splitSessionId,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Handle split/merge button click.
|
|
374
|
+
* window.open must be called directly in the click handler to avoid popup blockers.
|
|
375
|
+
*/
|
|
376
|
+
handleSplitClick = () => {
|
|
377
|
+
if (this._splitMode === 'output') {
|
|
378
|
+
// Output window — close it (partner will get 'closed' via beforeunload)
|
|
379
|
+
window.close()
|
|
380
|
+
return
|
|
381
|
+
} else if (this._splitMode === 'code') {
|
|
382
|
+
// Code window — merge back
|
|
383
|
+
this.setSplitMode(null)
|
|
384
|
+
this.dispatchEvent(
|
|
385
|
+
new CustomEvent('split-mode-change', { detail: null, bubbles: true })
|
|
386
|
+
)
|
|
387
|
+
} else {
|
|
388
|
+
// Open output in new window — must happen here in the click handler
|
|
389
|
+
const sid = crypto.randomUUID().slice(0, 8)
|
|
390
|
+
const params = new URLSearchParams(window.location.hash.slice(1))
|
|
391
|
+
params.set('mode', 'output')
|
|
392
|
+
params.set('sid', sid)
|
|
393
|
+
const url = window.location.pathname + '#' + params.toString()
|
|
394
|
+
const win = window.open(url, `tjs-output-${sid}`)
|
|
395
|
+
if (!win) return // popup blocked — do nothing
|
|
396
|
+
|
|
397
|
+
// Apply split mode directly — don't wait for event round-trip
|
|
398
|
+
this.setSplitMode('code', sid)
|
|
399
|
+
|
|
400
|
+
// Also notify parent so it can update URL state
|
|
401
|
+
this.dispatchEvent(
|
|
402
|
+
new CustomEvent('split-mode-change', {
|
|
403
|
+
detail: { mode: 'code', sid },
|
|
404
|
+
bubbles: true,
|
|
405
|
+
})
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Update split button appearance based on current mode
|
|
412
|
+
*/
|
|
413
|
+
private updateSplitButton = () => {
|
|
414
|
+
const btn = this._splitBtn
|
|
415
|
+
if (!btn) return
|
|
416
|
+
|
|
417
|
+
btn.innerHTML = ''
|
|
418
|
+
if (this._splitMode === 'output') {
|
|
419
|
+
// Output window — show x to close
|
|
420
|
+
btn.style.display = ''
|
|
421
|
+
btn.classList.remove('split-btn-flip')
|
|
422
|
+
btn.title = 'Close output window'
|
|
423
|
+
btn.append(icons.x({ size: 16 }))
|
|
424
|
+
} else if (this._splitMode === 'code') {
|
|
425
|
+
// Code window — hide (no button needed, output window has the x)
|
|
426
|
+
btn.style.display = 'none'
|
|
427
|
+
} else {
|
|
428
|
+
// Normal — show copy icon (CSS flips it)
|
|
429
|
+
btn.style.display = ''
|
|
430
|
+
btn.classList.add('split-btn-flip')
|
|
431
|
+
btn.title = 'Open output in new window'
|
|
432
|
+
btn.append(icons.copy({ size: 16 }))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Cache the split button reference after first access
|
|
437
|
+
private get _splitBtn(): HTMLButtonElement | null {
|
|
438
|
+
try {
|
|
439
|
+
return this.parts.splitBtn
|
|
440
|
+
} catch {
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
215
445
|
content = () => [
|
|
216
446
|
// Toolbar
|
|
217
447
|
div(
|
|
@@ -334,13 +564,18 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
334
564
|
{ class: 'tjs-output' },
|
|
335
565
|
tabSelector(
|
|
336
566
|
{ part: 'outputTabs', style: { height: '100%' } },
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
567
|
+
codeMirror({
|
|
568
|
+
name: 'JS',
|
|
569
|
+
part: 'jsOutput',
|
|
570
|
+
mode: 'javascript',
|
|
571
|
+
disabled: true,
|
|
572
|
+
}),
|
|
573
|
+
codeMirror({
|
|
574
|
+
name: 'Types',
|
|
575
|
+
part: 'typesOutput',
|
|
576
|
+
mode: 'typescript',
|
|
577
|
+
disabled: true,
|
|
578
|
+
}),
|
|
344
579
|
elements.iframe({
|
|
345
580
|
name: 'Preview',
|
|
346
581
|
part: 'previewFrame',
|
|
@@ -374,6 +609,16 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
374
609
|
{ part: 'testsOutput', class: 'tests-output' },
|
|
375
610
|
'Test results will appear here'
|
|
376
611
|
)
|
|
612
|
+
),
|
|
613
|
+
button(
|
|
614
|
+
{
|
|
615
|
+
part: 'splitBtn',
|
|
616
|
+
slot: 'after-tabs',
|
|
617
|
+
class: 'split-btn split-btn-flip',
|
|
618
|
+
title: 'Open output in new window',
|
|
619
|
+
onClick: this.handleSplitClick,
|
|
620
|
+
},
|
|
621
|
+
icons.copy({ size: 16 })
|
|
377
622
|
)
|
|
378
623
|
)
|
|
379
624
|
)
|
|
@@ -416,6 +661,7 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
416
661
|
debounceTimer = setTimeout(() => {
|
|
417
662
|
this.transpile()
|
|
418
663
|
this.updateRevertButton()
|
|
664
|
+
this.broadcastSource()
|
|
419
665
|
}, 300)
|
|
420
666
|
|
|
421
667
|
// Update autocomplete context more frequently
|
|
@@ -517,7 +763,15 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
517
763
|
this.lastTranspileTime = performance.now() - startTime
|
|
518
764
|
|
|
519
765
|
this.lastTranspileResult = result
|
|
520
|
-
this.parts.jsOutput.
|
|
766
|
+
this.parts.jsOutput.value = result.code
|
|
767
|
+
|
|
768
|
+
// Update .d.ts types
|
|
769
|
+
try {
|
|
770
|
+
const dts = generateDTS(result, source)
|
|
771
|
+
this.parts.typesOutput.value = dts
|
|
772
|
+
} catch {
|
|
773
|
+
this.parts.typesOutput.value = '// Could not generate types'
|
|
774
|
+
}
|
|
521
775
|
|
|
522
776
|
// Update docs
|
|
523
777
|
this.updateDocs(result)
|
|
@@ -545,12 +799,13 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
545
799
|
} catch (e: any) {
|
|
546
800
|
// Format error with location info if available
|
|
547
801
|
const errorInfo = this.formatTranspileError(e, source)
|
|
548
|
-
this.parts.jsOutput.
|
|
802
|
+
this.parts.jsOutput.value = errorInfo.detailed
|
|
549
803
|
this.parts.statusBar.textContent = errorInfo.short
|
|
550
804
|
this.parts.statusBar.classList.add('error')
|
|
551
805
|
this.lastTranspileResult = null
|
|
552
806
|
// Clear test results on error
|
|
553
807
|
this.parts.testsOutput.textContent = 'Transpilation failed - no tests run'
|
|
808
|
+
this.parts.typesOutput.value = '// Transpilation failed'
|
|
554
809
|
|
|
555
810
|
// Set error marker in gutter
|
|
556
811
|
if (e.line) {
|
|
@@ -954,6 +1209,11 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
954
1209
|
}
|
|
955
1210
|
|
|
956
1211
|
run = async () => {
|
|
1212
|
+
// In code mode, broadcast run to output window instead of running locally
|
|
1213
|
+
if (this._splitMode === 'code') {
|
|
1214
|
+
this.broadcastRun()
|
|
1215
|
+
}
|
|
1216
|
+
|
|
957
1217
|
this.parts.runBtn.disabled = true
|
|
958
1218
|
this.clearConsole()
|
|
959
1219
|
this.transpile()
|
|
@@ -1034,6 +1294,7 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
1034
1294
|
importStatements,
|
|
1035
1295
|
parentBindings: true,
|
|
1036
1296
|
autoCallTjsFunction: true,
|
|
1297
|
+
darkMode: document.body.classList.contains('darkmode'),
|
|
1037
1298
|
})
|
|
1038
1299
|
|
|
1039
1300
|
// Listen for messages from iframe
|
|
@@ -1244,5 +1505,27 @@ export const tjsPlayground = TJSPlayground.elementCreator({
|
|
|
1244
1505
|
display: 'flex',
|
|
1245
1506
|
flexDirection: 'column',
|
|
1246
1507
|
},
|
|
1508
|
+
|
|
1509
|
+
// Split button in tab bar
|
|
1510
|
+
':host .split-btn': {
|
|
1511
|
+
display: 'flex',
|
|
1512
|
+
alignItems: 'center',
|
|
1513
|
+
alignSelf: 'center',
|
|
1514
|
+
padding: '4px 6px',
|
|
1515
|
+
marginRight: '4px',
|
|
1516
|
+
background: 'transparent',
|
|
1517
|
+
border: 'none',
|
|
1518
|
+
cursor: 'pointer',
|
|
1519
|
+
color: 'var(--text-color, #6b7280)',
|
|
1520
|
+
borderRadius: '4px',
|
|
1521
|
+
opacity: '0.7',
|
|
1522
|
+
},
|
|
1523
|
+
':host .split-btn:hover': {
|
|
1524
|
+
opacity: '1',
|
|
1525
|
+
background: 'var(--code-background, #f3f4f6)',
|
|
1526
|
+
},
|
|
1527
|
+
':host .split-btn-flip svg': {
|
|
1528
|
+
transform: 'scaleY(-1)',
|
|
1529
|
+
},
|
|
1247
1530
|
},
|
|
1248
1531
|
}) as ElementCreator<TJSPlayground>
|
|
@@ -75,6 +75,7 @@ interface TSPlaygroundParts extends PartsMap {
|
|
|
75
75
|
testsToggle: HTMLInputElement
|
|
76
76
|
debugToggle: HTMLInputElement
|
|
77
77
|
safetyToggle: HTMLInputElement
|
|
78
|
+
splitBtn: HTMLButtonElement
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
@@ -88,6 +89,11 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
88
89
|
private originalCode: string = DEFAULT_TS
|
|
89
90
|
private editorCache: Map<string, string> = new Map()
|
|
90
91
|
|
|
92
|
+
// Split mode
|
|
93
|
+
private _splitMode: null | 'code' | 'output' = null
|
|
94
|
+
private _splitChannel: BroadcastChannel | null = null
|
|
95
|
+
private _splitSessionId: string = ''
|
|
96
|
+
|
|
91
97
|
// Build flags state
|
|
92
98
|
private buildFlags = {
|
|
93
99
|
tests: true, // Run tests at transpile time
|
|
@@ -95,6 +101,200 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
95
101
|
safe: true, // Safe mode (validates inputs)
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Set split mode and manage BroadcastChannel
|
|
106
|
+
*/
|
|
107
|
+
setSplitMode = (mode: null | 'code' | 'output', sessionId?: string) => {
|
|
108
|
+
const prev = this._splitMode
|
|
109
|
+
this._splitMode = mode
|
|
110
|
+
if (sessionId) this._splitSessionId = sessionId
|
|
111
|
+
|
|
112
|
+
this.updateSplitButton()
|
|
113
|
+
|
|
114
|
+
const input = this.querySelector('.ts-input') as HTMLElement | null
|
|
115
|
+
const output = this.querySelector('.ts-output') as HTMLElement | null
|
|
116
|
+
const tsConsole = this.querySelector('.ts-console') as HTMLElement | null
|
|
117
|
+
const flags = this.querySelector('.build-flags') as HTMLElement | null
|
|
118
|
+
const toolbar = this.querySelector('.ts-toolbar') as HTMLElement | null
|
|
119
|
+
|
|
120
|
+
if (mode === 'code') {
|
|
121
|
+
if (output) output.style.display = 'none'
|
|
122
|
+
if (tsConsole) tsConsole.style.display = 'none'
|
|
123
|
+
if (input) input.style.flex = '1 1 100%'
|
|
124
|
+
} else if (mode === 'output') {
|
|
125
|
+
if (input) input.style.display = 'none'
|
|
126
|
+
if (flags) flags.style.display = 'none'
|
|
127
|
+
if (toolbar) toolbar.style.display = 'none'
|
|
128
|
+
if (output) output.style.flex = '1 1 100%'
|
|
129
|
+
} else {
|
|
130
|
+
if (input) {
|
|
131
|
+
input.style.display = ''
|
|
132
|
+
input.style.flex = ''
|
|
133
|
+
}
|
|
134
|
+
if (output) {
|
|
135
|
+
output.style.display = ''
|
|
136
|
+
output.style.flex = ''
|
|
137
|
+
}
|
|
138
|
+
if (tsConsole) tsConsole.style.display = ''
|
|
139
|
+
if (flags) flags.style.display = ''
|
|
140
|
+
if (toolbar) toolbar.style.display = ''
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this._splitChannel && prev) {
|
|
144
|
+
this._splitChannel.postMessage({
|
|
145
|
+
type: 'closed',
|
|
146
|
+
mode: prev,
|
|
147
|
+
sid: this._splitSessionId,
|
|
148
|
+
})
|
|
149
|
+
this._splitChannel.close()
|
|
150
|
+
this._splitChannel = null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!mode) {
|
|
154
|
+
this._splitSessionId = ''
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sid = this._splitSessionId
|
|
159
|
+
const channel = new BroadcastChannel('tjs-playground')
|
|
160
|
+
this._splitChannel = channel
|
|
161
|
+
|
|
162
|
+
if (mode === 'output') {
|
|
163
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
164
|
+
const msg = e.data
|
|
165
|
+
if (msg.sid !== sid) return
|
|
166
|
+
if (msg.type === 'ping') {
|
|
167
|
+
channel.postMessage({ type: 'pong', sid })
|
|
168
|
+
} else if (msg.type === 'code-change' && msg.view === 'ts') {
|
|
169
|
+
this.parts.tsEditor.value = msg.source
|
|
170
|
+
this.transpile().then(() => this.run())
|
|
171
|
+
} else if (msg.type === 'run' && msg.view === 'ts') {
|
|
172
|
+
this.run()
|
|
173
|
+
} else if (msg.type === 'closed' && msg.mode === 'code') {
|
|
174
|
+
this.setSplitMode(null)
|
|
175
|
+
this.dispatchEvent(
|
|
176
|
+
new CustomEvent('split-mode-change', {
|
|
177
|
+
detail: null,
|
|
178
|
+
bubbles: true,
|
|
179
|
+
})
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Request current source from code window
|
|
184
|
+
channel.postMessage({ type: 'request-source', view: 'ts', sid })
|
|
185
|
+
} else if (mode === 'code') {
|
|
186
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
187
|
+
const msg = e.data
|
|
188
|
+
if (msg.sid !== sid) return
|
|
189
|
+
if (msg.type === 'ping') {
|
|
190
|
+
channel.postMessage({ type: 'pong', sid })
|
|
191
|
+
} else if (msg.type === 'request-source' && msg.view === 'ts') {
|
|
192
|
+
// Output window is asking for current source — send it
|
|
193
|
+
this.broadcastSource()
|
|
194
|
+
} else if (msg.type === 'closed' && msg.mode === 'output') {
|
|
195
|
+
this.setSplitMode(null)
|
|
196
|
+
this.dispatchEvent(
|
|
197
|
+
new CustomEvent('split-mode-change', {
|
|
198
|
+
detail: null,
|
|
199
|
+
bubbles: true,
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Send initial source to output window immediately
|
|
205
|
+
setTimeout(() => this.broadcastSource(), 50)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private broadcastSource = () => {
|
|
210
|
+
if (this._splitMode === 'code' && this._splitChannel) {
|
|
211
|
+
this._splitChannel.postMessage({
|
|
212
|
+
type: 'code-change',
|
|
213
|
+
view: 'ts',
|
|
214
|
+
source: this.parts.tsEditor.value,
|
|
215
|
+
sid: this._splitSessionId,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
broadcastRun = () => {
|
|
221
|
+
if (this._splitMode === 'code' && this._splitChannel) {
|
|
222
|
+
this._splitChannel.postMessage({
|
|
223
|
+
type: 'run',
|
|
224
|
+
view: 'ts',
|
|
225
|
+
sid: this._splitSessionId,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
notifyClose = () => {
|
|
231
|
+
if (this._splitChannel && this._splitMode) {
|
|
232
|
+
this._splitChannel.postMessage({
|
|
233
|
+
type: 'closed',
|
|
234
|
+
mode: this._splitMode,
|
|
235
|
+
sid: this._splitSessionId,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handleSplitClick = () => {
|
|
241
|
+
if (this._splitMode === 'output') {
|
|
242
|
+
window.close()
|
|
243
|
+
return
|
|
244
|
+
} else if (this._splitMode === 'code') {
|
|
245
|
+
this.setSplitMode(null)
|
|
246
|
+
this.dispatchEvent(
|
|
247
|
+
new CustomEvent('split-mode-change', { detail: null, bubbles: true })
|
|
248
|
+
)
|
|
249
|
+
} else {
|
|
250
|
+
const sid = crypto.randomUUID().slice(0, 8)
|
|
251
|
+
const params = new URLSearchParams(window.location.hash.slice(1))
|
|
252
|
+
params.set('mode', 'output')
|
|
253
|
+
params.set('sid', sid)
|
|
254
|
+
const url = window.location.pathname + '#' + params.toString()
|
|
255
|
+
const win = window.open(url, `ts-output-${sid}`)
|
|
256
|
+
if (!win) return
|
|
257
|
+
|
|
258
|
+
// Apply split mode directly
|
|
259
|
+
this.setSplitMode('code', sid)
|
|
260
|
+
|
|
261
|
+
this.dispatchEvent(
|
|
262
|
+
new CustomEvent('split-mode-change', {
|
|
263
|
+
detail: { mode: 'code', sid },
|
|
264
|
+
bubbles: true,
|
|
265
|
+
})
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private updateSplitButton = () => {
|
|
271
|
+
const btn = this._splitBtn
|
|
272
|
+
if (!btn) return
|
|
273
|
+
|
|
274
|
+
btn.innerHTML = ''
|
|
275
|
+
if (this._splitMode === 'output') {
|
|
276
|
+
btn.style.display = ''
|
|
277
|
+
btn.classList.remove('split-btn-flip')
|
|
278
|
+
btn.title = 'Close output window'
|
|
279
|
+
btn.append(icons.x({ size: 16 }))
|
|
280
|
+
} else if (this._splitMode === 'code') {
|
|
281
|
+
btn.style.display = 'none'
|
|
282
|
+
} else {
|
|
283
|
+
btn.style.display = ''
|
|
284
|
+
btn.classList.add('split-btn-flip')
|
|
285
|
+
btn.title = 'Open output in new window'
|
|
286
|
+
btn.append(icons.copy({ size: 16 }))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private get _splitBtn(): HTMLButtonElement | null {
|
|
291
|
+
try {
|
|
292
|
+
return this.parts.splitBtn
|
|
293
|
+
} catch {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
98
298
|
content = () => [
|
|
99
299
|
// Toolbar
|
|
100
300
|
div(
|
|
@@ -251,6 +451,16 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
251
451
|
{ part: 'testsOutput', class: 'tests-output' },
|
|
252
452
|
'Test results will appear here'
|
|
253
453
|
)
|
|
454
|
+
),
|
|
455
|
+
button(
|
|
456
|
+
{
|
|
457
|
+
part: 'splitBtn',
|
|
458
|
+
slot: 'after-tabs',
|
|
459
|
+
class: 'split-btn split-btn-flip',
|
|
460
|
+
title: 'Open output in new window',
|
|
461
|
+
onClick: this.handleSplitClick,
|
|
462
|
+
},
|
|
463
|
+
icons.copy({ size: 16 })
|
|
254
464
|
)
|
|
255
465
|
)
|
|
256
466
|
)
|
|
@@ -284,6 +494,7 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
284
494
|
debounceTimer = setTimeout(() => {
|
|
285
495
|
this.transpile()
|
|
286
496
|
this.updateRevertButton()
|
|
497
|
+
this.broadcastSource()
|
|
287
498
|
}, 300)
|
|
288
499
|
})
|
|
289
500
|
}
|
|
@@ -468,6 +679,11 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
468
679
|
}
|
|
469
680
|
|
|
470
681
|
run = async () => {
|
|
682
|
+
// In code mode, broadcast run to output window
|
|
683
|
+
if (this._splitMode === 'code') {
|
|
684
|
+
this.broadcastRun()
|
|
685
|
+
}
|
|
686
|
+
|
|
471
687
|
this.clearConsole()
|
|
472
688
|
await this.transpile()
|
|
473
689
|
|
|
@@ -513,6 +729,7 @@ export class TSPlayground extends Component<TSPlaygroundParts> {
|
|
|
513
729
|
htmlContent,
|
|
514
730
|
importMapScript,
|
|
515
731
|
jsCode,
|
|
732
|
+
darkMode: document.body.classList.contains('darkmode'),
|
|
516
733
|
})
|
|
517
734
|
|
|
518
735
|
// Clean up any previous message handler
|
|
@@ -696,5 +913,27 @@ export const tsPlayground = TSPlayground.elementCreator({
|
|
|
696
913
|
display: 'flex',
|
|
697
914
|
flexDirection: 'column',
|
|
698
915
|
},
|
|
916
|
+
|
|
917
|
+
// Split button in tab bar
|
|
918
|
+
':host .split-btn': {
|
|
919
|
+
display: 'flex',
|
|
920
|
+
alignItems: 'center',
|
|
921
|
+
alignSelf: 'center',
|
|
922
|
+
padding: '4px 6px',
|
|
923
|
+
marginRight: '4px',
|
|
924
|
+
background: 'transparent',
|
|
925
|
+
border: 'none',
|
|
926
|
+
cursor: 'pointer',
|
|
927
|
+
color: 'var(--text-color, #6b7280)',
|
|
928
|
+
borderRadius: '4px',
|
|
929
|
+
opacity: '0.7',
|
|
930
|
+
},
|
|
931
|
+
':host .split-btn:hover': {
|
|
932
|
+
opacity: '1',
|
|
933
|
+
background: 'var(--code-background, #f3f4f6)',
|
|
934
|
+
},
|
|
935
|
+
':host .split-btn-flip svg': {
|
|
936
|
+
transform: 'scaleY(-1)',
|
|
937
|
+
},
|
|
699
938
|
},
|
|
700
939
|
}) as ElementCreator<TSPlayground>
|