diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..232eed3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Pixel Editor
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..700a43a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,564 @@
+{
+ "name": "pixel-editor",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pixel-editor",
+ "version": "0.0.0",
+ "devDependencies": {
+ "typescript": "^5.0.2",
+ "vite": "^4.4.0"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.11.tgz",
+ "integrity": "sha512-q4qlUf5ucwbUJZXF5tEQ8LF7y0Nk4P58hOsGk3ucY0oCwgQqAnqXVbUuahCddVHfrxmpyewRpiTHwVHIETYu7Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.11.tgz",
+ "integrity": "sha512-snieiq75Z1z5LJX9cduSAjUr7vEI1OdlzFPMw0HH5YI7qQHDd3qs+WZoMrWYDsfRJSq36lIA6mfZBkvL46KoIw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.11.tgz",
+ "integrity": "sha512-iPuoxQEV34+hTF6FT7om+Qwziv1U519lEOvekXO9zaMMlT9+XneAhKL32DW3H7okrCOBQ44BMihE8dclbZtTuw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.11.tgz",
+ "integrity": "sha512-Gm0QkI3k402OpfMKyQEEMG0RuW2LQsSmI6OeO4El2ojJMoF5NLYb3qMIjvbG/lbMeLOGiW6ooU8xqc+S0fgz2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.11.tgz",
+ "integrity": "sha512-N15Vzy0YNHu6cfyDOjiyfJlRJCB/ngKOAvoBf1qybG3eOq0SL2Lutzz9N7DYUbb7Q23XtHPn6lMDF6uWbGv9Fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.11.tgz",
+ "integrity": "sha512-atEyuq6a3omEY5qAh5jIORWk8MzFnCpSTUruBgeyN9jZq1K/QI9uke0ATi3MHu4L8c59CnIi4+1jDKMuqmR71A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.11.tgz",
+ "integrity": "sha512-XtuPrEfBj/YYYnAAB7KcorzzpGTvOr/dTtXPGesRfmflqhA4LMF0Gh/n5+a9JBzPuJ+CGk17CA++Hmr1F/gI0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.11.tgz",
+ "integrity": "sha512-Idipz+Taso/toi2ETugShXjQ3S59b6m62KmLHkJlSq/cBejixmIydqrtM2XTvNCywFl3VC7SreSf6NV0i6sRyg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.11.tgz",
+ "integrity": "sha512-c6Vh2WS9VFKxKZ2TvJdA7gdy0n6eSy+yunBvv4aqNCEhSWVor1TU43wNRp2YLO9Vng2G+W94aRz+ILDSwAiYog==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.11.tgz",
+ "integrity": "sha512-S3hkIF6KUqRh9n1Q0dSyYcWmcVa9Cg+mSoZEfFuzoYXXsk6196qndrM+ZiHNwpZKi3XOXpShZZ+9dfN5ykqjjw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.11.tgz",
+ "integrity": "sha512-MRESANOoObQINBA+RMZW+Z0TJWpibtE7cPFnahzyQHDCA9X9LOmGh68MVimZlM9J8n5Ia8lU773te6O3ILW8kw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.11.tgz",
+ "integrity": "sha512-qVyPIZrXNMOLYegtD1u8EBccCrBVshxMrn5MkuFc3mEVsw7CCQHaqZ4jm9hbn4gWY95XFnb7i4SsT3eflxZsUg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.11.tgz",
+ "integrity": "sha512-T3yd8vJXfPirZaUOoA9D2ZjxZX4Gr3QuC3GztBJA6PklLotc/7sXTOuuRkhE9W/5JvJP/K9b99ayPNAD+R+4qQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.11.tgz",
+ "integrity": "sha512-evUoRPWiwuFk++snjH9e2cAjF5VVSTj+Dnf+rkO/Q20tRqv+644279TZlPK8nUGunjPAtQRCj1jQkDAvL6rm2w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.11.tgz",
+ "integrity": "sha512-/SlRJ15XR6i93gRWquRxYCfhTeC5PdqEapKoLbX63PLCmAkXZHY2uQm2l9bN0oPHBsOw2IswRZctMYS0MijFcg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.11.tgz",
+ "integrity": "sha512-xcncej+wF16WEmIwPtCHi0qmx1FweBqgsRtEL1mSHLFR6/mb3GEZfLQnx+pUDfRDEM4DQF8dpXIW7eDOZl1IbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.11.tgz",
+ "integrity": "sha512-aSjMHj/F7BuS1CptSXNg6S3M4F3bLp5wfFPIJM+Km2NfIVfFKhdmfHF9frhiCLIGVzDziggqWll0B+9AUbud/Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.11.tgz",
+ "integrity": "sha512-tNBq+6XIBZtht0xJGv7IBB5XaSyvYPCm1PxJ33zLQONdZoLVM0bgGqUrXnJyiEguD9LU4AHiu+GCXy/Hm9LsdQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.11.tgz",
+ "integrity": "sha512-kxfbDOrH4dHuAAOhr7D7EqaYf+W45LsAOOhAet99EyuxxQmjbk8M9N4ezHcEiCYPaiW8Dj3K26Z2V17Gt6p3ng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.11.tgz",
+ "integrity": "sha512-Sh0dDRyk1Xi348idbal7lZyfSkjhJsdFeuC13zqdipsvMetlGiFQNdO+Yfp6f6B4FbyQm7qsk16yaZk25LChzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.11.tgz",
+ "integrity": "sha512-o9JUIKF1j0rqJTFbIoF4bXj6rvrTZYOrfRcGyL0Vm5uJ/j5CkBD/51tpdxe9lXEDouhRgdr/BYzUrDOvrWwJpg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.11.tgz",
+ "integrity": "sha512-rQI4cjLHd2hGsM1LqgDI7oOCYbQ6IBOVsX9ejuRMSze0GqXUG2ekwiKkiBU1pRGSeCqFFHxTrcEydB2Hyoz9CA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.18.11",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.11.tgz",
+ "integrity": "sha512-i8u6mQF0JKJUlGR3OdFLKldJQMMs8OqM9Cc3UCi9XXziJ9WERM5bfkHaEAy0YAvPRMgqSW55W7xYn84XtEFTtA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.18.11",
+ "@esbuild/android-arm64": "0.18.11",
+ "@esbuild/android-x64": "0.18.11",
+ "@esbuild/darwin-arm64": "0.18.11",
+ "@esbuild/darwin-x64": "0.18.11",
+ "@esbuild/freebsd-arm64": "0.18.11",
+ "@esbuild/freebsd-x64": "0.18.11",
+ "@esbuild/linux-arm": "0.18.11",
+ "@esbuild/linux-arm64": "0.18.11",
+ "@esbuild/linux-ia32": "0.18.11",
+ "@esbuild/linux-loong64": "0.18.11",
+ "@esbuild/linux-mips64el": "0.18.11",
+ "@esbuild/linux-ppc64": "0.18.11",
+ "@esbuild/linux-riscv64": "0.18.11",
+ "@esbuild/linux-s390x": "0.18.11",
+ "@esbuild/linux-x64": "0.18.11",
+ "@esbuild/netbsd-x64": "0.18.11",
+ "@esbuild/openbsd-x64": "0.18.11",
+ "@esbuild/sunos-x64": "0.18.11",
+ "@esbuild/win32-arm64": "0.18.11",
+ "@esbuild/win32-ia32": "0.18.11",
+ "@esbuild/win32-x64": "0.18.11"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+ "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/postcss": {
+ "version": "8.4.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.25.tgz",
+ "integrity": "sha512-7taJ/8t2av0Z+sQEvNzCkpDynl0tX3uJMCODi6nT3PfASC7dYCWV9aQ+uiCf+KBD4SEFcu+GvJdGdwzQ6OSjCw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "3.26.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.2.tgz",
+ "integrity": "sha512-6umBIGVz93er97pMgQO08LuH3m6PUb3jlDUUGFsNJB6VgTCUaDFpupf5JfU30529m/UKOgmiX+uY6Sx8cOYpLA==",
+ "dev": true,
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=14.18.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.0.tgz",
+ "integrity": "sha512-Wf+DCEjuM8aGavEYiF77hnbxEZ+0+/jC9nABR46sh5Xi+GYeSvkeEFRiVuI3x+tPjxgZeS91h1jTAQTPFgePpA==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.18.10",
+ "postcss": "^8.4.24",
+ "rollup": "^3.25.2"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 14",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ec12b0f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "pixel-editor",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "typescript": "^5.0.2",
+ "vite": "^4.4.0"
+ }
+}
diff --git a/pixelEditor.ts b/pixelEditor.ts
new file mode 100644
index 0000000..9503f0b
--- /dev/null
+++ b/pixelEditor.ts
@@ -0,0 +1,170 @@
+// model for a frame of canvas
+class Picture {
+ public width: number;
+ public height: number;
+ public pixels: string[]; // array to store colors
+
+ constructor(width: number, height: number, pixels: string[]) {
+ this.width = width;
+ this.height = height;
+ this.pixels = pixels;
+ }
+ public static empty(width: number, height: number, color: string) {
+ const pixels = new Array(width * height).fill(color);
+ return new Picture(width, height, pixels);
+ }
+
+ public getPixel(x: number, y: number) {
+ return this.pixels[x + y * this.width];
+ }
+
+ public draw(pixels: Pixel[]) {
+ const copy = this.pixels.slice();
+ for (const { x, y, color } of pixels) {
+ copy[x + y * this.width] = color;
+ }
+ return new Picture(this.width, this.height, copy);
+ }
+}
+
+// draw Picture to canvas
+function drawPicture(
+ picture: Picture,
+ canvas: HTMLCanvasElement,
+ scale: number
+) {
+ canvas.width = picture.width * scale;
+ canvas.height = picture.height * scale;
+ let cx = canvas.getContext("2d");
+
+ for (let y = 0; y < picture.height; y++) {
+ for (let x = 0; x < picture.width; x++) {
+ cx!.fillStyle = picture.getPixel(x, y);
+ cx!.fillRect(x * scale, y * scale, scale, scale);
+ }
+ }
+}
+
+function startLoad(dispatch) {
+ let input = elt("input", {
+ type: "file",
+ onchange: () => finishLoad(input.files[0], dispatch),
+ }) as HTMLInputElement;
+ document.body.appendChild(input);
+ input.click();
+ input.remove();
+}
+
+function finishLoad(file, dispatch) {
+ if (file == null) return;
+ let reader = new FileReader();
+ reader.addEventListener("load", () => {
+ let image = elt("img", {
+ onload: () =>
+ dispatch({
+ picture: pictureFromImage(image),
+ }),
+ src: reader.result,
+ });
+ });
+ reader.readAsDataURL(file);
+}
+
+function pictureFromImage(image) {
+ let width = Math.min(100, image.width);
+ let height = Math.min(100, image.height);
+ let canvas = elt("canvas", { width, height });
+ let cx = canvas.getContext("2d");
+ cx!.drawImage(image, 0, 0);
+ let pixels = [];
+ let { data } = cx.getImageData(0, 0, width, height);
+
+ function hex(n) {
+ return n.toString(16).padStart(2, "0");
+ }
+ for (let i = 0; i < data.length; i += 4) {
+ let [r, g, b] = data.slice(i, i + 3);
+ pixels.push("#" + hex(r) + hex(g) + hex(b));
+ }
+ return new Picture(width, height, pixels);
+}
+
+function draw(pos, state: EditorState, dispatch): (...args: any[]) => void {
+ function drawPixel({ x, y }, state) {
+ const drawn = { x, y, color: state.color };
+ dispatch({ picture: state.picture.draw([drawn]) });
+ }
+ drawPixel(pos, state);
+ return drawPixel;
+}
+
+function rectangle(start: Position, state: EditorState, dispatch) {
+ function drawRectangle(pos: Position) {
+ let xStart = Math.min(start.x, pos.x);
+ let yStart = Math.min(start.y, pos.y);
+ let xEnd = Math.max(start.x, pos.x);
+ let yEnd = Math.max(start.y, pos.y);
+ let drawn: Pixel[] = [];
+ for (let y = yStart; y <= yEnd; y++) {
+ for (let x = xStart; x <= xEnd; x++) {
+ drawn.push({ x, y, color: state.color });
+ }
+ }
+ dispatch({ picture: state.picture.draw(drawn) });
+ }
+ drawRectangle(start);
+ return drawRectangle;
+}
+
+// fill
+const around = [
+ { dx: -1, dy: 0 },
+ { dx: 1, dy: 0 },
+ { dx: 0, dy: -1 },
+ { dx: 0, dy: 1 },
+];
+
+function fill({ x, y }, state, dispatch) {
+ let targetColor = state.picture.pixel(x, y);
+ let drawn = [{ x, y, color: state.color }];
+ for (let done = 0; done < drawn.length; done++) {
+ for (let { dx, dy } of around) {
+ let x = drawn[done].x + dx,
+ y = drawn[done].y + dy;
+ if (
+ x >= 0 &&
+ x < state.picture.width &&
+ y >= 0 &&
+ y < state.picture.height &&
+ state.picture.pixel(x, y) == targetColor &&
+ !drawn.some((p) => p.x == x && p.y == y)
+ ) {
+ drawn.push({ x, y, color: state.color });
+ }
+ }
+ }
+ dispatch({ picture: state.picture.draw(drawn) });
+}
+
+function pick(pos, state, dispatch) {
+ dispatch({ color: state.picture.pixel(pos.x, pos.y) });
+}
+
+// undo
+function historyUpdateState(state, action) {
+ if (action.undo == true) {
+ if (state.done.length == 0) return state;
+ return Object.assign({}, state, {
+ picture: state.done[0],
+ done: state.done.slice(1),
+ doneAt: 0,
+ });
+ } else if (action.picture && state.doneAt < Date.now() - 1000) {
+ return Object.assign({}, state, action, {
+ done: [state.picture, ...state.done],
+ doneAt: Date.now(),
+ });
+ } else {
+ return Object.assign({}, state, action);
+ }
+}
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/App.ts b/src/App.ts
new file mode 100644
index 0000000..1cdf592
--- /dev/null
+++ b/src/App.ts
@@ -0,0 +1,52 @@
+import {
+ ToolSelect,
+ ColorSelect,
+ SaveButton,
+ LoadButton,
+ UndoButton,
+ PixelEditor,
+} from "./components";
+
+import { EditorState } from "./types";
+
+const initialState: EditorState = {
+ tool: "draw",
+ color: "#000000",
+ picture: Picture.empty(60, 30, "#f0f0f0"),
+ done: [],
+ doneAt: 0,
+};
+
+const baseTools = {
+ draw,
+ fill,
+ rectangle,
+ pick,
+};
+
+// UI components
+const baseControls = [
+ ToolSelect,
+ ColorSelect,
+ SaveButton,
+ LoadButton,
+ UndoButton,
+];
+
+function startPixelEditor({
+ state = initialState,
+ tools = baseTools,
+ controls = baseControls,
+}) {
+ const app = new PixelEditor(state, {
+ tools,
+ controls,
+ dispatch(action) {
+ state = historyUpdateState(state, action);
+ app.syncState(state);
+ },
+ });
+ return app.dom;
+}
+
+export default startPixelEditor;
diff --git a/src/components/ColorSelect.ts b/src/components/ColorSelect.ts
new file mode 100644
index 0000000..7978e3a
--- /dev/null
+++ b/src/components/ColorSelect.ts
@@ -0,0 +1,19 @@
+import elt from "../utils/createElement";
+
+class ColorSelect implements UIComponent {
+ public input: HTMLInputElement;
+ public dom: HTMLElement;
+ constructor(state: EditorState, { dispatch }) {
+ this.input = elt("input", {
+ type: "color",
+ value: state.color,
+ onchange: () => dispatch({ color: this.input.value }),
+ }) as HTMLInputElement;
+ this.dom = elt("label", null, "🎨 Color: ", this.input);
+ }
+ public syncState(state: EditorState) {
+ this.input.value = state.color;
+ }
+}
+
+export default ColorSelect;
diff --git a/src/components/LoadButton.ts b/src/components/LoadButton.ts
new file mode 100644
index 0000000..b2ba00f
--- /dev/null
+++ b/src/components/LoadButton.ts
@@ -0,0 +1,17 @@
+import elt from "../utils/createElement";
+
+class LoadButton implements UIComponent {
+ public dom: HTMLElement;
+ constructor(_, { dispatch }) {
+ this.dom = elt(
+ "button",
+ {
+ onclick: () => startLoad(dispatch),
+ },
+ "📁 Load"
+ );
+ }
+ syncState() {}
+}
+
+export default LoadButton;
diff --git a/src/components/PictureCanvas.ts b/src/components/PictureCanvas.ts
new file mode 100644
index 0000000..3e5fe01
--- /dev/null
+++ b/src/components/PictureCanvas.ts
@@ -0,0 +1,79 @@
+import elt from "../utils/createElement";
+import { Position } from "../types";
+
+// A component holds canvas that only knows current picture
+// It adds mouse and touch events handlers when constructs
+class PictureCanvas {
+ public dom: HTMLCanvasElement;
+ public picture: Picture;
+ private scale: number = 10;
+
+ constructor(
+ picture: Picture,
+ pointerDown: (...args: any[]) => void,
+ scale?: number
+ ) {
+ this.dom = elt("canvas", {
+ onmousedown: (event: MouseEvent) => this.mouse(event, pointerDown),
+ ontouchstart: (event: TouchEvent) => this.touch(event, pointerDown),
+ }) as HTMLCanvasElement;
+ if (scale) this.scale = scale;
+ this.syncState(picture);
+ }
+
+ public syncState(picture: Picture) {
+ if (this.picture == picture) return;
+ this.picture = picture;
+ drawPicture(this.picture, this.dom, this.scale);
+ }
+
+ public mouse(downEvent: MouseEvent, onDown: (...args: any[]) => any) {
+ if (downEvent.button != 0) return;
+ let pos = this.pointerPosition(downEvent, this.dom);
+ let onMove = onDown(pos);
+ if (!onMove) return;
+ let move = (moveEvent: MouseEvent) => {
+ if (moveEvent.buttons == 0) {
+ this.dom.removeEventListener("mousemove", move);
+ } else {
+ let newPos = this.pointerPosition(moveEvent, this.dom);
+ if (newPos.x == pos.x && newPos.y == pos.y) return;
+ pos = newPos;
+ onMove(newPos);
+ }
+ };
+ this.dom.addEventListener("mousemove", move);
+ }
+
+ public touch(startEvent: TouchEvent, onDown: (...args: any[]) => any) {
+ let pos = this.pointerPosition(startEvent.changedTouches[0], this.dom);
+ let onMove = onDown(pos);
+ startEvent.preventDefault();
+ if (!onMove) return;
+ let move = (moveEvent: TouchEvent) => {
+ let newPos = this.pointerPosition(moveEvent.changedTouches[0], this.dom);
+ if (newPos.x == pos.x && newPos.y == pos.y) return;
+ pos = newPos;
+ onMove(newPos);
+ };
+ let end = () => {
+ this.dom.removeEventListener("touchmove", move);
+ this.dom.removeEventListener("touchend", end);
+ };
+ this.dom.addEventListener("touchmove", move);
+ this.dom.addEventListener("touchend", end);
+ }
+
+ private pointerPosition(
+ downEvent: MouseEvent | Touch,
+ domNode: HTMLCanvasElement
+ ): Position {
+ let rect = domNode.getBoundingClientRect();
+ return {
+ x: Math.floor((downEvent.clientX - rect.left) / this.scale),
+ y: Math.floor((downEvent.clientY - rect.top) / this.scale),
+ };
+ }
+}
+
+export default PictureCanvas;
diff --git a/src/components/PixelEditor.ts b/src/components/PixelEditor.ts
new file mode 100644
index 0000000..31e7fbf
--- /dev/null
+++ b/src/components/PixelEditor.ts
@@ -0,0 +1,39 @@
+import elt from "../utils/createElement";
+import PictureCanvas from "./PictureCanvas";
+
+class PixelEditor implements UIComponent {
+ public state: EditorState;
+ public canvas: PictureCanvas;
+ public controls: any[];
+ public dom: HTMLElement;
+
+ constructor(state: EditorState, config: EditorConfig) {
+ const { tools, controls, dispatch } = config;
+ this.state = state;
+
+ this.canvas = new PictureCanvas(state.picture, (pos) => {
+ const tool = tools[this.state.tool];
+ const onMove = tool(pos, this.state, dispatch);
+ if (onMove) return (pos) => onMove(pos, this.state);
+ });
+
+ this.controls = controls.map((Control) => new Control(state, config));
+ this.dom = elt(
+ "div",
+ {},
+ this.canvas.dom,
+ elt("br"),
+ ...this.controls.reduce((a, c) => a.concat(" ", c.dom), [])
+ );
+ }
+
+ public syncState(state: EditorState) {
+ this.state = state;
+ this.canvas.syncState(state.picture);
+ for (let ctrl of this.controls) {
+ ctrl.syncState(state);
+ }
+ }
+}
+
+export default PixelEditor;
diff --git a/src/components/SaveButton.ts b/src/components/SaveButton.ts
new file mode 100644
index 0000000..2d54dd9
--- /dev/null
+++ b/src/components/SaveButton.ts
@@ -0,0 +1,33 @@
+import elt from "../utils/createElement";
+
+class SaveButton implements UIComponent {
+ public picture: Picture;
+ public dom: HTMLElement;
+
+ constructor(state) {
+ this.picture = state.picture;
+ this.dom = elt(
+ "button",
+ {
+ onclick: () => this.save(),
+ },
+ "💾 Save"
+ );
+ }
+ save() {
+ let canvas = elt("canvas");
+ drawPicture(this.picture, canvas, 1);
+ let link = elt("a", {
+ href: canvas.toDataURL(),
+ download: "pixelart.png",
+ });
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ }
+ syncState(state) {
+ this.picture = state.picture;
+ }
+}
+
+export default SaveButton;
diff --git a/src/components/ToolSelect.ts b/src/components/ToolSelect.ts
new file mode 100644
index 0000000..5036bbe
--- /dev/null
+++ b/src/components/ToolSelect.ts
@@ -0,0 +1,30 @@
+import elt from "../utils/createElement";
+
+class ToolSelect implements UIComponent {
+ public select: HTMLSelectElement;
+ public dom: HTMLElement;
+
+ constructor(state: EditorState, { tools, dispatch }) {
+ this.select = elt(
+ "select",
+ {
+ onchange: () => dispatch({ tool: this.select.value }),
+ },
+ ...Object.keys(tools).map((name) =>
+ elt(
+ "option",
+ {
+ selected: name == state.tool,
+ },
+ name
+ )
+ )
+ ) as HTMLSelectElement;
+ this.dom = elt("label", null, "🖌 Tool: ", this.select);
+ }
+ public syncState(state: EditorState) {
+ this.select.value = state.tool;
+ }
+}
+
+export default ToolSelect;
diff --git a/src/components/UndoButton.ts b/src/components/UndoButton.ts
new file mode 100644
index 0000000..c07b9a5
--- /dev/null
+++ b/src/components/UndoButton.ts
@@ -0,0 +1,20 @@
+import elt from "../utils/createElement";
+
+class UndoButton implements UIComponent {
+ public dom: HTMLButtonElement;
+ constructor(state, { dispatch }) {
+ this.dom = elt(
+ "button",
+ {
+ onclick: () => dispatch({ undo: true }),
+ disabled: state.done.length == 0,
+ },
+ "⮪ Undo"
+ );
+ }
+ syncState(state: EditorState) {
+ this.dom.disabled = state.done.length == 0;
+ }
+}
+
+export default UndoButton;
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 0000000..3453ba5
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,17 @@
+import ColorSelect from "./ColorSelect";
+import SaveButton from "./SaveButton";
+import ToolSelect from "./ToolSelect";
+import LoadButton from "./LoadButton";
+import UndoButton from "./UndoButton";
+import PictureCanvas from "./PictureCanvas";
+import PixelEditor from "./PixelEditor";
+
+export {
+ ColorSelect,
+ SaveButton,
+ ToolSelect,
+ LoadButton,
+ UndoButton,
+ PictureCanvas,
+ PixelEditor,
+};
diff --git a/src/counter.ts b/src/counter.ts
new file mode 100644
index 0000000..09e5afd
--- /dev/null
+++ b/src/counter.ts
@@ -0,0 +1,9 @@
+export function setupCounter(element: HTMLButtonElement) {
+ let counter = 0
+ const setCounter = (count: number) => {
+ counter = count
+ element.innerHTML = `count is ${counter}`
+ }
+ element.addEventListener('click', () => setCounter(counter + 1))
+ setCounter(0)
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..2f526ea
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,4 @@
+import "./style.css";
+import startPixelEditor from "App";
+
+document.querySelector("#app")!.appendChild(startPixelEditor({}));
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..b528b6c
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,97 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+#app {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.vanilla:hover {
+ filter: drop-shadow(0 0 2em #3178c6aa);
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..db262a3
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,37 @@
+// UIs are modeled as components.
+// Each component creates corresponding HTML elements
+// and expose sysnState() to outside world
+export interface UIComponent {
+ syncState(state: EditorState): void;
+}
+
+// https://stackoverflow.com/questions/13407036/how-does-interfaces-with-construct-signatures-work
+declare var UIComponent: {
+ new (...args: any[]): UIComponent;
+};
+
+export interface EditorState {
+ tool: string;
+ color: string; // like "#000000",
+ picture: Picture;
+ done: any[];
+ doneAt: number;
+}
+
+export interface EditorConfig {
+ tools: any;
+ controls: (typeof UIComponent)[];
+ dispatch: any;
+}
+
+// store coordinates of color
+export class Pixel {
+ public x: number;
+ public y: number;
+ public color: string; // like "#000000"
+}
+
+export interface Position {
+ x: number;
+ y: number;
+}
diff --git a/src/typescript.svg b/src/typescript.svg
new file mode 100644
index 0000000..d91c910
--- /dev/null
+++ b/src/typescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/utils/createElement.ts b/src/utils/createElement.ts
new file mode 100644
index 0000000..f2964a3
--- /dev/null
+++ b/src/utils/createElement.ts
@@ -0,0 +1,16 @@
+// dom building
+function elt(
+ type: string,
+ props?: any,
+ ...children: (HTMLElement | string)[]
+): HTMLElement {
+ let dom = document.createElement(type);
+ if (props) Object.assign(dom, props);
+ for (let child of children) {
+ if (typeof child != "string") dom.appendChild(child);
+ else dom.appendChild(document.createTextNode(child));
+ }
+ return dom;
+}
+
+export default elt;
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..6f2d2a4
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,3 @@
+///
+declare module "App";
+declare module "type";
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..75abdef
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}