├── LICENSE ├── README.md └── client ├── .cargo └── config ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── README.md ├── assets ├── material-design-icons │ ├── LICENSE │ ├── README.md │ ├── close.svg │ ├── eraser.svg │ ├── pencil.svg │ └── selection-ellipse-arrow-inside.svg └── octicons │ ├── LICENSE │ ├── README.md │ └── mark-github.svg └── src ├── common.rs ├── ctrl.rs ├── lib.rs ├── model.rs ├── model ├── compat.rs ├── history.rs ├── recorder.rs └── tiling.rs ├── static ├── assets │ ├── clear.svg │ ├── cross.svg │ ├── eraser.svg │ ├── github.svg │ ├── pen.svg │ └── selector.svg ├── index.html └── main.sass ├── utils.rs ├── view.rs └── web.rs /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kuretchi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Papirs 2 | 3 | A simple whiteboard. 4 | 5 | ## License 6 | 7 | [MIT License](./LICENSE) 8 | -------------------------------------------------------------------------------- /client/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" 3 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | /public 2 | /target 3 | -------------------------------------------------------------------------------- /client/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 10 | 11 | [[package]] 12 | name = "anyhow" 13 | version = "1.0.45" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "ee10e43ae4a853c0a3591d4e2ada1719e553be18199d9da9d4a83f5927c2f5c7" 16 | 17 | [[package]] 18 | name = "approx" 19 | version = "0.4.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" 22 | dependencies = [ 23 | "num-traits", 24 | ] 25 | 26 | [[package]] 27 | name = "arrayvec" 28 | version = "0.7.2" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" 31 | 32 | [[package]] 33 | name = "as-slice" 34 | version = "0.1.5" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" 37 | dependencies = [ 38 | "generic-array 0.12.4", 39 | "generic-array 0.13.3", 40 | "generic-array 0.14.4", 41 | "stable_deref_trait", 42 | ] 43 | 44 | [[package]] 45 | name = "autocfg" 46 | version = "1.0.1" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 49 | 50 | [[package]] 51 | name = "base64" 52 | version = "0.13.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 55 | 56 | [[package]] 57 | name = "bincode" 58 | version = "1.3.3" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 61 | dependencies = [ 62 | "serde", 63 | ] 64 | 65 | [[package]] 66 | name = "bumpalo" 67 | version = "3.7.0" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" 70 | 71 | [[package]] 72 | name = "byteorder" 73 | version = "1.4.3" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 76 | 77 | [[package]] 78 | name = "cfg-if" 79 | version = "1.0.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 82 | 83 | [[package]] 84 | name = "console_error_panic_hook" 85 | version = "0.1.7" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 88 | dependencies = [ 89 | "cfg-if", 90 | "wasm-bindgen", 91 | ] 92 | 93 | [[package]] 94 | name = "console_log" 95 | version = "0.2.0" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "501a375961cef1a0d44767200e66e4a559283097e91d0730b1d75dfb2f8a1494" 98 | dependencies = [ 99 | "log", 100 | "web-sys", 101 | ] 102 | 103 | [[package]] 104 | name = "convert_case" 105 | version = "0.4.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 108 | 109 | [[package]] 110 | name = "crc32fast" 111 | version = "1.2.1" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" 114 | dependencies = [ 115 | "cfg-if", 116 | ] 117 | 118 | [[package]] 119 | name = "derive_more" 120 | version = "0.99.16" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "40eebddd2156ce1bb37b20bbe5151340a31828b1f2d22ba4141f3531710e38df" 123 | dependencies = [ 124 | "convert_case", 125 | "proc-macro2", 126 | "quote", 127 | "rustc_version", 128 | "syn", 129 | ] 130 | 131 | [[package]] 132 | name = "either" 133 | version = "1.6.1" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 136 | 137 | [[package]] 138 | name = "enum-map" 139 | version = "1.1.1" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "e893a7ba6116821058dec84a6fb14fb2a97cd8ce5fd0f85d5a4e760ecd7329d9" 142 | dependencies = [ 143 | "enum-map-derive", 144 | ] 145 | 146 | [[package]] 147 | name = "enum-map-derive" 148 | version = "0.6.0" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "84278eae0af6e34ff6c1db44c11634a694aafac559ff3080e4db4e4ac35907aa" 151 | dependencies = [ 152 | "proc-macro2", 153 | "quote", 154 | "syn", 155 | ] 156 | 157 | [[package]] 158 | name = "enum_dispatch" 159 | version = "0.3.7" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "bd53b3fde38a39a06b2e66dc282f3e86191e53bd04cc499929c15742beae3df8" 162 | dependencies = [ 163 | "once_cell", 164 | "proc-macro2", 165 | "quote", 166 | "syn", 167 | ] 168 | 169 | [[package]] 170 | name = "flate2" 171 | version = "1.0.22" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f" 174 | dependencies = [ 175 | "cfg-if", 176 | "crc32fast", 177 | "libc", 178 | "miniz_oxide", 179 | ] 180 | 181 | [[package]] 182 | name = "generic-array" 183 | version = "0.12.4" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" 186 | dependencies = [ 187 | "typenum", 188 | ] 189 | 190 | [[package]] 191 | name = "generic-array" 192 | version = "0.13.3" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" 195 | dependencies = [ 196 | "typenum", 197 | ] 198 | 199 | [[package]] 200 | name = "generic-array" 201 | version = "0.14.4" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" 204 | dependencies = [ 205 | "typenum", 206 | "version_check", 207 | ] 208 | 209 | [[package]] 210 | name = "geo" 211 | version = "0.18.0" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "02bf7fb342abefefb0abbb8d033f37233e6f857a1a970805d15f96560834d699" 214 | dependencies = [ 215 | "geo-types", 216 | "geographiclib-rs", 217 | "log", 218 | "num-traits", 219 | "robust", 220 | "rstar", 221 | "serde", 222 | ] 223 | 224 | [[package]] 225 | name = "geo-types" 226 | version = "0.7.2" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "d8bd2e95dd9f5c8ff74159ed9205ad7fd239a9569173a550863976421b45d2bb" 229 | dependencies = [ 230 | "approx", 231 | "num-traits", 232 | "rstar", 233 | "serde", 234 | ] 235 | 236 | [[package]] 237 | name = "geographiclib-rs" 238 | version = "0.2.0" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "b78e20d5d868fa2c4182a8170cb4df261e781a605810e3c1500269c1907da461" 241 | dependencies = [ 242 | "lazy_static", 243 | ] 244 | 245 | [[package]] 246 | name = "getrandom" 247 | version = "0.2.3" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" 250 | dependencies = [ 251 | "cfg-if", 252 | "js-sys", 253 | "libc", 254 | "wasi", 255 | "wasm-bindgen", 256 | ] 257 | 258 | [[package]] 259 | name = "hash32" 260 | version = "0.1.1" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" 263 | dependencies = [ 264 | "byteorder", 265 | ] 266 | 267 | [[package]] 268 | name = "heapless" 269 | version = "0.6.1" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" 272 | dependencies = [ 273 | "as-slice", 274 | "generic-array 0.14.4", 275 | "hash32", 276 | "stable_deref_trait", 277 | ] 278 | 279 | [[package]] 280 | name = "itertools" 281 | version = "0.10.1" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf" 284 | dependencies = [ 285 | "either", 286 | ] 287 | 288 | [[package]] 289 | name = "js-sys" 290 | version = "0.3.55" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84" 293 | dependencies = [ 294 | "wasm-bindgen", 295 | ] 296 | 297 | [[package]] 298 | name = "lazy_static" 299 | version = "1.4.0" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 302 | 303 | [[package]] 304 | name = "libc" 305 | version = "0.2.100" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "a1fa8cddc8fbbee11227ef194b5317ed014b8acbf15139bd716a18ad3fe99ec5" 308 | 309 | [[package]] 310 | name = "log" 311 | version = "0.4.14" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 314 | dependencies = [ 315 | "cfg-if", 316 | ] 317 | 318 | [[package]] 319 | name = "miniz_oxide" 320 | version = "0.4.4" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" 323 | dependencies = [ 324 | "adler", 325 | "autocfg", 326 | ] 327 | 328 | [[package]] 329 | name = "num-traits" 330 | version = "0.2.14" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 333 | dependencies = [ 334 | "autocfg", 335 | ] 336 | 337 | [[package]] 338 | name = "once_cell" 339 | version = "1.8.0" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" 342 | 343 | [[package]] 344 | name = "papirs-client" 345 | version = "0.0.0" 346 | dependencies = [ 347 | "anyhow", 348 | "arrayvec", 349 | "base64", 350 | "bincode", 351 | "console_error_panic_hook", 352 | "console_log", 353 | "derive_more", 354 | "enum-map", 355 | "enum_dispatch", 356 | "flate2", 357 | "geo", 358 | "itertools", 359 | "js-sys", 360 | "log", 361 | "rustc-hash", 362 | "serde", 363 | "uuid", 364 | "wasm-bindgen", 365 | "web-sys", 366 | ] 367 | 368 | [[package]] 369 | name = "pdqselect" 370 | version = "0.1.0" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" 373 | 374 | [[package]] 375 | name = "pest" 376 | version = "2.1.3" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" 379 | dependencies = [ 380 | "ucd-trie", 381 | ] 382 | 383 | [[package]] 384 | name = "proc-macro2" 385 | version = "1.0.28" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" 388 | dependencies = [ 389 | "unicode-xid", 390 | ] 391 | 392 | [[package]] 393 | name = "quote" 394 | version = "1.0.9" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 397 | dependencies = [ 398 | "proc-macro2", 399 | ] 400 | 401 | [[package]] 402 | name = "robust" 403 | version = "0.2.3" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "e5864e7ef1a6b7bcf1d6ca3f655e65e724ed3b52546a0d0a663c991522f552ea" 406 | 407 | [[package]] 408 | name = "rstar" 409 | version = "0.8.3" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "ce61d743ebe516592df4dd542dfe823577b811299f7bee1106feb1bbb993dbac" 412 | dependencies = [ 413 | "heapless", 414 | "num-traits", 415 | "pdqselect", 416 | "smallvec", 417 | ] 418 | 419 | [[package]] 420 | name = "rustc-hash" 421 | version = "1.1.0" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 424 | 425 | [[package]] 426 | name = "rustc_version" 427 | version = "0.3.3" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" 430 | dependencies = [ 431 | "semver", 432 | ] 433 | 434 | [[package]] 435 | name = "semver" 436 | version = "0.11.0" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" 439 | dependencies = [ 440 | "semver-parser", 441 | ] 442 | 443 | [[package]] 444 | name = "semver-parser" 445 | version = "0.10.2" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" 448 | dependencies = [ 449 | "pest", 450 | ] 451 | 452 | [[package]] 453 | name = "serde" 454 | version = "1.0.130" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" 457 | dependencies = [ 458 | "serde_derive", 459 | ] 460 | 461 | [[package]] 462 | name = "serde_derive" 463 | version = "1.0.130" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" 466 | dependencies = [ 467 | "proc-macro2", 468 | "quote", 469 | "syn", 470 | ] 471 | 472 | [[package]] 473 | name = "smallvec" 474 | version = "1.6.1" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" 477 | 478 | [[package]] 479 | name = "stable_deref_trait" 480 | version = "1.2.0" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 483 | 484 | [[package]] 485 | name = "syn" 486 | version = "1.0.75" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "b7f58f7e8eaa0009c5fec437aabf511bd9933e4b2d7407bd05273c01a8906ea7" 489 | dependencies = [ 490 | "proc-macro2", 491 | "quote", 492 | "unicode-xid", 493 | ] 494 | 495 | [[package]] 496 | name = "typenum" 497 | version = "1.13.0" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" 500 | 501 | [[package]] 502 | name = "ucd-trie" 503 | version = "0.1.3" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" 506 | 507 | [[package]] 508 | name = "unicode-xid" 509 | version = "0.2.2" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 512 | 513 | [[package]] 514 | name = "uuid" 515 | version = "0.8.2" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" 518 | dependencies = [ 519 | "getrandom", 520 | "serde", 521 | ] 522 | 523 | [[package]] 524 | name = "version_check" 525 | version = "0.9.3" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 528 | 529 | [[package]] 530 | name = "wasi" 531 | version = "0.10.2+wasi-snapshot-preview1" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 534 | 535 | [[package]] 536 | name = "wasm-bindgen" 537 | version = "0.2.78" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" 540 | dependencies = [ 541 | "cfg-if", 542 | "wasm-bindgen-macro", 543 | ] 544 | 545 | [[package]] 546 | name = "wasm-bindgen-backend" 547 | version = "0.2.78" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" 550 | dependencies = [ 551 | "bumpalo", 552 | "lazy_static", 553 | "log", 554 | "proc-macro2", 555 | "quote", 556 | "syn", 557 | "wasm-bindgen-shared", 558 | ] 559 | 560 | [[package]] 561 | name = "wasm-bindgen-macro" 562 | version = "0.2.78" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" 565 | dependencies = [ 566 | "quote", 567 | "wasm-bindgen-macro-support", 568 | ] 569 | 570 | [[package]] 571 | name = "wasm-bindgen-macro-support" 572 | version = "0.2.78" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" 575 | dependencies = [ 576 | "proc-macro2", 577 | "quote", 578 | "syn", 579 | "wasm-bindgen-backend", 580 | "wasm-bindgen-shared", 581 | ] 582 | 583 | [[package]] 584 | name = "wasm-bindgen-shared" 585 | version = "0.2.78" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc" 588 | 589 | [[package]] 590 | name = "web-sys" 591 | version = "0.3.55" 592 | source = "registry+https://github.com/rust-lang/crates.io-index" 593 | checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb" 594 | dependencies = [ 595 | "js-sys", 596 | "wasm-bindgen", 597 | ] 598 | -------------------------------------------------------------------------------- /client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "papirs-client" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | anyhow = "1.0.45" 11 | arrayvec = "0.7.2" 12 | base64 = "0.13.0" 13 | bincode = "1.3.3" 14 | console_error_panic_hook = "0.1.7" 15 | console_log = "0.2.0" 16 | derive_more = "0.99.16" 17 | enum-map = "1.1.1" 18 | enum_dispatch = "0.3.7" 19 | flate2 = "1.0.22" 20 | geo = { version = "0.18.0", features = ["use-serde"] } 21 | itertools = "0.10.1" 22 | js-sys = "0.3.55" 23 | log = "0.4.14" 24 | rustc-hash = "1.1.0" 25 | serde = { version = "1.0.130", features = ["derive"] } 26 | uuid = { version = "0.8.2", features = ["v4", "serde", "wasm-bindgen"] } 27 | wasm-bindgen = "0.2.78" 28 | 29 | [dependencies.web-sys] 30 | version = "0.3.55" 31 | features = [ 32 | "CanvasRenderingContext2d", 33 | "CssStyleDeclaration", 34 | "Document", 35 | "DomMatrix", 36 | "HtmlButtonElement", 37 | "HtmlCanvasElement", 38 | "HtmlDivElement", 39 | "HtmlInputElement", 40 | "HtmlLabelElement", 41 | "KeyboardEvent", 42 | "MouseEvent", 43 | "Path2d", 44 | "Storage", 45 | "WheelEvent", 46 | "Window", 47 | ] 48 | 49 | [profile.release] 50 | codegen-units = 1 51 | lto = true 52 | -------------------------------------------------------------------------------- /client/Makefile: -------------------------------------------------------------------------------- 1 | PROFILE = dev 2 | 3 | build: \ 4 | $(addprefix public/assets/,$(notdir $(wildcard src/static/assets/*))) \ 5 | public/index.html \ 6 | public/main.css \ 7 | public/papirs_client.js \ 8 | public/papirs_client_bg.wasm 9 | 10 | public/assets/%: src/static/assets/% 11 | @mkdir -p public/assets 12 | cp $< $@ 13 | 14 | public/%.html: src/static/%.html 15 | @mkdir -p public 16 | cp $< $@ 17 | 18 | public/%.css: src/static/%.sass 19 | @mkdir -p public 20 | sass --no-source-map $< $@ 21 | 22 | public/papirs_client.js public/papirs_client_bg.wasm: package 23 | package: 24 | @mkdir -p public 25 | wasm-pack build --no-typescript --target web --$(PROFILE) 26 | cp pkg/papirs_client.js pkg/papirs_client_bg.wasm public 27 | 28 | clean: 29 | rm -rf pkg public 30 | cargo clean 31 | 32 | .PHONY: build package clean 33 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Papirs client 2 | 3 | ## Build 4 | 5 | Prerequisites: 6 | 7 | * GNU Make 8 | * [rustup](https://rustup.rs/) 9 | * [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) 10 | * [Sass](https://sass-lang.com/install) 11 | 12 | To build: 13 | 14 | ```sh 15 | make PROFILE=release 16 | ``` 17 | 18 | Output will be in `public/` directory. 19 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/LICENSE: -------------------------------------------------------------------------------- 1 | Pictogrammers Free License 2 | -------------------------- 3 | 4 | This icon collection is released as free, open source, and GPL friendly by 5 | the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it 6 | for commercial projects, open source projects, or anything really. 7 | 8 | # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) 9 | Some of the icons are redistributed under the Apache 2.0 license. All other 10 | icons are either redistributed under their respective licenses or are 11 | distributed under the Apache 2.0 license. 12 | 13 | # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) 14 | All web and desktop fonts are distributed under the Apache 2.0 license. Web 15 | and desktop fonts contain some icons that are redistributed under the Apache 16 | 2.0 license. All other icons are either redistributed under their respective 17 | licenses or are distributed under the Apache 2.0 license. 18 | 19 | # Code: MIT (https://opensource.org/licenses/MIT) 20 | The MIT license applies to all non-font and non-icon files. 21 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/README.md: -------------------------------------------------------------------------------- 1 | [Material Design Icons](https://materialdesignicons.com/), 2 | licensed under the [Apache License 2.0](./LICENSE). 3 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/eraser.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/pencil.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/assets/material-design-icons/selection-ellipse-arrow-inside.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/assets/octicons/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GitHub Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/assets/octicons/README.md: -------------------------------------------------------------------------------- 1 | [Octicons](https://primer.style/octicons/) by GitHub, 2 | licensed under the [MIT License](./LICENSE). 3 | -------------------------------------------------------------------------------- /client/assets/octicons/mark-github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/common.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | utils::{self, MapScalars as _}, 3 | web, 4 | }; 5 | use derive_more::{Add, Neg, Sub}; 6 | use enum_map::Enum; 7 | use geo::{prelude::*, LineString, Rect}; 8 | use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; 9 | use std::mem; 10 | use uuid::Uuid; 11 | 12 | /// A marker that indicates that the wrapped coordinates are the actual screen's ones. 13 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug, Add, Sub, Neg)] 14 | pub struct OnScreen(pub T); 15 | 16 | impl OnScreen { 17 | pub fn map(self, f: impl FnOnce(T) -> U) -> OnScreen { 18 | OnScreen(f(self.0)) 19 | } 20 | } 21 | 22 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] 23 | #[serde(transparent)] 24 | pub struct PathId(Uuid); 25 | 26 | impl PathId { 27 | /// Generates a unique [`PathId`]. 28 | pub fn gen() -> Self { 29 | Self(Uuid::new_v4()) 30 | } 31 | } 32 | 33 | #[derive(Clone, Debug, Serialize, Deserialize)] 34 | pub struct Path { 35 | pub color: Color, 36 | pub coords: LineString, 37 | } 38 | 39 | #[derive(Clone, Debug, Serialize)] 40 | #[serde(transparent)] 41 | pub struct Renderable { 42 | inner: T, 43 | #[serde(skip)] 44 | obj: web::Path, 45 | } 46 | 47 | impl Renderable { 48 | pub fn get(&self) -> &T { 49 | &self.inner 50 | } 51 | 52 | pub fn path_obj(&self) -> &web::Path { 53 | &self.obj 54 | } 55 | } 56 | 57 | #[derive(Clone, Debug, Serialize)] 58 | #[serde(transparent)] 59 | pub struct RenderablePath { 60 | path: Renderable, 61 | #[serde(skip)] 62 | bounding_rect: Renderable>, 63 | } 64 | 65 | impl<'de> Deserialize<'de> for RenderablePath { 66 | fn deserialize(deserializer: D) -> Result 67 | where 68 | D: Deserializer<'de>, 69 | { 70 | let path = Path::deserialize(deserializer)?; 71 | Self::new(path).ok_or_else(|| D::Error::custom("empty path")) 72 | } 73 | } 74 | 75 | impl RenderablePath { 76 | /// Creates a new [`RenderablePath`]. Returns [`None`] when the given path is empty. 77 | pub fn new(path: Path) -> Option { 78 | let bounding_rect = path.coords.bounding_rect()?; 79 | let bounding_rect_ex1 = utils::expand_rect(bounding_rect.map_scalars(f64::from), 4.5); 80 | let bounding_rect_ex2 = utils::expand_rect(bounding_rect, 5); 81 | 82 | Some(Self { 83 | path: Renderable { 84 | obj: (&path.coords).into(), 85 | inner: path, 86 | }, 87 | bounding_rect: Renderable { 88 | obj: bounding_rect_ex1.into(), 89 | inner: bounding_rect_ex2, 90 | }, 91 | }) 92 | } 93 | 94 | pub fn take(&mut self) -> Path { 95 | Path { 96 | coords: LineString(mem::take(&mut self.path.inner.coords.0)), 97 | ..self.path.inner 98 | } 99 | } 100 | 101 | pub fn get(&self) -> &Renderable { 102 | &self.path 103 | } 104 | 105 | pub fn bounding_rect(&self) -> &Renderable> { 106 | &self.bounding_rect 107 | } 108 | } 109 | 110 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Enum, Serialize, Deserialize)] 111 | pub enum Color { 112 | Black, 113 | Red, 114 | Orange, 115 | Green, 116 | Blue, 117 | SkyBlue, 118 | } 119 | 120 | impl Default for Color { 121 | fn default() -> Self { 122 | Self::Black 123 | } 124 | } 125 | 126 | impl Color { 127 | pub fn rgb(self) -> (u8, u8, u8) { 128 | match self { 129 | Self::Black => (0, 0, 0), 130 | Self::Red => (255, 75, 0), 131 | Self::Orange => (246, 170, 0), 132 | Self::Green => (3, 175, 122), 133 | Self::Blue => (0, 90, 255), 134 | Self::SkyBlue => (77, 196, 255), 135 | } 136 | } 137 | } 138 | 139 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Enum, Serialize, Deserialize)] 140 | pub enum Tool { 141 | Selector, 142 | Pen, 143 | Eraser, 144 | } 145 | 146 | impl Default for Tool { 147 | fn default() -> Self { 148 | Self::Pen 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /client/src/ctrl.rs: -------------------------------------------------------------------------------- 1 | //! A controller, which recieves events from the view and manipulates the model. 2 | 3 | use crate::{ 4 | common::{Color, OnScreen, Path, PathId, RenderablePath, Tool}, 5 | model::{self, Model}, 6 | utils::{self, MapScalars}, 7 | web, 8 | }; 9 | use enum_dispatch::enum_dispatch; 10 | use geo::{prelude::*, Coordinate, Line, LineString, Rect}; 11 | use rustc_hash::FxHashSet; 12 | use std::iter; 13 | 14 | #[enum_dispatch] 15 | trait Handler { 16 | fn move_to(&mut self, model: model::DeferCommit, coord: OnScreen>); 17 | fn finish(self, model: model::DeferCommit); 18 | } 19 | 20 | #[enum_dispatch(Handler)] 21 | #[derive(Debug)] 22 | enum AnyHandler { 23 | Scroll(ScrollHandler), 24 | Select(SelectHandler), 25 | Shift(ShiftHandler), 26 | Draw(DrawHandler), 27 | Erase(EraseHandler), 28 | } 29 | 30 | #[derive(Debug)] 31 | struct ScrollHandler { 32 | prev_coord: OnScreen>, 33 | } 34 | 35 | impl ScrollHandler { 36 | pub fn new(coord: OnScreen>) -> Self { 37 | Self { prev_coord: coord } 38 | } 39 | } 40 | 41 | impl Handler for ScrollHandler { 42 | fn move_to(&mut self, mut model: model::DeferCommit, coord: OnScreen>) { 43 | let delta = model.delta_of(coord - self.prev_coord); 44 | model.scroll(delta); 45 | self.prev_coord = coord; 46 | } 47 | 48 | fn finish(self, _: model::DeferCommit) {} 49 | } 50 | 51 | #[derive(Debug)] 52 | struct SelectHandler { 53 | start_coord: Coordinate, 54 | prev_coord: Coordinate, 55 | } 56 | 57 | impl SelectHandler { 58 | pub fn new(model: &Model, coord: OnScreen>) -> Self { 59 | let coord = model.coord_at(coord); 60 | Self { 61 | start_coord: coord, 62 | prev_coord: coord, 63 | } 64 | } 65 | } 66 | 67 | impl Handler for SelectHandler { 68 | fn move_to(&mut self, mut model: model::DeferCommit, coord: OnScreen>) { 69 | let coord = model.coord_at(coord); 70 | let whole_rect = Rect::new(self.start_coord, coord); 71 | let diff = utils::rect_diff(self.start_coord, self.prev_coord, coord); 72 | for removed_rect in diff.removed { 73 | model.unselect_paths_with(removed_rect); 74 | } 75 | for added_rect in diff.added { 76 | model.select_paths_with(whole_rect, added_rect); 77 | } 78 | model.temp_layer().clear(); 79 | model.temp_layer().render_selection_rect(whole_rect); 80 | self.prev_coord = coord; 81 | } 82 | 83 | fn finish(self, model: model::DeferCommit) { 84 | model.temp_layer().clear(); 85 | } 86 | } 87 | 88 | #[derive(Debug)] 89 | struct ShiftHandler { 90 | shifting_path_ids: FxHashSet, 91 | start_coord: Coordinate, 92 | prev_coord: Coordinate, 93 | } 94 | 95 | impl ShiftHandler { 96 | pub fn new(mut model: model::DeferCommit, coord: OnScreen>) -> Self { 97 | let coord = model.coord_at(coord); 98 | let shifting_path_ids = model 99 | .selected_paths() 100 | .map(|(id, _)| id) 101 | .collect::>(); 102 | for &id in &shifting_path_ids { 103 | model.hide_path(id); 104 | } 105 | let this = Self { 106 | shifting_path_ids, 107 | start_coord: coord, 108 | prev_coord: coord, 109 | }; 110 | this.rerender(&*model); 111 | this 112 | } 113 | 114 | fn rerender(&self, model: &Model) { 115 | model.temp_layer().clear(); 116 | for &id in &self.shifting_path_ids { 117 | let path = model.path(id); 118 | model.temp_layer().render_path(path); 119 | model.temp_layer().render_bounding_rect_of(path); 120 | } 121 | } 122 | } 123 | 124 | impl Handler for ShiftHandler { 125 | fn move_to(&mut self, model: model::DeferCommit, coord: OnScreen>) { 126 | let coord = model.coord_at(coord); 127 | let delta = coord - self.prev_coord; 128 | model.temp_layer().translate(delta); 129 | self.rerender(&*model); 130 | self.prev_coord = coord; 131 | } 132 | 133 | fn finish(self, mut model: model::DeferCommit) { 134 | let delta = self.prev_coord - self.start_coord; 135 | model.temp_layer().clear(); 136 | model.temp_layer().translate(-delta); 137 | model.shift_paths(self.shifting_path_ids.iter().copied(), delta); 138 | for id in self.shifting_path_ids { 139 | model.unhide_path(id); 140 | } 141 | } 142 | } 143 | 144 | #[derive(Debug)] 145 | struct DrawHandler { 146 | coords: Vec>, 147 | } 148 | 149 | impl DrawHandler { 150 | pub fn new(model: &Model, coord: OnScreen>) -> Self { 151 | let coord = model.coord_at(coord); 152 | Self { 153 | coords: vec![coord], 154 | } 155 | } 156 | } 157 | 158 | impl Handler for DrawHandler { 159 | fn move_to(&mut self, model: model::DeferCommit, coord: OnScreen>) { 160 | let coord = model.coord_at(coord); 161 | let (start, control, end) = { 162 | let mut it = self.coords.iter().rev(); 163 | let c_0 = coord; 164 | let c_1 = it 165 | .next() 166 | .copied() 167 | .expect("`self.coords` should not be empty"); 168 | let c_2 = it.next().copied().unwrap_or(c_0); 169 | let mid_0_1 = (c_0 + c_1) / 2; 170 | let mid_1_2 = (c_1 + c_2) / 2; 171 | (mid_0_1, c_1, mid_1_2) 172 | }; 173 | model 174 | .temp_layer() 175 | .render_curve(model.pen_color(), start, control, end); 176 | self.coords.push(coord); 177 | } 178 | 179 | fn finish(self, mut model: model::DeferCommit) { 180 | let coords = LineString::from(self.coords) 181 | .map_scalars(f64::from) 182 | .simplify(&0.5) 183 | .map_scalars(|s| s as _); 184 | let path = Path { 185 | color: model.pen_color(), 186 | coords, 187 | }; 188 | let path = RenderablePath::new(path).expect("`path` should not be empty"); 189 | model.temp_layer().clear(); 190 | model.insert_paths(iter::once((PathId::gen(), path))); 191 | } 192 | } 193 | 194 | #[derive(Debug)] 195 | struct EraseHandler { 196 | removing_path_ids: FxHashSet, 197 | prev_coord: Coordinate, 198 | } 199 | 200 | impl EraseHandler { 201 | pub fn new(model: &Model, coord: OnScreen>) -> Self { 202 | let coord = model.coord_at(coord); 203 | Self { 204 | removing_path_ids: FxHashSet::default(), 205 | prev_coord: coord, 206 | } 207 | } 208 | } 209 | 210 | impl Handler for EraseHandler { 211 | fn move_to(&mut self, mut model: model::DeferCommit, coord: OnScreen>) { 212 | let coord = model.coord_at(coord); 213 | let eraser_line = Line::new(self.prev_coord, coord); 214 | let ids = model 215 | .bounding_tile_items(eraser_line) 216 | .filter(|(id, lines)| { 217 | !self.removing_path_ids.contains(id) 218 | && lines.iter().any(|line| line.intersects(&eraser_line)) 219 | }) 220 | .map(|(id, _)| id) 221 | .collect::>(); 222 | for &id in &ids { 223 | model.hide_path(id); 224 | } 225 | self.removing_path_ids.extend(ids.iter().copied()); 226 | self.prev_coord = coord; 227 | } 228 | 229 | fn finish(self, mut model: model::DeferCommit) { 230 | model.remove_paths(self.removing_path_ids); 231 | } 232 | } 233 | 234 | #[derive(Debug)] 235 | pub struct Controller { 236 | active_handler: Option, 237 | model: Model, 238 | } 239 | 240 | impl Controller { 241 | pub fn new(model: Model) -> Self { 242 | Self { 243 | active_handler: None, 244 | model, 245 | } 246 | } 247 | 248 | pub fn rerender(&self) { 249 | self.model.force_rerender(); 250 | } 251 | 252 | pub fn set_tool(&mut self, tool: Tool) { 253 | self.model.defer_commit().set_tool(tool); 254 | } 255 | 256 | pub fn set_pen_color(&mut self, color: Color) { 257 | self.model.defer_commit().set_pen_color(color); 258 | } 259 | 260 | pub fn clear_paths(&mut self) { 261 | self.model.defer_commit().clear_paths(); 262 | } 263 | 264 | pub fn on_key_down(&mut self, event: web::KeyboardEvent) { 265 | let mut model = self.model.defer_commit(); 266 | match event.key.as_str() { 267 | "Delete" => { 268 | model.remove_selected_paths(); 269 | } 270 | "z" if event.ctrl_key => { 271 | model.undo(); 272 | } 273 | "y" if event.ctrl_key => { 274 | model.redo(); 275 | } 276 | _ => {} 277 | } 278 | } 279 | 280 | pub fn on_wheel(&mut self, event: web::WheelEvent) { 281 | let mut model = self.model.defer_commit(); 282 | let delta = model.delta_of(event.delta.map(|d| -d)); 283 | model.scroll(delta); 284 | } 285 | 286 | pub fn on_pointer_down(&mut self, event: web::MouseEvent) { 287 | if self.active_handler.is_some() { 288 | return; 289 | } 290 | let mut model = self.model.defer_commit(); 291 | match event.button { 292 | web::MouseButton::Left => { 293 | if model.selected_paths().any(|(_, path)| { 294 | path.bounding_rect() 295 | .get() 296 | .contains(&model.coord_at(event.coord)) 297 | }) { 298 | self.active_handler = Some(ShiftHandler::new(model, event.coord).into()); 299 | } else { 300 | model.unselect_all_paths(); 301 | self.active_handler = Some(match model.tool() { 302 | Tool::Selector => SelectHandler::new(&*model, event.coord).into(), 303 | Tool::Pen => DrawHandler::new(&*model, event.coord).into(), 304 | Tool::Eraser => EraseHandler::new(&*model, event.coord).into(), 305 | }); 306 | } 307 | } 308 | web::MouseButton::Middle => { 309 | self.active_handler = Some(ScrollHandler::new(event.coord).into()); 310 | } 311 | web::MouseButton::Other => {} 312 | } 313 | } 314 | 315 | pub fn on_pointer_move(&mut self, event: web::MouseEvent) { 316 | if let Some(h) = &mut self.active_handler { 317 | h.move_to(self.model.defer_commit(), event.coord); 318 | } 319 | } 320 | 321 | pub fn on_pointer_up(&mut self) { 322 | if let Some(h) = self.active_handler.take() { 323 | h.finish(self.model.defer_commit()); 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /client/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod ctrl; 3 | mod model; 4 | mod utils; 5 | mod view; 6 | mod web; 7 | 8 | use crate::{ctrl::Controller, model::Model, view::View}; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(start)] 12 | pub fn start() { 13 | console_error_panic_hook::set_once(); 14 | 15 | let log_level = if cfg!(debug_assertions) { 16 | log::Level::Trace 17 | } else { 18 | log::Level::Info 19 | }; 20 | console_log::init_with_level(log_level).expect("failed to initialize log"); 21 | 22 | let storage = web::Storage::local().expect("no local storage"); 23 | let view = View::init(); 24 | let model = Model::load(storage, view.clone()); 25 | let ctrl = Controller::new(model); 26 | 27 | view.listen_events(ctrl); 28 | } 29 | -------------------------------------------------------------------------------- /client/src/model.rs: -------------------------------------------------------------------------------- 1 | //! A model, which manages the application data and requests the view to render objects. 2 | 3 | mod compat; 4 | mod history; 5 | mod recorder; 6 | mod tiling; 7 | 8 | use self::{history::History, recorder::Recorder, tiling::Tiling}; 9 | use crate::{ 10 | common::{Color, OnScreen, PathId, RenderablePath, Tool}, 11 | utils, 12 | view::{Layer, LayerHandle, View}, 13 | web, 14 | }; 15 | use derive_more::{Deref, DerefMut}; 16 | use geo::{prelude::*, Coordinate, Line, Rect}; 17 | use rustc_hash::{FxHashMap, FxHashSet}; 18 | use serde::{Deserialize, Serialize}; 19 | use std::mem; 20 | 21 | #[derive(Debug)] 22 | enum Command { 23 | Insert { 24 | path_ids: Vec, 25 | }, 26 | Shift { 27 | path_ids: Vec, 28 | delta: Coordinate, 29 | }, 30 | Remove { 31 | paths: Vec<(PathId, RenderablePath)>, 32 | }, 33 | } 34 | 35 | #[derive(Debug)] 36 | pub struct Model { 37 | paths: Recorder>, 38 | tiling: Tiling, 39 | history: History, 40 | 41 | selected_path_ids: Recorder>, 42 | hidden_path_ids: Recorder>, 43 | offset: Recorder>, 44 | tool: Recorder, 45 | pen_color: Recorder, 46 | 47 | storage: web::Storage, 48 | view: View, 49 | } 50 | 51 | impl Model { 52 | fn load_field(storage: &web::Storage, key: &str) -> T 53 | where 54 | T: for<'de> Deserialize<'de> + Default, 55 | { 56 | storage 57 | .get(&format!("papirs:{}", key)) 58 | .transpose() 59 | .unwrap_or_else(|err| { 60 | log::error!("`{}` found in storage but failed to load: {}", key, err); 61 | None 62 | }) 63 | .unwrap_or_default() 64 | } 65 | 66 | fn save_field(storage: &web::Storage, key: &str, value: &T) 67 | where 68 | T: Serialize, 69 | { 70 | if let Err(err) = storage.set(&format!("papirs:{}", key), value) { 71 | log::error!("failed to save `{}`: {}", key, err); 72 | } 73 | } 74 | 75 | pub fn load(storage: web::Storage, mut view: View) -> Self { 76 | macro_rules! load { 77 | ($field:ident) => { 78 | Self::load_field(&storage, stringify!($field)) 79 | }; 80 | } 81 | 82 | let old_data = compat::Data::load_and_remove(&storage); 83 | let needs_to_save = old_data.is_some(); 84 | 85 | let (paths, offset, tool, pen_color) = old_data.map_or_else( 86 | || (load!(paths), load!(offset), load!(tool), load!(pen_color)), 87 | |data| (data.paths, data.offset, data.tool, data.pen_color), 88 | ); 89 | let tiling = (paths.get().iter()) 90 | .map(|(&id, path)| (id, &path.get().get().coords)) 91 | .collect(); 92 | 93 | view.translate(*offset.get()); 94 | for path in paths.get().values() { 95 | view.layers[Layer::Main].render_path(path); 96 | } 97 | view.select_tool(*tool.get()); 98 | view.select_pen_color(*pen_color.get()); 99 | 100 | let model = Self { 101 | paths, 102 | tiling, 103 | history: Default::default(), 104 | 105 | selected_path_ids: Default::default(), 106 | hidden_path_ids: Default::default(), 107 | offset, 108 | tool, 109 | pen_color, 110 | 111 | storage, 112 | view, 113 | }; 114 | if needs_to_save { 115 | model.force_save(); 116 | } 117 | model 118 | } 119 | 120 | fn force_save(&self) { 121 | macro_rules! save { 122 | ($field:ident) => { 123 | Self::save_field(&self.storage, stringify!($field), &self.$field); 124 | }; 125 | } 126 | save!(paths); 127 | save!(offset); 128 | save!(tool); 129 | save!(pen_color); 130 | } 131 | 132 | fn save(&self) { 133 | macro_rules! save { 134 | ($field:ident) => { 135 | if self.$field.is_updated() { 136 | Self::save_field(&self.storage, stringify!($field), &self.$field); 137 | } 138 | }; 139 | } 140 | save!(paths); 141 | save!(offset); 142 | save!(tool); 143 | save!(pen_color); 144 | } 145 | 146 | pub fn bounding_tile_items( 147 | &self, 148 | geo: impl BoundingRect> + Intersects>, 149 | ) -> impl Iterator])> { 150 | self.tiling.bounding_tile_items(geo) 151 | } 152 | 153 | pub fn path(&self, id: PathId) -> &RenderablePath { 154 | self.paths.get().get(&id).expect("path not found") 155 | } 156 | 157 | pub fn contains_path(&self, id: PathId) -> bool { 158 | self.paths.get().contains_key(&id) 159 | } 160 | 161 | pub fn insert_paths(&mut self, paths: impl IntoIterator) { 162 | let paths = paths.into_iter(); 163 | self.paths.update(|p| { 164 | p.reserve(paths.size_hint().0); 165 | false 166 | }); 167 | let ids = paths 168 | .map(|(id, path)| { 169 | self.tiling.insert_path(id, &path.get().get().coords); 170 | let old = self.paths.get_mut().insert(id, path); 171 | assert!(old.is_none(), "path already exists"); 172 | id 173 | }) 174 | .collect(); 175 | self.history.push(Command::Insert { path_ids: ids }); 176 | } 177 | 178 | pub fn shift_paths(&mut self, ids: impl IntoIterator, delta: Coordinate) { 179 | let ids = (ids.into_iter()) 180 | .map(|id| { 181 | let path = self.paths.get_mut().get_mut(&id).expect("path not found"); 182 | *path = { 183 | let mut path = path.take(); 184 | path.coords.translate_inplace(delta.x, delta.y); 185 | RenderablePath::new(path).expect("`path` should not be empty") 186 | }; 187 | self.tiling.remove_path(id); 188 | self.tiling.insert_path(id, &path.get().get().coords); 189 | id 190 | }) 191 | .collect(); 192 | self.history.push(Command::Shift { 193 | path_ids: ids, 194 | delta, 195 | }); 196 | } 197 | 198 | pub fn remove_paths(&mut self, ids: impl IntoIterator) { 199 | let paths = (ids.into_iter()) 200 | .map(|id| { 201 | self.tiling.remove_path(id); 202 | self.selected_path_ids.update(|s| s.remove(&id)); 203 | self.hidden_path_ids.update(|h| h.remove(&id)); 204 | let path = self.paths.get_mut().remove(&id).expect("path not found"); 205 | (id, path) 206 | }) 207 | .collect(); 208 | self.history.push(Command::Remove { paths }); 209 | } 210 | 211 | pub fn remove_selected_paths(&mut self) { 212 | if self.selected_path_ids.get().is_empty() { 213 | return; 214 | } 215 | let mut ids = mem::take(self.selected_path_ids.get_mut()); 216 | self.remove_paths(ids.drain()); 217 | *self.selected_path_ids.get_mut() = ids; // restore capacity 218 | } 219 | 220 | pub fn clear_paths(&mut self) { 221 | if self.paths.get().is_empty() { 222 | return; 223 | } 224 | let paths = self.paths.get_mut().drain().collect(); 225 | self.tiling.clear(); 226 | self.selected_path_ids.get_mut().clear(); 227 | self.hidden_path_ids.get_mut().clear(); 228 | self.history.push(Command::Remove { paths }); 229 | } 230 | 231 | pub fn selected_paths(&self) -> impl Iterator { 232 | self.selected_path_ids 233 | .get() 234 | .iter() 235 | .map(move |&id| (id, self.paths.get().get(&id).expect("path not found"))) 236 | } 237 | 238 | /// Select paths which intersect `rect`, contained by `whole_rect`. 239 | pub fn select_paths_with(&mut self, whole_rect: Rect, rect: Rect) { 240 | let ids = (self.tiling.bounding_tile_items(rect)) 241 | .filter(|&(id, _)| { 242 | let path = self.paths.get().get(&id).expect("path not found"); 243 | let coords = &path.get().get().coords; 244 | whole_rect.contains(&coords.bounding_rect().expect("empty path")) 245 | }) 246 | .map(|(id, _)| id); 247 | self.selected_path_ids.update(|s| { 248 | let prev_len = s.len(); 249 | s.extend(ids); 250 | s.len() != prev_len 251 | }); 252 | } 253 | 254 | /// Unselect paths which intersect `rect`. 255 | pub fn unselect_paths_with(&mut self, rect: Rect) { 256 | let ids = (self.tiling.bounding_tile_items(rect)) 257 | .filter(|(_, lines)| lines.iter().any(|line| line.intersects(&rect))) 258 | .map(|(id, _)| id); 259 | for id in ids { 260 | self.selected_path_ids.update(|s| s.remove(&id)); 261 | } 262 | } 263 | 264 | pub fn unselect_all_paths(&mut self) { 265 | if self.selected_path_ids.get().is_empty() { 266 | return; 267 | } 268 | self.selected_path_ids.get_mut().clear(); 269 | } 270 | 271 | pub fn hide_path(&mut self, id: PathId) { 272 | assert!(self.contains_path(id), "path not found"); 273 | self.hidden_path_ids.update(|h| h.insert(id)); 274 | } 275 | 276 | pub fn unhide_path(&mut self, id: PathId) { 277 | assert!(self.contains_path(id), "path not found"); 278 | self.hidden_path_ids.update(|h| h.remove(&id)); 279 | } 280 | 281 | fn rollback(&mut self, com: Command) { 282 | match com { 283 | Command::Insert { path_ids } => { 284 | self.remove_paths(path_ids); 285 | } 286 | Command::Shift { path_ids, delta } => { 287 | self.shift_paths(path_ids, -delta); 288 | } 289 | Command::Remove { paths } => { 290 | self.insert_paths(paths); 291 | } 292 | } 293 | } 294 | 295 | pub fn undo(&mut self) { 296 | if let Some(com) = self.history.start_undo() { 297 | self.rollback(com); 298 | } 299 | } 300 | 301 | pub fn redo(&mut self) { 302 | if let Some(com) = self.history.start_redo() { 303 | self.rollback(com); 304 | } 305 | } 306 | 307 | pub fn coord_at(&self, coord: OnScreen>) -> Coordinate { 308 | coord.0 - *self.offset.get() 309 | } 310 | 311 | pub fn delta_of(&self, delta: OnScreen>) -> Coordinate { 312 | // consider scaling in the future 313 | delta.0 314 | } 315 | 316 | fn board_rect(&self) -> Rect { 317 | let origin = OnScreen(Coordinate::zero()); 318 | let diagonal = self 319 | .view 320 | .size() 321 | .map(|size| utils::coord_map_scalars(size, |s| s as i32)); 322 | Rect::new(self.coord_at(origin), self.coord_at(diagonal)) 323 | } 324 | 325 | pub fn scroll(&mut self, delta: Coordinate) { 326 | *self.offset.get_mut() = *self.offset.get() + delta; 327 | self.view.translate(delta); 328 | } 329 | 330 | pub fn tool(&self) -> Tool { 331 | *self.tool.get() 332 | } 333 | 334 | pub fn set_tool(&mut self, tool: Tool) { 335 | *self.tool.get_mut() = tool; 336 | if tool != Tool::Selector { 337 | self.unselect_all_paths(); 338 | } 339 | self.view.select_tool(tool); 340 | } 341 | 342 | pub fn pen_color(&self) -> Color { 343 | *self.pen_color.get() 344 | } 345 | 346 | pub fn set_pen_color(&mut self, color: Color) { 347 | *self.pen_color.get_mut() = color; 348 | self.view.select_pen_color(color); 349 | } 350 | 351 | fn rerender_main_layer(&self) { 352 | self.view.layers[Layer::Main].clear(); 353 | let ids = self 354 | .tiling 355 | .bounding_tile_items(self.board_rect()) 356 | .map(|(id, _)| id) 357 | .filter(|id| !self.hidden_path_ids.get().contains(id)) 358 | .collect::>(); // remove duplicates to prevent double rendering 359 | for id in ids { 360 | let path = self.paths.get().get(&id).expect("path not found"); 361 | self.view.layers[Layer::Main].render_path(path); 362 | } 363 | } 364 | 365 | fn rerender_sub_layer(&self) { 366 | self.view.layers[Layer::Sub].clear(); 367 | for (_, path) in self 368 | .selected_paths() 369 | .filter(|(id, _)| !self.hidden_path_ids.get().contains(id)) 370 | { 371 | self.view.layers[Layer::Sub].render_bounding_rect_of(path); 372 | } 373 | } 374 | 375 | pub fn force_rerender(&self) { 376 | self.rerender_main_layer(); 377 | self.rerender_sub_layer(); 378 | } 379 | 380 | fn rerender(&mut self) { 381 | if self.paths.is_updated() || self.offset.is_updated() || self.hidden_path_ids.is_updated() 382 | { 383 | self.rerender_main_layer(); 384 | } 385 | if self.paths.is_updated() 386 | || self.offset.is_updated() 387 | || self.selected_path_ids.is_updated() 388 | || self.hidden_path_ids.is_updated() 389 | { 390 | self.rerender_sub_layer(); 391 | } 392 | } 393 | 394 | /// Performs rerendering and saves the current state to the storage. 395 | pub fn commit(&mut self) { 396 | self.rerender(); 397 | self.save(); 398 | 399 | self.paths.resolve(); 400 | self.offset.resolve(); 401 | self.tool.resolve(); 402 | self.pen_color.resolve(); 403 | self.selected_path_ids.resolve(); 404 | self.hidden_path_ids.resolve(); 405 | } 406 | 407 | /// Returns a wrapper struct that triggers [`commit`](Self::commit) on scope exit. 408 | pub fn defer_commit(&mut self) -> DeferCommit<'_> { 409 | DeferCommit(self) 410 | } 411 | 412 | /// Lend a layer to the controller for temporary use. 413 | /// 414 | /// The model properly handles scrolling on this layer, 415 | /// but will not perform any (re)rendering on this. 416 | pub fn temp_layer(&self) -> &LayerHandle { 417 | &self.view.layers[Layer::Temp] 418 | } 419 | } 420 | 421 | #[derive(Debug, Deref, DerefMut)] 422 | #[deref(forward)] 423 | pub struct DeferCommit<'a>(&'a mut Model); 424 | 425 | impl Drop for DeferCommit<'_> { 426 | fn drop(&mut self) { 427 | self.0.commit(); 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /client/src/model/compat.rs: -------------------------------------------------------------------------------- 1 | //! Old format data. 2 | 3 | use super::Recorder; 4 | use crate::{ 5 | common::{Color, PathId, RenderablePath, Tool}, 6 | web, 7 | }; 8 | use geo::Coordinate; 9 | use rustc_hash::FxHashMap; 10 | use serde::Deserialize; 11 | 12 | #[derive(Debug, Deserialize)] 13 | pub(super) struct Data { 14 | pub paths: Recorder>, 15 | pub offset: Recorder>, 16 | pub tool: Recorder, 17 | pub pen_color: Recorder, 18 | } 19 | 20 | impl Data { 21 | pub fn load_and_remove(storage: &web::Storage) -> Option { 22 | const KEY: &str = "papirs"; 23 | let data = storage.get(KEY).transpose().unwrap_or_else(|err| { 24 | log::error!("data found in storage but failed to load: {}", err); 25 | None 26 | })?; 27 | storage.remove(KEY); 28 | Some(data) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/model/history.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] 2 | enum State { 3 | Undoing, 4 | Redoing, 5 | } 6 | 7 | #[derive(Debug)] 8 | pub(super) struct History { 9 | undo_stack: Vec, 10 | redo_stack: Vec, 11 | state: Option, 12 | } 13 | 14 | impl Default for History { 15 | fn default() -> Self { 16 | Self { 17 | undo_stack: vec![], 18 | redo_stack: vec![], 19 | state: None, 20 | } 21 | } 22 | } 23 | 24 | impl History { 25 | pub fn push(&mut self, com: C) { 26 | match self.state { 27 | None => { 28 | self.undo_stack.push(com); 29 | self.redo_stack.clear(); 30 | } 31 | Some(State::Undoing) => { 32 | self.redo_stack.push(com); 33 | self.state = None; 34 | } 35 | Some(State::Redoing) => { 36 | self.undo_stack.push(com); 37 | self.state = None; 38 | } 39 | } 40 | } 41 | 42 | pub fn start_undo(&mut self) -> Option { 43 | if let Some(state) = self.state { 44 | panic!("previous operation not finished: {:?}", state); 45 | } 46 | let com = self.undo_stack.pop(); 47 | if com.is_some() { 48 | self.state = Some(State::Undoing); 49 | } 50 | com 51 | } 52 | 53 | pub fn start_redo(&mut self) -> Option { 54 | if let Some(state) = self.state { 55 | panic!("previous operation not finished: {:?}", state); 56 | } 57 | let com = self.redo_stack.pop(); 58 | if com.is_some() { 59 | self.state = Some(State::Redoing); 60 | } 61 | com 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/model/recorder.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// A wrapper struct that provides modification detection. 4 | #[derive(Clone, Copy, Default, Debug, Serialize, Deserialize)] 5 | #[serde(transparent)] 6 | pub(super) struct Recorder { 7 | inner: T, 8 | #[serde(skip)] 9 | is_updated: bool, 10 | } 11 | 12 | impl Recorder { 13 | pub fn get(&self) -> &T { 14 | &self.inner 15 | } 16 | 17 | pub fn get_mut(&mut self) -> &mut T { 18 | self.is_updated = true; 19 | &mut self.inner 20 | } 21 | 22 | /// Updates the wrapped value by the given function `f`. 23 | /// This marks the value as updated iff `f` returns `true`. 24 | pub fn update(&mut self, f: impl FnOnce(&mut T) -> bool) { 25 | self.is_updated |= f(&mut self.inner); 26 | } 27 | 28 | pub fn is_updated(&self) -> bool { 29 | self.is_updated 30 | } 31 | 32 | pub fn resolve(&mut self) { 33 | self.is_updated = false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/model/tiling.rs: -------------------------------------------------------------------------------- 1 | use crate::{common::PathId, utils}; 2 | use geo::{prelude::*, Coordinate, Line, LineString, Rect}; 3 | use itertools::Itertools as _; 4 | use rustc_hash::{FxHashMap, FxHashSet}; 5 | use std::iter::FromIterator; 6 | 7 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] 8 | struct TileId(Coordinate); 9 | 10 | #[derive(Default, Debug)] 11 | pub(super) struct Tiling { 12 | tiles: FxHashMap>>>, 13 | tile_ids: FxHashMap>, 14 | } 15 | 16 | impl<'a> FromIterator<(PathId, &'a LineString)> for Tiling { 17 | fn from_iter(iter: I) -> Self 18 | where 19 | I: IntoIterator)>, 20 | { 21 | let mut this = Self::default(); 22 | for (path_id, coords) in iter { 23 | this.insert_path(path_id, coords); 24 | } 25 | this 26 | } 27 | } 28 | 29 | impl Tiling { 30 | const TILE_LEN: i32 = 128; 31 | 32 | fn tile_rect(id: TileId) -> Rect { 33 | Rect::new( 34 | utils::coord_map_scalars(id.0, |s| s * Self::TILE_LEN), 35 | utils::coord_map_scalars(id.0, |s| (s + 1) * Self::TILE_LEN), 36 | ) 37 | } 38 | 39 | fn bounding_tile_ids( 40 | geo: impl BoundingRect> + Intersects>, 41 | ) -> impl Iterator { 42 | let rect = geo.bounding_rect(); 43 | let xs = rect.min().x.div_euclid(Self::TILE_LEN)..=rect.max().x.div_euclid(Self::TILE_LEN); 44 | let ys = rect.min().y.div_euclid(Self::TILE_LEN)..=rect.max().y.div_euclid(Self::TILE_LEN); 45 | xs.cartesian_product(ys) 46 | .map(|(x, y)| TileId(Coordinate { x, y })) 47 | .filter(move |&id| geo.intersects(&Self::tile_rect(id))) 48 | } 49 | 50 | pub fn bounding_tile_items( 51 | &self, 52 | geo: impl BoundingRect> + Intersects>, 53 | ) -> impl Iterator])> { 54 | Self::bounding_tile_ids(geo) 55 | .flat_map(move |tile_id| self.tiles.get(&tile_id).into_iter().flatten()) 56 | .map(|(&path_id, lines)| (path_id, lines.as_slice())) 57 | } 58 | 59 | pub fn insert_path(&mut self, path_id: PathId, coords: &LineString) { 60 | let mut tile_ids = FxHashSet::default(); 61 | for line in coords.lines() { 62 | for tile_id in Self::bounding_tile_ids(line) { 63 | self.tiles 64 | .entry(tile_id) 65 | .or_default() 66 | .entry(path_id) 67 | .or_default() 68 | .push(line); 69 | tile_ids.insert(tile_id); 70 | } 71 | } 72 | let old = self 73 | .tile_ids 74 | .insert(path_id, tile_ids.into_iter().collect()); 75 | assert!(old.is_none(), "path already exists"); 76 | } 77 | 78 | pub fn remove_path(&mut self, path_id: PathId) { 79 | let tile_ids = self.tile_ids.remove(&path_id).expect("path not found"); 80 | for tile_id in tile_ids { 81 | self.tiles 82 | .get_mut(&tile_id) 83 | .expect("tile not found") 84 | .retain(|id, _| id != &path_id); 85 | } 86 | } 87 | 88 | pub fn clear(&mut self) { 89 | self.tiles.clear(); 90 | self.tile_ids.clear(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /client/src/static/assets/clear.svg: -------------------------------------------------------------------------------- 1 | ../../../assets/material-design-icons/close.svg -------------------------------------------------------------------------------- /client/src/static/assets/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/src/static/assets/eraser.svg: -------------------------------------------------------------------------------- 1 | ../../../assets/material-design-icons/eraser.svg -------------------------------------------------------------------------------- /client/src/static/assets/github.svg: -------------------------------------------------------------------------------- 1 | ../../../assets/octicons/mark-github.svg -------------------------------------------------------------------------------- /client/src/static/assets/pen.svg: -------------------------------------------------------------------------------- 1 | ../../../assets/material-design-icons/pencil.svg -------------------------------------------------------------------------------- /client/src/static/assets/selector.svg: -------------------------------------------------------------------------------- 1 | ../../../assets/material-design-icons/selection-ellipse-arrow-inside.svg -------------------------------------------------------------------------------- /client/src/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Papirs 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /client/src/static/main.sass: -------------------------------------------------------------------------------- 1 | @mixin circle($size) 2 | border-radius: 50% 3 | width: $size 4 | height: $size 5 | 6 | @mixin button($size) 7 | @include circle($size) 8 | background-color: white 9 | padding: 0 10 | border: none 11 | filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2)) 12 | 13 | @mixin img-fill 14 | height: 100% 15 | width: 100% 16 | padding: 8px 17 | 18 | @mixin vertical-list 19 | display: flex 20 | flex-direction: column 21 | row-gap: 8px 22 | 23 | html, 24 | body, 25 | #board 26 | height: 100% 27 | width: 100% 28 | margin: 0 29 | overflow: hidden 30 | 31 | #board 32 | background: 33 | color: white 34 | image: url('assets/cross.svg') 35 | size: 20px 36 | 37 | & > canvas 38 | position: absolute 39 | 40 | #controller 41 | @include vertical-list 42 | position: absolute 43 | top: 18px 44 | left: 18px 45 | 46 | img 47 | @include img-fill 48 | 49 | input[type="radio"] 50 | display: none 51 | 52 | & + label 53 | @include button(40px) 54 | 55 | img 56 | transition: filter 0.4s ease-out 57 | 58 | &:checked + label 59 | background-color: black 60 | 61 | img 62 | filter: invert(1) 63 | 64 | button 65 | @include button(40px) 66 | 67 | #tool-pen-radio:checked ~ #pen-colors 68 | visibility: visible 69 | opacity: 1 70 | 71 | #pen-colors 72 | @include vertical-list 73 | position: absolute 74 | top: 50px 75 | left: 50px 76 | transition: visibility 0.1s ease-out, opacity 0.1s ease-out 77 | visibility: hidden 78 | opacity: 0 79 | 80 | input[type="radio"] 81 | display: none 82 | 83 | & + label 84 | @include button(22px) 85 | transition: border-width 0.1s ease-out 86 | border: 87 | color: white 88 | style: solid 89 | width: 6px 90 | 91 | &:checked + label 92 | border-width: 0 93 | 94 | --black: rgb(0, 0, 0) 95 | --red: rgb(255, 75, 0) 96 | --orange: rgb(246, 170, 0) 97 | --green: rgb(3, 175, 122) 98 | --blue: rgb(0, 90, 255) 99 | --sky-blue: rgb(77, 196, 255) 100 | 101 | @each $color in "black", "red", "orange", "green", "blue", "sky-blue" 102 | #pen-color-#{$color}-radio + label 103 | background-color: var(--#{$color}) 104 | 105 | #info 106 | @include vertical-list 107 | position: absolute 108 | bottom: 18px 109 | left: 18px 110 | 111 | img 112 | @include img-fill 113 | 114 | a 115 | @include button(40px) 116 | -------------------------------------------------------------------------------- /client/src/utils.rs: -------------------------------------------------------------------------------- 1 | use arrayvec::ArrayVec; 2 | use geo::{map_coords::MapCoordsInplace as _, prelude::*, CoordNum, Coordinate, Rect}; 3 | use std::cmp::Ordering; 4 | 5 | pub fn coord_map_scalars(coord: Coordinate, mut f: impl FnMut(T) -> U) -> Coordinate 6 | where 7 | T: CoordNum, 8 | U: CoordNum, 9 | { 10 | Coordinate { 11 | x: f(coord.x), 12 | y: f(coord.y), 13 | } 14 | } 15 | 16 | pub trait MapScalars { 17 | type Output; 18 | fn map_scalars(&self, f: impl FnMut(T) -> U + Copy) -> Self::Output; 19 | } 20 | 21 | impl MapScalars for G 22 | where 23 | G: MapCoords, 24 | T: CoordNum, 25 | U: CoordNum, 26 | { 27 | type Output = G::Output; 28 | fn map_scalars(&self, f: impl FnMut(T) -> U + Copy) -> Self::Output { 29 | self.map_coords(|&c| coord_map_scalars(c.into(), f).x_y()) 30 | } 31 | } 32 | 33 | pub fn expand_rect(rect: Rect, delta: T) -> Rect 34 | where 35 | T: CoordNum, 36 | { 37 | let delta = Coordinate { x: delta, y: delta }; 38 | Rect::new(rect.min() - delta, rect.max() + delta) 39 | } 40 | 41 | #[derive(Debug)] 42 | pub struct RectDiff { 43 | pub removed: ArrayVec, 2>, 44 | pub added: ArrayVec, 2>, 45 | } 46 | 47 | /// Returns the "difference" between two rectangles with one common vertex. 48 | /// 49 | /// Let _R₁_ and _R₂_ are rectangles which have least one common vertex. 50 | /// A "removed part" _R₁_ \ _R₂_ and an "added part" _R₂_ \ _R₁_ can be represented as 51 | /// an union of at most two rectangles, respectively, and they are what this function returns. 52 | pub fn rect_diff( 53 | mut origin: Coordinate, 54 | mut old_diag: Coordinate, 55 | mut new_diag: Coordinate, 56 | ) -> RectDiff { 57 | let mut removed = ArrayVec::new(); 58 | let mut added = ArrayVec::new(); 59 | 60 | let flip_x = old_diag.x < origin.x; 61 | if flip_x { 62 | origin.x *= -1; 63 | old_diag.x *= -1; 64 | new_diag.x *= -1; 65 | } 66 | let flip_y = old_diag.y < origin.y; 67 | if flip_y { 68 | origin.y *= -1; 69 | old_diag.y *= -1; 70 | new_diag.y *= -1; 71 | } 72 | debug_assert!(origin.x <= old_diag.x); 73 | debug_assert!(origin.y <= old_diag.y); 74 | 75 | if new_diag.x <= origin.x || new_diag.y <= origin.y { 76 | removed.push(Rect::new(origin, old_diag)); 77 | added.push(Rect::new(origin, new_diag)); 78 | } else { 79 | match old_diag.x.cmp(&new_diag.x) { 80 | Ordering::Less => { 81 | added.push(Rect::new( 82 | Coordinate { 83 | x: old_diag.x, 84 | y: origin.y, 85 | }, 86 | new_diag, 87 | )); 88 | } 89 | Ordering::Equal => {} 90 | Ordering::Greater => { 91 | removed.push(Rect::new( 92 | Coordinate { 93 | x: new_diag.x, 94 | y: origin.y, 95 | }, 96 | old_diag, 97 | )); 98 | } 99 | } 100 | match old_diag.y.cmp(&new_diag.y) { 101 | Ordering::Less => { 102 | added.push(Rect::new( 103 | Coordinate { 104 | x: origin.x, 105 | y: old_diag.y, 106 | }, 107 | new_diag, 108 | )); 109 | } 110 | Ordering::Equal => {} 111 | Ordering::Greater => { 112 | removed.push(Rect::new( 113 | Coordinate { 114 | x: origin.x, 115 | y: new_diag.y, 116 | }, 117 | old_diag, 118 | )); 119 | } 120 | } 121 | } 122 | 123 | for rect in removed.iter_mut().chain(added.iter_mut()) { 124 | rect.map_coords_inplace(|&(x, y)| { 125 | (if flip_x { -x } else { x }, if flip_y { -y } else { y }) 126 | }); 127 | } 128 | 129 | RectDiff { removed, added } 130 | } 131 | -------------------------------------------------------------------------------- /client/src/view.rs: -------------------------------------------------------------------------------- 1 | //! A view, which renders objects and notifies the controller of recieved user events. 2 | 3 | use crate::{ 4 | common::{Color, OnScreen, RenderablePath, Tool}, 5 | ctrl::Controller, 6 | web, 7 | }; 8 | use enum_map::{enum_map, Enum, EnumMap}; 9 | use geo::{Coordinate, Rect}; 10 | use std::{cell::RefCell, rc::Rc}; 11 | use wasm_bindgen::prelude::*; 12 | 13 | fn adjust_canvas_size<'a>( 14 | board: &web_sys::HtmlDivElement, 15 | canvases: impl IntoIterator, 16 | ) { 17 | let size = OnScreen(Coordinate { 18 | x: board.client_width() as u32, 19 | y: board.client_height() as u32, 20 | }); 21 | for canvas in canvases { 22 | canvas.resize(size); 23 | } 24 | } 25 | 26 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Enum)] 27 | pub enum Layer { 28 | Main, 29 | Sub, 30 | Temp, 31 | } 32 | 33 | #[derive(Clone, Debug)] 34 | pub struct LayerHandle { 35 | canvas: web::Canvas, 36 | } 37 | 38 | impl LayerHandle { 39 | fn new(canvas: web::Canvas) -> Self { 40 | Self { canvas } 41 | } 42 | 43 | pub fn translate(&self, delta: Coordinate) { 44 | self.canvas.translate(delta); 45 | } 46 | 47 | pub fn render_path(&self, path: &RenderablePath) { 48 | self.set_style_for_path(); 49 | self.canvas.set_stroke_color(path.get().get().color); 50 | self.canvas.stroke_path_obj(path.get().path_obj()); 51 | } 52 | 53 | pub fn render_bounding_rect_of(&self, path: &RenderablePath) { 54 | self.set_style_for_bounding_rect(); 55 | self.canvas.stroke_path_obj(path.bounding_rect().path_obj()); 56 | } 57 | 58 | pub fn render_curve( 59 | &self, 60 | color: Color, 61 | start: Coordinate, 62 | control: Coordinate, 63 | end: Coordinate, 64 | ) { 65 | self.set_style_for_path(); 66 | self.canvas.set_stroke_color(color); 67 | self.canvas.stroke_curve(start, control, end); 68 | } 69 | 70 | pub fn render_selection_rect(&self, rect: Rect) { 71 | self.set_style_for_selection_rect(); 72 | self.canvas.fill_rect(rect); 73 | } 74 | 75 | pub fn clear(&self) { 76 | self.canvas.clear(); 77 | } 78 | 79 | fn set_style_for_path(&self) { 80 | self.canvas 81 | .ctx 82 | .set_line_dash(&js_sys::Array::new()) 83 | .expect("unexpected exception"); 84 | self.canvas.ctx.set_line_cap("round"); 85 | self.canvas.ctx.set_line_join("round"); 86 | self.canvas.ctx.set_line_width(2.0); 87 | } 88 | 89 | fn set_style_for_selection_rect(&self) { 90 | thread_local! { 91 | static FILL_STYLE: JsValue = JsValue::from_str("rgba(0,90,255,0.15)"); 92 | } 93 | FILL_STYLE.with(|val| self.canvas.ctx.set_fill_style(val)); 94 | } 95 | 96 | fn set_style_for_bounding_rect(&self) { 97 | thread_local! { 98 | static LINE_DASH: js_sys::Array = 99 | js_sys::Array::of2(&JsValue::from_f64(8.), &JsValue::from_f64(6.)); 100 | } 101 | LINE_DASH.with(|val| { 102 | self.canvas 103 | .ctx 104 | .set_line_dash(val) 105 | .expect("unexpected exception"); 106 | }); 107 | self.canvas.ctx.set_line_cap("butt"); 108 | self.canvas.ctx.set_line_join("butt"); 109 | self.canvas.ctx.set_line_width(1.0); 110 | self.canvas.set_stroke_color(Color::Black); 111 | } 112 | } 113 | 114 | #[derive(Clone, Debug)] 115 | pub struct View { 116 | board: web_sys::HtmlDivElement, 117 | pub layers: EnumMap, 118 | offset: Coordinate, 119 | 120 | tool_radios: EnumMap, 121 | tool_radio_labels: EnumMap, 122 | pen_color_radios: EnumMap, 123 | pen_color_radio_labels: EnumMap, 124 | clear_button: web_sys::HtmlButtonElement, 125 | } 126 | 127 | impl View { 128 | pub fn init() -> Self { 129 | web::bind_elements! { 130 | let board; 131 | let main_canvas: web_sys::HtmlCanvasElement; 132 | let sub_canvas: web_sys::HtmlCanvasElement; 133 | let temp_canvas: web_sys::HtmlCanvasElement; 134 | 135 | let tool_selector_radio: web_sys::HtmlInputElement; 136 | let tool_pen_radio: web_sys::HtmlInputElement; 137 | let tool_eraser_radio: web_sys::HtmlInputElement; 138 | 139 | let tool_selector_radio_label: web_sys::HtmlLabelElement; 140 | let tool_pen_radio_label: web_sys::HtmlLabelElement; 141 | let tool_eraser_radio_label: web_sys::HtmlLabelElement; 142 | 143 | let pen_color_black_radio: web_sys::HtmlInputElement; 144 | let pen_color_red_radio: web_sys::HtmlInputElement; 145 | let pen_color_orange_radio: web_sys::HtmlInputElement; 146 | let pen_color_green_radio: web_sys::HtmlInputElement; 147 | let pen_color_blue_radio: web_sys::HtmlInputElement; 148 | let pen_color_sky_blue_radio: web_sys::HtmlInputElement; 149 | 150 | let pen_color_black_radio_label: web_sys::HtmlLabelElement; 151 | let pen_color_red_radio_label: web_sys::HtmlLabelElement; 152 | let pen_color_orange_radio_label: web_sys::HtmlLabelElement; 153 | let pen_color_green_radio_label: web_sys::HtmlLabelElement; 154 | let pen_color_blue_radio_label: web_sys::HtmlLabelElement; 155 | let pen_color_sky_blue_radio_label: web_sys::HtmlLabelElement; 156 | 157 | let clear_button; 158 | } 159 | 160 | let main_canvas = web::Canvas::from(main_canvas); 161 | let sub_canvas = web::Canvas::from(sub_canvas); 162 | let temp_canvas = web::Canvas::from(temp_canvas); 163 | 164 | adjust_canvas_size(&board, [&main_canvas, &sub_canvas, &temp_canvas]); 165 | 166 | Self { 167 | board, 168 | layers: enum_map! { 169 | Layer::Main => LayerHandle::new(main_canvas.clone()), 170 | Layer::Sub => LayerHandle::new(sub_canvas.clone()), 171 | Layer::Temp => LayerHandle::new(temp_canvas.clone()), 172 | }, 173 | offset: Coordinate::zero(), 174 | 175 | tool_radios: enum_map! { 176 | Tool::Selector => tool_selector_radio.clone(), 177 | Tool::Pen => tool_pen_radio.clone(), 178 | Tool::Eraser => tool_eraser_radio.clone(), 179 | }, 180 | tool_radio_labels: enum_map! { 181 | Tool::Selector => tool_selector_radio_label.clone(), 182 | Tool::Pen => tool_pen_radio_label.clone(), 183 | Tool::Eraser => tool_eraser_radio_label.clone(), 184 | }, 185 | pen_color_radios: enum_map! { 186 | Color::Black => pen_color_black_radio.clone(), 187 | Color::Red => pen_color_red_radio.clone(), 188 | Color::Orange => pen_color_orange_radio.clone(), 189 | Color::Green => pen_color_green_radio.clone(), 190 | Color::Blue => pen_color_blue_radio.clone(), 191 | Color::SkyBlue => pen_color_sky_blue_radio.clone(), 192 | }, 193 | pen_color_radio_labels: enum_map! { 194 | Color::Black => pen_color_black_radio_label.clone(), 195 | Color::Red => pen_color_red_radio_label.clone(), 196 | Color::Orange => pen_color_orange_radio_label.clone(), 197 | Color::Green => pen_color_green_radio_label.clone(), 198 | Color::Blue => pen_color_blue_radio_label.clone(), 199 | Color::SkyBlue => pen_color_sky_blue_radio_label.clone(), 200 | }, 201 | clear_button, 202 | } 203 | } 204 | 205 | pub fn listen_events(self, ctrl: Controller) { 206 | let ctrl = Rc::new(RefCell::new(ctrl)); 207 | 208 | web::WINDOW.with({ 209 | let board = self.board.clone(); 210 | let layers = self.layers.clone(); 211 | let ctrl = Rc::clone(&ctrl); 212 | move |window| { 213 | web::listen_event(window, "resize", move |_: web_sys::UiEvent| { 214 | adjust_canvas_size(&board, layers.values().map(|l| &l.canvas)); 215 | ctrl.borrow().rerender(); 216 | }); 217 | } 218 | }); 219 | 220 | web::DOCUMENT.with({ 221 | let ctrl = Rc::clone(&ctrl); 222 | move |document| { 223 | web::listen_event(document, "keydown", move |event: web_sys::KeyboardEvent| { 224 | ctrl.borrow_mut().on_key_down(event.into()) 225 | }); 226 | } 227 | }); 228 | 229 | // Uses pointerdown instead of click for more sensitive response. 230 | for (tool, label) in &self.tool_radio_labels { 231 | web::listen_event(label, "pointerdown", { 232 | let ctrl = Rc::clone(&ctrl); 233 | move |_: web_sys::MouseEvent| ctrl.borrow_mut().set_tool(tool) 234 | }); 235 | } 236 | 237 | for (color, label) in &self.pen_color_radio_labels { 238 | web::listen_event(label, "pointerdown", { 239 | let ctrl = Rc::clone(&ctrl); 240 | move |_: web_sys::MouseEvent| ctrl.borrow_mut().set_pen_color(color) 241 | }); 242 | } 243 | 244 | web::listen_event(&self.clear_button, "pointerdown", { 245 | let ctrl = Rc::clone(&ctrl); 246 | move |_: web_sys::MouseEvent| ctrl.borrow_mut().clear_paths() 247 | }); 248 | 249 | web::listen_event(&self.board, "wheel", { 250 | let ctrl = Rc::clone(&ctrl); 251 | let this = self.clone(); 252 | move |event: web_sys::WheelEvent| { 253 | ctrl.borrow_mut() 254 | .on_wheel(web::WheelEvent::new(event, this.size())) 255 | } 256 | }); 257 | 258 | web::listen_event(&self.board, "pointerdown", { 259 | let ctrl = Rc::clone(&ctrl); 260 | move |event: web_sys::MouseEvent| ctrl.borrow_mut().on_pointer_down(event.into()) 261 | }); 262 | 263 | web::listen_event(&self.board, "pointermove", { 264 | let ctrl = Rc::clone(&ctrl); 265 | move |event: web_sys::MouseEvent| ctrl.borrow_mut().on_pointer_move(event.into()) 266 | }); 267 | 268 | web::listen_event(&self.board, "pointerup", { 269 | let ctrl = Rc::clone(&ctrl); 270 | move |_: web_sys::MouseEvent| ctrl.borrow_mut().on_pointer_up() 271 | }); 272 | } 273 | 274 | pub fn size(&self) -> OnScreen> { 275 | self.layers[Layer::Main].canvas.size() 276 | } 277 | 278 | pub fn translate(&mut self, delta: Coordinate) { 279 | for layer in self.layers.values() { 280 | layer.translate(delta); 281 | } 282 | self.offset = self.offset + delta; 283 | self.board 284 | .style() 285 | .set_property( 286 | "background-position", 287 | &format!("{}px {}px", self.offset.x, self.offset.y), 288 | ) 289 | .expect("unexpected exception"); 290 | } 291 | 292 | pub fn select_tool(&self, tool: Tool) { 293 | self.tool_radios[tool].set_checked(true); 294 | } 295 | 296 | pub fn select_pen_color(&self, color: Color) { 297 | self.pen_color_radios[color].set_checked(true); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /client/src/web.rs: -------------------------------------------------------------------------------- 1 | //! Web API wrappers. 2 | 3 | use crate::{ 4 | common::{Color, OnScreen}, 5 | utils, 6 | }; 7 | use anyhow::{anyhow, Result}; 8 | use geo::{CoordNum, Coordinate, LineString, Rect}; 9 | use itertools::Itertools as _; 10 | use serde::{Deserialize, Serialize}; 11 | use std::{cell::RefCell, mem}; 12 | use wasm_bindgen::{convert::FromWasmAbi, prelude::*, JsCast as _}; 13 | 14 | thread_local! { 15 | pub static WINDOW: web_sys::Window = web_sys::window().expect("no window"); 16 | pub static DOCUMENT: web_sys::Document = WINDOW.with(|w| w.document().expect("no document")); 17 | } 18 | 19 | macro_rules! bind_elements { 20 | ($(let $id:ident $(: $ty:ty)?;)*) => {$( 21 | let id = std::stringify!($id).replace('_', "-"); 22 | let $id $(: $ty)? = wasm_bindgen::JsCast::dyn_into(crate::web::DOCUMENT.with(|d| { 23 | d.get_element_by_id(&id) 24 | .unwrap_or_else(|| std::panic!("no element '{}' found", id)) 25 | })) 26 | .expect("element type mismatch"); 27 | )*}; 28 | } 29 | pub(crate) use bind_elements; 30 | 31 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] 32 | pub enum MouseButton { 33 | Left, 34 | Middle, 35 | Other, 36 | } 37 | 38 | #[derive(Clone, Debug)] 39 | pub struct MouseEvent { 40 | pub button: MouseButton, 41 | pub coord: OnScreen>, 42 | } 43 | 44 | impl From for MouseEvent { 45 | fn from(event: web_sys::MouseEvent) -> Self { 46 | Self { 47 | button: match event.button() { 48 | 0 => MouseButton::Left, 49 | 1 => MouseButton::Middle, 50 | _ => MouseButton::Other, 51 | }, 52 | coord: OnScreen(Coordinate { 53 | x: event.offset_x(), 54 | y: event.offset_y(), 55 | }), 56 | } 57 | } 58 | } 59 | 60 | #[derive(Clone, Debug)] 61 | pub struct WheelEvent { 62 | pub delta: OnScreen>, 63 | } 64 | 65 | impl WheelEvent { 66 | pub fn new(event: web_sys::WheelEvent, page_size: OnScreen>) -> Self { 67 | let coef = match event.delta_mode() { 68 | web_sys::WheelEvent::DOM_DELTA_PIXEL => Coordinate { x: 1., y: 1. }, 69 | web_sys::WheelEvent::DOM_DELTA_LINE => Coordinate { x: 25., y: 25. }, 70 | web_sys::WheelEvent::DOM_DELTA_PAGE => utils::coord_map_scalars(page_size.0, f64::from), 71 | m => unreachable!("invaild `deltaMode`: {}", m), 72 | }; 73 | let mut delta = OnScreen(Coordinate { 74 | x: (event.delta_x() * coef.x) as _, 75 | y: (event.delta_y() * coef.y) as _, 76 | }); 77 | if event.shift_key() { 78 | mem::swap(&mut delta.0.x, &mut delta.0.y); 79 | } 80 | Self { delta } 81 | } 82 | } 83 | 84 | #[derive(Clone, Debug)] 85 | pub struct KeyboardEvent { 86 | pub key: String, 87 | pub ctrl_key: bool, 88 | } 89 | 90 | impl From for KeyboardEvent { 91 | fn from(event: web_sys::KeyboardEvent) -> Self { 92 | Self { 93 | key: event.key(), 94 | ctrl_key: event.ctrl_key(), 95 | } 96 | } 97 | } 98 | 99 | pub fn listen_event( 100 | target: impl AsRef, 101 | event: &str, 102 | callback: impl FnMut(E) + 'static, 103 | ) where 104 | E: FromWasmAbi + 'static, 105 | { 106 | let callback = Closure::wrap(Box::new(callback) as Box) 107 | .into_js_value() 108 | .unchecked_into(); 109 | target 110 | .as_ref() 111 | .add_event_listener_with_callback(event, &callback) 112 | .expect("unexpected exception"); 113 | } 114 | 115 | #[derive(Clone, Debug)] 116 | pub struct Path(web_sys::Path2d); 117 | 118 | impl From<&'_ LineString> for Path 119 | where 120 | T: CoordNum, 121 | f64: From, 122 | { 123 | fn from(coords: &LineString) -> Self { 124 | let obj = web_sys::Path2d::new().expect("unexpected exception"); 125 | for pair in coords.0.iter().copied().tuple_windows().with_position() { 126 | use itertools::Position; 127 | match pair { 128 | Position::Only((c_0, c_1)) => { 129 | obj.move_to(c_0.x.into(), c_0.y.into()); 130 | obj.line_to(c_1.x.into(), c_1.y.into()); 131 | } 132 | Position::First((c_0, c_1)) => { 133 | let c_0 = utils::coord_map_scalars(c_0, f64::from); 134 | let c_1 = utils::coord_map_scalars(c_1, f64::from); 135 | let mid = (c_0 + c_1) / 2.; 136 | obj.move_to(c_0.x, c_0.y); 137 | obj.line_to(mid.x, mid.y); 138 | } 139 | Position::Middle((c_0, c_1)) => { 140 | let c_0 = utils::coord_map_scalars(c_0, f64::from); 141 | let c_1 = utils::coord_map_scalars(c_1, f64::from); 142 | let mid = (c_0 + c_1) / 2.; 143 | obj.quadratic_curve_to(c_0.x, c_0.y, mid.x, mid.y); 144 | } 145 | Position::Last((_, c_1)) => { 146 | obj.line_to(c_1.x.into(), c_1.y.into()); 147 | } 148 | } 149 | } 150 | Self(obj) 151 | } 152 | } 153 | 154 | impl From> for Path 155 | where 156 | T: CoordNum, 157 | f64: From, 158 | { 159 | fn from(rect: Rect) -> Self { 160 | let obj = web_sys::Path2d::new().expect("unexpected exception"); 161 | obj.rect( 162 | rect.min().x.into(), 163 | rect.min().y.into(), 164 | rect.width().into(), 165 | rect.height().into(), 166 | ); 167 | Self(obj) 168 | } 169 | } 170 | 171 | #[derive(Clone, Debug)] 172 | pub struct Canvas { 173 | pub ctx: web_sys::CanvasRenderingContext2d, 174 | } 175 | 176 | impl From for Canvas { 177 | fn from(canvas: web_sys::HtmlCanvasElement) -> Self { 178 | let ctx = canvas 179 | .get_context("2d") 180 | .expect("unexpected exception") 181 | .expect("could not get rendering context") 182 | .dyn_into::() 183 | .expect("failed to cast"); 184 | Self { ctx } 185 | } 186 | } 187 | 188 | impl Canvas { 189 | fn canvas(&self) -> web_sys::HtmlCanvasElement { 190 | self.ctx.canvas().expect("no canvas object associated") 191 | } 192 | 193 | pub fn size(&self) -> OnScreen> { 194 | let canvas = self.canvas(); 195 | OnScreen(Coordinate { 196 | x: canvas.width(), 197 | y: canvas.height(), 198 | }) 199 | } 200 | 201 | pub fn resize(&self, size: OnScreen>) { 202 | let canvas = self.canvas(); 203 | let mat = self.ctx.get_transform().expect("unexpected exception"); 204 | canvas.set_width(size.0.x); 205 | canvas.set_height(size.0.y); 206 | self.ctx 207 | .set_transform(mat.a(), mat.b(), mat.c(), mat.d(), mat.e(), mat.f()) 208 | .expect("unexpected exception"); 209 | } 210 | 211 | pub fn translate(&self, delta: Coordinate) { 212 | self.ctx 213 | .translate(delta.x.into(), delta.y.into()) 214 | .expect("unexpected exception"); 215 | } 216 | 217 | pub fn set_stroke_color(&self, color: Color) { 218 | let (r, g, b) = color.rgb(); 219 | let style = JsValue::from_str(&format!("rgb({},{},{})", r, g, b)); 220 | self.ctx.set_stroke_style(&style); 221 | } 222 | 223 | pub fn stroke_path_obj(&self, path: &Path) { 224 | self.ctx.stroke_with_path(&path.0); 225 | } 226 | 227 | pub fn stroke_curve( 228 | &self, 229 | start: Coordinate, 230 | control: Coordinate, 231 | end: Coordinate, 232 | ) { 233 | self.ctx.begin_path(); 234 | self.ctx.move_to(start.x.into(), start.y.into()); 235 | self.ctx.quadratic_curve_to( 236 | control.x.into(), 237 | control.y.into(), 238 | end.x.into(), 239 | end.y.into(), 240 | ); 241 | self.ctx.stroke(); 242 | } 243 | 244 | pub fn fill_rect(&self, rect: Rect) { 245 | self.ctx.fill_rect( 246 | rect.min().x.into(), 247 | rect.min().y.into(), 248 | rect.width().into(), 249 | rect.height().into(), 250 | ); 251 | } 252 | 253 | pub fn clear(&self) { 254 | let canvas = self.canvas(); 255 | let mat = self.ctx.get_transform().expect("unexpected exception"); 256 | self.ctx.clear_rect( 257 | -mat.e(), 258 | -mat.f(), 259 | canvas.width().into(), 260 | canvas.height().into(), 261 | ); 262 | } 263 | } 264 | 265 | #[derive(Clone, Debug)] 266 | pub struct Storage(web_sys::Storage); 267 | 268 | impl Storage { 269 | pub fn local() -> Option { 270 | let storage = WINDOW 271 | .with(|w| w.local_storage()) 272 | .expect("unexpected exception")?; 273 | Some(Self(storage)) 274 | } 275 | 276 | pub fn get(&self, key: &str) -> Option> 277 | where 278 | T: for<'de> Deserialize<'de>, 279 | { 280 | self.0 281 | .get_item(key) 282 | .expect("unexpected exception") 283 | .map(|s| Ok(Self::load_from(&s)?)) 284 | } 285 | 286 | pub fn set(&self, key: &str, val: &T) -> Result<()> 287 | where 288 | T: Serialize, 289 | { 290 | thread_local! { 291 | static BUF: RefCell = RefCell::new(String::new()); 292 | } 293 | BUF.with(|s| { 294 | let s = &mut *s.borrow_mut(); 295 | s.clear(); 296 | Self::save_to(val, s)?; 297 | self.0 298 | .set_item(key, s) 299 | .map_err(|e| anyhow!("exception (the storage is full?): {:?}", e)) 300 | }) 301 | } 302 | 303 | pub fn remove(&self, key: &str) { 304 | self.0.remove_item(key).expect("unexpected exception"); 305 | } 306 | 307 | fn load_from(s: &str) -> bincode::Result 308 | where 309 | T: for<'de> Deserialize<'de>, 310 | { 311 | let mut bytes = s.as_bytes(); 312 | let base64 = base64::read::DecoderReader::new(&mut bytes, base64::STANDARD_NO_PAD); 313 | let deflate = flate2::read::DeflateDecoder::new(base64); 314 | bincode::deserialize_from(deflate) 315 | } 316 | 317 | fn save_to(val: &T, s: &mut String) -> bincode::Result<()> 318 | where 319 | T: Serialize, 320 | { 321 | let mut base64 = base64::write::EncoderStringWriter::from(s, base64::STANDARD_NO_PAD); 322 | let deflate = flate2::write::DeflateEncoder::new(&mut base64, flate2::Compression::fast()); 323 | bincode::serialize_into(deflate, val)?; 324 | base64.into_inner(); 325 | Ok(()) 326 | } 327 | } 328 | --------------------------------------------------------------------------------