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 ? `
+
` : ''}
+
+
+ `;
+
+ 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' : ''}
+
+
+
+
+
+ ${! 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)