From 288ce744a530e8f6e8d305c5ef2f88fc6c52b11f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:38:22 +0000 Subject: [PATCH 01/27] Split into a workspace with a React package and a web component The playground was a single Vite app whose store was a module-level singleton wired directly to location.hash. That made it impossible to embed: it would take over the host page's URL, and two playgrounds on one page would share one diagram. Restructure into a pnpm workspace, mirroring the layout of openpatch/learningmap: - packages/java-memory-playground - the React library, exporting a MemoryPlayground component driven by a `memory` prop and an `onChange` callback - packages/web-component - registers via @r2wc/react-to-web-component, shipped as a self-contained UMD bundle - platforms/web - the standalone app at jmp.openpatch.org Each playground now creates its own store through a React context, so a page can host several of them independently. URL persistence became opt-in via setPersistence, which the standalone app enables during bootstrap. Fixes found along the way: - new global variables were always typed "List" regardless of the class - CustomNodeType included React Flow's BuiltInNode, which broke type checking against current @xyflow/react and left the build red - PNG export and the change event resolved their element via a document-wide query, so they hit the wrong playground when a page had more than one - handleDeclareLocalVariable changed identity whenever its dialog toggled, rebuilding nodeTypes and remounting every node - a malformed URL hash threw instead of falling back to the default diagram - component CSS styled bare button/input selectors, leaking into host pages Also adds CI, changesets, tests for the new code, and package READMEs, and drops dead files (config.json template, the eslint config whose plugins were never installed, the npm lockfile alongside the pnpm one). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/config.json | 18 + .changeset/tall-jars-invent.md | 12 + .eslintrc.cjs | 18 - .github/workflows/pull-request.yml | 28 + README.md | 76 +- config.json | 12 - package-lock.json | 2931 ----------------- package.json | 44 +- packages/java-memory-playground/README.md | 81 + packages/java-memory-playground/package.json | 68 + .../src}/ArrayCreationDialog.tsx | 0 .../src}/ConfigView.tsx | 3 +- .../src/MemoryPlayground.tsx | 126 + .../src}/MemoryView.tsx | 28 +- .../src}/MethodCallNode.tsx | 0 .../src}/ObjectNode.tsx | 3 +- .../src}/ReferenceEdge.tsx | 0 .../java-memory-playground/src}/Sidebar.tsx | 0 .../src}/SimpleInputDialog.tsx | 0 .../src}/VariableNode.tsx | 0 .../src}/getEdgesAndNodes.ts | 0 .../java-memory-playground/src/helper.test.ts | 57 + packages/java-memory-playground/src/helper.ts | 44 + .../java-memory-playground/src/index.css | 21 +- packages/java-memory-playground/src/index.ts | 29 + .../java-memory-playground/src}/memory.ts | 0 .../java-memory-playground/src}/serde.test.ts | 0 .../java-memory-playground/src}/serde.ts | 0 .../java-memory-playground/src/store.test.ts | 97 + packages/java-memory-playground/src/store.ts | 111 + .../src/storeContext.tsx | 54 + packages/java-memory-playground/src/types.ts | 11 + .../java-memory-playground/src}/useDnD.tsx | 0 .../java-memory-playground/src}/utils.ts | 0 .../java-memory-playground/src}/vite-env.d.ts | 0 .../tsconfig.build.json | 11 + packages/java-memory-playground/tsconfig.json | 9 + .../java-memory-playground/vite.config.ts | 29 + packages/web-component/README.md | 103 + packages/web-component/index.html | 84 + packages/web-component/multi.html | 57 + packages/web-component/package.json | 50 + packages/web-component/src/index.ts | 26 + packages/web-component/tsconfig.json | 9 + packages/web-component/vite.config.ts | 28 + index.html => platforms/web/index.html | 0 platforms/web/package.json | 25 + {public => platforms/web/public}/logo.svg | 0 platforms/web/src/App.tsx | 11 + {src => platforms/web/src}/index.css | 7 +- platforms/web/src/main.tsx | 18 + platforms/web/tsconfig.json | 7 + platforms/web/vite.config.ts | 19 + pnpm-lock.yaml | 1997 ++++++++--- pnpm-workspace.yaml | 7 + src/App.tsx | 34 - src/main.tsx | 12 - src/store.ts | 61 - src/types.ts | 8 - tsconfig.json => tsconfig.base.json | 4 +- tsconfig.node.json | 10 - vite.config.ts | 11 - 62 files changed, 2854 insertions(+), 3655 deletions(-) create mode 100644 .changeset/config.json create mode 100644 .changeset/tall-jars-invent.md delete mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/pull-request.yml delete mode 100644 config.json delete mode 100644 package-lock.json create mode 100644 packages/java-memory-playground/README.md create mode 100644 packages/java-memory-playground/package.json rename {src => packages/java-memory-playground/src}/ArrayCreationDialog.tsx (100%) rename {src => packages/java-memory-playground/src}/ConfigView.tsx (99%) create mode 100644 packages/java-memory-playground/src/MemoryPlayground.tsx rename {src => packages/java-memory-playground/src}/MemoryView.tsx (96%) rename {src => packages/java-memory-playground/src}/MethodCallNode.tsx (100%) rename {src => packages/java-memory-playground/src}/ObjectNode.tsx (98%) rename {src => packages/java-memory-playground/src}/ReferenceEdge.tsx (100%) rename {src => packages/java-memory-playground/src}/Sidebar.tsx (100%) rename {src => packages/java-memory-playground/src}/SimpleInputDialog.tsx (100%) rename {src => packages/java-memory-playground/src}/VariableNode.tsx (100%) rename {src => packages/java-memory-playground/src}/getEdgesAndNodes.ts (100%) create mode 100644 packages/java-memory-playground/src/helper.test.ts create mode 100644 packages/java-memory-playground/src/helper.ts rename src/App.css => packages/java-memory-playground/src/index.css (91%) create mode 100644 packages/java-memory-playground/src/index.ts rename {src => packages/java-memory-playground/src}/memory.ts (100%) rename {src => packages/java-memory-playground/src}/serde.test.ts (100%) rename {src => packages/java-memory-playground/src}/serde.ts (100%) create mode 100644 packages/java-memory-playground/src/store.test.ts create mode 100644 packages/java-memory-playground/src/store.ts create mode 100644 packages/java-memory-playground/src/storeContext.tsx create mode 100644 packages/java-memory-playground/src/types.ts rename {src => packages/java-memory-playground/src}/useDnD.tsx (100%) rename {src => packages/java-memory-playground/src}/utils.ts (100%) rename {src => packages/java-memory-playground/src}/vite-env.d.ts (100%) create mode 100644 packages/java-memory-playground/tsconfig.build.json create mode 100644 packages/java-memory-playground/tsconfig.json create mode 100644 packages/java-memory-playground/vite.config.ts create mode 100644 packages/web-component/README.md create mode 100644 packages/web-component/index.html create mode 100644 packages/web-component/multi.html create mode 100644 packages/web-component/package.json create mode 100644 packages/web-component/src/index.ts create mode 100644 packages/web-component/tsconfig.json create mode 100644 packages/web-component/vite.config.ts rename index.html => platforms/web/index.html (100%) create mode 100644 platforms/web/package.json rename {public => platforms/web/public}/logo.svg (100%) create mode 100644 platforms/web/src/App.tsx rename {src => platforms/web/src}/index.css (88%) create mode 100644 platforms/web/src/main.tsx create mode 100644 platforms/web/tsconfig.json create mode 100644 platforms/web/vite.config.ts create mode 100644 pnpm-workspace.yaml delete mode 100644 src/App.tsx delete mode 100644 src/main.tsx delete mode 100644 src/store.ts delete mode 100644 src/types.ts rename tsconfig.json => tsconfig.base.json (86%) delete mode 100644 tsconfig.node.json delete mode 100644 vite.config.ts diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..352872e --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", + "access": "public", + "baseBranch": "main", + "changelog": [ + "@changesets/changelog-github", + { + "repo": "openpatch/java-memory-playground" + } + ], + "privatePackages": { + "version": true, + "tag": true + }, + "commit": false, + "ignore": [], + "updateInternalDependencies": "patch" +} diff --git a/.changeset/tall-jars-invent.md b/.changeset/tall-jars-invent.md new file mode 100644 index 0000000..b385148 --- /dev/null +++ b/.changeset/tall-jars-invent.md @@ -0,0 +1,12 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Split the playground into a reusable React package, a web component and the standalone web app. + +- `@openpatch/java-memory-playground` exports a `MemoryPlayground` component that takes the diagram through a `memory` prop and reports saves through `onChange`. +- `@openpatch/java-memory-playground-web-component` registers `` for use in any page. +- Each playground now owns its store, so several playgrounds can share a page without overwriting each other. +- URL persistence is opt-in via `setPersistence`, so an embedded playground no longer takes over the host page's URL. diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index d6c9537..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..4cc8b57 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,28 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Lint packages + run: pnpm lint + - name: Test packages + run: pnpm test + - name: Build packages + run: pnpm build diff --git a/README.md b/README.md index b8bbbba..228017f 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,35 @@ For educators, the Java Memory Playground is perfect for teaching memory concept No setup or installation required for students - they just open the link and start learning! +## Embedding + +Besides the hosted app, the playground ships as two packages: + +- **React component** — integrate it into your own React app + (see [packages/java-memory-playground](packages/java-memory-playground)) +- **Web component** — use it in any page, no framework required + (see [packages/web-component](packages/web-component)) + +```html + + + + +``` + +An embedded playground never touches the URL of its host page, and several of +them can share a page. + ## For Developers ### Prerequisites -- Node.js (v14 or higher) -- npm +- Node.js (v22 or higher) +- pnpm (v8 or higher) ### Installation @@ -51,47 +74,51 @@ Clone the repository and install dependencies: ```sh git clone https://github.com/openpatch/java-memory-playground.git cd java-memory-playground -npm install +pnpm install ``` ### Development -Start the development server with hot reload: +Start the standalone app with hot reload: ```sh -npm run dev +pnpm build # the app consumes the built package +pnpm --filter web dev ``` The application will be available at `http://localhost:5173` (or another port if 5173 is in use). -### Building - -Build the project for production: +### Building and testing ```sh -npm run build +pnpm build # build every package and the app +pnpm test # run the test suites +pnpm lint # type-check every package ``` -The build output will be in the `dist` directory. +### Project Structure -### Testing +This is a pnpm workspace. -Run the test suite: +- `packages/java-memory-playground/` - the React component library + - `MemoryPlayground.tsx` - the embeddable entry point (props, change events) + - `MemoryView.tsx` - main canvas for creating memory diagrams + - `ConfigView.tsx` - configuration view for defining classes and options + - `store.ts` - per-instance state, with opt-in URL persistence + - `storeContext.tsx` - scopes a store to one playground instance + - `serde.ts` - serialization/deserialization for URL encoding + - `memory.ts` - type definitions for memory objects +- `packages/web-component/` - `` custom element (UMD bundle) +- `platforms/web/` - the standalone app served at jmp.openpatch.org -```sh -npm test -``` +### Releasing -### Project Structure +Versioning and changelogs are handled by [changesets](https://github.com/changesets/changesets). +Add one describing your change: -- `src/` - Source code - - `MemoryView.tsx` - Main canvas for creating memory diagrams - - `ConfigView.tsx` - Configuration view for defining classes and options - - `store.ts` - State management with URL persistence - - `serde.ts` - Serialization/deserialization for URL encoding - - `memory.ts` - Type definitions for memory objects -- `public/` - Static assets -- `dist/` - Build output (generated) +```sh +pnpm changeset +``` ### Technologies @@ -101,6 +128,7 @@ npm test - **@xyflow/react** - Flow diagram rendering - **Zustand** - State management - **Pako** - Compression for URL encoding +- **@r2wc/react-to-web-component** - React to custom element bridge ## License diff --git a/config.json b/config.json deleted file mode 100644 index 53a9b0c..0000000 --- a/config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "pro-example-id", - "name": "Pro Example Name", - "description": "...", - "tags": ["workflow"], - "files": ["App.tsx"], - "dependencies": {}, - "published": false, - "icon": "diagram-tree", - "variants": [], - "publicPath": "example-category/pro-example-id/" -} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f2825d7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2931 +0,0 @@ -{ - "name": "java-memory-playground", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "java-memory-playground", - "version": "0.0.0", - "license": "MIT", - "dependencies": { - "@xyflow/react": "^12.3.6", - "html-to-image": "^1.11.11", - "js-base64": "^3.7.7", - "pako": "^2.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-modal": "^3.16.1", - "zustand": "^4.5.4" - }, - "devDependencies": { - "@types/node": "^22.5.0", - "@types/pako": "^2.0.3", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/react-modal": "^3.16.3", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.5.4", - "typescript-json-schema": "^0.65.1", - "vite": "^5.4.0", - "vitest": "^2.0.5" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz", - "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz", - "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz", - "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz", - "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz", - "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz", - "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz", - "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz", - "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz", - "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz", - "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz", - "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz", - "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz", - "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz", - "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", - "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", - "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz", - "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz", - "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz", - "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/@types/pako": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.3.tgz", - "integrity": "sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-modal": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.16.3.tgz", - "integrity": "sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", - "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", - "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.8", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", - "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", - "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.8", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", - "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.8", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", - "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", - "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.8", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xyflow/react": { - "version": "12.3.6", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.6.tgz", - "integrity": "sha512-9GS+cz8hDZahpvTrVCmySAEgKUL8oN4b2q1DluHrKtkqhAMWfH2s7kblhbM4Y4Y4SUnH2lt4drXKZ/4/Lot/2Q==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.47", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.47", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.47.tgz", - "integrity": "sha512-aUXJPIvsCFxGX70ccRG8LPsR+A8ExYXfh/noYNpqn8udKerrLdSHxMG2VsvUrQ1PGex10fOpbJwFU4A+I/Xv8w==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001692", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz", - "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.80", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.80.tgz", - "integrity": "sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/exenv": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", - "license": "BSD-3-Clause" - }, - "node_modules/expect-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", - "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/html-to-image": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", - "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==", - "license": "MIT" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", - "license": "BSD-3-Clause" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/path-equal": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/path-equal/-/path-equal-1.2.5.tgz", - "integrity": "sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-modal": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", - "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", - "license": "MIT", - "dependencies": { - "exenv": "^1.2.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.0", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", - "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" - } - }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", - "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.1", - "@rollup/rollup-android-arm64": "4.30.1", - "@rollup/rollup-darwin-arm64": "4.30.1", - "@rollup/rollup-darwin-x64": "4.30.1", - "@rollup/rollup-freebsd-arm64": "4.30.1", - "@rollup/rollup-freebsd-x64": "4.30.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", - "@rollup/rollup-linux-arm-musleabihf": "4.30.1", - "@rollup/rollup-linux-arm64-gnu": "4.30.1", - "@rollup/rollup-linux-arm64-musl": "4.30.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", - "@rollup/rollup-linux-riscv64-gnu": "4.30.1", - "@rollup/rollup-linux-s390x-gnu": "4.30.1", - "@rollup/rollup-linux-x64-gnu": "4.30.1", - "@rollup/rollup-linux-x64-musl": "4.30.1", - "@rollup/rollup-win32-arm64-msvc": "4.30.1", - "@rollup/rollup-win32-ia32-msvc": "4.30.1", - "@rollup/rollup-win32-x64-msvc": "4.30.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", - "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-json-schema": { - "version": "0.65.1", - "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.65.1.tgz", - "integrity": "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@types/json-schema": "^7.0.9", - "@types/node": "^18.11.9", - "glob": "^7.1.7", - "path-equal": "^1.2.5", - "safe-stable-stringify": "^2.2.0", - "ts-node": "^10.9.1", - "typescript": "~5.5.0", - "yargs": "^17.1.1" - }, - "bin": { - "typescript-json-schema": "bin/typescript-json-schema" - } - }, - "node_modules/typescript-json-schema/node_modules/@types/node": { - "version": "18.19.70", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.70.tgz", - "integrity": "sha512-RE+K0+KZoEpDUbGGctnGdkrLFwi1eYKTlIHNl2Um98mUkGsm1u2Ff6Ltd0e8DktTtC98uy7rSj+hO8t/QuLoVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/typescript-json-schema/node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-json-schema/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", - "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", - "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.8", - "@vitest/mocker": "2.1.8", - "@vitest/pretty-format": "^2.1.8", - "@vitest/runner": "2.1.8", - "@vitest/snapshot": "2.1.8", - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.8", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.8", - "@vitest/ui": "2.1.8", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/zustand": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", - "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json index 8b48ac8..6a29c4a 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,25 @@ { - "name": "java-memory-playground", - "version": "0.0.0", + "name": "java-memory-playground-root", + "author": "Mike Barkmin", + "license": "MIT", + "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "schema": "typescript-json-schema ./src/memory.ts Memory Obj Variable MethodCall Klass DataType --out ./schemas/memory.schema.json --required", - "test": "vitest" - }, - "dependencies": { - "@xyflow/react": "^12.3.6", - "html-to-image": "^1.11.11", - "js-base64": "^3.7.7", - "pako": "^2.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-modal": "^3.16.1", - "zustand": "^4.5.4" + "preinstall": "npx only-allow pnpm", + "dev": "pnpm --parallel -r dev", + "build": "pnpm -r build", + "test": "CI=true pnpm -r test", + "lint": "pnpm -r lint", + "version-packages": "changeset version", + "release": "changeset publish" }, - "license": "MIT", "devDependencies": { + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "2.29.7", "@types/node": "^22.5.0", - "@types/pako": "^2.0.3", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/react-modal": "^3.16.3", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.5.4", - "typescript-json-schema": "^0.65.1", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "typescript": "^5.5.4" + }, + "engines": { + "pnpm": ">=8" } } diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md new file mode 100644 index 0000000..aac3177 --- /dev/null +++ b/packages/java-memory-playground/README.md @@ -0,0 +1,81 @@ +# @openpatch/java-memory-playground + +React components behind the [Java Memory Playground](https://jmp.openpatch.org) — +interactive diagrams of the Java stack and heap. + +Looking to embed the playground in a page that is not a React app? Use +[`@openpatch/java-memory-playground-web-component`](../web-component) instead. + +## Installation + +```sh +npm install @openpatch/java-memory-playground +``` + +`react` and `react-dom` are peer dependencies. + +## Usage + +```tsx +import { MemoryPlayground } from "@openpatch/java-memory-playground"; +import "@openpatch/java-memory-playground/index.css"; + +export function Example() { + return ( +
+ console.log(memory)} + /> +
+ ); +} +``` + +## Props + +| Prop | Type | Description | +| ------------- | --------------------------- | -------------------------------------------------------------------------------------------- | +| `memory` | `Memory \| string` | The diagram, as an object or a JSON string. Omit it to keep whatever the store already holds. | +| `options` | `Partial`| Overrides applied on top of `memory.options`. | +| `persistence` | `boolean` | Mirror the diagram into `location.hash`. Defaults to the value set through `setPersistence`. | +| `onChange` | `(memory: Memory) => void` | Called when the user presses **Save**. | + +Every `MemoryPlayground` creates its own store, so several playgrounds can live +on the same page without sharing state. + +## URL persistence + +The standalone app keeps the whole diagram in `location.hash`, which is what +makes a diagram shareable as a link. That behaviour is off by default, because +an embedded playground must not take over the URL of the page hosting it. Turn +it on once during bootstrap: + +```tsx +import { setPersistence } from "@openpatch/java-memory-playground"; + +setPersistence(true); +``` + +Or per instance with the `persistence` prop. + +## Development + +```sh +pnpm test # vitest +pnpm lint # tsc --noEmit +pnpm build # dist/index.js, dist/index.css and type declarations +``` diff --git a/packages/java-memory-playground/package.json b/packages/java-memory-playground/package.json new file mode 100644 index 0000000..1c5dd0c --- /dev/null +++ b/packages/java-memory-playground/package.json @@ -0,0 +1,68 @@ +{ + "name": "@openpatch/java-memory-playground", + "version": "0.1.0", + "author": "Mike Barkmin", + "description": "React components for visualizing the Java stack and heap.", + "homepage": "https://github.com/openpatch/java-memory-playground#readme", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./index.css": "./dist/index.css" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openpatch/java-memory-playground.git", + "directory": "packages/java-memory-playground" + }, + "bugs": { + "url": "https://github.com/openpatch/java-memory-playground/issues" + }, + "scripts": { + "version": "pnpm build", + "lint": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "build": "rimraf dist && vite build && pnpm build:types", + "build:types": "tsc --project tsconfig.build.json --declaration --emitDeclarationOnly", + "dev": "vite build --watch", + "schema": "typescript-json-schema ./src/memory.ts Memory Obj Variable MethodCall Klass DataType --out ./schemas/memory.schema.json --required" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "dependencies": { + "@xyflow/react": "^12.3.6", + "html-to-image": "^1.11.11", + "js-base64": "^3.7.7", + "pako": "^2.1.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/pako": "^2.0.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rimraf": "^6.0.1", + "typescript": "^5.5.4", + "typescript-json-schema": "^0.65.1", + "vite": "^5.4.0", + "vitest": "^2.0.5" + } +} diff --git a/src/ArrayCreationDialog.tsx b/packages/java-memory-playground/src/ArrayCreationDialog.tsx similarity index 100% rename from src/ArrayCreationDialog.tsx rename to packages/java-memory-playground/src/ArrayCreationDialog.tsx diff --git a/src/ConfigView.tsx b/packages/java-memory-playground/src/ConfigView.tsx similarity index 99% rename from src/ConfigView.tsx rename to packages/java-memory-playground/src/ConfigView.tsx index 075babc..ec84f87 100644 --- a/src/ConfigView.tsx +++ b/packages/java-memory-playground/src/ConfigView.tsx @@ -1,5 +1,6 @@ import { shallow } from "zustand/shallow"; -import useStore, { RFState } from "./store"; +import useStore from "./storeContext"; +import { RFState } from "./store"; import { useCallback, useState, useEffect } from "react"; import { DataType, primitveDataTypes } from "./memory"; import { SimpleInputDialog } from "./SimpleInputDialog"; diff --git a/packages/java-memory-playground/src/MemoryPlayground.tsx b/packages/java-memory-playground/src/MemoryPlayground.tsx new file mode 100644 index 0000000..f9b3176 --- /dev/null +++ b/packages/java-memory-playground/src/MemoryPlayground.tsx @@ -0,0 +1,126 @@ +import "@xyflow/react/dist/style.css"; +import "./index.css"; +import { ReactFlowProvider } from "@xyflow/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { shallow } from "zustand/shallow"; + +import { ConfigView } from "./ConfigView"; +import { MemoryView } from "./MemoryView"; +import { parseMemory } from "./helper"; +import { Memory } from "./memory"; +import { RFState } from "./store"; +import useStore, { StoreProvider } from "./storeContext"; +import { DnDProvider } from "./useDnD"; + +export interface MemoryPlaygroundProps { + /** + * The diagram to show, either as a `Memory` object or as a JSON string. + * Omit it to keep whatever the store already holds — the standalone app + * restores that from the URL. + */ + memory?: string | Memory; + /** + * Overrides for `memory.options`, applied on top of the options that come + * with `memory`. Handy for hiding the sidebar or the garbage collector + * without rewriting the whole diagram. + */ + options?: Partial; + /** + * Mirror the diagram into `location.hash`. Defaults to the value set through + * `setPersistence`, which is off unless a host opts in. + */ + persistence?: boolean; + /** + * Called with the full memory whenever the user saves. The web component + * wrapper uses this to dispatch its `change` event. + */ + onChange?: (memory: Memory) => void; +} + +const selector = (state: RFState) => ({ + route: state.route, + memory: state.memory, + updateMemory: state.updateMemory, +}); + +function Playground({ memory, options, onChange }: MemoryPlaygroundProps) { + const { + route, + memory: currentMemory, + updateMemory, + } = useStore(selector, shallow); + // MemoryView copies the memory into local React Flow state on mount, so a new + // diagram needs a fresh instance rather than a prop update. + const [loadCount, setLoadCount] = useState(0); + const loadedFromProps = useRef(null); + const hasMounted = useRef(false); + + // Serialized so that a host passing an inline object literal does not reload + // the diagram — and throw away the user's edits — on every render. + const memoryKey = useMemo( + () => + typeof memory === "string" ? memory : JSON.stringify(memory ?? null), + [memory], + ); + const optionsKey = useMemo(() => JSON.stringify(options ?? null), [options]); + + useEffect(() => { + const parsed = parseMemory(memory); + if (!parsed && !options) return; + + const base = parsed ?? currentMemory; + const merged: Memory = options + ? { ...base, options: { ...base.options, ...options } } + : base; + + loadedFromProps.current = merged; + updateMemory(merged); + setLoadCount((c) => c + 1); + // currentMemory is deliberately not a dependency: this effect loads the + // diagram from the props, it must not re-run on the user's own edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [memoryKey, optionsKey, updateMemory]); + + useEffect(() => { + // Mounting is not a change. Without this the host would receive an event + // carrying the default diagram before its own `memory` was even applied. + if (!hasMounted.current) { + hasMounted.current = true; + return; + } + // Do not echo a prop back to the host as if the user had changed it. + if (loadedFromProps.current === currentMemory) return; + + onChange?.(currentMemory); + }, [currentMemory, onChange]); + + return ( +
+ + {route === "view" && } + {route === "config" && } + +
+ ); +} + +/** + * A self-contained Java memory playground. + * + * Every instance gets its own store and React Flow provider, so a page can host + * several playgrounds side by side without them sharing state. + */ +export function MemoryPlayground({ + persistence, + ...props +}: MemoryPlaygroundProps) { + return ( + + + + + + ); +} + +export default MemoryPlayground; diff --git a/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx similarity index 96% rename from src/MemoryView.tsx rename to packages/java-memory-playground/src/MemoryView.tsx index 7cf0a0c..798ab06 100644 --- a/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -14,12 +14,13 @@ import { useReactFlow, } from "@xyflow/react"; import { toPng } from "html-to-image"; -import useStore, { RFState } from "./store"; +import useStore from "./storeContext"; +import { RFState } from "./store"; import { shallow } from "zustand/shallow"; import { getEdgesAndNodes, getMemory } from "./getEdgesAndNodes"; import ObjectNode, { ObjectNodeType } from "./ObjectNode"; import VariableNode from "./VariableNode"; -import { useCallback, useState, DragEvent, useRef, useMemo } from "react"; +import { useCallback, useState, useRef, useMemo } from "react"; import { Sidebar } from "./Sidebar"; import { Attribute, @@ -45,6 +46,7 @@ const selector = (state: RFState) => ({ updateMemory: state.updateMemory, memory: state.memory, setRoute: state.setRoute, + persistence: state.persistence, }); const edgeTypes = { @@ -100,10 +102,16 @@ const getRanMemoryAdress = (size: number): string => { }; export const MemoryView = () => { - const { memory, updateMemory, setRoute } = useStore(selector, shallow); + const { memory, updateMemory, setRoute, persistence } = useStore( + selector, + shallow + ); const { screenToFlowPosition } = useReactFlow(); const { edges: initialEdges, nodes: initialNodes } = getEdgesAndNodes(memory); const connectingNode = useRef(null); + // Scoped to this instance so that exporting works when a page embeds more + // than one playground. + const flowRef = useRef(null); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -208,7 +216,9 @@ export const MemoryView = () => { const handleDeclareLocalVariable = useCallback((nodeId: string) => { setLocalVarDialogNodeId(nodeId); setShowLocalVarDialog(true); - }, [setShowLocalVarDialog, showLocalVarDialog]); + // No dependencies: a stable identity keeps `nodeTypes` stable, which stops + // React Flow from remounting every node whenever the dialog toggles. + }, []); const handleLocalVarDialogConfirm = (name: string) => { if (localVarDialogNodeId) { @@ -408,7 +418,8 @@ export const MemoryView = () => { }; const onDownloadPng = () => { - toPng(document.querySelector(".memory") as any, { + if (!flowRef.current) return; + toPng(flowRef.current, { filter: (node) => { // we don't want to add the minimap and the controls to the image if ( @@ -628,7 +639,7 @@ export const MemoryView = () => { id: getId(), type: "variable", position, - data: { name, value: null, position, dataType: "List" }, + data: { name, value: null, position, dataType: "Object" }, }; setNodes((nds) => nds.concat(newNode)); }, @@ -647,6 +658,7 @@ export const MemoryView = () => {
{!memory.options.hideSidebar && } { n.className = ""; @@ -714,7 +726,9 @@ export const MemoryView = () => { >
- +
diff --git a/src/MethodCallNode.tsx b/packages/java-memory-playground/src/MethodCallNode.tsx similarity index 100% rename from src/MethodCallNode.tsx rename to packages/java-memory-playground/src/MethodCallNode.tsx diff --git a/src/ObjectNode.tsx b/packages/java-memory-playground/src/ObjectNode.tsx similarity index 98% rename from src/ObjectNode.tsx rename to packages/java-memory-playground/src/ObjectNode.tsx index 65266d6..8f89d99 100644 --- a/src/ObjectNode.tsx +++ b/packages/java-memory-playground/src/ObjectNode.tsx @@ -11,7 +11,8 @@ import { import { Attribute, Obj, numericDataTypes, primitveDataTypes } from "./memory"; import { isConnectedToMethodCall, isConnectedToVariable } from "./utils"; import { CustomEdgeType, CustomNodeType } from "./types"; -import useStore, { RFState } from "./store"; +import useStore from "./storeContext"; +import { RFState } from "./store"; import { shallow } from "zustand/shallow"; function AttributeHandle({ diff --git a/src/ReferenceEdge.tsx b/packages/java-memory-playground/src/ReferenceEdge.tsx similarity index 100% rename from src/ReferenceEdge.tsx rename to packages/java-memory-playground/src/ReferenceEdge.tsx diff --git a/src/Sidebar.tsx b/packages/java-memory-playground/src/Sidebar.tsx similarity index 100% rename from src/Sidebar.tsx rename to packages/java-memory-playground/src/Sidebar.tsx diff --git a/src/SimpleInputDialog.tsx b/packages/java-memory-playground/src/SimpleInputDialog.tsx similarity index 100% rename from src/SimpleInputDialog.tsx rename to packages/java-memory-playground/src/SimpleInputDialog.tsx diff --git a/src/VariableNode.tsx b/packages/java-memory-playground/src/VariableNode.tsx similarity index 100% rename from src/VariableNode.tsx rename to packages/java-memory-playground/src/VariableNode.tsx diff --git a/src/getEdgesAndNodes.ts b/packages/java-memory-playground/src/getEdgesAndNodes.ts similarity index 100% rename from src/getEdgesAndNodes.ts rename to packages/java-memory-playground/src/getEdgesAndNodes.ts diff --git a/packages/java-memory-playground/src/helper.test.ts b/packages/java-memory-playground/src/helper.test.ts new file mode 100644 index 0000000..cf1ee7b --- /dev/null +++ b/packages/java-memory-playground/src/helper.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from "vitest"; +import { parseMemory } from "./helper"; +import { initialMemory } from "./memory"; + +describe("parseMemory", () => { + test("returns null when there is nothing to load", () => { + expect(parseMemory(undefined)).toBeNull(); + expect(parseMemory(null)).toBeNull(); + expect(parseMemory("")).toBeNull(); + }); + + test("parses the JSON string a web component attribute carries", () => { + const parsed = parseMemory( + JSON.stringify({ + klasses: { Node: { attributes: { next: "Node" } } }, + }), + ); + + expect(parsed?.klasses).toEqual({ Node: { attributes: { next: "Node" } } }); + }); + + test("accepts an already parsed object", () => { + const parsed = parseMemory({ ...initialMemory }); + expect(parsed?.objects).toEqual(initialMemory.objects); + }); + + test("fills in every section so partial input stays renderable", () => { + const parsed = parseMemory('{"objects":{}}'); + + expect(parsed).not.toBeNull(); + expect(parsed?.variables).toEqual({}); + expect(parsed?.methodCalls).toEqual({}); + expect(parsed?.klasses).toEqual({}); + expect(parsed?.viewport).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + test("keeps the default options and lets the input override them", () => { + const parsed = parseMemory('{"options":{"hideSidebar":true}}'); + + expect(parsed?.options.hideSidebar).toBe(true); + // Untouched defaults survive. + expect(parsed?.options.hideDeclareGlobalVariable).toBe( + initialMemory.options.hideDeclareGlobalVariable, + ); + }); + + test("returns null instead of throwing on unusable input", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(parseMemory("{ not json")).toBeNull(); + expect(parseMemory("[]")).toBeNull(); + expect(parseMemory("42")).toBeNull(); + + expect(warn).toHaveBeenCalledTimes(3); + warn.mockRestore(); + }); +}); diff --git a/packages/java-memory-playground/src/helper.ts b/packages/java-memory-playground/src/helper.ts new file mode 100644 index 0000000..97b2b29 --- /dev/null +++ b/packages/java-memory-playground/src/helper.ts @@ -0,0 +1,44 @@ +import { Memory, initialMemory } from "./memory"; + +/** + * Normalizes whatever a host hands us into a complete `Memory`. + * + * Accepts a JSON string (what the web component receives through an attribute) + * or an already parsed object, and fills in every top level section so that + * partial input — e.g. only `klasses` and `objects` — is still safe to render. + * + * Returns `null` when the input is absent or not parsable, so callers can fall + * back to whatever is already in the store. + */ +export const parseMemory = ( + memory?: string | Memory | null, +): Memory | null => { + if (memory === undefined || memory === null || memory === "") { + return null; + } + + let parsed: unknown = memory; + if (typeof memory === "string") { + try { + parsed = JSON.parse(memory); + } catch (e) { + console.warn("Could not parse memory", e); + return null; + } + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + console.warn("Memory has to be an object"); + return null; + } + + const m = parsed as Partial; + return { + viewport: m.viewport ?? { x: 0, y: 0, zoom: 1 }, + options: { ...initialMemory.options, ...m.options }, + klasses: m.klasses ?? {}, + objects: m.objects ?? {}, + variables: m.variables ?? {}, + methodCalls: m.methodCalls ?? {}, + }; +}; diff --git a/src/App.css b/packages/java-memory-playground/src/index.css similarity index 91% rename from src/App.css rename to packages/java-memory-playground/src/index.css index be7db79..316da81 100644 --- a/src/App.css +++ b/packages/java-memory-playground/src/index.css @@ -1,5 +1,12 @@ -#root { +/* The playground is embeddable, so element selectors are scoped to the + container instead of leaking `button`/`input` styles into the host page. */ +.java-memory-playground { font-size: 24px; + line-height: 1.2; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + height: 100%; + background: white; + color: #222; } .button-group { @@ -8,20 +15,21 @@ padding: 8px; } -button { +.java-memory-playground button { background: whitesmoke; color: grey; cursor: pointer; border: 0; border-radius: 10px; padding: 8px; + font-family: inherit; } -button:hover { +.java-memory-playground button:hover { background: lightgrey; } -input { +.java-memory-playground input { display: inline-block; border-width: 1px; margin-left: 4px; @@ -30,6 +38,11 @@ input { border-color: grey; width: 80px; padding: 4px; + font-family: inherit; +} + +.memory { + background: white; } .memory-view { diff --git a/packages/java-memory-playground/src/index.ts b/packages/java-memory-playground/src/index.ts new file mode 100644 index 0000000..3f9251d --- /dev/null +++ b/packages/java-memory-playground/src/index.ts @@ -0,0 +1,29 @@ +import MemoryPlayground from "./MemoryPlayground"; + +export { MemoryPlayground }; +export type { MemoryPlaygroundProps } from "./MemoryPlayground"; + +export { MemoryView } from "./MemoryView"; +export { ConfigView } from "./ConfigView"; + +export { + createMemoryStore, + setPersistence, + isPersistenceEnabled, +} from "./store"; +export type { RFState, Route, MemoryStore } from "./store"; +export { useStore, useMemoryStore, StoreProvider } from "./storeContext"; + +export { parseMemory } from "./helper"; +export { getEdgesAndNodes, getMemory } from "./getEdgesAndNodes"; +export { serializeState, deserializeState } from "./serde"; +export { initialMemory, primitveDataTypes, numericDataTypes } from "./memory"; +export type { + Memory, + Obj, + Variable, + MethodCall, + Klass, + Attribute, + DataType, +} from "./memory"; diff --git a/src/memory.ts b/packages/java-memory-playground/src/memory.ts similarity index 100% rename from src/memory.ts rename to packages/java-memory-playground/src/memory.ts diff --git a/src/serde.test.ts b/packages/java-memory-playground/src/serde.test.ts similarity index 100% rename from src/serde.test.ts rename to packages/java-memory-playground/src/serde.test.ts diff --git a/src/serde.ts b/packages/java-memory-playground/src/serde.ts similarity index 100% rename from src/serde.ts rename to packages/java-memory-playground/src/serde.ts diff --git a/packages/java-memory-playground/src/store.test.ts b/packages/java-memory-playground/src/store.test.ts new file mode 100644 index 0000000..83b5b47 --- /dev/null +++ b/packages/java-memory-playground/src/store.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createMemoryStore, isPersistenceEnabled, setPersistence } from "./store"; +import { initialMemory } from "./memory"; + +afterEach(() => { + setPersistence(false); + vi.unstubAllGlobals(); +}); + +const stubLocation = (hash = "") => { + const location = { hash }; + vi.stubGlobal("location", location); + return location; +}; + +describe("createMemoryStore", () => { + test("gives every playground its own memory", () => { + const a = createMemoryStore(false); + const b = createMemoryStore(false); + + a.getState().updateMemory({ ...initialMemory, objects: {} }); + + expect(Object.keys(a.getState().memory.objects)).toHaveLength(0); + expect(Object.keys(b.getState().memory.objects)).toEqual( + Object.keys(initialMemory.objects), + ); + }); + + test("a non-persisting store never touches the URL", () => { + const location = stubLocation("#pako:something"); + + const store = createMemoryStore(false); + store.getState().updateMemory({ ...initialMemory, klasses: {} }); + + expect(location.hash).toBe("#pako:something"); + // The existing hash was not read either — the default memory is intact. + expect(store.getState().persistence).toBe(false); + }); + + test("a persisting store writes the memory into the URL", () => { + const location = stubLocation(); + + const store = createMemoryStore(true); + store.getState().updateMemory({ ...initialMemory, klasses: {} }); + + expect(location.hash.startsWith("pako:")).toBe(true); + }); + + test("survives a corrupt hash instead of throwing", () => { + stubLocation("#pako:this-is-not-valid"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const store = createMemoryStore(true); + + expect(store.getState().memory.objects).toEqual(initialMemory.objects); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + test("round-trips a diagram through the URL", () => { + const location = stubLocation(); + + const writer = createMemoryStore(true); + writer.getState().updateMemory({ + ...initialMemory, + klasses: { Foo: { attributes: { bar: "int" } } }, + }); + + // A second store reading the hash that the first one produced. + location.hash = "#" + location.hash.replace(/^#/, ""); + const reader = createMemoryStore(true); + + expect(reader.getState().memory.klasses).toEqual({ + Foo: { attributes: { bar: "int" } }, + }); + }); +}); + +describe("setPersistence", () => { + test("controls the default new stores are created with", () => { + expect(isPersistenceEnabled()).toBe(false); + expect(createMemoryStore().getState().persistence).toBe(false); + + stubLocation(); + setPersistence(true); + + expect(isPersistenceEnabled()).toBe(true); + expect(createMemoryStore().getState().persistence).toBe(true); + }); + + test("an explicit argument wins over the default", () => { + stubLocation(); + setPersistence(true); + + expect(createMemoryStore(false).getState().persistence).toBe(false); + }); +}); diff --git a/packages/java-memory-playground/src/store.ts b/packages/java-memory-playground/src/store.ts new file mode 100644 index 0000000..9b5d63f --- /dev/null +++ b/packages/java-memory-playground/src/store.ts @@ -0,0 +1,111 @@ +import { persist, StateStorage, createJSONStorage } from "zustand/middleware"; +import { createStore } from "zustand/vanilla"; + +import { deserializeState, serializeState } from "./serde"; +import { Memory, initialMemory } from "./memory"; + +export type Route = "view" | "config"; + +export type RFState = { + route: Route; + selectedNodeId: string; + memory: Memory; + /** Whether this store mirrors its memory into `location.hash`. */ + persistence: boolean; + setRoute: (route: Route) => void; + updateMemory: (memory: Memory) => void; + selectNodeId: (nodeId: string) => void; +}; + +// Persisting to `location.hash` is right for the standalone app but wrong for an +// embedded playground, which must not take over the URL of its host page. Hosts +// opt in once, before the first playground mounts. +let defaultPersistence = false; + +/** + * Set whether playgrounds created from now on mirror their state into + * `location.hash`. Off by default; the standalone web app turns it on during + * bootstrap. Embedded usages (React hosts, the web component) leave it off and + * drive state through the `memory` prop and the `change` event instead. + */ +export function setPersistence(enabled: boolean) { + defaultPersistence = enabled; +} + +/** The persistence setting new playgrounds are created with. */ +export function isPersistenceEnabled() { + return defaultPersistence; +} + +const createHashStorage = (enabled: boolean): StateStorage => ({ + getItem: (_): string | null => { + if (!enabled) return null; + try { + return deserializeState(location.hash.slice(1)); + } catch (e) { + // A truncated or hand-edited hash must not take down the whole app. + console.warn("Could not restore state from URL", e); + return null; + } + }, + setItem: (_, newValue): void => { + if (!enabled) return; + location.hash = serializeState(newValue); + }, + removeItem: (_): void => { + if (!enabled) return; + location.hash = ""; + }, +}); + +/** + * Creates an independent playground store. + * + * One store per `MemoryPlayground` instance, so that a page can host several + * playgrounds without them overwriting each other's diagrams. + */ +export const createMemoryStore = (persistence: boolean = defaultPersistence) => { + const store = createStore()( + persist( + (set) => ({ + route: "view", + setRoute: (route: Route) => { + set({ + route, + }); + }, + selectedNodeId: "", + selectNodeId: (nodeId: string) => { + set({ + selectedNodeId: nodeId, + }); + }, + memory: initialMemory, + persistence, + updateMemory: (memory: Memory) => { + set({ + memory: memory, + }); + }, + }), + { + name: "pako", + storage: createJSONStorage(() => createHashStorage(persistence)), + // Hydration is explicit so that a non-persisting store never touches + // the URL, not even to read it. + skipHydration: true, + partialize: (state): any => ({ + memory: state.memory, + }), + }, + ), + ); + + if (persistence) { + store.persist.rehydrate(); + } + + return store; +}; + +export type MemoryStore = ReturnType; diff --git a/packages/java-memory-playground/src/storeContext.tsx b/packages/java-memory-playground/src/storeContext.tsx new file mode 100644 index 0000000..f789d21 --- /dev/null +++ b/packages/java-memory-playground/src/storeContext.tsx @@ -0,0 +1,54 @@ +import { createContext, ReactNode, useContext, useRef } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; + +import { createMemoryStore, MemoryStore, RFState } from "./store"; + +const StoreContext = createContext(null); + +export const StoreProvider = ({ + persistence, + children, +}: { + persistence?: boolean; + children: ReactNode; +}) => { + // Created once per mounted playground, never shared between instances. + const storeRef = useRef(); + if (!storeRef.current) { + storeRef.current = createMemoryStore(persistence); + } + + return ( + + {children} + + ); +}; + +/** + * Reads from the store of the surrounding `MemoryPlayground`. + * + * Mirrors the zustand hook API: pass a selector and an optional equality + * function. + */ +export function useStore( + selector: (state: RFState) => U, + equalityFn?: (a: U, b: U) => boolean, +): U { + const store = useContext(StoreContext); + if (!store) { + throw new Error("useStore has to be used inside a MemoryPlayground"); + } + return useStoreWithEqualityFn(store, selector, equalityFn); +} + +/** The raw store instance, for reading or subscribing outside of React. */ +export function useMemoryStore(): MemoryStore { + const store = useContext(StoreContext); + if (!store) { + throw new Error("useMemoryStore has to be used inside a MemoryPlayground"); + } + return store; +} + +export default useStore; diff --git a/packages/java-memory-playground/src/types.ts b/packages/java-memory-playground/src/types.ts new file mode 100644 index 0000000..9af9592 --- /dev/null +++ b/packages/java-memory-playground/src/types.ts @@ -0,0 +1,11 @@ +import type { BuiltInEdge } from "@xyflow/react"; +import { ObjectNodeType } from "./ObjectNode"; +import { VariableNode } from "./VariableNode"; +import { MethodCallNodeType } from "./MethodCallNode"; +import { ReferenceEdge } from "./ReferenceEdge"; + +// Only the three node types registered in `nodeTypes` can occur. Including +// React Flow's BuiltInNode here widened the union to nodes that require a +// `data.label`, which made every setNodes call fail to type check. +export type CustomNodeType = ObjectNodeType | VariableNode | MethodCallNodeType; +export type CustomEdgeType = BuiltInEdge | ReferenceEdge; diff --git a/src/useDnD.tsx b/packages/java-memory-playground/src/useDnD.tsx similarity index 100% rename from src/useDnD.tsx rename to packages/java-memory-playground/src/useDnD.tsx diff --git a/src/utils.ts b/packages/java-memory-playground/src/utils.ts similarity index 100% rename from src/utils.ts rename to packages/java-memory-playground/src/utils.ts diff --git a/src/vite-env.d.ts b/packages/java-memory-playground/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to packages/java-memory-playground/src/vite-env.d.ts diff --git a/packages/java-memory-playground/tsconfig.build.json b/packages/java-memory-playground/tsconfig.build.json new file mode 100644 index 0000000..39cd94b --- /dev/null +++ b/packages/java-memory-playground/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "rootDir": "src", + "declarationDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/java-memory-playground/tsconfig.json b/packages/java-memory-playground/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/packages/java-memory-playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/java-memory-playground/vite.config.ts b/packages/java-memory-playground/vite.config.ts new file mode 100644 index 0000000..db293a6 --- /dev/null +++ b/packages/java-memory-playground/vite.config.ts @@ -0,0 +1,29 @@ +import { resolve } from "path"; +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +// Library build. React and react-dom stay external so that the consuming +// application (or the web component wrapper) provides a single React instance. +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + cssCodeSplit: false, + lib: { + formats: ["es"], + entry: resolve(__dirname, "src/index.ts"), + fileName: () => "index.js", + }, + rollupOptions: { + external: ["react", "react-dom", "react/jsx-runtime"], + output: { + assetFileNames: (asset) => + asset.name?.endsWith(".css") ? "index.css" : "assets/[name][extname]", + }, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/web-component/README.md b/packages/web-component/README.md new file mode 100644 index 0000000..aa7cbbd --- /dev/null +++ b/packages/web-component/README.md @@ -0,0 +1,103 @@ +# @openpatch/java-memory-playground-web-component + +The [Java Memory Playground](https://jmp.openpatch.org) as a framework agnostic +web component. Drop it into any page — plain HTML, a CMS, Hyperbook, an LMS — and +visualize the Java stack and heap. + +## Usage + +```html + + + + + + +``` + +The element has no intrinsic size — give it one: + +```css +java-memory-playground { + display: block; + width: 100%; + height: 600px; +} +``` + +## Attributes + +| Attribute | Type | Description | +| ------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `memory` | JSON | The diagram to show. Any missing section (`klasses`, `objects`, `variables`, `methodCalls`) defaults to empty. | +| `options` | JSON | Overrides for `memory.options`, e.g. `{"hideSidebar":true}`. Applied on top of the options in `memory`. | +| `persistence` | boolean | Mirror the diagram into `location.hash`. Off by default — an embedded playground should not take over the page URL. | + +Attributes can be updated at any time; setting `memory` again replaces the +diagram. + +### Options + +| Option | Description | +| ---------------------------- | ------------------------------------------------------ | +| `hideSidebar` | Hide the palette of draggable classes on the left. | +| `hideCallMethod` | Hide the "Call Method" entry in the sidebar. | +| `hideDeclareGlobalVariable` | Hide the "Declare Global Variable" entry in the sidebar.| +| `hideNewArray` | Hide the "new Array" entry in the sidebar. | +| `disableGarbageCollector` | Hide the garbage collector button. | +| `createNewOnEdgeDrop` | Create a new object when an edge is dropped on empty canvas. | + +## Events + +A `change` event fires when the user presses **Save**. `event.detail` is the +complete memory, in the same shape the `memory` attribute accepts, so it can be +stored and fed straight back in later. + +```javascript +playground.addEventListener("change", (event) => { + localStorage.setItem("diagram", JSON.stringify(event.detail)); +}); +``` + +## Several playgrounds on one page + +Each element keeps its own state and emits its own `change` events, so a page +can host as many playgrounds as it needs. + +## Development + +```sh +pnpm build # writes dist/index.umd.js and dist/index.css +``` + +`index.html` and `multi.html` in this package are demo pages for the built +bundle — serve the package directory and open them in a browser. diff --git a/packages/web-component/index.html b/packages/web-component/index.html new file mode 100644 index 0000000..fff43f8 --- /dev/null +++ b/packages/web-component/index.html @@ -0,0 +1,84 @@ + + + + + + + Java Memory Playground Web Component + + + + + + + + + + + + diff --git a/packages/web-component/multi.html b/packages/web-component/multi.html new file mode 100644 index 0000000..9a4c605 --- /dev/null +++ b/packages/web-component/multi.html @@ -0,0 +1,57 @@ + + + + + + + Two playgrounds on one page + + + + + + + + + + + + + diff --git a/packages/web-component/package.json b/packages/web-component/package.json new file mode 100644 index 0000000..9613c56 --- /dev/null +++ b/packages/web-component/package.json @@ -0,0 +1,50 @@ +{ + "name": "@openpatch/java-memory-playground-web-component", + "version": "0.1.0", + "author": "Mike Barkmin", + "description": "Java Memory Playground as a framework agnostic web component.", + "homepage": "https://github.com/openpatch/java-memory-playground#readme", + "license": "MIT", + "type": "module", + "main": "dist/index.umd.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.umd.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openpatch/java-memory-playground.git", + "directory": "packages/web-component" + }, + "bugs": { + "url": "https://github.com/openpatch/java-memory-playground/issues" + }, + "scripts": { + "version": "pnpm build", + "lint": "tsc --noEmit", + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "@openpatch/java-memory-playground": "workspace:*", + "@r2wc/react-to-web-component": "^2.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/packages/web-component/src/index.ts b/packages/web-component/src/index.ts new file mode 100644 index 0000000..e16afd9 --- /dev/null +++ b/packages/web-component/src/index.ts @@ -0,0 +1,26 @@ +import r2wc from "@r2wc/react-to-web-component"; +import { + MemoryPlayground, + setPersistence, +} from "@openpatch/java-memory-playground"; +import "@openpatch/java-memory-playground/index.css"; + +// Explicit even though it is the default: an embedded playground must not +// hijack the URL of the page hosting it. State comes in through the `memory` +// attribute and leaves through the `change` event. +setPersistence(false); + +const MemoryPlaygroundWC = r2wc(MemoryPlayground, { + props: { + memory: "string", + options: "json", + persistence: "boolean", + }, + // r2wc keys events by prop name: it passes an `onChange` prop that dispatches + // a `change` CustomEvent on this element, with the memory as `detail`. + events: { + onChange: {}, + }, +}); + +customElements.define("java-memory-playground", MemoryPlaygroundWC); diff --git a/packages/web-component/tsconfig.json b/packages/web-component/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/packages/web-component/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/web-component/vite.config.ts b/packages/web-component/vite.config.ts new file mode 100644 index 0000000..784710a --- /dev/null +++ b/packages/web-component/vite.config.ts @@ -0,0 +1,28 @@ +import { resolve } from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Everything (React included) is bundled into a single UMD file so the +// component can be dropped into any page with one + + + + diff --git a/packages/web-component/src/index.ts b/packages/web-component/src/index.ts index e16afd9..129e7ad 100644 --- a/packages/web-component/src/index.ts +++ b/packages/web-component/src/index.ts @@ -14,7 +14,9 @@ const MemoryPlaygroundWC = r2wc(MemoryPlayground, { props: { memory: "string", options: "json", + language: "string", persistence: "boolean", + keyBindings: "json", }, // r2wc keys events by prop name: it passes an `onChange` prop that dispatches // a `change` CustomEvent on this element, with the memory as `detail`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e699ec..c3bde4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@xyflow/react': specifier: ^12.3.6 version: 12.11.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 html-to-image: specifier: ^1.11.11 version: 1.11.13 @@ -35,9 +38,15 @@ importers: pako: specifier: ^2.1.0 version: 2.2.0 + throttle-debounce: + specifier: ^5.0.2 + version: 5.0.2 + zundo: + specifier: ^2.3.0 + version: 2.3.0(zustand@5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))) zustand: - specifier: ^4.5.4 - version: 4.5.7(@types/react@18.3.31)(react@18.3.1) + specifier: ^5.0.14 + version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@types/node': specifier: ^22.5.0 @@ -51,6 +60,9 @@ importers: '@types/react-dom': specifier: ^18.3.0 version: 18.3.7(@types/react@18.3.31) + '@types/throttle-debounce': + specifier: ^5.0.2 + version: 5.0.2 '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21(@types/node@22.20.1)) @@ -707,6 +719,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/throttle-debounce@5.0.2': + resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -987,6 +1002,9 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1395,6 +1413,10 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1578,6 +1600,11 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -1593,6 +1620,24 @@ packages: react: optional: true + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@babel/code-frame@7.29.7': @@ -2166,6 +2211,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/throttle-debounce@5.0.2': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))': dependencies: '@babel/core': 7.29.7 @@ -2449,6 +2496,8 @@ snapshots: extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2814,6 +2863,8 @@ snapshots: term-size@2.2.1: {} + throttle-debounce@5.0.2: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2990,9 +3041,19 @@ snapshots: yn@3.1.1: {} + zundo@2.3.0(zustand@5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))): + dependencies: + zustand: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + zustand@4.5.7(@types/react@18.3.31)(react@18.3.1): dependencies: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: '@types/react': 18.3.31 react: 18.3.1 + + zustand@5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) From 10dafa821b1b992a3b93095ea68d289521e12b43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 18:02:28 +0000 Subject: [PATCH 03/27] Upgrade dependencies to latest, and fix restoring older shared links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 18 -> 19, pako 2 -> 3, vitest 2 -> 4, TypeScript 5 -> 7, @types/node 22 -> 26, plus the smaller ones. The React peer range still covers 18 and 19. Fallout handled: - pako 3 renamed the decode option from `{ to: "string" }` to `{ toText: true }`. The compressed format is unchanged, so shared links still decode. - @types/pako is deprecated now that pako ships its own types; removed. - React 19 requires an argument to useRef. - TypeScript 7 reports side-effect CSS imports without declarations, so the two packages that were missing a vite-env.d.ts now have one. - The store moved off zustand's legacy `zustand/traditional` entry to `useStore` + `useShallow`, the recommended zustand 5 API. Vite stays on 7 (with @vitejs/plugin-react 5) on purpose. Vite 8 bundles with Rolldown, which leaves the `require("react")` inside use-sync-external-store's CJS shim unresolved, and the bundle throws on load with a blank page. That shim arrives via @xyflow/react, which depends on zustand 4, so it cannot be avoided from here. The reason is recorded next to the config. Separately, this fixes a bug in the previous commit that the upgrade testing uncovered: diagrams shared by early versions have no `methodCalls` section, building the graph from one threw inside the persist merge, and the failure was swallowed — so an old link silently opened the default diagram instead. Persisted state now goes through the same normalization as the `memory` prop, and getEdgesAndNodes tolerates missing sections. Covered by a test using a real pre-existing link. Verified in Chromium on the upgraded stack: a legacy link restores its eight objects and two variables, edits survive a config round trip, the URL syncs without adding history entries, undo/redo works by button and keyboard, the German UI renders, and two playgrounds on one page stay independent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/fifty-pugs-smoke.md | 11 + .changeset/quiet-moons-repeat.md | 9 + package.json | 8 +- packages/java-memory-playground/package.json | 31 +- .../java-memory-playground/src/ConfigView.tsx | 4 +- .../src/MemoryPlayground.tsx | 4 +- .../java-memory-playground/src/MemoryView.tsx | 4 +- .../java-memory-playground/src/ObjectNode.tsx | 4 +- .../src/getEdgesAndNodes.test.ts | 20 + .../src/getEdgesAndNodes.ts | 12 +- packages/java-memory-playground/src/serde.ts | 4 +- .../java-memory-playground/src/store.test.ts | 19 + packages/java-memory-playground/src/store.ts | 8 +- .../src/storeContext.tsx | 18 +- .../java-memory-playground/vite.config.ts | 2 +- packages/web-component/package.json | 16 +- packages/web-component/src/vite-env.d.ts | 1 + packages/web-component/vite.config.ts | 8 +- platforms/web/package.json | 16 +- platforms/web/src/vite-env.d.ts | 1 + pnpm-lock.yaml | 1536 ++++++++++------- 21 files changed, 1035 insertions(+), 701 deletions(-) create mode 100644 .changeset/fifty-pugs-smoke.md create mode 100644 .changeset/quiet-moons-repeat.md create mode 100644 packages/web-component/src/vite-env.d.ts create mode 100644 platforms/web/src/vite-env.d.ts diff --git a/.changeset/fifty-pugs-smoke.md b/.changeset/fifty-pugs-smoke.md new file mode 100644 index 0000000..83c7905 --- /dev/null +++ b/.changeset/fifty-pugs-smoke.md @@ -0,0 +1,11 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Upgrade to React 19, pako 3, vitest 4 and TypeScript 7. + +The React peer range still covers 18 and 19. Internally the store moved off zustand's legacy `zustand/traditional` entry to `useStore` + `useShallow`, which is the recommended zustand 5 API. + +Vite stays on 7 deliberately: Vite 8 bundles with Rolldown, which leaves an unresolved `require("react")` in the CJS shim that `@xyflow/react` pulls in through zustand 4, producing a bundle that throws on load. diff --git a/.changeset/quiet-moons-repeat.md b/.changeset/quiet-moons-repeat.md new file mode 100644 index 0000000..4f5491a --- /dev/null +++ b/.changeset/quiet-moons-repeat.md @@ -0,0 +1,9 @@ +--- +"@openpatch/java-memory-playground-web-component": patch +"@openpatch/java-memory-playground": patch +"web": patch +--- + +Fix diagrams shared before method calls existed opening as the default diagram. + +Links written by early versions have no `methodCalls` section at all. Reading one threw while restoring the state from the URL, and the failure was swallowed, so the playground silently showed its default diagram instead of the one the link pointed at. Persisted state now goes through the same normalization as the `memory` prop, and building the graph tolerates a diagram that is missing whole sections. diff --git a/package.json b/package.json index 6a29c4a..6344fd8 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "release": "changeset publish" }, "devDependencies": { - "@changesets/changelog-github": "0.5.1", - "@changesets/cli": "2.29.7", - "@types/node": "^22.5.0", - "typescript": "^5.5.4" + "@changesets/changelog-github": "0.7.0", + "@changesets/cli": "2.31.1", + "@types/node": "^26.1.2", + "typescript": "^7.0.2" }, "engines": { "pnpm": ">=8" diff --git a/packages/java-memory-playground/package.json b/packages/java-memory-playground/package.json index d0208f8..a3cbd31 100644 --- a/packages/java-memory-playground/package.json +++ b/packages/java-memory-playground/package.json @@ -45,28 +45,27 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@xyflow/react": "^12.3.6", + "@xyflow/react": "^12.11.2", "fast-deep-equal": "^3.1.3", - "html-to-image": "^1.11.11", - "js-base64": "^3.7.7", - "pako": "^2.1.0", + "html-to-image": "^1.11.13", + "js-base64": "^3.9.2", + "pako": "^3.0.1", "throttle-debounce": "^5.0.2", "zundo": "^2.3.0", "zustand": "^5.0.14" }, "devDependencies": { - "@types/node": "^22.5.0", - "@types/pako": "^2.0.3", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@types/throttle-debounce": "^5.0.2", - "@vitejs/plugin-react": "^4.3.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "rimraf": "^6.0.1", - "typescript": "^5.5.4", - "typescript-json-schema": "^0.65.1", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "@vitejs/plugin-react": "^5.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "rimraf": "^6.1.3", + "typescript": "^7.0.2", + "typescript-json-schema": "^0.68.0", + "vite": "^7.3.6", + "vitest": "^4.1.10" } } diff --git a/packages/java-memory-playground/src/ConfigView.tsx b/packages/java-memory-playground/src/ConfigView.tsx index 992d05a..064f1a6 100644 --- a/packages/java-memory-playground/src/ConfigView.tsx +++ b/packages/java-memory-playground/src/ConfigView.tsx @@ -1,4 +1,4 @@ -import { shallow } from "zustand/shallow"; +import { useShallow } from "zustand/shallow"; import useStore from "./storeContext"; import { RFState } from "./store"; import { useCallback, useState, useEffect } from "react"; @@ -22,7 +22,7 @@ export const ConfigView = () => { getMemory, setRoute, t, - } = useStore(selector, shallow); + } = useStore(useShallow(selector)); const [klasses, setKlasses] = useState(storedKlasses); const [options, setOptions] = useState(storedOptions); diff --git a/packages/java-memory-playground/src/MemoryPlayground.tsx b/packages/java-memory-playground/src/MemoryPlayground.tsx index 9339f0b..abbb128 100644 --- a/packages/java-memory-playground/src/MemoryPlayground.tsx +++ b/packages/java-memory-playground/src/MemoryPlayground.tsx @@ -2,7 +2,7 @@ import "@xyflow/react/dist/style.css"; import "./index.css"; import { ReactFlowProvider } from "@xyflow/react"; import { useEffect, useMemo } from "react"; -import { shallow } from "zustand/shallow"; +import { useShallow } from "zustand/shallow"; import { ConfigView } from "./ConfigView"; import { KeyboardShortcuts } from "./KeyboardShortcuts"; @@ -67,7 +67,7 @@ function Playground({ loadMemory, getMemory, setDefaultLanguage, - } = useStore(selector, shallow); + } = useStore(useShallow(selector)); // Serialized so that a host passing an inline object literal does not reload // the diagram — and throw away the user's edits — on every render. diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index 72131a7..6cb0256 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -15,7 +15,7 @@ import { toPng } from "html-to-image"; import useStore from "./storeContext"; import { useUndoRedo } from "./useUndoRedo"; import { RFState } from "./store"; -import { shallow } from "zustand/shallow"; +import { useShallow } from "zustand/shallow"; import ObjectNode, { ObjectNodeType } from "./ObjectNode"; import VariableNode from "./VariableNode"; import { useCallback, useState, useRef, useMemo } from "react"; @@ -121,7 +121,7 @@ export const MemoryView = () => { onEdgesChange, save, t, - } = useStore(selector, shallow); + } = useStore(useShallow(selector)); const { screenToFlowPosition } = useReactFlow(); const connectingNode = useRef(null); // Scoped to this instance so that exporting works when a page embeds more diff --git a/packages/java-memory-playground/src/ObjectNode.tsx b/packages/java-memory-playground/src/ObjectNode.tsx index b63ae4b..f733af6 100644 --- a/packages/java-memory-playground/src/ObjectNode.tsx +++ b/packages/java-memory-playground/src/ObjectNode.tsx @@ -13,7 +13,7 @@ import { isConnectedToMethodCall, isConnectedToVariable } from "./utils"; import { CustomEdgeType, CustomNodeType } from "./types"; import useStore from "./storeContext"; import { RFState } from "./store"; -import { shallow } from "zustand/shallow"; +import { useShallow } from "zustand/shallow"; function AttributeHandle({ name, @@ -116,7 +116,7 @@ const selector = (state: RFState) => ({ }); function ObjectNode({ id, data }: NodeProps) { - const { disableGarbageCollector } = useStore(selector, shallow); + const { disableGarbageCollector } = useStore(useShallow(selector)); const nodes = useNodes(); const edges = useEdges(); const gc = !disableGarbageCollector && diff --git a/packages/java-memory-playground/src/getEdgesAndNodes.test.ts b/packages/java-memory-playground/src/getEdgesAndNodes.test.ts index 84b982c..db38571 100644 --- a/packages/java-memory-playground/src/getEdgesAndNodes.test.ts +++ b/packages/java-memory-playground/src/getEdgesAndNodes.test.ts @@ -41,6 +41,26 @@ describe("getEdgesAndNodes", () => { // @55 is a Message whose attributes are all String/boolean. expect(edges.filter((e) => e.source === "@55")).toHaveLength(0); }); + + test("tolerates a diagram saved without every section", () => { + // Links shared by the earliest versions carry no `methodCalls` key at all. + const partial = { + options: {}, + viewport: { x: 0, y: 0, zoom: 1 }, + klasses: { Node: { attributes: { next: "Node" } } }, + objects: { + "@aa": { + klass: "Node", + attributes: { next: { dataType: "Node" } }, + position: { x: 0, y: 0 }, + }, + }, + variables: {}, + } as unknown as Memory; + + expect(() => getEdgesAndNodes(partial)).not.toThrow(); + expect(getEdgesAndNodes(partial).nodes).toHaveLength(1); + }); }); describe("getMemory", () => { diff --git a/packages/java-memory-playground/src/getEdgesAndNodes.ts b/packages/java-memory-playground/src/getEdgesAndNodes.ts index 60ac416..78344e0 100644 --- a/packages/java-memory-playground/src/getEdgesAndNodes.ts +++ b/packages/java-memory-playground/src/getEdgesAndNodes.ts @@ -12,7 +12,13 @@ export const getEdgesAndNodes = ( const nodes: CustomNodeType[] = []; const edges: CustomEdgeType[] = []; - Object.entries(memory.variables).forEach(([id, data]) => { + // Diagrams saved by older versions can be missing whole sections — the very + // first ones had no `methodCalls` at all — and a shared link must still open. + const variables = memory.variables ?? {}; + const methodCalls = memory.methodCalls ?? {}; + const objects = memory.objects ?? {}; + + Object.entries(variables).forEach(([id, data]) => { nodes.push({ id, type: "variable", @@ -29,7 +35,7 @@ export const getEdgesAndNodes = ( } }); - Object.entries(memory.methodCalls).forEach(([id, data]) => { + Object.entries(methodCalls).forEach(([id, data]) => { nodes.push({ id, type: "method-call", @@ -52,7 +58,7 @@ export const getEdgesAndNodes = ( }); }); - Object.entries(memory.objects).forEach(([id, data]) => { + Object.entries(objects).forEach(([id, data]) => { nodes.push({ id, type: "object", diff --git a/packages/java-memory-playground/src/serde.ts b/packages/java-memory-playground/src/serde.ts index 4fa35c4..e620a9e 100644 --- a/packages/java-memory-playground/src/serde.ts +++ b/packages/java-memory-playground/src/serde.ts @@ -11,7 +11,9 @@ const pakoSerde = { }, deserialize: (state: string): string => { const data = toUint8Array(state); - return inflate(data, { to: "string" }); + // pako 3 renamed the decode option from `{ to: "string" }` to `toText`. + // The compressed format is unchanged, so older links still decode. + return inflate(data, { toText: true }); }, }; diff --git a/packages/java-memory-playground/src/store.test.ts b/packages/java-memory-playground/src/store.test.ts index 7739c14..fda3b10 100644 --- a/packages/java-memory-playground/src/store.test.ts +++ b/packages/java-memory-playground/src/store.test.ts @@ -147,6 +147,25 @@ describe("createMemoryStore", () => { "String", ]); }); + + test("opens a link from before method calls existed", () => { + // This diagram has no `methodCalls` key at all. Reading it used to throw + // inside the persist merge, which silently dropped the user back to the + // default diagram instead of the one they were sent. + stubLocation( + "#pako:eNq1lk1v2zgQhv9K1rcFWpVDDodkLptbe9iPQxfYSy4URaVqLSuQ5Gyzgf_7jqwPJ5YVJHELw5BEisOXj17OcPVwvWpa38br1SXflrGs6vv-vrpti2rT9A9fiix-5n_qa27I_bqJu3fXq7si_ntb1W3_0ne-CG69H67_VVXJt9C9-W3tmyYO0X4vmmGIb9u6SLft2BO2dR03XWf_1p9VxtJ4fF7UzYlmjnrcuuumOzyemmUTv5-IFapNy3P_lX6Noe_-IzaNvxljTk-nQm6bWG98GffjPnPf5mYftB2netSW9bwPbbt9_Go_8RDvSpCM2uaZN3nunR-m3WOcpO-jLSN82E_l_76_jfPV3vn1tmvebNfr3SPALxrE-ryRzhiRB5sGmynkVRy-x0uDRGWU1w6NDz4TIRtI31ZN0ZnvYCuwiVRGSydRAQoCHHz2HhOSGoQ1ZEkLR7CPcELeHN-kaMEfL12FEdbJXGWZA6IQbI_i2E7H0SY7PQ2WYiDrhJIQyHmDi0gkUoL8pkTUghc-EgGlEqOMQxJaSWddD2Qu8ucBOfFZ3w4kQrAmmBSVRUVpvggEbQdEggJrLKLTExF2DxkJ5FCydQhtj2Qu8ychGffYGwmwbBODcyIQqSzCIgGDJlHaONCK16lBDwC4GdAQsBmk4p-gHsDca08BPNbzTMJ7eKy2LL7FMcsdVjZlukNKfDLqk1-vq1-eH5dNZeowTgqpPoD8INQFiEslnomwsIsIEimsFcIQIuDETEmbWCkEyg6Y1NoMppnZ8XxmX6u3INuejQtejUsrpiKo200OLO-lkZbqMGqDKNGBII09rbl1z6eV10XmX8_rnyJe3MQvbfPb2dzw1dwsmASBtCV0Bu1UvthaieBMLQwKTTRWr0gujaBTlWkTfE4_AlssyzdQ-7htLy5_PZuYeTUxQJewZziVKS0tOTkWfOg2puVcJsFy1dPg-uPTna8Ln65HDFdZ8EhRpWxIh2IseNMZrewRNsfJdn7uOimPZegEgAUKZTjjHhLHe0q0NOQM8JGEu9Dt3p0oEj26KytTTSoPLmRZkD49UllsQlUypqXaOPPJglgjEquQuCaikdZpNdEUmHAZVCiUMHyYALWkdqAc66aPLXar_wFTSdLd", + ); + + const store = createMemoryStore(true); + + expect(Object.keys(store.getState().klasses)).toEqual([ + "List", + "ListNode", + "Message", + ]); + expect(Object.keys(store.getState().getMemory().objects)).toHaveLength(8); + expect(Object.keys(store.getState().getMemory().variables)).toHaveLength(2); + }); }); describe("undo/redo", () => { diff --git a/packages/java-memory-playground/src/store.ts b/packages/java-memory-playground/src/store.ts index 7ce7231..e3cc6c9 100644 --- a/packages/java-memory-playground/src/store.ts +++ b/packages/java-memory-playground/src/store.ts @@ -7,6 +7,7 @@ import { persist, StateStorage, createJSONStorage } from "zustand/middleware"; import { createStore } from "zustand/vanilla"; import { getEdgesAndNodes, getMemory } from "./getEdgesAndNodes"; +import { parseMemory } from "./helper"; import { Memory, initialMemory } from "./memory"; import { deserializeState, serializeState } from "./serde"; import { @@ -251,7 +252,12 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => // shared before this refactor still open. partialize: (state): any => ({ memory: state.getMemory() }), merge: (persisted, current): RFState => { - const memory = (persisted as { memory?: Memory } | undefined)?.memory; + const stored = (persisted as { memory?: Memory } | undefined)?.memory; + // Through parseMemory, because a link may have been written by an + // older version that left whole sections out — early diagrams have no + // `methodCalls` at all, and reading those raw used to throw in here, + // silently dropping the user back to the default diagram. + const memory = parseMemory(stored); if (!memory) return current; const { nodes, edges } = getEdgesAndNodes(memory); diff --git a/packages/java-memory-playground/src/storeContext.tsx b/packages/java-memory-playground/src/storeContext.tsx index a332f94..ec49ae2 100644 --- a/packages/java-memory-playground/src/storeContext.tsx +++ b/packages/java-memory-playground/src/storeContext.tsx @@ -1,6 +1,6 @@ import { createContext, ReactNode, useContext, useRef } from "react"; import type { TemporalState } from "zundo"; -import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useStore as useZustandStore } from "zustand"; import { createMemoryStore, MemoryStore, RFState } from "./store"; @@ -14,7 +14,7 @@ export const StoreProvider = ({ children: ReactNode; }) => { // Created once per mounted playground, never shared between instances. - const storeRef = useRef(); + const storeRef = useRef(null); if (!storeRef.current) { storeRef.current = createMemoryStore(persistence); } @@ -29,18 +29,15 @@ export const StoreProvider = ({ /** * Reads from the store of the surrounding `MemoryPlayground`. * - * Mirrors the zustand hook API: pass a selector and an optional equality - * function. + * For a selector that builds an object, wrap it in zustand's `useShallow` so + * the component only re-renders when one of the picked values actually changes. */ -export function useStore( - selector: (state: RFState) => U, - equalityFn?: (a: U, b: U) => boolean, -): U { +export function useStore(selector: (state: RFState) => U): U { const store = useContext(StoreContext); if (!store) { throw new Error("useStore has to be used inside a MemoryPlayground"); } - return useStoreWithEqualityFn(store, selector, equalityFn); + return useZustandStore(store, selector); } /** The raw store instance, for reading or subscribing outside of React. */ @@ -59,10 +56,9 @@ export function useMemoryStore(): MemoryStore { */ export function useTemporalStore( selector: (state: TemporalState>) => U, - equalityFn?: (a: U, b: U) => boolean, ): U { const store = useMemoryStore(); - return useStoreWithEqualityFn(store.temporal, selector, equalityFn); + return useZustandStore(store.temporal, selector); } export default useStore; diff --git a/packages/java-memory-playground/vite.config.ts b/packages/java-memory-playground/vite.config.ts index db293a6..125a3ec 100644 --- a/packages/java-memory-playground/vite.config.ts +++ b/packages/java-memory-playground/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ cssCodeSplit: false, lib: { formats: ["es"], - entry: resolve(__dirname, "src/index.ts"), + entry: resolve(import.meta.dirname, "src/index.ts"), fileName: () => "index.js", }, rollupOptions: { diff --git a/packages/web-component/package.json b/packages/web-component/package.json index 9613c56..6ec16ac 100644 --- a/packages/web-component/package.json +++ b/packages/web-component/package.json @@ -35,16 +35,16 @@ }, "dependencies": { "@openpatch/java-memory-playground": "workspace:*", - "@r2wc/react-to-web-component": "^2.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "@r2wc/react-to-web-component": "^2.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tslib": "^2.8.1" }, "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.5.4", - "vite": "^5.4.0" + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^7.0.2", + "vite": "^7.3.6" } } diff --git a/packages/web-component/src/vite-env.d.ts b/packages/web-component/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/web-component/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/web-component/vite.config.ts b/packages/web-component/vite.config.ts index 784710a..18acec7 100644 --- a/packages/web-component/vite.config.ts +++ b/packages/web-component/vite.config.ts @@ -4,6 +4,12 @@ import react from "@vitejs/plugin-react"; // Everything (React included) is bundled into a single UMD file so the // component can be dropped into any page with one + + + From f532548a674fd77a17428c2e953827680e7660ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:29:09 +0000 Subject: [PATCH 05/27] Make a diagram a sequence of steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack is defined by pushing and popping, and a single frozen picture shows neither. The diagram is now a list of steps with a bar to walk through them, and Add step duplicates the step on screen so a trace is authored by changing what the next line did rather than by redrawing. That makes showable a set of things that were not: a frame appearing on a call and gone after the return, the assignment that drops the last reference to an object, and what reassigning a parameter does and does not do to the caller. Format: `Memory.steps` is a list of `{ label?, note?, objects, variables, methodCalls }`. A one-step diagram is still written in the shape it has always had, without a `steps` key, so a link to a single picture stays readable by older versions; parseMemory reads either shape and normalises to steps. The URL cost of a story is small because steps compress against each other — a 40-step trace of the linked-list example is 2.5x a single diagram, not 40x. - `step` / `onStepChange` on the component, `step` and a `stepchange` event on the custom element, so a page can drive the diagram from its prose and follow along. steps.html demonstrates both directions. - The store keeps `steps` and `currentStep`; setNodes/setEdges and the React Flow change handlers write into the current step, which left MemoryView's call sites untouched. - Node positions are shared across steps: dragging moves a node everywhere it appears, so the picture does not jump while stepping. - Class definitions are reconciled across every step, so the reconciliation moved out of ConfigView into an applyKlasses action. - Walking through a diagram is not undoable; changing one is. Verified in Chromium: a five-step trace pushes a frame at step 2 and pops it at step 5 with the prose staying in sync in both directions, and in the app Add step duplicates, an edit to the copy leaves the original intact, and the story survives the URL. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/spotty-melons-play.md | 17 ++ packages/java-memory-playground/README.md | 22 ++ .../java-memory-playground/src/ConfigView.tsx | 54 +---- .../src/InlineString.tsx | 4 +- .../src/MemoryPlayground.tsx | 29 ++- .../java-memory-playground/src/MemoryView.tsx | 12 +- .../java-memory-playground/src/StepBar.tsx | 89 +++++++ .../src/getEdgesAndNodes.test.ts | 8 +- .../src/getEdgesAndNodes.ts | 14 +- .../java-memory-playground/src/helper.test.ts | 35 ++- packages/java-memory-playground/src/helper.ts | 48 +++- packages/java-memory-playground/src/index.css | 22 ++ packages/java-memory-playground/src/index.ts | 4 +- packages/java-memory-playground/src/memory.ts | 33 ++- .../java-memory-playground/src/store.test.ts | 146 +++++++++-- packages/java-memory-playground/src/store.ts | 228 ++++++++++++++++-- .../src/translations.ts | 28 +++ packages/web-component/README.md | 33 ++- packages/web-component/src/index.ts | 7 +- packages/web-component/steps.html | 112 +++++++++ 20 files changed, 821 insertions(+), 124 deletions(-) create mode 100644 .changeset/spotty-melons-play.md create mode 100644 packages/java-memory-playground/src/StepBar.tsx create mode 100644 packages/web-component/steps.html diff --git a/.changeset/spotty-melons-play.md b/.changeset/spotty-melons-play.md new file mode 100644 index 0000000..832dfe7 --- /dev/null +++ b/.changeset/spotty-melons-play.md @@ -0,0 +1,17 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Make a diagram a sequence of steps. + +A stack is defined by pushing and popping, and a single frozen picture cannot show either. A diagram now holds a list of steps, with a bar to walk through them and an **Add step** button that duplicates the step on screen so a trace is authored by changing what the next line did. + +This makes a set of things showable that were not: a frame appearing on a call and gone after a return, the assignment that drops the last reference to an object, and what a parameter reassignment does and does not do to the caller. + +- `step` and `onStepChange` on the component, `step` and a `stepchange` event on the custom element, so a page can drive the diagram from its prose and follow along. +- Node positions are shared across steps, so dragging a node moves it everywhere and the picture does not jump while stepping. +- Class definitions are reconciled across every step when they change, rather than only the step on screen. +- Walking through a diagram is not an undo step; changing one is. +- A one-step diagram is saved in the shape it has always had, so a link to a single picture stays readable by older versions. `hideSteps` hides the bar. diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md index 1d2fd43..bbbae14 100644 --- a/packages/java-memory-playground/README.md +++ b/packages/java-memory-playground/README.md @@ -55,6 +55,8 @@ export function Example() { | `language` | `string` | `"en"`, `"de"`, or `"auto"` to follow the browser. Defaults to the browser language. | | `persistence` | `boolean` | Mirror the diagram into `location.hash`. Defaults to the value set through `setPersistence`. | | `keyBindings` | `Partial` | Overrides for the default keyboard shortcuts. | +| `step` | `number` | The step to show, zero based. Set it to drive the diagram from the page around it. | +| `onStepChange`| `(step: number) => void` | Called whenever the shown step changes. | | `onChange` | `(memory: Memory) => void` | Called when the user presses **Save**. | Every `MemoryPlayground` creates its own store, so several playgrounds can live @@ -70,6 +72,26 @@ back, and when persistence is on the URL keeps up on its own. **Save** is therefore a commit, not a rescue: it is what fires `onChange`, which is how a host learns the user considers the diagram finished. +## Steps + +A diagram is a sequence of steps, which is what lets it show the stack doing the +thing that makes it a stack: a frame pushed on a call, popped on a return, and an +object turning into garbage the moment the last reference to it is overwritten. + +A trace is authored by duplication — build a step, press **Add step**, and change +what the next line did. Node positions are shared across the whole diagram, so +dragging something moves it everywhere and the picture does not jump while +stepping through. + +```tsx +// Driving the diagram from the prose around it. + +``` + +A diagram with one step is just a picture, and is still saved in the shape it +always had, so a link to a single diagram stays readable by older versions. Set +`hideSteps` to hide the bar entirely. + ## Strings A String is a reference type, so a String value lives on the heap like any other diff --git a/packages/java-memory-playground/src/ConfigView.tsx b/packages/java-memory-playground/src/ConfigView.tsx index bc271e2..ef927da 100644 --- a/packages/java-memory-playground/src/ConfigView.tsx +++ b/packages/java-memory-playground/src/ConfigView.tsx @@ -8,8 +8,7 @@ import { SimpleInputDialog } from "./SimpleInputDialog"; const selector = (state: RFState) => ({ storedKlasses: state.klasses, storedOptions: state.options, - loadMemory: state.loadMemory, - getMemory: state.getMemory, + applyKlasses: state.applyKlasses, setRoute: state.setRoute, t: state.getTranslations(), }); @@ -18,8 +17,7 @@ export const ConfigView = () => { const { storedKlasses, storedOptions, - loadMemory, - getMemory, + applyKlasses, setRoute, t, } = useStore(useShallow(selector)); @@ -53,53 +51,13 @@ export const ConfigView = () => { }, [klasses, options, storedKlasses, storedOptions]); const onSave = useCallback(() => { - const memory = getMemory(); - // Update existing objects to match new class definitions - const updatedObjects = { ...memory.objects }; - - Object.entries(updatedObjects).forEach(([objId, obj]) => { - const klassDefinition = klasses[obj.klass]; - - // Skip if class doesn't exist (e.g., Array) or object class is not in klasses - if (!klassDefinition) return; - - const updatedAttributes = { ...obj.attributes }; - const klassAttributeNames = Object.keys(klassDefinition.attributes); - const currentAttributeNames = Object.keys(updatedAttributes); - - // Add new attributes from class definition - klassAttributeNames.forEach((attrName) => { - if (!updatedAttributes[attrName]) { - updatedAttributes[attrName] = { - dataType: klassDefinition.attributes[attrName], - value: undefined, - }; - } - }); - - // Remove attributes that are no longer in class definition - currentAttributeNames.forEach((attrName) => { - if (!klassAttributeNames.includes(attrName)) { - delete updatedAttributes[attrName]; - } - }); - - updatedObjects[objId] = { - ...obj, - attributes: updatedAttributes, - }; - }); - - loadMemory({ - ...memory, - klasses, - options, - objects: updatedObjects, - }); + // Class definitions belong to the whole diagram, so the store reconciles + // every step's objects rather than only the one on screen. + applyKlasses(klasses, options); setHasUnsavedChanges(false); setShowSaveSuccess(true); setTimeout(() => setShowSaveSuccess(false), 2000); - }, [getMemory, klasses, options, loadMemory]); + }, [applyKlasses, klasses, options]); const onView = useCallback(() => { if (hasUnsavedChanges) { diff --git a/packages/java-memory-playground/src/InlineString.tsx b/packages/java-memory-playground/src/InlineString.tsx index 606c774..4c88948 100644 --- a/packages/java-memory-playground/src/InlineString.tsx +++ b/packages/java-memory-playground/src/InlineString.tsx @@ -8,8 +8,8 @@ import { CustomEdgeType, CustomNodeType } from "./types"; import { getRanMemoryAdress } from "./utils"; const selector = (state: RFState) => ({ - nodes: state.nodes, - edges: state.edges, + nodes: state.steps[state.currentStep]?.nodes ?? [], + edges: state.steps[state.currentStep]?.edges ?? [], setNodes: state.setNodes, setEdges: state.setEdges, }); diff --git a/packages/java-memory-playground/src/MemoryPlayground.tsx b/packages/java-memory-playground/src/MemoryPlayground.tsx index abbb128..cf9d4f6 100644 --- a/packages/java-memory-playground/src/MemoryPlayground.tsx +++ b/packages/java-memory-playground/src/MemoryPlayground.tsx @@ -1,7 +1,7 @@ import "@xyflow/react/dist/style.css"; import "./index.css"; import { ReactFlowProvider } from "@xyflow/react"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useShallow } from "zustand/shallow"; import { ConfigView } from "./ConfigView"; @@ -39,6 +39,13 @@ export interface MemoryPlaygroundProps { persistence?: boolean; /** Overrides for the default keyboard shortcuts. */ keyBindings?: Partial; + /** + * The step to show, zero based. Set it to drive the diagram from the page + * around it — prose can walk a reader through a trace. + */ + step?: number; + /** Called with the step index whenever the shown step changes. */ + onStepChange?: (step: number) => void; /** * Called with the full memory whenever the user saves. The web component * wrapper uses this to dispatch its `change` event. @@ -49,6 +56,9 @@ export interface MemoryPlaygroundProps { const selector = (state: RFState) => ({ route: state.route, saveCount: state.saveCount, + currentStep: state.currentStep, + stepCount: state.steps.length, + goToStep: state.goToStep, loadMemory: state.loadMemory, getMemory: state.getMemory, setDefaultLanguage: state.setDefaultLanguage, @@ -59,11 +69,16 @@ function Playground({ options, language, keyBindings, + step, onChange, + onStepChange, }: MemoryPlaygroundProps) { const { route, saveCount, + currentStep, + stepCount, + goToStep, loadMemory, getMemory, setDefaultLanguage, @@ -95,6 +110,18 @@ function Playground({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [memoryKey, optionsKey, loadMemory]); + useEffect(() => { + if (step === undefined) return; + goToStep(step); + }, [step, stepCount, goToStep]); + + const reportedStep = useRef(null); + useEffect(() => { + if (reportedStep.current === currentStep) return; + reportedStep.current = currentStep; + onStepChange?.(currentStep); + }, [currentStep, onStepChange]); + useEffect(() => { // saveCount starts at 0 and is only ever bumped by the Save button, so this // fires exactly when the user commits — never on mount. diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index 9ceef65..5416b2e 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -20,6 +20,7 @@ import ObjectNode, { ObjectNodeType } from "./ObjectNode"; import VariableNode from "./VariableNode"; import { useCallback, useState, useRef, useMemo } from "react"; import { Sidebar } from "./Sidebar"; +import { StepBar } from "./StepBar"; import { Attribute, builtInDataTypes, @@ -47,8 +48,8 @@ const selector = (state: RFState) => ({ options: state.options, setRoute: state.setRoute, persistence: state.persistence, - nodes: state.nodes, - edges: state.edges, + nodes: state.steps[state.currentStep]?.nodes ?? [], + edges: state.steps[state.currentStep]?.edges ?? [], setNodes: state.setNodes, setEdges: state.setEdges, onNodesChange: state.onNodesChange, @@ -743,6 +744,13 @@ export const MemoryView = () => {
+ {!options.hideSteps && ( + +
+ +
+
+ )} {!options.disableGarbageCollector &&
+ + {currentStep + 1} / {steps.length} + + + + {editable ? ( + <> + setStepLabel(currentStep, e.target.value)} + /> + + + + ) : ( + step?.label && {step.label} + )} +
+ ); +} + +export default StepBar; diff --git a/packages/java-memory-playground/src/getEdgesAndNodes.test.ts b/packages/java-memory-playground/src/getEdgesAndNodes.test.ts index eb08fb0..b791c18 100644 --- a/packages/java-memory-playground/src/getEdgesAndNodes.test.ts +++ b/packages/java-memory-playground/src/getEdgesAndNodes.test.ts @@ -16,9 +16,9 @@ describe("getEdgesAndNodes", () => { return acc; }, {}); - expect(byType.object).toBe(Object.keys(initialMemory.objects).length); + expect(byType.object).toBe(Object.keys(initialMemory.objects!).length); expect(byType["method-call"]).toBe( - Object.keys(initialMemory.methodCalls).length, + Object.keys(initialMemory.methodCalls!).length, ); }); @@ -77,7 +77,7 @@ describe("getMemory", () => { // Method calls come back keyed by their stack index rather than by the key // they were written under, so compare the entries themselves. expect(Object.values(result.methodCalls!)).toEqual( - Object.values(initialMemory.methodCalls), + Object.values(initialMemory.methodCalls!), ); }); @@ -121,7 +121,7 @@ describe("getMemory", () => { const result = roundTrip(initialMemory); expect(result.objects!["@55"].position).toEqual( - initialMemory.objects["@55"].position, + initialMemory.objects!["@55"].position, ); }); diff --git a/packages/java-memory-playground/src/getEdgesAndNodes.ts b/packages/java-memory-playground/src/getEdgesAndNodes.ts index 78344e0..89bb261 100644 --- a/packages/java-memory-playground/src/getEdgesAndNodes.ts +++ b/packages/java-memory-playground/src/getEdgesAndNodes.ts @@ -1,10 +1,11 @@ -import { Memory, MethodCall, Obj, Variable, primitveDataTypes } from "./memory"; +import { MethodCall, Obj, Step, Variable, primitveDataTypes } from "./memory"; import { CustomEdgeType, CustomNodeType } from "./types"; export type EdgeData = {}; +/** Builds the React Flow graph for one step of a diagram. */ export const getEdgesAndNodes = ( - memory: Memory, + memory: Partial, ): { edges: CustomEdgeType[]; nodes: CustomNodeType[]; @@ -85,13 +86,14 @@ export const getEdgesAndNodes = ( }; }; +/** Serializes a React Flow graph back into one step of a diagram. */ export const getMemory = ( edges: CustomEdgeType[], nodes: CustomNodeType[], -): Partial => { - const variables: Memory["variables"] = {}; - const objects: Memory["objects"] = {}; - const methodCalls: Memory["methodCalls"] = {}; +): Step => { + const variables: Step["variables"] = {}; + const objects: Step["objects"] = {}; + const methodCalls: Step["methodCalls"] = {}; nodes.forEach((n) => { if (n.type == "object") { diff --git a/packages/java-memory-playground/src/helper.test.ts b/packages/java-memory-playground/src/helper.test.ts index cf1ee7b..fb55860 100644 --- a/packages/java-memory-playground/src/helper.test.ts +++ b/packages/java-memory-playground/src/helper.test.ts @@ -19,17 +19,46 @@ describe("parseMemory", () => { expect(parsed?.klasses).toEqual({ Node: { attributes: { next: "Node" } } }); }); + test("reads a diagram written before stepping as a single step", () => { + const parsed = parseMemory( + JSON.stringify({ + objects: { + "@a": { klass: "Node", attributes: {}, position: { x: 0, y: 0 } }, + }, + }), + ); + + expect(parsed?.steps).toHaveLength(1); + expect(Object.keys(parsed!.steps![0].objects)).toEqual(["@a"]); + }); + + test("keeps the steps of a diagram that has them", () => { + const parsed = parseMemory( + JSON.stringify({ + steps: [ + { label: "call", objects: {}, variables: {}, methodCalls: {} }, + { label: "return", objects: {}, variables: {} }, + ], + }), + ); + + expect(parsed?.steps).toHaveLength(2); + expect(parsed!.steps!.map((s) => s.label)).toEqual(["call", "return"]); + // A step missing a section is still safe to render. + expect(parsed!.steps![1].methodCalls).toEqual({}); + }); + test("accepts an already parsed object", () => { const parsed = parseMemory({ ...initialMemory }); - expect(parsed?.objects).toEqual(initialMemory.objects); + expect(parsed!.steps![0].objects).toEqual(initialMemory.objects); }); test("fills in every section so partial input stays renderable", () => { const parsed = parseMemory('{"objects":{}}'); expect(parsed).not.toBeNull(); - expect(parsed?.variables).toEqual({}); - expect(parsed?.methodCalls).toEqual({}); + expect(parsed!.steps![0].variables).toEqual({}); + expect(parsed!.steps![0].methodCalls).toEqual({}); expect(parsed?.klasses).toEqual({}); expect(parsed?.viewport).toEqual({ x: 0, y: 0, zoom: 1 }); }); diff --git a/packages/java-memory-playground/src/helper.ts b/packages/java-memory-playground/src/helper.ts index 7a5780a..60d6d82 100644 --- a/packages/java-memory-playground/src/helper.ts +++ b/packages/java-memory-playground/src/helper.ts @@ -3,6 +3,7 @@ import { Memory, Obj, STRING_KLASS, + Step, initialMemory, } from "./memory"; @@ -36,8 +37,8 @@ const stripQuotes = (value: string): string => { * something baked into the saved diagram. Links written before that still open: * their inline values are converted here, on read. */ -const migrateInlineStrings = (memory: Memory): Memory => { - const objects: Memory["objects"] = { ...memory.objects }; +const migrateInlineStrings = (step: Step): Step => { + const objects: Step["objects"] = { ...step.objects }; const used = new Set(Object.keys(objects)); let counter = 0; @@ -86,7 +87,7 @@ const migrateInlineStrings = (memory: Memory): Memory => { return next; }; - Object.entries(memory.objects ?? {}).forEach(([id, obj]) => { + Object.entries(step.objects ?? {}).forEach(([id, obj]) => { if (obj.klass === STRING_KLASS) return; objects[id] = { ...obj, @@ -94,15 +95,42 @@ const migrateInlineStrings = (memory: Memory): Memory => { } as Obj; }); - const methodCalls: Memory["methodCalls"] = {}; - Object.entries(memory.methodCalls ?? {}).forEach(([id, call]) => { + const methodCalls: Step["methodCalls"] = {}; + Object.entries(step.methodCalls ?? {}).forEach(([id, call]) => { methodCalls[id as unknown as number] = { ...call, localVariables: convertAll(call.localVariables ?? {}, call.position), }; }); - return { ...memory, objects, methodCalls }; + return { ...step, objects, methodCalls }; +}; + +/** + * The steps of a diagram, whichever shape it was saved in. + * + * Diagrams written before stepping existed hold a single state in the top level + * `objects` / `variables` / `methodCalls`, which is exactly one step. + */ +export const stepsOf = (m: Partial): Step[] => { + const steps = Array.isArray(m.steps) ? m.steps : []; + if (steps.length > 0) { + return steps.map((step) => ({ + label: step?.label, + note: step?.note, + objects: step?.objects ?? {}, + variables: step?.variables ?? {}, + methodCalls: step?.methodCalls ?? {}, + })); + } + + return [ + { + objects: m.objects ?? {}, + variables: m.variables ?? {}, + methodCalls: m.methodCalls ?? {}, + }, + ]; }; /** @@ -136,12 +164,10 @@ export const parseMemory = (memory?: string | Memory | null): Memory | null => { } const m = parsed as Partial; - return migrateInlineStrings({ + return { viewport: m.viewport ?? { x: 0, y: 0, zoom: 1 }, options: { ...initialMemory.options, ...m.options }, klasses: m.klasses ?? {}, - objects: m.objects ?? {}, - variables: m.variables ?? {}, - methodCalls: m.methodCalls ?? {}, - }); + steps: stepsOf(m).map(migrateInlineStrings), + }; }; diff --git a/packages/java-memory-playground/src/index.css b/packages/java-memory-playground/src/index.css index 6d77590..a149d7e 100644 --- a/packages/java-memory-playground/src/index.css +++ b/packages/java-memory-playground/src/index.css @@ -364,3 +364,25 @@ path.react-flow__edge-path:hover { .java-memory-playground input.inline-string__input { margin-left: 0; } + +.step-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + font-size: 14px; +} + +.step-bar__count { + min-width: 56px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.java-memory-playground input.step-bar__label { + width: 180px; +} + +.step-bar__label-text { + padding: 0 4px; +} diff --git a/packages/java-memory-playground/src/index.ts b/packages/java-memory-playground/src/index.ts index dd10e65..0dac463 100644 --- a/packages/java-memory-playground/src/index.ts +++ b/packages/java-memory-playground/src/index.ts @@ -7,13 +7,14 @@ export { MemoryView } from "./MemoryView"; export { ConfigView } from "./ConfigView"; export { KeyboardShortcuts, defaultKeyBindings } from "./KeyboardShortcuts"; export { InlineString } from "./InlineString"; +export { StepBar } from "./StepBar"; export { createMemoryStore, setPersistence, isPersistenceEnabled, } from "./store"; -export type { RFState, Route, MemoryStore } from "./store"; +export type { RFState, Route, MemoryStore, StoreStep } from "./store"; export { useStore, useMemoryStore, @@ -42,6 +43,7 @@ export { } from "./memory"; export type { Memory, + Step, Obj, Variable, MethodCall, diff --git a/packages/java-memory-playground/src/memory.ts b/packages/java-memory-playground/src/memory.ts index a677f77..8be9185 100644 --- a/packages/java-memory-playground/src/memory.ts +++ b/packages/java-memory-playground/src/memory.ts @@ -99,6 +99,21 @@ export interface MethodCalls { [key: number]: MethodCall; } +/** + * One state of the diagram: the heap, the roots and the call stack at a single + * moment. A diagram is a sequence of these, so that a stack can be shown doing + * the thing that makes it a stack — growing and shrinking over time. + */ +export type Step = { + /** Shown in the step bar, e.g. "3: head = head.next". */ + label?: string; + /** The teacher's note about what this step did. */ + note?: string; + objects: Objs; + variables: Variables; + methodCalls: MethodCalls; +}; + export type Memory = { viewport: { x: number; @@ -119,11 +134,23 @@ export type Memory = { * object like any other — `==` versus `.equals()`, the string pool. */ inlineStrings?: boolean; + /** Hide the step bar, for lessons that are about one picture. */ + hideSteps?: boolean; }; klasses: Klasses; - objects: Objs; - variables: Variables; - methodCalls: MethodCalls; + /** + * The steps of the diagram. `parseMemory` always fills this in, from the + * single-state fields below when a diagram predates stepping. + */ + steps?: Step[]; + /** + * The state of a one-step diagram. Still written for those, so that a link to + * a single picture stays readable by older versions, and still read from + * every diagram saved before steps existed. + */ + objects?: Objs; + variables?: Variables; + methodCalls?: MethodCalls; }; export const initialMemory: Memory = { diff --git a/packages/java-memory-playground/src/store.test.ts b/packages/java-memory-playground/src/store.test.ts index 7000164..08ba721 100644 --- a/packages/java-memory-playground/src/store.test.ts +++ b/packages/java-memory-playground/src/store.test.ts @@ -33,24 +33,24 @@ describe("createMemoryStore", () => { a.getState().loadMemory(emptyMemory); - expect(a.getState().nodes).toHaveLength(0); - expect(b.getState().nodes.length).toBeGreaterThan(0); + expect(a.getState().getNodes()).toHaveLength(0); + expect(b.getState().getNodes().length).toBeGreaterThan(0); }); test("holds the diagram as nodes and edges, not only on save", () => { const store = createMemoryStore(false); // The initial memory has one method call and four objects. - expect(store.getState().nodes.length).toBe( - Object.keys(initialMemory.objects).length + - Object.keys(initialMemory.methodCalls).length, + expect(store.getState().getNodes().length).toBe( + Object.keys(initialMemory.objects!).length + + Object.keys(initialMemory.methodCalls!).length, ); - expect(store.getState().getMemory().objects).toEqual(initialMemory.objects); + expect(store.getState().getMemory().objects!).toEqual(initialMemory.objects!); }); test("a node moved on the canvas is in the memory right away", () => { const store = createMemoryStore(false); - const target = store.getState().nodes.find((n) => n.type === "object")!; + const target = store.getState().getNodes().find((n: any) => n.type === "object")!; store.getState().onNodesChange([ { @@ -61,7 +61,7 @@ describe("createMemoryStore", () => { ]); // No save call in between — the store is the source of truth. - expect(store.getState().getMemory().objects[target.id].position).toEqual({ + expect(store.getState().getMemory().objects![target.id].position).toEqual({ x: 999, y: 111, }); @@ -69,7 +69,7 @@ describe("createMemoryStore", () => { test("survives switching to the config view and back", () => { const store = createMemoryStore(false); - const target = store.getState().nodes.find((n) => n.type === "object")!; + const target = store.getState().getNodes().find((n: any) => n.type === "object")!; store .getState() @@ -79,7 +79,7 @@ describe("createMemoryStore", () => { store.getState().setRoute("config"); store.getState().setRoute("view"); - expect(store.getState().getMemory().objects[target.id].position).toEqual({ + expect(store.getState().getMemory().objects![target.id].position).toEqual({ x: 42, y: 42, }); @@ -110,7 +110,7 @@ describe("createMemoryStore", () => { const store = createMemoryStore(true); - expect(store.getState().getMemory().objects).toEqual(initialMemory.objects); + expect(store.getState().getMemory().objects!).toEqual(initialMemory.objects!); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); @@ -163,7 +163,7 @@ describe("createMemoryStore", () => { "ListNode", "Message", ]); - const objects = store.getState().getMemory().objects; + const objects = store.getState().getMemory().objects!; const strings = Object.values(objects).filter( (o) => o.klass === STRING_KLASS, ); @@ -175,23 +175,129 @@ describe("createMemoryStore", () => { // Quotes were decoration typed into the old value field, not data. expect(strings.map((s) => s.literal)).toContain("mike"); expect(strings.map((s) => s.literal)).toContain("Hallo!"); - expect(Object.keys(store.getState().getMemory().variables)).toHaveLength(2); + expect(Object.keys(store.getState().getMemory().variables!)).toHaveLength(2); + }); +}); + +describe("steps", () => { + test("a diagram without steps is a one-step story", () => { + const store = createMemoryStore(false); + + expect(store.getState().steps).toHaveLength(1); + expect(store.getState().currentStep).toBe(0); + }); + + test("adding a step copies the one on screen and moves to it", () => { + const store = createMemoryStore(false); + const before = store.getState().getNodes().length; + + store.getState().addStep(); + + expect(store.getState().steps).toHaveLength(2); + expect(store.getState().currentStep).toBe(1); + expect(store.getState().getNodes()).toHaveLength(before); + }); + + test("editing a step leaves the others alone", () => { + const store = createMemoryStore(false); + store.getState().addStep(); + store.getState().setNodes([]); + + expect(store.getState().getNodes()).toHaveLength(0); + store.getState().goToStep(0); + expect(store.getState().getNodes().length).toBeGreaterThan(0); + }); + + test("moving a node moves it in every step", () => { + const store = createMemoryStore(false); + store.getState().addStep(); + const target = store.getState().getNodes().find((n: any) => n.type === "object")!; + + store + .getState() + .onNodesChange([ + { id: target.id, type: "position", position: { x: 500, y: 500 } }, + ]); + + // Layout belongs to the diagram, so scrubbing does not make things jump. + store.getState().goToStep(0); + expect( + store.getState().getNodes().find((n: any) => n.id === target.id)!.position, + ).toEqual({ x: 500, y: 500 }); + }); + + test("goToStep stays inside the story", () => { + const store = createMemoryStore(false); + store.getState().addStep(); + + store.getState().goToStep(99); + expect(store.getState().currentStep).toBe(1); + store.getState().goToStep(-5); + expect(store.getState().currentStep).toBe(0); + }); + + test("the last step cannot be deleted", () => { + const store = createMemoryStore(false); + + store.getState().deleteStep(0); + expect(store.getState().steps).toHaveLength(1); + }); + + test("deleting a step keeps the cursor in range", () => { + const store = createMemoryStore(false); + store.getState().addStep(); + store.getState().deleteStep(1); + + expect(store.getState().steps).toHaveLength(1); + expect(store.getState().currentStep).toBe(0); + }); + + test("a one-step diagram is still written in the old shape", () => { + const store = createMemoryStore(false); + const memory = store.getState().getMemory(); + + // So that a link to a single picture stays readable by older versions. + expect(memory.steps).toBeUndefined(); + expect(memory.objects).toBeDefined(); + }); + + test("a multi-step diagram round-trips through the URL", async () => { + const location = stubLocation(); + + const writer = createMemoryStore(true); + writer.getState().addStep(); + writer.getState().setNodes([]); + writer.getState().setStepLabel(1, "everything returned"); + + expect(writer.getState().getMemory().steps).toHaveLength(2); + + // Writes are throttled, so wait for the trailing one to land. + await new Promise((resolve) => setTimeout(resolve, 400)); + location.hash = "#" + location.hash.replace(/^#/, ""); + const reader = createMemoryStore(true); + + expect(reader.getState().steps).toHaveLength(2); + expect(reader.getState().steps[1].label).toBe("everything returned"); + expect(reader.getState().steps[1].nodes).toHaveLength(0); + expect(reader.getState().steps[0].nodes.length).toBeGreaterThan(0); + // A reader always starts at the beginning of the story. + expect(reader.getState().currentStep).toBe(0); }); }); describe("undo/redo", () => { test("undoes an edit to the diagram", () => { const store = createMemoryStore(false); - const before = store.getState().nodes.length; + const before = store.getState().getNodes().length; store.getState().setNodes((nodes) => nodes.slice(1)); - expect(store.getState().nodes.length).toBe(before - 1); + expect(store.getState().getNodes().length).toBe(before - 1); store.temporal.getState().undo(); - expect(store.getState().nodes.length).toBe(before); + expect(store.getState().getNodes().length).toBe(before); store.temporal.getState().redo(); - expect(store.getState().nodes.length).toBe(before - 1); + expect(store.getState().getNodes().length).toBe(before - 1); }); test("does not record navigation as an undo step", () => { @@ -199,8 +305,12 @@ describe("undo/redo", () => { store.getState().setRoute("config"); store.getState().selectNodeId("@33"); + store.getState().addStep(); + store.getState().goToStep(0); - expect(store.temporal.getState().pastStates).toHaveLength(0); + // Adding a step changes the story, so that is undoable; walking through it + // is not. + expect(store.temporal.getState().pastStates).toHaveLength(1); }); test("each playground has its own history", () => { diff --git a/packages/java-memory-playground/src/store.ts b/packages/java-memory-playground/src/store.ts index e3cc6c9..1df17fb 100644 --- a/packages/java-memory-playground/src/store.ts +++ b/packages/java-memory-playground/src/store.ts @@ -7,8 +7,8 @@ import { persist, StateStorage, createJSONStorage } from "zustand/middleware"; import { createStore } from "zustand/vanilla"; import { getEdgesAndNodes, getMemory } from "./getEdgesAndNodes"; -import { parseMemory } from "./helper"; -import { Memory, initialMemory } from "./memory"; +import { parseMemory, stepsOf } from "./helper"; +import { Memory, Step, initialMemory } from "./memory"; import { deserializeState, serializeState } from "./serde"; import { Translations, @@ -20,6 +20,14 @@ import { CustomEdgeType, CustomNodeType } from "./types"; export type Route = "view" | "config"; +/** One step, in the shape React Flow wants. */ +export type StoreStep = { + label?: string; + note?: string; + nodes: CustomNodeType[]; + edges: CustomEdgeType[]; +}; + type Updater = T[] | ((current: T[]) => T[]); export type RFState = { @@ -30,8 +38,11 @@ export type RFState = { // Core data. The diagram lives here rather than in React Flow's local state, // so it survives switching to the config view and is never silently lost. - nodes: CustomNodeType[]; - edges: CustomEdgeType[]; + // + // A diagram is a sequence of steps; `currentStep` is the one on screen and + // the one every edit applies to. + steps: StoreStep[]; + currentStep: number; klasses: Memory["klasses"]; options: Memory["options"]; viewport: Viewport; @@ -45,6 +56,18 @@ export type RFState = { // Actions save: () => void; + goToStep: (index: number) => void; + addStep: () => void; + deleteStep: (index: number) => void; + setStepLabel: (index: number, label: string) => void; + /** The nodes and edges of the step on screen. */ + getNodes: () => CustomNodeType[]; + getEdges: () => CustomEdgeType[]; + /** Reconciles every step's objects with a new set of class definitions. */ + applyKlasses: ( + klasses: Memory["klasses"], + options: Memory["options"], + ) => void; setRoute: (route: Route) => void; selectNodeId: (nodeId: string) => void; setDefaultLanguage: (language: string) => void; @@ -127,7 +150,21 @@ const createHashStorage = (enabled: boolean): StateStorage => { }; }; -const initialNodesAndEdges = getEdgesAndNodes(initialMemory); +const toStoreStep = (step: Step): StoreStep => ({ + label: step.label, + note: step.note, + ...getEdgesAndNodes(step), +}); + +const initialSteps: StoreStep[] = [toStoreStep(initialMemory as Step)]; + +/** Replaces the step at `index`, leaving the rest of the story alone. */ +const withStep = ( + steps: StoreStep[], + index: number, + update: (step: StoreStep) => StoreStep, +): StoreStep[] => + steps.map((step, i) => (i === index ? update(step) : step)); /** * Strips the fields React Flow maintains itself. What is left is the part of a @@ -154,32 +191,124 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => selectedNodeId: "", persistence, - nodes: initialNodesAndEdges.nodes, - edges: initialNodesAndEdges.edges, + steps: initialSteps, + currentStep: 0, klasses: initialMemory.klasses, options: initialMemory.options, viewport: initialMemory.viewport, saveCount: 0, save: () => set({ saveCount: get().saveCount + 1 }), + + getNodes: () => get().steps[get().currentStep]?.nodes ?? [], + getEdges: () => get().steps[get().currentStep]?.edges ?? [], + + goToStep: (index) => + set({ + currentStep: Math.min( + Math.max(index, 0), + get().steps.length - 1, + ), + }), + + addStep: () => { + const { steps, currentStep } = get(); + // A new step starts as a copy of the one on screen: a trace is + // authored by duplicating and then changing what the next line did. + const source = steps[currentStep]; + const copy: StoreStep = { + nodes: source.nodes.map( + (n) => ({ ...n, data: { ...n.data } }) as CustomNodeType, + ), + edges: source.edges.map((e) => ({ ...e })), + }; + set({ + steps: [ + ...steps.slice(0, currentStep + 1), + copy, + ...steps.slice(currentStep + 1), + ], + currentStep: currentStep + 1, + }); + }, + + deleteStep: (index) => { + const { steps, currentStep } = get(); + // A diagram always has at least one step to show. + if (steps.length <= 1) return; + const next = steps.filter((_, i) => i !== index); + set({ + steps: next, + currentStep: Math.min(currentStep, next.length - 1), + }); + }, + + setStepLabel: (index, label) => + set({ + steps: withStep(get().steps, index, (step) => ({ + ...step, + label: label || undefined, + })), + }), setRoute: (route) => set({ route }), selectNodeId: (selectedNodeId) => set({ selectedNodeId }), setDefaultLanguage: (defaultLanguage) => set({ defaultLanguage }), - onNodesChange: (changes) => - set({ nodes: applyNodeChanges(changes, get().nodes) }), + onNodesChange: (changes) => { + const { steps, currentStep } = get(); + const moves = new Map( + changes + .filter((c) => c.type === "position" && c.position) + .map((c: any) => [c.id, c.position]), + ); + + set({ + steps: steps.map((step, i) => { + if (i === currentStep) { + return { + ...step, + nodes: applyNodeChanges(changes, step.nodes), + }; + } + // Layout is a property of the diagram, not of one moment in it: + // dragging a node moves it in every step it appears in, so the + // picture does not jump around while scrubbing. + if (moves.size === 0) return step; + return { + ...step, + nodes: step.nodes.map((n) => + moves.has(n.id) + ? { ...n, position: moves.get(n.id)! } + : n, + ), + }; + }), + }); + }, + onEdgesChange: (changes) => - set({ edges: applyEdgeChanges(changes, get().edges) }), + set({ + steps: withStep(get().steps, get().currentStep, (step) => ({ + ...step, + edges: applyEdgeChanges(changes, step.edges), + })), + }), // The updater form keeps the call sites identical to React Flow's // useNodesState/useEdgesState that these replaced. setNodes: (nodes) => set({ - nodes: typeof nodes === "function" ? nodes(get().nodes) : nodes, + steps: withStep(get().steps, get().currentStep, (step) => ({ + ...step, + nodes: typeof nodes === "function" ? nodes(step.nodes) : nodes, + })), }), setEdges: (edges) => set({ - edges: typeof edges === "function" ? edges(get().edges) : edges, + steps: withStep(get().steps, get().currentStep, (step) => ({ + ...step, + edges: typeof edges === "function" ? edges(step.edges) : edges, + })), }), setKlasses: (klasses) => set({ klasses }), @@ -187,10 +316,12 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => setViewport: (viewport) => set({ viewport }), loadMemory: (memory) => { - const { nodes, edges } = getEdgesAndNodes(memory); + // Through stepsOf, so that a caller may hand over a diagram in + // either shape — a one-step diagram still has no `steps` key. + const steps = stepsOf(memory).map(toStoreStep); set({ - nodes, - edges, + steps: steps.length > 0 ? steps : initialSteps, + currentStep: 0, klasses: memory.klasses, options: memory.options, viewport: memory.viewport, @@ -199,14 +330,65 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => getMemory: () => { const state = get(); + const steps = state.steps.map((step) => ({ + ...(step.label ? { label: step.label } : {}), + ...(step.note ? { note: step.note } : {}), + ...getMemory(step.edges, step.nodes), + })); + + // A one-step diagram is written in the shape it has always had, so + // a link to a single picture stays readable by older versions. + if (steps.length === 1) { + return { + viewport: state.viewport, + options: state.options, + klasses: state.klasses, + ...steps[0], + } as Memory; + } + return { viewport: state.viewport, options: state.options, klasses: state.klasses, - ...getMemory(state.edges, state.nodes), + steps, } as Memory; }, + applyKlasses: (klasses, options) => { + // A class definition belongs to the whole diagram, so adding or + // removing an attribute has to reach every step's objects. + const reconcile = (node: CustomNodeType): CustomNodeType => { + if (node.type !== "object") return node; + const definition = klasses[node.data.klass]; + if (!definition) return node; + + const names = Object.keys(definition.attributes); + const attributes = { ...node.data.attributes }; + names.forEach((name) => { + if (!attributes[name]) { + attributes[name] = { + dataType: definition.attributes[name], + value: undefined, + }; + } + }); + Object.keys(attributes).forEach((name) => { + if (!names.includes(name)) delete attributes[name]; + }); + return { ...node, data: { ...node.data, attributes } }; + }; + + set({ + klasses, + options, + steps: get().steps.map((step) => ({ + ...step, + nodes: step.nodes.map(reconcile), + })), + }); + }, + getLanguage: () => { const language = get().defaultLanguage; if (language && language !== "auto" && translations[language]) { @@ -223,8 +405,12 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => // flag) is stripped too, so it neither creates history entries nor // gets restored on undo. partialize: (state: RFState) => ({ - nodes: state.nodes.map(undoableNode), - edges: state.edges, + steps: state.steps.map((step) => ({ + label: step.label, + note: step.note, + nodes: step.nodes.map(undoableNode), + edges: step.edges, + })), klasses: state.klasses, options: state.options, }), @@ -260,11 +446,11 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => const memory = parseMemory(stored); if (!memory) return current; - const { nodes, edges } = getEdgesAndNodes(memory); + const steps = stepsOf(memory).map(toStoreStep); return { ...current, - nodes, - edges, + steps: steps.length > 0 ? steps : current.steps, + currentStep: 0, klasses: memory.klasses, options: memory.options, viewport: memory.viewport ?? current.viewport, diff --git a/packages/java-memory-playground/src/translations.ts b/packages/java-memory-playground/src/translations.ts index 6ef08cc..a70950a 100644 --- a/packages/java-memory-playground/src/translations.ts +++ b/packages/java-memory-playground/src/translations.ts @@ -16,6 +16,15 @@ export interface Translations { callMethod: string; declareGlobalVariable: string; + // Steps + previousStep: string; + nextStep: string; + addStep: string; + addStepHint: string; + deleteStep: string; + stepLabel: string; + stepLabelPlaceholder: string; + // Nodes declareLocalVariable: string; returnMethod: string; @@ -61,6 +70,7 @@ export interface Translations { disableGarbageCollector: string; createNewOnEdgeDrop: string; inlineStrings: string; + hideSteps: string; }; } @@ -78,6 +88,14 @@ const en: Translations = { callMethod: "Call Method", declareGlobalVariable: "Declare Global Variable", + previousStep: "Previous step", + nextStep: "Next step", + addStep: "Add step", + addStepHint: "Duplicate this step and continue from it", + deleteStep: "Delete step", + stepLabel: "Step label", + stepLabelPlaceholder: "What happens here?", + declareLocalVariable: "Declare Local Variable", returnMethod: "Return", @@ -121,6 +139,7 @@ const en: Translations = { disableGarbageCollector: "Disable garbage collector", createNewOnEdgeDrop: "Create a new object when an edge is dropped", inlineStrings: "Show String values inside their object", + hideSteps: "Hide the step bar", }, }; @@ -138,6 +157,14 @@ const de: Translations = { callMethod: "Methode aufrufen", declareGlobalVariable: "Globale Variable deklarieren", + previousStep: "Vorheriger Schritt", + nextStep: "Nächster Schritt", + addStep: "Schritt hinzufügen", + addStepHint: "Diesen Schritt kopieren und dort weitermachen", + deleteStep: "Schritt löschen", + stepLabel: "Beschriftung", + stepLabelPlaceholder: "Was passiert hier?", + declareLocalVariable: "Lokale Variable deklarieren", returnMethod: "Zurückkehren", @@ -181,6 +208,7 @@ const de: Translations = { disableGarbageCollector: "Garbage Collector deaktivieren", createNewOnEdgeDrop: "Neues Objekt erstellen, wenn eine Kante abgelegt wird", inlineStrings: "String-Werte im Objekt anzeigen", + hideSteps: "Schrittleiste ausblenden", }, }; diff --git a/packages/web-component/README.md b/packages/web-component/README.md index bdd6e1d..dc14997 100644 --- a/packages/web-component/README.md +++ b/packages/web-component/README.md @@ -63,6 +63,7 @@ java-memory-playground { | `language` | string | `en`, `de`, or `auto` to follow the browser. Defaults to the browser language. | | `persistence` | boolean | Mirror the diagram into `location.hash`. Off by default — an embedded playground should not take over the page URL. | | `key-bindings`| JSON | Overrides for the keyboard shortcuts, e.g. `{"save":{"key":"e","ctrl":true}}`. | +| `step` | number | The step to show, zero based. Set it to drive the diagram from your page. | Attributes can be updated at any time; setting `memory` again replaces the diagram. @@ -78,6 +79,7 @@ diagram. | `disableGarbageCollector` | Hide the garbage collector button. | | `createNewOnEdgeDrop` | Create a new object when an edge is dropped on empty canvas. | | `inlineStrings` | Draw String values inside the object that references them instead of as their own heap box. On by default. | +| `hideSteps` | Hide the step bar, for a lesson that is about one picture. | ## Events @@ -95,6 +97,33 @@ playground.addEventListener("change", (event) => { }); ``` +## Steps + +A diagram can be a sequence of steps rather than a single picture, which is what +lets it show a frame being pushed and popped, or an object becoming garbage the +moment the last reference to it goes away. + +```json +{ + "klasses": { "Node": { "attributes": { "next": "Node" } } }, + "steps": [ + { "label": "start", "objects": {}, "variables": {}, "methodCalls": {} }, + { "label": "insert is called", "objects": {}, "variables": {}, "methodCalls": {} } + ] +} +``` + +Set the `step` attribute to drive it from your page, and listen for `stepchange` +to follow along — the two together let prose and diagram stay in sync: + +```javascript +playground.setAttribute("step", "2"); +playground.addEventListener("stepchange", (e) => highlight(e.detail)); +``` + +A diagram with a single state needs no `steps` key; it is read as a one-step +story, and saved back in the same shape. + ## Undo, redo and keyboard shortcuts The toolbar has undo/redo buttons, and the component listens for: @@ -152,5 +181,5 @@ can host as many playgrounds as it needs. pnpm build # writes dist/index.umd.js and dist/index.css ``` -`index.html`, `multi.html` and `de.html` in this package are demo pages for the -built bundle — serve the package directory and open them in a browser. +`index.html`, `multi.html`, `de.html`, `strings.html` and `steps.html` in this +package are demo pages for the built bundle — serve the package directory and open them in a browser. diff --git a/packages/web-component/src/index.ts b/packages/web-component/src/index.ts index 129e7ad..7e3c666 100644 --- a/packages/web-component/src/index.ts +++ b/packages/web-component/src/index.ts @@ -17,11 +17,14 @@ const MemoryPlaygroundWC = r2wc(MemoryPlayground, { language: "string", persistence: "boolean", keyBindings: "json", + step: "number", }, - // r2wc keys events by prop name: it passes an `onChange` prop that dispatches - // a `change` CustomEvent on this element, with the memory as `detail`. + // r2wc keys events by prop name and dispatches on this element: `onChange` + // becomes a `change` event carrying the memory, `onStepChange` a `stepchange` + // event carrying the step index. events: { onChange: {}, + onStepChange: {}, }, }); diff --git a/packages/web-component/steps.html b/packages/web-component/steps.html new file mode 100644 index 0000000..f72008d --- /dev/null +++ b/packages/web-component/steps.html @@ -0,0 +1,112 @@ + + + + + + + Stepping through a trace + + + + + + + + + + + + + From 855fa797ea77a80fbde760ffc3ce8011d8056c33 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:29:23 +0000 Subject: [PATCH 06/27] Split the playground into a student's and a teacher's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuring classes and authoring the steps of a trace are the teacher's work, and having them on screen while a student works through a diagram is noise. Following how learningmap separates its viewer from its editor: - MemoryPlayground and are the student's: the whole diagram, every edit, and the steps of a trace to walk through. - MemoryPlaygroundEditor and add class configuration and step authoring on top. - The standalone app serves the student's playground, and the teacher's at `?edit`. A `/edit` path works too, for whenever a rewrite rule exists; the app is served statically today, so only the query flag works without one. The split is about which tools are on screen, not about what a student may touch: a student still builds objects, connects references, walks the steps and runs the garbage collector. `setRoute` refuses to enter the configuration outside edit mode, so the shortcut cannot reach it either — the route is closed rather than merely hidden. This also subsumes the step-editing option I had been about to add: Add step, Delete step and the step label are the teacher's, while walking through a trace is everyone's, and a student sees the step label as text rather than a field. Mode is per store, so a page can host a student's and a teacher's playground at once without them sharing anything, which modes.html demonstrates. Verified in Chromium: both elements upgrade; the student has no Config and no step authoring but keeps the sidebar, Save, garbage collector and step navigation; the teacher has all of it; and in the app Ctrl+, opens the configuration at ?edit and does nothing on the student's route. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/afraid-bats-repeat.md | 15 ++++ README.md | 10 +++ packages/java-memory-playground/README.md | 22 ++++- .../src/KeyboardShortcuts.tsx | 3 + .../src/MemoryPlayground.tsx | 22 ++++- .../java-memory-playground/src/MemoryView.tsx | 8 +- packages/java-memory-playground/src/index.ts | 14 +++- .../java-memory-playground/src/store.test.ts | 35 ++++++++ packages/java-memory-playground/src/store.ts | 23 ++++- .../src/storeContext.tsx | 11 ++- packages/web-component/README.md | 15 +++- packages/web-component/modes.html | 83 +++++++++++++++++++ packages/web-component/src/index.ts | 47 +++++++---- platforms/web/src/App.tsx | 24 +++++- 14 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 .changeset/afraid-bats-repeat.md create mode 100644 packages/web-component/modes.html diff --git a/.changeset/afraid-bats-repeat.md b/.changeset/afraid-bats-repeat.md new file mode 100644 index 0000000..b076027 --- /dev/null +++ b/.changeset/afraid-bats-repeat.md @@ -0,0 +1,15 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Split the playground into a student's and a teacher's. + +Configuring classes and authoring the steps of a trace are the teacher's work, and having them on screen while a student works through a diagram is noise. They now live in their own component and their own custom element, following how learningmap separates its viewer from its editor. + +- `MemoryPlayground` and `` are the student's: the whole diagram, every edit, and the steps of a trace to walk through. +- `MemoryPlaygroundEditor` and `` add class configuration and step authoring on top. +- The standalone app serves the student's playground, and the teacher's at `?edit` (or `/edit` where a rewrite rule exists). + +The split is about which tools are on screen, not about what a student is allowed to touch: a student still builds objects, connects references, walks the steps and runs the garbage collector. The configuration route is closed in the student's playground rather than merely hidden, so the keyboard shortcut cannot reach it either. diff --git a/README.md b/README.md index 3dc8823..df125da 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Besides the hosted app, the playground ships as two packages: - **Web component** — use it in any page, no framework required (see [packages/web-component](packages/web-component)) +There are two elements: `` for students, and +`` for teachers, which adds class configuration +and step authoring on top. + ```html @@ -79,6 +83,12 @@ cd java-memory-playground pnpm install ``` +### Routes + +The app at [jmp.openpatch.org](https://jmp.openpatch.org) is the student's +playground. Append `?edit` for the teacher's, which adds class configuration and +step authoring. + ### Development Start the standalone app with hot reload: diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md index bbbae14..54accd9 100644 --- a/packages/java-memory-playground/README.md +++ b/packages/java-memory-playground/README.md @@ -14,6 +14,25 @@ npm install @openpatch/java-memory-playground `react` and `react-dom` are peer dependencies. +## Two playgrounds + +`MemoryPlayground` is the student's: the whole diagram, every edit, and the steps +of a trace to walk through. + +`MemoryPlaygroundEditor` is the teacher's: all of that, plus configuring classes +and options and authoring the steps. + +```tsx +import { + MemoryPlayground, // student + MemoryPlaygroundEditor, // teacher +} from "@openpatch/java-memory-playground"; +``` + +Both take the same props. The split is about which tools are on screen, not +about what a student is allowed to touch — a student still builds objects, +connects references and runs the garbage collector. + ## Usage ```tsx @@ -58,6 +77,7 @@ export function Example() { | `step` | `number` | The step to show, zero based. Set it to drive the diagram from the page around it. | | `onStepChange`| `(step: number) => void` | Called whenever the shown step changes. | | `onChange` | `(memory: Memory) => void` | Called when the user presses **Save**. | +| `mode` | `"view" \| "edit"` | Which tools to show. Prefer picking the component; this is what it sets. | Every `MemoryPlayground` creates its own store, so several playgrounds can live on the same page without sharing state. @@ -129,7 +149,7 @@ const { undo, redo, canUndo, canRedo, clear } = useUndoRedo(); | `Ctrl/Cmd + S` | Save | | `Ctrl/Cmd + Z` | Undo | | `Ctrl/Cmd + Y` | Redo | -| `Ctrl/Cmd + ,` | Toggle the config view | +| `Ctrl/Cmd + ,` | Toggle the config view (editor only) | | `Ctrl/Cmd + +` | Zoom in | | `Ctrl/Cmd + -` | Zoom out | | `Ctrl/Cmd + 0` | Reset zoom | diff --git a/packages/java-memory-playground/src/KeyboardShortcuts.tsx b/packages/java-memory-playground/src/KeyboardShortcuts.tsx index dc96134..be23abb 100644 --- a/packages/java-memory-playground/src/KeyboardShortcuts.tsx +++ b/packages/java-memory-playground/src/KeyboardShortcuts.tsx @@ -57,6 +57,7 @@ export const KeyboardShortcuts = ({ const save = useStore((state) => state.save); const route = useStore((state) => state.route); const setRoute = useStore((state) => state.setRoute); + const mode = useStore((state) => state.mode); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -73,6 +74,8 @@ export const KeyboardShortcuts = ({ e.preventDefault(); redo(); } else if (matchesKeyBinding(e, keyBindings.toggleConfig)) { + // The student's playground has no configuration to toggle into. + if (mode !== "edit") return; e.preventDefault(); setRoute(route === "config" ? "view" : "config"); } else if (matchesKeyBinding(e, keyBindings.zoomIn)) { diff --git a/packages/java-memory-playground/src/MemoryPlayground.tsx b/packages/java-memory-playground/src/MemoryPlayground.tsx index cf9d4f6..616d22d 100644 --- a/packages/java-memory-playground/src/MemoryPlayground.tsx +++ b/packages/java-memory-playground/src/MemoryPlayground.tsx @@ -9,7 +9,7 @@ import { KeyboardShortcuts } from "./KeyboardShortcuts"; import { MemoryView } from "./MemoryView"; import { parseMemory } from "./helper"; import { Memory } from "./memory"; -import { RFState } from "./store"; +import { PlaygroundMode, RFState } from "./store"; import useStore, { StoreProvider } from "./storeContext"; import { KeyBindings } from "./types"; import { DnDProvider } from "./useDnD"; @@ -46,6 +46,13 @@ export interface MemoryPlaygroundProps { step?: number; /** Called with the step index whenever the shown step changes. */ onStepChange?: (step: number) => void; + /** + * Who this playground is for. `view`, the default, is the student's: the whole + * diagram and every edit, but no class configuration and no step authoring. + * `edit` adds those. Prefer the `MemoryPlaygroundEditor` component, which is + * this with `mode` already set. + */ + mode?: PlaygroundMode; /** * Called with the full memory whenever the user saves. The web component * wrapper uses this to dispatch its `change` event. @@ -150,10 +157,11 @@ function Playground({ */ export function MemoryPlayground({ persistence, + mode, ...props }: MemoryPlaygroundProps) { return ( - + @@ -161,4 +169,14 @@ export function MemoryPlayground({ ); } +/** + * The playground with the teacher's tools: everything a student can do, plus + * configuring classes and options and authoring the steps of a trace. + */ +export function MemoryPlaygroundEditor( + props: Omit, +) { + return ; +} + export default MemoryPlayground; diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index 5416b2e..98bfb94 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -55,6 +55,7 @@ const selector = (state: RFState) => ({ onNodesChange: state.onNodesChange, onEdgesChange: state.onEdgesChange, save: state.save, + mode: state.mode, t: state.getTranslations(), }); @@ -95,6 +96,7 @@ export const MemoryView = () => { onNodesChange, onEdgesChange, save, + mode, t, } = useStore(useShallow(selector)); const { screenToFlowPosition } = useReactFlow(); @@ -741,13 +743,15 @@ export const MemoryView = () => { - + {mode === "edit" && ( + + )}
{!options.hideSteps && (
- +
)} diff --git a/packages/java-memory-playground/src/index.ts b/packages/java-memory-playground/src/index.ts index 0dac463..103e76b 100644 --- a/packages/java-memory-playground/src/index.ts +++ b/packages/java-memory-playground/src/index.ts @@ -1,6 +1,8 @@ -import MemoryPlayground from "./MemoryPlayground"; +import MemoryPlayground, { + MemoryPlaygroundEditor, +} from "./MemoryPlayground"; -export { MemoryPlayground }; +export { MemoryPlayground, MemoryPlaygroundEditor }; export type { MemoryPlaygroundProps } from "./MemoryPlayground"; export { MemoryView } from "./MemoryView"; @@ -14,7 +16,13 @@ export { setPersistence, isPersistenceEnabled, } from "./store"; -export type { RFState, Route, MemoryStore, StoreStep } from "./store"; +export type { + RFState, + Route, + PlaygroundMode, + MemoryStore, + StoreStep, +} from "./store"; export { useStore, useMemoryStore, diff --git a/packages/java-memory-playground/src/store.test.ts b/packages/java-memory-playground/src/store.test.ts index 08ba721..dc9f356 100644 --- a/packages/java-memory-playground/src/store.test.ts +++ b/packages/java-memory-playground/src/store.test.ts @@ -179,6 +179,41 @@ describe("createMemoryStore", () => { }); }); +describe("mode", () => { + test("a playground is the student's unless asked otherwise", () => { + expect(createMemoryStore(false).getState().mode).toBe("view"); + expect(createMemoryStore(false, "edit").getState().mode).toBe("edit"); + }); + + test("a student's playground cannot be routed into the configuration", () => { + const store = createMemoryStore(false, "view"); + + store.getState().setRoute("config"); + + expect(store.getState().route).toBe("view"); + }); + + test("the editor can", () => { + const store = createMemoryStore(false, "edit"); + + store.getState().setRoute("config"); + expect(store.getState().route).toBe("config"); + + store.getState().setRoute("view"); + expect(store.getState().route).toBe("view"); + }); + + test("mode is per playground, like everything else", () => { + const student = createMemoryStore(false, "view"); + const teacher = createMemoryStore(false, "edit"); + + teacher.getState().setRoute("config"); + + expect(student.getState().route).toBe("view"); + expect(teacher.getState().route).toBe("config"); + }); +}); + describe("steps", () => { test("a diagram without steps is a one-step story", () => { const store = createMemoryStore(false); diff --git a/packages/java-memory-playground/src/store.ts b/packages/java-memory-playground/src/store.ts index 1df17fb..876dfd6 100644 --- a/packages/java-memory-playground/src/store.ts +++ b/packages/java-memory-playground/src/store.ts @@ -20,6 +20,15 @@ import { CustomEdgeType, CustomNodeType } from "./types"; export type Route = "view" | "config"; +/** + * Who the playground is for. + * + * `view` is the student's playground: the whole diagram, every edit, steps to + * walk through. `edit` adds the teacher's tools on top — configuring classes + * and options, and authoring the steps of a trace. + */ +export type PlaygroundMode = "view" | "edit"; + /** One step, in the shape React Flow wants. */ export type StoreStep = { label?: string; @@ -32,6 +41,7 @@ type Updater = T[] | ((current: T[]) => T[]); export type RFState = { route: Route; + mode: PlaygroundMode; selectedNodeId: string; /** Whether this store mirrors its memory into `location.hash`. */ persistence: boolean; @@ -182,12 +192,16 @@ const undoableNode = (node: CustomNodeType) => { * One store per `MemoryPlayground` instance, so that a page can host several * playgrounds without them overwriting each other's diagrams. */ -export const createMemoryStore = (persistence: boolean = defaultPersistence) => { +export const createMemoryStore = ( + persistence: boolean = defaultPersistence, + mode: PlaygroundMode = "view", +) => { const store = createStore()( persist( temporal( (set, get) => ({ route: "view" as Route, + mode, selectedNodeId: "", persistence, @@ -250,7 +264,12 @@ export const createMemoryStore = (persistence: boolean = defaultPersistence) => label: label || undefined, })), }), - setRoute: (route) => set({ route }), + // Configuration belongs to the teacher, so a student's playground + // cannot be routed into it, by a shortcut or otherwise. + setRoute: (route) => + set({ + route: route === "config" && get().mode !== "edit" ? "view" : route, + }), selectNodeId: (selectedNodeId) => set({ selectedNodeId }), setDefaultLanguage: (defaultLanguage) => set({ defaultLanguage }), diff --git a/packages/java-memory-playground/src/storeContext.tsx b/packages/java-memory-playground/src/storeContext.tsx index ec49ae2..a9c78f1 100644 --- a/packages/java-memory-playground/src/storeContext.tsx +++ b/packages/java-memory-playground/src/storeContext.tsx @@ -2,21 +2,28 @@ import { createContext, ReactNode, useContext, useRef } from "react"; import type { TemporalState } from "zundo"; import { useStore as useZustandStore } from "zustand"; -import { createMemoryStore, MemoryStore, RFState } from "./store"; +import { + createMemoryStore, + MemoryStore, + PlaygroundMode, + RFState, +} from "./store"; const StoreContext = createContext(null); export const StoreProvider = ({ persistence, + mode, children, }: { persistence?: boolean; + mode?: PlaygroundMode; children: ReactNode; }) => { // Created once per mounted playground, never shared between instances. const storeRef = useRef(null); if (!storeRef.current) { - storeRef.current = createMemoryStore(persistence); + storeRef.current = createMemoryStore(persistence, mode); } return ( diff --git a/packages/web-component/README.md b/packages/web-component/README.md index dc14997..ee50a45 100644 --- a/packages/web-component/README.md +++ b/packages/web-component/README.md @@ -4,6 +4,17 @@ The [Java Memory Playground](https://jmp.openpatch.org) as a framework agnostic web component. Drop it into any page — plain HTML, a CMS, Hyperbook, an LMS — and visualize the Java stack and heap. +## Two elements + +`` is the student's: the whole diagram, every edit, and +the steps of a trace to walk through. + +`` is the teacher's: all of that, plus +configuring classes and options and authoring the steps. + +Both take the same attributes and fire the same events, so a page can hand the +same diagram to either. + ## Usage ```html @@ -181,5 +192,5 @@ can host as many playgrounds as it needs. pnpm build # writes dist/index.umd.js and dist/index.css ``` -`index.html`, `multi.html`, `de.html`, `strings.html` and `steps.html` in this -package are demo pages for the built bundle — serve the package directory and open them in a browser. +`index.html`, `multi.html`, `de.html`, `strings.html`, `steps.html` and +`modes.html` in this package are demo pages for the built bundle — serve the package directory and open them in a browser. diff --git a/packages/web-component/modes.html b/packages/web-component/modes.html new file mode 100644 index 0000000..a02a0e5 --- /dev/null +++ b/packages/web-component/modes.html @@ -0,0 +1,83 @@ + + + + + + + Student and teacher playgrounds + + + + + +

Student — <java-memory-playground>

+ + +

Teacher — <java-memory-playground-editor>

+ + + + + + + diff --git a/packages/web-component/src/index.ts b/packages/web-component/src/index.ts index 7e3c666..436c4bb 100644 --- a/packages/web-component/src/index.ts +++ b/packages/web-component/src/index.ts @@ -1,6 +1,7 @@ import r2wc from "@r2wc/react-to-web-component"; import { MemoryPlayground, + MemoryPlaygroundEditor, setPersistence, } from "@openpatch/java-memory-playground"; import "@openpatch/java-memory-playground/index.css"; @@ -10,22 +11,32 @@ import "@openpatch/java-memory-playground/index.css"; // attribute and leaves through the `change` event. setPersistence(false); -const MemoryPlaygroundWC = r2wc(MemoryPlayground, { - props: { - memory: "string", - options: "json", - language: "string", - persistence: "boolean", - keyBindings: "json", - step: "number", - }, - // r2wc keys events by prop name and dispatches on this element: `onChange` - // becomes a `change` event carrying the memory, `onStepChange` a `stepchange` - // event carrying the step index. - events: { - onChange: {}, - onStepChange: {}, - }, -}); +const props = { + memory: "string", + options: "json", + language: "string", + persistence: "boolean", + keyBindings: "json", + step: "number", +} as const; -customElements.define("java-memory-playground", MemoryPlaygroundWC); +// r2wc keys events by prop name and dispatches on this element: `onChange` +// becomes a `change` event carrying the memory, `onStepChange` a `stepchange` +// event carrying the step index. +const events = { + onChange: {}, + onStepChange: {}, +} as const; + +// The student's playground: the whole diagram and every edit, but no class +// configuration and no step authoring. +customElements.define( + "java-memory-playground", + r2wc(MemoryPlayground, { props, events }), +); + +// The teacher's: the same, plus configuration and authoring the steps. +customElements.define( + "java-memory-playground-editor", + r2wc(MemoryPlaygroundEditor, { props, events }), +); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index 94030ca..53c89f9 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -1,9 +1,29 @@ -import { MemoryPlayground } from "@openpatch/java-memory-playground"; +import { + MemoryPlayground, + MemoryPlaygroundEditor, +} from "@openpatch/java-memory-playground"; + +/** + * Whether this is the teacher's playground. + * + * The diagram itself lives in the hash, so the mode is kept out of it. Both a + * path and a query flag work, because the app is served statically and only the + * query flag survives without a rewrite rule. + */ +const isEditor = () => { + const { pathname, search } = window.location; + return ( + pathname.replace(/\/+$/, "").endsWith("/edit") || + new URLSearchParams(search).has("edit") + ); +}; function App() { + const Playground = isEditor() ? MemoryPlaygroundEditor : MemoryPlayground; + return (
- +
); } From 0e03f93db23c1b09c0034b5237041ea2453a2782 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:47:06 +0000 Subject: [PATCH 07/27] Mark what each step changed, and make the call stack behave like one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stack bugs first: - Return popped whichever frame you clicked, so a frame in the middle could vanish while the calls above it stayed. Only the top frame can return now; the others disable the button and say why rather than hiding it, because a call having to finish before the one below it resumes is the lesson. Returning also removes the references the frame held, which is what leaves an object unreachable for the garbage collector to find. - A new frame took `index = count of frames`, which handed out an index a surviving frame already had as soon as one in the middle was gone. It is one past the deepest frame now. Then phase 2 of stepping: walking a trace only helps if you can see what moved, so each step is marked against the one before it — a green outline for what appeared, a dashed amber one for what changed, an amber reference for one that was assigned or repointed. The first step marks nothing, because nothing has happened yet. `hideStepChanges` turns it off; `diffSteps` is exported. The comparison ignores position, since layout is shared across steps and a node cannot move between them, and it identifies a reference by the slot it leaves from so that retargeting one is a change rather than a delete and an add. Rendering the marks meant untangling the class assignment, which mutated the store's nodes during render and had the edges read those mutations back. Both now derive from one computed map. Verified in Chromium: on the five-step trace the marks follow the story — a frame appears at step 2, the reference assigned at step 4 is amber — and with three frames only the deepest has Return enabled, leaving f0 and f1 behind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/khaki-jars-shave.md | 14 ++ packages/java-memory-playground/README.md | 17 +++ .../java-memory-playground/src/ConfigView.tsx | 22 +++ .../java-memory-playground/src/MemoryView.tsx | 100 ++++++++----- .../src/MethodCallNode.tsx | 22 ++- packages/java-memory-playground/src/index.css | 35 +++++ packages/java-memory-playground/src/index.ts | 2 + packages/java-memory-playground/src/memory.ts | 5 + .../src/stepDiff.test.ts | 141 ++++++++++++++++++ .../java-memory-playground/src/stepDiff.ts | Bin 0 -> 2309 bytes .../java-memory-playground/src/store.test.ts | 44 ++++++ .../src/translations.ts | 8 + packages/web-component/README.md | 6 + 13 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 .changeset/khaki-jars-shave.md create mode 100644 packages/java-memory-playground/src/stepDiff.test.ts create mode 100644 packages/java-memory-playground/src/stepDiff.ts diff --git a/.changeset/khaki-jars-shave.md b/.changeset/khaki-jars-shave.md new file mode 100644 index 0000000..e151abb --- /dev/null +++ b/.changeset/khaki-jars-shave.md @@ -0,0 +1,14 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Mark what each step changed, and make the call stack behave like one. + +Walking a trace only helps if you can see what moved, so a step is now marked against the one before it: a green outline for what appeared, a dashed amber one for what changed, and an amber reference for one that was assigned or repointed. The first step marks nothing, because nothing has happened yet. `hideStepChanges` turns it off, and `diffSteps` is exported for the same comparison elsewhere. + +Two fixes to the stack itself: + +- Only the frame on top can return. Returning from the middle is the one thing a stack cannot do, so the button on the other frames is disabled and says why rather than disappearing. Returning now also removes the references that frame held, which is what leaves an object unreachable for the garbage collector to find. +- A new frame takes an index one past the deepest frame. Counting the frames instead handed out an index a surviving frame already had, as soon as one in the middle was gone. diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md index 54accd9..89088ca 100644 --- a/packages/java-memory-playground/README.md +++ b/packages/java-memory-playground/README.md @@ -108,10 +108,27 @@ stepping through. ``` +### What a step changed + +Walking a trace is only useful if you can see what moved, so each step marks +itself against the one before it: a green outline for what appeared, a dashed +amber one for what changed, and an amber reference for one that was assigned or +repointed. The first step of a story marks nothing, because nothing has happened +yet. Set `hideStepChanges` to turn the marking off. + +`diffSteps` is exported if you want the same comparison elsewhere. + A diagram with one step is just a picture, and is still saved in the shape it always had, so a link to a single diagram stays readable by older versions. Set `hideSteps` to hide the bar entirely. +### The call stack + +Only the frame on top of the stack can return; the others say so rather than +hiding the button, because a call having to finish before the one below it +resumes is the lesson. Returning takes the frame's references with it, which is +what leaves an object unreachable for the garbage collector to find. + ## Strings A String is a reference type, so a String value lives on the heap like any other diff --git a/packages/java-memory-playground/src/ConfigView.tsx b/packages/java-memory-playground/src/ConfigView.tsx index ef927da..d4805c1 100644 --- a/packages/java-memory-playground/src/ConfigView.tsx +++ b/packages/java-memory-playground/src/ConfigView.tsx @@ -414,6 +414,28 @@ export const ConfigView = () => { /> {t.optionLabels.inlineStrings} + diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index 98bfb94..9ad3d0e 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -21,6 +21,7 @@ import VariableNode from "./VariableNode"; import { useCallback, useState, useRef, useMemo } from "react"; import { Sidebar } from "./Sidebar"; import { StepBar } from "./StepBar"; +import { diffSteps } from "./stepDiff"; import { Attribute, builtInDataTypes, @@ -56,6 +57,7 @@ const selector = (state: RFState) => ({ onEdgesChange: state.onEdgesChange, save: state.save, mode: state.mode, + previousStep: state.steps[state.currentStep - 1], t: state.getTranslations(), }); @@ -97,6 +99,7 @@ export const MemoryView = () => { onEdgesChange, save, mode, + previousStep, t, } = useStore(useShallow(selector)); const { screenToFlowPosition } = useReactFlow(); @@ -431,7 +434,10 @@ export const MemoryView = () => { t.createMethodCall, t.methodName, (name) => { - const index = nodes.filter((n) => n.type === "method-call").length; + // One past the deepest frame. Counting the frames instead used to + // hand out an index that a surviving frame already had. + const index = + nodes.filter(isMethodCallNode).reduce((max, n) => Math.max(max, n.data.index), -1) + 1; const newNode: CustomNodeType = { id: getId(), type: "method-call", @@ -634,6 +640,40 @@ export const MemoryView = () => { }, [createNodeAtPosition]); + // How far down the call stack a node hangs, for the fading of older frames. + // Computed once rather than assigned onto the store's nodes while rendering, + // which the edges below then read back. + const stackClass = useMemo(() => { + const classes = new Map(); + nodes.forEach((n) => { + let c = ""; + if ( + previousMethodCall !== undefined && + isConnectedTo(n.id, previousMethodCall.id, nodes, edges) + ) { + c = "previous-method-call"; + } + if (n.id === previousMethodCall?.id) c = "previous-method-call"; + if ( + (lastMethodCall !== undefined && + isConnectedTo(n.id, lastMethodCall.id, nodes, edges)) || + isConnectedToVariable(n.id, nodes, edges) + ) { + c = "last-method-call"; + } + if (n.id === lastMethodCall?.id) c = "last-method-call"; + classes.set(n.id, c); + }); + return classes; + }, [nodes, edges, previousMethodCall, lastMethodCall]); + + // What this step changed. The first step of a story changed nothing. + const showChanges = !options.hideStepChanges; + const diff = useMemo( + () => diffSteps(previousStep, { nodes, edges }), + [previousStep, nodes, edges], + ); + // While Strings are inlined they are still real objects with real references // — they are only left out of the drawing, and out of it as edge targets. const { visibleNodes, visibleEdges } = useMemo(() => { @@ -658,47 +698,29 @@ export const MemoryView = () => { ref={flowRef} className="memory" nodes={visibleNodes.map((n) => { - n.className = ""; - n.deletable = options.disableGarbageCollector; - if ( - previousMethodCall !== undefined && - isConnectedTo(n.id, previousMethodCall.id, nodes, edges) - ) { - n.className = "previous-method-call"; - } - if (n.id === previousMethodCall?.id) { - n.className = "previous-method-call"; - } - if ( - (lastMethodCall !== undefined && - isConnectedTo(n.id, lastMethodCall.id, nodes, edges)) || - isConnectedToVariable(n.id, nodes, edges) - ) { - n.className = "last-method-call"; + const classes = [stackClass.get(n.id) ?? ""]; + if (showChanges) { + if (diff.added.has(n.id)) classes.push("step-added"); + else if (diff.changed.has(n.id)) classes.push("step-changed"); } - if (n.id === lastMethodCall?.id) { - n.className = "last-method-call"; - } - if (n.type === "variable") { - n.deletable = true; - } - return { ...n }; + return { + ...n, + className: classes.filter(Boolean).join(" "), + deletable: + n.type === "variable" ? true : options.disableGarbageCollector, + }; })} edges={visibleEdges.map((e) => { - const node = nodes.find((n) => n.id == e.source); - e.className = ""; - e.deletable = false; - if (node && node.className?.includes("previous-method-call")) { - e.className = "previous-method-call"; - } - if ( - (node && node.className?.includes("last-method-call")) || - node?.type === "variable" - ) { - e.className = "last-method-call"; - e.deletable = true; - } - return { ...e }; + const source = nodes.find((n) => n.id == e.source); + const stack = stackClass.get(e.source) ?? ""; + const live = stack === "last-method-call" || source?.type === "variable"; + const classes = [live ? "last-method-call" : stack]; + if (showChanges && diff.edges.has(e.id)) classes.push("step-changed"); + return { + ...e, + className: classes.filter(Boolean).join(" "), + deletable: live, + }; })} elevateEdgesOnSelect={true} defaultEdgeOptions={{ diff --git a/packages/java-memory-playground/src/MethodCallNode.tsx b/packages/java-memory-playground/src/MethodCallNode.tsx index b573f63..f0d0911 100644 --- a/packages/java-memory-playground/src/MethodCallNode.tsx +++ b/packages/java-memory-playground/src/MethodCallNode.tsx @@ -5,6 +5,7 @@ import { NodeProps, Position, useEdges, + useNodes, useReactFlow, } from "@xyflow/react"; import { @@ -148,15 +149,27 @@ function MethodCallNode({ data, onDeclareVariable, }: NodeProps & { onDeclareVariable?: (nodeId: string) => void }) { - const { setNodes } = useReactFlow(); + const { setNodes, setEdges } = useReactFlow(); const edges = useEdges(); + const nodes = useNodes(); const t = useStore((state) => state.getTranslations()); const inlineStrings = useStore((state) => state.options.inlineStrings); const localVariablesEdges = edges.filter((e) => e.source == id); + // Only the frame on top of the stack can return. Popping one from the middle + // is the one thing a stack cannot do, so the button says so rather than + // disappearing — that a call has to finish first is the lesson. + const isTopOfStack = !nodes.some( + (n) => isMethodCallNode(n) && n.data.index > data.index, + ); + const handleReturn = () => { + if (!isTopOfStack) return; setNodes((nds) => nds.filter((n) => n.id != id)); + // The references the frame held go with it; the objects they pointed at may + // now be unreachable, which is exactly what the garbage collector shows. + setEdges((eds) => eds.filter((e) => e.source != id)); }; const handleDeclareLocaleVariable = () => { @@ -197,7 +210,12 @@ function MethodCallNode({ > {t.declareLocalVariable} - diff --git a/packages/java-memory-playground/src/index.css b/packages/java-memory-playground/src/index.css index a149d7e..9faa01a 100644 --- a/packages/java-memory-playground/src/index.css +++ b/packages/java-memory-playground/src/index.css @@ -371,6 +371,11 @@ path.react-flow__edge-path:hover { gap: 8px; padding: 8px; font-size: 14px; + /* The bar floats over the canvas, so it needs to stay legible when a node + happens to sit underneath it. */ + background: rgba(255, 255, 255, 0.92); + border-radius: 10px; + box-shadow: 0 2px 6px rgb(0 0 0 / 15%); } .step-bar__count { @@ -386,3 +391,33 @@ path.react-flow__edge-path:hover { .step-bar__label-text { padding: 0 4px; } + +/* What a step changed, compared with the step before it. The marks sit on top + of the fading that shows how far down the call stack a node hangs, so they + have to stay visible on a faded node. */ +.react-flow__node.step-added, +.react-flow__node.step-changed { + opacity: 1; +} + +.react-flow__node.step-added::after, +.react-flow__node.step-changed::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 12px; + pointer-events: none; +} + +.react-flow__node.step-added::after { + border: 3px solid #22c55e; +} + +.react-flow__node.step-changed::after { + border: 3px dashed #f59e0b; +} + +.react-flow__edge.step-changed .react-flow__edge-path { + stroke: #f59e0b !important; + opacity: 1; +} diff --git a/packages/java-memory-playground/src/index.ts b/packages/java-memory-playground/src/index.ts index 103e76b..cf641d4 100644 --- a/packages/java-memory-playground/src/index.ts +++ b/packages/java-memory-playground/src/index.ts @@ -10,6 +10,8 @@ export { ConfigView } from "./ConfigView"; export { KeyboardShortcuts, defaultKeyBindings } from "./KeyboardShortcuts"; export { InlineString } from "./InlineString"; export { StepBar } from "./StepBar"; +export { diffSteps, emptyDiff } from "./stepDiff"; +export type { StepDiff } from "./stepDiff"; export { createMemoryStore, diff --git a/packages/java-memory-playground/src/memory.ts b/packages/java-memory-playground/src/memory.ts index 8be9185..9688993 100644 --- a/packages/java-memory-playground/src/memory.ts +++ b/packages/java-memory-playground/src/memory.ts @@ -136,6 +136,11 @@ export type Memory = { inlineStrings?: boolean; /** Hide the step bar, for lessons that are about one picture. */ hideSteps?: boolean; + /** + * Stop marking what a step changed compared with the one before it. The + * marking is usually the point of a trace, so it is on by default. + */ + hideStepChanges?: boolean; }; klasses: Klasses; /** diff --git a/packages/java-memory-playground/src/stepDiff.test.ts b/packages/java-memory-playground/src/stepDiff.test.ts new file mode 100644 index 0000000..860a36b --- /dev/null +++ b/packages/java-memory-playground/src/stepDiff.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "vitest"; +import { diffSteps } from "./stepDiff"; +import { StoreStep } from "./store"; +import { CustomEdgeType, CustomNodeType } from "./types"; + +const objectNode = ( + id: string, + attributes: Record = {}, + position = { x: 0, y: 0 }, +): CustomNodeType => + ({ + id, + type: "object", + position, + data: { klass: "Node", attributes, position }, + }) as unknown as CustomNodeType; + +const frameNode = (id: string, index: number): CustomNodeType => + ({ + id, + type: "method-call", + position: { x: 0, y: 0 }, + data: { name: "f", index, localVariables: {}, position: { x: 0, y: 0 } }, + }) as unknown as CustomNodeType; + +const edge = (id: string, source: string, handle: string, target: string) => + ({ id, source, sourceHandle: handle, target }) as CustomEdgeType; + +const step = ( + nodes: CustomNodeType[], + edges: CustomEdgeType[] = [], +): StoreStep => ({ nodes, edges }); + +describe("diffSteps", () => { + test("nothing has happened on the first step", () => { + const diff = diffSteps(undefined, step([objectNode("@a")])); + + expect(diff.added.size).toBe(0); + expect(diff.changed.size).toBe(0); + expect(diff.edges.size).toBe(0); + }); + + test("a frame pushed on a call is an addition", () => { + const diff = diffSteps( + step([objectNode("@a")]), + step([objectNode("@a"), frameNode("1", 0)]), + ); + + expect([...diff.added]).toEqual(["1"]); + expect(diff.changed.size).toBe(0); + }); + + test("an unchanged diagram highlights nothing", () => { + const before = step([objectNode("@a", { next: { dataType: "Node" } })]); + const after = step([objectNode("@a", { next: { dataType: "Node" } })]); + + const diff = diffSteps(before, after); + + expect(diff.added.size).toBe(0); + expect(diff.changed.size).toBe(0); + }); + + test("an attribute that got a value marks the object changed", () => { + const diff = diffSteps( + step([objectNode("@a", { count: { dataType: "int", value: 0 } })]), + step([objectNode("@a", { count: { dataType: "int", value: 1 } })]), + ); + + expect([...diff.changed]).toEqual(["@a"]); + }); + + test("moving a node is not a change", () => { + // Layout is shared across steps, so a position difference says nothing + // about what the step did. + const diff = diffSteps( + step([objectNode("@a", {}, { x: 0, y: 0 })]), + step([objectNode("@a", {}, { x: 400, y: 300 })]), + ); + + expect(diff.changed.size).toBe(0); + }); + + test("a new reference is highlighted", () => { + const diff = diffSteps( + step([objectNode("@a"), objectNode("@b")]), + step( + [objectNode("@a"), objectNode("@b")], + [edge("e1", "@a", "next", "@b")], + ), + ); + + expect([...diff.edges]).toEqual(["e1"]); + }); + + test("a reference that now points elsewhere is highlighted", () => { + const before = step( + [objectNode("@a"), objectNode("@b"), objectNode("@c")], + [edge("e1", "@a", "next", "@b")], + ); + const after = step( + [objectNode("@a"), objectNode("@b"), objectNode("@c")], + [edge("e1", "@a", "next", "@c")], + ); + + expect([...diffSteps(before, after).edges]).toEqual(["e1"]); + }); + + test("a reference that stayed put is not highlighted", () => { + const edges = [edge("e1", "@a", "next", "@b")]; + const diff = diffSteps( + step([objectNode("@a"), objectNode("@b")], edges), + step([objectNode("@a"), objectNode("@b")], edges), + ); + + expect(diff.edges.size).toBe(0); + }); + + test("a frame that returned leaves nothing to highlight", () => { + // The frame is gone, so there is no node left to mark — what the step shows + // is the absence. + const diff = diffSteps( + step([objectNode("@a"), frameNode("1", 0)]), + step([objectNode("@a")]), + ); + + expect(diff.added.size).toBe(0); + expect(diff.changed.size).toBe(0); + }); + + test("an object identified the same but of another class is a change", () => { + const before = step([objectNode("@a")]); + const after = step([ + { + ...objectNode("@a"), + data: { klass: "Other", attributes: {}, position: { x: 0, y: 0 } }, + } as unknown as CustomNodeType, + ]); + + expect([...diffSteps(before, after).changed]).toEqual(["@a"]); + }); +}); diff --git a/packages/java-memory-playground/src/stepDiff.ts b/packages/java-memory-playground/src/stepDiff.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf994a67d2f7e54b45f4844074eb529889b9fcb8 GIT binary patch literal 2309 zcmZ`)!H(ND5bfDtF;2P*h^b7kX zeM3^V*U4TCiJBR{dGqF3Z+hzjY5$SAzx!*e*3>$?p{rK;@V3F>Tb9$So5@6{*gf3` z>-au!Plp^`&Agl;>KOFX7Dt=AX2lOf&r?e8pKKG~&oTo(d#TUQ5sSRz3hqDZwxtE_ zCq%RBYm!t?p;Lhx-H@??_*r{^H#lYL9Y1Ni_2iLT(e(^@s%f~H(>;fGJ~(Yww>K}U z?K~J*MaB>UDJc` zsUQs7k^_;=Ds;UG%^q;Gw*Z1#fe<%rHf8*M^<{bt{z#&x2 z5Q($SEjgfg|9Z$0c0d9v;#(9ZgmfV72(qYu%Lnp2H zJXMetRRTAQZ9*fxuE8OAz*34B#lnVpEQk}(;e%*H_CS;qS0N%nQ3rLehZuXIYiJJN zMMZ+@#A`Gz(Rc$(h|10(E!KgN0PU&1XIY!1yX=RFYq2I-cQ1591f$!D35;(1H$>8K zs||#Pkj&m$#B_3D8sZtJ`Y3&Lm;!D9-<`pB(~5M^W+wnqod$+AB-?GZgEK{5GDBby z4UlfBcr(H*!9*0S^npo_*XF4$#4G7n)fZzzB^&0e4UP&ErS#QQ8Vh88FC(-- z?^N9(7HP*t9B&a5SY(A%D;T3hp;|1Iks_vy+bhc^D643u@_P|Zxfdab=n#KnvuFRJ z#mO!TsXVGy{3Bs3FK)~7{?2`JLp0~H9oZ=VA?f`)%*D@%xkSa+w5SCG23Oc(P;kLw z$~m$quivm3Eo2d$ { }); }); +describe("the call stack", () => { + const frame = (index: number) => ({ + name: `f${index}`, + index, + localVariables: {}, + position: { x: 0, y: index * 40 }, + }); + + const withFrames = (indices: number[]) => { + const store = createMemoryStore(false); + store.getState().loadMemory({ + ...emptyMemory, + methodCalls: Object.fromEntries(indices.map((i) => [i, frame(i)])), + }); + return store; + }; + + test("a new frame goes on top of the deepest one", () => { + const store = withFrames([0, 1, 2]); + const nodes = store.getState().getNodes(); + const deepest = Math.max( + ...nodes.filter((n: any) => n.type === "method-call").map((n: any) => n.data.index), + ); + + expect(deepest).toBe(2); + }); + + test("frame indices stay unique after one returns", () => { + // Counting the frames to pick the next index handed out one a surviving + // frame already had, as soon as a frame in the middle was gone. + const store = withFrames([0, 2]); + const indices = store + .getState() + .getNodes() + .filter((n: any) => n.type === "method-call") + .map((n: any) => n.data.index); + + const next = Math.max(...indices, -1) + 1; + + expect(indices).toEqual([0, 2]); + expect(indices).not.toContain(next); + }); +}); + describe("mode", () => { test("a playground is the student's unless asked otherwise", () => { expect(createMemoryStore(false).getState().mode).toBe("view"); diff --git a/packages/java-memory-playground/src/translations.ts b/packages/java-memory-playground/src/translations.ts index a70950a..97895ea 100644 --- a/packages/java-memory-playground/src/translations.ts +++ b/packages/java-memory-playground/src/translations.ts @@ -28,6 +28,7 @@ export interface Translations { // Nodes declareLocalVariable: string; returnMethod: string; + returnOnlyTopOfStack: string; // Dialogs ok: string; @@ -71,6 +72,7 @@ export interface Translations { createNewOnEdgeDrop: string; inlineStrings: string; hideSteps: string; + hideStepChanges: string; }; } @@ -98,6 +100,8 @@ const en: Translations = { declareLocalVariable: "Declare Local Variable", returnMethod: "Return", + returnOnlyTopOfStack: + "Only the method on top of the stack can return — the calls above it have to finish first", ok: "OK", cancel: "Cancel", @@ -140,6 +144,7 @@ const en: Translations = { createNewOnEdgeDrop: "Create a new object when an edge is dropped", inlineStrings: "Show String values inside their object", hideSteps: "Hide the step bar", + hideStepChanges: "Do not mark what changed in a step", }, }; @@ -167,6 +172,8 @@ const de: Translations = { declareLocalVariable: "Lokale Variable deklarieren", returnMethod: "Zurückkehren", + returnOnlyTopOfStack: + "Nur die oberste Methode auf dem Stapel kann zurückkehren — die Aufrufe darüber müssen zuerst beendet werden", ok: "OK", cancel: "Abbrechen", @@ -209,6 +216,7 @@ const de: Translations = { createNewOnEdgeDrop: "Neues Objekt erstellen, wenn eine Kante abgelegt wird", inlineStrings: "String-Werte im Objekt anzeigen", hideSteps: "Schrittleiste ausblenden", + hideStepChanges: "Änderungen eines Schritts nicht hervorheben", }, }; diff --git a/packages/web-component/README.md b/packages/web-component/README.md index ee50a45..b32c025 100644 --- a/packages/web-component/README.md +++ b/packages/web-component/README.md @@ -91,6 +91,7 @@ diagram. | `createNewOnEdgeDrop` | Create a new object when an edge is dropped on empty canvas. | | `inlineStrings` | Draw String values inside the object that references them instead of as their own heap box. On by default. | | `hideSteps` | Hide the step bar, for a lesson that is about one picture. | +| `hideStepChanges` | Stop marking what a step changed compared with the one before it. | ## Events @@ -132,6 +133,11 @@ playground.setAttribute("step", "2"); playground.addEventListener("stepchange", (e) => highlight(e.detail)); ``` +Each step marks itself against the one before it — a green outline for what +appeared, a dashed amber one for what changed, an amber reference for one that +was assigned or repointed — so a reader can see what a line did rather than +hunting for it. `hideStepChanges` turns that off. + A diagram with a single state needs no `steps` key; it is read as a one-step story, and saved back in the same shape. From 68db1318981d60cd280d8f25c95040ae57eae9e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:18:10 +0000 Subject: [PATCH 08/27] Exercises, garbage prediction, whole-trace export, presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exercises. A step can be marked as one: the teacher authors it as the answer, and a student's playground starts them from the step before it and checks what they build. The comparison is by shape reachable from the named roots — variables, and each frame's locals — not by address, because a student who allocates an object gets whatever address the playground handed out; the same diagram built independently has to match. The report names the root that is wrong rather than only saying no. Saving from a student's playground writes the exercise back as authored, so a shared link stays the exercise. - Garbage prediction. With `gcPrediction` on, the collector asks first: mark what you think is unreachable, and the score is worked out before the sweep, while there is still something to have been wrong about. - Download all steps: one image with every step under its label, for a worksheet. Exporting gave you the step on screen, which is the wrong picture. - Presets naming the option combinations a course moves through, as buttons in the config view, instead of a teacher remembering which flags go together. Also fixes a crash the garbage-prediction demo turned up. The reachability walk behind the collector and the stack fading recursed through references guarding only against self-loops, so any cycle between two or more objects recursed forever and took the playground down with a stack overflow. A circular linked list did it — an ordinary teaching example. It tracks visited nodes now, which also makes an unreachable cycle collectable, the demo that shows why tracing beats reference counting. Verified in Chromium: a student sees the previous step and gets "Not right yet: head", revealing the solution makes the check pass; marking one node of an unreachable pair scores "Found 1, missed 1" and collects both; the references preset ticks the right options; and exporting a two-step trace downloads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/olive-eels-tell.md | 14 ++ packages/java-memory-playground/README.md | 42 ++++ .../java-memory-playground/src/ConfigView.tsx | 37 ++++ .../java-memory-playground/src/MemoryView.tsx | 110 ++++++--- .../java-memory-playground/src/StepBar.tsx | 44 +++- .../src/canonical.test.ts | 208 ++++++++++++++++++ .../java-memory-playground/src/canonical.ts | 135 ++++++++++++ .../java-memory-playground/src/exportSteps.ts | 93 ++++++++ packages/java-memory-playground/src/helper.ts | 1 + packages/java-memory-playground/src/index.css | 77 +++++++ packages/java-memory-playground/src/index.ts | 6 + packages/java-memory-playground/src/memory.ts | 11 + .../java-memory-playground/src/presets.ts | 51 +++++ .../java-memory-playground/src/store.test.ts | 152 +++++++++++++ packages/java-memory-playground/src/store.ts | 167 +++++++++++++- .../src/translations.ts | 66 ++++++ .../java-memory-playground/src/utils.test.ts | 80 +++++++ packages/java-memory-playground/src/utils.ts | 72 +++--- packages/web-component/README.md | 7 + packages/web-component/exercise.html | 60 +++++ 20 files changed, 1348 insertions(+), 85 deletions(-) create mode 100644 .changeset/olive-eels-tell.md create mode 100644 packages/java-memory-playground/src/canonical.test.ts create mode 100644 packages/java-memory-playground/src/canonical.ts create mode 100644 packages/java-memory-playground/src/exportSteps.ts create mode 100644 packages/java-memory-playground/src/presets.ts create mode 100644 packages/java-memory-playground/src/utils.test.ts create mode 100644 packages/web-component/exercise.html diff --git a/.changeset/olive-eels-tell.md b/.changeset/olive-eels-tell.md new file mode 100644 index 0000000..efc0b91 --- /dev/null +++ b/.changeset/olive-eels-tell.md @@ -0,0 +1,14 @@ +--- +"@openpatch/java-memory-playground-web-component": minor +"@openpatch/java-memory-playground": minor +"web": minor +--- + +Exercises, garbage prediction, whole-trace export, and presets for teachers. + +- **Exercises.** A step can be marked as one: the teacher authors it as the answer, and a student's playground starts them from the step before it and checks what they build. The check compares the shape reachable from each named root rather than addresses, so a student's own objects match a solution built with different ones, and the report names the variable that is wrong. +- **Garbage prediction.** With `gcPrediction` on, the collector asks first — the student marks what they think is unreachable and is scored before the sweep. +- **Download all steps.** One image with every step under its label, which is what a worksheet needs; exporting gave you only the step on screen. +- **Presets.** `optionPresets` names the option combinations a course moves through — references only, with the stack, everything — as buttons in the config view. + +Also fixes a crash: the reachability walk behind the garbage collector and the stack fading followed references without remembering where it had been, so any reference cycle between two or more objects overflowed the stack and took the whole playground down. A circular linked list did it. It tracks visited nodes now, and an unreachable cycle is collected as it should be. diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md index 89088ca..5883eb6 100644 --- a/packages/java-memory-playground/README.md +++ b/packages/java-memory-playground/README.md @@ -122,6 +122,32 @@ A diagram with one step is just a picture, and is still saved in the shape it always had, so a link to a single diagram stays readable by older versions. Set `hideSteps` to hide the bar entirely. +### Exercises + +A step can be marked as an exercise. The teacher authors it as the answer; a +student's playground starts them from the step before it and checks what they +build. + +```json +{ "label": "insert at the head", "exercise": true, "objects": {}, "variables": {}, "methodCalls": {} } +``` + +The check compares the *shape* reachable from each root — the named variables +and each frame's locals — not the addresses, because a student who allocates an +object gets whatever address the playground handed out. Building the right +diagram passes however it was built, and the report names the root that is +wrong rather than only saying no. `checkAgainst` and `canonicalRoots` are +exported if you want to run the comparison yourself. + +Saving from a student's playground writes the exercise back as authored, not +their attempt, so a shared link stays the exercise. + +### Garbage collection + +With `gcPrediction` on, the collector asks first: the student marks the objects +they think are unreachable, and the check scores them before sweeping. Reaching +for an answer before seeing it is where the learning is. + ### The call stack Only the frame on top of the stack can return; the others say so rather than @@ -172,6 +198,10 @@ const { undo, redo, canUndo, canRedo, clear } = useUndoRedo(); | `Ctrl/Cmd + 0` | Reset zoom | | `Shift + 1` | Fit the diagram to the view | +**Download all steps** writes one image with every step under its label, which +is what a worksheet wants — exporting the step on screen gives you the last +picture instead. + Shortcuts are ignored while an input has focus. Override any of them with `keyBindings`: @@ -179,6 +209,18 @@ Shortcuts are ignored while an input has focus. Override any of them with ``` +## Presets + +`optionPresets` names the option combinations a course moves through — a teacher +picks one in the config view rather than remembering which flags belong to which +stage. + +| Preset | What it is for | +| ------ | -------------- | +| `references` | Objects and the names that point at them. No stack, no steps. | +| `stack` | Method calls, so the stack and stepping come with them. | +| `everything` | Arrays, the garbage collector, and Strings as heap objects. | + ## Languages English and German ship with the package. `language="auto"` (the default) picks diff --git a/packages/java-memory-playground/src/ConfigView.tsx b/packages/java-memory-playground/src/ConfigView.tsx index d4805c1..01efe32 100644 --- a/packages/java-memory-playground/src/ConfigView.tsx +++ b/packages/java-memory-playground/src/ConfigView.tsx @@ -4,6 +4,7 @@ import { RFState } from "./store"; import { useCallback, useState, useEffect } from "react"; import { DataType, builtInDataTypes } from "./memory"; import { SimpleInputDialog } from "./SimpleInputDialog"; +import { optionPresets } from "./presets"; const selector = (state: RFState) => ({ storedKlasses: state.klasses, @@ -257,6 +258,20 @@ export const ConfigView = () => { fontSize: "18px", fontWeight: "600" }}>{t.options} +
+ {t.presets}: + {( + [ + ["references", t.presetReferences], + ["stack", t.presetStack], + ["everything", t.presetEverything], + ] as const + ).map(([name, label]) => ( + + ))} +
{ /> {t.optionLabels.hideStepChanges} +
diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index 9ad3d0e..b649adc 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -11,7 +11,7 @@ import { MarkerType, useReactFlow, } from "@xyflow/react"; -import { toPng } from "html-to-image"; +import { downloadAllSteps, downloadStep } from "./exportSteps"; import useStore from "./storeContext"; import { useUndoRedo } from "./useUndoRedo"; import { RFState } from "./store"; @@ -32,7 +32,6 @@ import { import { getRanMemoryAdress, isConnectedTo, - isConnectedToMethodCall, isConnectedToVariable, } from "./utils"; import MethodCallNode, { @@ -58,6 +57,15 @@ const selector = (state: RFState) => ({ save: state.save, mode: state.mode, previousStep: state.steps[state.currentStep - 1], + steps: state.steps, + currentStep: state.currentStep, + goToStep: state.goToStep, + gcPrediction: state.gcPrediction, + gcResult: state.gcResult, + startGcPrediction: state.startGcPrediction, + toggleGcPrediction: state.toggleGcPrediction, + cancelGcPrediction: state.cancelGcPrediction, + collectGarbage: state.collectGarbage, t: state.getTranslations(), }); @@ -100,6 +108,15 @@ export const MemoryView = () => { save, mode, previousStep, + steps, + currentStep, + goToStep, + gcPrediction, + gcResult, + startGcPrediction, + toggleGcPrediction, + cancelGcPrediction, + collectGarbage, t, } = useStore(useShallow(selector)); const { screenToFlowPosition } = useReactFlow(); @@ -387,16 +404,6 @@ export const MemoryView = () => { [nodes, klasses, options, setNodes, setEdges] ); - const onGC = () => { - setNodes((nds) => - nds.filter( - (n) => - n.type != "object" || - isConnectedToVariable(n.id, nodes, edges) || - isConnectedToMethodCall(n.id, nodes, edges) - ) - ); - }; const onConfig = () => { setRoute("config"); @@ -404,26 +411,23 @@ export const MemoryView = () => { const onDownloadPng = () => { if (!flowRef.current) return; - toPng(flowRef.current, { - filter: (node) => { - // we don't want to add the minimap and the controls to the image - if ( - node?.classList?.contains("react-flow__minimap") || - node?.classList?.contains("react-flow__controls") || - node?.classList?.contains("button-group") - ) { - return false; - } + downloadStep(flowRef.current, "java-memory-playground.png"); + }; - return true; + const onDownloadAllPng = async () => { + if (!flowRef.current) return; + const back = currentStep; + await downloadAllSteps({ + element: flowRef.current, + stepCount: steps.length, + labelFor: (i) => steps[i]?.label ?? "", + showStep: async (i) => { + goToStep(i); + // Let the step render before it is photographed. + await new Promise((resolve) => setTimeout(resolve, 320)); }, - }).then((dataUrl) => { - const a = document.createElement("a"); - - a.setAttribute("download", "java-memory-playground.png"); - a.setAttribute("href", dataUrl); - a.click(); }); + goToStep(back); }; const createNodeAtPosition = (type: string, position: { x: number; y: number }) => { @@ -703,6 +707,7 @@ export const MemoryView = () => { if (diff.added.has(n.id)) classes.push("step-added"); else if (diff.changed.has(n.id)) classes.push("step-changed"); } + if (gcPrediction?.includes(n.id)) classes.push("gc-predicted"); return { ...n, className: classes.filter(Boolean).join(" "), @@ -730,6 +735,10 @@ export const MemoryView = () => { color: "#778899", }, }} + onNodeClick={(_, node) => { + if (gcPrediction === null) return; + if (node.type === "object") toggleGcPrediction(node.id); + }} onConnect={onConnect} onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} @@ -765,6 +774,11 @@ export const MemoryView = () => { + {steps.length > 1 && ( + + )} {mode === "edit" && ( )} @@ -777,13 +791,37 @@ export const MemoryView = () => { )} - {!options.disableGarbageCollector && -
- -
-
} + {!options.disableGarbageCollector && ( + +
+ {gcResult && ( + + {t.gcScore(gcResult.found, gcResult.missed, gcResult.wrong)} + + )} + {options.gcPrediction && gcPrediction === null && ( + + )} + {gcPrediction !== null && ( + <> + + {t.predictGarbageHint(gcPrediction.length)} + + + + )} + {(!options.gcPrediction || gcPrediction !== null) && ( + + )} +
+
+ )} diff --git a/packages/java-memory-playground/src/StepBar.tsx b/packages/java-memory-playground/src/StepBar.tsx index 04a3b48..dc0379b 100644 --- a/packages/java-memory-playground/src/StepBar.tsx +++ b/packages/java-memory-playground/src/StepBar.tsx @@ -10,6 +10,11 @@ const selector = (state: RFState) => ({ addStep: state.addStep, deleteStep: state.deleteStep, setStepLabel: state.setStepLabel, + setStepExercise: state.setStepExercise, + hasSolution: state.solutions[state.currentStep] !== undefined, + exerciseResult: state.exerciseResult, + checkExercise: state.checkExercise, + revealSolution: state.revealSolution, t: state.getTranslations(), }); @@ -27,11 +32,17 @@ export function StepBar({ editable = true }: { editable?: boolean }) { addStep, deleteStep, setStepLabel, + setStepExercise, + hasSolution, + exerciseResult, + checkExercise, + revealSolution, t, } = useStore(useShallow(selector)); const only = steps.length === 1; - if (only && !editable) return null; + // A single-step diagram is just a picture, unless it is an exercise. + if (only && !editable && !hasSolution) return null; const step = steps[currentStep]; @@ -61,6 +72,14 @@ export function StepBar({ editable = true }: { editable?: boolean }) { {editable ? ( <> + {step.label} )} + + {hasSolution && ( + <> + {t.yourTurn} + + + + )} + {exerciseResult && ( + + {exerciseResult.correct + ? t.exerciseCorrect + : exerciseResult.wrong.length > 0 + ? t.exerciseWrong(exerciseResult.wrong) + : t.exerciseExtra(exerciseResult.extra)} + + )} ); } diff --git a/packages/java-memory-playground/src/canonical.test.ts b/packages/java-memory-playground/src/canonical.test.ts new file mode 100644 index 0000000..288c447 --- /dev/null +++ b/packages/java-memory-playground/src/canonical.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "vitest"; +import { canonicalRoots, checkAgainst } from "./canonical"; +import { StoreStep } from "./store"; +import { CustomEdgeType, CustomNodeType } from "./types"; + +const obj = (id: string, klass: string, attributes: Record = {}) => + ({ + id, + type: "object", + position: { x: 0, y: 0 }, + data: { klass, attributes, position: { x: 0, y: 0 } }, + }) as unknown as CustomNodeType; + +const str = (id: string, literal: string) => + ({ + id, + type: "object", + position: { x: 0, y: 0 }, + data: { klass: "String", literal, attributes: {}, position: { x: 0, y: 0 } }, + }) as unknown as CustomNodeType; + +const variable = (id: string, name: string) => + ({ + id, + type: "variable", + position: { x: 0, y: 0 }, + data: { name, dataType: "Node", value: null, position: { x: 0, y: 0 } }, + }) as unknown as CustomNodeType; + +const edge = (source: string, handle: string, target: string) => + ({ + id: `${source}+${handle}`, + source, + sourceHandle: handle, + target, + }) as CustomEdgeType; + +const step = (nodes: CustomNodeType[], edges: CustomEdgeType[] = []): StoreStep => ({ + nodes, + edges, +}); + +const ref = { dataType: "Node" }; + +describe("canonicalRoots", () => { + test("describes the shape reachable from a variable", () => { + const s = step( + [ + variable("@v", "head"), + obj("@a", "Node", { next: ref }), + obj("@b", "Node", { next: ref }), + ], + [edge("@v", "", "@a"), edge("@a", "next", "@b")], + ); + + expect(canonicalRoots(s).head).toBe("Node{next=Node{next=null}}"); + }); + + test("an unset reference is null", () => { + const s = step([variable("@v", "head")], []); + + expect(canonicalRoots(s).head).toBe("null"); + }); + + test("addresses do not appear, so two diagrams can be compared", () => { + // The same shape, built with different addresses — which is what happens + // when a student allocates their own objects. + const mine = step( + [variable("@v", "head"), obj("@x1", "Node", { next: ref })], + [edge("@v", "", "@x1")], + ); + const theirs = step( + [variable("@zzz", "head"), obj("@9f2c", "Node", { next: ref })], + [edge("@zzz", "", "@9f2c")], + ); + + expect(canonicalRoots(mine)).toEqual(canonicalRoots(theirs)); + }); + + test("a cycle becomes a back reference instead of looping forever", () => { + const s = step( + [variable("@v", "head"), obj("@a", "Node", { next: ref })], + [edge("@v", "", "@a"), edge("@a", "next", "@a")], + ); + + expect(canonicalRoots(s).head).toBe("Node{next=#0}"); + }); + + test("String values are compared by their characters", () => { + const s = step( + [ + variable("@v", "name"), + obj("@p", "Person", { name: { dataType: "String" } }), + str("@s", "Ada"), + ], + [edge("@v", "", "@p"), edge("@p", "name", "@s")], + ); + + expect(canonicalRoots(s).name).toBe('Person{name="Ada"}'); + }); + + test("primitives are part of the shape", () => { + const s = step( + [ + variable("@v", "c"), + obj("@a", "Counter", { count: { dataType: "int", value: 3 } }), + ], + [edge("@v", "", "@a")], + ); + + expect(canonicalRoots(s).c).toBe("Counter{count=3}"); + }); + + test("frame locals are roots, keyed by frame and name", () => { + const frame = { + id: "1", + type: "method-call", + position: { x: 0, y: 0 }, + data: { + name: "App.main", + index: 0, + localVariables: { node: ref }, + position: { x: 0, y: 0 }, + }, + } as unknown as CustomNodeType; + + const s = step( + [frame, obj("@a", "Node", { next: ref })], + [edge("1", "node", "@a")], + ); + + expect(canonicalRoots(s)["0:App.main.node"]).toBe("Node{next=null}"); + }); +}); + +describe("checkAgainst", () => { + const solution = step( + [ + variable("@v", "head"), + obj("@a", "Node", { next: ref }), + obj("@b", "Node", { next: ref }), + ], + [edge("@v", "", "@a"), edge("@a", "next", "@b")], + ); + + test("accepts the same shape built with other addresses", () => { + const attempt = step( + [ + variable("@mine", "head"), + obj("@one", "Node", { next: ref }), + obj("@two", "Node", { next: ref }), + ], + [edge("@mine", "", "@one"), edge("@one", "next", "@two")], + ); + + expect(checkAgainst(solution, attempt).correct).toBe(true); + }); + + test("names the root that is wrong", () => { + // Only one node linked, where the solution has two. + const attempt = step( + [variable("@mine", "head"), obj("@one", "Node", { next: ref })], + [edge("@mine", "", "@one")], + ); + + const result = checkAgainst(solution, attempt); + + expect(result.correct).toBe(false); + expect(result.wrong).toEqual(["head"]); + }); + + test("a root that was never built counts as wrong, not missing silently", () => { + const result = checkAgainst(solution, step([], [])); + + expect(result.correct).toBe(false); + expect(result.wrong).toEqual(["head"]); + }); + + test("reports a root the attempt invented", () => { + const attempt = step( + [ + variable("@mine", "head"), + variable("@extra", "tail"), + obj("@one", "Node", { next: ref }), + obj("@two", "Node", { next: ref }), + ], + [edge("@mine", "", "@one"), edge("@one", "next", "@two")], + ); + + const result = checkAgainst(solution, attempt); + + expect(result.correct).toBe(false); + expect(result.extra).toEqual(["tail"]); + }); + + test("the order objects were created in does not matter", () => { + const attempt = step( + [ + obj("@two", "Node", { next: ref }), + obj("@one", "Node", { next: ref }), + variable("@mine", "head"), + ], + [edge("@one", "next", "@two"), edge("@mine", "", "@one")], + ); + + expect(checkAgainst(solution, attempt).correct).toBe(true); + }); +}); diff --git a/packages/java-memory-playground/src/canonical.ts b/packages/java-memory-playground/src/canonical.ts new file mode 100644 index 0000000..61011fd --- /dev/null +++ b/packages/java-memory-playground/src/canonical.ts @@ -0,0 +1,135 @@ +import { STRING_KLASS, primitveDataTypes } from "./memory"; +import { StoreStep } from "./store"; +import { CustomEdgeType, CustomNodeType } from "./types"; + +/** + * The shape of a diagram, written so that two diagrams built independently can + * be compared. + * + * Addresses cannot be compared: a student who allocates an object gets whatever + * address the playground handed out, never the one in the teacher's solution. + * What can be compared is the shape reachable from the roots — the named + * variables and the locals of each frame — because those names are authored. + */ + +const targetOf = ( + edges: CustomEdgeType[], + source: string, + handle: string, +) => edges.find((e) => e.source === source && e.sourceHandle === handle)?.target; + +const describe = ( + id: string | undefined, + nodes: Map, + edges: CustomEdgeType[], + seen: Map, +): string => { + if (!id) return "null"; + + const node = nodes.get(id); + if (!node || node.type !== "object") return "null"; + + // A cycle is part of the shape, so it is written as a back reference rather + // than followed forever. + const already = seen.get(id); + if (already !== undefined) return `#${already}`; + seen.set(id, seen.size); + + const data = node.data; + if (data.klass === STRING_KLASS) return `"${data.literal ?? ""}"`; + + const attributes = Object.keys(data.attributes ?? {}) + .sort() + .map((name) => { + const attribute = data.attributes[name]; + if (primitveDataTypes.includes(attribute.dataType)) { + return `${name}=${String(attribute.value ?? "")}`; + } + return `${name}=${describe(targetOf(edges, id, name), nodes, edges, seen)}`; + }); + + return `${data.klass}{${attributes.join(",")}}`; +}; + +/** + * The canonical form of each root, keyed by the root's name. + * + * Comparing per root rather than as one string is what lets a check say which + * variable is wrong instead of only that something is. + */ +export const canonicalRoots = (step: StoreStep): Record => { + const nodes = new Map(step.nodes.map((n) => [n.id, n])); + const roots: Record = {}; + + step.nodes.forEach((node) => { + if (node.type === "variable") { + const seen = new Map(); + roots[node.data.name] = describe( + targetOf(step.edges, node.id, "") ?? + step.edges.find((e) => e.source === node.id)?.target, + nodes, + step.edges, + seen, + ); + } + + if (node.type === "method-call") { + const frame = node.data; + Object.keys(frame.localVariables ?? {}) + .sort() + .forEach((name) => { + const local = frame.localVariables[name]; + const key = `${frame.index}:${frame.name}.${name}`; + if (primitveDataTypes.includes(local.dataType)) { + roots[key] = String(local.value ?? ""); + return; + } + const seen = new Map(); + roots[key] = describe( + targetOf(step.edges, node.id, name), + nodes, + step.edges, + seen, + ); + }); + } + }); + + return roots; +}; + +export type ExerciseResult = { + correct: boolean; + /** Roots the solution has that the attempt got right. */ + matched: string[]; + /** Roots whose shape differs, or that the attempt never created. */ + wrong: string[]; + /** Roots the attempt invented. */ + extra: string[]; +}; + +/** Compares a student's diagram with the one the exercise asks for. */ +export const checkAgainst = ( + solution: StoreStep, + attempt: StoreStep, +): ExerciseResult => { + const expected = canonicalRoots(solution); + const actual = canonicalRoots(attempt); + + const matched: string[] = []; + const wrong: string[] = []; + + Object.keys(expected).forEach((name) => { + if (actual[name] === expected[name]) matched.push(name); + else wrong.push(name); + }); + + const extra = Object.keys(actual).filter((name) => !(name in expected)); + + return { + correct: wrong.length === 0 && extra.length === 0, + matched, + wrong, + extra, + }; +}; diff --git a/packages/java-memory-playground/src/exportSteps.ts b/packages/java-memory-playground/src/exportSteps.ts new file mode 100644 index 0000000..930c58a --- /dev/null +++ b/packages/java-memory-playground/src/exportSteps.ts @@ -0,0 +1,93 @@ +import { toPng } from "html-to-image"; + +/** Chrome that belongs to the editor rather than to the diagram. */ +export const excludeChrome = (node: HTMLElement) => + !( + node?.classList?.contains("react-flow__minimap") || + node?.classList?.contains("react-flow__controls") || + node?.classList?.contains("button-group") + ); + +const loadImage = (src: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = src; + }); + +const download = (dataUrl: string, name: string) => { + const a = document.createElement("a"); + a.setAttribute("download", name); + a.setAttribute("href", dataUrl); + a.click(); +}; + +export const downloadStep = async (element: HTMLElement, name: string) => { + download(await toPng(element, { filter: excludeChrome }), name); +}; + +/** + * One image of a whole trace: every step stacked, each under its caption. + * + * A worksheet wants the sequence, not the last picture — which is what + * exporting the step on screen gives you. + */ +export const downloadAllSteps = async ({ + element, + stepCount, + labelFor, + showStep, + fileName = "java-memory-playground.png", +}: { + element: HTMLElement; + stepCount: number; + labelFor: (index: number) => string; + /** Puts a step on screen and resolves once it has been drawn. */ + showStep: (index: number) => Promise; + fileName?: string; +}) => { + const shots: { image: HTMLImageElement; caption: string }[] = []; + + for (let i = 0; i < stepCount; i++) { + await showStep(i); + const dataUrl = await toPng(element, { filter: excludeChrome }); + shots.push({ + image: await loadImage(dataUrl), + caption: `${i + 1}/${stepCount}${labelFor(i) ? ` — ${labelFor(i)}` : ""}`, + }); + } + + const captionHeight = 44; + const gap = 12; + const width = Math.max(...shots.map((s) => s.image.width)); + const height = shots.reduce( + (total, s) => total + s.image.height + captionHeight + gap, + gap, + ); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return; + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + + let y = gap; + shots.forEach(({ image, caption }) => { + context.fillStyle = "#f1f5f9"; + context.fillRect(0, y, width, captionHeight); + context.fillStyle = "#0f172a"; + context.font = "600 20px system-ui, sans-serif"; + context.textBaseline = "middle"; + context.fillText(caption, 16, y + captionHeight / 2); + y += captionHeight; + + context.drawImage(image, 0, y); + y += image.height + gap; + }); + + download(canvas.toDataURL("image/png"), fileName); +}; diff --git a/packages/java-memory-playground/src/helper.ts b/packages/java-memory-playground/src/helper.ts index 60d6d82..5e335eb 100644 --- a/packages/java-memory-playground/src/helper.ts +++ b/packages/java-memory-playground/src/helper.ts @@ -118,6 +118,7 @@ export const stepsOf = (m: Partial): Step[] => { return steps.map((step) => ({ label: step?.label, note: step?.note, + exercise: step?.exercise, objects: step?.objects ?? {}, variables: step?.variables ?? {}, methodCalls: step?.methodCalls ?? {}, diff --git a/packages/java-memory-playground/src/index.css b/packages/java-memory-playground/src/index.css index 9faa01a..87f5a39 100644 --- a/packages/java-memory-playground/src/index.css +++ b/packages/java-memory-playground/src/index.css @@ -376,6 +376,8 @@ path.react-flow__edge-path:hover { background: rgba(255, 255, 255, 0.92); border-radius: 10px; box-shadow: 0 2px 6px rgb(0 0 0 / 15%); + flex-wrap: nowrap; + max-width: min(92vw, 940px); } .step-bar__count { @@ -390,6 +392,19 @@ path.react-flow__edge-path:hover { .step-bar__label-text { padding: 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + +/* The bar is one row of controls; nothing in it should wrap into a column. */ +.step-bar > * { + flex: none; +} + +.step-bar button { + white-space: nowrap; } /* What a step changed, compared with the step before it. The marks sit on top @@ -421,3 +436,65 @@ path.react-flow__edge-path:hover { stroke: #f59e0b !important; opacity: 1; } + +/* An object marked as a guess in the garbage prediction. */ +.react-flow__node.gc-predicted { + opacity: 1; +} + +.react-flow__node.gc-predicted::before { + content: "?"; + position: absolute; + top: -14px; + right: -14px; + width: 28px; + height: 28px; + border-radius: 50%; + background: #ef4444; + color: white; + font-size: 18px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +.gc-panel { + align-items: center; +} + +.gc-hint, +.gc-result { + font-size: 14px; + padding: 0 4px; +} + +.step-bar__exercise { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + white-space: nowrap; +} + +.step-bar__your-turn { + font-weight: 600; + white-space: nowrap; +} + +.step-bar__result { + padding: 4px 8px; + border-radius: 8px; + white-space: nowrap; +} + +.step-bar__result.correct { + background: #dcfce7; + color: #166534; +} + +.step-bar__result.wrong { + background: #fee2e2; + color: #991b1b; +} diff --git a/packages/java-memory-playground/src/index.ts b/packages/java-memory-playground/src/index.ts index cf641d4..71c3925 100644 --- a/packages/java-memory-playground/src/index.ts +++ b/packages/java-memory-playground/src/index.ts @@ -11,6 +11,11 @@ export { KeyboardShortcuts, defaultKeyBindings } from "./KeyboardShortcuts"; export { InlineString } from "./InlineString"; export { StepBar } from "./StepBar"; export { diffSteps, emptyDiff } from "./stepDiff"; +export { canonicalRoots, checkAgainst } from "./canonical"; +export type { ExerciseResult } from "./canonical"; +export { optionPresets } from "./presets"; +export type { PresetName } from "./presets"; +export { downloadStep, downloadAllSteps } from "./exportSteps"; export type { StepDiff } from "./stepDiff"; export { @@ -24,6 +29,7 @@ export type { PlaygroundMode, MemoryStore, StoreStep, + GcPredictionResult, } from "./store"; export { useStore, diff --git a/packages/java-memory-playground/src/memory.ts b/packages/java-memory-playground/src/memory.ts index 9688993..9fa55e8 100644 --- a/packages/java-memory-playground/src/memory.ts +++ b/packages/java-memory-playground/src/memory.ts @@ -109,6 +109,12 @@ export type Step = { label?: string; /** The teacher's note about what this step did. */ note?: string; + /** + * The student produces this step themselves. Its contents are the solution: + * a student's playground starts them from the previous step and checks what + * they build against it. + */ + exercise?: boolean; objects: Objs; variables: Variables; methodCalls: MethodCalls; @@ -141,6 +147,11 @@ export type Memory = { * marking is usually the point of a trace, so it is on by default. */ hideStepChanges?: boolean; + /** + * Ask which objects are unreachable before collecting them, instead of + * simply sweeping. Committing to an answer is where the learning is. + */ + gcPrediction?: boolean; }; klasses: Klasses; /** diff --git a/packages/java-memory-playground/src/presets.ts b/packages/java-memory-playground/src/presets.ts new file mode 100644 index 0000000..9f9daa4 --- /dev/null +++ b/packages/java-memory-playground/src/presets.ts @@ -0,0 +1,51 @@ +import { Memory } from "./memory"; + +export type PresetName = "references" | "stack" | "everything"; + +/** + * Option sets for the stages a course goes through. + * + * The options already describe a teaching sequence — references before the + * stack, arrays and the collector after — but only as flags a teacher has to + * remember the combination of. These name the combinations. + */ +export const optionPresets: Record = { + // Objects and the names that point at them. No stack yet: a named handle on + // an object is enough to teach reference versus object. + references: { + hideSidebar: false, + hideCallMethod: true, + hideDeclareGlobalVariable: false, + hideNewArray: true, + disableGarbageCollector: true, + createNewOnEdgeDrop: true, + inlineStrings: true, + hideSteps: true, + hideStepChanges: false, + }, + // Method calls arrive, so the stack does too, and with it stepping — a frame + // pushed and popped is a thing that happens over time. + stack: { + hideSidebar: false, + hideCallMethod: false, + hideDeclareGlobalVariable: true, + hideNewArray: true, + disableGarbageCollector: true, + createNewOnEdgeDrop: true, + inlineStrings: true, + hideSteps: false, + hideStepChanges: false, + }, + // Arrays, the garbage collector, and Strings as the heap objects they are. + everything: { + hideSidebar: false, + hideCallMethod: false, + hideDeclareGlobalVariable: false, + hideNewArray: false, + disableGarbageCollector: false, + createNewOnEdgeDrop: true, + inlineStrings: false, + hideSteps: false, + hideStepChanges: false, + }, +}; diff --git a/packages/java-memory-playground/src/store.test.ts b/packages/java-memory-playground/src/store.test.ts index 0d5df1c..4e932d3 100644 --- a/packages/java-memory-playground/src/store.test.ts +++ b/packages/java-memory-playground/src/store.test.ts @@ -223,6 +223,158 @@ describe("the call stack", () => { }); }); +describe("exercises", () => { + const node = (next?: string) => ({ + klass: "Node", + attributes: { next: { dataType: "Node", value: next } }, + position: { x: 0, y: 0 }, + }); + + const exerciseMemory: Memory = { + ...emptyMemory, + klasses: { Node: { attributes: { next: "Node" } } }, + steps: [ + { + objects: { "@a": node() }, + variables: { + "@v": { name: "head", dataType: "Node", value: "@a", position: { x: 0, y: 0 } }, + }, + methodCalls: {}, + }, + { + exercise: true, + label: "add a second node", + objects: { "@a": node("@b"), "@b": node() }, + variables: { + "@v": { name: "head", dataType: "Node", value: "@a", position: { x: 0, y: 0 } }, + }, + methodCalls: {}, + }, + ], + }; + + test("a student starts an exercise from the step before it", () => { + const store = createMemoryStore(false, "view"); + store.getState().loadMemory(exerciseMemory); + store.getState().goToStep(1); + + // One object, as in step 1 — not the two of the solution. + expect( + store.getState().getNodes().filter((n: any) => n.type === "object"), + ).toHaveLength(1); + }); + + test("the teacher sees the solution instead", () => { + const store = createMemoryStore(false, "edit"); + store.getState().loadMemory(exerciseMemory); + store.getState().goToStep(1); + + expect( + store.getState().getNodes().filter((n: any) => n.type === "object"), + ).toHaveLength(2); + }); + + test("checking an unfinished attempt says which root is wrong", () => { + const store = createMemoryStore(false, "view"); + store.getState().loadMemory(exerciseMemory); + store.getState().goToStep(1); + + store.getState().checkExercise(); + + expect(store.getState().exerciseResult?.correct).toBe(false); + expect(store.getState().exerciseResult?.wrong).toEqual(["head"]); + }); + + test("revealing the solution makes the check pass", () => { + const store = createMemoryStore(false, "view"); + store.getState().loadMemory(exerciseMemory); + store.getState().goToStep(1); + + store.getState().revealSolution(); + store.getState().checkExercise(); + + expect(store.getState().exerciseResult?.correct).toBe(true); + }); + + test("saving from a student's playground keeps the exercise, not the attempt", () => { + const store = createMemoryStore(false, "view"); + store.getState().loadMemory(exerciseMemory); + store.getState().goToStep(1); + store.getState().setNodes([]); + + const saved = store.getState().getMemory(); + + expect(saved.steps![1].exercise).toBe(true); + expect(Object.keys(saved.steps![1].objects)).toHaveLength(2); + }); +}); + +describe("garbage collection", () => { + const withGarbage = () => { + const store = createMemoryStore(false); + store.getState().loadMemory({ + ...emptyMemory, + klasses: { Node: { attributes: { next: "Node" } } }, + objects: { + "@kept": { klass: "Node", attributes: {}, position: { x: 0, y: 0 } }, + "@junk": { klass: "Node", attributes: {}, position: { x: 0, y: 0 } }, + }, + variables: { + "@v": { name: "head", dataType: "Node", value: "@kept", position: { x: 0, y: 0 } }, + }, + }); + return store; + }; + + test("collects what no root reaches", () => { + const store = withGarbage(); + + store.getState().collectGarbage(); + + const ids = store.getState().getNodes().map((n: any) => n.id); + expect(ids).toContain("@kept"); + expect(ids).not.toContain("@junk"); + }); + + test("scores a prediction before sweeping it away", () => { + const store = withGarbage(); + + store.getState().startGcPrediction(); + store.getState().toggleGcPrediction("@junk"); + store.getState().collectGarbage(); + + expect(store.getState().gcResult).toEqual({ found: 1, missed: 0, wrong: 0 }); + }); + + test("marking a reachable object counts against the prediction", () => { + const store = withGarbage(); + + store.getState().startGcPrediction(); + store.getState().toggleGcPrediction("@kept"); + store.getState().collectGarbage(); + + expect(store.getState().gcResult).toEqual({ found: 0, missed: 1, wrong: 1 }); + }); + + test("a prediction can be unmarked again", () => { + const store = withGarbage(); + + store.getState().startGcPrediction(); + store.getState().toggleGcPrediction("@junk"); + store.getState().toggleGcPrediction("@junk"); + + expect(store.getState().gcPrediction).toEqual([]); + }); + + test("collecting without predicting scores nothing", () => { + const store = withGarbage(); + + store.getState().collectGarbage(); + + expect(store.getState().gcResult).toBeNull(); + }); +}); + describe("mode", () => { test("a playground is the student's unless asked otherwise", () => { expect(createMemoryStore(false).getState().mode).toBe("view"); diff --git a/packages/java-memory-playground/src/store.ts b/packages/java-memory-playground/src/store.ts index 876dfd6..b46caa4 100644 --- a/packages/java-memory-playground/src/store.ts +++ b/packages/java-memory-playground/src/store.ts @@ -6,6 +6,7 @@ import { temporal } from "zundo"; import { persist, StateStorage, createJSONStorage } from "zustand/middleware"; import { createStore } from "zustand/vanilla"; +import { ExerciseResult, checkAgainst } from "./canonical"; import { getEdgesAndNodes, getMemory } from "./getEdgesAndNodes"; import { parseMemory, stepsOf } from "./helper"; import { Memory, Step, initialMemory } from "./memory"; @@ -17,6 +18,7 @@ import { translations, } from "./translations"; import { CustomEdgeType, CustomNodeType } from "./types"; +import { isConnectedToMethodCall, isConnectedToVariable } from "./utils"; export type Route = "view" | "config"; @@ -33,10 +35,20 @@ export type PlaygroundMode = "view" | "edit"; export type StoreStep = { label?: string; note?: string; + exercise?: boolean; nodes: CustomNodeType[]; edges: CustomEdgeType[]; }; +export type GcPredictionResult = { + /** Unreachable objects the prediction found. */ + found: number; + /** Unreachable objects it missed. */ + missed: number; + /** Objects it marked that are still reachable. */ + wrong: number; +}; + type Updater = T[] | ((current: T[]) => T[]); export type RFState = { @@ -57,6 +69,17 @@ export type RFState = { options: Memory["options"]; viewport: Viewport; + /** + * The authored contents of the exercise steps, kept aside in a student's + * playground so that their attempt can be compared with it. + */ + solutions: Record; + exerciseResult: ExerciseResult | null; + + /** Ids of the objects a prediction has marked as unreachable, while running. */ + gcPrediction: string[] | null; + gcResult: GcPredictionResult | null; + defaultLanguage?: string; /** * Bumped every time the user presses Save. The diagram itself is kept in the @@ -89,6 +112,19 @@ export type RFState = { setOptions: (options: Memory["options"]) => void; setViewport: (viewport: Viewport) => void; + // Exercises + setStepExercise: (index: number, exercise: boolean) => void; + checkExercise: () => void; + revealSolution: () => void; + clearExerciseResult: () => void; + + // Garbage collection + startGcPrediction: () => void; + toggleGcPrediction: (id: string) => void; + cancelGcPrediction: () => void; + /** Checks a prediction if one is running, then collects. */ + collectGarbage: () => void; + // Bulk operations loadMemory: (memory: Memory) => void; getMemory: () => Memory; @@ -163,9 +199,30 @@ const createHashStorage = (enabled: boolean): StateStorage => { const toStoreStep = (step: Step): StoreStep => ({ label: step.label, note: step.note, + exercise: step.exercise, ...getEdgesAndNodes(step), }); +const copyStep = (step: StoreStep): StoreStep => ({ + nodes: step.nodes.map((n) => ({ ...n, data: { ...n.data } }) as CustomNodeType), + edges: step.edges.map((e) => ({ ...e })), +}); + +/** + * In a student's playground an exercise step is not shown — it is the answer. + * The student starts from the step before it and builds the next one, so the + * authored contents move aside and the step begins as a copy of its predecessor. + */ +const withExercisesHidden = (steps: StoreStep[]) => { + const solutions: Record = {}; + const working = steps.map((step, i) => { + if (!step.exercise || i === 0) return step; + solutions[i] = step; + return { ...copyStep(steps[i - 1]), label: step.label, exercise: true }; + }); + return { steps: working, solutions }; +}; + const initialSteps: StoreStep[] = [toStoreStep(initialMemory as Step)]; /** Replaces the step at `index`, leaving the rest of the story alone. */ @@ -211,6 +268,10 @@ export const createMemoryStore = ( options: initialMemory.options, viewport: initialMemory.viewport, saveCount: 0, + solutions: {}, + exerciseResult: null, + gcPrediction: null, + gcResult: null, save: () => set({ saveCount: get().saveCount + 1 }), @@ -330,6 +391,92 @@ export const createMemoryStore = ( })), }), + setStepExercise: (index, exercise) => + set({ + steps: withStep(get().steps, index, (step) => ({ + ...step, + exercise: exercise || undefined, + })), + }), + + checkExercise: () => { + const { steps, currentStep, solutions } = get(); + const solution = solutions[currentStep]; + if (!solution) return; + set({ exerciseResult: checkAgainst(solution, steps[currentStep]) }); + }, + + revealSolution: () => { + const { currentStep, solutions } = get(); + const solution = solutions[currentStep]; + if (!solution) return; + set({ + steps: withStep(get().steps, currentStep, (step) => ({ + ...copyStep(solution), + label: step.label, + exercise: true, + })), + exerciseResult: null, + }); + }, + + clearExerciseResult: () => set({ exerciseResult: null }), + + startGcPrediction: () => set({ gcPrediction: [], gcResult: null }), + + toggleGcPrediction: (id) => { + const marked = get().gcPrediction; + if (!marked) return; + set({ + gcPrediction: marked.includes(id) + ? marked.filter((m) => m !== id) + : [...marked, id], + }); + }, + + cancelGcPrediction: () => set({ gcPrediction: null, gcResult: null }), + + collectGarbage: () => { + const { steps, currentStep, gcPrediction } = get(); + const { nodes, edges } = steps[currentStep]; + + const unreachable = nodes + .filter( + (n) => + n.type === "object" && + !isConnectedToVariable(n.id, nodes, edges) && + !isConnectedToMethodCall(n.id, nodes, edges), + ) + .map((n) => n.id); + + // Scoring happens before the sweep, while there is still something + // to have been wrong about. + const gcResult = gcPrediction + ? { + found: gcPrediction.filter((id) => unreachable.includes(id)) + .length, + missed: unreachable.filter( + (id) => !gcPrediction.includes(id), + ).length, + wrong: gcPrediction.filter((id) => !unreachable.includes(id)) + .length, + } + : null; + + const collected = new Set(unreachable); + set({ + gcPrediction: null, + gcResult, + steps: withStep(steps, currentStep, (step) => ({ + ...step, + nodes: step.nodes.filter((n) => !collected.has(n.id)), + edges: step.edges.filter( + (e) => !collected.has(e.target) && !collected.has(e.source), + ), + })), + }); + }, + setKlasses: (klasses) => set({ klasses }), setOptions: (options) => set({ options }), setViewport: (viewport) => set({ viewport }), @@ -337,9 +484,19 @@ export const createMemoryStore = ( loadMemory: (memory) => { // Through stepsOf, so that a caller may hand over a diagram in // either shape — a one-step diagram still has no `steps` key. - const steps = stepsOf(memory).map(toStoreStep); + const loaded = stepsOf(memory).map(toStoreStep); + const all = loaded.length > 0 ? loaded : initialSteps; + const { steps, solutions } = + get().mode === "edit" + ? { steps: all, solutions: {} } + : withExercisesHidden(all); + set({ - steps: steps.length > 0 ? steps : initialSteps, + steps, + solutions, + exerciseResult: null, + gcPrediction: null, + gcResult: null, currentStep: 0, klasses: memory.klasses, options: memory.options, @@ -349,9 +506,13 @@ export const createMemoryStore = ( getMemory: () => { const state = get(); - const steps = state.steps.map((step) => ({ + // An exercise is written back as the teacher authored it, so that + // saving from a student's playground shares the exercise rather + // than whatever they had built when they pressed the button. + const steps = state.steps.map((s, i) => state.solutions[i] ?? s).map((step) => ({ ...(step.label ? { label: step.label } : {}), ...(step.note ? { note: step.note } : {}), + ...(step.exercise ? { exercise: true } : {}), ...getMemory(step.edges, step.nodes), })); diff --git a/packages/java-memory-playground/src/translations.ts b/packages/java-memory-playground/src/translations.ts index 97895ea..47fa953 100644 --- a/packages/java-memory-playground/src/translations.ts +++ b/packages/java-memory-playground/src/translations.ts @@ -5,8 +5,14 @@ export interface Translations { save: string; saveUrl: string; downloadPng: string; + downloadAllPng: string; + downloadAllPngHint: string; config: string; runGarbageCollector: string; + predictGarbage: string; + predictGarbageHint: (marked: number) => string; + checkAndCollect: string; + gcScore: (found: number, missed: number, wrong: number) => string; undo: string; redo: string; @@ -24,6 +30,14 @@ export interface Translations { deleteStep: string; stepLabel: string; stepLabelPlaceholder: string; + exerciseStep: string; + exerciseStepHint: string; + yourTurn: string; + checkAnswer: string; + showSolution: string; + exerciseCorrect: string; + exerciseWrong: (wrong: string[]) => string; + exerciseExtra: (extra: string[]) => string; // Nodes declareLocalVariable: string; @@ -57,6 +71,10 @@ export interface Translations { attributeName: string; dataType: string; options: string; + presets: string; + presetReferences: string; + presetStack: string; + presetEverything: string; backToDiagram: string; saved: string; unsavedChangesLeave: string; @@ -73,6 +91,7 @@ export interface Translations { inlineStrings: string; hideSteps: string; hideStepChanges: string; + gcPrediction: string; }; } @@ -80,8 +99,18 @@ const en: Translations = { save: "Save", saveUrl: "Save (URL)", downloadPng: "Download (PNG)", + downloadAllPng: "Download all steps", + downloadAllPngHint: "One image with every step, for a worksheet", config: "Config", runGarbageCollector: "Run Garbage Collector", + predictGarbage: "Predict garbage", + predictGarbageHint: (marked) => + `Click the objects you think are unreachable (${marked} marked)`, + checkAndCollect: "Check and collect", + gcScore: (found, missed, wrong) => + `Found ${found}${missed ? `, missed ${missed}` : ""}${ + wrong ? `, ${wrong} still reachable` : "" + }`, undo: "Undo", redo: "Redo", @@ -97,6 +126,14 @@ const en: Translations = { deleteStep: "Delete step", stepLabel: "Step label", stepLabelPlaceholder: "What happens here?", + exerciseStep: "Exercise", + exerciseStepHint: "The student builds this step; its contents are the solution", + yourTurn: "Your turn: build this step", + checkAnswer: "Check", + showSolution: "Show solution", + exerciseCorrect: "That matches.", + exerciseWrong: (wrong) => `Not right yet: ${wrong.join(", ")}`, + exerciseExtra: (extra) => `Not part of this step: ${extra.join(", ")}`, declareLocalVariable: "Declare Local Variable", returnMethod: "Return", @@ -128,6 +165,10 @@ const en: Translations = { attributeName: "Attribute Name", dataType: "Data Type", options: "Options", + presets: "Presets", + presetReferences: "References only", + presetStack: "With the stack", + presetEverything: "Everything", backToDiagram: "Back to Diagram", saved: "Saved", unsavedChangesLeave: @@ -145,6 +186,7 @@ const en: Translations = { inlineStrings: "Show String values inside their object", hideSteps: "Hide the step bar", hideStepChanges: "Do not mark what changed in a step", + gcPrediction: "Ask which objects are garbage before collecting", }, }; @@ -152,8 +194,18 @@ const de: Translations = { save: "Speichern", saveUrl: "Speichern (URL)", downloadPng: "Herunterladen (PNG)", + downloadAllPng: "Alle Schritte herunterladen", + downloadAllPngHint: "Ein Bild mit allen Schritten, für ein Arbeitsblatt", config: "Einstellungen", runGarbageCollector: "Garbage Collector ausführen", + predictGarbage: "Müll vorhersagen", + predictGarbageHint: (marked) => + `Klicke die Objekte an, die deiner Meinung nach nicht mehr erreichbar sind (${marked} markiert)`, + checkAndCollect: "Prüfen und aufräumen", + gcScore: (found, missed, wrong) => + `${found} gefunden${missed ? `, ${missed} übersehen` : ""}${ + wrong ? `, ${wrong} noch erreichbar` : "" + }`, undo: "Rückgängig", redo: "Wiederholen", @@ -169,6 +221,15 @@ const de: Translations = { deleteStep: "Schritt löschen", stepLabel: "Beschriftung", stepLabelPlaceholder: "Was passiert hier?", + exerciseStep: "Aufgabe", + exerciseStepHint: + "Diesen Schritt bauen die Schüler:innen selbst; der Inhalt ist die Lösung", + yourTurn: "Du bist dran: baue diesen Schritt", + checkAnswer: "Prüfen", + showSolution: "Lösung zeigen", + exerciseCorrect: "Das passt.", + exerciseWrong: (wrong) => `Noch nicht richtig: ${wrong.join(", ")}`, + exerciseExtra: (extra) => `Gehört nicht zu diesem Schritt: ${extra.join(", ")}`, declareLocalVariable: "Lokale Variable deklarieren", returnMethod: "Zurückkehren", @@ -200,6 +261,10 @@ const de: Translations = { attributeName: "Attributname", dataType: "Datentyp", options: "Optionen", + presets: "Voreinstellungen", + presetReferences: "Nur Referenzen", + presetStack: "Mit dem Stapel", + presetEverything: "Alles", backToDiagram: "Zurück zum Diagramm", saved: "Gespeichert", unsavedChangesLeave: @@ -217,6 +282,7 @@ const de: Translations = { inlineStrings: "String-Werte im Objekt anzeigen", hideSteps: "Schrittleiste ausblenden", hideStepChanges: "Änderungen eines Schritts nicht hervorheben", + gcPrediction: "Vor dem Aufräumen fragen, welche Objekte Müll sind", }, }; diff --git a/packages/java-memory-playground/src/utils.test.ts b/packages/java-memory-playground/src/utils.test.ts new file mode 100644 index 0000000..2bc07e6 --- /dev/null +++ b/packages/java-memory-playground/src/utils.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { Edge, Node } from "@xyflow/react"; +import { + isConnectedTo, + isConnectedToMethodCall, + isConnectedToVariable, +} from "./utils"; + +const node = (id: string, type: string): Node => + ({ id, type, data: {}, position: { x: 0, y: 0 } }) as Node; + +const edge = (source: string, target: string): Edge => + ({ id: `${source}->${target}`, source, target }) as Edge; + +describe("reachability", () => { + test("follows a chain of references back to a variable", () => { + const nodes = [node("@v", "variable"), node("@a", "object"), node("@b", "object")]; + const edges = [edge("@v", "@a"), edge("@a", "@b")]; + + expect(isConnectedToVariable("@b", nodes, edges)).toBe(true); + }); + + test("an object nothing points at is unreachable", () => { + const nodes = [node("@v", "variable"), node("@a", "object"), node("@junk", "object")]; + const edges = [edge("@v", "@a")]; + + expect(isConnectedToVariable("@junk", nodes, edges)).toBe(false); + }); + + test("a cycle between two objects terminates", () => { + // Two objects holding each other, reachable from nothing. Walking this + // without remembering where it had been overflowed the stack, which took + // the whole playground down — a circular list did it too. + const nodes = [node("@v", "variable"), node("@x", "object"), node("@y", "object")]; + const edges = [edge("@x", "@y"), edge("@y", "@x")]; + + expect(isConnectedToVariable("@x", nodes, edges)).toBe(false); + expect(isConnectedToMethodCall("@x", nodes, edges)).toBe(false); + }); + + test("a cycle that a variable does reach stays reachable", () => { + const nodes = [node("@v", "variable"), node("@x", "object"), node("@y", "object")]; + const edges = [edge("@v", "@x"), edge("@x", "@y"), edge("@y", "@x")]; + + expect(isConnectedToVariable("@y", nodes, edges)).toBe(true); + }); + + test("a circular list is reachable from its head", () => { + const nodes = [ + node("@v", "variable"), + node("@1", "object"), + node("@2", "object"), + node("@3", "object"), + ]; + const edges = [ + edge("@v", "@1"), + edge("@1", "@2"), + edge("@2", "@3"), + edge("@3", "@1"), + ]; + + expect(isConnectedToVariable("@3", nodes, edges)).toBe(true); + }); + + test("a frame reaches what its locals point at", () => { + const nodes = [node("1", "method-call"), node("@a", "object")]; + const edges = [edge("1", "@a")]; + + expect(isConnectedToMethodCall("@a", nodes, edges)).toBe(true); + expect(isConnectedToVariable("@a", nodes, edges)).toBe(false); + }); + + test("isConnectedTo finds a specific ancestor through a cycle", () => { + const nodes = [node("1", "method-call"), node("@x", "object"), node("@y", "object")]; + const edges = [edge("1", "@x"), edge("@x", "@y"), edge("@y", "@x")]; + + expect(isConnectedTo("@y", "1", nodes, edges)).toBe(true); + expect(isConnectedTo("@y", "nope", nodes, edges)).toBe(false); + }); +}); diff --git a/packages/java-memory-playground/src/utils.ts b/packages/java-memory-playground/src/utils.ts index 1ab97bd..56f375e 100644 --- a/packages/java-memory-playground/src/utils.ts +++ b/packages/java-memory-playground/src/utils.ts @@ -1,75 +1,57 @@ import { Node, Edge, getIncomers } from "@xyflow/react"; -export const isConnectedToVariable = ( +/** + * Whether any node matching `matches` reaches `nodeId` by following references. + * + * `seen` is what keeps this terminating: a diagram may contain reference + * cycles — a circular list, or two objects holding each other — and without it + * the walk goes round forever and takes the whole playground down with it. + */ +const isReachedFrom = ( nodeId: string, nodes: Node[], edges: Edge[], + matches: (node: Node) => boolean, + seen: Set = new Set(), ): boolean => { + if (seen.has(nodeId)) return false; + seen.add(nodeId); + const incomers = getIncomers( { id: nodeId, data: {}, position: { x: 0, y: 0 } }, nodes, edges, ).filter((n) => n.id !== nodeId); - for (let incomer of incomers) { - if (incomer.type === "variable") { - return true; - } - if (isConnectedToVariable(incomer.id, nodes, edges)) { - return true; - } + for (const incomer of incomers) { + if (matches(incomer)) return true; + if (isReachedFrom(incomer.id, nodes, edges, matches, seen)) return true; } return false; }; -export const isConnectedToMethodCall = ( +export const isConnectedToVariable = ( nodeId: string, nodes: Node[], edges: Edge[], -): boolean => { - const incomers = getIncomers( - { id: nodeId, data: {}, position: { x: 0, y: 0 } }, - nodes, - edges, - ).filter((n) => n.id !== nodeId); +): boolean => + isReachedFrom(nodeId, nodes, edges, (n) => n.type === "variable"); - for (let incomer of incomers) { - if (incomer.type === "method-call") { - return true; - } - if (isConnectedToMethodCall(incomer.id, nodes, edges)) { - return true; - } - } - - return false; -}; +export const isConnectedToMethodCall = ( + nodeId: string, + nodes: Node[], + edges: Edge[], +): boolean => + isReachedFrom(nodeId, nodes, edges, (n) => n.type === "method-call"); export const isConnectedTo = ( nodeId: string, conntectedId: string, nodes: Node[], edges: Edge[], -): boolean => { - - const incomers = getIncomers( - { id: nodeId, data: {}, position: { x: 0, y: 0 } }, - nodes, - edges, - ).filter((n) => n.id !== nodeId); - - for (let incomer of incomers) { - if (incomer.id == conntectedId) { - return true; - } - if (isConnectedTo(incomer.id, conntectedId, nodes, edges)) { - return true; - } - } - - return false; -}; +): boolean => + isReachedFrom(nodeId, nodes, edges, (n) => n.id === conntectedId); /** * A random hexadecimal address, optionally avoiding ids already in use. diff --git a/packages/web-component/README.md b/packages/web-component/README.md index b32c025..62ca2e2 100644 --- a/packages/web-component/README.md +++ b/packages/web-component/README.md @@ -92,6 +92,7 @@ diagram. | `inlineStrings` | Draw String values inside the object that references them instead of as their own heap box. On by default. | | `hideSteps` | Hide the step bar, for a lesson that is about one picture. | | `hideStepChanges` | Stop marking what a step changed compared with the one before it. | +| `gcPrediction` | Ask which objects are unreachable before collecting them. | ## Events @@ -138,6 +139,12 @@ appeared, a dashed amber one for what changed, an amber reference for one that was assigned or repointed — so a reader can see what a line did rather than hunting for it. `hideStepChanges` turns that off. +A step marked `"exercise": true` is one the student builds themselves: their +playground starts from the step before it, and **Check** compares what they made +with the authored contents. The comparison is by shape reachable from the named +roots, so a student's own addresses do not matter, and the report names the +variable that is wrong. + A diagram with a single state needs no `steps` key; it is read as a one-step story, and saved back in the same shape. diff --git a/packages/web-component/exercise.html b/packages/web-component/exercise.html new file mode 100644 index 0000000..85db01c --- /dev/null +++ b/packages/web-component/exercise.html @@ -0,0 +1,60 @@ + + + + + Exercise and garbage prediction + + + + +

Exercise — the student builds step 2

+ + +

Garbage prediction — mark what you think is unreachable, then check

+ + + + + + From 2cce643fdc88d21799c20cebf0ec7ac2c807a465 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:35:23 +0000 Subject: [PATCH 09/27] Float the node palette over the canvas as a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette was a flex column beside the canvas, taking 200px of width permanently — costly in an embed, and inconsistent with every other control, which floats. It is a top-left Panel now, styled like the step bar, capped to the canvas height and scrolling when a diagram has many classes. Dragging still works: useDnD captures the pointer on the chip and decides where to drop with elementFromPoint, neither of which cares that the chip now lives inside the flow. Panning is unaffected — a Panel is not the pane. Exports had to change with it. Photographing the whole canvas would now include the palette, so both exports capture the viewport framed to the diagram's nodes instead, via getNodesBounds and getViewportForBounds. That also crops away the empty canvas and every floating panel, which is what an exported diagram wants anyway — no filter list to keep in step with the panels. Verified in Chromium: the palette renders inside the flow, the canvas keeps the full width, dragging "new Message" out of it creates the object, panning still works on empty canvas, and an exported PNG is a tight crop with no chrome. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPSkhpmU2ZHLQvJTLfmPib --- .changeset/tidy-donkeys-look.md | 9 +++ packages/java-memory-playground/README.md | 3 +- .../java-memory-playground/src/MemoryView.tsx | 20 ++++-- .../java-memory-playground/src/exportSteps.ts | 67 +++++++++++++++---- packages/java-memory-playground/src/index.css | 12 +++- 5 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 .changeset/tidy-donkeys-look.md diff --git a/.changeset/tidy-donkeys-look.md b/.changeset/tidy-donkeys-look.md new file mode 100644 index 0000000..06fb275 --- /dev/null +++ b/.changeset/tidy-donkeys-look.md @@ -0,0 +1,9 @@ +--- +"@openpatch/java-memory-playground": patch +--- + +Float the node palette over the canvas instead of taking a column out of it. + +The palette is a panel now, like every other control, so a small embed keeps its whole width for the diagram. Dragging a class onto the canvas works from there unchanged. + +Exports are framed to the diagram's nodes rather than photographing the canvas, which crops away the empty space and keeps the palette, toolbar, step bar and collector button out of the picture. diff --git a/packages/java-memory-playground/README.md b/packages/java-memory-playground/README.md index 5883eb6..1420d10 100644 --- a/packages/java-memory-playground/README.md +++ b/packages/java-memory-playground/README.md @@ -200,7 +200,8 @@ const { undo, redo, canUndo, canRedo, clear } = useUndoRedo(); **Download all steps** writes one image with every step under its label, which is what a worksheet wants — exporting the step on screen gives you the last -picture instead. +picture instead. Both exports capture the diagram framed to its nodes, so the +empty canvas and the floating panels stay out of the picture. Shortcuts are ignored while an input has focus. Override any of them with `keyBindings`: diff --git a/packages/java-memory-playground/src/MemoryView.tsx b/packages/java-memory-playground/src/MemoryView.tsx index b649adc..63091c1 100644 --- a/packages/java-memory-playground/src/MemoryView.tsx +++ b/packages/java-memory-playground/src/MemoryView.tsx @@ -12,7 +12,7 @@ import { useReactFlow, } from "@xyflow/react"; import { downloadAllSteps, downloadStep } from "./exportSteps"; -import useStore from "./storeContext"; +import useStore, { useMemoryStore } from "./storeContext"; import { useUndoRedo } from "./useUndoRedo"; import { RFState } from "./store"; import { useShallow } from "zustand/shallow"; @@ -120,6 +120,7 @@ export const MemoryView = () => { t, } = useStore(useShallow(selector)); const { screenToFlowPosition } = useReactFlow(); + const store = useMemoryStore(); const connectingNode = useRef(null); // Scoped to this instance so that exporting works when a page embeds more // than one playground. @@ -411,21 +412,22 @@ export const MemoryView = () => { const onDownloadPng = () => { if (!flowRef.current) return; - downloadStep(flowRef.current, "java-memory-playground.png"); + downloadStep(flowRef.current, nodes, "java-memory-playground.png"); }; const onDownloadAllPng = async () => { if (!flowRef.current) return; const back = currentStep; await downloadAllSteps({ - element: flowRef.current, + flowElement: flowRef.current, stepCount: steps.length, labelFor: (i) => steps[i]?.label ?? "", showStep: async (i) => { goToStep(i); - // Let the step render before it is photographed. + // Let the step render and be measured before it is photographed. await new Promise((resolve) => setTimeout(resolve, 320)); }, + nodesNow: () => store.getState().getNodes(), }); goToStep(back); }; @@ -697,7 +699,6 @@ export const MemoryView = () => { return (
- {!options.hideSidebar && } { edgeTypes={edgeTypes} minZoom={0.1} > + {!options.hideSidebar && ( + + + + )}