diff --git a/tsl/content/Tour.md b/tsl/content/Guide.md similarity index 99% rename from tsl/content/Tour.md rename to tsl/content/Guide.md index a464b09bf5b454..a05ff25722a370 100644 --- a/tsl/content/Tour.md +++ b/tsl/content/Guide.md @@ -24,7 +24,6 @@ https://www.youtube.com/watch?v=iklqjgIpVG8 ### User Testimonials -https://x.com/mrdoob/status/1886416782673789317 https://x.com/mustache_dev/status/2010375315218944086 https://x.com/marcinignac/status/1805550271017144780&short https://x.com/mamesoncom/status/1842812329484017950 diff --git a/tsl/css/tour.css b/tsl/css/guide.css similarity index 86% rename from tsl/css/tour.css rename to tsl/css/guide.css index c311aa271c2119..41fc5f0aa4bd8a 100644 --- a/tsl/css/tour.css +++ b/tsl/css/guide.css @@ -86,6 +86,16 @@ body { font-size: 0.95rem; } +.markdown-content img { + max-width: 100%; + height: auto; + border-radius: 8px; + margin: 1.25rem 0; + display: block; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.3); +} + .markdown-content blockquote { margin: 1.5rem 0; padding: 0.85rem 1.25rem; @@ -445,16 +455,6 @@ body.preview-hidden .code-modifier-inline-btn.active { color: #ececec; } -.header-subtitle { - font-size: 0.75rem; - color: var(--text-muted); - font-weight: 500; - letter-spacing: 0.02em; - border-left: 1px solid var(--border-color); - padding-left: 0.6rem; - margin-top: 0.1rem; -} - .header-release { font-size: 0.75rem; font-weight: 500; @@ -464,7 +464,6 @@ body.preview-hidden .code-modifier-inline-btn.active { padding-left: 0.6rem; padding-top: 0.1rem; padding-bottom: 0.1rem; - margin-left: 0.4rem; margin-top: 0.1rem; user-select: none; display: inline-flex; @@ -1498,12 +1497,6 @@ body.v-resizer-editor-collapsed .resizer-v-toggle-btn:not(.inverted) { } } -@media (max-width: 600px) { - .header-subtitle { - display: none; - } -} - @media (min-width: 768px) { .content-col { width: 50%; @@ -2273,15 +2266,6 @@ body.preview-maximized .preview-hide-btn { background-color: var(--border-color) !important; } - /* Hide subtitle on mobile viewports */ - .header-subtitle { - display: none !important; - } - - .header-title-prefix { - display: none !important; - } - #copy-code-btn-header { display: flex !important; } @@ -2334,17 +2318,84 @@ body.preview-maximized .preview-hide-btn { .playground-tabs-bar { display: flex; align-items: center; - /*background: #111115;*/ + justify-content: space-between; border-bottom: 1px solid #2a2a35; height: 38px; + overflow: hidden; + user-select: none; + position: relative; + background: #15151a; +} + +.playground-tabs-scroll-wrapper { + flex: 1; + min-width: 0; + position: relative; + height: 100%; + display: flex; + overflow: hidden; +} + +.playground-tabs-scroll-container { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + height: 100%; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; - user-select: none; + -ms-overflow-style: none; + white-space: nowrap; } -.playground-tabs-bar::-webkit-scrollbar { +.playground-tabs-scroll-container::-webkit-scrollbar { display: none; + width: 0; + height: 0; +} + +.playground-custom-scrollbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: transparent; + z-index: 10; + cursor: pointer; + transition: height 0.15s; +} + +.playground-custom-scrollbar:hover { + height: 4px; +} + +.playground-custom-scrollbar-thumb { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #3e3e52; + border-radius: 1px; + cursor: grab; + transition: background 0.15s; +} + +.playground-custom-scrollbar-thumb:hover, +.playground-custom-scrollbar-thumb.dragging { + background: #007acc; + cursor: grabbing; +} + +.playground-tabs-actions { + display: flex; + align-items: center; + height: 100%; + flex-shrink: 0; + padding-right: 4px; + background: #15151a; + z-index: 5; } .playground-tab { @@ -2359,6 +2410,7 @@ body.preview-maximized .preview-hide-btn { font-family: 'Fira Code', sans-serif; font-size: 12px; position: relative; + flex-shrink: 0; transition: background 0.15s, color 0.15s; } @@ -2373,6 +2425,35 @@ body.preview-maximized .preview-hide-btn { border-bottom: 2px solid #007acc; } +.playground-tab.dragging { + opacity: 0.4; + cursor: grabbing; +} + +.playground-tab.drag-over-left { + box-shadow: inset 2px 0 0 0 #007acc; +} + +.playground-tab.drag-over-right { + box-shadow: inset -2px 0 0 0 #007acc; +} + +.playground-tab.read-only { + color: #9d9da6; +} + +.playground-tab-lock-icon { + display: inline-flex; + align-items: center; + margin-right: 6px; + color: #8e8e93; + flex-shrink: 0; +} + +.playground-tab.active .playground-tab-lock-icon { + color: #00aeff; +} + .playground-tab-label { white-space: nowrap; } @@ -2381,16 +2462,27 @@ body.preview-maximized .preview-hide-btn { display: flex; align-items: center; justify-content: center; - margin-left: 12px; + margin-left: 10px; width: 14px; height: 14px; color: #8e8e93; + opacity: 0; + pointer-events: none; + background: transparent; + border: none; line-height: 1; - transition: color 0.15s; + transition: color 0.15s, opacity 0.15s; +} + +.playground-tab:hover .playground-tab-close { + opacity: 0.65; + pointer-events: auto; } .playground-tab-close:hover { - color: #ff453a; + opacity: 1; + color: #ffffff; + background: transparent; } .playground-tab-add { @@ -2400,6 +2492,7 @@ body.preview-maximized .preview-hide-btn { width: 38px; height: 38px; min-width: 38px; + flex-shrink: 0; cursor: pointer; color: #8e8e93; font-size: 18px; @@ -2435,9 +2528,17 @@ body.preview-maximized .preview-hide-btn { background: transparent; color: #8e8e93; cursor: pointer; + outline: none !important; + box-shadow: none !important; transition: color 0.15s, opacity 0.15s; } +.playground-tab-btn:focus, +.playground-tab-btn:focus-visible { + outline: none !important; + box-shadow: none !important; +} + .playground-tab-btn svg { width: 16px; height: 16px; @@ -2453,10 +2554,6 @@ body.preview-maximized .preview-hide-btn { cursor: not-allowed; } -.playground-clean-btn { - margin-left: auto; -} - .inline-code-editor-container { margin-bottom: 1.25rem; border-radius: 6px; @@ -2655,7 +2752,7 @@ body.preview-maximized .preview-hide-btn { right: 60px; } -/* TSL Tour API Parameter Styling */ +/* TSL Guide API Parameter Styling */ .tsl-api-card { margin: 1.5rem 0; background-color: var(--bg-sidebar); @@ -2886,8 +2983,8 @@ body.preview-maximized .preview-hide-btn { font-weight: 300; } -/* Tour Important Callout Block */ -.tour-important-block { +/* Guide Important Callout Block */ +.guide-important-block { margin: 1.5rem 0; background-color: rgba(240, 160, 0, 0.04); border: 1px solid rgba(240, 160, 0, 0.15); @@ -2896,7 +2993,7 @@ body.preview-maximized .preview-hide-btn { padding: 0.85rem 1.25rem; } -.tour-important-header { +.guide-important-header { display: flex; align-items: center; gap: 0.4rem; @@ -2908,24 +3005,24 @@ body.preview-maximized .preview-hide-btn { letter-spacing: 0.05em; } -.tour-important-content { +.guide-important-content { font-size: 0.9rem; color: #d1d5db; line-height: 1.75; } -.tour-important-item { +.guide-important-item { line-height: 1.75; } -.tour-important-divider { +.guide-important-divider { height: 1px; background: rgba(240, 160, 0, 0.15); margin: 0.65rem 0; } -/* Tour Note Callout Block */ -.tour-note-block { +/* Guide Note Callout Block */ +.guide-note-block { margin: 1.5rem 0; background-color: rgba(0, 170, 255, 0.04); border: 1px solid rgba(0, 170, 255, 0.15); @@ -2934,7 +3031,7 @@ body.preview-maximized .preview-hide-btn { padding: 0.85rem 1.25rem; } -.tour-note-header { +.guide-note-header { display: flex; align-items: center; gap: 0.4rem; @@ -2946,24 +3043,24 @@ body.preview-maximized .preview-hide-btn { letter-spacing: 0.05em; } -.tour-note-content { +.guide-note-content { font-size: 0.9rem; color: #d1d5db; line-height: 1.75; } -.tour-note-item { +.guide-note-item { line-height: 1.75; } -.tour-note-divider { +.guide-note-divider { height: 1px; background: rgba(0, 170, 255, 0.15); margin: 0.65rem 0; } -/* Tour AI / LLM Accordion Callout Block */ -.tour-ai-accordion { +/* Guide AI / LLM Accordion Callout Block */ +.guide-ai-accordion { margin: 1.5rem 0; background: linear-gradient(135deg, rgba(168, 85, 247, 0.07) 0%, rgba(99, 102, 241, 0.04) 100%); border: 1px solid rgba(168, 85, 247, 0.25); @@ -2973,12 +3070,12 @@ body.preview-maximized .preview-hide-btn { transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } -.tour-ai-accordion:hover { +.guide-ai-accordion:hover { border-color: rgba(168, 85, 247, 0.45); box-shadow: 0 4px 20px -4px rgba(168, 85, 247, 0.15); } -.tour-ai-summary { +.guide-ai-summary { display: flex; align-items: center; justify-content: space-between; @@ -2990,29 +3087,29 @@ body.preview-maximized .preview-hide-btn { transition: background 0.15s ease; } -.tour-ai-summary::-webkit-details-marker, -.tour-ai-summary::marker { +.guide-ai-summary::-webkit-details-marker, +.guide-ai-summary::marker { display: none; } -.tour-ai-summary:hover { +.guide-ai-summary:hover { background: rgba(168, 85, 247, 0.06); } -.tour-ai-summary-left { +.guide-ai-summary-left { display: flex; align-items: center; gap: 0.55rem; } -.tour-ai-icon { +.guide-ai-icon { display: inline-flex; align-items: center; justify-content: center; color: #c084fc; } -.tour-ai-badge { +.guide-ai-badge { font-weight: 700; font-size: 0.85rem; letter-spacing: 0.05em; @@ -3022,7 +3119,7 @@ body.preview-maximized .preview-hide-btn { -webkit-text-fill-color: transparent; } -.tour-ai-hint { +.guide-ai-hint { font-size: 0.78rem; color: #9ca3af; opacity: 0.65; @@ -3030,7 +3127,7 @@ body.preview-maximized .preview-hide-btn { font-style: italic; } -.tour-ai-chevron { +.guide-ai-chevron { display: inline-flex; align-items: center; justify-content: center; @@ -3038,15 +3135,15 @@ body.preview-maximized .preview-hide-btn { transition: transform 0.25s ease; } -details.tour-ai-accordion[open] .tour-ai-chevron { +details.guide-ai-accordion[open] .guide-ai-chevron { transform: rotate(180deg); } -details.tour-ai-accordion[open] .tour-ai-hint { +details.guide-ai-accordion[open] .guide-ai-hint { display: none; } -.tour-ai-content { +.guide-ai-content { padding: 0.85rem 1.25rem 1rem 1.25rem; font-size: 0.9rem; color: #d1d5db; @@ -3055,12 +3152,12 @@ details.tour-ai-accordion[open] .tour-ai-hint { background: rgba(0, 0, 0, 0.15); } -.tour-ai-item { +.guide-ai-item { line-height: 1.75; margin-bottom: 0.5rem; } -.tour-ai-item:last-child { +.guide-ai-item:last-child { margin-bottom: 0; } @@ -3947,4 +4044,495 @@ body.loading .app-header { .mermaid code .tsl-identifier { color: #9cdcfe !important; +} + +/* TSL Modal Styles */ +@keyframes tslModalFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes tslModalScaleUp { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.tsl-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: tslModalFadeIn 0.18s ease-out; + user-select: none; +} + +.tsl-modal-container { + background: #15151a; + border: 1px solid #2e2e38; + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.06); + color: #d1d5db; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + display: flex; + flex-direction: column; + overflow: hidden; + animation: tslModalScaleUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.tsl-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #282836; + background: linear-gradient(180deg, #1b1b24 0%, #15151c 100%); +} + +.tsl-modal-title-group { + display: flex; + align-items: center; + gap: 10px; +} + +.tsl-modal-title-main { + font-size: 15px; + font-weight: 600; + color: #f9fafb; + letter-spacing: -0.01em; + line-height: 1.2; +} + +.tsl-modal-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: #8e8e93; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s; + padding: 0; +} + +.tsl-modal-close-btn:hover { + background: transparent; + color: #ffffff; +} + +/* Projects Modal Specifics */ +.tsl-projects-modal { + width: 740px; + max-width: 95vw; + height: 580px; + max-height: 88vh; +} + +.tsl-projects-body { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + padding: 16px 20px; + gap: 14px; +} + +.tsl-projects-top-actions { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 6px; + flex-wrap: wrap; +} + +.tsl-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + box-sizing: border-box; + transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s, transform 0.1s; + white-space: nowrap; +} + +.tsl-btn-sm { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + +.tsl-btn-primary { + background: linear-gradient(135deg, rgba(0, 122, 204, 0.28) 0%, rgba(0, 170, 255, 0.16) 100%); + border: 1px solid rgba(0, 170, 255, 0.42); + color: #38bdf8; +} + +.tsl-btn-primary:hover { + background: linear-gradient(135deg, rgba(0, 122, 204, 0.55) 0%, rgba(0, 170, 255, 0.38) 100%); + border-color: rgba(0, 170, 255, 0.75); + color: #ffffff; + box-shadow: 0 0 10px rgba(0, 170, 255, 0.22); +} + +.tsl-btn-secondary { + background: #252530; + border-color: #383848; + color: #d1d5db; +} + +.tsl-btn-secondary:hover { + background: #30303e; + color: #ffffff; + border-color: #48485c; +} + +.tsl-btn-danger { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.20) 0%, rgba(220, 38, 38, 0.10) 100%); + border: 1px solid rgba(239, 68, 68, 0.40); + color: #f87171; +} + +.tsl-btn-danger:hover { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.48) 0%, rgba(220, 38, 38, 0.32) 100%); + color: #ffffff; + border-color: rgba(239, 68, 68, 0.75); + box-shadow: 0 0 10px rgba(239, 68, 68, 0.22); +} + +.tsl-btn-sm.tsl-delete-btn { + width: 28px; + padding: 0; + flex-shrink: 0; +} + +.tsl-project-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #191922; + border: 1px solid #282834; + border-radius: 8px; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; + gap: 14px; +} + +.tsl-project-item:hover { + background: #1e1e28; + border-color: #383848; +} + +/* Empty Project Template Item (Dashed border pinned at top) */ +.tsl-project-item.tsl-project-template-item { + background: #14141c; + border: 1.5px dashed #3a3a4e; + border-radius: 8px; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.tsl-project-item.tsl-project-template-item:hover { + background: #181824; + border-color: #555570; +} + +.tsl-project-tag-template { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9ca3af; + background: rgba(156, 163, 175, 0.12); + border: 1px solid rgba(156, 163, 175, 0.25); + padding: 1px 6px; + border-radius: 4px; + line-height: 1.2; +} + +/* Open / Current Project Highlighting */ +.tsl-project-item.tsl-project-item-current { + background: #171d27; + border: 1px solid rgba(0, 122, 204, 0.45); + box-shadow: 0 0 12px rgba(0, 122, 204, 0.08); +} + +.tsl-project-item.tsl-project-item-current:hover { + background: #1a222f; + border-color: rgba(0, 122, 204, 0.65); +} + +.tsl-project-tag-active { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #00aaff; + background: rgba(0, 170, 255, 0.12); + border: 1px solid rgba(0, 170, 255, 0.25); + padding: 1px 6px; + border-radius: 4px; + line-height: 1.2; +} + +.tsl-project-tag-draft { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #f59e0b; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.25); + padding: 1px 6px; + border-radius: 4px; + line-height: 1.2; +} + +.tsl-projects-section-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #8e8e9b; + padding: 0 2px; + margin-top: 2px; +} + +.tsl-projects-count-badge { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #8e8e9b; +} + +.tsl-projects-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 4px; +} + +.tsl-projects-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 40px 20px; + color: #8e8e93; +} + +.tsl-projects-empty p { + margin: 12px 0 4px; + font-size: 15px; + font-weight: 500; + color: #c0c0cb; +} + +.tsl-projects-empty span { + font-size: 13px; +} + +.tsl-project-info { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; +} + +.tsl-project-name-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.tsl-project-name { + font-size: 14px; + font-weight: 600; + color: #f3f4f6; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.tsl-project-rename-btn { + background: transparent; + border: none; + color: #8e8e93; + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: flex; + align-items: center; + opacity: 0.5; + transition: opacity 0.15s, color 0.15s; +} + +.tsl-project-rename-btn:hover { + opacity: 1; + color: #ffffff; +} + +.tsl-project-rename-input { + background: #252530; + border: 1px solid #007acc; + color: #ffffff; + font-size: 13px; + padding: 2px 6px; + border-radius: 4px; + outline: none; + font-family: inherit; +} + +.tsl-project-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: #8e8e93; + flex-wrap: wrap; +} + +.tsl-project-tabs-badges { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.tsl-project-tab-badge { + background: #252532; + border: 1px solid #363646; + color: #9cdcfe; + font-family: 'Fira Code', monospace; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; +} + +.tsl-project-item-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.tsl-project-date { + white-space: nowrap; + font-size: 11px; + color: #8e8e9b; +} + +/* Responsive Mobile Layout for Projects Modal */ +@media (max-width: 640px) { + .tsl-modal-overlay { + padding: 8px; + } + + .tsl-projects-modal { + width: 100%; + max-width: 100%; + height: 92vh; + max-height: 92vh; + border-radius: 10px; + } + + .tsl-modal-header { + padding: 12px 14px; + } + + .tsl-projects-body { + padding: 12px 14px; + gap: 12px; + } + + .tsl-project-item { + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px; + } + + .tsl-project-info { + width: 100%; + } + + .tsl-project-name-row { + flex-wrap: wrap; + } + + .tsl-project-meta { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .tsl-project-tabs-badges { + width: 100%; + flex-wrap: wrap; + } + + .tsl-project-item-actions { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 8px; + } + + .tsl-project-item-actions .tsl-btn { + flex: 1; + min-width: 0; + padding: 0 8px; + font-size: 11px; + } + + .tsl-project-item-actions .tsl-delete-btn { + flex: 0 0 28px; + width: 28px; + } + + .tsl-project-template-item .tsl-project-item-actions { + width: 100%; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 8px; + } + + .tsl-project-template-item .tsl-project-item-actions .tsl-btn { + flex: 1; + } } \ No newline at end of file diff --git a/tsl/index.html b/tsl/index.html index accaa77e1ba67b..a437b587bb80b7 100644 --- a/tsl/index.html +++ b/tsl/index.html @@ -1,6 +1,6 @@ - Tour of TSL - Interactive Guide + TSL Guide @@ -12,7 +12,7 @@ - + diff --git a/tsl/js/Tour.js b/tsl/js/Guide.js similarity index 96% rename from tsl/js/Tour.js rename to tsl/js/Guide.js index a27f8e4a806220..738a18bbd1d847 100644 --- a/tsl/js/Tour.js +++ b/tsl/js/Guide.js @@ -3,7 +3,7 @@ import * as TSL from 'three/tsl'; import { Inspector } from 'three/addons/inspector/Inspector.js'; -import { parseTour, parse, tokenizeCodeToElement } from './utils/MarkdownUtils.js'; +import { parseGuide, parse, tokenizeCodeToElement } from './utils/MarkdownUtils.js'; import { CodeRunner } from './code/CodeRunner.js'; import { CodeCompiler } from './code/CodeCompiler.js'; import { CodeEditor } from './editor/CodeEditor.js'; @@ -12,7 +12,8 @@ import { HistoryManager } from './managers/HistoryManager.js'; import { LayoutManager } from './managers/LayoutManager.js'; import { ConsoleManager } from './managers/ConsoleManager.js'; import { PlaygroundManager } from './managers/PlaygroundManager.js'; -import { getSVG, compressString } from './utils/TourUtils.js'; +import { ProjectsManager } from './managers/ProjectsManager.js'; +import { getSVG, compressString } from './utils/GuideUtils.js'; import mermaid from 'mermaid'; const MOBILE_BREAKPOINT = 768; @@ -48,11 +49,12 @@ function getTwitterWidgets() { } -class Tour { +class Guide { - constructor( title = 'Tour of *TSL*' ) { + constructor( title = '*TSL* Guide' ) { - this.tourTitle = title; + this.guideTitle = title; + this.onEmptyProject = null; this.pages = []; this.pageTree = []; this.currentPageIndex = 0; @@ -82,8 +84,10 @@ class Tour { this.layoutManager = new LayoutManager( this ); this.consoleManager = new ConsoleManager( this ); this.playgroundManager = new PlaygroundManager( this ); + this.projectsManager = new ProjectsManager( this ); - this.lastTourPageHash = ''; + this.lastGuidePageHash = ''; + this.lastHandledHash = initialHash; this.debugStage = 'fragment'; this.debugLanguage = 'WGSL'; @@ -134,7 +138,7 @@ class Tour { setTitle( title ) { - this.tourTitle = title; + this.guideTitle = title; const parseParts = ( str ) => { @@ -166,9 +170,9 @@ class Tour { }; - const parts = parseParts( this.tourTitle ); + const parts = parseParts( this.guideTitle ); - const cleanTitle = this.tourTitle.replace( /\*/g, '' ); + const cleanTitle = this.guideTitle.replace( /\*/g, '' ); const headerTitleEl = this.dom?.headerTitle || document.querySelector( '.header-title' ); if ( headerTitleEl ) { @@ -200,19 +204,31 @@ class Tour { } - document.title = `${cleanTitle} - Interactive Guide`; + document.title = cleanTitle; return this; } + getEmptyProjectCode() { + + if ( typeof this.onEmptyProject === 'function' ) { + + return this.onEmptyProject(); + + } + + return '// TSL Guide\n'; + + } + async load( url ) { try { const response = await fetch( url ); const text = await response.text(); - const result = parseTour( text ); + const result = parseGuide( text ); this.pages = result.pages; this.pageTree = result.pageTree; @@ -220,7 +236,7 @@ class Tour { } catch ( err ) { - console.error( 'Error loading tour:', err ); + console.error( 'Error loading guide:', err ); } @@ -772,7 +788,8 @@ class Tour { } ) ); const release = THREE.RELEASE || THREE.REVISION; const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); - window.location.hash = newHash; + this.lastHandledHash = newHash; + history.replaceState( null, '', '#' + newHash ); const shareUrl = window.location.href; @@ -938,13 +955,26 @@ class Tour { this.dom.previewPlayground.onclick = () => { - this.dom.playgroundBtn.click(); + const activePage = this.pages[ this.currentPageIndex ]; + let currentCode = '// TSL Guide\n'; + const exampleTitle = activePage?.title || activePage?.name || 'Example'; + + if ( activePage && activePage.hasCode ) { + + currentCode = this.codeEditor ? this.codeEditor.getValue() : activePage.code; + + } + + this.playgroundManager.loadExampleIntoPlayground( currentCode, exampleTitle ); }; this.onWindowHashChange = () => { const hash = window.location.hash.substring( 1 ); + if ( hash === this.lastHandledHash ) return; + this.lastHandledHash = hash; + if ( hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ) ) { this.playgroundManager.loadPlaygroundFromHash( hash ); @@ -958,7 +988,7 @@ class Tour { } - this.lastTourPageHash = hash; + this.lastGuidePageHash = hash; const hashParts = hash.split( '&' ); const pageId = hashParts[ 0 ]; @@ -1018,20 +1048,13 @@ class Tour { if ( this.isPlaygroundActive ) { - const activeTab = this.playgroundManager.playgroundTabs.find( t => t.name === this.playgroundManager.activePlaygroundTabName ); - if ( activeTab ) { + const activeTab = this.playgroundManager.playgroundTabs?.find( t => t.name === this.playgroundManager.activePlaygroundTabName ); + if ( activeTab && ! activeTab.readOnly ) { activeTab.code = currentCode; } - const encoded = await compressString( JSON.stringify( { - tabs: this.playgroundManager.playgroundTabs - } ) ); - const release = THREE.RELEASE || THREE.REVISION; - const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); - window.location.hash = newHash; - } const page = this.pages[ this.currentPageIndex ]; @@ -1040,9 +1063,9 @@ class Tour { let activeNode = page.defaultNode || ''; if ( this.isPlaygroundActive ) { - if ( this.lastTourPageHash ) { + if ( this.lastGuidePageHash ) { - const lastHashParts = this.lastTourPageHash.split( '&' ); + const lastHashParts = this.lastGuidePageHash.split( '&' ); if ( lastHashParts[ 1 ] ) { activeNode = lastHashParts[ 1 ]; @@ -1110,6 +1133,7 @@ class Tour { if ( this.isPlaygroundActive ) { this.runPlayground(); + await this.playgroundManager.updatePlaygroundHash( true ); } else { @@ -1135,27 +1159,11 @@ class Tour { if ( this.isPlaygroundActive ) { - window.location.hash = this.lastTourPageHash || this.pages[ 0 ].id; + window.location.hash = this.lastGuidePageHash || this.pages[ 0 ].id; } else { - const activePage = this.pages[ this.currentPageIndex ]; - let currentCode = ''; - - if ( activePage && ! activePage.hasCode ) { - - currentCode = '// No example available.\nimport \'scenes/empty\';\n'; - - } else { - - currentCode = this.codeEditor ? this.codeEditor.getValue() : ''; - - } - - const encoded = await compressString( currentCode ); - const release = THREE.RELEASE || THREE.REVISION; - const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); - window.location.hash = newHash; + await this.playgroundManager.openExistingPlayground(); } @@ -1199,7 +1207,7 @@ class Tour { } - this.setTitle( this.tourTitle ); + this.setTitle( this.guideTitle ); document.body.classList.remove( 'loading' ); const loadingScreen = document.getElementById( 'loading-screen' ); @@ -1242,7 +1250,7 @@ class Tour { this.currentPageIndex = index; const page = this.pages[ index ]; - this.lastTourPageHash = page.id + ( activeNodeName ? '&' + activeNodeName : '' ); + this.lastGuidePageHash = page.id + ( activeNodeName ? '&' + activeNodeName : '' ); // Reset preview maximized state on page transitions this.isPreviewMaximized = false; @@ -1413,7 +1421,7 @@ class Tour { } else { - alert( 'You have completed the tour!' ); + alert( 'You have completed the guide!' ); } @@ -1439,6 +1447,11 @@ class Tour { }; + } else if ( href ) { + + link.setAttribute( 'target', '_blank' ); + link.setAttribute( 'rel', 'noopener noreferrer' ); + } } ); @@ -1496,6 +1509,7 @@ class Tour { // Update Code Editor if ( this.codeEditor ) { + this.codeEditor.setReadOnly( false ); this.codeEditor.setValue( pageCode ); const hash = window.location.hash.substring( 1 ); @@ -2722,4 +2736,4 @@ class Tour { } -export { Tour }; +export { Guide }; diff --git a/tsl/js/code/CodeCompiler.js b/tsl/js/code/CodeCompiler.js index a30f5cf462e569..7e0dcf84b7f780 100644 --- a/tsl/js/code/CodeCompiler.js +++ b/tsl/js/code/CodeCompiler.js @@ -1380,6 +1380,64 @@ ${declarationsStr} const importNodes = ast.body.filter( node => node.type === 'ImportDeclaration' ); const replacements = []; + function getFullLineRange( text, start, end ) { + + let lineStart = start; + while ( lineStart > 0 && text[ lineStart - 1 ] !== '\n' && text[ lineStart - 1 ] !== '\r' ) { + + if ( ! /\s/.test( text[ lineStart - 1 ] ) ) { + + return { start, end }; + + } + + lineStart --; + + } + + let lineEnd = end; + while ( lineEnd < text.length && text[ lineEnd ] !== '\n' && text[ lineEnd ] !== '\r' ) { + + if ( ! /\s/.test( text[ lineEnd ] ) && text[ lineEnd ] !== ';' ) { + + return { start, end }; + + } + + lineEnd ++; + + } + + if ( lineEnd < text.length ) { + + if ( text[ lineEnd ] === '\r' && text[ lineEnd + 1 ] === '\n' ) { + + lineEnd += 2; + + } else if ( text[ lineEnd ] === '\n' || text[ lineEnd ] === '\r' ) { + + lineEnd += 1; + + } + + } else if ( lineStart > 0 ) { + + if ( text[ lineStart - 1 ] === '\n' && lineStart > 1 && text[ lineStart - 2 ] === '\r' ) { + + lineStart -= 2; + + } else if ( text[ lineStart - 1 ] === '\n' || text[ lineStart - 1 ] === '\r' ) { + + lineStart -= 1; + + } + + } + + return { start: lineStart, end: lineEnd }; + + } + importNodes.forEach( node => { const moduleName = node.source.value; @@ -1395,9 +1453,10 @@ ${declarationsStr} if ( usedSpecifiers.length === 0 ) { + const range = getFullLineRange( code, node.start, node.end ); replacements.push( { - start: node.start, - end: node.end, + start: range.start, + end: range.end, replacement: '' } ); @@ -1508,6 +1567,7 @@ ${declarationsStr} ], 'no-multi-spaces': 2, 'no-extra-semi': 1, + 'no-multiple-empty-lines': [ 'error', { 'max': 1, 'maxBOF': 0, 'maxEOF': 1 } ], 'quotes': [ 'error', 'single' ], 'prefer-const': [ 'error', { 'destructuring': 'any', diff --git a/tsl/js/code/CodeRunner.js b/tsl/js/code/CodeRunner.js index f44f51bccb6e4a..9dd478c6b22d44 100644 --- a/tsl/js/code/CodeRunner.js +++ b/tsl/js/code/CodeRunner.js @@ -164,85 +164,120 @@ function stripImportDeclarations( code, declarations ) { function processExportDeclarations( code ) { - let cleanText = code; + let ast; + try { + + ast = acorn.parse( code, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch { + + return { cleanText: code, exportedSymbols: [] }; + + } + const exportedSymbols = []; + const replacements = []; + + const extractPattern = ( pattern ) => { - // 1. Parse braced exports (e.g., export { foo, bar as baz };) - const bracedExportRegex = /export\s*\{([\s\S]*?)\};?/g; - let bracedMatch; - while ( ( bracedMatch = bracedExportRegex.exec( cleanText ) ) !== null ) { + if ( ! pattern ) return []; + if ( pattern.type === 'Identifier' ) return [ pattern.name ]; + if ( pattern.type === 'ObjectPattern' ) { - const symbolList = bracedMatch[ 1 ].split( ',' ).map( s => s.trim() ).filter( Boolean ); - symbolList.forEach( symbol => { + const names = []; + pattern.properties.forEach( p => names.push( ...extractPattern( p.value || p.argument ) ) ); + return names; - let localName = symbol; - let exportName = symbol; - if ( symbol.includes( ' as ' ) ) { + } - const parts = symbol.split( /\s+as\s+/ ); - localName = parts[ 0 ].trim(); - exportName = parts[ 1 ].trim(); + if ( pattern.type === 'ArrayPattern' ) { - } + const names = []; + pattern.elements.forEach( el => names.push( ...extractPattern( el ) ) ); + return names; - exportedSymbols.push( { local: localName, export: exportName } ); + } - } ); + return []; - } + }; + + ast.body.forEach( node => { - cleanText = cleanText.replace( bracedExportRegex, '' ); + if ( node.type === 'ExportNamedDeclaration' ) { - // 2. Parse inline variable exports (e.g., export const foo = 1; or export let a = 1, b = 2;) - cleanText = cleanText.replace( /export\s+(const|let|var)\s+([^;\n]+)/g, ( match, type, decls ) => { + if ( node.declaration ) { - const parts = decls.split( ',' ); - parts.forEach( p => { + const decl = node.declaration; + const exportKwLength = decl.start - node.start; + const spaces = ' '.repeat( exportKwLength ); + replacements.push( { start: node.start, end: decl.start, replacement: spaces } ); - const name = p.trim().split( '=' )[ 0 ].trim().split( /\s+/ )[ 0 ]; - if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( name ) ) { + if ( decl.type === 'VariableDeclaration' ) { - exportedSymbols.push( { local: name, export: name } ); + decl.declarations.forEach( d => { + + extractPattern( d.id ).forEach( name => exportedSymbols.push( { local: name, export: name } ) ); + + } ); + + } else if ( decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' ) { + + if ( decl.id ) exportedSymbols.push( { local: decl.id.name, export: decl.id.name } ); + + } + + } else if ( node.specifiers ) { + + const snippet = code.substring( node.start, node.end ); + const linePreserved = snippet.replace( /[^\n]/g, ' ' ); + replacements.push( { start: node.start, end: node.end, replacement: linePreserved } ); + + node.specifiers.forEach( spec => { + + const local = spec.local ? ( spec.local.name || spec.local.value ) : null; + const exported = spec.exported ? ( spec.exported.name || spec.exported.value ) : local; + exportedSymbols.push( { local, export: exported } ); + + } ); } - } ); + } else if ( node.type === 'ExportDefaultDeclaration' ) { - return `${type} ${decls}`; + const decl = node.declaration; + if ( decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' ) { - } ); + if ( decl.id ) { - // 3. Parse inline function or class exports (e.g., export function foo() {}, export async function foo() {}) - cleanText = cleanText.replace( /export\s+(async\s+)?(function\*?|class)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g, ( match, asyncPrefix, type, name ) => { + const exportKwLength = decl.start - node.start; + const spaces = ' '.repeat( exportKwLength ); + replacements.push( { start: node.start, end: decl.start, replacement: spaces } ); + exportedSymbols.push( { local: decl.id.name, export: 'default' } ); - exportedSymbols.push( { local: name, export: name } ); - return `${asyncPrefix || ''}${type} ${name}`; + } else { - } ); + replacements.push( { start: node.start, end: decl.start, replacement: 'const __default_export__ = ' } ); + exportedSymbols.push( { local: '__default_export__', export: 'default' } ); - // 4. Parse default function/class declaration exports (e.g., export default function foo() {}) - cleanText = cleanText.replace( /export\s+default\s+(async\s+)?(function\*?|class)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g, ( match, asyncPrefix, type, name ) => { + } - exportedSymbols.push( { local: name, export: 'default' } ); - return `${asyncPrefix || ''}${type} ${name}`; + } else { - } ); + replacements.push( { start: node.start, end: decl.start, replacement: 'const __default_export__ = ' } ); + exportedSymbols.push( { local: '__default_export__', export: 'default' } ); - // 5. Parse default anonymous function/class exports (e.g., export default function() {}) - cleanText = cleanText.replace( /export\s+default\s+(async\s+)?(function\*?|class)\s*\(/g, ( match, asyncPrefix, type ) => { + } - const name = '__default_export__'; - exportedSymbols.push( { local: name, export: 'default' } ); - return `${asyncPrefix || ''}${type} ${name}(`; + } } ); - // 6. Parse default expression exports (e.g., export default foo;) - cleanText = cleanText.replace( /export\s+default\s+([^;]+);?/g, ( match, expression ) => { + replacements.sort( ( a, b ) => b.start - a.start ); + let cleanText = code; + replacements.forEach( r => { - const name = '__default_export__'; - exportedSymbols.push( { local: name, export: 'default' } ); - return `const ${name} = ${expression};`; + cleanText = cleanText.substring( 0, r.start ) + r.replacement + cleanText.substring( r.end ); } ); @@ -440,6 +475,31 @@ class CodeRunner extends EventDispatcher { } + call( methodName, ...args ) { + + for ( const name of this.activeScriptNames ) { + + const scriptConfig = this.scripts[ name ]; + const instance = scriptConfig ? scriptConfig.instance : null; + + if ( instance && typeof instance[ methodName ] === 'function' ) { + + try { + + instance[ methodName ]( ...args ); + + } catch ( e ) { + + console.error( `Error executing "${methodName}" on script "${name}":`, e ); + + } + + } + + } + + } + activateScript( name ) { const scriptConfig = this.scripts[ name ]; @@ -463,7 +523,65 @@ class CodeRunner extends EventDispatcher { } - async load( name ) { + invalidateScript( name ) { + + const scriptConfig = this.scripts[ name ]; + if ( ! scriptConfig ) return; + + if ( scriptConfig.instance ) { + + if ( scriptConfig.instance.dispose ) { + + try { + + scriptConfig.instance.dispose(); + + } catch ( e ) { + + console.error( `Error disposing script "${name}":`, e ); + + } + + } + + } + + scriptConfig.instance = null; + scriptConfig.promise = null; + scriptConfig.exportedKeys = new Set(); + + // Cascade invalidation to any script that depends on this script + for ( const [ otherName, otherConfig ] of Object.entries( this.scripts ) ) { + + if ( otherName !== name && otherConfig && otherConfig.dependencies && otherConfig.dependencies.includes( name ) ) { + + this.invalidateScript( otherName ); + + } + + } + + } + + dispose() { + + for ( const name of [ ...this.activeScriptNames ] ) { + + this.invalidateScript( name ); + + } + + if ( this.scripts[ '__main__' ] ) { + + this.invalidateScript( '__main__' ); + + } + + this.activeScriptNames = []; + + } + + async load( name, refreshedScripts = null, loadingStack = new Set() ) { const scriptConfig = this.scripts[ name ]; if ( ! scriptConfig ) return null; @@ -474,9 +592,35 @@ class CodeRunner extends EventDispatcher { } - if ( scriptConfig.instance ) return scriptConfig.instance; + if ( scriptConfig.instance ) { + + if ( refreshedScripts && ! refreshedScripts.has( name ) ) { + + refreshedScripts.add( name ); + + if ( scriptConfig.dependencies ) { + + for ( const dep of scriptConfig.dependencies ) { + + await this.load( dep, refreshedScripts, loadingStack ); + + } + + } + + if ( scriptConfig.instance.refresh ) { + + await scriptConfig.instance.refresh(); + + } + + } + + return scriptConfig.instance; + + } - if ( this.loadingScripts.has( name ) ) { + if ( loadingStack.has( name ) ) { return scriptConfig.instance || {}; @@ -484,7 +628,8 @@ class CodeRunner extends EventDispatcher { if ( ! scriptConfig.promise ) { - this.loadingScripts.add( name ); + const branchStack = new Set( loadingStack ); + branchStack.add( name ); scriptConfig.promise = ( async () => { @@ -497,175 +642,247 @@ class CodeRunner extends EventDispatcher { } else { - const response = await fetch( scriptConfig.url ); - if ( ! response.ok ) { + let response; + try { - throw new Error( `Failed to load script "${name}": Server returned status ${response.status}.` ); + response = await fetch( scriptConfig.url ); - } + } catch ( fetchErr ) { - text = await response.text(); - scriptConfig.text = text; + throw new Error( `Failed to load module "${name}". Network error: ${fetchErr.message || fetchErr}.` ); - } + } - const { importDeclarations, declaredSymbols } = parseScript( text ); + const contentType = response.headers.get( 'content-type' ) || ''; + if ( ! response.ok || contentType.includes( 'text/html' ) ) { - const symbols = []; - const values = []; + throw new Error( `Failed to load module "${name}". File not found at "${scriptConfig.url}".` ); - for ( const [ key, val ] of Object.entries( this.env ) ) { + } + + text = await response.text(); - if ( ! declaredSymbols.has( key ) ) { + if ( text.trim().startsWith( '<' ) ) { - symbols.push( key ); - values.push( val ); + throw new Error( `Failed to load module "${name}". Server returned HTML instead of JavaScript.` ); } + scriptConfig.text = text; + } - symbols.push( 'console' ); - values.push( this.customConsole ); + const { importDeclarations, declaredSymbols } = parseScript( text ); - const importPromises = []; + const loadedModules = new Map(); - importDeclarations.forEach( decl => { + const importPromises = importDeclarations.map( async ( decl ) => { const moduleName = decl.moduleName; - const fullMatch = decl.fullMatch; - importPromises.push( ( async () => { + let moduleObj = this.imports[ moduleName ]; + if ( ! moduleObj ) { - let moduleObj = this.imports[ moduleName ]; - if ( ! moduleObj ) { + const isStandard = isStandardModule( moduleName, this.imports ); + if ( ! isStandard ) { - const isStandard = isStandardModule( moduleName, this.imports ); - if ( ! isStandard ) { + const resolvedPath = resolvePath( name, moduleName ); + const baseName = resolvedPath.replace( /\.js$/, '' ); + if ( ! this.scripts[ baseName ] ) { - const resolvedPath = resolvePath( name, moduleName ); - const baseName = resolvedPath.replace( /\.js$/, '' ); - if ( ! this.scripts[ baseName ] ) { + this.scripts[ baseName ] = { + url: `./js/imports/scripts/${baseName}.js`, + instance: null, + promise: null, + dependencies: [] + }; - this.scripts[ baseName ] = { - url: `./js/imports/scripts/${baseName}.js`, - instance: null, - promise: null, - dependencies: [] - }; + } - } + if ( ! scriptConfig.dependencies.includes( baseName ) ) { - if ( ! scriptConfig.dependencies.includes( baseName ) ) { + scriptConfig.dependencies.push( baseName ); - scriptConfig.dependencies.push( baseName ); + } - } + try { - moduleObj = await this.load( baseName ); + moduleObj = await this.load( baseName, refreshedScripts, branchStack ); - } else { + } catch ( err ) { - try { + const lineNumber = text.substring( 0, decl.start ).split( '\n' ).length; + const error = new Error( `Failed to load import "${moduleName}" in script "${name}.js". ${err.message}` ); + error.customLineNumber = lineNumber; + throw error; - moduleObj = await import( moduleName ); + } - } catch ( err ) { + } else { - const charIndex = text.indexOf( fullMatch ); - const lineNumber = charIndex !== - 1 ? text.substring( 0, charIndex ).split( '\n' ).length : 1; - const error = new Error( `Failed to load import "${moduleName}" in script "${name}.js". Make sure the module path is correct.` ); - error.customLineNumber = lineNumber; - throw error; + try { - } + moduleObj = await import( moduleName ); + + } catch ( err ) { + + const lineNumber = text.substring( 0, decl.start ).split( '\n' ).length; + const error = new Error( `Failed to load import "${moduleName}" in script "${name}.js". Make sure the module path is correct.` ); + error.customLineNumber = lineNumber; + throw error; } } - if ( moduleObj ) { + } + + loadedModules.set( decl, moduleObj ); + + } ); + + if ( importPromises.length > 0 ) { + + await Promise.all( importPromises ); + + } + + const symbols = []; + const values = []; + + // 1. Process explicit import specifiers (named, namespace, default) or side-effect imports + for ( const decl of importDeclarations ) { + + const moduleObj = loadedModules.get( decl ); + if ( moduleObj ) { + + if ( decl.specifiers.length > 0 ) { decl.specifiers.forEach( spec => { if ( spec.type === 'named' ) { - symbols.push( spec.local ); - values.push( moduleObj[ spec.imported ] ); + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj[ spec.imported ] ); + + } } else if ( spec.type === 'namespace' ) { - symbols.push( spec.local ); - values.push( moduleObj ); + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj ); + + } } else if ( spec.type === 'default' ) { - symbols.push( spec.local ); - values.push( moduleObj[ 'default' ] ); + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj[ 'default' ] ); + + } } } ); + } else { + + // Side-effect import (e.g. import 'threejs-punk/scene';) + for ( const key of Object.keys( moduleObj ) ) { + + if ( ! LIFECYCLE_METHODS.includes( key ) && moduleObj[ key ] !== undefined ) { + + if ( ! symbols.includes( key ) && ! declaredSymbols.has( key ) ) { + + symbols.push( key ); + values.push( moduleObj[ key ] ); + + } + + } + + } + } - } )() ); + } - } ); + } - if ( importPromises.length > 0 ) { + // 2. Inject runner environment variables (e.g. renderer) not shadowed by local declarations + for ( const [ key, val ] of Object.entries( this.env ) ) { - await Promise.all( importPromises ); + if ( ! symbols.includes( key ) && ! declaredSymbols.has( key ) ) { + + symbols.push( key ); + values.push( val ); + + } } + symbols.push( 'console' ); + values.push( this.customConsole ); + const cleanImportsText = stripImportDeclarations( text, importDeclarations ); const { cleanText, exportedSymbols } = processExportDeclarations( cleanImportsText ); - const returnFields = LIFECYCLE_METHODS.map( name => `${name}: typeof ${name} !== 'undefined' ? ${name} : undefined` ); + const returnFields = []; exportedSymbols.forEach( symbol => { - if ( ! LIFECYCLE_METHODS.includes( symbol.export ) ) { + returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); - returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); + } ); - } + let wrapperFn; + try { - } ); + wrapperFn = new Function( ...symbols, `${cleanText}\nreturn { ${returnFields.join( ', ' )} };\n//# sourceURL=${name}.js` ); - const wrapperFn = new Function( ...symbols, `${cleanText}\nreturn { ${returnFields.join( ', ' )} };\n//# sourceURL=${name}.js` ); + } catch ( err ) { - scriptConfig.instance = wrapperFn( ...values ); + throw new Error( `Syntax error in script "${name}.js": ${err.message}` ); - if ( scriptConfig.instance ) { + } - for ( const key of Object.keys( scriptConfig.instance ) ) { + scriptConfig.exportedKeys = new Set(); - if ( ! LIFECYCLE_METHODS.includes( key ) ) { + try { - Object.defineProperty( this.env, key, { - get: () => scriptConfig.instance ? scriptConfig.instance[ key ] : undefined, - configurable: true, - enumerable: true - } ); + scriptConfig.instance = wrapperFn( ...values ); - } + } catch ( err ) { - } + throw new Error( `Error executing script "${name}.js": ${err.message}` ); } if ( scriptConfig.instance && scriptConfig.instance.init ) { - await scriptConfig.instance.init(); + try { + + await scriptConfig.instance.init(); + + } catch ( err ) { + + throw new Error( `Error in init() of script "${name}.js": ${err.message}` ); + + } } return scriptConfig.instance; - } finally { + } catch ( err ) { - this.loadingScripts.delete( name ); + scriptConfig.promise = null; + throw err; } @@ -673,21 +890,7 @@ class CodeRunner extends EventDispatcher { } - return scriptConfig.promise; - - } - call( name, ...args ) { - - this.activeScriptNames.forEach( scriptName => { - - const scriptConfig = this.scripts[ scriptName ]; - if ( scriptConfig && scriptConfig.instance && scriptConfig.instance[ name ] ) { - - scriptConfig.instance[ name ]( ...args ); - - } - - } ); + return await scriptConfig.promise; } @@ -696,10 +899,9 @@ class CodeRunner extends EventDispatcher { this.dispatchEvent( { type: 'start' } ); // Dispose previous main script - const prevMain = this.scripts[ '__main__' ]; - if ( prevMain && prevMain.instance && prevMain.instance.dispose ) { + if ( this.scripts[ '__main__' ] ) { - prevMain.instance.dispose(); + this.invalidateScript( '__main__' ); } @@ -712,10 +914,11 @@ class CodeRunner extends EventDispatcher { const importedCustomScripts = []; + const loadedStandardModules = new Map(); + for ( const decl of importDeclarations ) { const moduleName = decl.moduleName; - const fullMatch = decl.fullMatch; const isStandard = isStandardModule( moduleName, this.imports ); if ( ! isStandard ) { @@ -740,8 +943,8 @@ class CodeRunner extends EventDispatcher { } catch ( err ) { - const charIndex = code.indexOf( fullMatch ); - const lineNumber = charIndex !== - 1 ? code.substring( 0, charIndex ).split( '\n' ).length : 1; + const decl = importDeclarations.find( d => d.moduleName === moduleName ); + const lineNumber = decl ? code.substring( 0, decl.start ).split( '\n' ).length : 1; const error = new Error( `Failed to load import "${moduleName}" in script. Make sure the module path/importmap is correct.` ); error.customLineNumber = lineNumber; throw error; @@ -752,38 +955,7 @@ class CodeRunner extends EventDispatcher { if ( moduleObj ) { - decl.specifiers.forEach( spec => { - - if ( spec.type === 'named' ) { - - if ( ! symbols.includes( spec.local ) ) { - - symbols.push( spec.local ); - values.push( moduleObj[ spec.imported ] ); - - } - - } else if ( spec.type === 'namespace' ) { - - if ( ! symbols.includes( spec.local ) ) { - - symbols.push( spec.local ); - values.push( moduleObj ); - - } - - } else if ( spec.type === 'default' ) { - - if ( ! symbols.includes( spec.local ) ) { - - symbols.push( spec.local ); - values.push( moduleObj[ 'default' ] ); - - } - - } - - } ); + loadedStandardModules.set( decl, moduleObj ); } @@ -792,12 +964,12 @@ class CodeRunner extends EventDispatcher { } // Execute scene scripts dynamically - const activeModules = {}; const prevActiveCustomScripts = this.activeScriptNames.filter( name => name !== '__main__' ); + const refreshedScripts = new Set(); this.activeScriptNames = []; - // Load / Create active scripts + // 1. Load / Create active scripts (refreshing cached dependencies before dependents execute) for ( const baseName of importedCustomScripts ) { if ( ! this.scripts[ baseName ] ) { @@ -813,21 +985,20 @@ class CodeRunner extends EventDispatcher { try { - await this.load( baseName ); + await this.load( baseName, refreshedScripts ); } catch ( err ) { - // Find where the script was imported in the main editor code - const matchRegex = new RegExp( `import\\s+(?:[\\s\\S]*?\\s+from\\s+)?['"](\\.\\/)?${baseName}(\\.js)?['"];?`, 'i' ); - const match = code.match( matchRegex ); - if ( match ) { + const decl = importDeclarations.find( d => { - const charIndex = code.indexOf( match[ 0 ] ); - if ( charIndex !== - 1 ) { + const resolved = resolvePath( '__main__', d.moduleName ).replace( /\.js$/, '' ); + return resolved === baseName; - err.customLineNumber = code.substring( 0, charIndex ).split( '\n' ).length; + } ); - } + if ( decl ) { + + err.customLineNumber = code.substring( 0, decl.start ).split( '\n' ).length; } @@ -837,100 +1008,119 @@ class CodeRunner extends EventDispatcher { } - // Activate scripts recursively (building correct activeScriptNames order) + // 2. Activate scripts recursively (building correct activeScriptNames order) for ( const baseName of importedCustomScripts ) { this.activateScript( baseName ); } - // Dispose and clear removed scripts (using complete activeScriptNames list) + // 3. Dispose and clear removed scripts (using complete activeScriptNames list) const removedCustomScripts = prevActiveCustomScripts.filter( name => ! this.activeScriptNames.includes( name ) ); for ( const baseName of removedCustomScripts ) { + this.invalidateScript( baseName ); + + } + + // 4. Resize active custom scripts + for ( const baseName of this.activeScriptNames ) { + const scriptConfig = this.scripts[ baseName ]; - if ( scriptConfig ) { + const instance = scriptConfig ? scriptConfig.instance : null; + if ( instance ) { - if ( scriptConfig.instance ) { + if ( instance.resize && this.env.renderer ) { - if ( scriptConfig.instance.dispose ) { + const width = this.env.renderer.domElement.clientWidth; + const height = this.env.renderer.domElement.clientHeight; + if ( width > 0 && height > 0 ) { - scriptConfig.instance.dispose(); + instance.resize( width, height ); } - for ( const key of Object.keys( scriptConfig.instance ) ) { + } - if ( ! LIFECYCLE_METHODS.includes( key ) ) { + } - delete this.env[ key ]; + } - } + // 5. Process imports for main script scope + for ( const decl of importDeclarations ) { - } + const moduleName = decl.moduleName; + const isStandard = isStandardModule( moduleName, this.imports ); + let moduleObj; + if ( ! isStandard ) { - } + const resolvedPath = resolvePath( '__main__', moduleName ); + const baseName = resolvedPath.replace( /\.js$/, '' ); + const scriptConfig = this.scripts[ baseName ]; + moduleObj = scriptConfig ? scriptConfig.instance : null; - scriptConfig.instance = null; - scriptConfig.promise = null; + } else { + + moduleObj = loadedStandardModules.get( decl ); } - } + if ( moduleObj ) { - // Refresh, resize, and expose exports for all active custom scripts - for ( const baseName of this.activeScriptNames ) { + if ( decl.specifiers.length > 0 ) { - const scriptConfig = this.scripts[ baseName ]; - const instance = scriptConfig ? scriptConfig.instance : null; - if ( instance ) { - - if ( instance.refresh ) { + decl.specifiers.forEach( spec => { - await instance.refresh(); + if ( spec.type === 'named' ) { - } + if ( ! symbols.includes( spec.local ) ) { - if ( instance.resize && this.env.renderer ) { + symbols.push( spec.local ); + values.push( moduleObj[ spec.imported ] ); - const width = this.env.renderer.domElement.clientWidth; - const height = this.env.renderer.domElement.clientHeight; - if ( width > 0 && height > 0 ) { + } - instance.resize( width, height ); + } else if ( spec.type === 'namespace' ) { - } + if ( ! symbols.includes( spec.local ) ) { - } + symbols.push( spec.local ); + values.push( moduleObj ); - for ( const key of Object.keys( instance ) ) { + } - if ( ! LIFECYCLE_METHODS.includes( key ) && instance[ key ] !== undefined ) { + } else if ( spec.type === 'default' ) { - activeModules[ key ] = instance[ key ]; + if ( ! symbols.includes( spec.local ) ) { - const desc = Object.getOwnPropertyDescriptor( this.env, key ); - if ( ! desc || ! desc.get ) { + symbols.push( spec.local ); + values.push( moduleObj[ 'default' ] ); - this.env[ key ] = instance[ key ]; + } } - } + } ); - } + } else { - } + // Side-effect import (e.g. import 'threejs-punk/scene';) + for ( const key of Object.keys( moduleObj ) ) { - } + if ( ! LIFECYCLE_METHODS.includes( key ) && moduleObj[ key ] !== undefined ) { + + if ( ! symbols.includes( key ) && ! declaredSymbols.has( key ) ) { + + symbols.push( key ); + values.push( moduleObj[ key ] ); - // Inject active modules into parameters - for ( const [ name, obj ] of Object.entries( activeModules ) ) { + } + + } - if ( ! symbols.includes( name ) ) { + } - symbols.push( name ); - values.push( obj ); + } } @@ -955,14 +1145,10 @@ class CodeRunner extends EventDispatcher { const strippedImportsCode = stripImportDeclarations( code, importDeclarations ); const { cleanText: strippedCode, exportedSymbols } = processExportDeclarations( strippedImportsCode ); - const returnFields = LIFECYCLE_METHODS.map( name => `${name}: typeof ${name} !== 'undefined' ? ${name} : undefined` ); + const returnFields = []; exportedSymbols.forEach( symbol => { - if ( ! LIFECYCLE_METHODS.includes( symbol.export ) ) { - - returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); - - } + returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); } ); @@ -972,7 +1158,9 @@ class CodeRunner extends EventDispatcher { this.scripts[ '__main__' ] = { url: null, instance: instance, - promise: Promise.resolve( instance ) + promise: Promise.resolve( instance ), + dependencies: importedCustomScripts, + exportedKeys: new Set( Object.keys( instance || {} ) ) }; this.activeScriptNames.push( '__main__' ); @@ -999,15 +1187,22 @@ class CodeRunner extends EventDispatcher { } catch ( e ) { // Parse error stack to find line/col - let line = e.customLineNumber !== undefined ? e.customLineNumber : null; + let line = ( e.customLineNumber !== undefined && e.customLineNumber !== null && ! isNaN( e.customLineNumber ) && e.customLineNumber > 0 ) + ? e.customLineNumber + : null; let column = null; if ( line === null && e.stack ) { const pgMatch = e.stack.match( /playground-eval\.js:(\d+):(\d+)/ ); if ( pgMatch ) { - line = parseInt( pgMatch[ 1 ] ) - 2; - column = parseInt( pgMatch[ 2 ] ); + const parsedLine = parseInt( pgMatch[ 1 ] ) - 2; + if ( parsedLine > 0 ) { + + line = parsedLine; + column = parseInt( pgMatch[ 2 ] ); + + } } else { @@ -1015,8 +1210,13 @@ class CodeRunner extends EventDispatcher { const match = e.stack.match( /:(\d+):(\d+)/ ); if ( match ) { - line = parseInt( match[ 1 ] ) - 2; - column = parseInt( match[ 2 ] ); + const parsedLine = parseInt( match[ 1 ] ) - 2; + if ( parsedLine > 0 ) { + + line = parsedLine; + column = parseInt( match[ 2 ] ); + + } } else { @@ -1024,8 +1224,13 @@ class CodeRunner extends EventDispatcher { const ffMatch = e.stack.match( /Function:(\d+):(\d+)/ ); if ( ffMatch ) { - line = parseInt( ffMatch[ 1 ] ) - 2; - column = parseInt( ffMatch[ 2 ] ); + const parsedLine = parseInt( ffMatch[ 1 ] ) - 2; + if ( parsedLine > 0 ) { + + line = parsedLine; + column = parseInt( ffMatch[ 2 ] ); + + } } @@ -1036,7 +1241,7 @@ class CodeRunner extends EventDispatcher { } let displayMessage = e.message || e.toString(); - if ( line !== null && line > 0 ) { + if ( line !== null && ! isNaN( line ) && line > 0 ) { displayMessage = `Line ${line}: ${displayMessage}`; @@ -1054,46 +1259,6 @@ class CodeRunner extends EventDispatcher { } - dispose() { - - for ( const baseName of Object.keys( this.scripts ) ) { - - const scriptConfig = this.scripts[ baseName ]; - if ( scriptConfig && scriptConfig.instance ) { - - if ( scriptConfig.instance.dispose ) { - - try { - - scriptConfig.instance.dispose(); - - } catch ( e ) { - - console.error( `Error disposing script ${baseName}:`, e ); - - } - - } - - for ( const key of Object.keys( scriptConfig.instance ) ) { - - if ( ! LIFECYCLE_METHODS.includes( key ) ) { - - delete this.env[ key ]; - - } - - } - - } - - } - - this.scripts = {}; - this.activeScriptNames = []; - - } - } -export { CodeRunner }; +export { CodeRunner, parseScript, isStandardModule, resolvePath }; diff --git a/tsl/js/editor/CodeEditor.js b/tsl/js/editor/CodeEditor.js index d33940381276d4..6942194bac317c 100644 --- a/tsl/js/editor/CodeEditor.js +++ b/tsl/js/editor/CodeEditor.js @@ -758,7 +758,7 @@ class CodeEditor extends EventDispatcher { updateHighlights(); - if ( this.isProgrammaticChange ) return; + if ( this.readOnly || this.isProgrammaticChange ) return; this.dispatchEvent( { type: 'change', value: this.editor.getValue() } ); @@ -922,9 +922,26 @@ class CodeEditor extends EventDispatcher { } + setReadOnly( readOnly ) { + + this.readOnly = readOnly; + + if ( this.editor ) { + + this.editor.updateOptions( { + readOnly: readOnly, + renderLineHighlight: readOnly ? 'none' : 'line', + hideCursorInOverviewRuler: readOnly, + overviewRulerBorder: ! readOnly + } ); + + } + + } + format( formatted ) { - if ( ! this.editor ) return; + if ( ! this.editor || this.readOnly ) return; const model = this.editor.getModel(); this.isProgrammaticChange = true; try { diff --git a/tsl/js/managers/ConsoleManager.js b/tsl/js/managers/ConsoleManager.js index d96d02c9cf960e..bf5f0f12c81e9e 100644 --- a/tsl/js/managers/ConsoleManager.js +++ b/tsl/js/managers/ConsoleManager.js @@ -2,9 +2,9 @@ import { setConsoleFunction, getConsoleFunction } from 'three'; class ConsoleManager { - constructor( tour ) { + constructor( guide ) { - this.tour = tour; + this.guide = guide; this.originalConsoleError = console.error; this.originalConsoleWarn = console.warn; @@ -42,7 +42,7 @@ class ConsoleManager { if ( stackTrace && stackTrace.stack && stackTrace.stack.length > 0 ) { const frame = stackTrace.stack.find( f => f.file === 'playground-eval.js' ); - if ( frame ) { + if ( frame && frame.line && frame.line > 2 ) { line = frame.line - 2; column = frame.column; @@ -80,13 +80,13 @@ class ConsoleManager { } let cleanMsg = msg; - if ( line !== null ) { + if ( line !== null && ! isNaN( line ) && line > 0 ) { cleanMsg = cleanMsg.replace( /\s+(?:["']?[a-zA-Z0-9_$]+\(\)["']?\s+at\s+)?["']?[^"'\s]+\.js:\d+["']?/, '' ); } - const displayMessage = line !== null ? `Line ${line}: ${cleanMsg}` : cleanMsg; + const displayMessage = ( line !== null && ! isNaN( line ) && line > 0 ) ? `Line ${line}: ${cleanMsg}` : cleanMsg; let eventType = 'log'; if ( type === 'error' ) eventType = 'error-log'; @@ -94,7 +94,7 @@ class ConsoleManager { if ( eventType === 'error-log' || eventType === 'warn-log' ) { - this.tour.runner.dispatchEvent( { + this.guide.runner.dispatchEvent( { type: eventType, message: displayMessage, line: line, @@ -104,7 +104,7 @@ class ConsoleManager { } else { - this.tour.runner.dispatchEvent( { type: eventType, message: msg } ); + this.guide.runner.dispatchEvent( { type: eventType, message: msg } ); } @@ -154,16 +154,26 @@ class ConsoleManager { let match = stack.match( /playground-eval\.js:(\d+):(\d+)/ ); if ( match ) { - line = parseInt( match[ 1 ] ) - 2; - column = parseInt( match[ 2 ] ); + const parsedLine = parseInt( match[ 1 ] ) - 2; + if ( parsedLine > 0 ) { + + line = parsedLine; + column = parseInt( match[ 2 ] ); + + } } else { match = stack.match( /:(\d+):(\d+)/ ); if ( match ) { - line = parseInt( match[ 1 ] ) - 2; - column = parseInt( match[ 2 ] ); + const parsedLine = parseInt( match[ 1 ] ) - 2; + if ( parsedLine > 0 ) { + + line = parsedLine; + column = parseInt( match[ 2 ] ); + + } } @@ -172,9 +182,9 @@ class ConsoleManager { let cleanMsg = msg.split( '\n' )[ 0 ]; cleanMsg = cleanMsg.replace( /\s+["']?eval\(\)["']?\s+at\s+["']?[^"'\s]+\.js:\d+["']?/, '' ); - const displayMessage = line !== null ? `Line ${line}: ${cleanMsg}` : cleanMsg; + const displayMessage = ( typeof line === 'number' && line > 0 ) ? `Line ${line}: ${cleanMsg}` : cleanMsg; - this.tour.runner.dispatchEvent( { + this.guide.runner.dispatchEvent( { type: 'error-log', message: displayMessage, line: line, @@ -222,7 +232,7 @@ class ConsoleManager { } ).join( ' ' ); - this.tour.runner.dispatchEvent( { type: 'warn-log', message: msg } ); + this.guide.runner.dispatchEvent( { type: 'warn-log', message: msg } ); }; @@ -232,10 +242,10 @@ class ConsoleManager { this.onStart = () => { - this.tour.dom.consoleErrorMessage.textContent = ''; - if ( this.tour.codeEditor ) { + this.guide.dom.consoleErrorMessage.textContent = ''; + if ( this.guide.codeEditor ) { - this.tour.codeEditor.clearMarkers(); + this.guide.codeEditor.clearMarkers(); } @@ -262,9 +272,9 @@ class ConsoleManager { this.appendConsoleLine( event.message, '#fca5a5', event ); this.toggleConsole( false ); - if ( event.line !== null && event.line > 0 && this.tour.codeEditor ) { + if ( event.line !== null && event.line > 0 && this.guide.codeEditor ) { - this.tour.codeEditor.setErrorMarker( event.line, event.column, event.errorMsg || event.message ); + this.guide.codeEditor.setErrorMarker( event.line, event.column, event.errorMsg || event.message ); } @@ -272,14 +282,14 @@ class ConsoleManager { this.onSuccess = () => { - if ( this.tour.isPlaygroundActive ) { + if ( this.guide.isPlaygroundActive ) { - this.tour.updateDebugWGSL(); - setTimeout( () => this.tour.updateDebugWGSL(), 500 ); + this.guide.updateDebugWGSL(); + setTimeout( () => this.guide.updateDebugWGSL(), 500 ); } - if ( ! this.tour.dom.consoleErrorMessage.hasChildNodes() ) { + if ( ! this.guide.dom.consoleErrorMessage.hasChildNodes() ) { this.toggleConsole( true ); @@ -291,9 +301,9 @@ class ConsoleManager { this.appendConsoleLine( event.message, '#fca5a5', event ); - if ( event.line !== null && event.line > 0 && this.tour.codeEditor ) { + if ( event.line !== null && event.line > 0 && this.guide.codeEditor ) { - this.tour.codeEditor.setErrorMarker( event.line, event.column, event.error.toString() ); + this.guide.codeEditor.setErrorMarker( event.line, event.column, event.error.toString() ); } @@ -301,12 +311,33 @@ class ConsoleManager { }; - this.tour.runner.addEventListener( 'start', this.onStart ); - this.tour.runner.addEventListener( 'log', this.onLog ); - this.tour.runner.addEventListener( 'warn-log', this.onWarn ); - this.tour.runner.addEventListener( 'error-log', this.onErrorLog ); - this.tour.runner.addEventListener( 'success', this.onSuccess ); - this.tour.runner.addEventListener( 'error', this.onError ); + this.guide.runner.addEventListener( 'start', this.onStart ); + this.guide.runner.addEventListener( 'log', this.onLog ); + this.guide.runner.addEventListener( 'warn-log', this.onWarn ); + this.guide.runner.addEventListener( 'error-log', this.onErrorLog ); + this.guide.runner.addEventListener( 'success', this.onSuccess ); + this.guide.runner.addEventListener( 'error', this.onError ); + + } + + log( message ) { + + this.appendConsoleLine( message, '#e2e8f0' ); + this.toggleConsole( false ); + + } + + warn( message ) { + + this.appendConsoleLine( message, '#fde047' ); + this.toggleConsole( false ); + + } + + error( message ) { + + this.appendConsoleLine( message, '#fca5a5' ); + this.toggleConsole( false ); } @@ -321,7 +352,7 @@ class ConsoleManager { textSpan.textContent = message; line.appendChild( textSpan ); - if ( clickableEvent && clickableEvent.line !== null && clickableEvent.line > 0 && this.tour.codeEditor ) { + if ( clickableEvent && clickableEvent.line !== null && clickableEvent.line > 0 && this.guide.codeEditor ) { const jumpBtn = document.createElement( 'button' ); jumpBtn.className = 'console-jump-btn'; @@ -334,7 +365,7 @@ class ConsoleManager { jumpBtn.onclick = ( e ) => { e.stopPropagation(); - this.tour.codeEditor.revealLine( clickableEvent.line, clickableEvent.column || 1 ); + this.guide.codeEditor.revealLine( clickableEvent.line, clickableEvent.column || 1 ); }; @@ -342,18 +373,18 @@ class ConsoleManager { } - this.tour.dom.consoleErrorMessage.appendChild( line ); + this.guide.dom.consoleErrorMessage.appendChild( line ); // Instantiate icons if any were added - this.tour.createIcons( line ); + this.guide.createIcons( line ); - while ( this.tour.dom.consoleErrorMessage.childElementCount > 100 ) { + while ( this.guide.dom.consoleErrorMessage.childElementCount > 100 ) { - this.tour.dom.consoleErrorMessage.removeChild( this.tour.dom.consoleErrorMessage.firstChild ); + this.guide.dom.consoleErrorMessage.removeChild( this.guide.dom.consoleErrorMessage.firstChild ); } - this.tour.dom.consoleErrorMessage.scrollTop = this.tour.dom.consoleErrorMessage.scrollHeight; + this.guide.dom.consoleErrorMessage.scrollTop = this.guide.dom.consoleErrorMessage.scrollHeight; this.updateConsoleButtonsState(); @@ -361,8 +392,8 @@ class ConsoleManager { toggleConsole( forceState ) { - const consolePanel = this.tour.dom.editorConsole; - const toggleIcon = this.tour.dom.consoleToggleIcon; + const consolePanel = this.guide.dom.editorConsole; + const toggleIcon = this.guide.dom.consoleToggleIcon; const isMinimized = forceState !== undefined ? forceState : ! consolePanel.classList.contains( 'minimized' ); @@ -378,11 +409,11 @@ class ConsoleManager { } - this.tour.createIcons( this.tour.dom.consoleToggleBtn ); + this.guide.createIcons( this.guide.dom.consoleToggleBtn ); - if ( this.tour.codeEditor ) { + if ( this.guide.codeEditor ) { - this.tour.codeEditor.layout(); + this.guide.codeEditor.layout(); } @@ -390,30 +421,30 @@ class ConsoleManager { clearConsole() { - this.tour.dom.consoleErrorMessage.textContent = ''; + this.guide.dom.consoleErrorMessage.textContent = ''; this.updateConsoleButtonsState(); } copyConsole() { - const lines = Array.from( this.tour.dom.consoleErrorMessage.querySelectorAll( '.console-line-text' ) ) + const lines = Array.from( this.guide.dom.consoleErrorMessage.querySelectorAll( '.console-line-text' ) ) .map( span => span.textContent ); const text = lines.join( '\n' ); if ( ! text ) return; navigator.clipboard.writeText( text ).then( () => { - const btn = this.tour.dom.consoleCopyBtn; + const btn = this.guide.dom.consoleCopyBtn; btn.classList.add( 'success' ); btn.innerHTML = ''; - this.tour.createIcons( btn ); + this.guide.createIcons( btn ); setTimeout( () => { btn.classList.remove( 'success' ); btn.innerHTML = ''; - this.tour.createIcons( btn ); + this.guide.createIcons( btn ); }, 2000 ); @@ -423,9 +454,9 @@ class ConsoleManager { updateConsoleButtonsState() { - const hasLogs = this.tour.dom.consoleErrorMessage.childElementCount > 0; - const clearBtn = this.tour.dom.consoleClearBtn; - const copyBtn = this.tour.dom.consoleCopyBtn; + const hasLogs = this.guide.dom.consoleErrorMessage.childElementCount > 0; + const clearBtn = this.guide.dom.consoleClearBtn; + const copyBtn = this.guide.dom.consoleCopyBtn; if ( hasLogs ) { @@ -454,12 +485,12 @@ class ConsoleManager { console.error = this.originalConsoleError; console.warn = this.originalConsoleWarn; - this.tour.runner.removeEventListener( 'start', this.onStart ); - this.tour.runner.removeEventListener( 'log', this.onLog ); - this.tour.runner.removeEventListener( 'warn-log', this.onWarn ); - this.tour.runner.removeEventListener( 'error-log', this.onErrorLog ); - this.tour.runner.removeEventListener( 'success', this.onSuccess ); - this.tour.runner.removeEventListener( 'error', this.onError ); + this.guide.runner.removeEventListener( 'start', this.onStart ); + this.guide.runner.removeEventListener( 'log', this.onLog ); + this.guide.runner.removeEventListener( 'warn-log', this.onWarn ); + this.guide.runner.removeEventListener( 'error-log', this.onErrorLog ); + this.guide.runner.removeEventListener( 'success', this.onSuccess ); + this.guide.runner.removeEventListener( 'error', this.onError ); } diff --git a/tsl/js/managers/HistoryManager.js b/tsl/js/managers/HistoryManager.js index 6d66be7162e71e..3fc5d90f7d90a7 100644 --- a/tsl/js/managers/HistoryManager.js +++ b/tsl/js/managers/HistoryManager.js @@ -1,8 +1,8 @@ class HistoryManager { - constructor( tour ) { + constructor( guide ) { - this.tour = tour; + this.guide = guide; this.history = []; this.index = - 1; this.isUndoRedoAction = false; @@ -70,8 +70,8 @@ class HistoryManager { updateButtons() { - const undoBtn = this.tour.dom.tabsBar.querySelector( '.playground-undo-btn' ); - const redoBtn = this.tour.dom.tabsBar.querySelector( '.playground-redo-btn' ); + const undoBtn = this.guide.dom.tabsBar.querySelector( '.playground-undo-btn' ); + const redoBtn = this.guide.dom.tabsBar.querySelector( '.playground-redo-btn' ); if ( undoBtn ) { diff --git a/tsl/js/managers/LayoutManager.js b/tsl/js/managers/LayoutManager.js index 526340217a9cfa..2ea85a5ebd2f53 100644 --- a/tsl/js/managers/LayoutManager.js +++ b/tsl/js/managers/LayoutManager.js @@ -1,26 +1,26 @@ class LayoutManager { - constructor( tour ) { + constructor( guide ) { - this.tour = tour; + this.guide = guide; } toggleSidebar( force ) { - this.tour.isSidebarOpen = force !== undefined ? force : ! this.tour.isSidebarOpen; - if ( this.tour.isSidebarOpen ) { + this.guide.isSidebarOpen = force !== undefined ? force : ! this.guide.isSidebarOpen; + if ( this.guide.isSidebarOpen ) { - this.tour.dom.sidebar.classList.add( 'open' ); - this.tour.dom.menuToggleMain.style.display = 'none'; - this.tour.dom.headerSearchBtn.style.display = 'none'; + this.guide.dom.sidebar.classList.add( 'open' ); + this.guide.dom.menuToggleMain.style.display = 'none'; + this.guide.dom.headerSearchBtn.style.display = 'none'; } else { - this.tour.dom.sidebar.classList.remove( 'open' ); - this.tour.dom.menuToggleMain.style.display = 'flex'; - this.tour.dom.headerSearchBtn.style.display = 'flex'; - this.tour.openedViaHeaderSearch = false; + this.guide.dom.sidebar.classList.remove( 'open' ); + this.guide.dom.menuToggleMain.style.display = 'flex'; + this.guide.dom.headerSearchBtn.style.display = 'flex'; + this.guide.openedViaHeaderSearch = false; } @@ -28,11 +28,11 @@ class LayoutManager { setResizerToggleIcon( iconName ) { - const hResizerToggle = this.tour.dom.hResizerToggle; + const hResizerToggle = this.guide.dom.hResizerToggle; const currentIcon = hResizerToggle.querySelector( '[data-icon]' ); if ( currentIcon && currentIcon.getAttribute( 'data-icon' ) === iconName ) return; hResizerToggle.innerHTML = ``; - this.tour.createIcons( hResizerToggle ); + this.guide.createIcons( hResizerToggle ); } @@ -42,32 +42,32 @@ class LayoutManager { const isEditorCollapsed = height === '100%'; const iconName = isCollapsed ? 'chevron-down' : 'chevron-up'; - this.tour.setVResizerToggleIcon( iconName ); + this.guide.setVResizerToggleIcon( iconName ); const invertedIconName = isEditorCollapsed ? 'chevron-up' : 'chevron-down'; - this.tour.setVResizerToggleInvertedIcon( invertedIconName ); + this.guide.setVResizerToggleInvertedIcon( invertedIconName ); if ( isCollapsed ) { - this.tour.dom.vResizer.classList.add( 'collapsed' ); + this.guide.dom.vResizer.classList.add( 'collapsed' ); document.body.classList.add( 'v-resizer-collapsed' ); document.body.classList.add( 'preview-hidden' ); - this.tour.dom.headerPreviewToggle.innerHTML = ''; - this.tour.createIcons( this.tour.dom.headerPreviewToggle ); - this.tour.isPreviewVisible = false; + this.guide.dom.headerPreviewToggle.innerHTML = ''; + this.guide.createIcons( this.guide.dom.headerPreviewToggle ); + this.guide.isPreviewVisible = false; } else { - this.tour.dom.vResizer.classList.remove( 'collapsed' ); + this.guide.dom.vResizer.classList.remove( 'collapsed' ); document.body.classList.remove( 'v-resizer-collapsed' ); if ( ! isEditorCollapsed ) { document.body.classList.remove( 'preview-hidden' ); - this.tour.dom.headerPreviewToggle.innerHTML = ''; - this.tour.createIcons( this.tour.dom.headerPreviewToggle ); - this.tour.isPreviewVisible = true; + this.guide.dom.headerPreviewToggle.innerHTML = ''; + this.guide.createIcons( this.guide.dom.headerPreviewToggle ); + this.guide.isPreviewVisible = true; } @@ -75,12 +75,12 @@ class LayoutManager { if ( isEditorCollapsed ) { - this.tour.dom.vResizer.classList.add( 'editor-collapsed' ); + this.guide.dom.vResizer.classList.add( 'editor-collapsed' ); document.body.classList.add( 'v-resizer-editor-collapsed' ); } else { - this.tour.dom.vResizer.classList.remove( 'editor-collapsed' ); + this.guide.dom.vResizer.classList.remove( 'editor-collapsed' ); document.body.classList.remove( 'v-resizer-editor-collapsed' ); } @@ -89,8 +89,8 @@ class LayoutManager { toggleConsole( forceState ) { - const consolePanel = this.tour.dom.editorConsole; - const toggleIcon = this.tour.dom.consoleToggleIcon; + const consolePanel = this.guide.dom.editorConsole; + const toggleIcon = this.guide.dom.consoleToggleIcon; const isMinimized = forceState !== undefined ? forceState : ! consolePanel.classList.contains( 'minimized' ); @@ -106,10 +106,10 @@ class LayoutManager { } - this.tour.createIcons( this.tour.dom.consoleToggleBtn ); + this.guide.createIcons( this.guide.dom.consoleToggleBtn ); - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); - if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + if ( this.guide.codeEditor ) this.guide.codeEditor.layout(); + if ( this.guide.debugCodeEditor ) this.guide.debugCodeEditor.layout(); } @@ -117,192 +117,192 @@ class LayoutManager { const MOBILE_BREAKPOINT = 768; - this.tour.dom.hResizerToggle.addEventListener( 'pointerdown', ( e ) => { + this.guide.dom.hResizerToggle.addEventListener( 'pointerdown', ( e ) => { e.stopPropagation(); } ); - this.tour.dom.hResizerToggle.addEventListener( 'click', ( e ) => { + this.guide.dom.hResizerToggle.addEventListener( 'click', ( e ) => { e.stopPropagation(); - if ( this.tour.isEditorCollapsed ) { + if ( this.guide.isEditorCollapsed ) { document.body.classList.remove( 'collapsed-workspace' ); if ( window.innerWidth < MOBILE_BREAKPOINT ) { - this.tour.dom.contentCol.style.width = '0%'; - this.tour.dom.contentCol.style.display = 'none'; - this.tour.dom.editorCol.style.width = '100%'; + this.guide.dom.contentCol.style.width = '0%'; + this.guide.dom.contentCol.style.display = 'none'; + this.guide.dom.editorCol.style.width = '100%'; } else { - this.tour.dom.contentCol.style.width = this.tour.lastContentWidth; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = ''; + this.guide.dom.contentCol.style.width = this.guide.lastContentWidth; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = ''; } - this.tour.dom.editorCol.style.display = 'flex'; - this.tour.dom.hResizer.classList.remove( 'collapsed' ); + this.guide.dom.editorCol.style.display = 'flex'; + this.guide.dom.hResizer.classList.remove( 'collapsed' ); this.setResizerToggleIcon( 'chevron-right' ); - this.tour.isEditorCollapsed = false; + this.guide.isEditorCollapsed = false; - this.tour.isPreviewVisible = true; + this.guide.isPreviewVisible = true; document.body.classList.remove( 'preview-hidden' ); - this.tour.dom.headerPreviewToggle.innerHTML = ''; - this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + this.guide.dom.headerPreviewToggle.innerHTML = ''; + this.guide.createIcons( this.guide.dom.headerPreviewToggle ); } else { document.body.classList.add( 'collapsed-workspace' ); if ( window.innerWidth < MOBILE_BREAKPOINT ) { - this.tour.dom.contentCol.style.width = '100%'; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = '0%'; + this.guide.dom.contentCol.style.width = '100%'; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = '0%'; } else { - this.tour.lastContentWidth = this.tour.dom.contentCol.style.width || '50%'; - this.tour.dom.contentCol.style.width = '100%'; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = ''; + this.guide.lastContentWidth = this.guide.dom.contentCol.style.width || '50%'; + this.guide.dom.contentCol.style.width = '100%'; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = ''; } - this.tour.dom.editorCol.style.display = 'flex'; - this.tour.dom.hResizer.classList.add( 'collapsed' ); + this.guide.dom.editorCol.style.display = 'flex'; + this.guide.dom.hResizer.classList.add( 'collapsed' ); this.setResizerToggleIcon( 'chevron-left' ); - this.tour.isEditorCollapsed = true; + this.guide.isEditorCollapsed = true; - this.tour.isPreviewVisible = this.tour.lastReaderPreviewState; - document.body.classList.toggle( 'preview-hidden', ! this.tour.isPreviewVisible ); - this.tour.dom.headerPreviewToggle.innerHTML = this.tour.isPreviewVisible + this.guide.isPreviewVisible = this.guide.lastReaderPreviewState; + document.body.classList.toggle( 'preview-hidden', ! this.guide.isPreviewVisible ); + this.guide.dom.headerPreviewToggle.innerHTML = this.guide.isPreviewVisible ? '' : ''; - this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + this.guide.createIcons( this.guide.dom.headerPreviewToggle ); } - if ( this.tour.isPlaygroundActive ) { + if ( this.guide.isPlaygroundActive ) { - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); - if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + if ( this.guide.codeEditor ) this.guide.codeEditor.layout(); + if ( this.guide.debugCodeEditor ) this.guide.debugCodeEditor.layout(); } else { const currentHash = window.location.hash.substring( 1 ); const activeNode = currentHash.split( '&' )[ 1 ] || ''; - this.tour.renderPage( this.tour.currentPageIndex, activeNode, false ); + this.guide.renderPage( this.guide.currentPageIndex, activeNode, false ); } } ); - this.tour.dom.vResizerToggle.addEventListener( 'pointerdown', ( e ) => { + this.guide.dom.vResizerToggle.addEventListener( 'pointerdown', ( e ) => { e.stopPropagation(); } ); - this.tour.dom.vResizerToggle.addEventListener( 'click', ( e ) => { + this.guide.dom.vResizerToggle.addEventListener( 'click', ( e ) => { e.stopPropagation(); - const previewSection = this.tour.dom.previewSection; + const previewSection = this.guide.dom.previewSection; const currentHeight = previewSection.style.height; const isCollapsed = currentHeight === '0px' || currentHeight === '0%'; if ( isCollapsed ) { - const targetHeight = this.tour.lastPreviewHeight || '50%'; + const targetHeight = this.guide.lastPreviewHeight || '50%'; previewSection.style.height = targetHeight; this.updateVResizerIcons( targetHeight ); } else { - this.tour.lastPreviewHeight = currentHeight; + this.guide.lastPreviewHeight = currentHeight; previewSection.style.height = '0%'; this.updateVResizerIcons( '0%' ); } - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); - if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + if ( this.guide.codeEditor ) this.guide.codeEditor.layout(); + if ( this.guide.debugCodeEditor ) this.guide.debugCodeEditor.layout(); } ); - this.tour.dom.vResizerToggleInverted.addEventListener( 'pointerdown', ( e ) => { + this.guide.dom.vResizerToggleInverted.addEventListener( 'pointerdown', ( e ) => { e.stopPropagation(); } ); - this.tour.dom.vResizerToggleInverted.addEventListener( 'click', ( e ) => { + this.guide.dom.vResizerToggleInverted.addEventListener( 'click', ( e ) => { e.stopPropagation(); - const previewSection = this.tour.dom.previewSection; + const previewSection = this.guide.dom.previewSection; const currentHeight = previewSection.style.height; const isEditorCollapsed = currentHeight === '100%'; if ( isEditorCollapsed ) { - const targetHeight = this.tour.lastPreviewHeight || '50%'; + const targetHeight = this.guide.lastPreviewHeight || '50%'; previewSection.style.height = targetHeight; this.updateVResizerIcons( targetHeight ); } else { - this.tour.lastPreviewHeight = currentHeight; + this.guide.lastPreviewHeight = currentHeight; previewSection.style.height = '100%'; this.updateVResizerIcons( '100%' ); } - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); - if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + if ( this.guide.codeEditor ) this.guide.codeEditor.layout(); + if ( this.guide.debugCodeEditor ) this.guide.debugCodeEditor.layout(); } ); // Initial icon setup if ( window.innerWidth >= MOBILE_BREAKPOINT ) { - this.updateVResizerIcons( this.tour.dom.previewSection.style.height || '50%' ); + this.updateVResizerIcons( this.guide.dom.previewSection.style.height || '50%' ); } else { - const height = this.tour.dom.previewSection.style.height || '50%'; + const height = this.guide.dom.previewSection.style.height || '50%'; const isCollapsed = height === '0%' || height === '0px'; const isEditorCollapsed = height === '100%'; const iconName = isCollapsed ? 'chevron-down' : 'chevron-up'; - this.tour.setVResizerToggleIcon( iconName ); + this.guide.setVResizerToggleIcon( iconName ); const invertedIconName = isEditorCollapsed ? 'chevron-up' : 'chevron-down'; - this.tour.setVResizerToggleInvertedIcon( invertedIconName ); + this.guide.setVResizerToggleInvertedIcon( invertedIconName ); if ( isCollapsed ) { - this.tour.dom.vResizer.classList.add( 'collapsed' ); + this.guide.dom.vResizer.classList.add( 'collapsed' ); document.body.classList.add( 'v-resizer-collapsed' ); } else { - this.tour.dom.vResizer.classList.remove( 'collapsed' ); + this.guide.dom.vResizer.classList.remove( 'collapsed' ); } if ( isEditorCollapsed ) { - this.tour.dom.vResizer.classList.add( 'editor-collapsed' ); + this.guide.dom.vResizer.classList.add( 'editor-collapsed' ); document.body.classList.add( 'v-resizer-editor-collapsed' ); } else { - this.tour.dom.vResizer.classList.remove( 'editor-collapsed' ); + this.guide.dom.vResizer.classList.remove( 'editor-collapsed' ); document.body.classList.remove( 'v-resizer-editor-collapsed' ); } @@ -312,22 +312,22 @@ class LayoutManager { let isResizingH = false; let isResizingV = false; - this.tour.dom.hResizer.addEventListener( 'pointerdown', ( e ) => { + this.guide.dom.hResizer.addEventListener( 'pointerdown', ( e ) => { - if ( this.tour.isEditorCollapsed ) return; + if ( this.guide.isEditorCollapsed ) return; isResizingH = true; - this.tour.dom.hResizer.classList.add( 'dragging' ); - this.tour.dom.hResizer.setPointerCapture( e.pointerId ); + this.guide.dom.hResizer.classList.add( 'dragging' ); + this.guide.dom.hResizer.setPointerCapture( e.pointerId ); document.body.style.userSelect = 'none'; } ); - this.tour.dom.vResizer.addEventListener( 'pointerdown', ( e ) => { + this.guide.dom.vResizer.addEventListener( 'pointerdown', ( e ) => { - if ( this.tour.dom.vResizer.classList.contains( 'collapsed' ) || this.tour.dom.vResizer.classList.contains( 'editor-collapsed' ) ) return; + if ( this.guide.dom.vResizer.classList.contains( 'collapsed' ) || this.guide.dom.vResizer.classList.contains( 'editor-collapsed' ) ) return; isResizingV = true; - this.tour.dom.vResizer.classList.add( 'dragging' ); - this.tour.dom.vResizer.setPointerCapture( e.pointerId ); + this.guide.dom.vResizer.classList.add( 'dragging' ); + this.guide.dom.vResizer.setPointerCapture( e.pointerId ); document.body.style.userSelect = 'none'; } ); @@ -346,7 +346,7 @@ class LayoutManager { if ( newWidth > 20 && newWidth < 80 ) { - this.tour.dom.contentCol.style.width = `${ newWidth }%`; + this.guide.dom.contentCol.style.width = `${ newWidth }%`; } @@ -363,7 +363,7 @@ class LayoutManager { if ( newHeight > 10 && newHeight < 90 ) { const targetHeight = `${ newHeight }%`; - this.tour.dom.previewSection.style.height = targetHeight; + this.guide.dom.previewSection.style.height = targetHeight; this.updateVResizerIcons( targetHeight ); } @@ -376,8 +376,8 @@ class LayoutManager { isResizingH = false; isResizingV = false; - this.tour.dom.hResizer.classList.remove( 'dragging' ); - this.tour.dom.vResizer.classList.remove( 'dragging' ); + this.guide.dom.hResizer.classList.remove( 'dragging' ); + this.guide.dom.vResizer.classList.remove( 'dragging' ); document.body.style.userSelect = ''; }; diff --git a/tsl/js/managers/PlaygroundManager.js b/tsl/js/managers/PlaygroundManager.js index 41027f24a27750..ceef6d6bf1ef03 100644 --- a/tsl/js/managers/PlaygroundManager.js +++ b/tsl/js/managers/PlaygroundManager.js @@ -1,93 +1,96 @@ import * as THREE from 'three'; import { CodeCompiler } from '../code/CodeCompiler.js'; -import { compressString, decompressString } from '../utils/TourUtils.js'; +import { parseScript, isStandardModule, resolvePath } from '../code/CodeRunner.js'; +import { compressString, decompressString } from '../utils/GuideUtils.js'; class PlaygroundManager { - constructor( tour ) { + constructor( guide ) { - this.tour = tour; + this.guide = guide; this.playgroundTabs = null; this.activePlaygroundTabName = null; + this.currentExampleName = null; + this.initialTabsSnapshot = null; } togglePlayground( active ) { const MOBILE_BREAKPOINT = 768; - if ( this.tour.isPlaygroundActive === active ) return; + if ( this.guide.isPlaygroundActive === active ) return; - this.tour.isPlaygroundActive = active; + this.guide.isPlaygroundActive = active; document.body.classList.toggle( 'playground-mode', active ); - this.tour.dom.playgroundBtn.classList.toggle( 'active', active ); + this.guide.dom.playgroundBtn.classList.toggle( 'active', active ); if ( active ) { - if ( this.tour.renderer && this.tour.renderer.domElement.parentElement !== this.tour.dom.previewContainer ) { + if ( this.guide.renderer && this.guide.renderer.domElement.parentElement !== this.guide.dom.previewContainer ) { - this.tour.dom.previewContainer.appendChild( this.tour.renderer.domElement ); + this.guide.dom.previewContainer.appendChild( this.guide.renderer.domElement ); } - if ( this.tour.resizeObserver ) { + if ( this.guide.resizeObserver ) { - this.tour.resizeObserver.disconnect(); - this.tour.resizeObserver.observe( this.tour.dom.previewContainer ); + this.guide.resizeObserver.disconnect(); + this.guide.resizeObserver.observe( this.guide.dom.previewContainer ); } - this.tour.isPreviewVisible = true; + this.guide.isPreviewVisible = true; document.body.classList.remove( 'preview-hidden' ); if ( window.innerWidth < MOBILE_BREAKPOINT ) { // Mobile layout: go to workspace-editor mode const editorWorkspace = document.querySelector( '.editor-workspace' ); - editorWorkspace.insertBefore( this.tour.dom.codeContainer, this.tour.dom.debugContainer ); - editorWorkspace.appendChild( this.tour.dom.editorConsole ); + editorWorkspace.insertBefore( this.guide.dom.codeContainer, this.guide.dom.debugContainer ); + editorWorkspace.appendChild( this.guide.dom.editorConsole ); document.body.classList.remove( 'collapsed-workspace' ); - this.tour.dom.contentCol.style.width = '0%'; - this.tour.dom.contentCol.style.display = 'none'; - this.tour.dom.editorCol.style.width = '100%'; - this.tour.dom.editorCol.style.display = 'flex'; - this.tour.dom.vResizer.style.display = ''; - this.tour.dom.previewSection.style.height = ''; - this.tour.dom.previewSection.style.flex = ''; - this.tour.dom.debugContainer.style.display = 'none'; + this.guide.dom.contentCol.style.width = '0%'; + this.guide.dom.contentCol.style.display = 'none'; + this.guide.dom.editorCol.style.width = '100%'; + this.guide.dom.editorCol.style.display = 'flex'; + this.guide.dom.vResizer.style.display = ''; + this.guide.dom.previewSection.style.height = ''; + this.guide.dom.previewSection.style.flex = ''; + this.guide.dom.debugContainer.style.display = 'none'; } else { // Desktop layout: code editor on the left (replacing contentArea), preview taking top half of right column, debug container taking bottom half - this.tour.dom.contentArea.style.display = 'none'; - this.tour.dom.contentCol.appendChild( this.tour.dom.codeContainer ); - this.tour.dom.contentCol.appendChild( this.tour.dom.editorConsole ); - this.tour.dom.vResizer.style.display = 'block'; - this.tour.dom.previewSection.style.height = '50%'; - this.tour.dom.previewSection.style.flex = ''; - this.tour.dom.codeContainer.style.height = ''; - this.tour.dom.debugContainer.style.display = 'flex'; + this.guide.dom.contentArea.style.display = 'none'; + this.guide.dom.contentCol.appendChild( this.guide.dom.codeContainer ); + this.guide.dom.contentCol.appendChild( this.guide.dom.editorConsole ); + this.guide.dom.vResizer.style.display = 'block'; + this.guide.dom.previewSection.style.height = '50%'; + this.guide.dom.previewSection.style.flex = ''; + this.guide.dom.codeContainer.style.height = ''; + this.guide.dom.debugContainer.style.display = 'flex'; // Set column widths to default (50/50) or keep current horizontal split - if ( this.tour.isEditorCollapsed ) { + if ( this.guide.isEditorCollapsed ) { document.body.classList.add( 'collapsed-workspace' ); - this.tour.dom.hResizer.classList.add( 'collapsed' ); - this.tour.setResizerToggleIcon( 'chevron-left' ); - this.tour.dom.contentCol.style.width = '100%'; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = '0%'; - this.tour.dom.editorCol.style.display = 'flex'; + this.guide.dom.hResizer.classList.add( 'collapsed' ); + this.guide.setResizerToggleIcon( 'chevron-left' ); + this.guide.dom.contentCol.style.width = '100%'; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = '0%'; + this.guide.dom.editorCol.style.display = 'flex'; } else { document.body.classList.remove( 'collapsed-workspace' ); - this.tour.dom.hResizer.classList.remove( 'collapsed' ); - this.tour.setResizerToggleIcon( 'chevron-right' ); - this.tour.dom.contentCol.style.width = '50%'; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = '50%'; - this.tour.dom.editorCol.style.display = 'flex'; + this.guide.dom.hResizer.classList.remove( 'collapsed' ); + this.guide.setResizerToggleIcon( 'chevron-right' ); + this.guide.dom.contentCol.style.width = '50%'; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = '50%'; + this.guide.dom.editorCol.style.display = 'flex'; } @@ -95,73 +98,80 @@ class PlaygroundManager { } - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.guide.codeEditor ) this.guide.codeEditor.layout(); } else { - if ( ! this.tour.isContentRendered ) { + if ( ! this.guide.isContentRendered ) { - this.tour.renderPage( this.tour.currentPageIndex || 0 ); + this.guide.renderPage( this.guide.currentPageIndex || 0 ); } - this.tour.dom.debugContainer.style.display = 'none'; - this.tour.dom.contentArea.style.display = ''; + this.guide.dom.debugContainer.style.display = 'none'; + this.guide.dom.contentArea.style.display = ''; if ( window.innerWidth < MOBILE_BREAKPOINT ) { // Restore mobile layout (reader mode by default) - this.tour.isEditorCollapsed = true; + this.guide.isEditorCollapsed = true; document.body.classList.add( 'collapsed-workspace' ); - this.tour.dom.contentCol.style.width = '100%'; - this.tour.dom.contentCol.style.display = 'flex'; - this.tour.dom.editorCol.style.width = '0%'; - this.tour.dom.editorCol.style.display = 'none'; + this.guide.dom.contentCol.style.width = '100%'; + this.guide.dom.contentCol.style.display = 'flex'; + this.guide.dom.editorCol.style.width = '0%'; + this.guide.dom.editorCol.style.display = 'none'; } else { // Restore desktop layout - this.tour.dom.contentArea.style.display = ''; + this.guide.dom.contentArea.style.display = ''; const editorWorkspace = document.querySelector( '.editor-workspace' ); - editorWorkspace.insertBefore( this.tour.dom.codeContainer, this.tour.dom.debugContainer ); - editorWorkspace.appendChild( this.tour.dom.editorConsole ); - this.tour.dom.vResizer.style.display = ''; - this.tour.dom.previewSection.style.height = ''; - this.tour.dom.previewSection.style.flex = ''; - this.tour.dom.codeContainer.style.height = ''; + editorWorkspace.insertBefore( this.guide.dom.codeContainer, this.guide.dom.debugContainer ); + editorWorkspace.appendChild( this.guide.dom.editorConsole ); + this.guide.dom.vResizer.style.display = ''; + this.guide.dom.previewSection.style.height = ''; + this.guide.dom.previewSection.style.flex = ''; + this.guide.dom.codeContainer.style.height = ''; - this.tour.layoutManager.updateVResizerIcons( '' ); + this.guide.layoutManager.updateVResizerIcons( '' ); - if ( this.tour.isEditorCollapsed ) { + if ( this.guide.isEditorCollapsed ) { document.body.classList.add( 'collapsed-workspace' ); - this.tour.dom.hResizer.classList.add( 'collapsed' ); - this.tour.setResizerToggleIcon( 'chevron-left' ); - this.tour.dom.contentCol.style.width = '100%'; - this.tour.dom.editorCol.style.width = '0%'; + this.guide.dom.hResizer.classList.add( 'collapsed' ); + this.guide.setResizerToggleIcon( 'chevron-left' ); + this.guide.dom.contentCol.style.width = '100%'; + this.guide.dom.editorCol.style.width = '0%'; } else { document.body.classList.remove( 'collapsed-workspace' ); - this.tour.dom.hResizer.classList.remove( 'collapsed' ); - this.tour.setResizerToggleIcon( 'chevron-right' ); - this.tour.dom.contentCol.style.width = this.tour.lastContentWidth || '50%'; - this.tour.dom.editorCol.style.width = ''; + this.guide.dom.hResizer.classList.remove( 'collapsed' ); + this.guide.setResizerToggleIcon( 'chevron-right' ); + this.guide.dom.contentCol.style.width = this.guide.lastContentWidth || '50%'; + this.guide.dom.editorCol.style.width = ''; } } - if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( false ); + this.guide.codeEditor.layout(); + + } } - this.tour.updateUI(); + this.guide.updateUI(); } async loadPlaygroundFromHash( hash ) { + this.guide.lastHandledHash = hash; + const base64Str = hash.replace( /^playground[=\/]/, '' ).split( '&' )[ 0 ]; let decodedCode = ''; try { @@ -178,7 +188,7 @@ class PlaygroundManager { // Enable playground layout this.togglePlayground( true ); - this.tour.historyManager.pushState( hash ); + this.guide.historyManager.pushState( hash ); let decodedTabs = null; try { @@ -232,17 +242,25 @@ class PlaygroundManager { } + if ( ! this.initialTabsSnapshot ) { + + this.initialTabsSnapshot = JSON.stringify( this.playgroundTabs ); + + } + // Render the playground tabs UI this.renderPlaygroundTabs(); const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ) || this.playgroundTabs[ 0 ]; - if ( this.tour.codeEditor ) { + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( !! activeTab.readOnly ); - const currentVal = this.tour.codeEditor.getValue(); + const currentVal = this.guide.codeEditor.getValue(); if ( currentVal !== activeTab.code ) { - this.tour.codeEditor.setValue( activeTab.code ); + this.guide.codeEditor.setValue( activeTab.code ); } @@ -254,33 +272,153 @@ class PlaygroundManager { renderPlaygroundTabs() { - if ( ! this.tour.isPlaygroundActive ) { + if ( ! this.guide.isPlaygroundActive ) { - this.tour.dom.tabsBar.style.display = 'none'; + this.guide.dom.tabsBar.style.display = 'none'; return; } - this.tour.dom.tabsBar.style.display = 'flex'; - this.tour.dom.tabsBar.innerHTML = ''; + this.guide.dom.tabsBar.style.display = 'flex'; + this.guide.dom.tabsBar.innerHTML = ''; + + const scrollWrapper = document.createElement( 'div' ); + scrollWrapper.className = 'playground-tabs-scroll-wrapper'; + + const tabsScrollContainer = document.createElement( 'div' ); + tabsScrollContainer.className = 'playground-tabs-scroll-container'; + + const customScrollbar = document.createElement( 'div' ); + customScrollbar.className = 'playground-custom-scrollbar'; + const thumb = document.createElement( 'div' ); + thumb.className = 'playground-custom-scrollbar-thumb'; + customScrollbar.appendChild( thumb ); + + const updateScrollThumb = () => { + + const { clientWidth, scrollWidth, scrollLeft } = tabsScrollContainer; + if ( scrollWidth <= clientWidth + 2 ) { + + customScrollbar.style.display = 'none'; + return; + + } + + customScrollbar.style.display = 'block'; + const thumbWidth = Math.max( 28, ( clientWidth / scrollWidth ) * clientWidth ); + const maxScroll = scrollWidth - clientWidth; + const maxThumbLeft = clientWidth - thumbWidth; + const thumbLeft = ( scrollLeft / maxScroll ) * maxThumbLeft; + + thumb.style.width = `${thumbWidth}px`; + thumb.style.transform = `translateX(${thumbLeft}px)`; + + }; + + tabsScrollContainer.addEventListener( 'scroll', updateScrollThumb ); + + tabsScrollContainer.addEventListener( 'wheel', ( e ) => { + + if ( e.deltaY !== 0 ) { + + e.preventDefault(); + tabsScrollContainer.scrollLeft += e.deltaY; + + } + + }, { passive: false } ); + + // Dragging thumb + let isDraggingThumb = false; + let startX = 0; + let startScrollLeft = 0; + + thumb.onpointerdown = ( e ) => { + + e.stopPropagation(); + e.preventDefault(); + isDraggingThumb = true; + startX = e.clientX; + startScrollLeft = tabsScrollContainer.scrollLeft; + thumb.classList.add( 'dragging' ); + thumb.setPointerCapture( e.pointerId ); + + }; + + thumb.onpointermove = ( e ) => { + + if ( ! isDraggingThumb ) return; + const dx = e.clientX - startX; + const { clientWidth, scrollWidth } = tabsScrollContainer; + const thumbWidth = thumb.offsetWidth; + const maxThumbLeft = clientWidth - thumbWidth; + const maxScroll = scrollWidth - clientWidth; + if ( maxThumbLeft > 0 ) { + + const scrollDelta = ( dx / maxThumbLeft ) * maxScroll; + tabsScrollContainer.scrollLeft = startScrollLeft + scrollDelta; + + } + + }; + + thumb.onpointerup = ( e ) => { + + isDraggingThumb = false; + thumb.classList.remove( 'dragging' ); + try { + + thumb.releasePointerCapture( e.pointerId ); + + } catch ( _ ) {} + + }; + + customScrollbar.onclick = ( e ) => { + + if ( e.target === thumb ) return; + const rect = customScrollbar.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const { clientWidth, scrollWidth } = tabsScrollContainer; + const ratio = clickX / clientWidth; + tabsScrollContainer.scrollLeft = ratio * ( scrollWidth - clientWidth ); + + }; if ( ! this.playgroundTabs ) { this.playgroundTabs = [ { name: 'main', code: '// Play here!\n' } ]; this.activePlaygroundTabName = 'main'; + this.initialTabsSnapshot = JSON.stringify( this.playgroundTabs ); } + let draggedTabName = null; + this.playgroundTabs.forEach( ( tab ) => { const tabEl = document.createElement( 'div' ); tabEl.className = 'playground-tab'; + tabEl.draggable = true; + if ( tab.name === this.activePlaygroundTabName ) { tabEl.classList.add( 'active' ); } + if ( tab.readOnly ) { + + tabEl.classList.add( 'read-only' ); + + const lockEl = document.createElement( 'span' ); + lockEl.className = 'playground-tab-lock-icon'; + lockEl.innerHTML = ''; + lockEl.title = 'Read-only'; + tabEl.appendChild( lockEl ); + + } + const labelEl = document.createElement( 'span' ); labelEl.className = 'playground-tab-label'; labelEl.textContent = tab.name; @@ -290,8 +428,8 @@ class PlaygroundManager { const closeEl = document.createElement( 'span' ); closeEl.className = 'playground-tab-close'; - closeEl.innerHTML = ''; - closeEl.title = 'Delete tab'; + closeEl.innerHTML = ''; + closeEl.title = 'Close tab'; closeEl.onclick = ( e ) => { e.stopPropagation(); @@ -303,20 +441,122 @@ class PlaygroundManager { } - tabEl.onclick = () => { + let isDragging = false; + + tabEl.ondragstart = ( e ) => { + + draggedTabName = tab.name; + isDragging = true; + tabEl.classList.add( 'dragging' ); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData( 'text/plain', tab.name ); + + }; + + tabEl.ondragend = () => { + + tabEl.classList.remove( 'dragging' ); + this.guide.dom.tabsBar.querySelectorAll( '.playground-tab' ).forEach( el => { + + el.classList.remove( 'drag-over-left', 'drag-over-right' ); + + } ); + draggedTabName = null; + setTimeout( () => { + + isDragging = false; + + }, 0 ); - this.activatePlaygroundTab( tab.name ); + }; + + tabEl.ondragover = ( e ) => { + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + if ( draggedTabName && draggedTabName !== tab.name ) { + + const rect = tabEl.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + + if ( e.clientX < midX ) { + + tabEl.classList.add( 'drag-over-left' ); + tabEl.classList.remove( 'drag-over-right' ); + + } else { + + tabEl.classList.add( 'drag-over-right' ); + tabEl.classList.remove( 'drag-over-left' ); + + } + + } }; - labelEl.ondblclick = ( e ) => { + tabEl.ondragleave = () => { + + tabEl.classList.remove( 'drag-over-left', 'drag-over-right' ); + + }; + + tabEl.ondrop = ( e ) => { + + e.preventDefault(); + tabEl.classList.remove( 'drag-over-left', 'drag-over-right' ); + + const sourceName = e.dataTransfer.getData( 'text/plain' ) || draggedTabName; + if ( ! sourceName || sourceName === tab.name ) return; + + const fromIndex = this.playgroundTabs.findIndex( t => t.name === sourceName ); + let toIndex = this.playgroundTabs.findIndex( t => t.name === tab.name ); + + if ( fromIndex === - 1 || toIndex === - 1 ) return; + + const rect = tabEl.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const isRight = e.clientX >= midX; + + const [ movedTab ] = this.playgroundTabs.splice( fromIndex, 1 ); + + toIndex = this.playgroundTabs.findIndex( t => t.name === tab.name ); + if ( isRight ) { + + toIndex ++; + + } + + this.playgroundTabs.splice( toIndex, 0, movedTab ); + + this.renderPlaygroundTabs(); + this.updatePlaygroundHash( true ); + + }; + + tabEl.onclick = () => { + + if ( ! isDragging ) { - e.stopPropagation(); - this.startRenameTab( tab.name, labelEl ); + this.activatePlaygroundTab( tab.name ); + + } }; - this.tour.dom.tabsBar.appendChild( tabEl ); + if ( ! tab.readOnly ) { + + labelEl.ondblclick = ( e ) => { + + e.stopPropagation(); + this.startRenameTab( tab.name, labelEl ); + + }; + + } + + tabsScrollContainer.appendChild( tabEl ); } ); @@ -330,7 +570,38 @@ class PlaygroundManager { }; - this.tour.dom.tabsBar.appendChild( addBtn ); + tabsScrollContainer.appendChild( addBtn ); + + scrollWrapper.appendChild( tabsScrollContainer ); + scrollWrapper.appendChild( customScrollbar ); + + // Create Actions toolbar container pinned to the right + const actionsContainer = document.createElement( 'div' ); + actionsContainer.className = 'playground-tabs-actions'; + + // Create Projects button + const projectsBtn = document.createElement( 'button' ); + projectsBtn.className = 'playground-tab-btn playground-projects-btn'; + projectsBtn.innerHTML = ''; + projectsBtn.title = 'Playground Projects (Save, Load & JSON)'; + projectsBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.guide.projectsManager.openProjectsModal(); + + }; + + // Create Imported Files button + const importedFilesBtn = document.createElement( 'button' ); + importedFilesBtn.className = 'playground-tab-btn playground-imported-files-btn'; + importedFilesBtn.innerHTML = ''; + importedFilesBtn.title = 'Imported Files (Read-only)'; + importedFilesBtn.onclick = async ( e ) => { + + e.stopPropagation(); + await this.showImportedFiles(); + + }; // Create Clean & Format button const cleanBtn = document.createElement( 'button' ); @@ -354,7 +625,7 @@ class PlaygroundManager { e.stopPropagation(); - await this.tour.refresh(); + await this.guide.refresh(); }; @@ -381,10 +652,24 @@ class PlaygroundManager { }; - this.tour.dom.tabsBar.appendChild( cleanBtn ); - this.tour.dom.tabsBar.appendChild( undoBtn ); - this.tour.dom.tabsBar.appendChild( redoBtn ); - this.tour.dom.tabsBar.appendChild( refreshBtn ); + actionsContainer.appendChild( projectsBtn ); + actionsContainer.appendChild( importedFilesBtn ); + actionsContainer.appendChild( cleanBtn ); + actionsContainer.appendChild( undoBtn ); + actionsContainer.appendChild( redoBtn ); + actionsContainer.appendChild( refreshBtn ); + + this.guide.dom.tabsBar.appendChild( scrollWrapper ); + this.guide.dom.tabsBar.appendChild( actionsContainer ); + + const activeTabEl = tabsScrollContainer.querySelector( '.playground-tab.active' ); + if ( activeTabEl ) { + + activeTabEl.scrollIntoView( { behavior: 'smooth', block: 'nearest', inline: 'nearest' } ); + + } + + setTimeout( updateScrollThumb, 0 ); this.updateUndoRedoButtons(); @@ -395,30 +680,31 @@ class PlaygroundManager { if ( this.activePlaygroundTabName === name ) return; const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( activeTab && this.tour.codeEditor ) { + if ( activeTab && ! activeTab.readOnly && this.guide.codeEditor ) { - activeTab.code = this.tour.codeEditor.getValue(); + activeTab.code = this.guide.codeEditor.getValue(); } this.activePlaygroundTabName = name; const newActiveTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( newActiveTab && this.tour.codeEditor ) { + if ( newActiveTab && this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( !! newActiveTab.readOnly ); - const currentVal = this.tour.codeEditor.getValue(); + const currentVal = this.guide.codeEditor.getValue(); if ( currentVal !== newActiveTab.code ) { - this.tour.codeEditor.setValue( newActiveTab.code ); + this.guide.codeEditor.setValue( newActiveTab.code ); } } this.renderPlaygroundTabs(); - this.runPlayground(); - this.updatePlaygroundHash(); + this.updatePlaygroundHash( false ); } @@ -442,25 +728,26 @@ class PlaygroundManager { const newTabCode = `// Script: ${newTabName}\nexport { };\n`; const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( activeTab && this.tour.codeEditor ) { + if ( activeTab && ! activeTab.readOnly && this.guide.codeEditor ) { - activeTab.code = this.tour.codeEditor.getValue(); + activeTab.code = this.guide.codeEditor.getValue(); } this.playgroundTabs.push( { name: newTabName, code: newTabCode } ); this.activePlaygroundTabName = newTabName; - if ( this.tour.codeEditor ) { + if ( this.guide.codeEditor ) { - this.tour.codeEditor.setValue( newTabCode ); + this.guide.codeEditor.setReadOnly( false ); + this.guide.codeEditor.setValue( newTabCode ); } this.renderPlaygroundTabs(); this.runPlayground(); - this.updatePlaygroundHash(); + this.updatePlaygroundHash( true ); } @@ -470,9 +757,9 @@ class PlaygroundManager { if ( index === - 1 ) return; const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( activeTab && this.tour.codeEditor ) { + if ( activeTab && ! activeTab.readOnly && this.guide.codeEditor ) { - activeTab.code = this.tour.codeEditor.getValue(); + activeTab.code = this.guide.codeEditor.getValue(); } @@ -485,12 +772,14 @@ class PlaygroundManager { } const newActiveTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ) || this.playgroundTabs[ 0 ]; - if ( newActiveTab && this.tour.codeEditor ) { + if ( newActiveTab && this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( !! newActiveTab.readOnly ); - const currentVal = this.tour.codeEditor.getValue(); + const currentVal = this.guide.codeEditor.getValue(); if ( currentVal !== newActiveTab.code ) { - this.tour.codeEditor.setValue( newActiveTab.code ); + this.guide.codeEditor.setValue( newActiveTab.code ); } @@ -499,13 +788,14 @@ class PlaygroundManager { this.renderPlaygroundTabs(); this.runPlayground(); - this.updatePlaygroundHash(); + this.updatePlaygroundHash( true ); } startRenameTab( name, labelEl ) { - if ( name === 'main' ) return; + const tab = this.playgroundTabs?.find( t => t.name === name ); + if ( name === 'main' || tab?.readOnly ) return; const currentName = name; const input = document.createElement( 'input' ); @@ -531,9 +821,9 @@ class PlaygroundManager { if ( newName && isValidIdentifier && isUnique ) { const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( activeTab && this.tour.codeEditor ) { + if ( activeTab && ! activeTab.readOnly && this.guide.codeEditor ) { - activeTab.code = this.tour.codeEditor.getValue(); + activeTab.code = this.guide.codeEditor.getValue(); } @@ -552,7 +842,7 @@ class PlaygroundManager { this.renderPlaygroundTabs(); this.runPlayground(); - this.updatePlaygroundHash(); + this.updatePlaygroundHash( true ); } else { @@ -585,57 +875,72 @@ class PlaygroundManager { } - async updatePlaygroundHash() { + async updatePlaygroundHash( pushHistory = false ) { + if ( ! this.playgroundTabs ) return; + + this.guide.projectsManager.autoSaveCurrentProject(); + + const persistTabs = this.playgroundTabs.filter( t => ! t.readOnly ); const encoded = await compressString( JSON.stringify( { - tabs: this.playgroundTabs + tabs: persistTabs } ) ); - const revision = THREE.REVISION; - const newHash = 'playground=' + encoded + '&release=' + revision; - window.location.hash = newHash; + const release = THREE.REVISION; + const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); + + this.guide.lastHandledHash = newHash; + + if ( pushHistory ) { + + this.guide.historyManager.pushState( newHash ); + + } + + history.replaceState( null, '', '#' + newHash ); } undoPlayground() { - this.tour.historyManager.undo(); + this.guide.historyManager.undo(); } redoPlayground() { - this.tour.historyManager.redo(); + this.guide.historyManager.redo(); } updateUndoRedoButtons() { - this.tour.historyManager.updateButtons(); + this.guide.historyManager.updateButtons(); } async cleanAndFormatActiveTab() { - if ( ! this.tour.codeEditor ) return; + const activeTab = this.playgroundTabs?.find( t => t.name === this.activePlaygroundTabName ); + if ( ! this.guide.codeEditor || ( activeTab && activeTab.readOnly ) ) return; - const code = this.tour.codeEditor.getValue(); + const code = this.guide.codeEditor.getValue(); const formatted = await CodeCompiler.format( code ); // Set the new formatted value in the editor and update state/hash - const currentVal = this.tour.codeEditor.getValue(); + const currentVal = this.guide.codeEditor.getValue(); if ( currentVal !== formatted ) { - this.tour.codeEditor.format( formatted ); + this.guide.codeEditor.format( formatted ); - const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); - if ( activeTab ) { + const currentActiveTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( currentActiveTab ) { - activeTab.code = formatted; + currentActiveTab.code = formatted; } this.runPlayground(); - this.updatePlaygroundHash(); + this.updatePlaygroundHash( true ); } @@ -650,20 +955,14 @@ class PlaygroundManager { const tabNames = this.playgroundTabs.map( t => t.name ); // 1. Identify virtual scripts that are no longer in tabs (i.e. deleted tabs) - for ( const key of Object.keys( this.tour.runner.scripts ) ) { + for ( const key of Object.keys( this.guide.runner.scripts ) ) { - if ( this.tour.runner.scripts[ key ].url === null && key !== '__main__' ) { + if ( this.guide.runner.scripts[ key ].url === null && key !== '__main__' ) { if ( ! tabNames.includes( key ) ) { - const scriptConfig = this.tour.runner.scripts[ key ]; - if ( scriptConfig && scriptConfig.instance && scriptConfig.instance.dispose ) { - - scriptConfig.instance.dispose(); - - } - - delete this.tour.runner.scripts[ key ]; + this.guide.runner.invalidateScript( key ); + delete this.guide.runner.scripts[ key ]; } @@ -674,38 +973,30 @@ class PlaygroundManager { // 2. Add or update virtual script configs based on tabs this.playgroundTabs.forEach( tab => { - if ( tab.name !== 'main' ) { - - const existing = this.tour.runner.scripts[ tab.name ]; - if ( ! existing || existing.text !== tab.code ) { - - if ( existing && existing.instance && existing.instance.dispose ) { + if ( tab.name !== 'main' && ! tab.readOnly ) { - existing.instance.dispose(); - - } + this.guide.runner.invalidateScript( tab.name ); - this.tour.runner.scripts[ tab.name ] = { - url: null, - text: tab.code, - instance: null, - promise: null - }; - - } + this.guide.runner.scripts[ tab.name ] = { + url: null, + text: tab.code, + instance: null, + promise: null, + dependencies: [] + }; } } ); - this.tour.runner.run( mainTab.code ); + this.guide.runner.run( mainTab.code ); } getDebugTarget() { // 1. Check main script - const mainScript = this.tour.runner.scripts[ '__main__' ]; + const mainScript = this.guide.runner.scripts[ '__main__' ]; if ( mainScript && mainScript.instance && typeof mainScript.instance.debug === 'function' ) { return mainScript.instance.debug(); @@ -713,9 +1004,9 @@ class PlaygroundManager { } // 2. Check other scripts - for ( const scriptName of this.tour.runner.activeScriptNames ) { + for ( const scriptName of this.guide.runner.activeScriptNames ) { - const script = this.tour.runner.scripts[ scriptName ]; + const script = this.guide.runner.scripts[ scriptName ]; if ( script && script.instance && typeof script.instance.debug === 'function' ) { return script.instance.debug(); @@ -730,27 +1021,27 @@ class PlaygroundManager { updateDebugWGSL() { - if ( ! this.tour.isPlaygroundActive ) return; + if ( ! this.guide.isPlaygroundActive ) return; const debugData = this.getDebugTarget(); if ( ! debugData ) { - this.tour.debugCodeEditor.setValue( 'No debug() function exported or debug target object found.' ); + this.guide.debugCodeEditor.setValue( 'No debug() function exported or debug target object found.' ); // Collapse/hide debug container similar to clicking v-resizer-toggle-inverted - const previewSection = this.tour.dom.previewSection; + const previewSection = this.guide.dom.previewSection; const currentHeight = previewSection.style.height; if ( currentHeight !== '100%' ) { - this.tour.lastPreviewHeight = currentHeight; + this.guide.lastPreviewHeight = currentHeight; } previewSection.style.height = '100%'; - this.tour.layoutManager.updateVResizerIcons( '100%' ); + this.guide.layoutManager.updateVResizerIcons( '100%' ); - this.tour.codeEditor.layout(); - this.tour.debugCodeEditor.layout(); + this.guide.codeEditor.layout(); + this.guide.debugCodeEditor.layout(); return; } @@ -765,73 +1056,286 @@ class PlaygroundManager { } else { object = debugData; - scene = this.tour.runner.env.scene || this.tour.scene; - camera = this.tour.runner.env.camera || this.tour.camera; + scene = this.guide.runner.env.scene || this.guide.scene; + camera = this.guide.runner.env.camera || this.guide.camera; } if ( ! scene || ! camera ) { - this.tour.debugCodeEditor.setValue( 'Invalid debug data. Ensure scene, camera, and object are provided.' ); + this.guide.debugCodeEditor.setValue( 'Invalid debug data. Ensure scene, camera, and object are provided.' ); // Collapse/hide debug container - const previewSection = this.tour.dom.previewSection; + const previewSection = this.guide.dom.previewSection; const currentHeight = previewSection.style.height; if ( currentHeight !== '100%' ) { - this.tour.lastPreviewHeight = currentHeight; + this.guide.lastPreviewHeight = currentHeight; } previewSection.style.height = '100%'; - this.tour.layoutManager.updateVResizerIcons( '100%' ); + this.guide.layoutManager.updateVResizerIcons( '100%' ); - this.tour.codeEditor.layout(); - this.tour.debugCodeEditor.layout(); + this.guide.codeEditor.layout(); + this.guide.debugCodeEditor.layout(); return; } // Restore/expand debug container since we have valid debug data - const previewSection = this.tour.dom.previewSection; + const previewSection = this.guide.dom.previewSection; const currentHeight = previewSection.style.height; if ( currentHeight === '100%' ) { - const targetHeight = this.tour.lastPreviewHeight || '50%'; + const targetHeight = this.guide.lastPreviewHeight || '50%'; previewSection.style.height = targetHeight; - this.tour.layoutManager.updateVResizerIcons( targetHeight ); + this.guide.layoutManager.updateVResizerIcons( targetHeight ); - this.tour.codeEditor.layout(); - this.tour.debugCodeEditor.layout(); + this.guide.codeEditor.layout(); + this.guide.debugCodeEditor.layout(); } - const targetRenderer = this.tour.debugLanguage === 'GLSL' ? this.tour.webGLRenderer : this.tour.renderer; + const targetRenderer = this.guide.debugLanguage === 'GLSL' ? this.guide.webGLRenderer : this.guide.renderer; if ( targetRenderer && targetRenderer.debug && typeof targetRenderer.debug.getShaderAsync === 'function' ) { targetRenderer.debug.getShaderAsync( scene, camera, object ) .then( ( shader ) => { - const code = this.tour.debugStage === 'vertex' + const code = this.guide.debugStage === 'vertex' ? ( shader.vertexShader || 'No vertex shader generated.' ) : ( shader.fragmentShader || 'No fragment shader generated.' ); - this.tour.debugCodeEditor.setValue( code ); + this.guide.debugCodeEditor.setValue( code ); } ) .catch( ( err ) => { - this.tour.debugCodeEditor.setValue( 'Error retrieving shader: ' + err.message ); + this.guide.debugCodeEditor.setValue( 'Error retrieving shader: ' + err.message ); } ); } else { - this.tour.debugCodeEditor.setValue( 'WebGPURenderer debug.getShaderAsync is not available.' ); + this.guide.debugCodeEditor.setValue( 'WebGPURenderer debug.getShaderAsync is not available.' ); + + } + + } + + getImportedCustomScripts() { + + const customImports = new Set(); + const runnerImports = this.guide.runner ? this.guide.runner.imports : {}; + + if ( this.playgroundTabs ) { + + for ( const tab of this.playgroundTabs ) { + + if ( ! tab.readOnly && tab.code ) { + + const { importDeclarations } = parseScript( tab.code ); + for ( const decl of importDeclarations ) { + + const moduleName = decl.moduleName; + if ( ! isStandardModule( moduleName, runnerImports ) ) { + + const importerName = tab.name === 'main' ? '__main__' : tab.name; + const resolvedPath = resolvePath( importerName, moduleName ); + const baseName = resolvedPath.replace( /\.js$/, '' ); + customImports.add( baseName ); + + } + + } + + } + + } + + } + + if ( this.guide.runner && this.guide.runner.scripts ) { + + for ( const [ name, scriptConfig ] of Object.entries( this.guide.runner.scripts ) ) { + + if ( scriptConfig && scriptConfig.url && name !== '__main__' ) { + + if ( ! isStandardModule( name, runnerImports ) ) { + + customImports.add( name ); + + } + + } + + } } + return Array.from( customImports ); + + } + + async showImportedFiles() { + + const importedFiles = this.getImportedCustomScripts(); + if ( importedFiles.length === 0 ) { + + this.guide.consoleManager.log( 'No custom imported files found in current playground code.' ); + return; + + } + + let targetTabName = null; + + for ( const name of importedFiles ) { + + let tab = this.playgroundTabs.find( t => t.name === name ); + if ( ! tab ) { + + let code = this.guide.runner?.scripts?.[ name ]?.text; + if ( code === undefined || code === null ) { + + try { + + const response = await fetch( `./js/imports/scripts/${name}.js` ); + const contentType = response.headers.get( 'content-type' ) || ''; + if ( response.ok && ! contentType.includes( 'text/html' ) ) { + + const text = await response.text(); + if ( ! text.trim().startsWith( '<' ) ) { + + code = text; + + } else { + + console.warn( `Could not fetch imported file: ${name}` ); + continue; + + } + + } else { + + console.warn( `Could not fetch imported file: ${name}` ); + continue; + + } + + } catch ( err ) { + + console.warn( `Error loading imported file ${name}:`, err ); + continue; + + } + + } + + tab = { + name: name, + code: code, + readOnly: true + }; + + this.playgroundTabs.push( tab ); + + } + + if ( ! targetTabName ) { + + targetTabName = name; + + } + + } + + if ( targetTabName ) { + + this.renderPlaygroundTabs(); + this.activatePlaygroundTab( targetTabName ); + + } + + } + + hasUnsavedChanges() { + + if ( this.guide.projectsManager.currentProjectId ) return false; + if ( ! this.playgroundTabs || this.playgroundTabs.length === 0 ) return false; + if ( ! this.initialTabsSnapshot ) return false; + + const persistTabs = this.playgroundTabs.filter( t => ! t.readOnly ); + return JSON.stringify( persistTabs ) !== this.initialTabsSnapshot; + + } + + hasActiveCustomProject() { + + return this.hasUnsavedChanges(); + + } + + async openExistingPlayground() { + + if ( ! this.playgroundTabs || this.playgroundTabs.length === 0 ) { + + const activePage = this.guide.pages[ this.guide.currentPageIndex ]; + let currentCode = '// TSL Guide\n'; + + if ( activePage && activePage.hasCode && this.guide.codeEditor ) { + + currentCode = this.guide.codeEditor.getValue(); + + } + + this.playgroundTabs = [ { name: 'main', code: currentCode } ]; + this.activePlaygroundTabName = 'main'; + this.currentExampleName = activePage?.title || activePage?.name || 'Example'; + this.initialTabsSnapshot = JSON.stringify( this.playgroundTabs ); + + } + + this.togglePlayground( true ); + this.renderPlaygroundTabs(); + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ) || this.playgroundTabs[ 0 ]; + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( !! activeTab.readOnly ); + this.guide.codeEditor.setValue( activeTab.code ); + + } + + this.runPlayground(); + await this.updatePlaygroundHash( false ); + + } + + async loadExampleIntoPlayground( code, name = null ) { + + this.guide.projectsManager.setCurrentProjectId( null ); + this.currentExampleName = name; + + const newCode = code || '// TSL Guide\n'; + this.playgroundTabs = [ { name: 'main', code: newCode } ]; + this.activePlaygroundTabName = 'main'; + this.initialTabsSnapshot = JSON.stringify( this.playgroundTabs ); + + this.togglePlayground( true ); + this.renderPlaygroundTabs(); + + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.setReadOnly( false ); + this.guide.codeEditor.setValue( newCode ); + + } + + this.runPlayground(); + await this.updatePlaygroundHash( true ); + } } diff --git a/tsl/js/managers/ProjectsManager.js b/tsl/js/managers/ProjectsManager.js new file mode 100644 index 00000000000000..1f0f487d49a7ea --- /dev/null +++ b/tsl/js/managers/ProjectsManager.js @@ -0,0 +1,969 @@ +class ProjectsManager { + + constructor( guide ) { + + this.guide = guide; + this.storageKey = 'tsl_playground_saved_projects'; + this.currentProjectIdKey = 'tsl_playground_current_project_id'; + this.currentProjectId = localStorage.getItem( this.currentProjectIdKey ) || null; + this.modalElement = null; + + } + + setCurrentProjectId( id ) { + + this.currentProjectId = id || null; + if ( id ) { + + try { + + localStorage.setItem( this.currentProjectIdKey, id ); + + } catch ( e ) { + + console.error( 'Error saving currentProjectId to localStorage:', e ); + + } + + } else { + + try { + + localStorage.removeItem( this.currentProjectIdKey ); + + } catch ( e ) { + + console.error( 'Error removing currentProjectId from localStorage:', e ); + + } + + } + + } + + getDraftProject() { + + const rawTabs = this.guide.playgroundManager.playgroundTabs; + const tabs = ( rawTabs && rawTabs.length > 0 ) + ? rawTabs.filter( t => ! t.readOnly ) + : [ { name: 'main', code: '// TSL Guide\n' } ]; + + if ( tabs.length === 0 ) { + + tabs.push( { name: 'main', code: '// TSL Guide\n' } ); + + } + + let name = 'Draft'; + if ( this.guide.playgroundManager.currentExampleName ) { + + const cleanName = this.guide.playgroundManager.currentExampleName.replace( /\s*\(Draft\)$/i, '' ).trim(); + if ( cleanName ) { + + name = `${cleanName} (Draft)`; + + } + + } + + return { + id: 'draft', + isDraft: true, + name: name, + updatedAt: Date.now(), + tabs: JSON.parse( JSON.stringify( tabs ) ) + }; + + } + + getCurrentActiveProject() { + + if ( this.currentProjectId ) { + + const saved = this.getProjects().find( p => p.id === this.currentProjectId ); + if ( saved ) return saved; + + } + + return this.getDraftProject(); + + } + + createNewProject() { + + const code = ( typeof this.guide.onEmptyProject === 'function' ) + ? this.guide.onEmptyProject() + : ( this.guide.getEmptyProjectCode ? this.guide.getEmptyProjectCode() : '// TSL Guide\n' ); + const emptyTabs = [ { name: 'main', code: code || '// TSL Guide\n' } ]; + + const name = this.getNextProjectName(); + const now = Date.now(); + const newProject = { + id: `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`, + name: name, + createdAt: now, + updatedAt: now, + tabs: JSON.parse( JSON.stringify( emptyTabs ) ) + }; + + const projects = this.getProjects(); + projects.unshift( newProject ); + this.saveProjectsList( projects ); + + this.setCurrentProjectId( newProject.id ); + this.guide.playgroundManager.currentExampleName = newProject.name; + this.guide.playgroundManager.playgroundTabs = emptyTabs; + this.guide.playgroundManager.activePlaygroundTabName = 'main'; + this.guide.playgroundManager.initialTabsSnapshot = JSON.stringify( emptyTabs ); + this.guide.playgroundManager.togglePlayground( true ); + this.guide.playgroundManager.renderPlaygroundTabs(); + if ( this.guide.codeEditor ) this.guide.codeEditor.setValue( emptyTabs[ 0 ].code ); + this.guide.playgroundManager.runPlayground(); + this.guide.playgroundManager.updatePlaygroundHash( true ); + this.closeProjectsModal(); + + } + + resetDraft() { + + const code = ( typeof this.guide.onEmptyProject === 'function' ) + ? this.guide.onEmptyProject() + : ( this.guide.getEmptyProjectCode ? this.guide.getEmptyProjectCode() : '// TSL Guide\n' ); + const emptyTabs = [ { name: 'main', code: code || '// TSL Guide\n' } ]; + this.guide.playgroundManager.playgroundTabs = emptyTabs; + this.guide.playgroundManager.activePlaygroundTabName = 'main'; + this.setCurrentProjectId( null ); + this.guide.playgroundManager.currentExampleName = null; + this.guide.playgroundManager.initialTabsSnapshot = JSON.stringify( emptyTabs ); + this.guide.playgroundManager.togglePlayground( true ); + this.guide.playgroundManager.renderPlaygroundTabs(); + if ( this.guide.codeEditor ) this.guide.codeEditor.setValue( emptyTabs[ 0 ].code ); + this.guide.playgroundManager.runPlayground(); + this.guide.playgroundManager.updatePlaygroundHash( true ); + + } + + isProjectCurrent( project ) { + + if ( ! project ) return false; + + if ( project.isDraft ) { + + const savedProjects = this.getProjects(); + return ! this.currentProjectId || ! savedProjects.some( p => p.id === this.currentProjectId ); + + } + + if ( this.currentProjectId ) { + + return this.currentProjectId === project.id; + + } + + return false; + + } + + formatDate( timestamp ) { + + if ( ! timestamp ) return ''; + const d = new Date( timestamp ); + const year = d.getFullYear(); + const month = String( d.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( d.getDate() ).padStart( 2, '0' ); + const hours = String( d.getHours() ).padStart( 2, '0' ); + const mins = String( d.getMinutes() ).padStart( 2, '0' ); + return `${year} - ${month}/${day} ${hours}:${mins}`; + + } + + getProjects() { + + try { + + const raw = localStorage.getItem( this.storageKey ); + if ( ! raw ) return []; + const parsed = JSON.parse( raw ); + if ( Array.isArray( parsed ) ) { + + return parsed.sort( ( a, b ) => ( b.updatedAt || 0 ) - ( a.updatedAt || 0 ) ); + + } + + } catch ( e ) { + + console.error( 'Error reading saved projects from localStorage:', e ); + + } + + return []; + + } + + saveProjectsList( list ) { + + try { + + localStorage.setItem( this.storageKey, JSON.stringify( list ) ); + + } catch ( e ) { + + console.error( 'Error saving projects list to localStorage:', e ); + + } + + } + + getNextProjectName() { + + const projects = this.getProjects(); + let maxNum = 0; + const regex = /^Project\s+(\d+)$/i; + + for ( const project of projects ) { + + const match = ( project.name || '' ).trim().match( regex ); + if ( match ) { + + const num = parseInt( match[ 1 ], 10 ); + if ( num > maxNum ) maxNum = num; + + } + + } + + return `Project ${maxNum + 1}`; + + } + + autoSaveCurrentProject() { + + if ( ! this.currentProjectId ) return; + + const tabs = ( this.guide.playgroundManager.playgroundTabs || [] ).filter( t => ! t.readOnly ); + if ( ! tabs || tabs.length === 0 ) return; + + const projects = this.getProjects(); + const index = projects.findIndex( p => p.id === this.currentProjectId ); + if ( index === - 1 ) return; + + projects[ index ].tabs = JSON.parse( JSON.stringify( tabs ) ); + projects[ index ].updatedAt = Date.now(); + + this.saveProjectsList( projects ); + + } + + saveCurrentProject( name ) { + + const rawTabs = this.guide.playgroundManager.playgroundTabs || [ { name: 'main', code: '// TSL Guide\n' } ]; + const tabs = rawTabs.filter( t => ! t.readOnly ); + if ( tabs.length === 0 ) tabs.push( { name: 'main', code: '// TSL Guide\n' } ); + const projects = this.getProjects(); + + let existingIndex = - 1; + if ( this.currentProjectId ) { + + existingIndex = projects.findIndex( p => p.id === this.currentProjectId ); + + } + + const defaultName = ( existingIndex !== - 1 ? projects[ existingIndex ].name : null ) + || ( this.guide.playgroundManager.currentExampleName ? this.guide.playgroundManager.currentExampleName.replace( /\s*\(Draft\)$/i, '' ).trim() : null ) + || this.getNextProjectName(); + + const cleanName = ( name || '' ).trim() || defaultName; + + if ( existingIndex === - 1 ) { + + existingIndex = projects.findIndex( p => p.name.toLowerCase() === cleanName.toLowerCase() ); + + } + + const now = Date.now(); + const projectData = { + id: existingIndex !== - 1 ? projects[ existingIndex ].id : `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`, + name: cleanName, + createdAt: existingIndex !== - 1 ? projects[ existingIndex ].createdAt : now, + updatedAt: now, + tabs: JSON.parse( JSON.stringify( tabs ) ) + }; + + if ( existingIndex !== - 1 ) { + + projects[ existingIndex ] = projectData; + + } else { + + projects.unshift( projectData ); + + } + + this.setCurrentProjectId( projectData.id ); + this.guide.playgroundManager.currentExampleName = projectData.name; + this.guide.playgroundManager.initialTabsSnapshot = JSON.stringify( tabs ); + this.saveProjectsList( projects ); + return projectData; + + } + + renameProject( id, newName ) { + + const cleanName = ( newName || '' ).trim(); + if ( ! cleanName ) return false; + + if ( id === 'draft' ) { + + this.saveCurrentProject( cleanName ); + return true; + + } + + const projects = this.getProjects(); + const project = projects.find( p => p.id === id ); + if ( ! project ) return false; + + project.name = cleanName; + project.updatedAt = Date.now(); + + this.saveProjectsList( projects ); + return true; + + } + + deleteProject( id ) { + + if ( id === 'draft' ) { + + return; + + } + + const isDeletingCurrent = ( this.currentProjectId === id ); + + let projects = this.getProjects(); + projects = projects.filter( p => p.id !== id ); + this.saveProjectsList( projects ); + + if ( isDeletingCurrent ) { + + this.resetDraft(); + + } + + } + + duplicateProject( project ) { + + const projects = this.getProjects(); + const now = Date.now(); + const id = `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`; + const clonedTabs = ( project.tabs || [] ).map( t => ( { + name: t.name, + code: t.code + } ) ); + + const baseName = ( project.name || 'Project' ).replace( /\s*\(Draft\)$/i, '' ).trim(); + const name = `${baseName} (Copy)`; + + const newProject = { + id, + name, + createdAt: now, + updatedAt: now, + tabs: clonedTabs + }; + + const targetIndex = projects.findIndex( p => p.id === project.id ); + if ( targetIndex !== - 1 ) { + + projects.splice( targetIndex + 1, 0, newProject ); + + } else { + + projects.unshift( newProject ); + + } + + this.saveProjectsList( projects ); + + return newProject; + + } + + loadProject( project ) { + + if ( ! project || ! Array.isArray( project.tabs ) || project.tabs.length === 0 ) { + + console.error( 'Invalid project data to load' ); + return; + + } + + this.setCurrentProjectId( project.id ); + this.guide.playgroundManager.currentExampleName = project.name; + this.guide.playgroundManager.playgroundTabs = JSON.parse( JSON.stringify( project.tabs ) ); + this.guide.playgroundManager.activePlaygroundTabName = this.guide.playgroundManager.playgroundTabs[ 0 ].name; + this.guide.playgroundManager.initialTabsSnapshot = JSON.stringify( this.guide.playgroundManager.playgroundTabs ); + + this.guide.playgroundManager.togglePlayground( true ); + this.guide.playgroundManager.renderPlaygroundTabs(); + + const activeTab = this.guide.playgroundManager.playgroundTabs.find( t => t.name === this.guide.playgroundManager.activePlaygroundTabName ) || this.guide.playgroundManager.playgroundTabs[ 0 ]; + + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.setValue( activeTab.code ); + + } + + this.guide.playgroundManager.runPlayground(); + this.guide.playgroundManager.updatePlaygroundHash( true ); + + this.closeProjectsModal(); + + } + + exportProjectJSON( project ) { + + if ( ! project ) return; + + const safeTabs = ( project.tabs || [] ).map( t => ( { + name: t.name, + code: t.code + } ) ); + + const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent( JSON.stringify( { + name: project.name || 'project', + version: 1, + exportedAt: new Date().toISOString(), + tabs: safeTabs + }, null, 2 ) ); + + const downloadAnchor = document.createElement( 'a' ); + const safeName = ( project.name || 'project' ).toLowerCase().replace( /[^a-z0-9_-]/g, '_' ); + downloadAnchor.setAttribute( 'href', dataStr ); + downloadAnchor.setAttribute( 'download', `${safeName}.json` ); + document.body.appendChild( downloadAnchor ); + downloadAnchor.click(); + downloadAnchor.remove(); + + } + + exportAllProjectsJSON() { + + const projects = this.getProjects(); + if ( projects.length === 0 ) { + + return; + + } + + const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent( JSON.stringify( { + type: 'tsl_playground_backup', + version: 1, + exportedAt: new Date().toISOString(), + projects: projects + }, null, 2 ) ); + + const downloadAnchor = document.createElement( 'a' ); + downloadAnchor.setAttribute( 'href', dataStr ); + downloadAnchor.setAttribute( 'download', 'tsl_playground_all_projects.json' ); + document.body.appendChild( downloadAnchor ); + downloadAnchor.click(); + downloadAnchor.remove(); + + } + + importProjectJSON( file ) { + + return new Promise( ( resolve, reject ) => { + + const reader = new FileReader(); + reader.onload = ( e ) => { + + try { + + const parsed = JSON.parse( e.target.result ); + let importedCount = 0; + let firstImportedProject = null; + + if ( ! parsed || typeof parsed !== 'object' ) { + + throw new Error( 'Invalid JSON format. Please ensure it contains valid tabs or projects.' ); + + } + + const projects = this.getProjects(); + const now = Date.now(); + + if ( parsed.type === 'tsl_playground_backup' && Array.isArray( parsed.projects ) ) { + + for ( const proj of parsed.projects ) { + + if ( proj && proj.name && Array.isArray( proj.tabs ) && proj.tabs.length > 0 ) { + + const id = `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`; + const newProj = { + id, + name: proj.name, + createdAt: proj.createdAt || now, + updatedAt: proj.updatedAt || now, + tabs: proj.tabs + }; + projects.unshift( newProj ); + if ( ! firstImportedProject ) firstImportedProject = newProj; + importedCount ++; + + } + + } + + } else if ( Array.isArray( parsed ) ) { + + for ( const proj of parsed ) { + + if ( proj && Array.isArray( proj.tabs ) && proj.tabs.length > 0 ) { + + const id = `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`; + const newProj = { + id, + name: proj.name || 'Imported Project', + createdAt: now, + updatedAt: now, + tabs: proj.tabs + }; + projects.unshift( newProj ); + if ( ! firstImportedProject ) firstImportedProject = newProj; + importedCount ++; + + } + + } + + } else if ( Array.isArray( parsed.tabs ) || parsed.name || parsed.code ) { + + const tabs = Array.isArray( parsed.tabs ) ? parsed.tabs : [ { name: 'main', code: parsed.code || '// TSL Guide\n' } ]; + const name = parsed.name || file.name.replace( /\.json$/i, '' ) || 'Imported Project'; + const id = `proj_${now}_${Math.random().toString( 36 ).substring( 2, 7 )}`; + + const newProj = { + id, + name, + createdAt: now, + updatedAt: now, + tabs + }; + projects.unshift( newProj ); + firstImportedProject = newProj; + importedCount = 1; + + } else { + + throw new Error( 'Invalid JSON format. Please ensure it contains valid tabs or projects.' ); + + } + + this.saveProjectsList( projects ); + resolve( { importedCount, project: firstImportedProject } ); + + } catch ( err ) { + + reject( err ); + + } + + }; + + reader.onerror = reject; + reader.readAsText( file ); + + } ); + + } + + openProjectsModal() { + + this.closeProjectsModal(); + + const overlay = document.createElement( 'div' ); + overlay.className = 'tsl-modal-overlay'; + overlay.id = 'tsl-projects-modal-overlay'; + + const modal = document.createElement( 'div' ); + modal.className = 'tsl-modal-container tsl-projects-modal'; + + // Header + const header = document.createElement( 'div' ); + header.className = 'tsl-modal-header'; + header.innerHTML = ` +
+ + Playground Projects +
+ + `; + modal.appendChild( header ); + + // Modal body + const bodyContainer = document.createElement( 'div' ); + bodyContainer.className = 'tsl-projects-body'; + + // Hidden file input for importing JSON + const fileInput = document.createElement( 'input' ); + fileInput.type = 'file'; + fileInput.id = 'tsl-project-file-input'; + fileInput.accept = '.json'; + fileInput.style.display = 'none'; + bodyContainer.appendChild( fileInput ); + + // Top Active Workspace Container + const topContainer = document.createElement( 'div' ); + topContainer.className = 'tsl-project-top-container'; + bodyContainer.appendChild( topContainer ); + + // Section Header + const sectionHeader = document.createElement( 'div' ); + sectionHeader.className = 'tsl-projects-section-header'; + sectionHeader.innerHTML = ` + Saved Projects + 0 saved + `; + bodyContainer.appendChild( sectionHeader ); + + // Projects List Container + const listContainer = document.createElement( 'div' ); + listContainer.className = 'tsl-projects-list custom-scrollbar'; + bodyContainer.appendChild( listContainer ); + + modal.appendChild( bodyContainer ); + overlay.appendChild( modal ); + document.body.appendChild( overlay ); + this.modalElement = overlay; + + const countBadge = sectionHeader.querySelector( '#tsl-projects-count-badge' ); + + const renderTopSection = () => { + + topContainer.innerHTML = ''; + + const isDraft = ! this.currentProjectId || ! this.getProjects().some( p => p.id === this.currentProjectId ); + const rawTabs = this.guide.playgroundManager.playgroundTabs || []; + const tabs = rawTabs.filter( t => ! t.readOnly ); + const tabBadges = isDraft ? tabs.map( t => `${t.name}` ).join( '' ) : ''; + + const item = document.createElement( 'div' ); + item.className = 'tsl-project-item' + ( isDraft ? ' tsl-project-item-current tsl-project-item-draft' : '' ); + item.innerHTML = ` +
+
+ Draft + Draft + ${isDraft ? 'Current' : ''} +
+
+ ${isDraft ? 'Current unsaved draft' : 'Create a blank project or import from JSON'} + ${tabBadges ? `
${tabBadges}
` : ''} +
+
+
+ + ${isDraft ? ` + ` : ''} + +
+ `; + + item.querySelector( '.tsl-top-new-btn' ).onclick = () => { + + this.createNewProject(); + + }; + + const saveBtn = item.querySelector( '.tsl-top-save-btn' ); + if ( saveBtn ) { + + saveBtn.onclick = () => { + + const defaultName = this.guide.playgroundManager.currentExampleName + ? this.guide.playgroundManager.currentExampleName.replace( /\s*\(Draft\)$/i, '' ).trim() + : this.getNextProjectName(); + this.saveCurrentProject( defaultName ); + this.closeProjectsModal(); + + }; + + } + + item.querySelector( '.tsl-top-import-btn' ).onclick = () => { + + fileInput.value = ''; + fileInput.click(); + + }; + + topContainer.appendChild( item ); + + }; + + const renderList = () => { + + listContainer.innerHTML = ''; + const savedProjects = this.getProjects(); + countBadge.textContent = `${savedProjects.length} saved`; + + if ( savedProjects.length === 0 ) { + + const emptyEl = document.createElement( 'div' ); + emptyEl.className = 'tsl-projects-empty'; + emptyEl.innerHTML = ` + +

No saved projects yet.

+ Click "Save" above to save your current work, or "New" to start a blank project. + `; + listContainer.appendChild( emptyEl ); + return; + + } + + savedProjects.forEach( project => { + + const isCurrent = this.isProjectCurrent( project ); + const item = document.createElement( 'div' ); + item.className = 'tsl-project-item' + ( isCurrent ? ' tsl-project-item-current' : '' ); + + const dateStr = this.formatDate( project.updatedAt ); + const tabBadges = ( project.tabs || [] ).map( t => `${t.name}` ).join( '' ); + + item.innerHTML = ` +
+
+ ${project.name} + ${isCurrent ? 'Current' : ''} + +
+
+ ${dateStr} +
${tabBadges}
+
+
+
+ ${! isCurrent ? ` + ` : ''} + + + +
+ `; + + const loadBtn = item.querySelector( '.tsl-load-btn' ); + if ( loadBtn ) { + + loadBtn.onclick = () => { + + this.loadProject( project ); + + }; + + } + + item.querySelector( '.tsl-duplicate-btn' ).onclick = () => { + + this.duplicateProject( project ); + refreshAll(); + + }; + + item.querySelector( '.tsl-export-btn' ).onclick = () => { + + this.exportProjectJSON( project ); + + }; + + item.querySelector( '.tsl-delete-btn' ).onclick = () => { + + this.deleteProject( project.id ); + refreshAll(); + + }; + + const startRename = () => { + + const nameSpan = item.querySelector( '.tsl-project-name' ); + if ( ! nameSpan || item.querySelector( '.tsl-project-rename-input' ) ) return; + + const oldName = project.name; + const input = document.createElement( 'input' ); + input.type = 'text'; + input.className = 'tsl-project-rename-input'; + input.value = oldName; + + nameSpan.replaceWith( input ); + input.focus(); + input.select(); + + let isDone = false; + const commitRename = () => { + + if ( isDone ) return; + isDone = true; + const newName = input.value.trim(); + if ( newName && newName !== oldName ) { + + this.renameProject( project.id, newName ); + + } + + refreshAll(); + + }; + + input.onblur = commitRename; + input.onkeydown = ( e ) => { + + if ( e.key === 'Enter' ) commitRename(); + if ( e.key === 'Escape' ) { + + isDone = true; + refreshAll(); + + } + + }; + + }; + + item.querySelector( '.tsl-project-name' ).ondblclick = startRename; + item.querySelector( '.tsl-project-rename-btn' ).onclick = startRename; + + listContainer.appendChild( item ); + + } ); + + }; + + const refreshAll = () => { + + renderTopSection(); + renderList(); + + }; + + fileInput.onchange = async ( e ) => { + + const file = e.target.files[ 0 ]; + if ( ! file ) return; + + try { + + const result = await this.importProjectJSON( file ); + if ( result && result.project ) { + + this.loadProject( result.project ); + + } else { + + refreshAll(); + + } + + } catch ( err ) { + + console.error( 'Failed to import project:', err ); + + } + + }; + + refreshAll(); + + // Close button Action + header.querySelector( '.tsl-modal-close-btn' ).onclick = () => { + + this.closeProjectsModal(); + + }; + + // Backdrop click to close + overlay.onclick = ( e ) => { + + if ( e.target === overlay ) { + + this.closeProjectsModal(); + + } + + }; + + // ESC to close + this._escListener = ( e ) => { + + if ( e.key === 'Escape' ) { + + this.closeProjectsModal(); + + } + + }; + + window.addEventListener( 'keydown', this._escListener ); + + } + + closeProjectsModal() { + + if ( document.activeElement && typeof document.activeElement.blur === 'function' ) { + + document.activeElement.blur(); + + } + + if ( this.modalElement ) { + + this.modalElement.remove(); + this.modalElement = null; + + } + + if ( this._escListener ) { + + window.removeEventListener( 'keydown', this._escListener ); + this._escListener = null; + + } + + if ( this.guide.codeEditor ) { + + this.guide.codeEditor.focus(); + + } + + } + +} + +export { ProjectsManager }; diff --git a/tsl/js/managers/SearchManager.js b/tsl/js/managers/SearchManager.js index d362f5e73e14e6..fe79041f944414 100644 --- a/tsl/js/managers/SearchManager.js +++ b/tsl/js/managers/SearchManager.js @@ -1,8 +1,8 @@ class SearchManager { - constructor( tour ) { + constructor( guide ) { - this.tour = tour; + this.guide = guide; this.index = new Map(); // word -> Set of page IDs / node references this.casedVocabulary = new Map(); // lowercase -> original case this.debounceTimeout = null; @@ -33,7 +33,7 @@ class SearchManager { }; - const allNodes = collectAllNodes( this.tour.pageTree || [] ); + const allNodes = collectAllNodes( this.guide.pageTree || [] ); allNodes.forEach( node => { @@ -289,7 +289,7 @@ class SearchManager { if ( trimmed.length === 0 ) { - this.tour.dom.tocList.classList.remove( 'search-active' ); + this.guide.dom.tocList.classList.remove( 'search-active' ); const cleanTree = ( nodes ) => { @@ -303,17 +303,17 @@ class SearchManager { }; - cleanTree( this.tour.pageTree ); - this.tour.setupTOC( this.tour.pageTree ); + cleanTree( this.guide.pageTree ); + this.guide.setupTOC( this.guide.pageTree ); } else { - this.tour.dom.tocList.classList.add( 'search-active' ); + this.guide.dom.tocList.classList.add( 'search-active' ); const queryLower = trimmed.toLowerCase(); const queryTerms = queryLower.split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( Boolean ); - let rankedTree = this.getRankedTree( this.tour.pageTree, trimmed, queryTerms ); + let rankedTree = this.getRankedTree( this.guide.pageTree, trimmed, queryTerms ); let suggestion = null; if ( rankedTree.length === 0 ) { @@ -323,17 +323,17 @@ class SearchManager { const suggTrimmed = suggestion.trim().toLowerCase(); const suggQueryTerms = suggTrimmed.split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( Boolean ); - rankedTree = this.getRankedTree( this.tour.pageTree, suggestion, suggQueryTerms ); + rankedTree = this.getRankedTree( this.guide.pageTree, suggestion, suggQueryTerms ); } } - this.tour.setupTOC( rankedTree, null, suggestion ); + this.guide.setupTOC( rankedTree, null, suggestion ); } - const sidebarContent = this.tour.dom.sidebar.querySelector( '.sidebar-content' ); + const sidebarContent = this.guide.dom.sidebar.querySelector( '.sidebar-content' ); if ( sidebarContent ) { sidebarContent.scrollTop = 0; @@ -344,7 +344,7 @@ class SearchManager { scrollToSearchMatch() { - const query = this.tour.dom.searchInput.value.trim(); + const query = this.guide.dom.searchInput.value.trim(); if ( query.length === 0 ) return; const queryTerms = query.toLowerCase().split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( t => t.length > 0 ); @@ -353,7 +353,7 @@ class SearchManager { let targetElement = null; // 1. Prioritize API classes, summaries, signatures, and rows - const apiElements = this.tour.dom.contentArea.querySelectorAll( '.tsl-api-class-summary, .tsl-api-class-name, .tsl-api-class-extends-name, .tsl-api-table-row, .tsl-api-signature, .tsl-api-sig-name, .tsl-api-card, .tsl-api-param, .tsl-api-inherited-summary' ); + const apiElements = this.guide.dom.contentArea.querySelectorAll( '.tsl-api-class-summary, .tsl-api-class-name, .tsl-api-class-extends-name, .tsl-api-table-row, .tsl-api-signature, .tsl-api-sig-name, .tsl-api-card, .tsl-api-param, .tsl-api-inherited-summary' ); for ( const el of apiElements ) { const text = el.textContent.toLowerCase(); @@ -363,7 +363,7 @@ class SearchManager { targetElement = el.closest( '.tsl-api-table-row' ) || el.closest( '.tsl-api-class-summary' ) || el.closest( '.tsl-api-inherited-summary' ) || el; let parent = targetElement.parentElement; - while ( parent && parent !== this.tour.dom.contentArea ) { + while ( parent && parent !== this.guide.dom.contentArea ) { if ( parent.tagName === 'DETAILS' ) { @@ -384,7 +384,7 @@ class SearchManager { // 2. If no API element matched, check general text elements if ( ! targetElement ) { - const generalElements = this.tour.dom.contentArea.querySelectorAll( 'h1, h2, h3, p, li, td, code, blockquote, .tour-note-block, .tour-important-block, .tour-ai-accordion, .tour-ai-content' ); + const generalElements = this.guide.dom.contentArea.querySelectorAll( 'h1, h2, h3, p, li, td, code, blockquote, .guide-note-block, .guide-important-block, .guide-ai-accordion, .guide-ai-content' ); for ( const el of generalElements ) { const text = el.textContent.toLowerCase(); @@ -394,7 +394,7 @@ class SearchManager { targetElement = el; let parent = el.parentElement; - while ( parent && parent !== this.tour.dom.contentArea ) { + while ( parent && parent !== this.guide.dom.contentArea ) { if ( parent.tagName === 'DETAILS' ) { @@ -414,9 +414,9 @@ class SearchManager { } - if ( ! targetElement && this.tour.readOnlyEditors ) { + if ( ! targetElement && this.guide.readOnlyEditors ) { - for ( const editor of this.tour.readOnlyEditors ) { + for ( const editor of this.guide.readOnlyEditors ) { const codeText = ( editor.getValue() || '' ).toLowerCase(); const matches = queryTerms.every( term => codeText.includes( term ) ); @@ -433,7 +433,7 @@ class SearchManager { if ( targetElement ) { - const previousFlashes = this.tour.dom.contentArea.querySelectorAll( '.search-match-flash' ); + const previousFlashes = this.guide.dom.contentArea.querySelectorAll( '.search-match-flash' ); previousFlashes.forEach( el => el.classList.remove( 'search-match-flash' ) ); const observer = new IntersectionObserver( ( entries ) => { @@ -450,7 +450,7 @@ class SearchManager { } ); }, { - root: this.tour.dom.contentArea, + root: this.guide.dom.contentArea, threshold: 0.1 } ); @@ -467,7 +467,7 @@ class SearchManager { if ( hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ) ) return; const hashParts = hash.split( '&' ); - const pageId = hashParts[ 0 ] || ( this.tour.pages[ this.tour.currentPageIndex ] ? this.tour.pages[ this.tour.currentPageIndex ].id : '' ); + const pageId = hashParts[ 0 ] || ( this.guide.pages[ this.guide.currentPageIndex ] ? this.guide.pages[ this.guide.currentPageIndex ].id : '' ); if ( ! pageId ) return; let selectedNode = ''; @@ -496,7 +496,7 @@ class SearchManager { } history.replaceState( null, null, '#' + newHash ); - this.tour.lastTourPageHash = newHash; + this.guide.lastGuidePageHash = newHash; } @@ -518,18 +518,18 @@ class SearchManager { if ( searchQuery ) { - this.tour.dom.searchInput.value = searchQuery; - this.tour.dom.searchClear.style.display = 'flex'; - this.tour.dom.searchContainer.classList.add( 'focused' ); + this.guide.dom.searchInput.value = searchQuery; + this.guide.dom.searchClear.style.display = 'flex'; + this.guide.dom.searchContainer.classList.add( 'focused' ); this.performSearch( searchQuery ); } else { - if ( this.tour.dom.searchInput.value ) { + if ( this.guide.dom.searchInput.value ) { - this.tour.dom.searchInput.value = ''; - this.tour.dom.searchClear.style.display = 'none'; - this.tour.dom.searchContainer.classList.remove( 'focused' ); + this.guide.dom.searchInput.value = ''; + this.guide.dom.searchClear.style.display = 'none'; + this.guide.dom.searchContainer.classList.remove( 'focused' ); this.performSearch( '' ); } diff --git a/tsl/js/utils/TourUtils.js b/tsl/js/utils/GuideUtils.js similarity index 96% rename from tsl/js/utils/TourUtils.js rename to tsl/js/utils/GuideUtils.js index fbe16d7212ac78..5d898b5a5f1d13 100644 --- a/tsl/js/utils/TourUtils.js +++ b/tsl/js/utils/GuideUtils.js @@ -27,6 +27,7 @@ const SVG_ICONS = { 'refresh': '', 'box': '', 'layers': '', + 'folder-code': '', 'share-2': '' }; diff --git a/tsl/js/utils/MarkdownUtils.js b/tsl/js/utils/MarkdownUtils.js index 781004f7b28565..8fc38c465d6f09 100644 --- a/tsl/js/utils/MarkdownUtils.js +++ b/tsl/js/utils/MarkdownUtils.js @@ -14,12 +14,20 @@ marked.use( { return false; + }, + link( href, title, text ) { + + const targetAttr = href && ! href.startsWith( '#' ) ? ' target="_blank" rel="noopener noreferrer"' : ''; + const titleAttr = title ? ` title="${title}"` : ''; + + return `${text}`; + } } } ); -function parseTour( rawMarkdown ) { +function parseGuide( markdown ) { const pageTree = []; @@ -29,10 +37,10 @@ function parseTour( rawMarkdown ) { let lastIndex = 0; const stack = []; - while ( ( match = tokenRegex.exec( rawMarkdown ) ) !== null ) { + while ( ( match = tokenRegex.exec( markdown ) ) !== null ) { const index = match.index; - const textSegment = rawMarkdown.substring( lastIndex, index ); + const textSegment = markdown.substring( lastIndex, index ); if ( stack.length > 0 ) { @@ -95,9 +103,9 @@ function parseTour( rawMarkdown ) { } // Add any remaining text - if ( lastIndex < rawMarkdown.length && stack.length > 0 ) { + if ( lastIndex < markdown.length && stack.length > 0 ) { - stack[ stack.length - 1 ].content += rawMarkdown.substring( lastIndex ); + stack[ stack.length - 1 ].content += markdown.substring( lastIndex ); } @@ -352,8 +360,8 @@ function parse( md ) { }; - parseCallouts( 'Important', 'Important', '⚠️', 'tour-important' ); - parseCallouts( 'Note', 'Note', '📌', 'tour-note' ); + parseCallouts( 'Important', 'Important', '⚠️', 'guide-important' ); + parseCallouts( 'Note', 'Note', '📌', 'guide-note' ); // Helper to parse collapsible accordion callout blocks for AI / LLM (> IA: or > AI: or > LLM:) const parseAccordionCallouts = ( tag, title, icon, className ) => { @@ -391,7 +399,7 @@ function parse( md ) { }; - parseAccordionCallouts( '(?:IA|AI|LLM)', 'AI / LLM Guide', 'sparkles', 'tour-ai' ); + parseAccordionCallouts( '(?:IA|AI|LLM)', 'AI / LLM Guide', 'sparkles', 'guide-ai' ); // Group consecutive API blocks const apiBlockRegex = /::: api\s+([^\n]+?)(?:\s*:::\s*(?=\n|$)|(?:\r?\n([\s\S]*?):::))/gi; @@ -1456,4 +1464,4 @@ function tokenizeCodeToElement( codeContent, targetElement ) { } -export { parseTour, parse, tokenizeInlineCode, tokenizeCodeToElement }; +export { parseGuide, parse, tokenizeInlineCode, tokenizeCodeToElement }; diff --git a/utils/llms/build.js b/utils/llms/build.js index 3b128edccf51a2..af48a0a4151ac1 100644 --- a/utils/llms/build.js +++ b/utils/llms/build.js @@ -7,7 +7,7 @@ const packageJson = JSON.parse( fs.readFileSync( 'package.json', 'utf8' ) ); const version = packageJson.version; // Read TSL specification -const tslSpec = fs.readFileSync( 'tsl/content/Tour.md', 'utf8' ); +const tslSpec = fs.readFileSync( 'tsl/content/Guide.md', 'utf8' ); // Setup Turndown for HTML to Markdown conversion const turndown = new TurndownService( { @@ -181,7 +181,8 @@ When using TSL, use node-based materials: ## Core Concepts -- [TSL Specification](https://threejs.org/docs/#api/en/nodes/TSL): Complete shader language reference +- [TSL Guide](https://threejs.org/tsl/): Three.js Shading Language guide with playground +- [TSL Reference](https://threejs.org/docs/#TSL): All TSL functions - [Animation System](https://threejs.org/manual/#animation-system) - [Loading 3D Models](https://threejs.org/manual/#loading-3d-models) - [Scene Graph](https://threejs.org/manual/#scenegraph)