From cc4cd1a53228548d9d6e94d1bdcd07ad96fac206 Mon Sep 17 00:00:00 2001 From: ochafik Date: Thu, 26 Dec 2024 19:18:46 +0000 Subject: [PATCH] Basic URL loading support --- src/components/App.tsx | 2 +- src/components/EditorPanel.tsx | 4 +-- src/index.tsx | 2 +- src/runner/openscad-worker.ts | 9 +++-- src/state/fragment-state.ts | 18 ++++++---- src/state/initial-state.ts | 66 ++++++++++++++++++++++------------ src/state/model.ts | 35 +++++++++++------- src/utils.ts | 12 ++++--- tests/e2e.test.js | 20 +++++++---- 9 files changed, 109 insertions(+), 59 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 3f98f4c..07e0817 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -107,4 +107,4 @@ export function App({initialState, statePersister, fs}: {initialState: State, st ); -} \ No newline at end of file +} diff --git a/src/components/EditorPanel.tsx b/src/components/EditorPanel.tsx index f566a86..9d1c04d 100644 --- a/src/components/EditorPanel.tsx +++ b/src/components/EditorPanel.tsx @@ -9,7 +9,7 @@ import { Button } from 'primereact/button'; import { MenuItem } from 'primereact/menuitem'; import { Menu } from 'primereact/menu'; import { buildUrlForStateParams } from '../state/fragment-state'; -import { blankProjectState, defaultSourcePath } from '../state/initial-state'; +import { getBlankProjectState, defaultSourcePath } from '../state/initial-state'; import { ModelContext, FSContext } from './contexts'; import FilePicker, { } from './FilePicker'; @@ -89,7 +89,7 @@ export default function EditorPanel({className, style}: {className?: string, sty { label: "New project", icon: 'pi pi-plus-circle', - url: buildUrlForStateParams(blankProjectState), + command: async () => window.open(buildUrlForStateParams(await getBlankProjectState()), '_blank'), target: '_blank', }, { diff --git a/src/index.tsx b/src/index.tsx index 0af1d28..4456c80 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -89,7 +89,7 @@ window.addEventListener('load', async () => { }; } - const initialState = createInitialState(persistedState); + const initialState = await createInitialState(persistedState); const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement diff --git a/src/runner/openscad-worker.ts b/src/runner/openscad-worker.ts index 05456ce..158eb7f 100644 --- a/src/runner/openscad-worker.ts +++ b/src/runner/openscad-worker.ts @@ -6,7 +6,6 @@ import { createEditorFS, getParentDir, symlinkLibraries } from "../fs/filesystem import { OpenSCADInvocation, OpenSCADInvocationCallback, OpenSCADInvocationResults } from "./openscad-runner"; import { deployedArchiveNames, zipArchives } from "../fs/zip-archives"; import { fetchSource } from "../utils.js"; -import { stderr } from "process"; declare var BrowserFS: BrowserFSInterface importScripts("browserfs.min.js"); @@ -94,7 +93,13 @@ addEventListener('message', async (e) => { for (const source of inputs) { try { console.log(`Writing ${source.path}`); - instance.FS.writeFile(source.path, await fetchSource(source)); + if (source.content == null && source.path != null && source.url == null) { + if (!instance.FS.isFile(source.path)) { + console.error(`File ${source.path} does not exist!`); + } + } else { + instance.FS.writeFile(source.path, await fetchSource(instance.FS, source)); + } } catch (e) { console.trace(e); throw new Error(`Error while trying to write ${source.path}: ${e}`); diff --git a/src/state/fragment-state.ts b/src/state/fragment-state.ts index 9e87631..8e146e7 100644 --- a/src/state/fragment-state.ts +++ b/src/state/fragment-state.ts @@ -52,15 +52,19 @@ export async function readStateFromFragment(): Promise { try { const serialized = window.location.hash.substring(1); if (serialized === 'blank') { - return createInitialState(null, ''); - } else if (serialized.startsWith('src:')) { + return createInitialState(null, {content: ''}); + } else if (serialized.startsWith('src=')) { // For testing - const src = decodeURIComponent(serialized.substring('src:'.length)); - return createInitialState(null, src); - } else if (serialized.startsWith('testpath:')) { + const src = decodeURIComponent(serialized.substring('src='.length)); + return createInitialState(null, {content: src}); + } else if (serialized.startsWith('path=')) { + const path = decodeURIComponent(serialized.substring('path='.length)); + return createInitialState(null, {path}); + } else if (serialized.startsWith('url=')) { // For testing - const path = decodeURIComponent(serialized.substring('testpath:'.length)); - return createInitialState(null, '...', path); + const url = decodeURIComponent(serialized.substring('url='.length)); + const path = '/' + new URL(url).pathname.split('/').pop(); + return createInitialState(null, {path, url}); } let obj; try { diff --git a/src/state/initial-state.ts b/src/state/initial-state.ts index 26cd638..4a858cb 100644 --- a/src/state/initial-state.ts +++ b/src/state/initial-state.ts @@ -2,36 +2,52 @@ import defaultScad from './default-scad'; import { State } from './app-state'; +import { fetchSource } from '../utils'; export const defaultSourcePath = '/playground.scad'; export const defaultModelColor = '#f9d72c'; -export function createInitialState(state: State | null, content = defaultScad, activePath = defaultSourcePath): State { +export async function createInitialState(state: State | null, source?: {content?: string, path?: string, url?: string}): Promise { type Mode = State['view']['layout']['mode']; const mode: Mode = window.matchMedia("(min-width: 768px)").matches ? 'multi' : 'single'; - const initialState: State = { - params: { - activePath: activePath, - sources: [{path: activePath, content}], - features: [], - exportFormat2D: 'svg', - exportFormat3D: 'glb', - }, - view: { - layout: { - mode: 'multi', - editor: true, - viewer: true, - customizer: false, - } as any, + let initialState: State; + if (state) { + if (source) throw new Error('Cannot provide source when state is provided'); + initialState = state; + } else { + let content, path, url; + if (source) { + content = source.content; + path = source.path; + url = source.url; + } else { + content = defaultScad; + path = defaultSourcePath; + } + let activePath = path ?? (url && new URL(url).pathname.split('/').pop()) ?? defaultSourcePath; + initialState = { + params: { + activePath, + sources: [{path: activePath, content, url}], + features: [], + exportFormat2D: 'svg', + exportFormat3D: 'glb', + }, + view: { + layout: { + mode: 'multi', + editor: true, + viewer: true, + customizer: false, + } as any, - color: defaultModelColor, - }, - ...(state ?? {}) - }; + color: defaultModelColor, + }, + }; + } if (initialState.view.layout.mode != mode) { if (mode === 'multi' && initialState.view.layout.mode === 'single') { @@ -51,7 +67,7 @@ export function createInitialState(state: State | null, content = defaultScad, a } } - initialState.view.showAxes ??= true + initialState.view.showAxes ??= true; // fs.writeFile(initialState.params.sourcePath, initialState.params.source); // if (initialState.params.sourcePath !== defaultSourcePath) { @@ -67,5 +83,9 @@ export function createInitialState(state: State | null, content = defaultScad, a return initialState; } - -export const blankProjectState: State = createInitialState(null, ''); +export async function getBlankProjectState() { + return await createInitialState(null, { + path: defaultSourcePath, + content: defaultScad, + }); +} diff --git a/src/state/model.ts b/src/state/model.ts index 85dbaeb..30cf50e 100644 --- a/src/state/model.ts +++ b/src/state/model.ts @@ -14,14 +14,15 @@ import { exportGlb } from "../io/export_glb"; import { export3MF } from "../io/export_3mf"; import chroma from "chroma-js"; +const githubRx = /^https:\/\/github.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/; + export class Model { constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void, private statePersister?: StatePersister) { } init() { - if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering && - this.source.trim() != '') { + if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering) { this.processSource(); } } @@ -156,16 +157,26 @@ export class Model { } } - private processSource() { - // const params = this.state.params; - // if (isFileWritable(params.sourcePath)) { - // const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${params.sourcePath}`; - // this.fs.writeFile(params.sourcePath, params.source); - // } - if (this.state.params.activePath.endsWith('.scad')) { - this.checkSyntax(); + private async processSource() { + let src = this.state.params.sources.find(src => src.path === this.state.params.activePath); + if (src && src.content == null) { + let {path, url} = src; + // Transform https://github.com/tenstad/keyboard/blob/main/keyboard.scad to https://raw.githubusercontent.com/tenstad/keyboard/refs/heads/main/keyboard.scad + let match; + if (url && (match = url.match(githubRx))) { + url = `https://raw.githubusercontent.com/${match[1]}/${match[2]}/refs/heads/${match[3]}`; + } + const content = new TextDecoder().decode(await fetchSource(this.fs, {path, url})); + this.mutate(s => { + s.params.sources = s.params.sources.map(src => src.path === s.params.activePath ? {...src, content} : src); + }); + } + if (this.source.trim() !== '') { + if (this.state.params.activePath.endsWith('.scad')) { + this.checkSyntax(); + } + this.render({isPreview: true, now: false}); } - this.render({isPreview: true, now: false}); } async checkSyntax() { @@ -304,7 +315,7 @@ export class Model { if (path.startsWith('/')) { path = path.substring(1); } - zip.file(path, await fetchSource(source)); + zip.file(path, await fetchSource(this.fs, source)); } zip.generateAsync({type: 'blob'}).then(blob => { const file = new File([blob], 'project.zip'); diff --git a/src/utils.ts b/src/utils.ts index d587153..4bc8a9d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -151,13 +151,14 @@ export function downloadUrl(url: string, filename: string) { link.parentNode?.removeChild(link); } -export async function fetchSource({content, path, url}: Source) { +export async function fetchSource(fs: FS, {content, path, url}: Source): Promise { + const isText = path.endsWith('.scad') || path.endsWith('.json'); if (content) { - return content; + return new TextEncoder().encode(content); } else if (url) { - if (path.endsWith('.scad') || path.endsWith('.json')) { + if (isText) { content = await (await fetch(url)).text(); - return content.replace(/\r\n/g, '\n'); + return new TextEncoder().encode(content.replace(/\r\n/g, '\n')); } else { // Fetch bytes const response = await fetch(url); @@ -165,6 +166,9 @@ export async function fetchSource({content, path, url}: Source) { const data = new Uint8Array(buffer); return data; } + } else if (path) { + const data = fs.readFileSync(path); + return new Uint8Array('buffer' in data ? data.buffer : data); } else { throw new Error('Invalid source: ' + JSON.stringify({path, content, url})); } diff --git a/tests/e2e.test.js b/tests/e2e.test.js index de3eb94..6ea7f50 100644 --- a/tests/e2e.test.js +++ b/tests/e2e.test.js @@ -1,7 +1,7 @@ const longTimeout = 60000; const isProd = process.env.NODE_ENV === 'production'; -const url = isProd ? 'http://localhost:3000/dist/' : 'http://localhost:4000/'; +const baseUrl = isProd ? 'http://localhost:3000/dist/' : 'http://localhost:4000/'; const messages = []; @@ -33,10 +33,13 @@ afterEach(async () => { }); function loadSrc(src) { - return page.goto(url + '#src:' + encodeURIComponent(src)); + return page.goto(baseUrl + '#src=' + encodeURIComponent(src)); } function loadPath(path) { - return page.goto(url + '#testpath:' + encodeURIComponent(path)); + return page.goto(baseUrl + '#path=' + encodeURIComponent(path)); +} +function loadUrl(url) { + return page.goto(baseUrl + '#url=' + encodeURIComponent(url)); } async function waitForViewer() { await page.waitForSelector('model-viewer'); @@ -77,7 +80,7 @@ function waitForLabel(text) { describe('e2e', () => { test('load the default page', async () => { - await page.goto(url); + await page.goto(baseUrl); await waitForViewer(); expectObjectList(); }, longTimeout); @@ -112,6 +115,12 @@ describe('e2e', () => { expect3DPolySet(); }, longTimeout); + test('load a demo by url', async () => { + await loadUrl('https://github.com/tenstad/keyboard/blob/main/keyboard.scad'); + await waitForViewer(); + expect3DManifold(); + }, longTimeout); + test('customizer & windows line endings work', async () => { await loadSrc([ 'myVar = 10;', @@ -119,11 +128,8 @@ describe('e2e', () => { ].join('\r\n')); await waitForViewer(); expect3DPolySet(); - await (await waitForCustomizeButton()).click(); - await page.waitForSelector('fieldset'); - await waitForLabel('myVar'); }, longTimeout); });