From a69e9e87da0b0e7a5270b83635c355b0121d1dc0 Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 6 Jul 2023 23:09:30 +0800 Subject: [PATCH] init repo --- .gitignore | 24 ++ index.html | 13 + package-lock.json | 564 ++++++++++++++++++++++++++++++++ package.json | 15 + pixelEditor.ts | 170 ++++++++++ public/vite.svg | 1 + src/App.ts | 52 +++ src/components/ColorSelect.ts | 19 ++ src/components/LoadButton.ts | 17 + src/components/PictureCanvas.ts | 79 +++++ src/components/PixelEditor.ts | 39 +++ src/components/SaveButton.ts | 33 ++ src/components/ToolSelect.ts | 30 ++ src/components/UndoButton.ts | 20 ++ src/components/index.ts | 17 + src/counter.ts | 9 + src/main.ts | 4 + src/style.css | 97 ++++++ src/types.ts | 37 +++ src/typescript.svg | 1 + src/utils/createElement.ts | 16 + src/vite-env.d.ts | 3 + tsconfig.json | 23 ++ 23 files changed, 1283 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pixelEditor.ts create mode 100644 public/vite.svg create mode 100644 src/App.ts create mode 100644 src/components/ColorSelect.ts create mode 100644 src/components/LoadButton.ts create mode 100644 src/components/PictureCanvas.ts create mode 100644 src/components/PixelEditor.ts create mode 100644 src/components/SaveButton.ts create mode 100644 src/components/ToolSelect.ts create mode 100644 src/components/UndoButton.ts create mode 100644 src/components/index.ts create mode 100644 src/counter.ts create mode 100644 src/main.ts create mode 100644 src/style.css create mode 100644 src/types.ts create mode 100644 src/typescript.svg create mode 100644 src/utils/createElement.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json 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"] +}