From 34099eaafb6c1b6084ececdfd9ac69bc9e2bef14 Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Tue, 22 Oct 2024 17:49:18 +0200 Subject: [PATCH] bring EditCodec component Co-authored-by: Josep M Sobrepere Co-authored-by: Carlo Sala Co-authored-by: Hannah Redler --- package.json | 3 + pnpm-lock.yaml | 267 ++++++++++++++- src/components/BinaryInput.tsx | 183 ++++++++++ src/components/Copy.tsx | 34 ++ src/components/Expand.tsx | 12 + src/components/Icons.tsx | 63 ++++ src/components/Modal.tsx | 153 +++++++++ src/components/Select.tsx | 75 +++++ src/components/TextInputField.tsx | 50 +++ src/components/Toggle.tsx | 23 ++ src/components/icons/binary.svg | 4 + src/components/icons/enum.svg | 8 + src/components/icons/focus.svg | 5 + src/components/modal.css | 48 +++ src/components/useHeightObserver.ts | 17 + src/components/useRefEffect.ts | 30 ++ src/components/useSynchroniseInput.ts | 48 +++ src/index.css | 15 + .../codecComponents/get-codec-component.tsx | 4 +- src/pages/Extrinsics/BinaryEditModal.tsx | 149 ++++++++ src/pages/Extrinsics/EditCodec/CAccountId.tsx | 41 +++ src/pages/Extrinsics/EditCodec/CBytes.tsx | 6 + src/pages/Extrinsics/EditCodec/CEnum.tsx | 58 ++++ src/pages/Extrinsics/EditCodec/CSequence.tsx | 59 ++++ src/pages/Extrinsics/EditCodec/CStr.tsx | 16 + src/pages/Extrinsics/EditCodec/CStruct.tsx | 84 +++++ src/pages/Extrinsics/EditCodec/CTuple.tsx | 21 ++ src/pages/Extrinsics/EditCodec/EditNumber.tsx | 59 ++++ src/pages/Extrinsics/EditCodec/ListItem.tsx | 47 +++ src/pages/Extrinsics/EditCodec/Tree/CEnum.tsx | 111 ++++++ .../Extrinsics/EditCodec/Tree/CStruct.tsx | 108 ++++++ .../EditCodec/Tree/ListComponents.tsx | 317 ++++++++++++++++++ .../EditCodec/Tree/codec-components.tsx | 224 +++++++++++++ .../Extrinsics/EditCodec/Tree/components.ts | 4 + src/pages/Extrinsics/EditCodec/Tree/index.ts | 5 + .../Extrinsics/EditCodec/codec-components.tsx | 144 ++++++++ src/pages/Extrinsics/EditCodec/components.ts | 9 + src/pages/Extrinsics/EditCodec/index.tsx | 4 + src/pages/Extrinsics/EditMode.tsx | 149 ++++++++ src/pages/Extrinsics/Extrinsics.tsx | 35 +- src/pages/Extrinsics/FocusPath.tsx | 120 +++++++ src/pages/Extrinsics/common/Markers.tsx | 132 ++++++++ src/pages/Extrinsics/common/SubtreeFocus.ts | 20 ++ src/pages/Extrinsics/common/paths.state.ts | 74 ++++ src/pages/Extrinsics/common/scroll.ts | 52 +++ src/utils/default.tsx | 6 + src/utils/shape.ts | 54 +++ 47 files changed, 3145 insertions(+), 5 deletions(-) create mode 100644 src/components/BinaryInput.tsx create mode 100644 src/components/Copy.tsx create mode 100644 src/components/Expand.tsx create mode 100644 src/components/Icons.tsx create mode 100644 src/components/Modal.tsx create mode 100644 src/components/Select.tsx create mode 100644 src/components/TextInputField.tsx create mode 100644 src/components/Toggle.tsx create mode 100644 src/components/icons/binary.svg create mode 100644 src/components/icons/enum.svg create mode 100644 src/components/icons/focus.svg create mode 100644 src/components/modal.css create mode 100644 src/components/useHeightObserver.ts create mode 100644 src/components/useRefEffect.ts create mode 100644 src/components/useSynchroniseInput.ts create mode 100644 src/pages/Extrinsics/BinaryEditModal.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CAccountId.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CBytes.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CEnum.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CSequence.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CStr.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CStruct.tsx create mode 100644 src/pages/Extrinsics/EditCodec/CTuple.tsx create mode 100644 src/pages/Extrinsics/EditCodec/EditNumber.tsx create mode 100644 src/pages/Extrinsics/EditCodec/ListItem.tsx create mode 100644 src/pages/Extrinsics/EditCodec/Tree/CEnum.tsx create mode 100644 src/pages/Extrinsics/EditCodec/Tree/CStruct.tsx create mode 100644 src/pages/Extrinsics/EditCodec/Tree/ListComponents.tsx create mode 100644 src/pages/Extrinsics/EditCodec/Tree/codec-components.tsx create mode 100644 src/pages/Extrinsics/EditCodec/Tree/components.ts create mode 100644 src/pages/Extrinsics/EditCodec/Tree/index.ts create mode 100644 src/pages/Extrinsics/EditCodec/codec-components.tsx create mode 100644 src/pages/Extrinsics/EditCodec/components.ts create mode 100644 src/pages/Extrinsics/EditCodec/index.tsx create mode 100644 src/pages/Extrinsics/EditMode.tsx create mode 100644 src/pages/Extrinsics/FocusPath.tsx create mode 100644 src/pages/Extrinsics/common/Markers.tsx create mode 100644 src/pages/Extrinsics/common/SubtreeFocus.ts create mode 100644 src/pages/Extrinsics/common/paths.state.ts create mode 100644 src/pages/Extrinsics/common/scroll.ts create mode 100644 src/utils/default.tsx create mode 100644 src/utils/shape.ts diff --git a/package.json b/package.json index 2f40d48..32e8302 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "semi": false }, "dependencies": { + "@headlessui/react": "^2.1.10", "@polkadot-api/known-chains": "^0.5.4", "@polkadot-api/metadata-builders": "^0.8.2", "@polkadot-api/polkadot-sdk-compat": "^2.3.1", @@ -22,6 +23,7 @@ "@polkadot-api/substrate-client": "^0.2.2", "@polkadot-api/utils": "^0.1.2", "@polkadot-api/ws-provider": "^0.3.2", + "@radix-ui/react-toggle": "^1.1.0", "@react-rxjs/core": "^0.10.7", "@react-rxjs/utils": "^0.9.7", "lucide-react": "^0.453.0", @@ -39,6 +41,7 @@ "@types/node": "^22.7.8", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", + "@types/react-portal": "^4.0.7", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "eslint": "^9.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41e0932..895a587 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@headlessui/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@18.3.1)(react@18.3.1) '@polkadot-api/known-chains': specifier: ^0.5.4 version: 0.5.4 @@ -32,6 +35,9 @@ dependencies: '@polkadot-api/ws-provider': specifier: ^0.3.2 version: 0.3.2 + '@radix-ui/react-toggle': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@react-rxjs/core': specifier: ^0.10.7 version: 0.10.7(react@18.3.1)(rxjs@7.8.1) @@ -79,6 +85,9 @@ devDependencies: '@types/react-dom': specifier: ^18.3.0 version: 18.3.1 + '@types/react-portal': + specifier: ^4.0.7 + version: 4.0.7 '@vitejs/plugin-react': specifier: ^4.3.2 version: 4.3.3(vite@5.4.9) @@ -601,6 +610,62 @@ packages: levn: 0.4.1 dev: true + /@floating-ui/core@1.6.8: + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + dependencies: + '@floating-ui/utils': 0.2.8 + dev: false + + /@floating-ui/dom@1.6.11: + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + dev: false + + /@floating-ui/react-dom@2.1.2(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@floating-ui/react@0.26.25(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-hZOmgN0NTOzOuZxI1oIrDu3Gcl8WViIkvPMpB4xdd4QD6xAMtwgwr3VPoiyH/bLtRcS1cDnhxLSD1NsMJmwh/A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1)(react@18.3.1) + '@floating-ui/utils': 0.2.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.2.0 + dev: false + + /@floating-ui/utils@0.2.8: + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + dev: false + + /@headlessui/react@2.1.10(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-6mLa2fjMDAFQi+/R10B+zU3edsUk/MDtENB2zHho0lqKU1uzhAfJLUduWds4nCo8wbl3vULtC5rJfZAQ1yqIng==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 + react-dom: ^18 + dependencies: + '@floating-ui/react': 0.26.25(react-dom@18.3.1)(react@18.3.1) + '@react-aria/focus': 3.18.4(react@18.3.1) + '@react-aria/interactions': 3.22.4(react@18.3.1) + '@tanstack/react-virtual': 3.10.8(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@humanfs/core@0.19.0: resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==} engines: {node: '>=18.18.0'} @@ -775,6 +840,154 @@ packages: - utf-8-validate dev: false + /@radix-ui/primitive@1.1.0: + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + dev: false + + /@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-slot@1.1.0(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-toggle@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@react-aria/focus@3.18.4(react@18.3.1): + resolution: {integrity: sha512-91J35077w9UNaMK1cpMUEFRkNNz0uZjnSwiyBCFuRdaVuivO53wNC9XtWSDNDdcO5cGy87vfJRVAiyoCn/mjqA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@react-aria/interactions': 3.22.4(react@18.3.1) + '@react-aria/utils': 3.25.3(react@18.3.1) + '@react-types/shared': 3.25.0(react@18.3.1) + '@swc/helpers': 0.5.13 + clsx: 2.1.1 + react: 18.3.1 + dev: false + + /@react-aria/interactions@3.22.4(react@18.3.1): + resolution: {integrity: sha512-E0vsgtpItmknq/MJELqYJwib+YN18Qag8nroqwjk1qOnBa9ROIkUhWJerLi1qs5diXq9LHKehZDXRlwPvdEFww==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@react-aria/ssr': 3.9.6(react@18.3.1) + '@react-aria/utils': 3.25.3(react@18.3.1) + '@react-types/shared': 3.25.0(react@18.3.1) + '@swc/helpers': 0.5.13 + react: 18.3.1 + dev: false + + /@react-aria/ssr@3.9.6(react@18.3.1): + resolution: {integrity: sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@swc/helpers': 0.5.13 + react: 18.3.1 + dev: false + + /@react-aria/utils@3.25.3(react@18.3.1): + resolution: {integrity: sha512-PR5H/2vaD8fSq0H/UB9inNbc8KDcVmW6fYAfSWkkn+OAdhTTMVKqXXrZuZBWyFfSD5Ze7VN6acr4hrOQm2bmrA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@react-aria/ssr': 3.9.6(react@18.3.1) + '@react-stately/utils': 3.10.4(react@18.3.1) + '@react-types/shared': 3.25.0(react@18.3.1) + '@swc/helpers': 0.5.13 + clsx: 2.1.1 + react: 18.3.1 + dev: false + /@react-rxjs/core@0.10.7(react@18.3.1)(rxjs@7.8.1): resolution: {integrity: sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A==} peerDependencies: @@ -799,6 +1012,23 @@ packages: rxjs: 7.8.1 dev: false + /@react-stately/utils@3.10.4(react@18.3.1): + resolution: {integrity: sha512-gBEQEIMRh5f60KCm7QKQ2WfvhB2gLUr9b72sqUdIZ2EG+xuPgaIlCBeSicvjmjBvYZwOjoOEnmIkcx2GHp/HWw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@swc/helpers': 0.5.13 + react: 18.3.1 + dev: false + + /@react-types/shared@3.25.0(react@18.3.1): + resolution: {integrity: sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + react: 18.3.1 + dev: false + /@remix-run/router@1.20.0: resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==} engines: {node: '>=14.0.0'} @@ -944,6 +1174,12 @@ packages: resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} dev: false + /@swc/helpers@0.5.13: + resolution: {integrity: sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==} + dependencies: + tslib: 2.8.0 + dev: false + /@tanem/svg-injector@10.1.68: resolution: {integrity: sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA==} dependencies: @@ -952,6 +1188,21 @@ packages: tslib: 2.8.0 dev: false + /@tanstack/react-virtual@3.10.8(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@tanstack/virtual-core': 3.10.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@tanstack/virtual-core@3.10.8: + resolution: {integrity: sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==} + dev: false + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -1001,6 +1252,11 @@ packages: resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} dependencies: '@types/react': 18.3.11 + + /@types/react-portal@4.0.7: + resolution: {integrity: sha512-3zQJL6Pqcq7d73+cakVkZDqAYGFBo37XXPfahjhdScr9EdYPlf9evYilL38Fpzs1YGWz7F2SkPtN6+ygTO7ymg==} + dependencies: + '@types/react': 18.3.11 dev: true /@types/react@18.3.11: @@ -1008,7 +1264,6 @@ packages: dependencies: '@types/prop-types': 15.7.13 csstype: 3.1.3 - dev: true /@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0)(eslint@9.13.0)(typescript@5.6.3): resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==} @@ -1347,6 +1602,11 @@ packages: fsevents: 2.3.3 dev: true + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -1403,7 +1663,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} @@ -2473,6 +2732,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /tailwind-merge@2.5.4: resolution: {integrity: sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==} dev: false diff --git a/src/components/BinaryInput.tsx b/src/components/BinaryInput.tsx new file mode 100644 index 0000000..00c4bcf --- /dev/null +++ b/src/components/BinaryInput.tsx @@ -0,0 +1,183 @@ +import { NOTIN } from "@codec-components" +import { Binary } from "@polkadot-api/substrate-bindings" +import { FileUp } from "lucide-react" +import { FC, useState } from "react" +import { twMerge } from "tailwind-merge" +import { TextInputField } from "./TextInputField" +import { useGenericSynchronizeInput } from "./useSynchroniseInput" + +export const BinaryInput: React.FC<{ + encodedValue: Binary | NOTIN + onValueChanged: (newValue: Binary | NOTIN) => boolean + len?: number +}> = ({ encodedValue, onValueChanged, len }) => { + const [localInput, setLocalInput] = useGenericSynchronizeInput( + encodedValue, + onValueChanged, + (value) => validate(parseValue(value), len), + "" as string | Binary, + serializeValue, + checkEqualInputBinary, + ) + const [uploadError, setUploadError] = useState("") + + const inputValue = typeof localInput === "string" ? localInput : "" + const placeholder = + typeof localInput === "string" + ? "Enter text or hex" + (len ? ` (${len.toLocaleString()} bytes)` : "") + : "(value is too long to display)" + + const validateFile = (file: File) => { + if (file.size > 512 * 1024 * 1024) { + setUploadError("File size can't exceed 512MB") + return false + } + if (len && file.size !== len) { + setUploadError( + `Field requires exactly ${len} bytes, uploaded file is ${file.size} bytes instead`, + ) + return false + } + return true + } + + const parsed = parseValue(inputValue) + const length = parsed === NOTIN ? null : parsed.asBytes().length + + const oddHexLength = + typeof localInput === "string" && + localInput.startsWith("0x") && + localInput.length % 2 === 1 + + const warn = oddHexLength + ? "Hex length is odd" + : len && length && length !== len + ? `Field requires ${len} bytes, got ${length} instead.` + : false + + return ( + { + setUploadError("") + setLocalInput(v) + }} + placeholder={placeholder} + className="min-w-80 border-none p-0 outline-none bg-transparent flex-1" + warn={warn} + error={uploadError} + > + {(input) => ( +
+ {input} + +
+ )} +
+ ) +} + +export const BinaryFileInput: FC<{ + validate: (file: File) => boolean + onLoaded: (value: Binary) => void + onError: () => void +}> = ({ validate, onLoaded, onError }) => { + const loadFile = async (file: File) => { + if (!validate(file)) { + return + } + try { + const buffer = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = reject + reader.readAsArrayBuffer(file) + }) + onLoaded(Binary.fromBytes(new Uint8Array(buffer))) + } catch (ex) { + console.error(ex) + onError() + } + } + + return ( + { + const file = e.currentTarget.files?.[0] + if (file) loadFile(file) + }} + className="hidden" + /> + ) +} + +const validate = (value: Binary | NOTIN, len?: number): Binary | NOTIN => { + if (!len || value === NOTIN) return value + return value.asBytes().length !== len ? NOTIN : value +} + +const parseValue = (value: string | Binary): Binary | NOTIN => { + if (value instanceof Binary) return value + + if (value.trim() === "") return Binary.fromText("") + + if (value.startsWith("0x")) { + try { + return Binary.fromHex(value) + } catch (_) { + return NOTIN + } + } + + return Binary.fromText(value) +} + +const textDecoder = new TextDecoder("utf-8", { fatal: true }) +const serializeValue = (value: Binary): string | Binary => { + const bytes = value.asBytes() + if (bytes.length === 0) return "" + if (bytes.length > 5 * 1024 * 1024) return value + + try { + return textDecoder.decode(bytes) + } catch (_) { + return value.asHex() + } +} + +export const checkEqualInputBinary = ( + input: string | Binary, + value: Binary | typeof NOTIN, +) => { + const parsed = parseValue(input) + if (parsed === NOTIN || value === NOTIN) return true + const inputBytes = parsed.asBytes() + const valueBytes = value.asBytes() + + if (inputBytes.length !== valueBytes.length) { + return false + } + for (let i = 0; i < inputBytes.length; i++) { + if (inputBytes[i] !== valueBytes[i]) { + return false + } + } + return true +} diff --git a/src/components/Copy.tsx b/src/components/Copy.tsx new file mode 100644 index 0000000..0dd0627 --- /dev/null +++ b/src/components/Copy.tsx @@ -0,0 +1,34 @@ +import { CheckCircle, Copy } from "lucide-react" +import { useEffect, useState } from "react" + +export const CopyText: React.FC<{ text: string; disabled?: boolean }> = ({ + text, + disabled = false, +}) => { + const [copied, setCopied] = useState(false) + const copy = async () => { + if (disabled) return + + await navigator.clipboard.writeText(text) + setCopied(true) + } + useEffect(() => { + if (copied) { + setTimeout(() => setCopied(false), 1000) + } + }, [copied]) + + return ( + + ) +} diff --git a/src/components/Expand.tsx b/src/components/Expand.tsx new file mode 100644 index 0000000..9b32149 --- /dev/null +++ b/src/components/Expand.tsx @@ -0,0 +1,12 @@ +import { FC } from "react" +import { ChevronRight } from "lucide-react" +import { twMerge as clsx } from "tailwind-merge" + +export const ExpandBtn: FC<{ + expanded: boolean +}> = ({ expanded }) => ( + +) diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..5e54b96 --- /dev/null +++ b/src/components/Icons.tsx @@ -0,0 +1,63 @@ +import { ReactSVG, Props } from "react-svg" +import focusSvg from "./icons/focus.svg" +import enumSvg from "./icons/enum.svg" +import binarySvg from "./icons/binary.svg" +import { useEffect, useRef } from "react" +import { Ban, Binary, Braces, CircleHelp, Hash, List, User } from "lucide-react" +import { LookupEntry } from "@polkadot-api/metadata-builders" + +const customIcon = + (url: string) => + ({ size = 16, ...props }: Omit & { size?: number }) => { + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + ref.current.setAttribute("width", String(size)) + ref.current.setAttribute("height", String(size)) + }, [size]) + + return ( + { + ref.current = svg + svg.setAttribute("width", String(size)) + svg.setAttribute("height", String(size)) + }} + /> + ) + } + +export const Focus = customIcon(focusSvg) +export const Enum = customIcon(enumSvg) +export const BinaryEdit = customIcon(binarySvg) + +export const TypeIcons = { + list: List, + enum: Enum, + primitive: Hash, + binary: Binary, + account: User, + object: Braces, + maybe: CircleHelp, + void: Ban, +} +export type TypeIcon = keyof typeof TypeIcons + +export const lookupToType: Record = { + primitive: "primitive", + void: "void", + compact: "primitive", + bitSequence: "binary", + AccountId32: "account", + AccountId20: "account", + tuple: "list", + struct: "object", + sequence: "list", + array: "list", + option: "maybe", + result: "maybe", + enum: "enum", +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..93c4a67 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,153 @@ +import { XSquare } from "lucide-react" +import { + FC, + PropsWithChildren, + ReactNode, + createContext, + useEffect, + useState, +} from "react" +import { Portal } from "react-portal" +import { twMerge } from "tailwind-merge" +import "./modal.css" + +export type ModalProps = PropsWithChildren<{ + title?: ReactNode + open?: boolean + // On some cases you have to disable this (e.g. when using non-native select boxes, those popups will go under the native dialog) + useNative?: boolean + onClose?: () => void + className?: string +}> + +export const ModalRefContext = createContext(null) + +export const Modal: FC = ({ + title, + open, + children, + useNative = true, + onClose, + className, +}) => { + const [ref, setRef] = useState(null) + const isDialogCompatible = + useNative && + Boolean(typeof window === "undefined" || globalThis.HTMLDialogElement) + + useEffect(() => { + if (!ref) return + if (ref.open && !open) { + ref.close() + } else if (!ref.open && open) { + ref.showModal() + } + }, [open, ref]) + + usePreventBodyScroll(open) + const showContent = usePersistOpen(open) + + const content = showContent ? ( + + {children} + + ) : null + + if (isDialogCompatible) { + return ( + + + {content} + + + ) + } + + return ( + + + {content} + + + ) +} + +const ModalPolyfill: FC = ({ open, children, onClose }) => { + useEffect(() => { + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === "Escape") onClose?.() + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + if (!open) return null + + return ( +
{ + evt.stopPropagation() + evt.preventDefault() + }} + > +
+ {children} +
+
+ ) +} + +const ModalContent: FC< + PropsWithChildren<{ + title?: ReactNode + onClose?: () => void + className?: string + }> +> = ({ title, onClose, children, className }) => ( + <> +
+
{title}
+ +
+
+ {children} +
+ +) + +const usePreventBodyScroll = (open?: boolean) => + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden" + return () => { + document.body.style.overflow = "" + } + } + }, [open]) + +// Persists the `open` property to let the modal animate out before removing the children. +const usePersistOpen = (open?: boolean) => { + const [persist, setPersist] = useState(Boolean(open)) + + useEffect(() => { + if (open) { + setPersist(true) + } else { + const token = setTimeout(() => setPersist(false), 500) + return () => clearTimeout(token) + } + }, [open]) + + return open || persist +} diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 0000000..b7958d5 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,75 @@ +import { useState } from "react" +import { twMerge as clsx } from "tailwind-merge" +import { ChevronDown, CheckIcon } from "lucide-react" +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + ComboboxButton, +} from "@headlessui/react" + +export const SearchableSelect = ({ + value, + setValue, + options, +}: { + value: T + setValue: (val: T | null) => void + options: { value: T; text: string }[] +}) => { + const [query, setQuery] = useState("") + + const filteredOptions = + query === "" + ? options + : options.filter((option) => { + return option.text.toLowerCase().includes(query.toLowerCase()) + }) + + return ( + <> + setValue(value)} + onClose={() => setQuery("")} + > +
+ + options.find((_option) => _option.value === option)?.text ?? "" + } + onChange={(event) => setQuery(event.target.value)} + placeholder="Select" + /> + + + +
+ + + {filteredOptions + .sort((a, b) => a.text.localeCompare(b.text)) + .map((option, idx) => ( + + +
{option.text}
+
+ ))} +
+
+ + ) +} diff --git a/src/components/TextInputField.tsx b/src/components/TextInputField.tsx new file mode 100644 index 0000000..fb05d3b --- /dev/null +++ b/src/components/TextInputField.tsx @@ -0,0 +1,50 @@ +import { FC, ReactNode, useState } from "react" +import { twMerge } from "tailwind-merge" + +export const TextInputField: FC<{ + className?: string + error?: string | boolean | null + softError?: string | boolean | null + warn?: string | boolean | null + placeholder?: string + value: string + onChange: (value: string) => void + children?: (inputElement: ReactNode) => ReactNode +}> = ({ + className, + error, + softError, + warn, + value, + placeholder, + onChange, + children = (v) => v, +}) => { + const [hasBlurred, blur] = useState(false) + + return ( +
+ {children( + onChange(evt.target.value)} + onFocus={() => blur(false)} + onBlur={() => blur(true)} + />, + )} + {(hasBlurred || error) && + (typeof (error || softError) === "string" ? ( + {error || softError} + ) : typeof warn === "string" ? ( + {warn} + ) : null)} +
+ ) +} diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx new file mode 100644 index 0000000..4b0ba92 --- /dev/null +++ b/src/components/Toggle.tsx @@ -0,0 +1,23 @@ +import * as Toggle from "@radix-ui/react-toggle" + +const SliderToggle: React.FC<{ + isToggled: boolean + toggle: () => void +}> = ({ isToggled, toggle }) => { + return ( + toggle()} + className={`relative w-8 h-5 rounded-full p-1 transition-colors + ${isToggled ? "bg-polkadot-400" : "bg-gray-400"}`} + aria-label="Toggle" + > + + + ) +} + +export default SliderToggle diff --git a/src/components/icons/binary.svg b/src/components/icons/binary.svg new file mode 100644 index 0000000..f7750a2 --- /dev/null +++ b/src/components/icons/binary.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/icons/enum.svg b/src/components/icons/enum.svg new file mode 100644 index 0000000..765c082 --- /dev/null +++ b/src/components/icons/enum.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/icons/focus.svg b/src/components/icons/focus.svg new file mode 100644 index 0000000..7cfde73 --- /dev/null +++ b/src/components/icons/focus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/modal.css b/src/components/modal.css new file mode 100644 index 0000000..032910f --- /dev/null +++ b/src/components/modal.css @@ -0,0 +1,48 @@ +/* standarize native modal */ +.modal { + max-width: calc(100% - 6px - 2em); + max-height: calc(100% - 6px - 2em); +} + +dialog.modal[open] { + opacity: 1; + transform: translateY(0); +} + +dialog.modal { + opacity: 0; + transform: translateY(30px); + transition: + opacity 0.3s ease-out, + transform 0.3s ease-out, + overlay 0.3s ease-out allow-discrete, + display 0.3s ease-out allow-discrete; +} + +@starting-style { + dialog.modal[open] { + opacity: 0; + transform: translateY(30px); + } +} + +/* Transition the :backdrop when the dialog modal is promoted to the top layer */ +dialog.modal::backdrop { + opacity: 0; + transition: + display 0.1s allow-discrete, + overlay 0.1s allow-discrete, + opacity 0.1s; +} + +dialog.modal[open]::backdrop { + opacity: 1; +} + +/* This starting-style rule cannot be nested inside the above selector +because the nesting selector cannot represent pseudo-elements. */ +@starting-style { + dialog.modal[open]::backdrop { + opacity: 0; + } +} diff --git a/src/components/useHeightObserver.ts b/src/components/useHeightObserver.ts new file mode 100644 index 0000000..e8eaa69 --- /dev/null +++ b/src/components/useHeightObserver.ts @@ -0,0 +1,17 @@ +import { useRefEffect } from "./useRefEffect" + +export const useHeightObserver = (onChange: (height: number) => void) => + useRefEffect( + (element) => { + const observer = new ResizeObserver((cb) => { + onChange(cb[0]?.borderBoxSize[0]?.blockSize ?? 0) + }) + + observer.observe(element) + return () => { + observer.unobserve(element) + observer.disconnect() + } + }, + [onChange], + ) diff --git a/src/components/useRefEffect.ts b/src/components/useRefEffect.ts new file mode 100644 index 0000000..242b6d1 --- /dev/null +++ b/src/components/useRefEffect.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useRef } from "react" + +export const useRefEffect = ( + effect: (value: T) => () => void, + deps: any[] = [], +) => { + const ref = useRef<[(() => void) | null, T] | null>(null) + const setRef = useCallback((value: T | null) => { + if (ref.current?.[0]) { + ref.current?.[0]() + } + ref.current = value ? [effect(value), value] : null + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (ref.current && !ref.current[0]) { + ref.current[0] = effect(ref.current[1]) + } + return () => { + if (ref.current) { + ref.current[0]?.() + ref.current[0] = null + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) + + return setRef +} diff --git a/src/components/useSynchroniseInput.ts b/src/components/useSynchroniseInput.ts new file mode 100644 index 0000000..d52aa3a --- /dev/null +++ b/src/components/useSynchroniseInput.ts @@ -0,0 +1,48 @@ +import { NOTIN } from "@codec-components" +import { useEffect, useLayoutEffect, useRef, useState } from "react" + +export const useGenericSynchronizeInput = ( + value: T | NOTIN, + onValueChanged: (value: T | NOTIN) => void, + parseValue: (value: R) => T | NOTIN, + defaultValue: R, + serializeValue: (value: T) => R, + eqFn: (input: R, value: T | NOTIN) => boolean, +) => { + const [inputValue, setInputValue] = useState(defaultValue) + const values = useRef({ + editor: value, + input: inputValue, + }) + + useLayoutEffect(() => { + values.current.editor = value + if (!eqFn(values.current.input, value)) { + setInputValue(value === NOTIN ? defaultValue : serializeValue(value)) + } + }, [value]) + + useEffect(() => { + values.current.input = inputValue + const parsed = parseValue(inputValue) + if (parsed !== values.current.editor) { + onValueChanged(parsed) + } + }, [inputValue]) + + return [inputValue, setInputValue] as const +} + +export const useSynchronizeInput = ( + value: T | NOTIN, + onValueChanged: (value: T | NOTIN) => void, + parseValue: (value: string) => T | NOTIN, +) => + useGenericSynchronizeInput( + value, + onValueChanged, + parseValue, + "", + String, + (input, value) => parseValue(input) === value, + ) diff --git a/src/index.css b/src/index.css index b5c61c9..f7a1369 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,18 @@ @tailwind base; @tailwind components; @tailwind utilities; + +input, dialog { + @apply bg-black +} + + +.visible_when_parent_hover { + opacity: 0; + pointer-events: none; + transition: 0.2s opacity; +} +*:hover > .visible_when_parent_hover { + opacity: 1; + pointer-events: all; +} diff --git a/src/lib/codecComponents/get-codec-component.tsx b/src/lib/codecComponents/get-codec-component.tsx index 82fe524..d4fdf47 100644 --- a/src/lib/codecComponents/get-codec-component.tsx +++ b/src/lib/codecComponents/get-codec-component.tsx @@ -369,7 +369,7 @@ export function getViewCodecComponent( switch (entry.type) { case "compact": { ResultComponent = entry.isBig ? CBigNumber : CNumber - type = (entry as any).size + type = (entry as any).size ?? "u128" break } case "primitive": { @@ -833,7 +833,7 @@ export function getCodecComponent( switch (entry.type) { case "compact": { ResultComponent = entry.isBig ? CBigNumber : CNumber - type = (entry as any).size + type = (entry as any).size ?? "u128" break } case "primitive": { diff --git a/src/pages/Extrinsics/BinaryEditModal.tsx b/src/pages/Extrinsics/BinaryEditModal.tsx new file mode 100644 index 0000000..e1f7178 --- /dev/null +++ b/src/pages/Extrinsics/BinaryEditModal.tsx @@ -0,0 +1,149 @@ +import { Binary } from "@polkadot-api/substrate-bindings" +import { Download, FileUp } from "lucide-react" +import { FC, useState } from "react" +import { twMerge } from "tailwind-merge" +import { useGenericSynchronizeInput } from "@/components/useSynchroniseInput" +import { NOTIN } from "@codec-components" +import { + BinaryFileInput, + checkEqualInputBinary, +} from "@/components/BinaryInput" +import { Modal } from "@/components/Modal" +// @ts-expect-error save-as typings not available +import { saveAs } from "save-as" +import { BinaryStatus } from "./EditCodec/Tree/codec-components" + +export const BinaryEditModal: FC<{ + status: BinaryStatus + open: boolean + path: string + onClose: () => void +}> = ({ status, path, open, onClose }) => ( + + + {path} + + + +) + +const MAX_DISPLAY_SIZE = 5 * 1024 * 1024 +const BinaryEditModalContent: FC<{ + path: string + status: BinaryStatus + onClose: () => void +}> = ({ path, status, onClose }) => { + const [value, setValue] = useState(status.encodedValue ?? new Uint8Array()) + const [error, setError] = useState("") + const [inputValue, setInputValue] = useGenericSynchronizeInput( + value, + setValue as any, + (input) => parseInput(input).asBytes(), + "" as string | Uint8Array, + (value) => { + if (value.length > MAX_DISPLAY_SIZE) return "" + return Binary.fromBytes(value).asHex() + }, + (input, value) => + checkEqualInputBinary( + parseInput(input), + value === NOTIN ? NOTIN : Binary.fromBytes(value), + ), + ) + + const placeholder = + value.length > MAX_DISPLAY_SIZE ? "(value is too long to display)" : "" + const isValid = status.decode(value) !== NOTIN + const textareaValue = inputValue instanceof Uint8Array ? "" : inputValue + + const submit = () => { + if (status.decode(value) !== NOTIN) { + status.onValueChanged(status.decode(value)) + onClose() + } else { + // Shouldn't happen, but it's better than just swallowing that it failed. + setError("Couldn't set the binary data 😢") + } + } + + const validateFile = (file: File) => { + if (file.size > 512 * 1024 * 1024) { + setError("File size can't exceed 512MB") + return false + } + return true + } + + const downloadDisabled = value.length === 0 + const download = () => { + saveAs( + new Blob([value], { + type: "application/octet-stream", + }), + `${path.replace(/\./g, "__") || "root"}.dat`, + ) + } + + return ( + <> +
+
+ +
+
+ +
+
+
+
Hex data
+