From 169aab4e65315b27daf82d2ae62143dbcfa58440 Mon Sep 17 00:00:00 2001 From: Remco Veldkamp Date: Sun, 4 Oct 2026 21:32:58 +0200 Subject: [PATCH 1/3] Turn the example into a reusable component package Wrap gcode-preview in a component with typed option props (live or mount-only), src/gcode loading, ready/load/error callbacks and instance access. The library builds to dist/ for npm (still private: true); the demo imports it from source and gets its own build script for Firebase. --- .github/workflows/firebase-hosting-merge.yml | 2 +- .../firebase-hosting-pull-request.yml | 2 +- README.md | 179 +++++++-- eslint.config.js | 2 +- package-lock.json | 97 ++++- package.json | 79 ++-- src/App.tsx | 72 +++- src/components/GCodePreview.test.jsx | 103 ----- src/components/GCodePreview.tsx | 87 ---- src/lib/GCodePreview.test.tsx | 371 ++++++++++++++++++ src/lib/GCodePreview.tsx | 285 ++++++++++++++ src/lib/index.ts | 2 + src/styles.css | 19 +- tsconfig.lib.json | 13 + 14 files changed, 1044 insertions(+), 269 deletions(-) delete mode 100644 src/components/GCodePreview.test.jsx delete mode 100644 src/components/GCodePreview.tsx create mode 100644 src/lib/GCodePreview.test.tsx create mode 100644 src/lib/GCodePreview.tsx create mode 100644 src/lib/index.ts create mode 100644 tsconfig.lib.json diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 43c1ffe..df25666 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -15,7 +15,7 @@ jobs: with: node-version-file: .nvmrc cache: npm - - run: npm ci && npm run build + - run: npm ci && npm run build:demo - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index d86a5f4..183561f 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -17,7 +17,7 @@ jobs: with: node-version-file: .nvmrc cache: npm - - run: npm ci && npm run build + - run: npm ci && npm run build:demo - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 2a25c8f..377bb91 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,164 @@ -# GCode Preview 3.0 with React & TypeScript +# gcode-preview-react -This demo uses [GCode Preview](https://github.com/xyz-tools/gcode-preview) -`3.0.0-alpha.6` with a React 19 / TypeScript / Vite setup. +A React component for [GCode Preview](https://github.com/xyz-tools/gcode-preview): +a 3D preview of G-code files, rendered with three.js into a ``. It +creates and disposes the preview for you, loads a file from a URL or a string, +and keeps the preview in sync with its props. + +Works with React 18 and 19, and in the Next.js App Router (the component is +marked `'use client'`). + +## Install ```sh -npm install -npm start +npm install gcode-preview-react gcode-preview ``` -Open http://localhost:5173. `npm run build` typechecks and creates the -production build in `build/`, which `npm run preview` serves. `npm run lint` -checks the source and `npm test` runs the lifecycle regression tests (Vitest). +`gcode-preview` (`^3.0.0-alpha.6`) and `react` are peer dependencies. -## Component +With TypeScript, also install `@types/three` to get strict types for the +color props (the core's option types come from three.js). + +## Usage ```tsx -import GCodePreview from './components/GCodePreview'; - - +import { GCodePreview } from 'gcode-preview-react'; + +export function Viewer() { + return ( + console.warn(error.message)} + /> + ); +} ``` -The wrapper creates a `new GCodePreview(...)` after the canvas mounts and -passes a decoded fetch stream to `processGCodeStream`. Source or option changes -reload the preview. Layer ranges are applied through `preview.sceneManager` -after loading, when the complete layer count is known. Loading failures appear -below the canvas. Cleanup aborts fetch, removes the resize listener and calls -`preview.dispose()`, including during React Strict Mode remounts. +## Props + +### Loading + +| Prop | Type | Description | +| ------- | -------------------- | ---------------------------------------------------------- | +| `src` | `string \| URL` | URL of a G-code file. It is fetched and streamed in. | +| `gcode` | `string \| string[]` | G-code to process directly. If both are set, `gcode` wins. | + +Changing `src` or `gcode` clears the preview and starts a new load; a load +that is still running is aborted. Unsetting both clears the preview. Pass a +stable array for `gcode` (for example from state or `useMemo`), since a new +array starts a new load. + +### Preview options + +Every option of the core `GCodePreview` (except `canvas`) is a prop, with the +type from `gcode-preview`. See the +[core documentation](https://gcode-preview.web.app/docs) for what each one +does. An option you don't pass is not passed to the core either, so the core +default applies. + +**Live options** update the preview in place when the prop changes: + +`buildVolume`, `backgroundColor`, `extrusionColor`, `travelColor`, +`topLayerColor`, `lastSegmentColor`, `boundingBoxColor`, `startLayer`, +`endLayer`, `renderExtrusion`, `renderTravel`, `renderTubes`, `lineWidth`, +`lineHeight`, `extrusionWidth`, `disableGradient`, `orthographic`, `devMode` + +Only options whose value changed are applied. Objects and arrays such as +`buildVolume` and `extrusionColor` are compared one level deep, so passing +them inline is fine. `startLayer` and `endLayer` are applied again after each +load, because the core clamps them to the loaded layer count. + +Changing a live option back to `undefined` resets it for `startLayer`, +`endLayer`, `topLayerColor`, `lastSegmentColor`, `boundingBoxColor`, +`buildVolume`, `lineHeight`, `extrusionWidth` and `devMode`. The other live +options keep their last value, because their core setters don't accept +`undefined`. + +**Mount-only options** are read once, when the preview is created. Later +changes are ignored; remount the component (for example with a `key`) to +apply them: + +`initialCameraPosition`, `minLayerThreshold`, `droppable`, `keepLines`, +`liveRenderInterval`, `arcChordTolerance` + +### Canvas attributes + +Any other canvas attribute (`className`, `style`, `id`, `tabIndex`, `aria-*`, +event handlers, ...) is passed to the ``. It has +`aria-label="G-code preview"` unless you pass your own. + +## Callbacks + +| Prop | Called with | When | +| --------- | --------------------------- | ----------------------------------------------- | +| `onReady` | the `GCodePreview` instance | the preview has been created | +| `onLoad` | the `GCodePreview` instance | `src` or `gcode` has been loaded and rendered | +| `onError` | an `Error` | loading failed, e.g. `HTTP 404: /missing.gcode` | + +Without an `onError` prop, load errors are logged with `console.error`. A load +that was replaced or whose component unmounted calls nothing. The latest +callbacks are always used, so inline functions don't recreate the preview. + +## Instance access + +The `ref` gives you the core `GCodePreview` instance, for anything the props +don't cover. It is set after mount (use it in an effect, an event handler or +`onReady`), and `null` after unmount. + +```tsx +import type { GCodePreview as Preview } from 'gcode-preview'; +import { GCodePreview } from 'gcode-preview-react'; +import { useRef } from 'react'; + +function Viewer() { + const preview = useRef(null); + return ( + <> + + + + ); +} +``` + +## Sizing + +The component renders a bare `` without any size or stylesheet: size +the canvas with CSS (`className` or `style`). The preview follows the canvas +size through a `ResizeObserver`. + +## Development + +```sh +npm install +npm run dev +``` + +`npm run dev` starts the demo app (`src/App.tsx`) at http://localhost:5173. The +demo imports the component from source (`src/lib`). `npm run build:demo` +typechecks and builds the demo into `build/`, which is deployed to Firebase +Hosting. + +| Script | What it does | +| ---------------------- | ---------------------------------------------- | +| `npm run build` | builds the library (`src/lib`) into `dist/` | +| `npm test` | runs the component tests (Vitest, core mocked) | +| `npm run typecheck` | typechecks the demo, tests and library | +| `npm run lint` | ESLint + Prettier | +| `npm run lint:package` | checks the package setup with publint | + +The package is marked `"private": true` so it can't be published by accident. +Remove that flag when it is ready to be published; `prepack` builds the +library first. + +The square and triangle towers in `public/` are small synthetic samples, not +printer-ready G-code. + +## License -The local square and triangle towers are small synthetic preview samples, not -printer-ready G-code. Put your own files in `public/` and change `src` to use them. +MIT diff --git a/eslint.config.js b/eslint.config.js index 20eb782..22020a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['build', 'coverage'] }, + { ignores: ['build', 'dist', 'coverage'] }, js.configs.recommended, tseslint.configs.recommended, react.configs.flat.recommended, diff --git a/package-lock.json b/package-lock.json index 89fbbf4..76dc0f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,13 @@ { - "name": "react-typescript", - "version": "1.0.0", + "name": "gcode-preview-react", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "react-typescript", - "version": "1.0.0", - "dependencies": { - "gcode-preview": "3.0.0-alpha.6", - "react": "19.3.0", - "react-dom": "19.3.0", - "three": "0.185.0" - }, + "name": "gcode-preview-react", + "version": "0.1.0", + "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.5", "@types/react": "^19.3.0", @@ -24,9 +19,13 @@ "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", + "gcode-preview": "3.0.0-alpha.6", "globals": "^17.12.0", "jsdom": "^29.1.1", "prettier": "^3.9.6", + "publint": "^0.3.25", + "react": "19.3.0", + "react-dom": "19.3.0", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", @@ -34,6 +33,10 @@ }, "engines": { "node": ">=22.12.0" + }, + "peerDependencies": { + "gcode-preview": "^3.0.0-alpha.6", + "react": "^18.0.0 || ^19.0.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -801,6 +804,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@publint/pack": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.7.tgz", + "integrity": "sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", @@ -2885,6 +2904,7 @@ "version": "3.0.0-alpha.6", "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-3.0.0-alpha.6.tgz", "integrity": "sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ==", + "dev": true, "license": "MIT", "dependencies": { "lil-gui": "^0.20.0", @@ -4070,6 +4090,7 @@ "version": "0.20.0", "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.20.0.tgz", "integrity": "sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4165,6 +4186,16 @@ "node": "*" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4418,6 +4449,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4581,6 +4619,28 @@ "react-is": "^16.13.1" } }, + "node_modules/publint": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.25.tgz", + "integrity": "sha512-WMMbn/d7JqRlfXQL/JUI4n5skC8cJ5WVr8/wT5mmiYWkUdENkZr1I5rgs2+dQPVRgoJd+JRYtB46MTsuDylw9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.7", + "package-manager-detector": "^1.8.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4595,6 +4655,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4604,6 +4665,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.28.0" @@ -4741,6 +4803,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -4813,6 +4888,7 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -5183,6 +5259,7 @@ "version": "0.185.0", "resolved": "https://registry.npmjs.org/three/-/three-0.185.0.tgz", "integrity": "sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w==", + "dev": true, "license": "MIT" }, "node_modules/tinybench": { diff --git a/package.json b/package.json index 82473db..badba68 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,59 @@ { - "name": "react-typescript", - "version": "1.0.0", - "description": "React and TypeScript example starter project", + "name": "gcode-preview-react", + "version": "0.1.0", + "private": true, + "description": "React component for gcode-preview: a 3D preview of G-code files in a canvas", "keywords": [ - "typescript", + "gcode", + "g-code", + "gcode-preview", + "3d-printing", + "preview", "react", - "starter" + "react-component", + "threejs" ], - "main": "src/index.tsx", - "dependencies": { - "gcode-preview": "3.0.0-alpha.6", - "react": "19.3.0", - "react-dom": "19.3.0", - "three": "0.185.0" + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/xyz-tools/gcode-preview-react.git" + }, + "homepage": "https://github.com/xyz-tools/gcode-preview-react#readme", + "bugs": { + "url": "https://github.com/xyz-tools/gcode-preview-react/issues" + }, + "type": "module", + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "npm run clean && tsc -p tsconfig.lib.json", + "build:demo": "tsc --noEmit && vite build", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "preview": "vite preview", + "prepack": "npm run build", + "typecheck": "tsc --noEmit && tsc -p tsconfig.lib.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:package": "publint", + "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc.json" + }, + "peerDependencies": { + "gcode-preview": "^3.0.0-alpha.6", + "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -25,26 +66,18 @@ "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", + "gcode-preview": "3.0.0-alpha.6", "globals": "^17.12.0", "jsdom": "^29.1.1", "prettier": "^3.9.6", + "publint": "^0.3.25", + "react": "19.3.0", + "react-dom": "19.3.0", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", "vitest": "^5.0.0" }, - "scripts": { - "start": "vite", - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc.json" - }, - "type": "module", "engines": { "node": ">=22.12.0" } diff --git a/src/App.tsx b/src/App.tsx index f2e6484..68eeaf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,72 @@ -import type { JSX } from 'react'; +import { useState, type JSX } from 'react'; -import GCodePreview from './components/GCodePreview'; +import { GCodePreview } from './lib'; import './styles.css'; +const samples = [ + { + name: 'Square tower', + file: 'square-tower.gcode', + topLayerColor: 'lime', + lastSegmentColor: 'red', + startLayer: 20, + endLayer: 150 + }, + { + name: 'Triangle tower', + file: 'triangle-tower.gcode', + topLayerColor: 'purple', + lastSegmentColor: 'cyan' + } +]; + +type Status = + | { state: 'loading' } + | { state: 'loaded' } + | { state: 'error'; message: string }; + function App(): JSX.Element { + const [sample, setSample] = useState(samples[0]); + const [status, setStatus] = useState({ state: 'loading' }); + + function select(next: (typeof samples)[number]) { + setSample(next); + setStatus({ state: 'loading' }); + } + return (

GCode Preview 3.0 React & TypeScript Demo

+
+ {samples.map(s => ( + + ))} +
- setStatus({ state: 'loaded' })} + onError={error => setStatus({ state: 'error', message: error.message })} /> + {status.state === 'loading' &&

Loading G-code…

} + {status.state === 'error' &&

{status.message}

} +

+ Layers {sample.startLayer ?? 'first'} to {sample.endLayer ?? 'last'} +

); } diff --git a/src/components/GCodePreview.test.jsx b/src/components/GCodePreview.test.jsx deleted file mode 100644 index 355f154..0000000 --- a/src/components/GCodePreview.test.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import { StrictMode, act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { GCodePreview } from 'gcode-preview'; -import Preview from './GCodePreview'; - -vi.mock('gcode-preview', () => ({ GCodePreview: vi.fn() })); - -let container; -let root; -let instances; -const originalFetch = globalThis.fetch; -const originalDecoder = globalThis.TextDecoderStream; - -beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true; - globalThis.TextDecoderStream = class {}; - instances = []; - vi.mocked(GCodePreview).mockImplementation(function () { - const instance = { - processGCodeStream: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sceneManager: { resize: vi.fn(), render: vi.fn() } - }; - instances.push(instance); - return instance; - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - globalThis.fetch = originalFetch; - globalThis.TextDecoderStream = originalDecoder; - vi.clearAllMocks(); -}); - -function response() { - return { ok: true, body: { pipeThrough: vi.fn(() => 'decoded stream') } }; -} - -test('streams decoded text, applies layers after loading, and cleans up', async () => { - globalThis.fetch = vi.fn().mockResolvedValue(response()); - await act(async () => { - root.render(); - }); - const preview = instances[0]; - expect(preview.processGCodeStream).toHaveBeenCalledWith('decoded stream'); - expect(preview.sceneManager.startLayer).toBe(20); - expect(preview.sceneManager.endLayer).toBe(150); - expect(container.querySelector('[role="status"]')).toBeNull(); - window.dispatchEvent(new Event('resize')); - expect(preview.sceneManager.resize).toHaveBeenCalledTimes(1); - await act(async () => root.render(null)); - expect(globalThis.fetch.mock.calls[0][1].signal.aborted).toBe(true); - expect(preview.dispose).toHaveBeenCalledTimes(1); - window.dispatchEvent(new Event('resize')); - expect(preview.sceneManager.resize).toHaveBeenCalledTimes(1); -}); - -test('ignores a late response from a replaced source', async () => { - let resolveOld; - globalThis.fetch = vi - .fn() - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveOld = resolve; - }) - ) - .mockResolvedValue(response()); - await act(async () => root.render()); - await act(async () => root.render()); - await act(async () => resolveOld(response())); - expect(instances[0].dispose).toHaveBeenCalledTimes(1); - expect(instances[0].processGCodeStream).not.toHaveBeenCalled(); - expect(instances[1].processGCodeStream).toHaveBeenCalledTimes(1); -}); - -test('reports failed loads', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); - await act(async () => root.render()); - expect(container.querySelector('[role="alert"]').textContent).toContain( - 'HTTP 404' - ); -}); - -test('survives Strict Mode effect replay without loading a disposed preview', async () => { - globalThis.fetch = vi.fn().mockResolvedValue(response()); - await act(async () => - root.render( - - - - ) - ); - expect(instances).toHaveLength(2); - expect(instances[0].dispose).toHaveBeenCalledTimes(1); - expect(instances[0].processGCodeStream).not.toHaveBeenCalled(); - expect(instances[1].processGCodeStream).toHaveBeenCalledTimes(1); -}); diff --git a/src/components/GCodePreview.tsx b/src/components/GCodePreview.tsx deleted file mode 100644 index adff4e8..0000000 --- a/src/components/GCodePreview.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { GCodePreview } from 'gcode-preview'; -import { useEffect, useRef, useState, type JSX } from 'react'; - -interface GCodePreviewProps { - src: string; - topLayerColor?: string; - lastSegmentColor?: string; - startLayer?: number; - endLayer?: number; - lineWidth?: number; -} - -export default function GCodePreviewUI({ - src, - topLayerColor = 'lime', - lastSegmentColor = 'red', - startLayer, - endLayer, - lineWidth = 1 -}: GCodePreviewProps): JSX.Element { - const canvasRef = useRef(null); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!canvasRef.current) return; - - const controller = new AbortController(); - const preview = new GCodePreview({ - canvas: canvasRef.current, - lineWidth, - topLayerColor, - lastSegmentColor, - buildVolume: { x: 250, y: 220, z: 150, smallGrid: true }, - initialCameraPosition: [0, 400, 450], - droppable: false - }); - const resize = () => preview.sceneManager.resize(); - window.addEventListener('resize', resize); - setError(''); - setLoading(true); - - async function load() { - try { - const response = await fetch(src, { signal: controller.signal }); - if (controller.signal.aborted) return; - if (!response.ok) throw new Error(`HTTP ${response.status}: ${src}`); - if (!response.body) - throw new Error('The response has no G-code stream.'); - await preview.processGCodeStream( - response.body.pipeThrough(new TextDecoderStream()) - ); - if (controller.signal.aborted) return; - // Apply the range after loading so it is clamped against the full job. - preview.sceneManager.startLayer = startLayer; - preview.sceneManager.endLayer = endLayer; - preview.sceneManager.render(); - } catch (cause) { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : String(cause)); - } - } finally { - if (!controller.signal.aborted) setLoading(false); - } - } - void load(); - - return () => { - controller.abort(); - window.removeEventListener('resize', resize); - preview.dispose(); - }; - }, [src, topLayerColor, lastSegmentColor, startLayer, endLayer, lineWidth]); - - return ( -
- - {loading &&

Loading G-code…

} - {error &&

{error}

} -
topLayerColor: {topLayerColor}
-
lastSegmentColor: {lastSegmentColor}
-
startLayer: {startLayer ?? 'first'}
-
endLayer: {endLayer ?? 'last'}
-
lineWidth: {lineWidth}
-
- ); -} diff --git a/src/lib/GCodePreview.test.tsx b/src/lib/GCodePreview.test.tsx new file mode 100644 index 0000000..904c5c2 --- /dev/null +++ b/src/lib/GCodePreview.test.tsx @@ -0,0 +1,371 @@ +import { GCodePreview as CorePreview } from 'gcode-preview'; +import { StrictMode, act, createRef, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { Mock } from 'vitest'; +import { GCodePreview } from './index'; + +vi.mock('gcode-preview', () => ({ GCodePreview: vi.fn() })); + +interface MockPreview { + options: Record; + processGCode: Mock; + processGCodeStream: Mock; + clear: Mock; + dispose: Mock; + devMode?: unknown; + sceneManager: Record & { resize: Mock }; +} + +let container: HTMLDivElement; +let root: Root; +let instances: MockPreview[]; +// Every setter call and core method call, in order. +let log: string[]; +let observers: MockResizeObserver[]; +const originalFetch = globalThis.fetch; + +class MockResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + constructor(public callback: () => void) { + observers.push(this); + } +} + +class MockTextDecoderStream {} + +function recording(target: T, prefix: string): T { + return new Proxy(target, { + set(obj, key, value) { + log.push( + `${prefix}${String(key)}=${JSON.stringify(value) ?? 'undefined'}` + ); + return Reflect.set(obj, key, value); + } + }); +} + +beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', MockResizeObserver); + vi.stubGlobal('TextDecoderStream', MockTextDecoderStream); + instances = []; + log = []; + observers = []; + vi.mocked(CorePreview).mockImplementation(function ( + options: Record + ) { + const instance: MockPreview = { + options, + processGCode: vi.fn(async () => void log.push('processGCode')), + processGCodeStream: vi.fn( + async () => void log.push('processGCodeStream') + ), + clear: vi.fn(() => void log.push('clear')), + dispose: vi.fn(), + sceneManager: recording({ resize: vi.fn() }, '') + }; + const preview = recording(instance, 'preview.'); + instances.push(preview); + return preview; + } as never); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + globalThis.fetch = originalFetch; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +async function render(element: ReactNode) { + await act(async () => root.render(element)); +} + +function response() { + return { ok: true, body: { pipeThrough: vi.fn(() => 'decoded stream') } }; +} + +function mockFetch(...responses: unknown[]) { + const fetch = vi.fn(); + for (const r of responses) fetch.mockImplementationOnce(() => r); + fetch.mockImplementation(() => Promise.resolve(response())); + globalThis.fetch = fetch; + return fetch; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +test('creates the preview with the canvas and the given options only', async () => { + mockFetch(); + const onReady = vi.fn(); + await render( + {}} + onError={() => {}} + className="viewer" + id="preview" + /> + ); + const canvas = container.querySelector('canvas')!; + expect(instances).toHaveLength(1); + expect(instances[0].options).toEqual({ + canvas, + lineWidth: 2, + topLayerColor: 'lime', + buildVolume: { x: 200, y: 200, z: 180, smallGrid: false }, + initialCameraPosition: [0, 400, 450], + renderTravel: false + }); + expect('renderExtrusion' in instances[0].options).toBe(false); + expect(onReady).toHaveBeenCalledWith(instances[0]); + expect(canvas.className).toBe('viewer'); + expect(canvas.id).toBe('preview'); + expect(canvas.getAttribute('aria-label')).toBe('G-code preview'); + expect(canvas.hasAttribute('src')).toBe(false); + expect(container.children).toHaveLength(1); + + await render(); + expect(canvas.getAttribute('aria-label')).toBe('Benchy'); +}); + +test('streams a decoded src, then applies the layer range and calls onLoad', async () => { + const res = response(); + const fetch = mockFetch(Promise.resolve(res)); + const onLoad = vi.fn(); + await render( + + ); + const preview = instances[0]; + expect(fetch).toHaveBeenCalledWith('https://example.com/a.gcode', { + signal: expect.any(AbortSignal) + }); + expect(res.body.pipeThrough).toHaveBeenCalledWith( + expect.any(MockTextDecoderStream) + ); + expect(preview.processGCodeStream).toHaveBeenCalledWith('decoded stream'); + expect(log).toEqual([ + 'clear', + 'processGCodeStream', + 'startLayer=20', + 'endLayer=150' + ]); + expect(onLoad).toHaveBeenCalledExactlyOnceWith(preview); +}); + +test('processes gcode directly, and gcode wins over src', async () => { + const fetch = mockFetch(); + const onLoad = vi.fn(); + await render( + + ); + const preview = instances[0]; + expect(fetch).not.toHaveBeenCalled(); + expect(preview.processGCode).toHaveBeenCalledWith('G1 X1'); + expect(log).toEqual([ + 'clear', + 'processGCode', + 'startLayer=undefined', + 'endLayer=3' + ]); + expect(onLoad).toHaveBeenCalledWith(preview); + + // Unsetting both clears the preview. + log.length = 0; + await render(); + expect(log).toEqual(['clear']); +}); + +test('does not load or clear without src or gcode', async () => { + const fetch = mockFetch(); + await render(); + expect(fetch).not.toHaveBeenCalled(); + expect(instances[0].clear).not.toHaveBeenCalled(); + expect(instances[0].processGCode).not.toHaveBeenCalled(); +}); + +test('replacing src aborts the old fetch and ignores its late response', async () => { + const old = deferred>(); + const oldResponse = response(); + const fetch = mockFetch(old.promise); + const onLoad = vi.fn(); + await render(); + await render(); + const oldSignal: AbortSignal = fetch.mock.calls[0][1].signal; + expect(oldSignal.aborted).toBe(true); + expect(fetch.mock.calls[1][1].signal.aborted).toBe(false); + + await act(async () => old.resolve(oldResponse)); + expect(instances).toHaveLength(1); + expect(oldResponse.body.pipeThrough).not.toHaveBeenCalled(); + expect(instances[0].processGCodeStream).toHaveBeenCalledTimes(1); + expect(instances[0].clear).toHaveBeenCalledTimes(2); + expect(onLoad).toHaveBeenCalledTimes(1); +}); + +test('applies only changed live options and ignores mount-only options', async () => { + const onReady = vi.fn(); + const props = { + buildVolume: { x: 200, y: 200, z: 180, smallGrid: false }, + extrusionColor: ['red', 'blue'], + topLayerColor: 'lime', + renderTravel: true, + initialCameraPosition: [0, 1, 2], + droppable: false + }; + await render(); + const preview = instances[0]; + expect(log).toEqual([]); + + // Equal inline objects/arrays and a new callback identity change nothing. + await render( + {}} + /> + ); + expect(log).toEqual([]); + + props.topLayerColor = 'purple'; + await render(); + expect(log).toEqual(['topLayerColor="purple"']); + + log.length = 0; + await render(); + expect(log).toEqual(['preview.devMode=true']); + + log.length = 0; + await render( + + ); + expect(log).toEqual([]); + + // Setters that accept undefined get it; the others keep their value. + log.length = 0; + await render( + + ); + expect(log).toEqual(['topLayerColor=undefined']); + + expect(instances).toHaveLength(1); + expect(preview.dispose).not.toHaveBeenCalled(); + expect(onReady).toHaveBeenCalledTimes(1); +}); + +test('reports a failed fetch to onError', async () => { + mockFetch(Promise.resolve({ ok: false, status: 404 })); + const onError = vi.fn(); + const onLoad = vi.fn(); + await render( + + ); + expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); + expect(onError.mock.calls[0][0].message).toBe('HTTP 404: /missing.gcode'); + expect(onLoad).not.toHaveBeenCalled(); +}); + +test('logs a failed load without an error handler', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(Promise.resolve({ ok: false, status: 404 })); + await render(); + expect(consoleError).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); + expect(consoleError.mock.calls[0][0].message).toBe( + 'HTTP 404: /missing.gcode' + ); +}); + +test('resizes on ResizeObserver and cleans up on unmount', async () => { + const fetch = mockFetch(new Promise(() => {})); + const onLoad = vi.fn(); + await render(); + const preview = instances[0]; + const canvas = container.querySelector('canvas'); + expect(observers).toHaveLength(1); + expect(observers[0].observe).toHaveBeenCalledWith(canvas); + observers[0].callback(); + expect(preview.sceneManager.resize).toHaveBeenCalledTimes(1); + + await render(null); + expect(fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + expect(preview.dispose).toHaveBeenCalledTimes(1); + expect(onLoad).not.toHaveBeenCalled(); +}); + +test('ignores a load that finishes after unmount', async () => { + const done = deferred(); + mockFetch(); + const onLoad = vi.fn(); + await render(); + instances[0].processGCode.mockReturnValueOnce(done.promise); + await render(); + onLoad.mockClear(); + log.length = 0; + await render(null); + await act(async () => done.resolve()); + expect(onLoad).not.toHaveBeenCalled(); + expect(log).toEqual([]); +}); + +test('exposes the preview instance through ref', async () => { + const ref = createRef(); + await render(); + expect(ref.current).toBe(instances[0]); + await render(null); + expect(ref.current).toBeNull(); +}); + +test('points the ref at the live preview under StrictMode', async () => { + mockFetch(); + const ref = createRef(); + const callbackRef = vi.fn(); + await render( + + + + + ); + const live = instances.filter(i => !i.dispose.mock.calls.length); + expect(live).toHaveLength(2); + expect(ref.current).toBe(live[0]); + expect(callbackRef).toHaveBeenLastCalledWith(live[1]); + for (const instance of instances) { + const loads = instance.processGCodeStream.mock.calls.length; + expect(loads).toBe(instance === live[0] ? 1 : 0); + } +}); diff --git a/src/lib/GCodePreview.tsx b/src/lib/GCodePreview.tsx new file mode 100644 index 0000000..c6d8f6b --- /dev/null +++ b/src/lib/GCodePreview.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { + GCodePreview as CorePreview, + type GCodePreviewOptions +} from 'gcode-preview'; +import { + forwardRef, + useEffect, + useRef, + type ComponentPropsWithoutRef, + type ForwardedRef +} from 'react'; + +type Options = Omit; + +// Options with a live setter: a changed prop updates the preview in place. +const LIVE_OPTIONS = [ + 'buildVolume', + 'backgroundColor', + 'extrusionColor', + 'travelColor', + 'topLayerColor', + 'lastSegmentColor', + 'boundingBoxColor', + 'startLayer', + 'endLayer', + 'renderExtrusion', + 'renderTravel', + 'renderTubes', + 'lineWidth', + 'lineHeight', + 'extrusionWidth', + 'disableGradient', + 'orthographic', + 'devMode' +] as const satisfies readonly (keyof Options)[]; + +// Options the core only reads in its constructor. +const MOUNT_OPTIONS = [ + 'initialCameraPosition', + 'minLayerThreshold', + 'droppable', + 'keepLines', + 'liveRenderInterval', + 'arcChordTolerance' +] as const satisfies readonly (keyof Options)[]; + +type Unlisted = Exclude< + keyof Options, + (typeof LIVE_OPTIONS)[number] | (typeof MOUNT_OPTIONS)[number] +>; +// Fails the typecheck when the core gains an option that is in neither list. +const allListed: [Unlisted] extends [never] ? true : Unlisted = true; +void allListed; + +type LiveOption = (typeof LIVE_OPTIONS)[number]; + +const OPTION_KEYS: ReadonlySet = new Set([ + ...LIVE_OPTIONS, + ...MOUNT_OPTIONS +]); + +// Everything else is passed through to the canvas. +const OWN_PROPS: ReadonlySet = new Set([ + ...OPTION_KEYS, + 'src', + 'gcode', + 'onReady', + 'onLoad', + 'onError' +]); + +// The other setters do not accept undefined, so an unset prop keeps the last value. +const ACCEPTS_UNDEFINED: ReadonlySet = new Set([ + 'startLayer', + 'endLayer', + 'topLayerColor', + 'lastSegmentColor', + 'boundingBoxColor', + 'buildVolume', + 'lineHeight', + 'extrusionWidth', + 'devMode' +]); + +type CanvasProps = Omit< + ComponentPropsWithoutRef<'canvas'>, + keyof Options | 'children' | 'onLoad' | 'onError' +>; + +export interface GCodePreviewProps extends Options, CanvasProps { + /** URL of a G-code file to fetch and stream in. Ignored when `gcode` is set. */ + src?: string | URL; + /** G-code to process directly. Wins over `src`. */ + gcode?: string | string[]; + /** Called once the preview has been created. */ + onReady?: (preview: CorePreview) => void; + /** Called after `src` or `gcode` has been loaded and rendered. */ + onLoad?: (preview: CorePreview) => void; + /** Called when loading fails. Without it, the error is logged. */ + onError?: (error: Error) => void; +} + +// Inline objects and arrays (buildVolume, extrusionColor, ...) are new on +// every render; compare them one level deep so their setters, some of which +// rebuild geometry, only run on a real change. +function sameValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => Object.is(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every(key => Object.is(a[key], b[key])) + ); + } + return false; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function applyOption(preview: CorePreview, key: LiveOption, value: unknown) { + if (key === 'devMode') { + preview.devMode = value as Options['devMode']; + return; + } + // Each key has its own setter type; the value comes from the matching prop. + (preview.sceneManager as unknown as Record)[key] = value; +} + +function assignRef(ref: ForwardedRef, value: T | null) { + if (typeof ref === 'function') ref(value); + else if (ref) ref.current = value; +} + +export const GCodePreview = forwardRef( + function GCodePreview(props, ref) { + const canvasRef = useRef(null); + const previewRef = useRef(null); + const loadRef = useRef(null); + const appliedRef = useRef>>({}); + // Whether the current preview holds content that an unset source clears. + const loadedRef = useRef(false); + // Callbacks and options are read from here at call time, so a new + // callback identity never recreates the preview. + const latest = useRef(props); + + useEffect(() => { + latest.current = props; + }); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const options: GCodePreviewOptions = { canvas }; + const applied: Partial> = {}; + for (const key of OPTION_KEYS) { + const value = latest.current[key as keyof Options]; + if (value !== undefined) { + (options as Record)[key] = value; + } + } + for (const key of LIVE_OPTIONS) applied[key] = latest.current[key]; + + const preview = new CorePreview(options); + previewRef.current = preview; + appliedRef.current = applied; + + const observer = new ResizeObserver(() => preview.sceneManager.resize()); + observer.observe(canvas); + + latest.current.onReady?.(preview); + + return () => { + loadRef.current?.abort(); + loadRef.current = null; + loadedRef.current = false; + observer.disconnect(); + previewRef.current = null; + preview.dispose(); + }; + }, []); + + // Separate from the effect above so a new ref does not recreate the + // preview; declared after it so the instance exists (also after a + // StrictMode remount). + useEffect(() => { + assignRef(ref, previewRef.current); + return () => assignRef(ref, null); + }, [ref]); + + useEffect(() => { + const preview = previewRef.current; + if (!preview) return; + for (const key of LIVE_OPTIONS) { + const value = props[key]; + if (sameValue(value, appliedRef.current[key])) continue; + appliedRef.current[key] = value; + if (value === undefined && !ACCEPTS_UNDEFINED.has(key)) continue; + applyOption(preview, key, value); + } + }); + + // A URL object is new on every render; compare its string form instead. + const src = props.src === undefined ? undefined : String(props.src); + const { gcode } = props; + const hasContent = gcode !== undefined || src !== undefined; + + useEffect(() => { + const preview = previewRef.current; + if (!preview) return; + if (!hasContent) { + if (loadedRef.current) preview.clear(); + loadedRef.current = false; + return; + } + + // The controller doubles as the load token: it is aborted when this + // load is superseded or the component unmounts. + loadRef.current?.abort(); + const controller = new AbortController(); + loadRef.current = controller; + // Also cancels a stream that is still being read. + preview.clear(); + loadedRef.current = true; + + async function load(preview: CorePreview) { + try { + if (gcode !== undefined) { + await preview.processGCode(gcode); + } else if (src !== undefined) { + const response = await fetch(src, { signal: controller.signal }); + if (controller.signal.aborted) return; + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${src}`); + } + if (!response.body) throw new Error(`No response body: ${src}`); + await preview.processGCodeStream( + response.body.pipeThrough(new TextDecoderStream()) + ); + } + if (controller.signal.aborted) return; + // The layer setters clamp against the loaded job, so apply the + // range again now that the job is complete. + const { startLayer, endLayer, onLoad } = latest.current; + preview.sceneManager.startLayer = startLayer; + preview.sceneManager.endLayer = endLayer; + appliedRef.current.startLayer = startLayer; + appliedRef.current.endLayer = endLayer; + onLoad?.(preview); + } catch (cause) { + if (controller.signal.aborted) return; + const error = + cause instanceof Error ? cause : new Error(String(cause)); + const { onError } = latest.current; + if (onError) onError(error); + else console.error(error); + } + } + void load(preview); + + return () => controller.abort(); + }, [hasContent, src, gcode]); + + const canvasProps: Record = {}; + for (const key in props) { + if (!OWN_PROPS.has(key)) { + canvasProps[key] = props[key as keyof GCodePreviewProps]; + } + } + + return ( + + ); + } +); diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..0cb11e4 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,2 @@ +export { GCodePreview } from './GCodePreview.js'; +export type { GCodePreviewProps } from './GCodePreview.js'; diff --git a/src/styles.css b/src/styles.css index 4df3e8f..58f823e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,13 +7,22 @@ body { margin-top: 60px; } -.gcode-preview { - width: 50vw; - margin: 0 auto 30px; +.samples { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 16px; +} + +button[aria-pressed='true'] { + font-weight: bold; } -canvas { +/* The component renders a bare canvas; its size comes from here. */ +.gcode-preview { + display: block; outline: none; - width: 100%; + width: 50vw; height: 320px; + margin: 0 auto; } diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..185db5c --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/lib/**/*"], + "exclude": ["./src/lib/**/*.test.*"], + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src/lib", + "allowJs": false, + "types": [] + } +} From 2fd55d472ad679e8b01a5e15e022af4ccafca4e9 Mon Sep 17 00:00:00 2001 From: Remco Veldkamp Date: Sun, 4 Oct 2026 22:19:20 +0200 Subject: [PATCH 2/3] Strip Create React App leftovers from the demo Drops the React logos, web manifest, robots.txt and the matching head tags, trims CRA entries from .gitignore, gives the page a real title, and makes the preview droppable with a hint, like the Svelte and Vue demos. --- .gitignore | 7 ------- index.html | 12 ++---------- public/favicon.ico | Bin 3870 -> 0 bytes public/logo192.png | Bin 5347 -> 0 bytes public/logo512.png | Bin 9664 -> 0 bytes public/manifest.json | 25 ------------------------- public/robots.txt | 3 --- src/App.tsx | 7 ++++++- 8 files changed, 8 insertions(+), 46 deletions(-) delete mode 100644 public/favicon.ico delete mode 100644 public/logo192.png delete mode 100644 public/logo512.png delete mode 100644 public/manifest.json delete mode 100644 public/robots.txt diff --git a/.gitignore b/.gitignore index 73934c6..12084f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,5 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules -/.pnp -.pnp.js - -# testing -/coverage # production /build diff --git a/index.html b/index.html index 1f32332..ac2f040 100644 --- a/index.html +++ b/index.html @@ -2,19 +2,11 @@ - - - - - - gcode-preview-react-typescript-demo + + GCode Preview React demo -
diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/public/logo192.png b/public/logo192.png deleted file mode 100644 index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 6c35195..0000000 --- a/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "GCode Preview", - "name": "GCode Preview React Demo", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/src/App.tsx b/src/App.tsx index 68eeaf4..21539d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,11 @@ function App(): JSX.Element { return (
-

GCode Preview 3.0 React & TypeScript Demo

+

GCode Preview React demo

+

+ Drop a .gcode file onto the canvas to preview your own + print. +

{samples.map(s => (