Skip to content

Commit eabe759

Browse files
deltakoshDavid Catuhe
andauthored
Add support for local load and save in the PG (#17152)
Playground will allow you to save and load from local files <img width="493" height="282" alt="image" src="https://github.com/user-attachments/assets/727ce56b-512d-4e17-928c-29a96116ab3f" /> Co-authored-by: David Catuhe <[email protected]>
1 parent c3c680d commit eabe759

File tree

6 files changed

+221
-95
lines changed

6 files changed

+221
-95
lines changed
Lines changed: 3 additions & 0 deletions
Loading

packages/tools/playground/src/components/commandBarComponent.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,23 @@ export class CommandBarComponent extends React.Component<ICommandBarComponentPro
261261
},
262262
];
263263

264+
const fileOptions = [
265+
{
266+
label: "Load",
267+
tooltip: "Load a saved playground from a local file",
268+
onClick: () => {
269+
this.props.globalState.onLocalLoadRequiredObservable.notifyObservers();
270+
},
271+
},
272+
{
273+
label: "Save",
274+
tooltip: "Save the playground to a local file",
275+
onClick: () => {
276+
this.props.globalState.onLocalSaveRequiredObservable.notifyObservers();
277+
},
278+
},
279+
];
280+
264281
if (this._webGPUSupported) {
265282
engineOptions.splice(0, 0, {
266283
label: "WebGPU",
@@ -280,6 +297,7 @@ export class CommandBarComponent extends React.Component<ICommandBarComponentPro
280297
<div className="commands-left">
281298
<CommandButtonComponent globalState={this.props.globalState} tooltip="Run" icon="play" shortcut="Alt+Enter" isActive={true} onClick={() => this.onPlay()} />
282299
<CommandButtonComponent globalState={this.props.globalState} tooltip="Save" icon="save" shortcut="Ctrl+S" isActive={false} onClick={() => this.onSave()} />
300+
<CommandDropdownComponent globalState={this.props.globalState} icon="saveLocal" tooltip="Local file" items={fileOptions} />
283301
<CommandButtonComponent globalState={this.props.globalState} tooltip="Inspector" icon="inspector" isActive={false} onClick={() => this.onInspector()} />
284302
<CommandButtonComponent
285303
globalState={this.props.globalState}

packages/tools/playground/src/globalState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export class GlobalState {
4747
public onInsertSnippetRequiredObservable = new Observable<string>();
4848
public onClearRequiredObservable = new Observable<void>();
4949
public onSaveRequiredObservable = new Observable<void>();
50+
public onLocalSaveRequiredObservable = new Observable<void>();
5051
public onLoadRequiredObservable = new Observable<string>();
52+
public onLocalLoadRequiredObservable = new Observable<void>();
5153
public onErrorObservable = new Observable<Nullable<CompilationError>>();
5254
public onMobileDefaultModeChangedObservable = new Observable<void>();
5355
public onDisplayWaitRingObservable = new Observable<boolean>();

packages/tools/playground/src/scss/commandBar.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@
107107
display: grid;
108108
align-content: center;
109109
justify-content: center;
110+
111+
img {
112+
width: 24px;
113+
height: 24px;
114+
}
110115
}
111116

112117
.command-dropdown-active {

packages/tools/playground/src/tools/loadManager.ts

Lines changed: 115 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DecodeBase64ToBinary } from "@dev/core";
1+
import { DecodeBase64ToBinary, Logger } from "@dev/core";
22
import type { GlobalState } from "../globalState";
33
import { Utilities } from "./utilities";
44

@@ -38,6 +38,48 @@ export class LoadManager {
3838
this._loadPlayground(id);
3939
}
4040
});
41+
42+
globalState.onLocalLoadRequiredObservable.add(async () => {
43+
globalState.onDisplayWaitRingObservable.notifyObservers(true);
44+
const json = await this._pickJsonFileAsync();
45+
if (json) {
46+
location.hash = "";
47+
this._processJsonPayload(json);
48+
} else {
49+
globalState.onDisplayWaitRingObservable.notifyObservers(false);
50+
}
51+
});
52+
}
53+
54+
private async _pickJsonFileAsync() {
55+
try {
56+
// Show native file picker
57+
const [handle] = await (window as any).showOpenFilePicker({
58+
types: [
59+
{
60+
description: "Playground JSON Files",
61+
// eslint-disable-next-line @typescript-eslint/naming-convention
62+
accept: { "application/json": [".json"] },
63+
},
64+
],
65+
multiple: false,
66+
});
67+
68+
// Get the file from the handle
69+
const file = await handle.getFile();
70+
71+
// Read the file as text
72+
const text = await file.text();
73+
74+
return text; // This is the raw JSON string
75+
} catch (err) {
76+
if (err.name === "AbortError") {
77+
Logger.Warn("User canceled file selection");
78+
} else {
79+
Logger.Error("Error reading file:", err);
80+
}
81+
return null;
82+
}
4183
}
4284

4385
private _cleanHash() {
@@ -92,81 +134,85 @@ export class LoadManager {
92134
}
93135
}
94136

137+
private _processJsonPayload(data: string) {
138+
if (data.indexOf("class Playground") !== -1) {
139+
if (this.globalState.language === "JS") {
140+
Utilities.SwitchLanguage("TS", this.globalState, true);
141+
}
142+
} else {
143+
// If we're loading JS content and it's TS page
144+
if (this.globalState.language === "TS") {
145+
Utilities.SwitchLanguage("JS", this.globalState, true);
146+
}
147+
}
148+
149+
const snippet = JSON.parse(data);
150+
151+
// Check if title / descr / tags are already set
152+
if (snippet.name != null && snippet.name != "") {
153+
this.globalState.currentSnippetTitle = snippet.name;
154+
} else {
155+
this.globalState.currentSnippetTitle = "";
156+
}
157+
158+
if (snippet.description != null && snippet.description != "") {
159+
this.globalState.currentSnippetDescription = snippet.description;
160+
} else {
161+
this.globalState.currentSnippetDescription = "";
162+
}
163+
164+
if (snippet.tags != null && snippet.tags != "") {
165+
this.globalState.currentSnippetTags = snippet.tags;
166+
} else {
167+
this.globalState.currentSnippetTags = "";
168+
}
169+
170+
// Extract code
171+
const payload = JSON.parse(snippet.jsonPayload || snippet.payload);
172+
let code: string = payload.code.toString();
173+
174+
if (payload.unicode) {
175+
// Need to decode
176+
const encodedData = payload.unicode;
177+
const decoder = new TextDecoder("utf8");
178+
179+
code = decoder.decode((DecodeBase64ToBinary || DecodeBase64ToBinaryReproduced)(encodedData));
180+
}
181+
182+
// check the engine
183+
if (payload.engine && ["WebGL1", "WebGL2", "WebGPU"].includes(payload.engine)) {
184+
// check if an engine is forced in the URL
185+
const url = new URL(window.location.href);
186+
const engineInURL = url.searchParams.get("engine") || url.search.includes("webgpu");
187+
// get the current engine
188+
const currentEngine = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
189+
if (!engineInURL && currentEngine !== payload.engine) {
190+
if (
191+
window.confirm(
192+
`The engine version in this playground (${payload.engine}) is different from the one you are currently using (${currentEngine}).
193+
Confirm to switch to ${payload.engine}, cancel to keep ${currentEngine}`
194+
)
195+
) {
196+
// we need to change the engine
197+
Utilities.StoreStringToStore("engineVersion", payload.engine, true);
198+
window.location.reload();
199+
}
200+
}
201+
}
202+
203+
this.globalState.onCodeLoaded.notifyObservers(code);
204+
205+
this.globalState.onMetadataUpdatedObservable.notifyObservers();
206+
}
207+
95208
private _loadPlayground(id: string) {
96209
this.globalState.loadingCodeInProgress = true;
97210
try {
98211
const xmlHttp = new XMLHttpRequest();
99212
xmlHttp.onreadystatechange = () => {
100213
if (xmlHttp.readyState === 4) {
101214
if (xmlHttp.status === 200) {
102-
if (xmlHttp.responseText.indexOf("class Playground") !== -1) {
103-
if (this.globalState.language === "JS") {
104-
Utilities.SwitchLanguage("TS", this.globalState, true);
105-
}
106-
} else {
107-
// If we're loading JS content and it's TS page
108-
if (this.globalState.language === "TS") {
109-
Utilities.SwitchLanguage("JS", this.globalState, true);
110-
}
111-
}
112-
113-
const snippet = JSON.parse(xmlHttp.responseText);
114-
115-
// Check if title / descr / tags are already set
116-
if (snippet.name != null && snippet.name != "") {
117-
this.globalState.currentSnippetTitle = snippet.name;
118-
} else {
119-
this.globalState.currentSnippetTitle = "";
120-
}
121-
122-
if (snippet.description != null && snippet.description != "") {
123-
this.globalState.currentSnippetDescription = snippet.description;
124-
} else {
125-
this.globalState.currentSnippetDescription = "";
126-
}
127-
128-
if (snippet.tags != null && snippet.tags != "") {
129-
this.globalState.currentSnippetTags = snippet.tags;
130-
} else {
131-
this.globalState.currentSnippetTags = "";
132-
}
133-
134-
// Extract code
135-
const payload = JSON.parse(snippet.jsonPayload);
136-
let code: string = payload.code.toString();
137-
138-
if (payload.unicode) {
139-
// Need to decode
140-
const encodedData = payload.unicode;
141-
const decoder = new TextDecoder("utf8");
142-
143-
code = decoder.decode((DecodeBase64ToBinary || DecodeBase64ToBinaryReproduced)(encodedData));
144-
}
145-
146-
// check the engine
147-
if (payload.engine && ["WebGL1", "WebGL2", "WebGPU"].includes(payload.engine)) {
148-
// check if an engine is forced in the URL
149-
const url = new URL(window.location.href);
150-
const engineInURL = url.searchParams.get("engine") || url.search.includes("webgpu");
151-
// get the current engine
152-
const currentEngine = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
153-
if (!engineInURL && currentEngine !== payload.engine) {
154-
if (
155-
window.confirm(
156-
`The engine version in this playground (${payload.engine}) is different from the one you are currently using (${currentEngine}).
157-
Confirm to switch to ${payload.engine}, cancel to keep ${currentEngine}`
158-
)
159-
) {
160-
// we need to change the engine
161-
Utilities.StoreStringToStore("engineVersion", payload.engine, true);
162-
window.location.reload();
163-
}
164-
}
165-
}
166-
167-
this.globalState.onCodeLoaded.notifyObservers(code);
168-
169-
this.globalState.onMetadataUpdatedObservable.notifyObservers();
215+
this._processJsonPayload(xmlHttp.responseText);
170216
}
171217
}
172218
};

packages/tools/playground/src/tools/saveManager.ts

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EncodeArrayBufferToBase64 } from "@dev/core";
1+
import { EncodeArrayBufferToBase64, Logger } from "@dev/core";
22
import type { GlobalState } from "../globalState";
33
import { Utilities } from "./utilities";
44

@@ -16,6 +16,82 @@ export class SaveManager {
1616
}
1717
this._saveSnippet();
1818
});
19+
20+
globalState.onLocalSaveRequiredObservable.add(() => {
21+
if (!this.globalState.currentSnippetTitle || !this.globalState.currentSnippetDescription || !this.globalState.currentSnippetTags) {
22+
this.globalState.onMetadataWindowHiddenObservable.addOnce((status) => {
23+
if (status) {
24+
this._localSaveSnippet();
25+
}
26+
});
27+
this.globalState.onDisplayMetadataObservable.notifyObservers(true);
28+
return;
29+
}
30+
this._localSaveSnippet();
31+
});
32+
}
33+
34+
private _getSnippetData() {
35+
const encoder = new TextEncoder();
36+
const buffer = encoder.encode(this.globalState.currentCode);
37+
38+
// Check if we need to encode it to store the unicode characters
39+
let testData = "";
40+
41+
for (let i = 0; i < buffer.length; i++) {
42+
testData += String.fromCharCode(buffer[i]);
43+
}
44+
const activeEngineVersion = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
45+
46+
const payLoad = JSON.stringify({
47+
code: this.globalState.currentCode,
48+
unicode: testData !== this.globalState.currentCode ? EncodeArrayBufferToBase64(buffer) : undefined,
49+
engine: activeEngineVersion,
50+
});
51+
52+
const dataToSend = {
53+
payload: payLoad,
54+
name: this.globalState.currentSnippetTitle,
55+
description: this.globalState.currentSnippetDescription,
56+
tags: this.globalState.currentSnippetTags,
57+
};
58+
59+
return JSON.stringify(dataToSend);
60+
}
61+
62+
private async _saveJsonFileAsync(snippetData: string) {
63+
try {
64+
// Open "Save As" dialog
65+
const handle = await (window as any).showSaveFilePicker({
66+
suggestedName: "playground.json",
67+
types: [
68+
{
69+
description: "JSON Files",
70+
// eslint-disable-next-line @typescript-eslint/naming-convention
71+
accept: { "application/json": [".json"] },
72+
},
73+
],
74+
});
75+
76+
// Create a writable stream
77+
const writable = await handle.createWritable();
78+
79+
// Write the JSON string (pretty-printed)
80+
await writable.write(snippetData);
81+
82+
// Close the file
83+
await writable.close();
84+
} catch (err) {
85+
if (err.name === "AbortError") {
86+
Logger.Warn("User canceled save dialog");
87+
} else {
88+
Logger.Error("Error saving file:", err);
89+
}
90+
}
91+
}
92+
93+
private _localSaveSnippet() {
94+
void this._saveJsonFileAsync(this._getSnippetData());
1995
}
2096

2197
private _saveSnippet() {
@@ -67,30 +143,6 @@ export class SaveManager {
67143
xmlHttp.open("POST", this.globalState.SnippetServerUrl + (this.globalState.currentSnippetToken ? "/" + this.globalState.currentSnippetToken : ""), true);
68144
xmlHttp.setRequestHeader("Content-Type", "application/json");
69145

70-
const encoder = new TextEncoder();
71-
const buffer = encoder.encode(this.globalState.currentCode);
72-
73-
// Check if we need to encode it to store the unicode characters
74-
let testData = "";
75-
76-
for (let i = 0; i < buffer.length; i++) {
77-
testData += String.fromCharCode(buffer[i]);
78-
}
79-
const activeEngineVersion = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
80-
81-
const payLoad = JSON.stringify({
82-
code: this.globalState.currentCode,
83-
unicode: testData !== this.globalState.currentCode ? EncodeArrayBufferToBase64(buffer) : undefined,
84-
engine: activeEngineVersion,
85-
});
86-
87-
const dataToSend = {
88-
payload: payLoad,
89-
name: this.globalState.currentSnippetTitle,
90-
description: this.globalState.currentSnippetDescription,
91-
tags: this.globalState.currentSnippetTags,
92-
};
93-
94-
xmlHttp.send(JSON.stringify(dataToSend));
146+
xmlHttp.send(this._getSnippetData());
95147
}
96148
}

0 commit comments

Comments
 (0)