diff --git a/package-lock.json b/package-lock.json index eed599f..fe2f813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,24 @@ { "name": "browsercode", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browsercode", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@iconify-json/mingcute": "^1.2.7", "@iconify/svelte": "^5.2.1", "@leaningtech/browserpod": "3.0.1", + "@shikijs/langs": "^4.4.3", + "@shikijs/monaco": "^4.4.3", + "@shikijs/themes": "^4.4.3", "@sveltejs/adapter-static": "^3.0.10", "fflate": "^0.8.3", "monaco-editor": "^0.55.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "shiki": "^4.4.3" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -1148,6 +1152,120 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/monaco": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/monaco/-/monaco-4.4.3.tgz", + "integrity": "sha512-xyXoWFtqVsN33RhGbZReHFKOoxGbDEJ5mgGKdUbQLe6GF0/DDv7C4kgxkNwbWwBo7zhkICbqIqCfGjKKdF8RzQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1534,6 +1652,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1541,6 +1668,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -1567,6 +1703,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -1849,6 +1991,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1974,6 +2122,16 @@ "node": ">=6" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1991,6 +2149,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2045,6 +2223,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2132,6 +2320,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2148,6 +2345,19 @@ "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -2651,6 +2861,52 @@ "node": ">=8" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3130,6 +3386,116 @@ "node": ">= 18" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3214,6 +3580,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3584,6 +3967,16 @@ } } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3625,6 +4018,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3755,6 +4172,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3778,6 +4214,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3792,6 +4238,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3958,6 +4418,16 @@ "node": ">=6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4037,6 +4507,74 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4054,6 +4592,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -4304,6 +4870,16 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 3b7ab53..9505c3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "browsercode", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite dev", @@ -39,9 +39,13 @@ "@iconify-json/mingcute": "^1.2.7", "@iconify/svelte": "^5.2.1", "@leaningtech/browserpod": "3.0.1", + "@shikijs/langs": "^4.4.3", + "@shikijs/monaco": "^4.4.3", + "@shikijs/themes": "^4.4.3", "@sveltejs/adapter-static": "^3.0.10", "fflate": "^0.8.3", "monaco-editor": "^0.55.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "shiki": "^4.4.3" } } diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte index b40a86a..32a1caf 100644 --- a/src/lib/components/Portal.svelte +++ b/src/lib/components/Portal.svelte @@ -1,4 +1,5 @@ {#if portals.length > 0}
- +
-
- - Preview -
+ {#if onCollapse} + + + {/if} {#if src} -
- {#if portals.length > 1} -
- -
+
+ + {#if hasChoice} + + {/if} + + + {#if showPorts} +
+ {#each portals as item (item.port)} + + {/each}
{/if} +
+ + + + + +
{#if showMenu} -
- - -
{/if}
+ {#if showInfo} +
+
+ +
+ {#if qrError} +

{qrError}

+ {:else} +

Scan to open this preview on your phone.

+

{src}

+ {/if} +
+ {/if} + {/if} + + {#if sweeping} + {#key sweepId} +
+ {/key} {/if}
@@ -153,42 +267,140 @@ {src} id="portal" title="Portal content" + bind:this={frameEl} onload={onFrameLoad} class="h-full min-h-0 w-full border-none {frameStatus === 'ready' ? 'bg-white' : 'bg-bc-navy'}" > {/if} - - {#if showInfo} -
- - -
- -
- - {#if qrError} -
{qrError}
- {:else} - -
- {src} -
- {/if} -
- {/if}
{/if}
{/if} + + diff --git a/src/lib/components/ide/EditorPane.svelte b/src/lib/components/ide/EditorPane.svelte index 1119ab1..730ed05 100644 --- a/src/lib/components/ide/EditorPane.svelte +++ b/src/lib/components/ide/EditorPane.svelte @@ -3,8 +3,10 @@ import { SvelteMap } from 'svelte/reactivity'; import Icon from '@iconify/svelte'; import { fileIcon } from '$lib/ide/file-icons'; + import ImageViewer from '$lib/components/ide/ImageViewer.svelte'; import type * as Monaco from 'monaco-editor'; import type { IdeSession } from '$lib/ide/session.svelte'; + import { activeEditorThemeId } from '$lib/stores/editor-settings.svelte'; let { session }: { session: IdeSession } = $props(); @@ -18,6 +20,8 @@ const viewStates = new SvelteMap(); let renderedPath = ''; + let activeFile = $derived(session.openFiles.find((file) => file.path === session.selectedFile)); + // Responsive font const FONT_QUERY = '(min-width: 640px)'; const fontSizeFor = (desktop: boolean) => (desktop ? 12.8 : 11.5); @@ -27,13 +31,15 @@ const onMediaChange = () => editor?.updateOptions({ fontSize: fontSizeFor(media.matches) }); media.addEventListener('change', onMediaChange); - // Load Monaco lazily - void import('./monaco').then((mod) => { + // Load Monaco lazily. Highlighting must be installed first: it patches `editor.create`. + void (async () => { + const mod = await import('./monaco'); + await mod.highlightingReady; if (destroyed || !container) return; monacoMod = mod; editor = mod.monaco.editor.create(container, { model: null, - theme: 'browsercode-dark', + theme: activeEditorThemeId(), automaticLayout: true, fontSize: fontSizeFor(media.matches), fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace", @@ -55,17 +61,19 @@ entry.content = value; entry.preview = false; }); - }); + })(); return () => media.removeEventListener('change', onMediaChange); }); - /** Returns (creating if needed) the Monaco model that backs `path`. */ + /** + * Returns (creating if needed) the Monaco model that backs `path`. Passing no language lets + * Monaco resolve it from the URI. + */ function modelFor(mod: typeof import('./monaco'), path: string, content: string) { const uri = mod.monaco.Uri.file(path); return ( - mod.monaco.editor.getModel(uri) ?? - mod.monaco.editor.createModel(content, mod.languageFor(path), uri) + mod.monaco.editor.getModel(uri) ?? mod.monaco.editor.createModel(content, undefined, uri) ); } @@ -82,11 +90,12 @@ // Show the active tab: park the outgoing view state, attach the incoming // model, restore its cursor/scroll. $effect(() => { - const entry = session.openFiles.find((file) => file.path === session.selectedFile); + const entry = activeFile; if (!editor || !monacoMod) return; // Track the reveal request so a jump to the already-open file still re-runs this effect. void session.revealRequest; - if (!entry) { + // Detaching on an image tab stops the previous file's text showing through under it. + if (!entry || entry.image) { if (renderedPath) viewStates.set(renderedPath, editor.saveViewState()); editor.setModel(null); renderedPath = ''; @@ -109,6 +118,12 @@ consumeReveal(entry.path); }); + // Theme switching repaints only; no model or view state is touched. + $effect(() => { + const themeId = activeEditorThemeId(); + monacoMod?.applyTheme(themeId); + }); + // Dispose models and view states whose tab has been closed (or renamed away). $effect(() => { const open = new Set(session.openFiles.map((file) => file.path)); @@ -198,6 +213,11 @@
+ {#if activeFile?.image} +
+ +
+ {/if} {#if session.openFiles.length === 0 && !session.loading && editor}
No file open diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte index 60fb0cd..eb0ec58 100644 --- a/src/lib/components/ide/IdeShell.svelte +++ b/src/lib/components/ide/IdeShell.svelte @@ -7,6 +7,7 @@ import SearchPanel from '$lib/components/ide/SearchPanel.svelte'; import TerminalTabs from '$lib/components/ide/TerminalTabs.svelte'; import LoadingScene from '$lib/components/ide/LoadingScene.svelte'; + import SettingsMenu from '$lib/components/ide/SettingsMenu.svelte'; import { fade } from 'svelte/transition'; import type { BootStage, IdeSession } from '$lib/ide/session.svelte'; import { downloadProject } from '$lib/ide/download'; @@ -76,10 +77,24 @@ let activePanel = $state<'files' | 'search' | null>('files'); let fileTree = $state<{ startCreate: (kind: 'file' | 'folder') => void } | null>(null); + // ── Mobile state ────────────────────────────────────────────────────────── + let isMobile = $state(false); + let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); + // Frameworks with a declared app port keep the preview pinned to it; other - // ports stay reachable through the port selector. + // ports stay reachable through the toolbar's port menu. const portal = new PortalState({ preferredPort: () => session.appPort }); + let isPreviewVisible = $state(true); + let previewCollapsed = $derived(!isPreviewVisible && !isMobile); + + /** Collapses to the stub without unmounting: a remount would lose the previewed app's route. */ + function togglePreview(): void { + isPreviewVisible = !isPreviewVisible; + // xterm only refits on a resize event. + setTimeout(() => fitTerminals(), 0); + } + // Recomputed as the preview moves ports, so a report always carries the live portal URL. let bugReportHref = $derived(bugReportUrl({ repo: session.repo, previewUrl: portal.url })); @@ -93,10 +108,6 @@ let bootLines = $derived(BOOT_LOG[session.mode]); let activeLine = $derived(STAGE_LINE[session.bootStage]); - // ── Mobile state ────────────────────────────────────────────────────────── - let isMobile = $state(false); - let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); - // ── Resize state ────────────────────────────────────────────────────────── let filePanelWidth = $state(208); let leftColFraction = $state(0.6); @@ -255,6 +266,11 @@
@@ -570,8 +617,8 @@ /* ── Mobile ────────────────────────────────────────────────────────────── */ /* Keep hidden panes mounted (terminals/iframes need persistent DOM) but - take them out of layout so the active pane fills the viewport. */ - .mobile-hidden { + take them out of layout so the visible panes fill the space. */ + .pane-hidden { display: none !important; } diff --git a/src/lib/components/ide/ImageViewer.svelte b/src/lib/components/ide/ImageViewer.svelte new file mode 100644 index 0000000..cce0d9a --- /dev/null +++ b/src/lib/components/ide/ImageViewer.svelte @@ -0,0 +1,169 @@ + + +
+
+ {#if failed} +
+ + This image could not be displayed +
+ {:else} + +
+ {path} (failed = true)} + class:pixelated={scale > 1} + class={natural ? 'm-auto' : 'm-auto max-h-full max-w-full object-contain'} + style={natural + ? `width: ${Math.round(natural.width * scale)}px; height: ${Math.round(natural.height * scale)}px;` + : ''} + /> +
+ {/if} +
+
+ {#if natural} + {natural.width} × {natural.height} + {/if} + {formatBytes(image.bytes)} + {#if natural && !failed} +
+ + + +
+ {/if} +
+
+ + diff --git a/src/lib/components/ide/SettingsMenu.svelte b/src/lib/components/ide/SettingsMenu.svelte new file mode 100644 index 0000000..ea77c84 --- /dev/null +++ b/src/lib/components/ide/SettingsMenu.svelte @@ -0,0 +1,130 @@ + + +
+ + + {#if open} +
+

Editor theme

+ {#each themes as theme (theme.id)} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/ide/monaco.ts b/src/lib/components/ide/monaco.ts index 4f6dd08..4eb4726 100644 --- a/src/lib/components/ide/monaco.ts +++ b/src/lib/components/ide/monaco.ts @@ -5,6 +5,31 @@ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; +import { shikiToMonaco, textmateThemeToMonacoTheme } from '@shikijs/monaco'; +import { createHighlighterCore } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import type { ThemeRegistrationResolved } from 'shiki'; +import css from '@shikijs/langs/css'; +import docker from '@shikijs/langs/docker'; +import graphql from '@shikijs/langs/graphql'; +import html from '@shikijs/langs/html'; +import ini from '@shikijs/langs/ini'; +import javascript from '@shikijs/langs/javascript'; +import json from '@shikijs/langs/json'; +import less from '@shikijs/langs/less'; +import markdown from '@shikijs/langs/markdown'; +import python from '@shikijs/langs/python'; +import scss from '@shikijs/langs/scss'; +import shellscript from '@shikijs/langs/shellscript'; +import sql from '@shikijs/langs/sql'; +import svelte from '@shikijs/langs/svelte'; +import toml from '@shikijs/langs/toml'; +import typescript from '@shikijs/langs/typescript'; +import vue from '@shikijs/langs/vue'; +import xml from '@shikijs/langs/xml'; +import yaml from '@shikijs/langs/yaml'; +import type { EditorThemeId } from '$lib/config/editor-themes'; +import { THEME_DATA } from './themes/theme-data'; /** * Monaco bootstrap for the playground editor. This module (and EditorPane, which @@ -26,61 +51,68 @@ self.MonacoEnvironment = { for (const defaults of [monacoTs.typescriptDefaults, monacoTs.javascriptDefaults]) defaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false }); -monaco.editor.defineTheme('browsercode-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#09090b', - 'editor.foreground': '#d9d9d9', - 'editorCursor.foreground': '#10b981', - 'editor.selectionBackground': '#10b9812e', - 'editor.inactiveSelectionBackground': '#10b9811a', - 'editor.lineHighlightBackground': '#ffffff05', - 'editorLineNumber.foreground': '#ffffff40', - 'editorLineNumber.activeForeground': '#ffffff8c', - 'editorGutter.background': '#09090b', - 'editorIndentGuide.background1': '#ffffff0a', - 'editorIndentGuide.activeBackground1': '#ffffff1f', - 'editorWidget.background': '#111111', - 'editorWidget.border': '#ffffff14', - 'scrollbarSlider.background': '#ffffff1f', - 'scrollbarSlider.hoverBackground': '#ffffff38', - 'scrollbarSlider.activeBackground': '#ffffff38', - 'editorOverviewRuler.border': '#00000000' - } -}); +// Fallback for a failed install below: Monaco resolves an unknown theme name to vs-light, and +// silently, so every theme has to exist even without TextMate. +for (const [id, theme] of Object.entries(THEME_DATA)) + monaco.editor.defineTheme( + id, + textmateThemeToMonacoTheme( + theme as ThemeRegistrationResolved + ) as monaco.editor.IStandaloneThemeData + ); -// Svelte and Vue have no Monaco grammar, so they fall back to HTML. -const LANGUAGE_BY_EXT: Record = { - svelte: 'html', - vue: 'html', - html: 'html', - htm: 'html', - ts: 'typescript', - tsx: 'typescript', - mts: 'typescript', - cts: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - css: 'css', - scss: 'scss', - less: 'less', - json: 'json', - md: 'markdown', - markdown: 'markdown', - yaml: 'yaml', - yml: 'yaml', - xml: 'xml', - svg: 'xml' -}; +/* Replaces Monarch with TextMate grammars */ +async function installHighlighting(): Promise { + // Not Monaco languages, so shikiToMonaco would skip them and they would fall back to + // HTML (.svelte/.vue) or plaintext (.toml). Registering the extensions also lets Monaco + // infer them in `modelFor`. + monaco.languages.register({ id: 'svelte', extensions: ['.svelte'] }); + monaco.languages.register({ id: 'vue', extensions: ['.vue'] }); + monaco.languages.register({ id: 'toml', extensions: ['.toml'] }); -// Maps a file path to a Monaco language id by its extension; unknown types are plaintext. -export function languageFor(file: string): string { - const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase(); - return LANGUAGE_BY_EXT[ext] ?? 'plaintext'; + const highlighter = await createHighlighterCore({ + themes: Object.values(THEME_DATA), + langs: [ + css, + docker, + graphql, + html, + ini, + javascript, + json, + less, + markdown, + python, + scss, + shellscript, + sql, + svelte, + toml, + typescript, + vue, + xml, + yaml + ], + // Covers every grammar loaded here, and keeps the oniguruma wasm out of the bundle. + engine: createJavaScriptRegexEngine() + }); + + // Reads the theme list once, and re-running would wrap `setTheme` twice. + shikiToMonaco(highlighter, monaco); +} + +/** Await before creating an editor: `shikiToMonaco` patches `create` and `setTheme`. */ +export const highlightingReady: Promise = installHighlighting().catch((error) => { + console.error('Failed to install TextMate highlighting:', error); +}); + +/** Global; no editor or model is rebuilt. */ +export function applyTheme(id: EditorThemeId): void { + try { + monaco.editor.setTheme(id); + } catch (error) { + console.error(`Failed to apply the ${id} editor theme:`, error); + } } export { monaco }; diff --git a/src/lib/components/ide/themes/browsercode-dark.ts b/src/lib/components/ide/themes/browsercode-dark.ts new file mode 100644 index 0000000..8d5b317 --- /dev/null +++ b/src/lib/components/ide/themes/browsercode-dark.ts @@ -0,0 +1,93 @@ +import type { ThemeRegistration } from 'shiki'; + +/** + * The house theme, in TextMate form so Shiki loads it alongside the bundled ones. Token colors come + * from the `bc-*` palette in `routes/layout.css`. + */ +export const browsercodeDark: ThemeRegistration = { + name: 'browsercode-dark', + type: 'dark', + colors: { + 'editor.background': '#09090b', + 'editor.foreground': '#d9d9d9', + 'editorCursor.foreground': '#10b981', + 'editor.selectionBackground': '#10b9812e', + 'editor.inactiveSelectionBackground': '#10b9811a', + 'editor.lineHighlightBackground': '#ffffff05', + 'editorLineNumber.foreground': '#ffffff40', + 'editorLineNumber.activeForeground': '#ffffff8c', + 'editorGutter.background': '#09090b', + 'editorIndentGuide.background1': '#ffffff0a', + 'editorIndentGuide.activeBackground1': '#ffffff1f', + 'editorWidget.background': '#111111', + 'editorWidget.border': '#ffffff14', + 'scrollbarSlider.background': '#ffffff1f', + 'scrollbarSlider.hoverBackground': '#ffffff38', + 'scrollbarSlider.activeBackground': '#ffffff38', + 'editorOverviewRuler.border': '#00000000' + }, + tokenColors: [ + { + scope: ['comment', 'punctuation.definition.comment'], + settings: { foreground: '#5c6473', fontStyle: 'italic' } + }, + { + scope: ['string', 'string.quoted', 'string.template'], + settings: { foreground: '#b7cdff' } + }, + { + scope: ['constant.character.escape', 'string.regexp'], + settings: { foreground: '#ffd633' } + }, + { + scope: ['constant.numeric', 'constant.language', 'support.constant'], + settings: { foreground: '#c73da6' } + }, + { + scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], + settings: { foreground: '#4a7dff' } + }, + { + scope: ['keyword.operator', 'punctuation', 'meta.brace'], + settings: { foreground: '#7d8595' } + }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call'], + settings: { foreground: '#ffd633' } + }, + { + scope: [ + 'entity.name.type', + 'entity.name.class', + 'entity.other.inherited-class', + 'support.type', + 'support.class' + ], + settings: { foreground: '#8fb0ff' } + }, + { + scope: ['variable', 'variable.other', 'meta.definition.variable'], + settings: { foreground: '#d9d9d9' } + }, + { + scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'], + settings: { foreground: '#a9b4c8' } + }, + { + scope: ['entity.name.tag', 'punctuation.definition.tag'], + settings: { foreground: '#ff6161' } + }, + { + scope: ['entity.other.attribute-name'], + settings: { foreground: '#ffd633' } + }, + { + scope: ['markup.heading', 'markup.bold'], + settings: { foreground: '#b7cdff', fontStyle: 'bold' } + }, + { + scope: ['invalid', 'invalid.illegal'], + settings: { foreground: '#ff6161' } + } + ] +}; diff --git a/src/lib/components/ide/themes/colorblind-safe.ts b/src/lib/components/ide/themes/colorblind-safe.ts new file mode 100644 index 0000000..086eefd --- /dev/null +++ b/src/lib/components/ide/themes/colorblind-safe.ts @@ -0,0 +1,93 @@ +import type { ThemeRegistration } from 'shiki'; + +/** + * Built on the Okabe–Ito palette, which stays separable under protanopia, deuteranopia and + * tritanopia. Every color clears WCAG AA (4.5:1) on this background. + */ +export const colorblindSafe: ThemeRegistration = { + name: 'colorblind-safe', + type: 'dark', + colors: { + 'editor.background': '#0f1319', + 'editor.foreground': '#e6edf3', + 'editorCursor.foreground': '#56b4e9', + 'editor.selectionBackground': '#56b4e93d', + 'editor.inactiveSelectionBackground': '#56b4e921', + 'editor.lineHighlightBackground': '#ffffff08', + 'editorLineNumber.foreground': '#ffffff4d', + 'editorLineNumber.activeForeground': '#ffffff99', + 'editorGutter.background': '#0f1319', + 'editorIndentGuide.background1': '#ffffff0f', + 'editorIndentGuide.activeBackground1': '#ffffff2b', + 'editorWidget.background': '#161b22', + 'editorWidget.border': '#ffffff1f', + 'scrollbarSlider.background': '#ffffff26', + 'scrollbarSlider.hoverBackground': '#ffffff40', + 'scrollbarSlider.activeBackground': '#ffffff40', + 'editorOverviewRuler.border': '#00000000' + }, + tokenColors: [ + { + scope: ['comment', 'punctuation.definition.comment'], + settings: { foreground: '#8d96a0', fontStyle: 'italic' } + }, + { + scope: ['string', 'string.quoted', 'string.template'], + settings: { foreground: '#f0e442' } + }, + { + scope: ['constant.character.escape', 'string.regexp'], + settings: { foreground: '#f5793a' } + }, + { + scope: ['constant.numeric', 'constant.language', 'support.constant'], + settings: { foreground: '#e69f00' } + }, + { + scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], + settings: { foreground: '#cc79a7' } + }, + { + scope: ['keyword.operator', 'punctuation', 'meta.brace'], + settings: { foreground: '#7d8895' } + }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call'], + settings: { foreground: '#56b4e9' } + }, + { + scope: [ + 'entity.name.type', + 'entity.name.class', + 'entity.other.inherited-class', + 'support.type', + 'support.class' + ], + settings: { foreground: '#9fe6ff' } + }, + { + scope: ['variable', 'variable.other', 'meta.definition.variable'], + settings: { foreground: '#e6edf3' } + }, + { + scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'], + settings: { foreground: '#b8c4d0' } + }, + { + scope: ['entity.name.tag', 'punctuation.definition.tag'], + settings: { foreground: '#cc79a7' } + }, + { + scope: ['entity.other.attribute-name'], + settings: { foreground: '#56b4e9' } + }, + { + scope: ['markup.heading', 'markup.bold'], + settings: { foreground: '#e6edf3', fontStyle: 'bold' } + }, + { + scope: ['invalid', 'invalid.illegal'], + settings: { foreground: '#f5793a', fontStyle: 'underline' } + } + ] +}; diff --git a/src/lib/components/ide/themes/theme-data.ts b/src/lib/components/ide/themes/theme-data.ts new file mode 100644 index 0000000..c47b274 --- /dev/null +++ b/src/lib/components/ide/themes/theme-data.ts @@ -0,0 +1,25 @@ +import type { ThemeRegistration } from 'shiki'; +import catppuccinMocha from '@shikijs/themes/catppuccin-mocha'; +import dracula from '@shikijs/themes/dracula'; +import githubDarkDefault from '@shikijs/themes/github-dark-default'; +import nightOwl from '@shikijs/themes/night-owl'; +import oneDarkPro from '@shikijs/themes/one-dark-pro'; +import tokyoNight from '@shikijs/themes/tokyo-night'; +import type { EditorThemeId } from '$lib/config/editor-themes'; +import { browsercodeDark } from './browsercode-dark'; +import { colorblindSafe } from './colorblind-safe'; + +/** + * Colors for every id in `config/editor-themes.ts`, which holds the metadata the menu and store + * read. Kept apart from it so this data stays behind the lazy Monaco import. + */ +export const THEME_DATA: Record = { + 'browsercode-dark': browsercodeDark, + 'colorblind-safe': colorblindSafe, + dracula, + 'tokyo-night': tokyoNight, + 'night-owl': nightOwl, + 'one-dark-pro': oneDarkPro, + 'github-dark-default': githubDarkDefault, + 'catppuccin-mocha': catppuccinMocha +}; diff --git a/src/lib/config/editor-themes.ts b/src/lib/config/editor-themes.ts new file mode 100644 index 0000000..77f4607 --- /dev/null +++ b/src/lib/config/editor-themes.ts @@ -0,0 +1,44 @@ +/** + * Editor theme metadata. + */ + +export type EditorThemeVariant = 'dark' | 'light'; + +export type EditorThemeId = + | 'browsercode-dark' + | 'colorblind-safe' + | 'dracula' + | 'tokyo-night' + | 'night-owl' + | 'one-dark-pro' + | 'github-dark-default' + | 'catppuccin-mocha'; + +export type EditorThemeConfig = { + id: EditorThemeId; + label: string; + variant: EditorThemeVariant; +}; + +/** Ids are Shiki theme names, which `shikiToMonaco` registers with Monaco verbatim. */ +export const editorThemes: EditorThemeConfig[] = [ + { id: 'browsercode-dark', label: 'BrowserCode Dark', variant: 'dark' }, + { id: 'colorblind-safe', label: 'Colorblind Safe', variant: 'dark' }, + { id: 'dracula', label: 'Dracula', variant: 'dark' }, + { id: 'tokyo-night', label: 'Tokyo Night', variant: 'dark' }, + { id: 'night-owl', label: 'Night Owl', variant: 'dark' }, + { id: 'one-dark-pro', label: 'One Dark Pro', variant: 'dark' }, + { id: 'github-dark-default', label: 'GitHub Dark', variant: 'dark' }, + { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', variant: 'dark' } +]; + +/** Stands in for any variant with nothing stored, including light until one ships. */ +export const defaultEditorThemeId: EditorThemeId = 'browsercode-dark'; + +export function editorThemesFor(variant: EditorThemeVariant): EditorThemeConfig[] { + return editorThemes.filter((theme) => theme.variant === variant); +} + +export function isEditorThemeId(value: string | null): value is EditorThemeId { + return value !== null && editorThemes.some((theme) => theme.id === value); +} diff --git a/src/lib/ide/media.ts b/src/lib/ide/media.ts new file mode 100644 index 0000000..d950591 --- /dev/null +++ b/src/lib/ide/media.ts @@ -0,0 +1,57 @@ +/** + * Image tabs: pod files have no URL an `` can reach, so the bytes are read once and + * republished as an object URL, owned by the tab that holds it. + */ +import type { BrowserPod } from '@leaningtech/browserpod'; +import { readPodBinaryFile } from '$lib/pod/fs'; + +/** `url` stays valid until {@link releaseImage}. */ +export type ImagePayload = { url: string; bytes: number }; + +/** Extensions a browser renders in an ``. SVG is absent so it stays editable as text; */ +const IMAGE_MIME: Record = { + png: 'image/png', + apng: 'image/apng', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + jpe: 'image/jpeg', + jif: 'image/jpeg', + jfif: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + bmp: 'image/bmp', + ico: 'image/x-icon' +}; + +const extensionOf = (path: string): string => path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + +/** True when `path` opens as an image tab rather than in the text editor. */ +export function isImagePath(path: string): boolean { + return extensionOf(path) in IMAGE_MIME; +} + +/** Reads an image out of the pod and publishes it as an object URL. */ +export async function loadPodImage(pod: BrowserPod, absPath: string): Promise { + const bytes = await readPodBinaryFile(pod, absPath); + const blob = new Blob([bytes], { type: IMAGE_MIME[extensionOf(absPath)] }); + return { url: URL.createObjectURL(blob), bytes: bytes.byteLength }; +} + +/** Releases the object URL; a leaked one pins the whole image in memory. */ +export function releaseImage(image: ImagePayload | undefined): void { + if (image) URL.revokeObjectURL(image.url); +} + +const BYTE_UNITS = ['B', 'KB', 'MB']; + +/** Byte count for the viewer's status line, e.g. `24.1 KB`. */ +export function formatBytes(bytes: number): string { + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < BYTE_UNITS.length - 1) { + value /= 1024; + unit++; + } + return `${unit === 0 ? value : value.toFixed(1)} ${BYTE_UNITS[unit]}`; +} diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index db42cca..5b838f2 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -11,6 +11,9 @@ type Manifest = { /** What the repo declares, package name to version spec, dependencies and devDependencies. */ type DeclaredDeps = Map; +/** Reads the repo's own value for an entry, never an earlier patch's, so rule order cannot matter. */ +type CurrentValue = (section: string, name: string) => string | undefined; + type SectionPatch = { /** Top-level manifest key holding a name to value map. */ section: string; @@ -21,7 +24,7 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: SectionPatch[]; + patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -42,24 +45,46 @@ const DEP_SECTIONS = new Set([ 'peerDependencies' ]); +/** Native binaries the pod cannot execute. */ const WASM_BUNDLERS = { - esbuild: 'npm:esbuild-wasm@0.25.11', - rollup: 'npm:@rollup/wasm-node@4.52.4' + esbuild: 'npm:esbuild-wasm@*', + rollup: 'npm:@rollup/wasm-node@*' }; const FRAMEWORK_RULES: FrameworkRule[] = [ - // Vite 8+ + // Vite 8.2+ { - applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + applies: (deps) => minorAtLeast(deps, 'vite', 8, 2), + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })] }, // Vite 7 and earlier { applies: (deps) => majorBelow(deps, 'vite', 8), - patches: [force('overrides', WASM_BUNDLERS)] + patches: () => [force('overrides', WASM_BUNDLERS)] + }, + // Next.js + { + applies: (deps) => deps.has('next'), + patches: (deps, current) => { + const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); + return dev ? [force('scripts', { dev })] : []; + } } ]; +/** + * Turbopack needs native bindings the pod cannot execute. Dropping its flags is enough through Next + * 15; 16 defaults to it and needs `--webpack` to opt out. + */ +function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): string | undefined { + if (!script) return undefined; + const withoutTurbopack = script.replace(/ --turbo(?:pack)?\b/g, ''); + if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { + return withoutTurbopack; + } + return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); +} + /** Highest major the spec could install. Null means no ceiling at all. */ function highestMajor(spec: string): number | null { if (spec.includes('>') && !spec.includes('<')) return null; @@ -68,6 +93,27 @@ function highestMajor(spec: string): number | null { return Math.max(...versions.map((version) => Number.parseInt(version, 10))); } +/** Highest minor the spec could install within `major`, Infinity when it leaves the minor free. */ +function highestMinor(spec: string, major: number): number { + // A caret or a bare major floats the minor: `^8.1.1` installs 8.2 today, so it reads as 8.x. + if (spec.includes('^')) return Infinity; + const minors = (spec.match(/\d+(?:\.\d+)*/g) ?? []) + .map((version) => version.split('.').map(Number)) + .filter(([declared]) => declared === major) + .map(([, minor]) => minor ?? Infinity); + return minors.length > 0 ? Math.max(...minors) : Infinity; +} + +/** True when `name` is declared and can install `major.minor` or newer. */ +function minorAtLeast(deps: DeclaredDeps, name: string, major: number, minor: number): boolean { + const spec = deps.get(name); + if (spec === undefined) return false; + const highest = highestMajor(spec); + if (highest === null) return true; + if (highest !== major) return highest > major; + return highestMinor(spec, major) >= minor; +} + /** True when `name` is declared and can install `major` or newer, an unversioned spec included. */ function majorAtLeast(deps: DeclaredDeps, name: string, major: number): boolean { const spec = deps.get(name); @@ -116,6 +162,10 @@ export function patchClonedManifest( const deps: DeclaredDeps = new Map( Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) ); + const currentValue: CurrentValue = (section, name) => { + const held = manifest[section]; + return isRecord(held) ? held[name] : undefined; + }; // Working copies, seeded from the manifest the first time a rule touches the section. A section // holding anything other than a map is treated as absent; npm would reject it anyway. const sections = new Map>(); @@ -132,7 +182,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { if (!rule.applies(deps)) continue; - for (const { section: sectionName, mode, entries } of rule.patches) { + for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue; diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts index b0d218f..6f82467 100644 --- a/src/lib/ide/session.svelte.ts +++ b/src/lib/ide/session.svelte.ts @@ -15,6 +15,7 @@ import { } from '$lib/pod/fs'; import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc'; import { patchClonedManifest } from './native-deps'; +import { isImagePath, loadPodImage, releaseImage, type ImagePayload } from './media'; import { fetchRepoTree } from '$lib/github/api'; import { trackEvent } from '$lib/utils/useLazyTracking'; import type { PortalUpdate } from '$lib/pod/portals'; @@ -29,7 +30,14 @@ const COLOR_ENV = ['FORCE_COLOR=3', 'COLORTERM=truecolor']; * A file open as an editor tab. A `preview` tab (opened by single-click) * is reused by the next preview open; double-clicking or editing pins it. */ -export type OpenFile = { path: string; content: string; savedContent: string; preview: boolean }; +export type OpenFile = { + path: string; + content: string; + savedContent: string; + preview: boolean; + /** Set on image tabs, which render as a picture and never save. */ + image?: ImagePayload; +}; /** Where the boot pipeline currently is; drives the loader's progress readout. `copying` is * framework-only, `cloning` GitHub-only. */ @@ -38,8 +46,8 @@ export type BootStage = 'booting' | 'copying' | 'cloning' | 'installing' | 'star /** * Owns the BrowserPod lifecycle for the playground IDE: boots the pod, hydrates * the selected framework template into its filesystem, starts the dev server and - * exposes file/terminal/portal state. UI components render from this; the editor - * implementation (Monaco today, VS Code Web later) stays swappable on top. + * exposes file/terminal/portal state. UI components render from this; it holds no + * editor state of its own, so Monaco lives entirely in the component layer. */ export class IdeSession { framework = $state(defaultFrameworkId); @@ -461,6 +469,7 @@ export class IdeSession { const gone = (p: string) => p === path || p.startsWith(`${path}/`); this.projectFiles = this.projectFiles.filter((p) => !gone(p)); this.projectDirs = this.projectDirs.filter((p) => !gone(p)); + for (const file of this.openFiles) if (gone(file.path)) releaseImage(file.image); this.openFiles = this.openFiles.filter((file) => !gone(file.path)); if (gone(this.selectedFile)) this.selectedFile = this.openFiles.at(-1)?.path ?? ''; return null; @@ -486,20 +495,28 @@ export class IdeSession { this.loading = true; this.selectedFile = path; try { - const content = await readPodFile(this.pod, `${this.workdir}/${path}`); - if (this.unmounted || this.openFiles.some((file) => file.path === path)) return; + const absPath = `${this.workdir}/${path}`; + const image = isImagePath(path) ? await loadPodImage(this.pod, absPath) : undefined; + const content = image ? '' : await readPodFile(this.pod, absPath); + if (this.unmounted || this.openFiles.some((file) => file.path === path)) { + releaseImage(image); + return; + } // A pin that arrived while the read was in flight wins over the preview flag. const entry: OpenFile = { path, content, savedContent: content, - preview: preview && !this.pendingPins.delete(path) + preview: preview && !this.pendingPins.delete(path), + image }; const previewIndex = entry.preview ? this.openFiles.findIndex((file) => file.preview) : -1; - this.openFiles = - previewIndex >= 0 - ? this.openFiles.map((file, i) => (i === previewIndex ? entry : file)) - : [...this.openFiles, entry]; + if (previewIndex >= 0) { + releaseImage(this.openFiles[previewIndex].image); + this.openFiles = this.openFiles.map((file, i) => (i === previewIndex ? entry : file)); + } else { + this.openFiles = [...this.openFiles, entry]; + } } catch (error) { console.error('Failed to load file:', error); this.pendingPins.delete(path); @@ -531,6 +548,7 @@ export class IdeSession { if (index < 0) return; const entry = this.openFiles[index]; if (entry.content !== entry.savedContent) void this.saveEntry(entry); + releaseImage(entry.image); this.openFiles = this.openFiles.filter((file) => file.path !== path); if (this.selectedFile === path) this.selectedFile = (this.openFiles[index] ?? this.openFiles[index - 1])?.path ?? ''; @@ -554,6 +572,8 @@ export class IdeSession { } private async saveEntry(entry: OpenFile): Promise { + // An image tab carries no text, so writing its content back would truncate the file. + if (entry.image) return; // Saving only makes sense once the dev server is reachable; earlier writes // would race the template hydration. if (!this.pod || !this.hasPortal || this.unmounted) return; @@ -594,6 +614,7 @@ export class IdeSession { /** Tears down the pod and cancels any in-flight boot. */ shutdown(): void { this.unmounted = true; + for (const file of this.openFiles) releaseImage(file.image); this.bootToken += 1; if (this.pod) void shutdownPod(this.pod); } diff --git a/src/lib/stores/editor-settings.svelte.ts b/src/lib/stores/editor-settings.svelte.ts new file mode 100644 index 0000000..dc21a48 --- /dev/null +++ b/src/lib/stores/editor-settings.svelte.ts @@ -0,0 +1,45 @@ +import { + defaultEditorThemeId, + isEditorThemeId, + type EditorThemeId, + type EditorThemeVariant +} from '$lib/config/editor-themes'; + +const STORAGE_KEY = 'browsercode:editor-theme'; + +export const appearance: EditorThemeVariant = 'dark'; + +type ThemeChoices = Record; + +/** Unknown ids are dropped, so a stale value can't reach Monaco. */ +function loadChoices(): ThemeChoices { + const choices: ThemeChoices = { dark: null, light: null }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return choices; + const stored = JSON.parse(raw) as Partial>; + for (const variant of ['dark', 'light'] as const) { + const value = stored[variant]; + if (typeof value === 'string' && isEditorThemeId(value)) choices[variant] = value; + } + } catch (error) { + console.warn('Could not read the stored editor theme:', error); + } + return choices; +} + +const choices = $state(loadChoices()); + +export function activeEditorThemeId(): EditorThemeId { + return choices[appearance] ?? defaultEditorThemeId; +} + +/** Persists the pick for the current appearance. */ +export function setEditorTheme(id: EditorThemeId): void { + choices[appearance] = id; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(choices)); + } catch (error) { + console.warn('Could not persist the editor theme:', error); + } +} diff --git a/src/lib/stores/portals.svelte.ts b/src/lib/stores/portals.svelte.ts index 98a1d33..96318c0 100644 --- a/src/lib/stores/portals.svelte.ts +++ b/src/lib/stores/portals.svelte.ts @@ -35,6 +35,7 @@ export class PortalState { url = $state(''); frameStatus = $state('waiting'); showMenu = $state(false); + showPorts = $state(false); showInfo = $state(false); copied = $state(false); qrError = $state(''); @@ -76,11 +77,8 @@ export class PortalState { if (next.length === 0) this.options.onEmpty?.(); }; - /** Change handler for Portal.svelte's port