├── .eslintrc.js ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── res ├── editor.css └── icons.png ├── rollup.config.js ├── src ├── Downloader.ts ├── NbtDocument.ts ├── NbtEditor.ts ├── WebviewCollection.ts ├── common │ ├── NbtPath.ts │ ├── Operations.ts │ └── types.ts ├── dispose.ts ├── editor │ ├── AlphaMaterials.ts │ ├── BlockFlags.ts │ ├── ChunkEditor.ts │ ├── Editor.ts │ ├── FileInfoEditor.ts │ ├── Locale.ts │ ├── MapEditor.ts │ ├── MultiStructure.ts │ ├── ResourceManager.ts │ ├── Schematics.ts │ ├── SnbtEditor.ts │ ├── StructureEditor.ts │ ├── TreeEditor.ts │ └── Util.ts ├── extension.ts ├── fileUtil.ts └── mcmeta.ts ├── tsconfig.eslint.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "tsconfigRootDir": __dirname, 9 | "project": "./tsconfig.eslint.json" 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint" 13 | ], 14 | "ignorePatterns": [ 15 | "**/out", 16 | "**/res", 17 | "**/node_modules", 18 | ".eslintrc.js" 19 | ], 20 | "rules": { 21 | "@typescript-eslint/consistent-type-imports": [ 22 | "warn", 23 | { 24 | "prefer": "type-imports" 25 | } 26 | ], 27 | "@typescript-eslint/prefer-readonly": "warn", 28 | "@typescript-eslint/quotes": [ 29 | "warn", 30 | "single", 31 | { 32 | "avoidEscape": true 33 | } 34 | ], 35 | "@typescript-eslint/semi": [ 36 | "warn", 37 | "never" 38 | ], 39 | "@typescript-eslint/indent": [ 40 | "warn", 41 | "tab" 42 | ], 43 | "@typescript-eslint/member-delimiter-style": [ 44 | "warn", 45 | { 46 | "multiline": { 47 | "delimiter": "comma", 48 | "requireLast": true 49 | }, 50 | "singleline": { 51 | "delimiter": "comma", 52 | "requireLast": false 53 | }, 54 | "overrides": { 55 | "interface": { 56 | "multiline": { 57 | "delimiter": undefined 58 | } 59 | } 60 | } 61 | } 62 | ], 63 | "comma-dangle": "off", 64 | "@typescript-eslint/comma-dangle": ["warn", "always-multiline"], 65 | "indent": "off", 66 | "eol-last": "warn", 67 | "no-fallthrough": "warn", 68 | "prefer-const": "warn", 69 | "prefer-object-spread": "warn", 70 | "quote-props": [ 71 | "warn", 72 | "as-needed" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | node_modules/ 3 | .vscode-test/ 4 | *.vsix 5 | /res/generated/ 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": [ 10 | "--extensionDevelopmentPath=${workspaceFolder}" 11 | ], 12 | "outFiles": [ 13 | "${workspaceFolder}/out/**/*.js" 14 | ], 15 | "preLaunchTask": "${defaultBuildTask}" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit", 5 | "source.fixAll.eslint": "explicit" 6 | } 7 | }, 8 | "[javascript]": { 9 | "editor.codeActionsOnSave": { 10 | "source.organizeImports": "explicit", 11 | "source.fixAll.eslint": "explicit" 12 | } 13 | }, 14 | "editor.insertSpaces": false 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Watch Extension", 8 | "type": "npm", 9 | "script": "dev", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | }, 18 | "problemMatcher": { 19 | "base": "$tsc-watch", 20 | "background": { 21 | "activeOnStart": true, 22 | "beginsPattern": "^bundles ", 23 | "endsPattern": "waiting for changes\\.\\.\\.$" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | node_modules/** 4 | !node_modules/vscode-codicons/dist/** 5 | src/** 6 | .gitignore 7 | **/tsconfig.json 8 | **/tsconfig.eslint.json 9 | **/rollup.config.js 10 | **/.eslintrc.json 11 | **/*.map 12 | **/*.ts 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Misode 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NBT Viewer 2 | VSCode extension to view Minecraft NBT files. For structure files, this shows a 3D block view. 3 | 4 | ![](https://user-images.githubusercontent.com/17352009/104337363-b4aeb000-54f5-11eb-93a2-47ce2e3e4fea.png) 5 | 6 | ## Credits 7 | 8 | * [AmberW](https://github.com/AmberWat) for the NBT icons 9 | * [SPGoding](https://github.com/SPGoding) for the downloading and caching logic from [Spyglass](https://github.com/SpyglassMC/Spyglass) 10 | 11 | ## Disclaimer 12 | While this extension has been tested, it can technically corrupt your files. Always be careful and backup important files. 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-nbt", 3 | "displayName": "NBT Viewer", 4 | "description": "View Minecraft NBT and 3D structures", 5 | "version": "0.9.3", 6 | "preview": true, 7 | "publisher": "Misodee", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/misode/vscode-nbt.git" 11 | }, 12 | "engines": { 13 | "vscode": "^1.46.0" 14 | }, 15 | "categories": [ 16 | "Other" 17 | ], 18 | "activationEvents": [ 19 | "onCustomEditor:nbtEditor.nbt" 20 | ], 21 | "main": "./out/extension.js", 22 | "capabilities": { 23 | "untrustedWorkspaces": { 24 | "supported": true 25 | } 26 | }, 27 | "contributes": { 28 | "customEditors": [ 29 | { 30 | "viewType": "nbtEditor.nbt", 31 | "displayName": "NBT Viewer", 32 | "selector": [ 33 | { 34 | "filenamePattern": "*.nbt" 35 | }, 36 | { 37 | "filenamePattern": "*.dat" 38 | }, 39 | { 40 | "filenamePattern": "*.dat_old" 41 | }, 42 | { 43 | "filenamePattern": "*.mca" 44 | }, 45 | { 46 | "filenamePattern": "*.mcstructure" 47 | }, 48 | { 49 | "filenamePattern": "*.schematic" 50 | }, 51 | { 52 | "filenamePattern": "*.litematic" 53 | }, 54 | { 55 | "filenamePattern": "*.schem" 56 | } 57 | ], 58 | "priority": "default" 59 | } 60 | ] 61 | }, 62 | "scripts": { 63 | "vscode:prepublish": "npm run build", 64 | "build": "rollup --config", 65 | "dev": "rollup --config --watch", 66 | "lint": "eslint . --ext .ts" 67 | }, 68 | "dependencies": { 69 | "deepslate": "^0.22.2", 70 | "env-paths": "^2.2.1", 71 | "follow-redirects": "^1.14.8", 72 | "gl-matrix": "^3.4.3", 73 | "tar": "^6.1.11", 74 | "vscode-codicons": "^0.0.14" 75 | }, 76 | "devDependencies": { 77 | "@rollup/plugin-commonjs": "^20.0.0", 78 | "@rollup/plugin-node-resolve": "^13.0.6", 79 | "@rollup/plugin-typescript": "^8.3.0", 80 | "@types/follow-redirects": "^1.14.0", 81 | "@types/node": "^14.14.37", 82 | "@types/tar": "^6.1.1", 83 | "@types/vscode": "^1.46.0", 84 | "@typescript-eslint/eslint-plugin": "^5.3.1", 85 | "@typescript-eslint/parser": "^5.3.1", 86 | "eslint": "^8.2.0", 87 | "rollup": "^2.59.0", 88 | "ts-node": "^10.4.0", 89 | "typescript": "^4.4.4" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /res/editor.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | --tree-border: var(--vscode-descriptionForeground); 4 | --monospace: 'Monaco', 'Menlo', 'Consolas', 'Droid Sans Mono', 'Inconsolata', 'Courier New', 'monospace'; 5 | } 6 | 7 | html, body { 8 | height: 100%; 9 | font-size: 14px; 10 | } 11 | 12 | input { 13 | background: var(--vscode-input-background); 14 | color: var(--vscode-input-foreground); 15 | border: 1px solid var(--vscode-input-border); 16 | font-family: Segoe WPC, Segoe UI, sans-serif; 17 | padding: 2px 4px; 18 | } 19 | 20 | button:focus, 21 | select:focus, 22 | input:focus { 23 | outline: 1px solid var(--vscode-focusBorder); 24 | outline-offset: -1px; 25 | } 26 | 27 | input::placeholder { 28 | color: var(--vscode-input-placeholderForeground) 29 | } 30 | 31 | input::selection { 32 | background: var(--vscode-selection-background, var(--vscode-editor-selectionBackground)); 33 | } 34 | 35 | .nbt-editor { 36 | display: flex; 37 | flex-direction: column; 38 | width: 100%; 39 | height: 100%; 40 | } 41 | 42 | .nbt-content { 43 | padding: 30px 0; 44 | } 45 | 46 | .nbt-editor > .nbt-content { 47 | min-height: 100vh; 48 | } 49 | 50 | .nbt-warning { 51 | display: none; 52 | position: absolute; 53 | left: 0; 54 | right: 0; 55 | width: 100%; 56 | height: 100%; 57 | flex-direction: column; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | .nbt-warning .btn { 63 | margin-top: 10px; 64 | } 65 | 66 | .nbt-warning.active { 67 | display: flex; 68 | } 69 | 70 | .nbt-warning.active ~ canvas { 71 | display: none; 72 | } 73 | 74 | .nbt-tag { 75 | height: 22px; 76 | cursor: default; 77 | user-select: none; 78 | display: flex; 79 | align-items: center; 80 | } 81 | 82 | .nbt-tag:hover { 83 | background: var(--vscode-list-hoverBackground); 84 | } 85 | 86 | .nbt-tag.selected { 87 | background-color: var(--vscode-list-activeSelectionBackground); 88 | } 89 | 90 | .nbt-tag.highlighted { 91 | background-color: var(--vscode-editor-findMatchBackground); 92 | } 93 | 94 | .nbt-tag:not(.collapse) { 95 | position: relative; 96 | padding-left: 13px; 97 | margin-left: 7px; 98 | border-left: 1px solid var(--tree-border); 99 | } 100 | 101 | div:last-child > .nbt-tag { 102 | border: none; 103 | } 104 | 105 | div:last-child > .nbt-tag:not(.collapse) > [data-icon] { 106 | margin-left: 1px; 107 | } 108 | 109 | .nbt-tag:not(.collapse)::before { 110 | content: ''; 111 | position: absolute; 112 | width: 9px; 113 | height: 10px; 114 | top: 0; 115 | left: 0; 116 | border-bottom: 1px solid var(--tree-border); 117 | } 118 | 119 | div:last-child > .nbt-tag:not(.collapse)::before { 120 | border-left: 1px solid var(--tree-border); 121 | } 122 | 123 | .nbt-body { 124 | padding-left: 12px; 125 | margin-left: 7px; 126 | border-left: 1px solid var(--tree-border); 127 | } 128 | 129 | div:last-child > .nbt-body { 130 | border-color: transparent; 131 | } 132 | 133 | .nbt-entries { 134 | font-style: italic; 135 | } 136 | 137 | .nbt-tag > *:not(:last-child) { 138 | margin-right: 3px; 139 | } 140 | 141 | .nbt-key { 142 | line-height: 1; 143 | } 144 | 145 | .nbt-tag input { 146 | padding: 1px 2px; 147 | width: 250px; 148 | font-family: var(--monospace); 149 | font-size: 14px; 150 | } 151 | 152 | .nbt-tag button { 153 | border: none; 154 | background-color: var(--vscode-button-background); 155 | color: var(--vscode-button-foreground); 156 | font-family: var(--vscode-font-family); 157 | } 158 | 159 | .nbt-tag > .hidden { 160 | display: none; 161 | } 162 | 163 | span.nbt-value { 164 | text-overflow: ellipsis; 165 | overflow: hidden; 166 | white-space: nowrap; 167 | margin: 0 2px; 168 | font-family: var(--monospace); 169 | } 170 | 171 | .center { 172 | display: flex; 173 | flex-direction: column; 174 | justify-content: center; 175 | align-items: center; 176 | width: 100%; 177 | height: 100%; 178 | } 179 | 180 | .error { 181 | color: var(--vscode-errorForeground); 182 | } 183 | 184 | .nbt-collapse { 185 | display: inline-block; 186 | width: 12px; 187 | height: 12px; 188 | margin: 2px; 189 | text-align: center; 190 | border: 1px solid var(--tree-border); 191 | color: var(--tree-border); 192 | font-family: monospace; 193 | font-size: 12px; 194 | line-height: 12px; 195 | font-weight: bold; 196 | margin-right: 7px !important; 197 | } 198 | 199 | .type-select, 200 | [data-icon] { 201 | display: inline-block; 202 | background-image: url('./icons.png'); 203 | background-size: 64px; 204 | width: 16px; 205 | height: 16px; 206 | min-width: 16px; 207 | } 208 | 209 | [data-icon=Byte] { background-position: 0 0; } 210 | [data-icon=Double] { background-position: -16px 0; } 211 | [data-icon=Float] { background-position: -32px 0; } 212 | [data-icon=Int] { background-position: -48px 0; } 213 | [data-icon=Long] { background-position: 0 -16px; } 214 | [data-icon=Short] { background-position: -16px -16px; } 215 | [data-icon=String] { background-position: -32px -16px; } 216 | [data-icon=Compound] { background-position: -48px -16px; } 217 | [data-icon=List] { background-position: -32px -32px; } 218 | [data-icon=ByteArray] { background-position: 0 -32px; } 219 | [data-icon=IntArray] { background-position: -16px -32px; } 220 | [data-icon=LongArray] { background-position: 0 -48px; } 221 | [data-icon=Chunk] { background-position: -16px -48px; } 222 | [data-icon=Number] { background-position: -32px -48px; } 223 | [data-icon=Null] { background-position: -48px -48px; } 224 | [data-icon=Any] { 225 | background: var(--vscode-input-background); 226 | } 227 | 228 | .type-select { 229 | position: relative; 230 | border: none; 231 | width: 28px; 232 | min-width: 28px; 233 | } 234 | 235 | .type-select select { 236 | position: absolute; 237 | z-index: 1; 238 | width: 28px; 239 | color: transparent; 240 | background: transparent; 241 | border: 1px solid var(--vscode-input-border); 242 | } 243 | 244 | .type-select option { 245 | color: var(--vscode-input-foreground); 246 | background: var(--vscode-input-background); 247 | } 248 | 249 | .type-select::before { 250 | content: ''; 251 | position: absolute; 252 | top: 0; 253 | right: 0; 254 | width: 12px; 255 | height: 16px; 256 | background: var(--vscode-input-background); 257 | } 258 | 259 | .type-select::after { 260 | content: ''; 261 | position: absolute; 262 | top: 6px; 263 | right: 2px; 264 | width: 0; 265 | height: 0; 266 | border-left: 4px solid transparent; 267 | border-right: 4px solid transparent; 268 | border-top: 4px solid var(--vscode-input-foreground); 269 | } 270 | 271 | .structure-3d { 272 | display: block; 273 | position: absolute; 274 | top: 0; 275 | left: 0; 276 | width: 100vw; 277 | height: 100vh; 278 | background-color: var(--vscode-editor-background); 279 | border: none; 280 | } 281 | 282 | .structure-3d.click-detection { 283 | z-index: -1; 284 | image-rendering: pixelated; 285 | } 286 | 287 | .texture-atlas { 288 | display: none; 289 | } 290 | 291 | .panel-menu { 292 | position: fixed; 293 | z-index: 1; 294 | top: 5px; 295 | left: 20px; 296 | display: flex; 297 | } 298 | 299 | .region-menu { 300 | position: fixed; 301 | z-index: 2; 302 | width: 100%; 303 | height: 35px; 304 | top: 0; 305 | left: 0; 306 | background-color: var(--vscode-sideBar-background); 307 | border: 1px solid var(--vscode-sideBarSectionHeader-border); 308 | border-left: transparent; 309 | border-right: transparent; 310 | display: flex; 311 | align-items: center; 312 | padding: 0 20px; 313 | white-space: nowrap; 314 | } 315 | 316 | .region-menu ~ .panel-menu { 317 | top: 40px; 318 | } 319 | 320 | .region-menu ~ .find-widget { 321 | top: 35px; 322 | } 323 | 324 | .region-menu ~ .nbt-editor > .nbt-content { 325 | padding-top: 65px; 326 | } 327 | 328 | .region-menu ~ .nbt-editor > .side-panel { 329 | top: 72px 330 | } 331 | 332 | .region-menu input { 333 | margin-left: 3px; 334 | width: 50px; 335 | } 336 | 337 | .region-menu.invalid input { 338 | background-color: var(--vscode-inputValidation-errorBackground); 339 | border-color: var(--vscode-inputValidation-errorBorder); 340 | } 341 | 342 | .region-menu label { 343 | margin-left: 10px; 344 | } 345 | 346 | .menu-spacer { 347 | margin: 0 10px; 348 | } 349 | 350 | .btn { 351 | background-color: var(--vscode-sideBar-background); 352 | color: var(--vscode-sideBar-foreground); 353 | padding: 2px 8px; 354 | cursor: pointer; 355 | box-shadow: 0 0 4px 2px var(--vscode-widget-shadow); 356 | user-select: none; 357 | -webkit-user-select: none; 358 | } 359 | 360 | .btn.active { 361 | background-color: var(--vscode-button-background); 362 | color: var(--vscode-button-foreground); 363 | outline: 1px solid var(--vscode-button-border); 364 | z-index: 1; 365 | } 366 | 367 | .btn.disabled { 368 | display: none; 369 | } 370 | 371 | .btn.unavailable { 372 | cursor: unset; 373 | background-color: var(--vscode-button-secondaryBackground); 374 | } 375 | 376 | .btn:not(:last-child) { 377 | margin-right: 5px; 378 | } 379 | 380 | .btn-group { 381 | display: flex; 382 | box-shadow: 0 0 4px 2px var(--vscode-widget-shadow); 383 | } 384 | 385 | .btn-group > .btn { 386 | margin-right: 0; 387 | box-shadow: none; 388 | } 389 | 390 | .spinner { 391 | position: absolute; 392 | top: 80px; 393 | left: 50%; 394 | transform: translateX(-50%); 395 | width: 80px; 396 | height: 80px; 397 | } 398 | 399 | .spinner:after { 400 | content: ""; 401 | display: block; 402 | width: 64px; 403 | height: 64px; 404 | margin: 8px; 405 | border-radius: 50%; 406 | border: 6px solid #333333; 407 | border-color: #333333 transparent #333333 transparent; 408 | animation: spinner 1.2s linear infinite, fadein 0.4s; 409 | } 410 | 411 | @keyframes spinner { 412 | 0% { transform: rotate(0deg); } 413 | 100% { transform: rotate(360deg); } 414 | } 415 | 416 | @keyframes fadein { 417 | from { opacity: 0; } 418 | to { opacity: 1; } 419 | } 420 | 421 | .snbt-editor { 422 | background-color: var(--vscode-editor-background); 423 | color: var(--vscode-editor-foreground); 424 | font-family: var(--monospace); 425 | font-size: 14px; 426 | width: 100%; 427 | border: none; 428 | outline: none !important; 429 | resize: none; 430 | white-space: pre; 431 | overflow-wrap: normal; 432 | overflow-x: scroll; 433 | overflow-y: hidden; 434 | } 435 | 436 | .snbt-editor::selection { 437 | background-color: var(--vscode-editor-selectionBackground); 438 | } 439 | 440 | .side-panel { 441 | background-color: var(--vscode-sideBar-background); 442 | border: 1px solid var(--vscode-sideBar-border); 443 | position: absolute; 444 | left: 20px; 445 | top: 37px; 446 | padding: 4px 8px; 447 | max-width: 400px; 448 | overflow: hidden; 449 | } 450 | 451 | .side-panel .nbt-content { 452 | padding: 10px 0 0; 453 | } 454 | 455 | .block-name { 456 | color: var(--vscode-textLink-foreground); 457 | font-size: large; 458 | } 459 | 460 | .block-pos { 461 | word-spacing: 5px; 462 | font-size: 15px; 463 | } 464 | 465 | .block-props { 466 | padding-top: 5px; 467 | display: inline-grid; 468 | grid-template-columns: auto auto; 469 | } 470 | 471 | .prop-key { 472 | grid-column: 1; 473 | color: var(--vscode-list-activeSelectionForeground); 474 | } 475 | 476 | .prop-value { 477 | grid-column: 2; 478 | color: var(--vscode-foreground); 479 | padding-left: 9px; 480 | } 481 | 482 | .block-props > * { 483 | border-bottom: 1px solid var(--vscode-editorWidget-border); 484 | margin-bottom: 2px; 485 | } 486 | 487 | .structure-size input { 488 | width: 70px; 489 | height: 25px; 490 | margin-left: 7px; 491 | } 492 | 493 | .file-info { 494 | position: fixed; 495 | display: none; 496 | z-index: 2; 497 | padding: 10px; 498 | background-color: var(--vscode-editorWidget-background); 499 | border: 1px solid var(--vscode-contrastBorder); 500 | box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); 501 | top: 32px; 502 | left: 20px; 503 | } 504 | 505 | .file-info.active { 506 | display: block; 507 | } 508 | 509 | .find-widget { 510 | position: fixed; 511 | z-index: 1; 512 | top: 0px; 513 | right: 14px; 514 | width: 500px; 515 | height: 33px; 516 | background: var(--vscode-editorWidget-background); 517 | color: var(--vscode-editorWidget-foreground); 518 | border: 1px solid var(--vscode-contrastBorder); 519 | box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); 520 | transform: translateY(calc(-100% - 10px)); 521 | transition: transform 0.2s linear; 522 | } 523 | 524 | .find-widget.expanded { 525 | height: 62px; 526 | } 527 | 528 | .find-widget.visible { 529 | transform: translateY(0); 530 | } 531 | 532 | .find-widget .button.replace-expand { 533 | position: absolute; 534 | top: 0; 535 | left: 0; 536 | width: 18px; 537 | height: 100%; 538 | margin-left: 0; 539 | margin-right: 3px; 540 | box-sizing: border-box; 541 | } 542 | 543 | .find-widget > .find-part, 544 | .find-widget > .replace-part { 545 | margin: 4px 0 0 17px; 546 | font-size: 12px; 547 | display: flex; 548 | align-items: center; 549 | } 550 | 551 | .find-widget:not(.expanded) > .replace-part { 552 | display: none; 553 | } 554 | 555 | .find-widget .type-select { 556 | margin-left: 4px; 557 | align-self: center; 558 | } 559 | 560 | .find-widget > .replace-part .type-select { 561 | visibility: hidden; 562 | } 563 | 564 | .find-widget input { 565 | width: 150px; 566 | height: 25px; 567 | margin-left: 7px; 568 | } 569 | 570 | .find-widget .button { 571 | width: 20px; 572 | height: 20px; 573 | margin-left: 3px; 574 | cursor: pointer; 575 | display: flex; 576 | align-items: center; 577 | justify-content: center; 578 | } 579 | 580 | .find-widget .matches { 581 | min-width: 60px; 582 | height: 25px; 583 | margin-left: 3px; 584 | padding: 2px 0 0 2px; 585 | font-size: 12px; 586 | line-height: 23px; 587 | vertical-align: middle; 588 | } 589 | 590 | .find-widget.no-results .matches { 591 | color: var(--vscode-errorForeground); 592 | } 593 | 594 | .find-widget .button.disabled { 595 | opacity: 0.3; 596 | cursor: default; 597 | } 598 | 599 | .nbt-map { 600 | width: 512px; 601 | height: 512px; 602 | image-rendering: pixelated; 603 | } 604 | 605 | .region-menu .map-toggle { 606 | margin-right: 15px; 607 | } 608 | 609 | .region-map { 610 | padding-top: 45px; 611 | padding-bottom: 20px; 612 | max-width: 1152px; 613 | margin: 0 auto; 614 | display: grid; 615 | grid-template-columns: repeat(32, 1fr); 616 | } 617 | 618 | .region-map-chunk { 619 | aspect-ratio: 1/1; 620 | background-color: var(--vscode-sideBar-background); 621 | color: var(--vscode-sideBar-foreground); 622 | border: 1px solid var(--vscode-sideBarSectionHeader-border); 623 | padding: 1px; 624 | display: flex; 625 | justify-content: center; 626 | align-items: center; 627 | font-size: 10px; 628 | min-width: 32px; 629 | white-space: nowrap; 630 | cursor: pointer; 631 | } 632 | 633 | .region-map-chunk:nth-child(-n+32) { 634 | border-top-width: 2px; 635 | } 636 | 637 | .region-map-chunk:nth-child(n+993) { 638 | border-bottom-width: 2px; 639 | } 640 | 641 | .region-map-chunk:nth-child(32n+1) { 642 | border-left-width: 2px; 643 | } 644 | 645 | .region-map-chunk:nth-child(32n) { 646 | border-right-width: 2px; 647 | } 648 | 649 | .region-map-chunk.empty { 650 | background-color: transparent; 651 | color: transparent; 652 | cursor: initial; 653 | user-select: none; 654 | } 655 | 656 | .region-map-chunk.loaded { 657 | background-color: var(--vscode-menu-selectionBackground); 658 | } 659 | 660 | .region-map-chunk.invalid { 661 | background-color: var(--vscode-inputValidation-errorBackground); 662 | } 663 | 664 | .hidden { 665 | display: none !important; 666 | } 667 | -------------------------------------------------------------------------------- /res/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/misode/vscode-nbt/43ee1769564579f99190e7dbb313ba04f69ffe90/res/icons.png -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import typescript from '@rollup/plugin-typescript' 4 | import { defineConfig } from 'rollup' 5 | 6 | export default defineConfig([ 7 | { 8 | input: 'src/extension.ts', 9 | output: [ 10 | { 11 | file: 'out/extension.js', 12 | format: 'cjs', 13 | sourcemap: true, 14 | }, 15 | ], 16 | external: ['vscode'], 17 | plugins: [ 18 | resolve(), 19 | commonjs(), 20 | typescript(), 21 | ], 22 | onwarn, 23 | }, 24 | { 25 | input: 'src/editor/Editor.ts', 26 | output: [ 27 | { 28 | file: 'out/editor.js', 29 | format: 'iife', 30 | name: 'nbtEditor', 31 | sourcemap: true, 32 | }, 33 | ], 34 | plugins: [ 35 | resolve(), 36 | commonjs(), 37 | typescript(), 38 | ], 39 | onwarn, 40 | }, 41 | ]) 42 | 43 | function onwarn(warning) { 44 | if (warning.code === 'CIRCULAR_DEPENDENCY') { 45 | return 46 | } 47 | console.warn(`(!) ${warning.message}`) 48 | } 49 | -------------------------------------------------------------------------------- /src/Downloader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Downloader from Spyglass 3 | * MIT License 4 | * Copyright (c) 2019-2022 SPGoding 5 | */ 6 | import { http, https } from 'follow-redirects' 7 | import { promises as fsp } from 'fs' 8 | import type { IncomingMessage } from 'http' 9 | import path from 'path' 10 | import type { Logger } from './common/types' 11 | import { bufferToString, fileUtil, isEnoent, promisifyAsyncIterable } from './fileUtil' 12 | 13 | type RemoteProtocol = 'http:' | 'https:' 14 | export type RemoteUriString = `${RemoteProtocol}${string}` 15 | export namespace RemoteUriString { 16 | export function getProtocol(uri: RemoteUriString): RemoteProtocol { 17 | return uri.slice(0, uri.indexOf(':') + 1) as RemoteProtocol 18 | } 19 | } 20 | 21 | export interface DownloaderDownloadOut { 22 | cachePath?: string, 23 | checksum?: string, 24 | } 25 | 26 | export class Downloader { 27 | constructor( 28 | private readonly cacheRoot: string, 29 | private readonly logger: Logger, 30 | private readonly lld = LowLevelDownloader.create(), 31 | ) { } 32 | 33 | async download(job: Job, out: DownloaderDownloadOut = {}): Promise { 34 | const { id, cache, uri, options, transformer } = job 35 | let checksum: string | undefined 36 | let cachePath: string | undefined 37 | let cacheChecksumPath: string | undefined 38 | if (cache) { 39 | const { checksumJob, checksumExtension } = cache 40 | out.cachePath = cachePath = path.join(this.cacheRoot, id) 41 | cacheChecksumPath = path.join(this.cacheRoot, id + checksumExtension) 42 | try { 43 | out.checksum = checksum = await this.download({ ...checksumJob, id: id + checksumExtension }) 44 | try { 45 | const cacheChecksum = bufferToString(await fileUtil.readFile(fileUtil.pathToFileUri(cacheChecksumPath))) 46 | .slice(0, -1) // Remove ending newline 47 | if (checksum === cacheChecksum) { 48 | try { 49 | const cachedBuffer = await fileUtil.readFile(fileUtil.pathToFileUri(cachePath)) 50 | const deserializer = cache.deserializer ?? (b => b) 51 | const ans = await transformer(await deserializer(cachedBuffer)) 52 | this.logger.info(`[Downloader] [${id}] Skipped downloading thanks to cache ${cacheChecksum}`) 53 | return ans 54 | } catch (e) { 55 | this.logger.error(`[Downloader] [${id}] Loading cached file “${cachePath}”`, e) 56 | if (isEnoent(e)) { 57 | // Cache checksum exists, but cached file doesn't. 58 | // Remove the invalid cache checksum. 59 | try { 60 | await fsp.unlink(cacheChecksumPath) 61 | } catch (e) { 62 | this.logger.error(`[Downloader] [${id}] Removing invalid cache checksum “${cacheChecksumPath}”`, e) 63 | } 64 | } 65 | } 66 | } 67 | } catch (e) { 68 | if (!isEnoent(e)) { 69 | this.logger.error(`[Downloader] [${id}] Loading cache checksum “${cacheChecksumPath}”`, e) 70 | } 71 | } 72 | } catch (e) { 73 | this.logger.error(`[Downloader] [${id}] Fetching latest checksum “${checksumJob.uri}”`, e) 74 | } 75 | } 76 | 77 | try { 78 | const buffer = await this.lld.get(uri, options) 79 | if (cache && cachePath && cacheChecksumPath) { 80 | if (checksum) { 81 | try { 82 | await fileUtil.writeFile(fileUtil.pathToFileUri(cacheChecksumPath), `${checksum}\n`) 83 | } catch (e) { 84 | this.logger.error(`[Downloader] [${id}] Saving cache checksum “${cacheChecksumPath}”`, e) 85 | } 86 | } 87 | try { 88 | const serializer = cache.serializer ?? (b => b) 89 | await fileUtil.writeFile(fileUtil.pathToFileUri(cachePath), await serializer(buffer)) 90 | } catch (e) { 91 | this.logger.error(`[Downloader] [${id}] Caching file “${cachePath}”`, e) 92 | } 93 | } 94 | this.logger.info(`[Downloader] [${id}] Downloaded from “${uri}”`) 95 | return await transformer(buffer) 96 | } catch (e) { 97 | this.logger.error(`[Downloader] [${id}] Downloading “${uri}”`, e) 98 | if (cache && cachePath) { 99 | try { 100 | const cachedBuffer = await fileUtil.readFile(fileUtil.pathToFileUri(cachePath)) 101 | const deserializer = cache.deserializer ?? (b => b) 102 | const ans = await transformer(await deserializer(cachedBuffer)) 103 | this.logger.warn(`[Downloader] [${id}] Fell back to cached file “${cachePath}”`) 104 | return ans 105 | } catch (e) { 106 | this.logger.error(`[Downloader] [${id}] Fallback: loading cached file “${cachePath}”`, e) 107 | } 108 | } 109 | } 110 | 111 | return undefined 112 | } 113 | } 114 | 115 | export interface Job { 116 | /** 117 | * A unique ID for the cache. 118 | * 119 | * It also determines where the file is cached. Use slashes (`/`) to create directories. 120 | */ 121 | id: string, 122 | uri: RemoteUriString, 123 | cache?: { 124 | /** 125 | * A download {@link Job} that will return a checksum of the latest remote data. 126 | */ 127 | checksumJob: Omit, 'cache' | 'id'>, 128 | checksumExtension: `.${string}`, 129 | serializer?: (data: Buffer) => Buffer | Promise, 130 | deserializer?: (cache: Buffer) => Buffer | Promise, 131 | }, 132 | transformer: (data: Buffer) => PromiseLike | R, 133 | options?: LowLevelDownloadOptions, 134 | } 135 | 136 | interface LowLevelDownloadOptions { 137 | /** 138 | * Use an string array to set multiple values to the header. 139 | */ 140 | headers?: Record 141 | timeout?: number, 142 | } 143 | 144 | export interface LowLevelDownloader { 145 | /** 146 | * @throws 147 | */ 148 | get(uri: RemoteUriString, options?: LowLevelDownloadOptions): Promise 149 | } 150 | 151 | export namespace LowLevelDownloader { 152 | export function create(): LowLevelDownloader { 153 | return new LowLevelDownloaderImpl() 154 | } 155 | export function mock(options: LowLevelDownloaderMockOptions): LowLevelDownloader { 156 | return new LowLevelDownloaderMock(options) 157 | } 158 | } 159 | 160 | class LowLevelDownloaderImpl implements LowLevelDownloader { 161 | get(uri: RemoteUriString, options: LowLevelDownloadOptions = {}): Promise { 162 | const protocol = RemoteUriString.getProtocol(uri) 163 | return new Promise((resolve, reject) => { 164 | const callback = (res: IncomingMessage) => { 165 | if (res.statusCode !== 200) { 166 | reject(new Error(`Status code ${res.statusCode}: ${res.statusMessage}`)) 167 | } else { 168 | resolve(promisifyAsyncIterable(res, chunks => Buffer.concat(chunks))) 169 | } 170 | } 171 | if (protocol === 'http:') { 172 | http.get(uri, options, callback) 173 | } else { 174 | https.get(uri, options, callback) 175 | } 176 | }) 177 | } 178 | } 179 | 180 | interface LowLevelDownloaderMockOptions { 181 | /** 182 | * A record from URIs to fixture data. The {@link LowLevelDownloader.get} only returns a {@link Buffer}, 183 | * therefore `string` fixtures will be turned into a `Buffer` and `object` fixtures will be transformed 184 | * into JSON and then turned into a `Buffer`. 185 | */ 186 | fixtures: Record, 187 | } 188 | 189 | class LowLevelDownloaderMock implements LowLevelDownloader { 190 | constructor(private readonly options: LowLevelDownloaderMockOptions) { } 191 | 192 | async get(uri: RemoteUriString): Promise { 193 | if (!this.options.fixtures[uri]) { 194 | throw new Error(`404 not found: ${uri}`) 195 | } 196 | const fixture = this.options.fixtures[uri] 197 | if (Buffer.isBuffer(fixture)) { 198 | return fixture 199 | } else if (typeof fixture === 'string') { 200 | return Buffer.from(fixture, 'utf-8') 201 | } else { 202 | return Buffer.from(JSON.stringify(fixture), 'utf-8') 203 | } 204 | } 205 | } 206 | function pathToFileUri(pathToFileUri: any) { 207 | throw new Error('Function not implemented.') 208 | } 209 | -------------------------------------------------------------------------------- /src/NbtDocument.ts: -------------------------------------------------------------------------------- 1 | import type { NbtChunk } from 'deepslate' 2 | import { NbtFile, NbtRegion, NbtType } from 'deepslate' 3 | import * as vscode from 'vscode' 4 | import { applyEdit, reverseEdit } from './common/Operations' 5 | import type { Logger, NbtEdit } from './common/types' 6 | import { Disposable } from './dispose' 7 | 8 | export class NbtDocument extends Disposable implements vscode.CustomDocument { 9 | 10 | static async create( 11 | uri: vscode.Uri, 12 | backupId: string | undefined, 13 | logger: Logger, 14 | ): Promise> { 15 | const dataFile = typeof backupId === 'string' ? vscode.Uri.parse(backupId) : uri 16 | logger.info(`Creating NBT document [uri=${JSON.stringify(dataFile)}]`) 17 | const fileData = await NbtDocument.readFile(dataFile, logger) 18 | return new NbtDocument(uri, fileData, logger) 19 | } 20 | 21 | private static async readFile(uri: vscode.Uri, logger: Logger): Promise { 22 | const array = await vscode.workspace.fs.readFile(uri) 23 | 24 | logger.info(`Read file [length=${array.length}, scheme=${uri.scheme}, extension=${uri.path.match(/(?:\.([^.]+))?$/)?.[1]}]`) 25 | 26 | if (uri.scheme === 'git' && array.length === 0) { 27 | return NbtFile.create() 28 | } 29 | 30 | if (uri.fsPath.endsWith('.mca')) { 31 | return NbtRegion.read(array) 32 | } 33 | 34 | let littleEndian = uri.fsPath.endsWith('.mcstructure') 35 | let file: NbtFile 36 | try { 37 | file = NbtFile.read(array, { littleEndian, bedrockHeader: littleEndian }) 38 | if (!littleEndian && file.root.size === 0) { 39 | // if the file is empty, we try to read using little-endian 40 | const bedrockFile = NbtFile.read(array, { littleEndian: true, bedrockHeader: true }) 41 | if (bedrockFile.root.size > 0) { 42 | littleEndian = true 43 | file = bedrockFile 44 | } 45 | } 46 | } catch (e) { 47 | // if reading throws, we try to read using opposite endianness 48 | littleEndian = !littleEndian 49 | file = NbtFile.read(array, { littleEndian, bedrockHeader: littleEndian }) 50 | } 51 | 52 | logger.info(`Parsed NBT [compression=${file.compression ?? 'none'}, littleEndian=${file.littleEndian ?? false}, bedrockHeader=${file.bedrockHeader ?? 'none'}]`) 53 | 54 | return file 55 | } 56 | 57 | 58 | private readonly _uri: vscode.Uri 59 | 60 | private _documentData: NbtFile | NbtRegion 61 | private readonly _isStructure: boolean 62 | private readonly _isMap: boolean 63 | private readonly _isReadOnly: boolean 64 | private _edits: NbtEdit[] = [] 65 | private _savedEdits: NbtEdit[] = [] 66 | 67 | private constructor( 68 | uri: vscode.Uri, 69 | initialContent: NbtFile | NbtRegion, 70 | private readonly logger: Logger, 71 | ) { 72 | super() 73 | this._uri = uri 74 | this._documentData = initialContent 75 | this._isStructure = this.isStructureData() 76 | this._isMap = this.isMapData() 77 | 78 | this._isReadOnly = uri.scheme === 'git' 79 | } 80 | 81 | public get uri() { return this._uri } 82 | 83 | public get documentData() { return this._documentData } 84 | 85 | public get isStructure() { return this._isStructure } 86 | 87 | public get isMap() { return this._isMap } 88 | 89 | public get isReadOnly() { return this._isReadOnly } 90 | 91 | public get dataVersion() { 92 | const file = this._documentData 93 | if (file instanceof NbtRegion) { 94 | const firstChunk = file.getFirstChunk() 95 | return firstChunk?.getRoot().getNumber('DataVersion') ?? 0 96 | } else if (file.root.has('Blocks') && file.root.has('Data')) { 97 | // schematic files don't have DataVersion 98 | // should be 1.12 but mcmeta doesn't have that, so using 1.14 99 | // TODO: handle {Materials:"Pocket"} differently 100 | return 1952 101 | } else { 102 | return file.root.getNumber('DataVersion') ?? 0 103 | } 104 | } 105 | 106 | private readonly _onDidDispose = this._register(new vscode.EventEmitter()) 107 | public readonly onDidDispose = this._onDidDispose.event 108 | 109 | private readonly _onDidChangeDocument = this._register(new vscode.EventEmitter()) 110 | public readonly onDidChangeContent = this._onDidChangeDocument.event 111 | 112 | private readonly _onDidChange = this._register(new vscode.EventEmitter<{ 113 | readonly label: string, 114 | undo(): void, 115 | redo(): void, 116 | }>()) 117 | public readonly onDidChange = this._onDidChange.event 118 | 119 | dispose(): void { 120 | this._onDidDispose.fire() 121 | super.dispose() 122 | } 123 | 124 | makeEdit(edit: NbtEdit) { 125 | if (this._isReadOnly) { 126 | vscode.window.showWarningMessage('Cannot edit in read-only editor') 127 | return 128 | } 129 | 130 | this._edits.push(edit) 131 | applyEdit(this._documentData, edit, this.logger) 132 | const reversed = reverseEdit(edit) 133 | 134 | this._onDidChange.fire({ 135 | label: 'Edit', 136 | undo: async () => { 137 | this._edits.pop() 138 | applyEdit(this._documentData, reversed, this.logger) 139 | this._onDidChangeDocument.fire(reversed) 140 | }, 141 | redo: async () => { 142 | this._edits.push(edit) 143 | applyEdit(this._documentData, edit, this.logger) 144 | this._onDidChangeDocument.fire(edit) 145 | }, 146 | }) 147 | this._onDidChangeDocument.fire(edit) 148 | } 149 | 150 | private isStructureData() { 151 | if (this._documentData instanceof NbtRegion) { 152 | return false // region file 153 | } 154 | if (this.uri.fsPath.endsWith('.schem') || this.uri.fsPath.endsWith('.schematic') || this.uri.fsPath.endsWith('.litematic')) { 155 | return true // schematic 156 | } 157 | const root = this._documentData.root 158 | if (root.hasList('size', NbtType.Int, 3) && root.hasList('blocks') && root.hasList('palette')) { 159 | return true // vanilla structure 160 | } 161 | return false // anything else 162 | } 163 | 164 | private isMapData() { 165 | return this._uri.fsPath.match(/(?:\\|\/)map_\d+\.dat$/) !== null 166 | } 167 | 168 | async getChunkData(x: number, z: number): Promise { 169 | if (!(this._documentData instanceof NbtRegion)) { 170 | throw new Error('File is not a region file') 171 | } 172 | 173 | const chunk = this._documentData.findChunk(x, z) 174 | if (!chunk) { 175 | throw new Error(`Cannot find chunk [${x}, ${z}]`) 176 | } 177 | return chunk 178 | } 179 | 180 | async save(cancellation: vscode.CancellationToken): Promise { 181 | await this.saveAs(this.uri, cancellation) 182 | this._savedEdits = Array.from(this._edits) 183 | } 184 | 185 | async saveAs(targetResource: vscode.Uri, cancellation: vscode.CancellationToken): Promise { 186 | if (this._savedEdits.length === this._edits.length) { 187 | return 188 | } 189 | if (this._isReadOnly) { 190 | return 191 | } 192 | 193 | const nbtFile = this._documentData 194 | if (cancellation.isCancellationRequested) { 195 | return 196 | } 197 | 198 | const fileData = nbtFile.write() 199 | 200 | await vscode.workspace.fs.writeFile(targetResource, fileData) 201 | } 202 | 203 | async revert(_cancellation: vscode.CancellationToken): Promise { 204 | const diskContent = await NbtDocument.readFile(this.uri, this.logger) 205 | this._documentData = diskContent 206 | this._edits = this._savedEdits 207 | // TODO: notify listeners that document has reset 208 | } 209 | 210 | async backup(destination: vscode.Uri, cancellation: vscode.CancellationToken): Promise { 211 | await this.saveAs(destination, cancellation) 212 | return { 213 | id: destination.toString(), 214 | delete: async () => { 215 | try { 216 | await vscode.workspace.fs.delete(destination) 217 | } catch { } 218 | }, 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/NbtEditor.ts: -------------------------------------------------------------------------------- 1 | import { NbtRegion } from 'deepslate' 2 | import * as path from 'path' 3 | import * as vscode from 'vscode' 4 | import type { EditorMessage, Logger, ViewMessage } from './common/types' 5 | import { disposeAll } from './dispose' 6 | import { getAssets, mcmetaRoot } from './mcmeta' 7 | import { NbtDocument } from './NbtDocument' 8 | import { WebviewCollection } from './WebviewCollection' 9 | 10 | export class NbtEditorProvider implements vscode.CustomEditorProvider { 11 | 12 | public static register(context: vscode.ExtensionContext, logger: Logger): vscode.Disposable { 13 | return vscode.window.registerCustomEditorProvider( 14 | 'nbtEditor.nbt', 15 | new NbtEditorProvider(context, logger), 16 | { 17 | webviewOptions: { 18 | retainContextWhenHidden: true, 19 | }, 20 | supportsMultipleEditorsPerDocument: true, 21 | }) 22 | } 23 | 24 | private readonly webviews = new WebviewCollection() 25 | 26 | constructor( 27 | private readonly _context: vscode.ExtensionContext, 28 | private readonly logger: Logger, 29 | ) { } 30 | 31 | //#region CustomEditorProvider 32 | 33 | async openCustomDocument( 34 | uri: vscode.Uri, 35 | openContext: vscode.CustomDocumentOpenContext, 36 | _token: vscode.CancellationToken 37 | ): Promise { 38 | const document: NbtDocument = await NbtDocument.create(uri, openContext.backupId, this.logger) 39 | 40 | const listeners: vscode.Disposable[] = [] 41 | 42 | listeners.push(document.onDidChange(e => { 43 | this._onDidChangeCustomDocument.fire({ document, ...e }) 44 | })) 45 | 46 | listeners.push(document.onDidChangeContent(e => { 47 | this.broadcastMessage(document, { type: 'update', body: e }) 48 | })) 49 | 50 | document.onDidDispose(() => disposeAll(listeners)) 51 | 52 | return document 53 | } 54 | 55 | async resolveCustomEditor( 56 | document: NbtDocument, 57 | webviewPanel: vscode.WebviewPanel, 58 | _token: vscode.CancellationToken 59 | ): Promise { 60 | this.webviews.add(document.uri, webviewPanel) 61 | 62 | const assets = await getAssets(document.dataVersion, this.logger) 63 | 64 | webviewPanel.webview.options = { 65 | enableScripts: true, 66 | localResourceRoots: [ 67 | vscode.Uri.file(mcmetaRoot), 68 | vscode.Uri.file(this._context.extensionPath), 69 | ], 70 | } 71 | webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview, assets.version, document.isStructure, document.documentData instanceof NbtRegion) 72 | 73 | webviewPanel.webview.onDidReceiveMessage(e => this.onMessage(e, document, webviewPanel)) 74 | } 75 | 76 | private readonly _onDidChangeCustomDocument = new vscode.EventEmitter>() 77 | public readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event 78 | 79 | public saveCustomDocument(document: NbtDocument, cancellation: vscode.CancellationToken): Thenable { 80 | return document.save(cancellation) 81 | } 82 | 83 | public saveCustomDocumentAs(document: NbtDocument, destination: vscode.Uri, cancellation: vscode.CancellationToken): Thenable { 84 | return document.saveAs(destination, cancellation) 85 | } 86 | 87 | public revertCustomDocument(document: NbtDocument, cancellation: vscode.CancellationToken): Thenable { 88 | return document.revert(cancellation) 89 | } 90 | 91 | public backupCustomDocument(document: NbtDocument, context: vscode.CustomDocumentBackupContext, cancellation: vscode.CancellationToken): Thenable { 92 | return document.backup(context.destination, cancellation) 93 | } 94 | 95 | //#endregion 96 | 97 | private getNonce() { 98 | let text = '' 99 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 100 | for (let i = 0; i < 32; i++) { 101 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 102 | } 103 | return text 104 | } 105 | 106 | private getHtmlForWebview(webview: vscode.Webview, version: string, isStructure: boolean, isRegion: boolean): string { 107 | const uri = (...folders: string[]) => webview.asWebviewUri(vscode.Uri.file( 108 | path.join(this._context.extensionPath, ...folders) 109 | )) 110 | const scriptUri = uri('out', 'editor.js') 111 | const styleUri = uri('res', 'editor.css') 112 | const codiconsUri = uri('node_modules', 'vscode-codicons', 'dist', 'codicon.css') 113 | 114 | const mcmetaUri = (id: string) => webview.asWebviewUri(vscode.Uri.file( 115 | path.join(mcmetaRoot, `${version}-${id}`) 116 | )) 117 | 118 | // const blocksUrl = mcmetaUri('blocks') 119 | const assetsUrl = mcmetaUri('assets') 120 | const uvmappingUrl = mcmetaUri('uvmapping') 121 | const blocksUrl = mcmetaUri('blocks') 122 | const atlasUrl = mcmetaUri('atlas') 123 | 124 | const nonce = this.getNonce() 125 | 126 | return ` 127 | 128 | 129 | 130 | 131 | 132 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | NBT Editor 144 | 145 | 146 | ${isRegion ? ` 147 |
148 |
Map
149 | Select Chunk: 150 | 151 | 152 | 153 | 154 |
155 | ` : ''} 156 |
157 |
158 |
159 |
160 |
161 | 162 | 163 |
No results
164 |
165 | 166 |
167 |
168 | 169 |
170 |
171 | 172 |
173 |
174 |
175 |
176 | 177 | 178 |
179 | 180 |
181 |
182 | 183 |
184 |
185 |
186 |
187 |
188 | 189 | ${isStructure || isRegion ? ` 190 | 191 | 192 | 193 | 194 | ` : ''} 195 | 196 | 197 | 198 | ` 199 | } 200 | 201 | private postMessage(panel: vscode.WebviewPanel, message: ViewMessage): void { 202 | panel.webview.postMessage(message) 203 | } 204 | 205 | private broadcastMessage(document: NbtDocument, message: ViewMessage): void { 206 | for (const webviewPanel of this.webviews.get(document.uri)) { 207 | this.postMessage(webviewPanel, message) 208 | } 209 | } 210 | 211 | private onMessage(message: EditorMessage, document: NbtDocument, panel: vscode.WebviewPanel) { 212 | switch (message.type) { 213 | case 'ready': 214 | this.postMessage(panel, { 215 | type: 'init', 216 | body: { 217 | type: document.documentData instanceof NbtRegion ? 'region' : 218 | document.isStructure ? 'structure' : document.isMap ? 'map' : 'default', 219 | readOnly: document.isReadOnly, 220 | content: document.documentData.toJson(), 221 | }, 222 | }) 223 | return 224 | 225 | case 'edit': 226 | try { 227 | document.makeEdit(message.body) 228 | } catch (e) { 229 | vscode.window.showErrorMessage(`Failed to apply edit: ${e.message}`) 230 | } 231 | return 232 | 233 | case 'getChunkData': 234 | (async () => { 235 | try { 236 | document.getChunkData(message.body.x, message.body.z).then(chunk => { 237 | this.postMessage(panel, { 238 | type: 'response', 239 | requestId: message.requestId, 240 | body: chunk.getFile().toJson(), 241 | }) 242 | }) 243 | } catch (e) { 244 | vscode.window.showErrorMessage(`Failed to load chunk: ${e.message}`) 245 | this.postMessage(panel, { 246 | type: 'response', 247 | requestId: message.requestId, 248 | error: e.message, 249 | }) 250 | } 251 | })() 252 | return 253 | 254 | case 'error': 255 | this.logger.error(message.body) 256 | vscode.window.showErrorMessage(`Error in webview: ${message.body}`) 257 | return 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/WebviewCollection.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from 'vscode' 2 | 3 | export class WebviewCollection { 4 | 5 | private readonly _webviews = new Set<{ 6 | readonly resource: string, 7 | readonly webviewPanel: vscode.WebviewPanel, 8 | }>() 9 | 10 | public *get(uri: vscode.Uri): Iterable { 11 | const key = uri.toString() 12 | for (const entry of this._webviews) { 13 | if (entry.resource === key) { 14 | yield entry.webviewPanel 15 | } 16 | } 17 | } 18 | 19 | public add(uri: vscode.Uri, webviewPanel: vscode.WebviewPanel) { 20 | const entry = { resource: uri.toString(), webviewPanel } 21 | this._webviews.add(entry) 22 | 23 | webviewPanel.onDidDispose(() => { 24 | this._webviews.delete(entry) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/NbtPath.ts: -------------------------------------------------------------------------------- 1 | export class NbtPath { 2 | constructor(public arr: (string | number)[] = []) {} 3 | 4 | public pop(count = 1) { 5 | if (count === 0) return new NbtPath(this.arr) 6 | return new NbtPath(this.arr.slice(0, -count)) 7 | } 8 | 9 | public shift(count = 1) { 10 | return new NbtPath(this.arr.slice(count)) 11 | } 12 | 13 | public push(...el: (string | number)[]) { 14 | return new NbtPath([...this.arr, ...el]) 15 | } 16 | 17 | public head() { 18 | return this.arr[0] 19 | } 20 | 21 | public last() { 22 | return this.arr[this.arr.length - 1] 23 | } 24 | 25 | public length() { 26 | return this.arr.length 27 | } 28 | 29 | public startsWith(other: NbtPath) { 30 | return other.arr.every((e, i) => this.arr[i] === e) 31 | } 32 | 33 | public subPaths() { 34 | return [...Array(this.arr.length + 1)].map((_, i) => this.pop(this.arr.length - i)) 35 | } 36 | 37 | public equals(other: NbtPath) { 38 | return other.length() === this.length() 39 | && other.arr.every((e, i) => this.arr[i] === e) 40 | } 41 | 42 | public toString() { 43 | return this.arr 44 | .map(e => (typeof e === 'string') ? `.${e}` : `[${e}]`) 45 | .join('') 46 | .replace(/^\./, '') 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/common/Operations.ts: -------------------------------------------------------------------------------- 1 | import { NbtByte, NbtChunk, NbtCompound, NbtDouble, NbtEnd, NbtFile, NbtFloat, NbtInt, NbtLong, NbtRegion, NbtShort, NbtString, NbtTag, NbtType } from 'deepslate' 2 | import { NbtPath } from './NbtPath' 3 | import type { Logger, NbtEdit } from './types' 4 | 5 | export function reverseEdit(edit: NbtEdit): NbtEdit { 6 | switch(edit.type) { 7 | case 'composite': return { ...edit, edits: [...edit.edits].reverse().map(reverseEdit) } 8 | case 'chunk': return { ...edit, edit: reverseEdit(edit.edit) } 9 | case 'set': return { ...edit, new: edit.old, old: edit.new } 10 | case 'add': return { ...edit, type: 'remove' } 11 | case 'remove': return { ...edit, type: 'add' } 12 | case 'move': return { ...edit, path: edit.source, source: edit.path } 13 | } 14 | } 15 | 16 | export function mapEdit(edit: NbtEdit, mapper: (e: NbtEdit & { type: 'set' | 'add' | 'remove' | 'move' }) => NbtEdit): NbtEdit { 17 | switch(edit.type) { 18 | case 'composite': return { ...edit, edits: edit.edits.map(e => mapEdit(e, mapper)) } 19 | case 'chunk': return { ...edit, edit: mapEdit(edit.edit, mapper) } 20 | default: return mapper(edit) 21 | } 22 | } 23 | 24 | export function applyEdit(file: NbtFile | NbtRegion | NbtRegion.Ref, edit: NbtEdit, logger?: Logger) { 25 | logger?.info(`Applying edit to file ${editToString(edit)}`) 26 | if (file instanceof NbtRegion || file instanceof NbtRegion.Ref) { 27 | if (edit.type !== 'chunk') { 28 | throw new Error(`Expected chunk edit, but got '${edit.type}'`) 29 | } 30 | const chunk = file.findChunk(edit.x, edit.z) 31 | const chunkFile = chunk?.getFile() 32 | if (chunkFile === undefined) { 33 | // chunk does not exist or the ref is not loaded, so no need to apply any edits. 34 | logger?.error(`Cannot apply chunk edit, chunk x=${edit.x} z=${edit.z} is not loaded or does not exist`) 35 | return 36 | } 37 | applyEdit(chunkFile, edit.edit, logger) 38 | if (chunk instanceof NbtChunk) { 39 | chunk.markDirty() 40 | } 41 | } else { 42 | if (edit.type === 'chunk') { 43 | throw new Error('Cannot apply chunk edit, this is not a region file') 44 | } 45 | if (edit.type !== 'composite' && edit.path.length === 0) { 46 | if (edit.type !== 'set') { 47 | throw new Error(`Cannot apply ${edit.type} edit on the root, expected 'set'`) 48 | } 49 | const newTag = NbtTag.fromJsonWithId(edit.new) 50 | if (!newTag.isCompound()) { 51 | throw new Error(`Expected a compound, but got ${NbtType[newTag.getId()]}`) 52 | } 53 | file.root = newTag 54 | } else { 55 | applyEditTag(file.root, edit, logger) 56 | } 57 | } 58 | } 59 | 60 | export function getEditedFile(file: NbtFile | NbtRegion | NbtRegion.Ref, edit: NbtEdit) { 61 | if (file instanceof NbtRegion || file instanceof NbtRegion.Ref) { 62 | if (edit.type !== 'chunk') { 63 | throw new Error(`Expected chunk edit, but got '${edit.type}'`) 64 | } 65 | const chunk = file.findChunk(edit.x, edit.z) 66 | return { file: chunk?.getFile(), edit: edit.edit } 67 | } else { 68 | return { file, edit } 69 | } 70 | } 71 | 72 | export function applyEditTag(tag: NbtTag, edit: NbtEdit, logger?: Logger) { 73 | logger?.info(`Applying edit ${editToString(edit)}`) 74 | try { 75 | if (edit.type === 'composite') { 76 | edit.edits.forEach(edit => applyEditTag(tag, edit, logger)) 77 | return 78 | } else if (edit.type === 'chunk') { 79 | throw new Error('Cannot apply chunk edit to a tag') 80 | } 81 | if (edit.path.length === 0) { 82 | throw new Error('Cannot apply edit to the root') 83 | } 84 | const path = new NbtPath(edit.path) 85 | const node = getNode(tag, path.pop()) 86 | const last = path.last() 87 | switch(edit.type) { 88 | case 'set': return setValue(node, last, NbtTag.fromJsonWithId(edit.new)) 89 | case 'add': return addValue(node, last, NbtTag.fromJsonWithId(edit.value)) 90 | case 'remove': return removeValue(node, last) 91 | case 'move': { 92 | if (edit.source.length === 0) { 93 | throw new Error('Cannot move the root') 94 | } 95 | const sPath = new NbtPath(edit.source) 96 | const sNode = getNode(tag, sPath.pop()) 97 | const sLast = sPath.last() 98 | return moveNode(node, last, sNode, sLast) 99 | } 100 | } 101 | } catch (e) { 102 | logger?.error(`Error applying edit to tag: ${e.message}`) 103 | throw e 104 | } 105 | } 106 | 107 | function editToString(edit: NbtEdit) { 108 | return `type=${edit.type} ${edit.type === 'chunk' ? `x=${edit.x} z=${edit.z} ` : ''}${edit.type !== 'composite' && edit.type !== 'chunk' ? ` path=${new NbtPath(edit.path).toString()}` : ''} ${edit.type === 'remove' || edit.type === 'composite' || edit.type === 'chunk' ? '' : edit.type === 'move' ? `source=${new NbtPath(edit.source).toString()}` : `value=${(a => a.slice(0, 40) + (a.length > 40 ? '...' : ''))(JSON.stringify(edit.type === 'set' ? edit.new : edit.value))}`}` 109 | } 110 | 111 | export function getNode(tag: NbtTag, path: NbtPath) { 112 | let node: NbtTag | undefined = tag 113 | for (const el of path.arr) { 114 | if (node?.isCompound() && typeof el === 'string') { 115 | node = node.get(el) 116 | } else if (node?.isListOrArray() && typeof el === 'number') { 117 | node = node.get(el) 118 | } else { 119 | node = undefined 120 | } 121 | if (node === undefined) { 122 | throw new Error(`Invalid path ${path.toString()}`) 123 | } 124 | } 125 | return node 126 | } 127 | 128 | function moveNode(tag: NbtTag, last: number | string, sTag: NbtTag, sLast: number | string) { 129 | const value = getNode(sTag, new NbtPath([sLast])) 130 | addValue(tag, last, value) 131 | removeValue(sTag, sLast) 132 | } 133 | 134 | function setValue(tag: NbtTag, last: number | string, value: NbtTag) { 135 | if (tag.isCompound() && typeof last === 'string') { 136 | tag.set(last, value) 137 | } else if (tag.isList() && typeof last === 'number') { 138 | tag.set(last, value) 139 | } else if (tag.isByteArray() && typeof last === 'number' && value.isByte()) { 140 | tag.set(last, value) 141 | } else if (tag.isIntArray() && typeof last === 'number' && value.isInt()) { 142 | tag.set(last, value) 143 | } else if (tag.isLongArray() && typeof last === 'number' && value.isLong()) { 144 | tag.set(last, value) 145 | } 146 | } 147 | 148 | function addValue(tag: NbtTag, last: number | string, value: NbtTag) { 149 | if (tag.isCompound() && typeof last === 'string') { 150 | tag.set(last, value) 151 | } else if (tag.isList() && typeof last === 'number') { 152 | tag.insert(last, value) 153 | } else if (tag.isByteArray() && typeof last === 'number' && value.isByte()) { 154 | tag.insert(last, value) 155 | } else if (tag.isIntArray() && typeof last === 'number' && value.isInt()) { 156 | tag.insert(last, value) 157 | } else if (tag.isLongArray() && typeof last === 'number' && value.isLong()) { 158 | tag.insert(last, value) 159 | } 160 | } 161 | 162 | function removeValue(tag: NbtTag, last: number | string) { 163 | if (tag.isCompound() && typeof last === 'string') { 164 | tag.delete(last) 165 | } else if (tag.isListOrArray() && typeof last === 'number') { 166 | tag.delete(last) 167 | } 168 | } 169 | 170 | export type SearchQuery = { 171 | type?: number, 172 | name?: string, 173 | value?: string, 174 | } 175 | 176 | export function searchNodes(tag: NbtCompound, query: SearchQuery): NbtPath[] { 177 | const results: NbtPath[] = [] 178 | let parsedValue: NbtTag | undefined = undefined 179 | try { 180 | if (query.value !== undefined) { 181 | parsedValue = NbtTag.fromString(query.value) 182 | } 183 | } catch (e) {} 184 | searchNodesImpl(new NbtPath(), tag, query, results, parsedValue) 185 | return results 186 | } 187 | 188 | function searchNodesImpl(path: NbtPath, tag: NbtTag, query: SearchQuery, results: NbtPath[], parsedValue: NbtTag | undefined) { 189 | if (matchesNode(path, tag, query, parsedValue)) { 190 | results.push(path) 191 | } 192 | if (tag.isCompound()) { 193 | [...tag.keys()].sort().forEach(k => { 194 | searchNodesImpl(path.push(k), tag.get(k)!, query, results, parsedValue) 195 | }) 196 | } else if (tag.isListOrArray()) { 197 | tag.forEach((v, i) => { 198 | searchNodesImpl(path.push(i), v, query, results, parsedValue) 199 | }) 200 | } 201 | } 202 | 203 | function matchesNode(path: NbtPath, tag: NbtTag, query: SearchQuery, parsedValue: NbtTag | undefined): boolean { 204 | const last = path.last() 205 | const typeMatches = !query.type || tag.getId() === query.type 206 | const nameMatches = !query.name || (typeof last === 'string' && last.includes(query.name)) 207 | const valueMatches = !query.value || matchesValue(tag, query.value, parsedValue) 208 | return typeMatches && nameMatches && valueMatches 209 | } 210 | 211 | function matchesValue(tag: NbtTag, value: string, parsedValue: NbtTag | undefined): boolean { 212 | if (parsedValue && tag.getId() == parsedValue.getId() && tag.toString() == parsedValue.toString()) { 213 | return true 214 | } 215 | try { 216 | if (tag.isString()) { 217 | return tag.getAsString().includes(value) 218 | } else if (tag.isLong()) { 219 | const long = NbtLong.bigintToPair(BigInt(value)) 220 | return tag.getAsPair()[0] === long[0] && tag.getAsPair()[1] === long[1] 221 | } else if (tag.isNumber()) { 222 | return tag.getAsNumber() === JSON.parse(value) 223 | } 224 | } catch (e) {} 225 | return false 226 | } 227 | 228 | export function replaceNode(tag: NbtTag, path: NbtPath, replace: SearchQuery): NbtEdit { 229 | const edits: NbtEdit[] = [] 230 | if (replace.value) { 231 | const node = getNode(tag, path) 232 | const newNode = parsePrimitive(node.getId(), replace.value) 233 | edits.push({ type: 'set', path: path.arr, old: node.toJsonWithId(), new: newNode.toJsonWithId() }) 234 | } 235 | if (replace.name) { 236 | edits.push({ type: 'move', source: path.arr, path: path.pop().push(replace.name).arr }) 237 | } 238 | if (edits.length === 1) { 239 | return edits[0] 240 | } 241 | return { type: 'composite', edits } 242 | } 243 | 244 | export function serializePrimitive(tag: NbtTag) { 245 | if (tag.isString()) return tag.getAsString() 246 | if (tag.isLong()) return NbtLong.pairToString(tag.getAsPair()) 247 | if (tag.isNumber()) return tag.getAsNumber().toString() 248 | return '' 249 | } 250 | 251 | export function parsePrimitive(id: number, value: string) { 252 | switch (id) { 253 | case NbtType.String: return new NbtString(value) 254 | case NbtType.Byte: return new NbtByte(parseInt(value)) 255 | case NbtType.Short: return new NbtShort(parseInt(value)) 256 | case NbtType.Int: return new NbtInt(parseInt(value)) 257 | case NbtType.Long: return new NbtLong(BigInt(value)) 258 | case NbtType.Float: return new NbtFloat(parseFloat(value)) 259 | case NbtType.Double: return new NbtDouble(parseFloat(value)) 260 | default: return NbtEnd.INSTANCE 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from 'deepslate' 2 | 3 | export interface Logger { 4 | error(data: any, ...args: any[]): void 5 | info(data: any, ...args: any[]): void 6 | log(data: any, ...args: any[]): void 7 | warn(data: any, ...args: any[]): void 8 | } 9 | 10 | export type NbtEdit = { 11 | type: 'composite', 12 | edits: NbtEdit[], 13 | } | { 14 | type: 'chunk', 15 | x: number, 16 | z: number, 17 | edit: NbtEdit, 18 | } | { 19 | type: 'set', 20 | path: (number | string)[], 21 | new: JsonValue, 22 | old: JsonValue, 23 | } | { 24 | type: 'remove' | 'add', 25 | path: (number | string)[], 26 | value: JsonValue, 27 | } | { 28 | type: 'move', 29 | path: (number | string)[], 30 | source: (number | string)[], 31 | } 32 | 33 | export type EditorMessage = { requestId?: number } & ({ 34 | type: 'ready', 35 | } | { 36 | type: 'response', 37 | requestId: number, 38 | body: any, 39 | } | { 40 | type: 'error', 41 | body: string, 42 | } | { 43 | type: 'edit', 44 | body: NbtEdit, 45 | } | { 46 | type: 'getChunkData', 47 | body: { 48 | x: number, 49 | z: number, 50 | }, 51 | }) 52 | 53 | export type ViewMessage = { requestId?: number } & ({ 54 | type: 'init', 55 | body: { 56 | type: 'default' | 'structure' | 'map' | 'region', 57 | readOnly: boolean, 58 | content: JsonValue, 59 | }, 60 | } | { 61 | type: 'update', 62 | body: NbtEdit, 63 | } | { 64 | type: 'chunk', 65 | body: { 66 | x: number, 67 | z: number, 68 | size: number, 69 | content: JsonValue, 70 | }, 71 | } | { 72 | type: 'response', 73 | body?: unknown, 74 | error?: string, 75 | }) 76 | -------------------------------------------------------------------------------- /src/dispose.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from 'vscode' 2 | 3 | export function disposeAll(disposables: vscode.Disposable[]): void { 4 | while (disposables.length) { 5 | const item = disposables.pop() 6 | if (item) { 7 | item.dispose() 8 | } 9 | } 10 | } 11 | 12 | export abstract class Disposable { 13 | private _isDisposed = false 14 | 15 | protected _disposables: vscode.Disposable[] = [] 16 | 17 | public dispose(): any { 18 | if (this._isDisposed) { 19 | return 20 | } 21 | this._isDisposed = true 22 | disposeAll(this._disposables) 23 | } 24 | 25 | protected _register(value: T): T { 26 | if (this._isDisposed) { 27 | value.dispose() 28 | } else { 29 | this._disposables.push(value) 30 | } 31 | return value 32 | } 33 | 34 | protected get isDisposed(): boolean { 35 | return this._isDisposed 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/editor/BlockFlags.ts: -------------------------------------------------------------------------------- 1 | export const OPAQUE_BLOCKS = new Set([ 2 | 'minecraft:acacia_planks', 3 | 'minecraft:acacia_wood', 4 | 'minecraft:amethyst_block', 5 | 'minecraft:ancient_debris', 6 | 'minecraft:andesite', 7 | 'minecraft:barrel', 8 | 'minecraft:bamboo_block', 9 | 'minecraft:bamboo_mosaic', 10 | 'minecraft:bamboo_planks', 11 | 'minecraft:basalt', 12 | 'minecraft:bedrock', 13 | 'minecraft:bee_nest', 14 | 'minecraft:beehive', 15 | 'minecraft:birch_log', 16 | 'minecraft:birch_planks', 17 | 'minecraft:birch_wood', 18 | 'minecraft:black_concrete', 19 | 'minecraft:black_concrete_powder', 20 | 'minecraft:black_glazed_terracotta', 21 | 'minecraft:black_terracotta', 22 | 'minecraft:blackstone', 23 | 'minecraft:blast_furnace', 24 | 'minecraft:blue_concrete', 25 | 'minecraft:blue_concrete_powder', 26 | 'minecraft:blue_glazed_terracotta', 27 | 'minecraft:blue_ice', 28 | 'minecraft:blue_terracotta', 29 | 'minecraft:blue_wool', 30 | 'minecraft:bone_block', 31 | 'minecraft:bookshelf', 32 | 'minecraft:brain_coral_block', 33 | 'minecraft:bricks', 34 | 'minecraft:brown_concrete', 35 | 'minecraft:brown_concrete_powder', 36 | 'minecraft:brown_glazed_terracotta', 37 | 'minecraft:brown_mushroom_block', 38 | 'minecraft:brown_terracotta', 39 | 'minecraft:brown_wool', 40 | 'minecraft:bubble_coral_block', 41 | 'minecraft:calcite', 42 | 'minecraft:cartography_table', 43 | 'minecraft:carved_pumpkin', 44 | 'minecraft:chain_command_block', 45 | 'minecraft:cherry_log', 46 | 'minecraft:cherry_planks', 47 | 'minecraft:cherry_wood', 48 | 'minecraft:chiseled_bookshelf', 49 | 'minecraft:chiseled_copper', 50 | 'minecraft:chiseled_deepslate', 51 | 'minecraft:chiseled_nether_bricks', 52 | 'minecraft:chiseled_polished_blackstone', 53 | 'minecraft:chiseled_quartz_block', 54 | 'minecraft:chiseled_red_sandstone', 55 | 'minecraft:chiseled_sandstone', 56 | 'minecraft:chiseled_stone_bricks', 57 | 'minecraft:chiseled_tuff', 58 | 'minecraft:chiseled_tuff_bricks', 59 | 'minecraft:clay', 60 | 'minecraft:coal_block', 61 | 'minecraft:coal_ore', 62 | 'minecraft:coarse_dirt', 63 | 'minecraft:cobbled_deepslate', 64 | 'minecraft:cobbled_deepslate_wall', 65 | 'minecraft:cobblestone', 66 | 'minecraft:command_block', 67 | 'minecraft:copper_block', 68 | 'minecraft:copper_bulb', 69 | 'minecraft:copper_ore', 70 | 'minecraft:cracked_deepslate_bricks', 71 | 'minecraft:cracked_deepslate_tiles', 72 | 'minecraft:cracked_nether_bricks', 73 | 'minecraft:cracked_polished_blackstone_bricks', 74 | 'minecraft:cracked_stone_bricks', 75 | 'minecraft:crafting_table', 76 | 'minecraft:crafter', 77 | 'minecraft:crimson_hyphae', 78 | 'minecraft:crimson_nylium', 79 | 'minecraft:crimson_planks', 80 | 'minecraft:crimson_roots', 81 | 'minecraft:crimson_stem', 82 | 'minecraft:crying_obsidian', 83 | 'minecraft:cut_copper', 84 | 'minecraft:cut_red_sandstone', 85 | 'minecraft:cut_sandstone', 86 | 'minecraft:cyan_concrete', 87 | 'minecraft:cyan_concrete_powder', 88 | 'minecraft:cyan_glazed_terracotta', 89 | 'minecraft:cyan_terracotta', 90 | 'minecraft:cyan_wool', 91 | 'minecraft:dark_oak_log', 92 | 'minecraft:dark_oak_planks', 93 | 'minecraft:dark_oak_wood', 94 | 'minecraft:dark_prismarine', 95 | 'minecraft:dead_brain_coral_block', 96 | 'minecraft:dead_bubble_coral_block', 97 | 'minecraft:dead_fire_coral_block', 98 | 'minecraft:dead_horn_coral_block', 99 | 'minecraft:dead_tube_coral_block', 100 | 'minecraft:deepslate', 101 | 'minecraft:deepslate_bricks', 102 | 'minecraft:deepslate_coal_ore', 103 | 'minecraft:deepslate_copper_ore', 104 | 'minecraft:deepslate_diamond_ore', 105 | 'minecraft:deepslate_emerald_ore', 106 | 'minecraft:deepslate_gold_ore', 107 | 'minecraft:deepslate_iron_ore', 108 | 'minecraft:deepslate_lapis_ore', 109 | 'minecraft:deepslate_redstone_ore', 110 | 'minecraft:deepslate_tiles', 111 | 'minecraft:diamond_block', 112 | 'minecraft:diamond_ore', 113 | 'minecraft:diorite', 114 | 'minecraft:dirt', 115 | 'minecraft:dispenser', 116 | 'minecraft:dried_kelp_block', 117 | 'minecraft:dripstone_block', 118 | 'minecraft:dropper', 119 | 'minecraft:emerald_block', 120 | 'minecraft:emerald_ore', 121 | 'minecraft:end_stone', 122 | 'minecraft:end_stone_bricks', 123 | 'minecraft:exposed_chiseled_copper', 124 | 'minecraft:exposed_copper', 125 | 'minecraft:exposed_copper_bulb', 126 | 'minecraft:exposed_cut_copper', 127 | 'minecraft:fire_coral_block', 128 | 'minecraft:fletching_table', 129 | 'minecraft:furnace', 130 | 'minecraft:gilded_blackstone', 131 | 'minecraft:glowstone', 132 | 'minecraft:gold_block', 133 | 'minecraft:gold_ore', 134 | 'minecraft:granite', 135 | 'minecraft:grass_block', 136 | 'minecraft:gravel', 137 | 'minecraft:gray_concrete', 138 | 'minecraft:gray_concrete_powder', 139 | 'minecraft:gray_glazed_terracotta', 140 | 'minecraft:gray_terracotta', 141 | 'minecraft:gray_wool', 142 | 'minecraft:green_concrete', 143 | 'minecraft:green_concrete_powder', 144 | 'minecraft:green_glazed_terracotta', 145 | 'minecraft:green_terracotta', 146 | 'minecraft:green_wool', 147 | 'minecraft:hay_block', 148 | 'minecraft:honeycomb_block', 149 | 'minecraft:horn_coral_block', 150 | 'minecraft:infested_chiseled_stone_bricks', 151 | 'minecraft:infested_cobblestone', 152 | 'minecraft:infested_cracked_stone_bricks', 153 | 'minecraft:infested_deepslate', 154 | 'minecraft:infested_mossy_stone_bricks', 155 | 'minecraft:infested_stone', 156 | 'minecraft:infested_stone_bricks', 157 | 'minecraft:iron_block', 158 | 'minecraft:iron_ore', 159 | 'minecraft:jack_o_lantern', 160 | 'minecraft:jigsaw', 161 | 'minecraft:jukebox', 162 | 'minecraft:jungle_log', 163 | 'minecraft:jungle_planks', 164 | 'minecraft:jungle_wood', 165 | 'minecraft:lapis_block', 166 | 'minecraft:lapis_ore', 167 | 'minecraft:light_blue_concrete', 168 | 'minecraft:light_blue_concrete_powder', 169 | 'minecraft:light_blue_glazed_terracotta', 170 | 'minecraft:light_blue_terracotta', 171 | 'minecraft:light_blue_wool', 172 | 'minecraft:light_gray_concrete', 173 | 'minecraft:light_gray_concrete_powder', 174 | 'minecraft:light_gray_glazed_terracotta', 175 | 'minecraft:light_gray_terracotta', 176 | 'minecraft:light_gray_wool', 177 | 'minecraft:lime_concrete', 178 | 'minecraft:lime_concrete_powder', 179 | 'minecraft:lime_glazed_terracotta', 180 | 'minecraft:lime_terracotta', 181 | 'minecraft:lime_wool', 182 | 'minecraft:lodestone', 183 | 'minecraft:loom', 184 | 'minecraft:magenta_concrete', 185 | 'minecraft:magenta_concrete_powder', 186 | 'minecraft:magenta_glazed_terracotta', 187 | 'minecraft:magenta_terracotta', 188 | 'minecraft:magenta_wool', 189 | 'minecraft:magma_block', 190 | 'minecraft:mangrove_log', 191 | 'minecraft:mangrove_planks', 192 | 'minecraft:mangrove_wood', 193 | 'minecraft:melon', 194 | 'minecraft:moss_block', 195 | 'minecraft:mossy_cobblestone', 196 | 'minecraft:mossy_stone_bricks', 197 | 'minecraft:mud', 198 | 'minecraft:mud_bricks', 199 | 'minecraft:mycelium', 200 | 'minecraft:nether_bricks', 201 | 'minecraft:nether_gold_ore', 202 | 'minecraft:nether_quartz_ore', 203 | 'minecraft:nether_wart_block', 204 | 'minecraft:netherite_block', 205 | 'minecraft:netherrack', 206 | 'minecraft:note_block', 207 | 'minecraft:oak_log', 208 | 'minecraft:oak_planks', 209 | 'minecraft:oak_wood', 210 | 'minecraft:observer', 211 | 'minecraft:obsidian', 212 | 'minecraft:ochre_froglight', 213 | 'minecraft:orange_concrete', 214 | 'minecraft:orange_concrete_powder', 215 | 'minecraft:orange_glazed_terracotta', 216 | 'minecraft:orange_terracotta', 217 | 'minecraft:orange_wool', 218 | 'minecraft:oxidized_chiseled_copper', 219 | 'minecraft:oxidized_copper', 220 | 'minecraft:oxidized_copper_bulb', 221 | 'minecraft:oxidized_cut_copper', 222 | 'minecraft:packed_ice', 223 | 'minecraft:packed_mud', 224 | 'minecraft:pearlescent_froglight', 225 | 'minecraft:pink_concrete', 226 | 'minecraft:pink_concrete_powder', 227 | 'minecraft:pink_glazed_terracotta', 228 | 'minecraft:pink_terracotta', 229 | 'minecraft:pink_wool', 230 | 'minecraft:podzol', 231 | 'minecraft:polished_andesite', 232 | 'minecraft:polished_basalt', 233 | 'minecraft:polished_blackstone', 234 | 'minecraft:polished_blackstone_bricks', 235 | 'minecraft:polished_deepslate', 236 | 'minecraft:polished_diorite', 237 | 'minecraft:polished_granite', 238 | 'minecraft:polished_tuff', 239 | 'minecraft:powder_snow', 240 | 'minecraft:prismarine', 241 | 'minecraft:prismarine_bricks', 242 | 'minecraft:pumpkin', 243 | 'minecraft:purple_concrete', 244 | 'minecraft:purple_concrete_powder', 245 | 'minecraft:purple_glazed_terracotta', 246 | 'minecraft:purple_terracotta', 247 | 'minecraft:purple_wool', 248 | 'minecraft:purpur_block', 249 | 'minecraft:purpur_pillar', 250 | 'minecraft:quartz_block', 251 | 'minecraft:quartz_bricks', 252 | 'minecraft:quartz_pillar', 253 | 'minecraft:raw_copper_block', 254 | 'minecraft:raw_gold_block', 255 | 'minecraft:raw_iron_block', 256 | 'minecraft:red_concrete', 257 | 'minecraft:red_concrete_powder', 258 | 'minecraft:red_glazed_terracotta', 259 | 'minecraft:red_mushroom_block', 260 | 'minecraft:red_nether_bricks', 261 | 'minecraft:red_sand', 262 | 'minecraft:red_sandstone', 263 | 'minecraft:red_terracotta', 264 | 'minecraft:red_wool', 265 | 'minecraft:redstone_block', 266 | 'minecraft:redstone_lamp', 267 | 'minecraft:redstone_ore', 268 | 'minecraft:repeating_command_block', 269 | 'minecraft:respawn_anchor', 270 | 'minecraft:rooted_dirt', 271 | 'minecraft:sand', 272 | 'minecraft:sandstone', 273 | 'minecraft:sculk', 274 | 'minecraft:sculk_catalyst', 275 | 'minecraft:sea_lantern', 276 | 'minecraft:shroomlight', 277 | 'minecraft:smithing_table', 278 | 'minecraft:smoker', 279 | 'minecraft:smooth_basalt', 280 | 'minecraft:smooth_quartz', 281 | 'minecraft:smooth_red_sandstone', 282 | 'minecraft:smooth_sandstone', 283 | 'minecraft:smooth_stone', 284 | 'minecraft:snow_block', 285 | 'minecraft:soul_sand', 286 | 'minecraft:soul_soil', 287 | 'minecraft:sponge', 288 | 'minecraft:spruce_log', 289 | 'minecraft:spruce_planks', 290 | 'minecraft:spruce_wood', 291 | 'minecraft:stone', 292 | 'minecraft:stone_bricks', 293 | 'minecraft:stripped_acacia_log', 294 | 'minecraft:stripped_acacia_wood', 295 | 'minecraft:stripped_bamboo_block', 296 | 'minecraft:stripped_birch_log', 297 | 'minecraft:stripped_birch_wood', 298 | 'minecraft:stripped_cherry_log', 299 | 'minecraft:stripped_cherry_wood', 300 | 'minecraft:stripped_crimson_hyphae', 301 | 'minecraft:stripped_crimson_stem', 302 | 'minecraft:stripped_dark_oak_log', 303 | 'minecraft:stripped_dark_oak_wood', 304 | 'minecraft:stripped_jungle_log', 305 | 'minecraft:stripped_jungle_wood', 306 | 'minecraft:stripped_mangrove_log', 307 | 'minecraft:stripped_mangrove_wood', 308 | 'minecraft:stripped_oak_log', 309 | 'minecraft:stripped_oak_wood', 310 | 'minecraft:stripped_spruce_log', 311 | 'minecraft:stripped_spruce_wood', 312 | 'minecraft:stripped_warped_hyphae', 313 | 'minecraft:stripped_warped_stem', 314 | 'minecraft:structure_block', 315 | 'minecraft:suspicious_gravel', 316 | 'minecraft:suspicious_sand', 317 | 'minecraft:target', 318 | 'minecraft:terracotta', 319 | 'minecraft:tnt', 320 | 'minecraft:tube_coral_block', 321 | 'minecraft:tuff', 322 | 'minecraft:tuff_bricks', 323 | 'minecraft:verdant_froglight', 324 | 'minecraft:warped_hyphae', 325 | 'minecraft:warped_nylium', 326 | 'minecraft:warped_planks', 327 | 'minecraft:warped_stem', 328 | 'minecraft:warped_wart_block', 329 | 'minecraft:waxed_chiseled_copper', 330 | 'minecraft:waxed_copper_block', 331 | 'minecraft:waxed_copper_bulb', 332 | 'minecraft:waxed_cut_copper', 333 | 'minecraft:waxed_exposed_chiseled_copper', 334 | 'minecraft:waxed_exposed_copper', 335 | 'minecraft:waxed_exposed_copper_bulb', 336 | 'minecraft:waxed_exposed_cut_copper', 337 | 'minecraft:waxed_oxidized_chiseled_copper', 338 | 'minecraft:waxed_oxidized_copper', 339 | 'minecraft:waxed_oxidized_copper_bulb', 340 | 'minecraft:waxed_oxidized_cut_copper', 341 | 'minecraft:waxed_weathered_chiseled_copper', 342 | 'minecraft:waxed_weathered_copper', 343 | 'minecraft:waxed_weathered_copper_bulb', 344 | 'minecraft:waxed_weathered_cut_copper', 345 | 'minecraft:weathered_chiseled_copper', 346 | 'minecraft:weathered_copper', 347 | 'minecraft:weathered_copper_bulb', 348 | 'minecraft:weathered_cut_copper', 349 | 'minecraft:wet_sponge', 350 | 'minecraft:white_concrete', 351 | 'minecraft:white_concrete_powder', 352 | 'minecraft:white_glazed_terracotta', 353 | 'minecraft:white_terracotta', 354 | 'minecraft:white_wool', 355 | 'minecraft:yellow_concrete', 356 | 'minecraft:yellow_concrete_powder', 357 | 'minecraft:yellow_glazed_terracotta', 358 | 'minecraft:yellow_terracotta', 359 | 'minecraft:yellow_wool', 360 | ]) 361 | 362 | export const TRANSLUCENT_BLOCKS = new Set([ 363 | 'minecraft:black_stained_glass', 364 | 'minecraft:black_stained_glass_pane', 365 | 'minecraft:blue_stained_glass', 366 | 'minecraft:blue_stained_glass_pane', 367 | 'minecraft:bubble_column', 368 | 'minecraft:brown_stained_glass', 369 | 'minecraft:brown_stained_glass_pane', 370 | 'minecraft:cyan_stained_glass', 371 | 'minecraft:cyan_stained_glass_pane', 372 | 'minecraft:frosted_ice', 373 | 'minecraft:gray_stained_glass', 374 | 'minecraft:gray_stained_glass_pane', 375 | 'minecraft:green_stained_glass', 376 | 'minecraft:green_stained_glass_pane', 377 | 'minecraft:honey_block', 378 | 'minecraft:ice', 379 | 'minecraft:kelp', 380 | 'minecraft:kelp_plant', 381 | 'minecraft:light_blue_stained_glass', 382 | 'minecraft:light_blue_stained_glass_pane', 383 | 'minecraft:light_gray_stained_glass', 384 | 'minecraft:light_gray_stained_glass_pane', 385 | 'minecraft:lime_stained_glass', 386 | 'minecraft:lime_stained_glass_pane', 387 | 'minecraft:magenta_stained_glass', 388 | 'minecraft:magenta_stained_glass_pane', 389 | 'minecraft:orange_stained_glass', 390 | 'minecraft:orange_stained_glass_pane', 391 | 'minecraft:pink_stained_glass', 392 | 'minecraft:pink_stained_glass_pane', 393 | 'minecraft:purple_stained_glass', 394 | 'minecraft:purple_stained_glass_pane', 395 | 'minecraft:red_stained_glass', 396 | 'minecraft:red_stained_glass_pane', 397 | 'minecraft:seagrass', 398 | 'minecraft:slime_block', 399 | 'minecraft:tall_seagrass', 400 | 'minecraft:water', 401 | 'minecraft:white_stained_glass', 402 | 'minecraft:white_stained_glass_pane', 403 | 'minecraft:yellow_stained_glass', 404 | 'minecraft:yellow_stained_glass_pane', 405 | ]) 406 | 407 | export const NON_SELF_CULLING = new Set([ 408 | 'minecraft:acacia_leaves', 409 | 'minecraft:azalea_leaves', 410 | 'minecraft:birch_leaves', 411 | 'minecraft:cherry_leaves', 412 | 'minecraft:dark_oak_leaves', 413 | 'minecraft:flowering_azalea_leaves', 414 | 'minecraft:jungle_leaves', 415 | 'minecraft:mangrove_leaves', 416 | 'minecraft:oak_leaves', 417 | 'minecraft:spruce_leaves', 418 | ]) 419 | -------------------------------------------------------------------------------- /src/editor/ChunkEditor.ts: -------------------------------------------------------------------------------- 1 | import type { NbtFile } from 'deepslate' 2 | import { BlockState, NbtType, Structure } from 'deepslate' 3 | import { vec3 } from 'gl-matrix' 4 | import { StructureEditor } from './StructureEditor' 5 | 6 | const VERSION_20w17a = 2529 7 | const VERSION_21w43a = 2844 8 | 9 | export class ChunkEditor extends StructureEditor { 10 | 11 | onInit(file: NbtFile) { 12 | this.updateStructure(file) 13 | vec3.copy(this.cPos, this.structure.getSize()) 14 | vec3.mul(this.cPos, this.cPos, [-0.5, -1, -0.5]) 15 | vec3.add(this.cPos, this.cPos, [0, 16, 0]) 16 | this.cDist = 25 17 | this.showSidePanel() 18 | this.render() 19 | } 20 | 21 | protected loadStructure() { 22 | this.gridActive = false 23 | 24 | const dataVersion = this.file.root.getNumber('DataVersion') 25 | const N = dataVersion >= VERSION_21w43a 26 | const stretches = dataVersion < VERSION_20w17a 27 | 28 | const sections = N 29 | ? this.file.root.getList('sections', NbtType.Compound) 30 | : this.file.root.getCompound('Level').getList('Sections', NbtType.Compound) 31 | 32 | const filledSections = sections.filter(section => { 33 | const palette = N 34 | ? section.getCompound('block_states').getList('palette', NbtType.Compound) 35 | : section.has('Palette') && section.getList('Palette', NbtType.Compound) 36 | return palette && 37 | palette.filter(state => state.getString('Name') !== 'minecraft:air') 38 | .length > 0 39 | }) 40 | if (filledSections.length === 0) { 41 | throw new Error('Empty chunk') 42 | } 43 | const minY = 16 * Math.min(...filledSections.map(s => s.getNumber('Y'))) 44 | const maxY = 16 * Math.max(...filledSections.map(s => s.getNumber('Y'))) 45 | 46 | const K_palette = N ? 'palette' : 'Palette' 47 | const K_data = N ? 'data' : 'BlockStates' 48 | 49 | const structure = new Structure([16, maxY - minY + 16, 16]) 50 | for (const section of filledSections) { 51 | const states = N ? section.getCompound('block_states') : section 52 | if (!states.has(K_palette) || !states.has(K_data)) { 53 | continue 54 | } 55 | const yOffset = section.getNumber('Y') * 16 - minY 56 | const palette = states.getList(K_palette, NbtType.Compound) 57 | const blockStates = states.getLongArray(K_data) 58 | const tempDataview = new DataView(new Uint8Array(8).buffer) 59 | const statesData = blockStates.map(long => { 60 | tempDataview.setInt32(0, Number(long.getAsPair()[0])) 61 | tempDataview.setInt32(4, Number(long.getAsPair()[1])) 62 | return tempDataview.getBigUint64(0) 63 | }) 64 | 65 | const bits = Math.max(4, Math.ceil(Math.log2(palette.length))) 66 | const bigBits = BigInt(bits) 67 | const big64 = BigInt(64) 68 | const bitMask = BigInt(Math.pow(2, bits) - 1) 69 | let state = 0 70 | let data = statesData[state] 71 | let dataLength = big64 72 | 73 | for (let j = 0; j < 4096; j += 1) { 74 | if (dataLength < bits) { 75 | state += 1 76 | const newData = statesData[state] 77 | if (stretches) { 78 | data = (newData << dataLength) | data 79 | dataLength += big64 80 | } else { 81 | data = newData 82 | dataLength = big64 83 | } 84 | } 85 | const paletteId = data & bitMask 86 | const blockState = palette.get(Number(paletteId)) 87 | if (blockState) { 88 | const pos: [number, number, number] = [j & 0xF, yOffset + (j >> 8), (j >> 4) & 0xF] 89 | const block = BlockState.fromNbt(blockState) 90 | structure.addBlock(pos, block.getName(), block.getProperties()) 91 | } 92 | data >>= bigBits 93 | dataLength -= bigBits 94 | } 95 | } 96 | console.log(structure) 97 | return structure 98 | } 99 | 100 | menu() { 101 | return [] 102 | } 103 | 104 | protected showSidePanel() { 105 | this.root.querySelector('.side-panel')?.remove() 106 | const block = this.selectedBlock ? this.structure.getBlock(this.selectedBlock) : null 107 | if (block) { 108 | super.showSidePanel() 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/editor/Editor.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from 'deepslate' 2 | import { NbtFile, NbtRegion, NbtType } from 'deepslate' 3 | import type { NbtPath } from '../common/NbtPath' 4 | import type { SearchQuery } from '../common/Operations' 5 | import { applyEdit, getEditedFile } from '../common/Operations' 6 | import type { EditorMessage, NbtEdit, ViewMessage } from '../common/types' 7 | import { ChunkEditor } from './ChunkEditor' 8 | import { FileInfoEditor } from './FileInfoEditor' 9 | import { locale } from './Locale' 10 | import { MapEditor } from './MapEditor' 11 | import { SnbtEditor } from './SnbtEditor' 12 | import { StructureEditor } from './StructureEditor' 13 | import { TreeEditor } from './TreeEditor' 14 | import { getInt } from './Util' 15 | 16 | export type VSCode = { 17 | postMessage(message: EditorMessage): void, 18 | } 19 | 20 | export const TYPES = Object.keys(NbtType).filter(e => isNaN(Number(e))) 21 | 22 | declare function acquireVsCodeApi(): VSCode 23 | const vscode = acquireVsCodeApi() 24 | 25 | const root = document.querySelector('.nbt-editor')! 26 | 27 | function lazy(getter: () => T) { 28 | let value: T | null = null 29 | return () => { 30 | if (value === null) { 31 | value = getter() 32 | } 33 | return value 34 | } 35 | } 36 | 37 | export type SearchResult = { 38 | path: NbtPath, 39 | show(): void, 40 | replace(replacement: SearchQuery): NbtEdit, 41 | } 42 | 43 | export interface EditorPanel { 44 | reveal?(): void 45 | hide?(): void 46 | onInit?(file: NbtFile, prefix?: NbtPath): void 47 | onUpdate?(file: NbtFile, edit: NbtEdit): void 48 | onMessage?(message: ViewMessage): void 49 | onSearch?(query: SearchQuery | null): SearchResult[] 50 | menu?(): Element[] 51 | } 52 | 53 | export type EditHandler = (edit: NbtEdit) => void 54 | 55 | class Editor { 56 | private readonly panels: { 57 | [key: string]: { 58 | editor: () => EditorPanel, 59 | updated?: boolean, 60 | options?: string[], 61 | }, 62 | } = { 63 | structure: { 64 | editor: lazy(() => new StructureEditor(root, vscode, e => this.makeEdit(e), this.readOnly)), 65 | options: ['structure', 'default', 'snbt', 'info'], 66 | }, 67 | map: { 68 | editor: lazy(() => new MapEditor(root, vscode, e => this.makeEdit(e), this.readOnly)), 69 | options: ['map', 'default', 'snbt', 'info'], 70 | }, 71 | chunk: { 72 | editor: lazy(() => new ChunkEditor(root, vscode, e => this.makeEdit(e), this.readOnly)), 73 | options: ['chunk', 'default', 'snbt'], 74 | }, 75 | default: { 76 | editor: lazy(() => new TreeEditor(root, vscode, e => this.makeEdit(e), this.readOnly)), 77 | options: ['default', 'snbt', 'info'], 78 | }, 79 | snbt: { 80 | editor: lazy(() => new SnbtEditor(root, vscode, e => this.makeEdit(e), this.readOnly)), 81 | }, 82 | info: { 83 | editor: lazy(() => new FileInfoEditor(root, vscode, () => {}, this.readOnly)), 84 | }, 85 | } 86 | 87 | private type: string 88 | private nbtFile: NbtFile | NbtRegion.Ref 89 | private activePanel: string 90 | private readOnly: boolean 91 | 92 | private readonly findWidget: HTMLElement 93 | private searchQuery: SearchQuery = {} 94 | private searchResults: SearchResult[] | null = null 95 | private searchIndex: number = 0 96 | private lastReplace: NbtPath | null = null 97 | 98 | private inMap = false 99 | private selectedChunk: { x: number, z: number } = { x: 0, z: 0 } 100 | private readonly invalidChunks = new Set() 101 | 102 | private readonly messageCallbacks = new Map void, rej: (error: string) => void }>() 103 | private messageId = 1 104 | 105 | constructor() { 106 | window.addEventListener('message', async e => { 107 | this.onMessage(e.data) 108 | }) 109 | 110 | this.findWidget = document.querySelector('.find-widget') as HTMLElement 111 | const findTypeSelect = this.findWidget.querySelector('.find-part > .type-select > select') as HTMLSelectElement 112 | const findNameInput = this.findWidget.querySelector('.find-part > .name-input') as HTMLInputElement 113 | const findValueInput = this.findWidget.querySelector('.find-part > .value-input') as HTMLInputElement 114 | findTypeSelect.addEventListener('change', () => { 115 | findTypeSelect.parentElement!.setAttribute('data-icon', findTypeSelect.value) 116 | this.doSearch() 117 | }) 118 | this.findWidget.querySelectorAll('.type-select select').forEach(select => { 119 | ['Any', ...TYPES].filter(e => e !== 'End').forEach(t => { 120 | const option = document.createElement('option') 121 | option.value = t 122 | option.textContent = t.charAt(0).toUpperCase() + t.slice(1).split(/(?=[A-Z])/).join(' ') 123 | select.append(option) 124 | }) 125 | select.parentElement!.setAttribute('data-icon', 'Any') 126 | }) 127 | this.findWidget.querySelector('.find-part')?.addEventListener('keyup', evt => { 128 | if (evt.key !== 'Enter') { 129 | this.doSearch() 130 | } 131 | }) 132 | this.findWidget.querySelector('.find-part')?.addEventListener('keydown', evt => { 133 | if (evt.key === 'Enter') { 134 | if (evt.shiftKey) { 135 | this.showMatch(this.searchIndex - 1) 136 | } else { 137 | this.showMatch(this.searchIndex + 1) 138 | } 139 | } 140 | }) 141 | this.findWidget.querySelector('.previous-match')?.addEventListener('click', () => { 142 | this.showMatch(this.searchIndex - 1) 143 | }) 144 | this.findWidget.querySelector('.next-match')?.addEventListener('click', () => { 145 | this.showMatch(this.searchIndex + 1) 146 | }) 147 | this.findWidget.querySelector('.close-widget')?.addEventListener('click', () => { 148 | this.findWidget.classList.remove('visible') 149 | }) 150 | this.findWidget.querySelector('.replace-part')?.addEventListener('keydown', evt => { 151 | if (evt.key === 'Enter') { 152 | if (evt.altKey && evt.ctrlKey) { 153 | this.doReplaceAll() 154 | } else { 155 | this.doReplace() 156 | } 157 | } 158 | }) 159 | const replaceExpand = this.findWidget.querySelector('.replace-expand') 160 | replaceExpand?.addEventListener('click', () => { 161 | const expanded = this.findWidget.classList.toggle('expanded') 162 | replaceExpand?.classList.remove('codicon-chevron-right', 'codicon-chevron-down') 163 | replaceExpand?.classList.add(expanded ? 'codicon-chevron-down' : 'codicon-chevron-right') 164 | }) 165 | this.findWidget.querySelector('.replace')?.addEventListener('click', () => { 166 | this.doReplace() 167 | }) 168 | this.findWidget.querySelector('.replace-all')?.addEventListener('click', () => { 169 | this.doReplaceAll() 170 | }) 171 | 172 | document.querySelector('.region-menu .btn')?.addEventListener('click', () => { 173 | this.inMap = !this.inMap 174 | this.updateRegionMap() 175 | }) 176 | 177 | document.querySelectorAll('.region-menu input').forEach(el => { 178 | el.addEventListener('change', () => { 179 | this.refreshChunk() 180 | }) 181 | el.addEventListener('keydown', evt => { 182 | if ((evt as KeyboardEvent).key === 'Enter') { 183 | this.refreshChunk() 184 | } 185 | }) 186 | }) 187 | 188 | document.addEventListener('keydown', evt => { 189 | if (evt.ctrlKey && (evt.code === 'KeyF' || evt.code === 'KeyH')) { 190 | this.findWidget.classList.add('visible') 191 | if (this.searchQuery.name) { 192 | findNameInput.focus() 193 | findNameInput.setSelectionRange(0, findNameInput.value.length) 194 | } else { 195 | findValueInput.focus() 196 | findValueInput.setSelectionRange(0, findValueInput.value.length) 197 | } 198 | this.findWidget.classList.toggle('expanded', evt.code === 'KeyH') 199 | replaceExpand?.classList.remove('codicon-chevron-right', 'codicon-chevron-down') 200 | replaceExpand?.classList.add(evt.code === 'KeyH' ? 'codicon-chevron-down' : 'codicon-chevron-right') 201 | if (this.searchResults && this.searchResults.length > 0) { 202 | this.searchResults[this.searchIndex].show() 203 | } 204 | } else if (evt.key === 'Escape') { 205 | this.findWidget.classList.remove('visible') 206 | this.getPanel()?.onSearch?.(null) 207 | } 208 | }) 209 | document.addEventListener('contextmenu', evt => { 210 | evt.preventDefault() 211 | }) 212 | 213 | vscode.postMessage({ type: 'ready' }) 214 | } 215 | 216 | private onMessage(m: ViewMessage) { 217 | switch (m.type) { 218 | case 'init': 219 | console.log(m.body) 220 | this.type = m.body.type 221 | this.nbtFile = m.body.type === 'region' 222 | ? NbtRegion.fromJson(m.body.content, (x, z) => this.getChunkData(x, z)) 223 | : NbtFile.fromJson(m.body.content) 224 | console.log(this.nbtFile) 225 | this.readOnly = m.body.readOnly 226 | if (this.nbtFile instanceof NbtRegion.Ref) { 227 | this.type = 'chunk' 228 | this.activePanel = 'default' 229 | this.inMap = true 230 | this.invalidChunks.clear() 231 | this.updateRegionMap() 232 | } else { 233 | this.setPanel(this.type) 234 | } 235 | return 236 | 237 | case 'update': 238 | try { 239 | applyEdit(this.nbtFile, m.body) 240 | const { file: editedFile, edit } = getEditedFile(this.nbtFile, m.body) 241 | if (editedFile) { 242 | this.refreshSearch() 243 | Object.values(this.panels).forEach(p => p.updated = false) 244 | this.getPanel()?.onUpdate?.(editedFile, edit) 245 | this.panels[this.activePanel].updated = true 246 | } 247 | } catch (e) { 248 | vscode.postMessage({ type: 'error', body: e.message }) 249 | } 250 | return 251 | 252 | case 'response': 253 | const callback = this.messageCallbacks.get(m.requestId ?? 0) 254 | if (m.body) { 255 | callback?.res(m.body) 256 | } else { 257 | callback?.rej(m.error ?? 'Unknown response') 258 | } 259 | return 260 | 261 | default: 262 | this.panels[this.type].editor().onMessage?.(m) 263 | } 264 | } 265 | 266 | private async sendMessageWithResponse(message: EditorMessage) { 267 | const requestId = this.messageId++ 268 | const promise = new Promise((res, rej) => { 269 | this.messageCallbacks.set(requestId, { res, rej }) 270 | }) 271 | vscode.postMessage({ ...message, requestId }) 272 | return promise 273 | } 274 | 275 | private async getChunkData(x: number, z: number) { 276 | const data = await this.sendMessageWithResponse({ type: 'getChunkData', body: { x, z } }) 277 | const chunk = NbtFile.fromJson(data as JsonValue) 278 | return chunk 279 | } 280 | 281 | private getPanel(): EditorPanel | undefined { 282 | return this.panels[this.activePanel]?.editor() 283 | } 284 | 285 | private setPanel(panel: string) { 286 | root.innerHTML = '
' 287 | this.getPanel()?.hide?.() 288 | this.activePanel = panel 289 | const editorPanel = this.getPanel()! 290 | this.setPanelMenu(editorPanel) 291 | setTimeout(async () => { 292 | if (!this.panels[panel].updated) { 293 | try { 294 | if (this.nbtFile instanceof NbtRegion.Ref) { 295 | const chunk = this.nbtFile.findChunk(this.selectedChunk.x, this.selectedChunk.z) 296 | const file = chunk?.getFile() 297 | if (chunk && file) { 298 | editorPanel.onInit?.(file) 299 | } 300 | } else { 301 | editorPanel.onInit?.(this.nbtFile) 302 | } 303 | } catch (e) { 304 | if (e instanceof Error) { 305 | console.error(e) 306 | const div = document.createElement('div') 307 | div.classList.add('nbt-content', 'error') 308 | div.textContent = e.message 309 | root.innerHTML = '' 310 | root.append(div) 311 | return 312 | } 313 | } 314 | this.panels[panel].updated = true 315 | } 316 | root.innerHTML = '' 317 | editorPanel?.reveal?.() 318 | }) 319 | } 320 | 321 | private setPanelMenu(panel: EditorPanel) { 322 | const el = document.querySelector('.panel-menu')! 323 | el.innerHTML = '' 324 | const btnGroup = document.createElement('div') 325 | btnGroup.classList.add('btn-group') 326 | el.append(btnGroup) 327 | this.panels[this.type].options?.forEach((p: string) => { 328 | const button = document.createElement('div') 329 | btnGroup.append(button) 330 | button.classList.add('btn') 331 | button.textContent = locale(`panel.${p}`) 332 | if (p === this.activePanel) { 333 | button.classList.add('active') 334 | } else { 335 | button.addEventListener('click', () => this.setPanel(p)) 336 | } 337 | }) 338 | const menuPanels = panel.menu?.() ?? [] 339 | if (menuPanels.length > 0) { 340 | el.insertAdjacentHTML('beforeend', '') 341 | menuPanels.forEach(e => el.append(e)) 342 | } 343 | } 344 | 345 | private updateRegionMap() { 346 | if (!(this.nbtFile instanceof NbtRegion.Ref)) { 347 | return 348 | } 349 | document.querySelector('.region-menu .btn')?.classList.toggle('active', this.inMap) 350 | document.querySelector('.panel-menu')?.classList.toggle('hidden', this.inMap) 351 | document.querySelector('.nbt-editor')?.classList.toggle('hidden', this.inMap) 352 | 353 | document.querySelector('.region-map')?.remove() 354 | if (this.inMap) { 355 | const map = document.createElement('div') 356 | map.classList.add('region-map') 357 | for (let z = 0; z < 32; z += 1) { 358 | for (let x = 0; x < 32; x += 1) { 359 | const chunk = this.nbtFile.findChunk(x, z) 360 | const cell = document.createElement('div') 361 | cell.classList.add('region-map-chunk') 362 | cell.textContent = `${x} ${z}` 363 | cell.classList.toggle('empty', chunk === undefined) 364 | cell.classList.toggle('loaded', chunk?.isResolved() ?? false) 365 | cell.classList.toggle('invalid', this.invalidChunks.has(`${x} ${z}`)) 366 | if (chunk !== undefined) { 367 | cell.addEventListener('click', () => { 368 | this.selectChunk(x, z) 369 | }) 370 | } 371 | cell.setAttribute('data-pos', `${x} ${z}`) 372 | map.append(cell) 373 | } 374 | } 375 | document.body.append(map) 376 | } 377 | } 378 | 379 | private refreshChunk() { 380 | if (!(this.nbtFile instanceof NbtRegion.Ref)) { 381 | return 382 | } 383 | const x = getInt(document.getElementById('chunk-x')) ?? 0 384 | const z = getInt(document.getElementById('chunk-z')) ?? 0 385 | this.selectChunk(x, z) 386 | } 387 | 388 | private async selectChunk(x: number, z: number) { 389 | if (!(this.nbtFile instanceof NbtRegion.Ref)) { 390 | return 391 | } 392 | x = Math.max(0, Math.min(31, Math.floor(x))) 393 | z = Math.max(0, Math.min(31, Math.floor(z))); 394 | (document.getElementById('chunk-x') as HTMLInputElement).value = `${x}`; 395 | (document.getElementById('chunk-z') as HTMLInputElement).value = `${z}` 396 | if (this.selectedChunk.x === x && this.selectedChunk.z === z) { 397 | this.inMap = false 398 | this.updateRegionMap() 399 | return 400 | } 401 | this.selectedChunk = { x, z } 402 | const chunk = this.nbtFile.findChunk(x, z) 403 | if (!chunk) { 404 | this.invalidChunks.add(`${x} ${z}`) 405 | document.querySelector('.region-menu')?.classList.add('invalid') 406 | this.updateRegionMap() 407 | return 408 | } 409 | Object.values(this.panels).forEach(p => p.updated = false) 410 | try { 411 | await chunk.getFileAsync() 412 | } catch (e) { 413 | this.invalidChunks.add(`${x} ${z}`) 414 | document.querySelector('.region-menu')?.classList.add('invalid') 415 | this.updateRegionMap() 416 | return 417 | } 418 | document.querySelector('.region-menu')?.classList.remove('invalid') 419 | this.setPanel(this.activePanel) 420 | this.inMap = false 421 | this.updateRegionMap() 422 | this.setPanel(this.activePanel) 423 | } 424 | 425 | private doSearch() { 426 | const query = this.getQuery(this.findWidget.querySelector('.find-part')) 427 | if (['type', 'name', 'value'].every(e => this.searchQuery?.[e] === query[e])) { 428 | return 429 | } 430 | this.searchQuery = query 431 | this.searchIndex = 0 432 | this.refreshSearch() 433 | } 434 | 435 | private refreshSearch() { 436 | const editorPanel = this.getPanel() 437 | if (editorPanel?.onSearch && (this.searchQuery.name || this.searchQuery.value || this.searchQuery.type)) { 438 | this.searchResults = editorPanel.onSearch(this.searchQuery) 439 | } else { 440 | this.searchResults = null 441 | } 442 | if (this.lastReplace && this.searchResults?.[this.searchIndex]?.path?.equals(this.lastReplace)) { 443 | this.searchIndex += 1 444 | this.lastReplace = null 445 | } 446 | this.showMatch(this.searchIndex) 447 | } 448 | 449 | private doReplace() { 450 | if (this.searchResults === null || this.searchResults.length === 0) return 451 | const query = this.getQuery(this.findWidget.querySelector('.replace-part')) 452 | if (query.name || query.value || query.type) { 453 | const result = this.searchResults[this.searchIndex] 454 | this.lastReplace = result.path 455 | this.makeEdit(result.replace(query)) 456 | } 457 | console.log('Done replace!') 458 | } 459 | 460 | private doReplaceAll() { 461 | if (!this.searchResults) return 462 | const query = this.getQuery(this.findWidget.querySelector('.replace-part')) 463 | if (query.name || query.value || query.type) { 464 | const edits = this.searchResults.map(r => r.replace(query)) 465 | this.makeEdit({ type: 'composite', edits }) 466 | } 467 | } 468 | 469 | private getQuery(element: Element | null): SearchQuery { 470 | const typeQuery = (element?.querySelector('.type-select > select') as HTMLSelectElement).value 471 | const nameQuery = (element?.querySelector('.name-input') as HTMLInputElement).value 472 | const valueQuery = (element?.querySelector('.value-input') as HTMLInputElement).value 473 | return { 474 | type: typeQuery === 'Any' ? undefined : TYPES.indexOf(typeQuery as any), 475 | name: nameQuery || undefined, 476 | value: valueQuery || undefined, 477 | } 478 | } 479 | 480 | private showMatch(index: number) { 481 | if (this.searchResults === null || this.searchResults.length === 0) { 482 | this.findWidget.querySelector('.matches')!.textContent = 'No results' 483 | this.getPanel()?.onSearch?.(null) 484 | } else { 485 | const matches = this.searchResults.length 486 | this.searchIndex = (index % matches + matches) % matches 487 | this.findWidget.querySelector('.matches')!.textContent = `${this.searchIndex + 1} of ${matches}` 488 | this.searchResults[this.searchIndex].show() 489 | } 490 | this.findWidget.classList.toggle('no-results', this.searchResults !== null && this.searchResults.length === 0) 491 | this.findWidget.querySelectorAll('.previous-match, .next-match').forEach(e => 492 | e.classList.toggle('disabled', this.searchResults === null || this.searchResults.length === 0)) 493 | } 494 | 495 | private makeEdit(edit: NbtEdit) { 496 | if (this.readOnly) return 497 | if (this.nbtFile instanceof NbtRegion.Ref) { 498 | edit = { type: 'chunk', ...this.selectedChunk, edit } 499 | } 500 | console.warn('Edit', edit) 501 | vscode.postMessage({ type: 'edit', body: edit }) 502 | } 503 | } 504 | 505 | new Editor() 506 | -------------------------------------------------------------------------------- /src/editor/FileInfoEditor.ts: -------------------------------------------------------------------------------- 1 | import type { NbtFile } from 'deepslate' 2 | import { NbtInt, NbtString } from 'deepslate' 3 | import type { EditHandler, VSCode } from './Editor' 4 | import { TreeEditor } from './TreeEditor' 5 | 6 | export class FileInfoEditor extends TreeEditor { 7 | constructor(root: Element, vscode: VSCode, editHandler: EditHandler, readOnly: boolean) { 8 | super(root, vscode, editHandler, true) 9 | } 10 | 11 | onInit(file: NbtFile) { 12 | this.file.root.clear() 13 | .set('RootName', new NbtString(file.name)) 14 | .set('Endianness', new NbtString(file.littleEndian ? 'little' : 'big')) 15 | .set('Compression', new NbtString(file.compression ?? 'none')) 16 | if (file.bedrockHeader) { 17 | this.file.root.set('BedrockHeader', new NbtInt(file.bedrockHeader)) 18 | } 19 | super.onInit(this.file) 20 | } 21 | 22 | onUpdate(file: NbtFile) { 23 | this.onInit(file) 24 | } 25 | 26 | menu() { 27 | return [] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/editor/Locale.ts: -------------------------------------------------------------------------------- 1 | const LOCALES = { 2 | copy: 'Copy', 3 | name: 'Name', 4 | value: 'Value', 5 | confirm: 'Confirm', 6 | addTag: 'Add Tag', 7 | editTag: 'Edit', 8 | removeTag: 'Remove', 9 | renameTag: 'Rename', 10 | grid: 'Show Grid', 11 | invisibleBlocks: 'Show Invisible Blocks', 12 | invisibleBlocksUnavailable: 'Invisible blocks is unavailable for large structures', 13 | 'panel.structure': '3D', 14 | 'panel.chunk': '3D', 15 | 'panel.map': 'Map', 16 | 'panel.region': 'Region', 17 | 'panel.default': 'Default', 18 | 'panel.snbt': 'SNBT', 19 | 'panel.info': 'File Info', 20 | } 21 | 22 | export function locale(key: string) { 23 | return LOCALES[key] ?? key 24 | } 25 | -------------------------------------------------------------------------------- /src/editor/MapEditor.ts: -------------------------------------------------------------------------------- 1 | import { NbtFile } from 'deepslate' 2 | import type { NbtEdit } from '../common/types' 3 | import type { EditHandler, EditorPanel, VSCode } from './Editor' 4 | 5 | export class MapEditor implements EditorPanel { 6 | protected file: NbtFile 7 | 8 | constructor(protected root: Element, protected vscode: VSCode, protected editHandler: EditHandler, protected readOnly: boolean) { 9 | this.file = NbtFile.create() 10 | } 11 | 12 | reveal() { 13 | const content = document.createElement('div') 14 | content.classList.add('nbt-content') 15 | const canvas = document.createElement('canvas') 16 | canvas.classList.add('nbt-map') 17 | canvas.width = 128 18 | canvas.height = 128 19 | const ctx = canvas.getContext('2d')! 20 | this.paint(ctx) 21 | content.append(canvas) 22 | this.root.append(content) 23 | } 24 | 25 | onInit(file: NbtFile) { 26 | this.file = file 27 | } 28 | 29 | onUpdate(file: NbtFile, edit: NbtEdit) { 30 | this.onInit(file) 31 | } 32 | 33 | private paint(ctx: CanvasRenderingContext2D) { 34 | const img = ctx.createImageData(128, 128) 35 | const colors = this.file.root.getCompound('data').getByteArray('colors') 36 | for (let x = 0; x < 128; x += 1) { 37 | for (let z = 0; z < 128; z += 1) { 38 | const id = ((colors[x + z * 128] ?? 0) + 256) % 256 39 | const base = colorIds[id >> 2] ?? [0, 0, 0] 40 | const m = multipliers[id & 0b11] ?? 1 41 | const color = [base[0] * m, base[1] * m, base[2] * m] 42 | const i = x * 4 + z * 4 * 128 43 | img.data[i] = color[0] 44 | img.data[i+1] = color[1] 45 | img.data[i+2] = color[2] 46 | img.data[i+3] = id < 4 ? 0 : 255 47 | } 48 | } 49 | ctx.putImageData(img, 0, 0) 50 | } 51 | } 52 | 53 | const multipliers = [ 54 | 0.71, 55 | 0.86, 56 | 1, 57 | 0.53, 58 | ] 59 | 60 | const colorIds = [ 61 | [0, 0, 0], 62 | [127, 178, 56], 63 | [247, 233, 163], 64 | [199, 199, 199], 65 | [255, 0, 0], 66 | [160, 160, 255], 67 | [167, 167, 167], 68 | [0, 124, 0], 69 | [255, 255, 255], 70 | [164, 168, 184], 71 | [151, 109, 77], 72 | [112, 112, 112], 73 | [64, 64, 255], 74 | [143, 119, 72], 75 | [255, 252, 245], 76 | [216, 127, 51], 77 | [178, 76, 216], 78 | [102, 153, 216], 79 | [229, 229, 51], 80 | [127, 204, 25], 81 | [242, 127, 165], 82 | [76, 76, 76], 83 | [153, 153, 153], 84 | [76, 127, 153], 85 | [127, 63, 178], 86 | [51, 76, 178], 87 | [102, 76, 51], 88 | [102, 127, 51], 89 | [153, 51, 51], 90 | [25, 25, 25], 91 | [250, 238, 77], 92 | [92, 219, 213], 93 | [74, 128, 255], 94 | [0, 217, 58], 95 | [129, 86, 49], 96 | [112, 2, 0], 97 | [209, 177, 161], 98 | [159, 82, 36], 99 | [149, 87, 108], 100 | [112, 108, 138], 101 | [186, 133, 36], 102 | [103, 117, 53], 103 | [160, 77, 78], 104 | [57, 41, 35], 105 | [135, 107, 98], 106 | [87, 92, 92], 107 | [122, 73, 88], 108 | [76, 62, 92], 109 | [76, 50, 35], 110 | [76, 82, 42], 111 | [142, 60, 46], 112 | [37, 22, 16], 113 | [189, 48, 49], 114 | [148, 63, 97], 115 | [92, 25, 29], 116 | [22, 126, 134], 117 | [58, 142, 140], 118 | [86, 44, 62], 119 | [20, 180, 133], 120 | [100, 100, 100], 121 | [216, 175, 147], 122 | [127, 167, 150], 123 | ] 124 | -------------------------------------------------------------------------------- /src/editor/MultiStructure.ts: -------------------------------------------------------------------------------- 1 | import type { PlacedBlock, Structure, StructureProvider } from 'deepslate' 2 | import { BlockPos } from 'deepslate' 3 | 4 | export type StructureRegion = { 5 | pos: BlockPos, 6 | structure: StructureProvider, 7 | name?: string, 8 | } 9 | 10 | export class MultiStructure implements StructureProvider { 11 | constructor( 12 | private readonly size: BlockPos, 13 | private readonly regions: StructureRegion[], 14 | ) {} 15 | 16 | getSize(): BlockPos { 17 | return this.size 18 | } 19 | 20 | getBlock(pos: BlockPos): PlacedBlock | null { 21 | for (const region of this.regions) { 22 | if (MultiStructure.posInRegion(pos, region)) { 23 | const block = region.structure.getBlock(BlockPos.subtract(pos, region.pos)) 24 | if (block !== null) { 25 | return block 26 | } 27 | } 28 | } 29 | return null 30 | } 31 | 32 | getBlocks(): PlacedBlock[] { 33 | return this.regions.flatMap(r => { 34 | try { 35 | return r.structure.getBlocks().map(b => ({ 36 | pos: BlockPos.add(r.pos, b.pos), 37 | state: b.state, 38 | ...b.nbt ? { nbt: b.nbt } : {}, 39 | })) 40 | } catch (e) { 41 | if (e instanceof Error) { 42 | console.log((r.structure as Structure)['blocks']) 43 | e.message = e.message.replace(' in structure ', ` in structure region "${r.name}" `) 44 | } 45 | throw e 46 | } 47 | }) 48 | } 49 | 50 | private static posInRegion(pos: BlockPos, region: StructureRegion) { 51 | const size = region.structure.getSize() 52 | return pos[0] >= region.pos[0] && pos[0] < region.pos[0] + size[0] 53 | && pos[1] >= region.pos[1] && pos[1] < region.pos[1] + size[1] 54 | && pos[2] >= region.pos[2] && pos[2] < region.pos[2] + size[2] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/editor/ResourceManager.ts: -------------------------------------------------------------------------------- 1 | import type { BlockDefinitionProvider, BlockFlagsProvider, BlockModelProvider, BlockPropertiesProvider, TextureAtlasProvider } from 'deepslate' 2 | import { BlockDefinition, BlockModel, Identifier, TextureAtlas, upperPowerOfTwo } from 'deepslate' 3 | import { NON_SELF_CULLING, OPAQUE_BLOCKS, TRANSLUCENT_BLOCKS } from './BlockFlags' 4 | 5 | export class ResourceManager implements BlockDefinitionProvider, BlockModelProvider, BlockFlagsProvider, BlockPropertiesProvider, TextureAtlasProvider { 6 | private blockDefinitions: { [id: string]: BlockDefinition } 7 | private blockModels: { [id: string]: BlockModel } 8 | private textureAtlas: TextureAtlas 9 | private readonly blocks: Map, 11 | properties: Record, 12 | }> 13 | 14 | constructor(blocks: any, assets: any, textureAtlas: HTMLImageElement) { 15 | this.blocks = new Map(Object.entries(blocks) 16 | .map(([k, v]: [string, any]) => [ 17 | Identifier.create(k).toString(), 18 | { properties: v[0], default: v[1] }, 19 | ])) 20 | this.blockDefinitions = {} 21 | this.blockModels = {} 22 | this.textureAtlas = TextureAtlas.empty() 23 | this.loadBlockDefinitions(assets.blockstates) 24 | this.loadBlockModels(assets.models) 25 | this.loadBlockAtlas(textureAtlas, assets.textures) 26 | } 27 | 28 | public getBlockDefinition(id: Identifier) { 29 | return this.blockDefinitions[id.toString()] 30 | } 31 | 32 | public getBlockModel(id: Identifier) { 33 | return this.blockModels[id.toString()] 34 | } 35 | 36 | public getTextureUV(id: Identifier) { 37 | return this.textureAtlas.getTextureUV(id) 38 | } 39 | 40 | public getTextureAtlas() { 41 | return this.textureAtlas.getTextureAtlas() 42 | } 43 | 44 | public getBlockFlags(id: Identifier) { 45 | const str = id.toString() 46 | return { 47 | opaque: OPAQUE_BLOCKS.has(str), 48 | semi_transparent: TRANSLUCENT_BLOCKS.has(str), 49 | self_culling: !NON_SELF_CULLING.has(str), 50 | } 51 | } 52 | 53 | public getBlockProperties(id: Identifier) { 54 | return this.blocks[id.toString()]?.properties ?? null 55 | } 56 | 57 | public getDefaultBlockProperties(id: Identifier) { 58 | return this.blocks.get(id.toString())?.default ?? null 59 | } 60 | 61 | public loadBlockDefinitions(definitions: any) { 62 | Object.keys(definitions).forEach(id => { 63 | this.blockDefinitions[Identifier.create(id).toString()] = BlockDefinition.fromJson(definitions[id]) 64 | }) 65 | } 66 | 67 | public loadBlockModels(models: any) { 68 | Object.keys(models).forEach(id => { 69 | this.blockModels[Identifier.create(id).toString()] = BlockModel.fromJson(models[id]) 70 | }) 71 | Object.values(this.blockModels).forEach(m => m.flatten(this)) 72 | } 73 | 74 | public loadBlockAtlas(image: HTMLImageElement, textures: any) { 75 | const atlasCanvas = document.createElement('canvas') 76 | const w = upperPowerOfTwo(image.width) 77 | const h = upperPowerOfTwo(image.height) 78 | atlasCanvas.width = w 79 | atlasCanvas.height = h 80 | const atlasCtx = atlasCanvas.getContext('2d')! 81 | atlasCtx.drawImage(image, 0, 0) 82 | const atlasData = atlasCtx.getImageData(0, 0, w, h) 83 | const idMap = {} 84 | Object.keys(textures).forEach(id => { 85 | const [u, v, du, dv] = textures[id] 86 | const dv2 = (du !== dv && id.startsWith('block/')) ? du : dv 87 | idMap[Identifier.create(id).toString()] = [u / w, v / h, (u + du) / w, (v + dv2) / h] 88 | }) 89 | this.textureAtlas = new TextureAtlas(atlasData, idMap) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/editor/Schematics.ts: -------------------------------------------------------------------------------- 1 | import type { BlockPos } from 'deepslate' 2 | import { BlockState, NbtCompound, NbtIntArray, NbtType, Structure } from 'deepslate' 3 | import { fromAlphaMaterial } from './AlphaMaterials' 4 | import type { StructureRegion } from './MultiStructure' 5 | import { MultiStructure } from './MultiStructure' 6 | 7 | function getTriple(tag: NbtCompound): BlockPos { 8 | return [tag.getNumber('x'), tag.getNumber('y'), tag.getNumber('z')] 9 | } 10 | 11 | export function spongeToStructure(root: NbtCompound) { 12 | const width = root.getNumber('Width') 13 | const height = root.getNumber('Height') 14 | const length = root.getNumber('Length') 15 | 16 | const schemPalette = root.getCompound('Palette') 17 | const palette: BlockState[] = [] 18 | for (const key of schemPalette.keys()) { 19 | const id = schemPalette.getNumber(key) 20 | palette[id] = BlockState.parse(key) 21 | } 22 | 23 | const blockData = root.getByteArray('BlockData').map(e => e.getAsNumber()) 24 | const blockEntities = new Map() 25 | root.getList('BlockEntities', NbtType.Compound).forEach((tag) => { 26 | const pos = tag.getIntArray('Pos').toString() 27 | const copy = NbtCompound.fromJson(tag.toJson()) 28 | copy.delete('Pos') 29 | blockEntities.set(pos, copy) 30 | }) 31 | const blocks: { pos: BlockPos, state: number, nbt?: NbtCompound }[] = [] 32 | let i = 0 33 | for (let y = 0; y < height; y += 1) { 34 | for (let z = 0; z < length; z += 1) { 35 | for (let x = 0; x < width; x += 1) { 36 | let id = blockData[i] ?? 0 37 | i += 1 38 | if (id > 127) { 39 | id += ((blockData[i] ?? 0) - 1) << 7 40 | i += 1 41 | } 42 | const pos = new NbtIntArray([x, y, z]).toString() 43 | blocks.push({ 44 | pos: [x, y, z], 45 | state: id, 46 | nbt: blockEntities.get(pos), 47 | }) 48 | } 49 | } 50 | } 51 | 52 | return new Structure([width, height, length], palette, blocks) 53 | } 54 | 55 | export function litematicToStructure(root: NbtCompound) { 56 | const enclosingSize = root.getCompound('Metadata').getCompound('EnclosingSize') 57 | const [width, height, length] = getTriple(enclosingSize) 58 | 59 | const regions: StructureRegion[] = [] 60 | root.getCompound('Regions').forEach((name, region) => { 61 | if (!region.isCompound()) return 62 | const pos = getTriple(region.getCompound('Position')) 63 | const size = getTriple(region.getCompound('Size')) 64 | for (let i = 0; i < 3; i += 1) { 65 | if (size[i] < 0) { 66 | pos[i] += size[i] 67 | size[i] = -size[i] 68 | } 69 | } 70 | const volume = size[0] * size[1] * size[2] 71 | const stretches = true 72 | 73 | const palette = region.getList('BlockStatePalette', NbtType.Compound).map(BlockState.fromNbt) 74 | const blockStates = region.getLongArray('BlockStates') 75 | const tempDataview = new DataView(new Uint8Array(8).buffer) 76 | const statesData = blockStates.map(long => { 77 | tempDataview.setInt32(0, Number(long.getAsPair()[0])) 78 | tempDataview.setInt32(4, Number(long.getAsPair()[1])) 79 | return tempDataview.getBigUint64(0) 80 | }) 81 | 82 | // litematica use at least 2 bits for palette indices (https://github.com/misode/vscode-nbt/issues/76) 83 | const bits = Math.max(2, Math.ceil(Math.log2(palette.length))) 84 | const bigBits = BigInt(bits) 85 | const big0 = BigInt(0) 86 | const big64 = BigInt(64) 87 | const bitMask = BigInt(Math.pow(2, bits) - 1) 88 | let state = 0 89 | let data = statesData[state] 90 | let dataLength = big64 91 | 92 | const arr: number[] = [] 93 | for (let j = 0; j < volume; j += 1) { 94 | if (dataLength < bits) { 95 | state += 1 96 | let newData = statesData[state] 97 | if (newData === undefined) { 98 | console.error(`Out of bounds states access ${state}`) 99 | newData = big0 100 | } 101 | if (stretches) { 102 | data = (newData << dataLength) | data 103 | dataLength += big64 104 | } else { 105 | data = newData 106 | dataLength = big64 107 | } 108 | } 109 | 110 | let paletteId = Number(data & bitMask) 111 | if (paletteId > palette.length - 1) { 112 | console.error(`Invalid palette ID ${paletteId}`) 113 | paletteId = 0 114 | } 115 | arr.push(paletteId) 116 | data >>= bigBits 117 | dataLength -= bigBits 118 | } 119 | const blocks: { pos: BlockPos, state: number, nbt?: NbtCompound }[] = [] 120 | const blockEntities = new Map() 121 | region.getList('TileEntities', NbtType.Compound).forEach((tag) => { 122 | const pos = getTriple(tag).toString() 123 | const copy = NbtCompound.fromJson(tag.toJson()) 124 | copy.delete('x') 125 | copy.delete('y') 126 | copy.delete('z') 127 | blockEntities.set(pos, copy) 128 | }) 129 | for (let x = 0; x < size[0]; x += 1) { 130 | for (let y = 0; y < size[1]; y += 1) { 131 | for (let z = 0; z < size[2]; z += 1) { 132 | const index = (y * size[0] * size[2]) + z * size[0] + x 133 | const pos = [x, y, z].toString() 134 | blocks.push({ 135 | pos: [x, y, z], 136 | state: arr[index], 137 | nbt: blockEntities.get(pos), 138 | }) 139 | } 140 | } 141 | } 142 | const structure = new Structure(size, palette, blocks) 143 | regions.push({ pos, structure, name }) 144 | }) 145 | 146 | const minPos: BlockPos = [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER] 147 | for (const region of regions) { 148 | for (let i = 0; i < 3; i += 1) { 149 | minPos[i] = Math.min(minPos[i], region.pos[i]) 150 | } 151 | } 152 | for (const region of regions) { 153 | for (let i = 0; i < 3; i += 1) { 154 | region.pos[i] -= minPos[i] 155 | } 156 | } 157 | 158 | return new MultiStructure([width, height, length], regions) 159 | } 160 | 161 | export function schematicToStructure(root: NbtCompound) { 162 | const width = root.getNumber('Width') 163 | const height = root.getNumber('Height') 164 | const length = root.getNumber('Length') 165 | 166 | const blocksArray = root.getByteArray('Blocks').map(e => e.getAsNumber()) 167 | const dataArray = root.getByteArray('Data').map(e => e.getAsNumber()) 168 | 169 | const blockEntities = new Map() 170 | root.getList('TileEntities', NbtType.Compound).forEach((tag) => { 171 | const pos = getTriple(tag).toString() 172 | const copy = NbtCompound.fromJson(tag.toJson()) 173 | copy.delete('x') 174 | copy.delete('y') 175 | copy.delete('z') 176 | blockEntities.set(pos, copy) 177 | }) 178 | 179 | const structure = new Structure([width, height, length]) 180 | for (let x = 0; x < width; x += 1) { 181 | for (let y = 0; y < height; y += 1) { 182 | for (let z = 0; z < length; z += 1) { 183 | const i = (y * width * length) + z * width + x 184 | const blockStata = fromAlphaMaterial(blocksArray[i], dataArray[i]) 185 | const nbt = blockEntities.get([x, y, z].toString()) 186 | structure.addBlock([x, y, z], blockStata.getName(), blockStata.getProperties(), nbt) 187 | } 188 | } 189 | } 190 | 191 | return structure 192 | } 193 | -------------------------------------------------------------------------------- /src/editor/SnbtEditor.ts: -------------------------------------------------------------------------------- 1 | import type { NbtFile } from 'deepslate' 2 | import { NbtTag } from 'deepslate' 3 | import type { EditHandler, EditorPanel, VSCode } from './Editor' 4 | import { locale } from './Locale' 5 | 6 | export class SnbtEditor implements EditorPanel { 7 | private file: NbtFile 8 | private snbt: string 9 | 10 | constructor(private readonly root: Element, private readonly vscode: VSCode, private readonly editHandler: EditHandler, private readonly readOnly: boolean) { 11 | this.snbt = '' 12 | } 13 | 14 | reveal() { 15 | const content = document.createElement('div') 16 | content.classList.add('nbt-content') 17 | const textarea = document.createElement('textarea') 18 | textarea.classList.add('snbt-editor') 19 | textarea.textContent = this.snbt 20 | textarea.rows = (this.snbt.match(/\n/g)?.length ?? 0) + 1 21 | textarea.addEventListener('change', () => { 22 | const newRoot = NbtTag.fromString(textarea.value) 23 | this.editHandler({ type: 'set', path: [], old: this.file.root.toJsonWithId(), new: newRoot.toJsonWithId() }) 24 | }) 25 | content.append(textarea) 26 | this.root.append(content) 27 | } 28 | 29 | onInit(file: NbtFile) { 30 | this.file = file 31 | this.snbt = file.root.toPrettyString(' ') 32 | const textarea = this.root.querySelector('.snbt-editor') 33 | if (textarea) { 34 | textarea.textContent = this.snbt 35 | } 36 | } 37 | 38 | onUpdate(file: NbtFile) { 39 | this.onInit(file) 40 | } 41 | 42 | menu() { 43 | const copyButton = document.createElement('div') 44 | copyButton.classList.add('btn') 45 | copyButton.textContent = locale('copy') 46 | copyButton.addEventListener('click', () => { 47 | const textarea = this.root.querySelector('.snbt-editor') as HTMLTextAreaElement 48 | textarea.select() 49 | document.execCommand('copy') 50 | }) 51 | 52 | return [copyButton] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/editor/StructureEditor.ts: -------------------------------------------------------------------------------- 1 | import type { StructureProvider } from 'deepslate' 2 | import { BlockPos, NbtFile, NbtInt, NbtType, Structure, StructureRenderer } from 'deepslate' 3 | import { mat4, vec2, vec3 } from 'gl-matrix' 4 | import { mapEdit } from '../common/Operations' 5 | import type { NbtEdit } from '../common/types' 6 | import type { EditHandler, EditorPanel, VSCode } from './Editor' 7 | import { locale } from './Locale' 8 | import { ResourceManager } from './ResourceManager' 9 | import { litematicToStructure, schematicToStructure, spongeToStructure } from './Schematics' 10 | import { TreeEditor } from './TreeEditor' 11 | import { clamp, clampVec3, negVec3 } from './Util' 12 | 13 | declare const stringifiedAssets: string 14 | declare const stringifiedBlocks: string 15 | declare const stringifiedUvmapping: string 16 | 17 | export class StructureEditor implements EditorPanel { 18 | private readonly resources: ResourceManager 19 | protected file: NbtFile 20 | protected structure: StructureProvider 21 | private readonly warning: HTMLDivElement 22 | private readonly canvas: HTMLCanvasElement 23 | private readonly canvas2: HTMLCanvasElement 24 | private readonly gl2: WebGLRenderingContext 25 | protected readonly renderer: StructureRenderer 26 | protected readonly renderer2: StructureRenderer 27 | 28 | private renderRequested = false 29 | 30 | private movement = [0, 0, 0, 0, 0, 0] 31 | private readonly movementKeys = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space', 'ShiftLeft'] 32 | 33 | protected readonly cPos: vec3 34 | protected cRot: vec2 35 | protected cDist: number 36 | 37 | protected gridActive: boolean 38 | protected invisibleBlocksActive: boolean 39 | protected selectedBlock: BlockPos | null 40 | 41 | constructor(protected readonly root: Element, protected readonly vscode: VSCode, protected readonly editHandler: EditHandler, protected readonly readOnly: boolean) { 42 | const assets = JSON.parse(stringifiedAssets) 43 | const uvMapping = JSON.parse(stringifiedUvmapping) 44 | const blocks = JSON.parse(stringifiedBlocks) 45 | const img = (document.querySelector('.texture-atlas') as HTMLImageElement) 46 | this.resources = new ResourceManager(blocks, { ...assets, textures: uvMapping }, img) 47 | 48 | this.canvas = document.createElement('canvas') 49 | this.canvas.className = 'structure-3d' 50 | const gl = this.canvas.getContext('webgl')! 51 | this.structure = new Structure([0, 0, 0]) 52 | this.renderer = new StructureRenderer(gl, this.structure, this.resources) 53 | 54 | this.canvas2 = document.createElement('canvas') 55 | this.canvas2.className = 'structure-3d click-detection' 56 | this.gl2 = this.canvas2.getContext('webgl')! 57 | this.renderer2 = new StructureRenderer(this.gl2, this.structure, this.resources) 58 | 59 | this.warning = document.createElement('div') 60 | this.warning.className = 'nbt-warning' 61 | const warningMsg = document.createElement('span') 62 | warningMsg.textContent = 'Trying to render a very large structure' 63 | this.warning.append(warningMsg) 64 | const warningButton = document.createElement('div') 65 | warningButton.className = 'btn active' 66 | warningButton.textContent = 'Continue' 67 | warningButton.addEventListener('click', () => { 68 | this.warning.classList.remove('active') 69 | this.root.innerHTML = '
' 70 | setTimeout(() => { 71 | this.buildStructure() 72 | this.reveal() 73 | this.render() 74 | }) 75 | }) 76 | this.warning.append(warningButton) 77 | 78 | this.cPos = vec3.create() 79 | this.cRot = vec2.fromValues(0.4, 0.6) 80 | this.cDist = 10 81 | 82 | this.gridActive = true 83 | this.invisibleBlocksActive = false 84 | this.selectedBlock = null 85 | 86 | let dragTime: number 87 | let dragPos: [number, number] | null = null 88 | let dragButton: number 89 | this.canvas.addEventListener('mousedown', evt => { 90 | dragTime = Date.now() 91 | dragPos = [evt.clientX, evt.clientY] 92 | dragButton = evt.button 93 | }) 94 | this.canvas.addEventListener('mousemove', evt => { 95 | if (dragPos) { 96 | const dx = (evt.clientX - dragPos[0]) / 100 97 | const dy = (evt.clientY - dragPos[1]) / 100 98 | if (dragButton === 0) { 99 | vec2.add(this.cRot, this.cRot, [dx, dy]) 100 | this.cRot[0] = this.cRot[0] % (Math.PI * 2) 101 | this.cRot[1] = clamp(this.cRot[1], -Math.PI / 2, Math.PI / 2) 102 | } else if (dragButton === 2 || dragButton === 1) { 103 | vec3.rotateY(this.cPos, this.cPos, [0, 0, 0], this.cRot[0]) 104 | vec3.rotateX(this.cPos, this.cPos, [0, 0, 0], this.cRot[1]) 105 | const d = vec3.fromValues(dx, -dy, 0) 106 | vec3.scale(d, d, 0.25 * this.cDist) 107 | vec3.add(this.cPos, this.cPos, d) 108 | vec3.rotateX(this.cPos, this.cPos, [0, 0, 0], -this.cRot[1]) 109 | vec3.rotateY(this.cPos, this.cPos, [0, 0, 0], -this.cRot[0]) 110 | clampVec3(this.cPos, negVec3(this.structure.getSize()), [0, 0, 0]) 111 | } else { 112 | return 113 | } 114 | dragPos = [evt.clientX, evt.clientY] 115 | this.render() 116 | } 117 | }) 118 | this.canvas.addEventListener('mouseup', evt => { 119 | dragPos = null 120 | if (Date.now() - dragTime < 200) { 121 | if (dragButton === 0) { 122 | this.selectBlock(evt.clientX, evt.clientY) 123 | } 124 | } 125 | }) 126 | this.canvas.addEventListener('wheel', evt => { 127 | this.cDist += evt.deltaY / 100 128 | this.cDist = Math.max(1, Math.min(100, this.cDist)) 129 | this.render() 130 | }) 131 | 132 | window.addEventListener('resize', () => { 133 | if (this.resize()) this.render() 134 | }) 135 | 136 | this.render() 137 | } 138 | 139 | render() { 140 | if (this.renderRequested) { 141 | return 142 | } 143 | const requestTime = performance.now() 144 | this.renderRequested = true 145 | requestAnimationFrame((time) => { 146 | const delta = Math.max(0, time - requestTime) 147 | this.renderRequested = false 148 | this.resize() 149 | 150 | if (this.movement.some(m => m)) { 151 | vec3.rotateY(this.cPos, this.cPos, [0, 0, 0], this.cRot[0]) 152 | const [w, a, s, d, space, shift] = this.movement 153 | const move = vec3.fromValues(a - d, shift - space, w - s) 154 | vec3.scaleAndAdd(this.cPos, this.cPos, move, delta * 0.02) 155 | vec3.rotateY(this.cPos, this.cPos, [0, 0, 0], -this.cRot[0]) 156 | this.render() 157 | } 158 | 159 | const viewMatrix = this.getViewMatrix() 160 | 161 | if (this.gridActive) { 162 | this.renderer.drawGrid(viewMatrix) 163 | } 164 | 165 | if (this.invisibleBlocksActive) { 166 | this.renderer.drawInvisibleBlocks(viewMatrix) 167 | } 168 | 169 | this.renderer.drawStructure(viewMatrix) 170 | 171 | if (this.selectedBlock) { 172 | this.renderer.drawOutline(viewMatrix, this.selectedBlock) 173 | } 174 | }) 175 | } 176 | 177 | resize() { 178 | const displayWidth2 = this.canvas2.clientWidth 179 | const displayHeight2 = this.canvas2.clientHeight 180 | if (this.canvas2.width !== displayWidth2 || this.canvas2.height !== displayHeight2) { 181 | this.canvas2.width = displayWidth2 182 | this.canvas2.height = displayHeight2 183 | this.renderer2.setViewport(0, 0, this.canvas2.width, this.canvas2.height) 184 | } 185 | 186 | const displayWidth = this.canvas.clientWidth 187 | const displayHeight = this.canvas.clientHeight 188 | if (this.canvas.width !== displayWidth || this.canvas.height !== displayHeight) { 189 | this.canvas.width = displayWidth 190 | this.canvas.height = displayHeight 191 | this.renderer.setViewport(0, 0, this.canvas.width, this.canvas.height) 192 | return true 193 | } 194 | return false 195 | } 196 | 197 | reveal() { 198 | this.root.append(this.warning) 199 | this.root.append(this.canvas) 200 | this.root.append(this.canvas2) 201 | this.showSidePanel() 202 | document.addEventListener('keydown', this.onKeyDown) 203 | document.addEventListener('keyup', this.onKeyUp) 204 | } 205 | 206 | hide() { 207 | document.removeEventListener('keydown', this.onKeyDown) 208 | document.removeEventListener('keyup', this.onKeyUp) 209 | } 210 | 211 | onInit(file: NbtFile) { 212 | this.updateStructure(file) 213 | vec3.copy(this.cPos, this.structure.getSize()) 214 | vec3.scale(this.cPos, this.cPos, -0.5) 215 | this.cDist = vec3.dist([0, 0, 0], this.cPos) * 1.5 216 | this.render() 217 | } 218 | 219 | onUpdate(file: NbtFile, edit: NbtEdit) { 220 | this.updateStructure(file) 221 | this.showSidePanel() 222 | this.render() 223 | } 224 | 225 | private readonly onKeyDown = (evt: KeyboardEvent) => { 226 | const index = this.movementKeys.indexOf(evt.code) 227 | if (index !== -1) { 228 | this.movement[index] = 1 229 | this.render() 230 | } 231 | } 232 | 233 | private readonly onKeyUp = (evt: KeyboardEvent) => { 234 | const index = this.movementKeys.indexOf(evt.code) 235 | if (index !== -1) { 236 | this.movement[index] = 0 237 | } 238 | } 239 | 240 | protected updateStructure(file: NbtFile) { 241 | this.file = file 242 | this.structure = this.loadStructure() 243 | const isLarge = this.isLarge() 244 | const toggle = document.querySelector('.invisible-blocks-toggle') 245 | toggle?.classList.toggle('unavailable', isLarge) 246 | toggle?.setAttribute('title', isLarge ? locale('invisibleBlocksUnavailable') : '') 247 | 248 | if (isLarge) { 249 | this.warning.classList.add('active') 250 | return 251 | } 252 | 253 | this.buildStructure() 254 | } 255 | 256 | protected isLarge() { 257 | const [x, y, z] = this.structure.getSize() 258 | return x * y * z > 48 * 48 * 48 259 | } 260 | 261 | protected loadStructure() { 262 | if (this.file.root.get('BlockData')?.isByteArray() && this.file.root.hasCompound('Palette')) { 263 | return spongeToStructure(this.file.root) 264 | } 265 | if (this.file.root.hasCompound('Regions')) { 266 | return litematicToStructure(this.file.root) 267 | } 268 | if (this.file.root.get('Blocks')?.isByteArray() && this.file.root.get('Data')?.isByteArray()) { 269 | return schematicToStructure(this.file.root) 270 | } 271 | return Structure.fromNbt(this.file.root) 272 | } 273 | 274 | private buildStructure() { 275 | const isLarge = this.isLarge() 276 | this.renderer.useInvisibleBlocks = !isLarge 277 | this.renderer2.useInvisibleBlocks = !isLarge 278 | this.renderer.setStructure(this.structure) 279 | this.renderer2.setStructure(this.structure) 280 | } 281 | 282 | menu() { 283 | const gridToggle = document.createElement('div') 284 | gridToggle.classList.add('btn') 285 | gridToggle.textContent = locale('grid') 286 | gridToggle.classList.toggle('active', this.gridActive) 287 | gridToggle.addEventListener('click', () => { 288 | this.gridActive = !this.gridActive 289 | gridToggle.classList.toggle('active', this.gridActive) 290 | this.render() 291 | }) 292 | 293 | const invisibleBlocksToggle = document.createElement('div') 294 | invisibleBlocksToggle.classList.add('btn', 'invisible-blocks-toggle') 295 | invisibleBlocksToggle.textContent = locale('invisibleBlocks') 296 | invisibleBlocksToggle.classList.toggle('active', this.invisibleBlocksActive) 297 | invisibleBlocksToggle.addEventListener('click', () => { 298 | if (!this.renderer.useInvisibleBlocks) return 299 | this.invisibleBlocksActive = !this.invisibleBlocksActive 300 | invisibleBlocksToggle.classList.toggle('active', this.invisibleBlocksActive) 301 | this.render() 302 | }) 303 | 304 | return [gridToggle, invisibleBlocksToggle] 305 | } 306 | 307 | private getViewMatrix() { 308 | const viewMatrix = mat4.create() 309 | mat4.translate(viewMatrix, viewMatrix, [0, 0, -this.cDist]) 310 | mat4.rotateX(viewMatrix, viewMatrix, this.cRot[1]) 311 | mat4.rotateY(viewMatrix, viewMatrix, this.cRot[0]) 312 | mat4.translate(viewMatrix, viewMatrix, this.cPos) 313 | return viewMatrix 314 | } 315 | 316 | private selectBlock(x: number, y: number) { 317 | const viewMatrix = this.getViewMatrix() 318 | this.renderer2.drawColoredStructure(viewMatrix) 319 | const color = new Uint8Array(4) 320 | this.gl2.readPixels(x, this.canvas2.height - y, 1, 1, this.gl2.RGBA, this.gl2.UNSIGNED_BYTE, color) 321 | const oldSelectedBlock = this.selectedBlock ? [...this.selectedBlock] : null 322 | if (color[3] === 255) { 323 | this.selectedBlock = [color[0], color[1], color[2]] 324 | } else { 325 | this.selectedBlock = null 326 | } 327 | if (JSON.stringify(oldSelectedBlock) !== JSON.stringify(this.selectedBlock)) { 328 | this.showSidePanel() 329 | this.render() 330 | } 331 | } 332 | 333 | protected showSidePanel() { 334 | this.root.querySelector('.side-panel')?.remove() 335 | const block = this.selectedBlock ? this.structure.getBlock(this.selectedBlock) : null 336 | 337 | const readOnly = this.readOnly || !this.file.root.hasList('size', NbtType.Int, 3) 338 | 339 | const sidePanel = document.createElement('div') 340 | sidePanel.classList.add('side-panel') 341 | this.root.append(sidePanel) 342 | if (block) { 343 | const properties = block.state.getProperties() 344 | sidePanel.innerHTML = ` 345 |
${block.state.getName()}
346 |
${block.pos.join(' ')}
347 | ${Object.keys(properties).length === 0 ? '' : ` 348 |
349 | ${Object.entries(properties).map(([k, v]) => ` 350 | ${k} 351 | ${v} 352 | `).join('')} 353 |
354 | `} 355 | ` 356 | if (block.nbt) { 357 | const nbtTree = document.createElement('div') 358 | sidePanel.append(nbtTree) 359 | const blockIndex = this.file.root.getList('blocks', NbtType.Compound).getItems() 360 | .findIndex(t => BlockPos.equals(BlockPos.fromNbt(t.getList('pos')), block.pos)) 361 | const tree = new TreeEditor(nbtTree, this.vscode, edit => { 362 | this.editHandler(mapEdit(edit, e => { 363 | return { ...e, path: ['blocks', blockIndex, 'nbt', ...e.path] } 364 | })) 365 | }, readOnly) 366 | tree.onInit(new NbtFile('', block.nbt, 'none', this.file.littleEndian, undefined)) 367 | tree.reveal() 368 | } 369 | } else { 370 | sidePanel.innerHTML = ` 371 |
372 | 373 | 374 | 375 | 376 |
377 | ` 378 | sidePanel.querySelectorAll('.structure-size input').forEach((el, i) => { 379 | const original = this.structure.getSize()[i]; 380 | (el as HTMLInputElement).value = original.toString() 381 | if (readOnly) return 382 | 383 | el.addEventListener('change', () => { 384 | this.editHandler({ 385 | type: 'set', 386 | path: ['size', i], 387 | old: new NbtInt(original).toJsonWithId(), 388 | new: new NbtInt(parseInt((el as HTMLInputElement).value)).toJsonWithId(), 389 | }) 390 | }) 391 | }) 392 | } 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /src/editor/TreeEditor.ts: -------------------------------------------------------------------------------- 1 | import type { NbtCompound, NbtList } from 'deepslate' 2 | import { NbtFile, NbtTag, NbtType } from 'deepslate' 3 | import { NbtPath } from '../common/NbtPath' 4 | import type { SearchQuery } from '../common/Operations' 5 | import { getNode, parsePrimitive, replaceNode, searchNodes, serializePrimitive } from '../common/Operations' 6 | import type { NbtEdit } from '../common/types' 7 | import type { EditHandler, EditorPanel, SearchResult, VSCode } from './Editor' 8 | import { TYPES } from './Editor' 9 | import { locale } from './Locale' 10 | 11 | export type SelectedTag = { 12 | path: NbtPath, 13 | tag: NbtTag, 14 | el: Element, 15 | } 16 | 17 | export type EditingTag = Omit & { path: NbtPath | null } 18 | 19 | type PathToElements = { 20 | element?: Element, 21 | childs?: { 22 | [key in string | number]: PathToElements 23 | }, 24 | } 25 | 26 | export class TreeEditor implements EditorPanel { 27 | static readonly EXPANDABLE_TYPES = new Set([NbtType.Compound, NbtType.List, NbtType.ByteArray, NbtType.IntArray, NbtType.LongArray]) 28 | 29 | protected expanded: Set 30 | protected content: HTMLDivElement 31 | protected file: NbtFile 32 | protected prefix: NbtPath 33 | 34 | protected pathToElement: PathToElements 35 | protected highlighted: null | NbtPath 36 | protected selected: null | SelectedTag 37 | protected editing: null | EditingTag 38 | 39 | constructor(protected root: Element, protected vscode: VSCode, protected editHandler: EditHandler, protected readOnly: boolean) { 40 | this.expanded = new Set() 41 | this.content = document.createElement('div') 42 | this.content.className = 'nbt-content' 43 | this.file = NbtFile.create() 44 | this.prefix = new NbtPath() 45 | this.pathToElement = {childs: {}} 46 | this.highlighted = null 47 | this.selected = null 48 | this.editing = null 49 | } 50 | 51 | reveal() { 52 | this.root.append(this.content) 53 | if (this.selected) { 54 | this.select(this.selected) 55 | } 56 | document.addEventListener('keydown', this.onKey) 57 | } 58 | 59 | hide() { 60 | document.removeEventListener('keydown', this.onKey) 61 | } 62 | 63 | onInit(file: NbtFile, prefix?: NbtPath) { 64 | if (prefix) { 65 | this.prefix = prefix 66 | } 67 | this.file = file 68 | this.expand(this.prefix) 69 | const rootKeys = [...this.file.root.keys()] 70 | if (rootKeys.length === 1) { 71 | this.expand(this.prefix.push(rootKeys[0])) 72 | } 73 | this.select(null) 74 | this.editing = null 75 | this.redraw() 76 | } 77 | 78 | onUpdate(data: NbtFile) { 79 | this.onInit(data) 80 | } 81 | 82 | onSearch(query: SearchQuery | null): SearchResult[] { 83 | if (query === null) { 84 | const prevHighlight = this.highlighted 85 | this.highlighted = null 86 | this.hidePath(prevHighlight) 87 | return [] 88 | } 89 | const searchResults = searchNodes(this.file.root, query) 90 | return searchResults.map(path => ({ 91 | path, 92 | show: () => this.showPath(path), 93 | replace: (query) => replaceNode(this.file.root, path, query), 94 | })) 95 | } 96 | 97 | private async showPath(path: NbtPath) { 98 | if (this.highlighted?.equals(path)) { 99 | return 100 | } 101 | const redrawStart = path.pop().subPaths() 102 | .find(p => !this.expanded.has(p.toString())) 103 | const prevHighlight = this.highlighted 104 | this.highlighted = path 105 | if (redrawStart) { 106 | const tag = getNode(this.file.root, redrawStart) 107 | const el = this.getPathElement(redrawStart) 108 | if (el) { 109 | await this.openBody(redrawStart, tag, el) 110 | } 111 | } 112 | this.hidePath(prevHighlight) 113 | const resultEl = this.getPathElement(path) 114 | if (resultEl) { 115 | resultEl.classList.add('highlighted') 116 | const bounds = resultEl.getBoundingClientRect() 117 | if (bounds.bottom > window.innerHeight || bounds.top < 0) { 118 | resultEl.scrollIntoView({ block: 'center' }) 119 | } 120 | } 121 | } 122 | 123 | private hidePath(prevHighlight: NbtPath | null) { 124 | this.root.querySelectorAll('.nbt-tag.highlighted') 125 | .forEach(e => e.classList.remove('highlighted')) 126 | const pathToClose = prevHighlight?.subPaths() 127 | .find(p => !this.isExpanded(p)) 128 | if (pathToClose) { 129 | const el = this.getPathElement(pathToClose) 130 | if (el && el.classList.contains('collapse')) { 131 | this.closeBody(pathToClose, el!) 132 | } 133 | } 134 | } 135 | 136 | menu() { 137 | if (this.readOnly) return [] 138 | 139 | const actionButton = (action: string, fn: (...args: any[]) => void) => { 140 | const el = document.createElement('div') 141 | el.className = `btn btn-${action}-tag disabled` 142 | el.textContent = locale(`${action}Tag`) 143 | el.addEventListener('click', () => { 144 | if (!this.selected) return 145 | this.selected.el.scrollIntoView({ block: 'center' }) 146 | fn.bind(this)(this.selected.path, this.selected.tag, this.selected.el) 147 | }) 148 | return el 149 | } 150 | 151 | const editTag = actionButton('edit', this.clickTag) 152 | const removeTag = actionButton('remove', this.removeTag) 153 | const addTag = actionButton('add', this.addTag) 154 | const renameTag = actionButton('rename', this.renameTag) 155 | return [removeTag, editTag, addTag, renameTag] 156 | } 157 | 158 | protected onKey = (evt: KeyboardEvent) => { 159 | const s = this.selected 160 | if (evt.key === 'Delete' && s) { 161 | this.removeTag(s.path, s.tag, s.el) 162 | } else if (evt.key === 'F2' && s) { 163 | this.renameTag(s.path, s.tag, s.el) 164 | } else if (evt.key === 'Escape') { 165 | if (this.editing === null) { 166 | this.select(null) 167 | } else { 168 | this.clearEditing() 169 | } 170 | } 171 | } 172 | 173 | protected redraw() { 174 | this.pathToElement = { childs: {} } 175 | const root = this.drawTag(this.prefix, this.file.root) 176 | this.content.innerHTML = '' 177 | this.content.append(root) 178 | } 179 | 180 | protected isExpanded(path: NbtPath) { 181 | const p = path.toString() 182 | return this.expanded.has(p) || this.highlighted?.pop().startsWith(path) 183 | } 184 | 185 | protected collapse(path: NbtPath) { 186 | const p = path.toString() 187 | this.expanded.delete(p) 188 | } 189 | 190 | protected expand(path: NbtPath) { 191 | path.subPaths().forEach(p => { 192 | this.expanded.add(p.toString()) 193 | }) 194 | } 195 | 196 | protected select(selected: SelectedTag | null) { 197 | if (this.readOnly) return 198 | 199 | this.selected = selected 200 | this.root.querySelectorAll('.nbt-tag.selected').forEach(e => e.classList.remove('selected')) 201 | if (selected) { 202 | this.expand(selected.path.pop()) 203 | this.root.querySelectorAll('.nbt-tag.highlighted').forEach(e => e.classList.remove('highlighted')) 204 | selected.el.classList.add('selected') 205 | } 206 | 207 | const btnEditTag = document.querySelector('.btn-edit-tag') as HTMLElement 208 | btnEditTag?.classList.toggle('disabled', !selected || this.canExpand(selected.tag)) 209 | const btnAddTag = document.querySelector('.btn-add-tag') as HTMLElement 210 | btnAddTag?.classList.toggle('disabled', !selected || !this.canExpand(selected.tag)) 211 | const parent = selected ? getNode(this.file.root, selected.path.pop()) : null 212 | const btnRenameTag = document.querySelector('.btn-rename-tag') as HTMLElement 213 | btnRenameTag?.classList.toggle('disabled', !parent?.isCompound()) 214 | const btnRemoveTag = document.querySelector('.btn-remove-tag') as HTMLElement 215 | btnRemoveTag?.classList.toggle('disabled', !this.selected || this.selected.path.length() === 0) 216 | } 217 | 218 | protected setPathElement(path: NbtPath, el: Element) { 219 | let node = this.pathToElement 220 | for (const e of path.arr) { 221 | if (!node.childs) node.childs = {} 222 | if (!node.childs[e]) node.childs[e] = {} 223 | node = node.childs[e] 224 | } 225 | node.element = el 226 | } 227 | 228 | protected getPathElement(path: NbtPath) { 229 | let node = this.pathToElement 230 | for (const e of path.arr) { 231 | if (!node.childs || !node.childs[e]) return undefined 232 | node = node.childs[e] 233 | } 234 | return node.element 235 | } 236 | 237 | protected drawTag(path: NbtPath, tag: NbtTag) { 238 | const expanded = this.canExpand(tag) && this.isExpanded(path) 239 | const el = document.createElement('div') 240 | const head = document.createElement('div') 241 | this.setPathElement(path, head) 242 | head.classList.add('nbt-tag') 243 | if (this.highlighted?.equals(path)) { 244 | head.classList.add('highlighted') 245 | } 246 | if (this.canExpand(tag)) { 247 | head.classList.add('collapse') 248 | head.append(this.drawCollapse(path, () => this.clickTag(path, tag, head))) 249 | } 250 | head.append(this.drawIcon(tag)) 251 | if (typeof path.last() === 'string') { 252 | head.append(this.drawKey(`${path.last()}: `)) 253 | } 254 | head.append(this.drawTagHeader(tag)) 255 | head.addEventListener('click', () => { 256 | if (head === this.selected?.el) return 257 | this.clearEditing() 258 | this.select({path, tag, el: head }) 259 | }) 260 | head.addEventListener('dblclick', () => { 261 | this.clickTag(path, tag, head) 262 | }) 263 | el.append(head) 264 | 265 | const body = expanded 266 | ? this.drawTagBody(path, tag) 267 | : document.createElement('div') 268 | body.classList.add('nbt-body') 269 | el.append(body) 270 | 271 | return el 272 | } 273 | 274 | protected canExpand(tag: NbtTag | number) { 275 | return TreeEditor.EXPANDABLE_TYPES.has(typeof tag === 'number' ? tag : tag.getId()) 276 | } 277 | 278 | protected drawTagHeader(tag: NbtTag): HTMLElement { 279 | try { 280 | if (tag.isCompound()) { 281 | return this.drawEntries(tag.size) 282 | } else if (tag.isList()) { 283 | return this.drawEntries(tag.length) 284 | } else if (tag.isArray()) { 285 | return this.drawEntries(tag.length) 286 | } else { 287 | return this.drawPrimitiveTag(tag) 288 | } 289 | } catch (e) { 290 | this.vscode.postMessage({ type: 'error', body: e.message }) 291 | return this.drawError(e.message) 292 | } 293 | } 294 | 295 | protected drawTagBody(path: NbtPath, tag: NbtTag): HTMLElement { 296 | try { 297 | switch(tag.getId()) { 298 | case NbtType.Compound: return this.drawCompound(path, tag as NbtCompound) 299 | case NbtType.List: return this.drawList(path, tag as NbtList) 300 | case NbtType.ByteArray: return this.drawArray(path, tag) 301 | case NbtType.IntArray: return this.drawArray(path, tag) 302 | case NbtType.LongArray: return this.drawArray(path, tag) 303 | default: return document.createElement('div') 304 | } 305 | } catch (e) { 306 | this.vscode.postMessage({ type: 'error', body: e.message }) 307 | return this.drawError(e.message) 308 | } 309 | } 310 | 311 | protected drawError(message: string) { 312 | const el = document.createElement('span') 313 | el.classList.add('error') 314 | el.textContent = `Error "${message}"` 315 | return el 316 | } 317 | 318 | protected drawIcon(tag: NbtTag) { 319 | const el = document.createElement('span') 320 | el.setAttribute('data-icon', NbtType[tag.getId()]) 321 | return el 322 | } 323 | 324 | protected drawKey(key: string) { 325 | const el = document.createElement('span') 326 | el.classList.add('nbt-key') 327 | el.textContent = key 328 | return el 329 | } 330 | 331 | protected drawCollapse(path: NbtPath, handler: () => void) { 332 | const el = document.createElement('span') 333 | el.classList.add('nbt-collapse') 334 | el.textContent = this.isExpanded(path) ? '-' : '+' 335 | el.addEventListener('click', () => handler()) 336 | return el 337 | } 338 | 339 | protected drawEntries(length: number) { 340 | const el = document.createElement('span') 341 | el.classList.add('nbt-entries') 342 | el.textContent = `${length} entr${length === 1 ? 'y' : 'ies'}` 343 | return el 344 | } 345 | 346 | protected drawCompound(path: NbtPath, tag: NbtCompound) { 347 | const el = document.createElement('div'); 348 | [...tag.keys()].sort().forEach(key => { 349 | const child = this.drawTag(path.push(key), tag.get(key)!) 350 | el.append(child) 351 | }) 352 | return el 353 | } 354 | 355 | protected drawList(path: NbtPath, tag: NbtList) { 356 | const el = document.createElement('div') 357 | tag.forEach((v, i) => { 358 | const child = this.drawTag(path.push(i), v) 359 | el.append(child) 360 | }) 361 | return el 362 | } 363 | 364 | protected drawArray(path: NbtPath, tag: NbtTag) { 365 | if (!tag.isArray()) { 366 | throw new Error(`Trying to draw an array, but got a ${NbtType[tag.getId()]}`) 367 | } 368 | const el = document.createElement('div') 369 | tag.forEach((v, i) => { 370 | const child = this.drawTag(path.push(i), v) 371 | el.append(child) 372 | }) 373 | return el 374 | } 375 | 376 | protected drawPrimitiveTag(tag: NbtTag) { 377 | const el = document.createElement('span') 378 | el.classList.add('nbt-value') 379 | el.textContent = serializePrimitive(tag) 380 | return el 381 | } 382 | 383 | protected clickTag(path: NbtPath, tag: NbtTag, el: Element) { 384 | if (this.canExpand(tag)) { 385 | this.clickExpandableTag(path, tag, el) 386 | } else { 387 | this.clickPrimitiveTag(path, tag, el) 388 | } 389 | } 390 | 391 | protected clickExpandableTag(path: NbtPath, tag: NbtTag, el: Element) { 392 | if (this.expanded.has(path.toString())) { 393 | this.collapse(path) 394 | this.closeBody(path, el) 395 | } else { 396 | this.expand(path) 397 | this.openBody(path, tag, el) 398 | } 399 | } 400 | 401 | protected closeBody(path: NbtPath, el: Element) { 402 | el.nextElementSibling!.innerHTML = '' 403 | el.querySelector('.nbt-collapse')!.textContent = '+' 404 | } 405 | 406 | protected async openBody(path: NbtPath, tag: NbtTag, el: Element) { 407 | el.querySelector('.nbt-collapse')!.textContent = '-' 408 | const body = el.nextElementSibling! 409 | await new Promise((res) => setTimeout(res)) 410 | body.innerHTML = '' 411 | body.append(this.drawTagBody(path, tag)) 412 | } 413 | 414 | protected clickPrimitiveTag(path: NbtPath, tag: NbtTag, el: Element) { 415 | if (this.readOnly) return 416 | 417 | el.querySelector('span.nbt-value')?.remove() 418 | const value = serializePrimitive(tag) 419 | 420 | const valueEl = document.createElement('input') 421 | el.append(valueEl) 422 | valueEl.classList.add('nbt-value') 423 | valueEl.value = value 424 | valueEl.focus() 425 | valueEl.setSelectionRange(value.length, value.length) 426 | valueEl.scrollLeft = valueEl.scrollWidth 427 | 428 | const confirmButton = document.createElement('button') 429 | el.append(confirmButton) 430 | confirmButton.classList.add('nbt-confirm') 431 | confirmButton.textContent = locale('confirm') 432 | const makeEdit = () => { 433 | const newTag = parsePrimitive(tag.getId(), valueEl.value).toJsonWithId() 434 | const oldTag = tag.toJsonWithId() 435 | if (JSON.stringify(oldTag) !== JSON.stringify(newTag)) { 436 | this.editHandler({ type: 'set', path: path.arr, old: oldTag, new: newTag }) 437 | } 438 | } 439 | confirmButton.addEventListener('click', makeEdit) 440 | valueEl.addEventListener('keyup', evt => { 441 | if (evt.key === 'Enter') { 442 | makeEdit() 443 | } 444 | }) 445 | this.setEditing(path, tag, el) 446 | } 447 | 448 | protected removeTag(path: NbtPath, tag: NbtTag, el: Element) { 449 | if (this.readOnly) return 450 | 451 | this.editHandler({ type: 'remove', path: path.arr, value: tag.toJsonWithId() }) 452 | } 453 | 454 | protected addTag(path: NbtPath, tag: NbtTag, el: Element) { 455 | if (this.readOnly) return 456 | 457 | const body = el.nextElementSibling! 458 | const root = document.createElement('div') 459 | body.prepend(root) 460 | const nbtTag = document.createElement('div') 461 | nbtTag.classList.add('nbt-tag') 462 | root.append(nbtTag) 463 | 464 | const typeRoot = document.createElement('div') 465 | nbtTag.append(typeRoot) 466 | 467 | const keyInput = document.createElement('input') 468 | if (tag.isCompound()) { 469 | keyInput.classList.add('nbt-key') 470 | keyInput.placeholder = locale('name') 471 | nbtTag.append(keyInput) 472 | } 473 | 474 | const valueInput = document.createElement('input') 475 | valueInput.classList.add('nbt-value') 476 | valueInput.placeholder = locale('value') 477 | nbtTag.append(valueInput) 478 | 479 | const typeSelect = document.createElement('select') 480 | if (tag.isCompound() || (tag.isList() && tag.length === 0)) { 481 | typeRoot.classList.add('type-select') 482 | typeRoot.setAttribute('data-icon', 'Byte') 483 | typeRoot.append(typeSelect) 484 | 485 | TYPES.filter(t => t !== 'End').forEach(t => { 486 | const option = document.createElement('option') 487 | option.value = t 488 | option.textContent = t.charAt(0).toUpperCase() + t.slice(1).split(/(?=[A-Z])/).join(' ') 489 | typeSelect.append(option) 490 | }) 491 | 492 | const onChangeType = () => { 493 | typeRoot.setAttribute('data-icon', typeSelect.value) 494 | const typeSelectId = NbtType[typeSelect.value] as number 495 | valueInput.classList.toggle('hidden', this.canExpand(typeSelectId)) 496 | } 497 | 498 | typeSelect.focus() 499 | typeSelect.addEventListener('change', onChangeType) 500 | const hotKeys = { 501 | c: NbtType.Compound, 502 | l: NbtType.List, 503 | s: NbtType.String, 504 | b: NbtType.Byte, 505 | t: NbtType.Short, 506 | i: NbtType.Int, 507 | g: NbtType.Long, 508 | f: NbtType.Float, 509 | d: NbtType.Double, 510 | } 511 | typeSelect.addEventListener('keydown', evt => { 512 | if (hotKeys[evt.key]) { 513 | typeSelect.value = NbtType[hotKeys[evt.key]] 514 | onChangeType() 515 | evt.preventDefault() 516 | nbtTag.querySelector('input')?.focus() 517 | } 518 | }) 519 | } else if (tag.isListOrArray()) { 520 | const keyType = tag.getType() 521 | typeRoot.setAttribute('data-icon', NbtType[keyType]) 522 | valueInput.focus() 523 | } 524 | 525 | const confirmButton = document.createElement('button') 526 | nbtTag.append(confirmButton) 527 | confirmButton.classList.add('nbt-confirm') 528 | confirmButton.textContent = locale('confirm') 529 | const makeEdit = () => { 530 | const valueType = (tag.isCompound() || (tag.isList() && tag.length === 0)) 531 | ? NbtType[typeSelect.value] as number 532 | : (tag.isListOrArray() ? tag.getType() : NbtType.End) 533 | const last = tag.isCompound() ? keyInput.value : 0 534 | let newTag = NbtTag.create(valueType) 535 | if (!this.canExpand(valueType)) { 536 | try { 537 | newTag = parsePrimitive(valueType, valueInput.value) 538 | } catch(e) {} 539 | } 540 | 541 | const edit: NbtEdit = { type: 'add', path: path.push(last).arr, value: newTag.toJsonWithId() } 542 | 543 | this.expand(path) 544 | this.editHandler(edit) 545 | } 546 | confirmButton.addEventListener('click', makeEdit) 547 | valueInput.addEventListener('keyup', evt => { 548 | if (evt.key === 'Enter') { 549 | makeEdit() 550 | } 551 | }) 552 | this.setEditing(null, tag, nbtTag) 553 | } 554 | 555 | protected renameTag(path: NbtPath, tag: NbtTag, el: Element) { 556 | if (this.readOnly) return 557 | 558 | el.querySelector('span.nbt-key')?.remove() 559 | const valueEl = el.querySelector('.nbt-value, .nbt-entries') 560 | const key = path.last() as string 561 | 562 | const keyEl = document.createElement('input') 563 | el.insertBefore(keyEl, valueEl) 564 | keyEl.classList.add('nbt-value') 565 | keyEl.value = key 566 | keyEl.focus() 567 | keyEl.setSelectionRange(key.length, key.length) 568 | keyEl.scrollLeft = keyEl.scrollWidth 569 | 570 | const confirmButton = document.createElement('button') 571 | el.insertBefore(confirmButton, valueEl) 572 | confirmButton.classList.add('nbt-confirm') 573 | confirmButton.textContent = locale('confirm') 574 | const makeEdit = () => { 575 | const newKey = keyEl.value 576 | if (key !== newKey) { 577 | this.editHandler({ type: 'move', source: path.arr, path: path.pop().push(newKey).arr }) 578 | } 579 | this.clearEditing() 580 | } 581 | confirmButton.addEventListener('click', makeEdit) 582 | keyEl.addEventListener('keyup', evt => { 583 | if (evt.key === 'Enter') { 584 | makeEdit() 585 | } 586 | }) 587 | this.setEditing(path, tag, el) 588 | } 589 | 590 | protected clearEditing() { 591 | if (this.editing && this.editing.el.parentElement) { 592 | if (this.editing.path === null) { 593 | this.editing.el.parentElement.remove() 594 | } else { 595 | const tag = this.drawTag(this.editing.path, this.editing.tag) 596 | this.editing.el.parentElement.replaceWith(tag) 597 | if (this.selected?.el === this.editing.el) { 598 | this.selected.el = tag.firstElementChild! 599 | this.selected.el.classList.add('selected') 600 | } 601 | } 602 | } 603 | this.editing = null 604 | } 605 | 606 | protected setEditing(path: NbtPath | null, tag: NbtTag, el: Element) { 607 | this.clearEditing() 608 | this.editing = { path, tag, el } 609 | } 610 | } 611 | -------------------------------------------------------------------------------- /src/editor/Util.ts: -------------------------------------------------------------------------------- 1 | import { vec3 } from 'gl-matrix' 2 | 3 | const dec2hex = (dec: number) => ('0' + dec.toString(16)).substr(-2) 4 | 5 | export function hexId(length = 12) { 6 | var arr = new Uint8Array(length / 2) 7 | window.crypto.getRandomValues(arr) 8 | return Array.from(arr, dec2hex).join('') 9 | } 10 | 11 | export function clamp(a: number, b: number, c: number) { 12 | return Math.max(b, Math.min(c, a)) 13 | } 14 | 15 | export function clampVec3(a: vec3, b: vec3, c: vec3) { 16 | a[0] = clamp(a[0], b[0], c[0]) 17 | a[1] = clamp(a[1], b[1], c[1]) 18 | a[2] = clamp(a[2], b[2], c[2]) 19 | } 20 | 21 | export function negVec3(a: vec3) { 22 | return vec3.fromValues(-a[0], -a[1], -a[2]) 23 | } 24 | 25 | export function getInt(el: HTMLElement | null) { 26 | const value = parseInt((el as HTMLInputElement)?.value) 27 | return isNaN(value) ? undefined : value 28 | } 29 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as util from 'util' 2 | import * as vscode from 'vscode' 3 | import type { Logger } from './common/types' 4 | import { NbtEditorProvider } from './NbtEditor' 5 | 6 | export function activate(context: vscode.ExtensionContext) { 7 | const output = vscode.window.createOutputChannel('NBT Viewer') 8 | const logger: Logger = { 9 | error: (msg: any, ...args: any[]): void => output.appendLine(util.format(msg, ...args)), 10 | info: (msg: any, ...args: any[]): void => output.appendLine(util.format(msg, ...args)), 11 | log: (msg: any, ...args: any[]): void => output.appendLine(util.format(msg, ...args)), 12 | warn: (msg: any, ...args: any[]): void => output.appendLine(util.format(msg, ...args)), 13 | } 14 | 15 | context.subscriptions.push(NbtEditorProvider.register(context, logger)) 16 | } 17 | -------------------------------------------------------------------------------- /src/fileUtil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * fileUtil from Spyglass 3 | * MIT License 4 | * Copyright (c) 2019-2022 SPGoding 5 | */ 6 | import cp from 'child_process' 7 | import type fs from 'fs' 8 | import { promises as fsp } from 'fs' 9 | import { resolve } from 'path' 10 | import process from 'process' 11 | import url, { URL as Uri } from 'url' 12 | import { promisify } from 'util' 13 | import zlib from 'zlib' 14 | 15 | export type RootUriString = `${string}/` 16 | 17 | export type FileExtension = `.${string}` 18 | 19 | /** 20 | * A string file path, string file URI, or a file URI object. 21 | */ 22 | export type PathLike = string | Uri 23 | 24 | export namespace fileUtil { 25 | /** 26 | * @param rootUris The root URIs. Each URI in this array must end with a slash (`/`). 27 | * @param uri The target file URI. 28 | * @returns The relative path from one of the `roots` to the `uri`, or `undefined` if the `uri` is not under any roots. 29 | * The separation used in the relative path is always slash (`/`). 30 | * @example 31 | * getRels(['file:///root/foo/', 'file:///root/'], 'file:///root/foo/bar/qux.json') 32 | * // -> 'bar/qux.json' 33 | * // -> 'foo/bar/qux.json' 34 | * // -> undefined 35 | * 36 | * getRels(['file:///root/foo/', 'file:///root/'], 'file:///outsider.json') 37 | * // -> undefined 38 | */ 39 | export function* getRels(uri: string, rootUris: readonly RootUriString[]): Generator { 40 | for (const root of rootUris) { 41 | if (uri.startsWith(root)) { 42 | yield decodeURIComponent(uri.slice(root.length)) 43 | } 44 | } 45 | return undefined 46 | } 47 | 48 | /** 49 | * @see {@link getRels} 50 | * @example 51 | * getRel(['file:///root/foo/', 'file:///root/'], 'file:///root/foo/bar/qux.json') // -> 'bar/qux.json' 52 | * getRel(['file:///root/foo/', 'file:///root/'], 'file:///outsider.json') // -> undefined 53 | */ 54 | export function getRel(uri: string, rootUris: readonly RootUriString[]): string | undefined { 55 | return getRels(uri, rootUris).next().value 56 | } 57 | 58 | export function isRootUri(uri: string): uri is RootUriString { 59 | return uri.endsWith('/') 60 | } 61 | 62 | export function ensureEndingSlash(uri: string): RootUriString { 63 | return isRootUri(uri) ? uri : (`${uri}/` as const) 64 | } 65 | 66 | export function join(fromUri: string, toUri: string): string { 67 | return ensureEndingSlash(fromUri) + (toUri.startsWith('/') ? toUri.slice(1) : toUri) 68 | } 69 | 70 | /** 71 | * @throws If `uri` is not a valid URI. 72 | */ 73 | export function isFileUri(uri: string): boolean { 74 | return uri.startsWith('file:') 75 | } 76 | 77 | /** 78 | * @returns The part from the last `.` to the end of the URI, or `undefined` if no dots exist. No special treatment for leading dots. 79 | */ 80 | export function extname(value: string): FileExtension | undefined { 81 | const i = value.lastIndexOf('.') 82 | return i >= 0 ? value.slice(i) as FileExtension : undefined 83 | } 84 | 85 | /* istanbul ignore next */ 86 | /** 87 | * @param fileUri A file URI. 88 | * @returns The corresponding file path of the `fileUri` in platform-specific format. 89 | * @throws If the URI is not a file schema URI. 90 | */ 91 | export function fileUriToPath(fileUri: string): string { 92 | return url.fileURLToPath(new Uri(fileUri)) 93 | } 94 | 95 | /* istanbul ignore next */ 96 | /** 97 | * @param path A file path. 98 | * @returns The corresponding file URI of the `path`. 99 | */ 100 | export function pathToFileUri(path: string): string { 101 | return url.pathToFileURL(path).toString() 102 | } 103 | 104 | /* istanbul ignore next */ 105 | export function normalize(uri: string): string { 106 | try { 107 | return isFileUri(uri) ? pathToFileUri(fileUriToPath(uri)) : new Uri(uri).toString() 108 | } catch { 109 | return uri 110 | } 111 | } 112 | 113 | /** 114 | * @param path A string file path, string file URI, or a file URI object. 115 | * 116 | * @returns A {@link fs.PathLike}. 117 | */ 118 | function toFsPathLike(path: PathLike): fs.PathLike { 119 | if (typeof path === 'string' && isFileUri(path)) { 120 | return new Uri(path) 121 | } 122 | return path 123 | } 124 | 125 | /* istanbul ignore next */ 126 | export function getParentOfFile(path: PathLike): PathLike { 127 | if (path instanceof Uri || isFileUri(path)) { 128 | return new Uri('.', path) 129 | } else { 130 | return resolve(path, '..') 131 | } 132 | } 133 | 134 | /* istanbul ignore next */ 135 | /** 136 | * @throws 137 | * 138 | * @param mode Default to `0o777` (`rwxrwxrwx`) 139 | */ 140 | export async function ensureDir(path: PathLike, mode: fs.Mode = 0o777): Promise { 141 | try { 142 | await fsp.mkdir(toFsPathLike(path), { mode, recursive: true }) 143 | } catch (e) { 144 | if (!isErrorCode(e, 'EEXIST')) { 145 | throw e 146 | } 147 | } 148 | } 149 | 150 | /* istanbul ignore next */ 151 | /** 152 | * @throws 153 | * 154 | * Ensures the parent directory of the path exists. 155 | * 156 | * @param mode Default to `0o777` (`rwxrwxrwx`) 157 | */ 158 | export async function ensureParentOfFile(path: PathLike, mode: fs.Mode = 0o777): Promise { 159 | return ensureDir(getParentOfFile(path), mode) 160 | } 161 | 162 | export async function readFile(path: PathLike): Promise { 163 | return fsp.readFile(toFsPathLike(path)) 164 | } 165 | 166 | /* istanbul ignore next */ 167 | /** 168 | * @throws 169 | * 170 | * The directory of the file will be created recursively if it doesn'texist. 171 | * 172 | * * Encoding: `utf-8` 173 | * * Mode: `0o666` (`rw-rw-rw-`) 174 | * * Flag: `w` 175 | */ 176 | export async function writeFile(path: PathLike, data: Buffer | string): Promise { 177 | await ensureParentOfFile(path) 178 | return fsp.writeFile(toFsPathLike(path), data) 179 | } 180 | 181 | /* istanbul ignore next */ 182 | /** 183 | * @throws 184 | */ 185 | export async function readJson(path: PathLike): Promise { 186 | return JSON.parse(bufferToString(await readFile(path))) 187 | } 188 | 189 | /* istanbul ignore next */ 190 | /** 191 | * @throws 192 | * 193 | * @see {@link writeFile} 194 | */ 195 | export async function writeJson(path: PathLike, data: any): Promise { 196 | return writeFile(path, JSON.stringify(data)) 197 | } 198 | 199 | /** 200 | * @throws 201 | */ 202 | export async function readGzippedFile(path: PathLike): Promise { 203 | const unzip = promisify(zlib.gunzip) 204 | return unzip(await readFile(path)) 205 | } 206 | 207 | /** 208 | * @throws 209 | */ 210 | export async function writeGzippedFile(path: PathLike, buffer: Buffer | string): Promise { 211 | const zip = promisify(zlib.gzip) 212 | return writeFile(path, await zip(buffer)) 213 | } 214 | 215 | /** 216 | * @throws 217 | */ 218 | export async function readGzippedJson(path: PathLike): Promise { 219 | return JSON.parse(bufferToString(await readGzippedFile(path))) 220 | } 221 | 222 | /** 223 | * @throws 224 | */ 225 | export async function writeGzippedJson(path: PathLike, data: any): Promise { 226 | return writeGzippedFile(path, JSON.stringify(data)) 227 | } 228 | 229 | /** 230 | * Show the file/directory in the platform-specific explorer program. 231 | * 232 | * Should not be called with unsanitized user-input path due to the possibility of 233 | * arbitrary code execution. 234 | * 235 | * @returns The `stdout` from the spawned child process. 236 | */ 237 | export async function showFile(path: string): Promise<{ stdout: string, stderr: string }> { 238 | const execFile = promisify(cp.execFile) 239 | let command: string 240 | switch (process.platform) { 241 | case 'darwin': 242 | command = 'open' 243 | break 244 | case 'win32': 245 | command = 'explorer' 246 | break 247 | default: 248 | command = 'xdg-open' 249 | break 250 | } 251 | return execFile(command, [path]) 252 | } 253 | } 254 | 255 | /** 256 | * @returns The string value decoded from the buffer according to UTF-8. 257 | * Byte order mark is correctly removed. 258 | */ 259 | export function bufferToString(buffer: Buffer): string { 260 | const ans = buffer.toString('utf-8') 261 | if (ans.charCodeAt(0) === 0xFEFF) { 262 | return ans.slice(1) 263 | } 264 | return ans 265 | } 266 | 267 | export function isEnoent(e: unknown): boolean { 268 | return isErrorCode(e, 'ENOENT') 269 | } 270 | 271 | export function isErrorCode(e: unknown, code: string): boolean { 272 | return e instanceof Error && (e as NodeJS.ErrnoException).code === code 273 | } 274 | 275 | export function promisifyAsyncIterable(iterable: AsyncIterable, joiner: (chunks: T[]) => U): Promise { 276 | return (async () => { 277 | const chunks: T[] = [] 278 | for await (const chunk of iterable) { 279 | chunks.push(chunk) 280 | } 281 | return joiner(chunks) 282 | })() 283 | } 284 | -------------------------------------------------------------------------------- /src/mcmeta.ts: -------------------------------------------------------------------------------- 1 | import envPaths from 'env-paths' 2 | import path from 'path' 3 | import { Readable, Writable } from 'stream' 4 | import tar from 'tar' 5 | import type { Logger } from './common/types' 6 | import type { Job } from './Downloader' 7 | import { Downloader } from './Downloader' 8 | 9 | const MCMETA = 'https://raw.githubusercontent.com/misode/mcmeta' 10 | const FALLBACK_VERSION = '1.18.2' 11 | 12 | const cacheRoot = envPaths('vscode-nbt').cache 13 | export const mcmetaRoot = path.join(cacheRoot, 'mcmeta') 14 | 15 | interface McmetaVersion { 16 | id: string, 17 | name: string, 18 | release_target: string, 19 | type: 'release' | 'snapshot', 20 | stable: boolean, 21 | data_version: number, 22 | protocol_version: number, 23 | data_pack_version: number, 24 | resource_pack_version: number, 25 | build_time: string, 26 | release_time: string, 27 | sha1: string 28 | } 29 | 30 | export async function getAssets(dataVersion: number | undefined, logger: Logger) { 31 | const downloader = new Downloader(mcmetaRoot, logger) 32 | 33 | const versions = await downloader.download({ 34 | id: 'versions', 35 | uri: `${MCMETA}/summary/versions/data.min.json`, 36 | transformer: data => JSON.parse(data.toString('utf-8')), 37 | cache: cacheOptions(), 38 | }) 39 | const target = dataVersion ?? versions?.find(v => v.type === 'release')?.data_version 40 | const version = versions?.find(v => v.data_version === target)?.id ?? FALLBACK_VERSION 41 | logger.info(`Found matching version for ${target}: ${version}`) 42 | 43 | const [blocks, atlas, uvmapping] = await Promise.all([ 44 | downloader.download({ 45 | id: `${version}-blocks`, 46 | uri: `${MCMETA}/${version}-summary/blocks/data.min.json`, 47 | transformer: () => true, 48 | cache: cacheOptions(data => Buffer.from('const stringifiedBlocks = `' + data.toString('utf-8') + '`')), 49 | }), 50 | downloader.download({ 51 | id: `${version}-atlas`, 52 | uri: `${MCMETA}/${version}-atlas/all/atlas.png`, 53 | transformer: () => true, 54 | cache: cacheOptions(), 55 | }), 56 | downloader.download({ 57 | id: `${version}-uvmapping`, 58 | uri: `${MCMETA}/${version}-atlas/all/data.min.json`, 59 | transformer: () => true, 60 | cache: cacheOptions(data => Buffer.from('const stringifiedUvmapping = `' + data.toString('utf-8') + '`')), 61 | }), 62 | downloader.download({ 63 | id: `${version}-assets`, 64 | uri: `https://github.com/misode/mcmeta/tarball/${version}-assets-json`, 65 | transformer: data => data, 66 | cache: cacheOptions(async data => { 67 | const entries = await readTarGz(data, (path: string) => { 68 | return path.includes('/assets/minecraft/models/') 69 | || path.includes('/assets/minecraft/blockstates/') 70 | }) 71 | const filterEntries = (type: string) => { 72 | const pattern = RegExp(`/assets/minecraft/${type}/([a-z0-9/_]+)\\.json`) 73 | return Object.fromEntries(entries.flatMap<[string, unknown]>(({ path, data }) => { 74 | const match = path.match(pattern) 75 | return match ? [[match[1], JSON.parse(data)]] : [] 76 | })) 77 | } 78 | const blockstates = filterEntries('blockstates') 79 | const models = filterEntries('models') 80 | return Buffer.from('const stringifiedAssets = `' + JSON.stringify({ blockstates, models }) + '`') 81 | }), 82 | }), 83 | ]) 84 | 85 | if (!blocks || !atlas || !uvmapping) { 86 | throw new Error('Failed loading assets') 87 | } 88 | 89 | return { 90 | version, 91 | } 92 | } 93 | 94 | function cacheOptions(serializer?: (data: Buffer) => Buffer | Promise): Job['cache'] { 95 | return { 96 | checksumExtension: '.cache', 97 | checksumJob: { 98 | uri: `${MCMETA}/summary/version.json`, 99 | transformer: data => JSON.parse(data.toString('utf-8')).id, 100 | }, 101 | serializer, 102 | } 103 | } 104 | 105 | interface TarEntry { 106 | path: string, 107 | data: string, 108 | } 109 | 110 | function readTarGz(buffer: Buffer, filter: (path: string) => boolean = (() => true)) { 111 | const entries: TarEntry[] = [] 112 | return new Promise((res, rej) => { 113 | Readable.from(buffer) 114 | .on('error', err => rej(err)) 115 | .pipe(new tar.Parse()) 116 | .on('entry', entry => { 117 | if (filter(entry.path)) { 118 | let data = '' 119 | entry.pipe(new Writable({ 120 | write(chunk, _, next) { 121 | data += chunk.toString() 122 | next() 123 | }, 124 | final() { 125 | entries.push({ path: entry.path, data }) 126 | }, 127 | })) 128 | } else { 129 | entry.resume() 130 | } 131 | }) 132 | .on('end', () => { 133 | res(entries) 134 | }) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "./src/**/*.ts", 5 | "./scripts/**/*.ts", 6 | "rollup.config.js" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "lib": ["dom", "es2019"], 8 | "outDir": "out", 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": false, 12 | "strictNullChecks": true 13 | }, 14 | "ts-node": { 15 | "compilerOptions": { 16 | "module": "commonjs" 17 | } 18 | }, 19 | "include": [ 20 | "src" 21 | ] 22 | } 23 | --------------------------------------------------------------------------------