mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
1003 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aaad03270 | |||
| 9252683260 | |||
| f5748eb5e7 | |||
| 0555fd2caa | |||
| d885252fb0 | |||
| df9b0bcf91 | |||
| 31910f1628 | |||
| 14cb3a36c1 | |||
| d3350c20b6 | |||
| 174acfac32 | |||
| 9ef4e2b437 | |||
| 8088da8cf3 | |||
| 221ee48429 | |||
| ffe8c046ef | |||
| dbe8a10702 | |||
| 11f7e3a060 | |||
| 0f41a04e49 | |||
| d055021ad4 | |||
| 810dc5a061 | |||
| 7abe375b3e | |||
| 63371d8f5b | |||
| 6e61df0729 | |||
| 5ddc4000d0 | |||
| 48b61559cc | |||
| 0cac5cfe51 | |||
| e9ce80a3aa | |||
| 07d488c826 | |||
| e2bd03494d | |||
| ead55442e0 | |||
| 449dc55aaa | |||
| e312b264a6 | |||
| 68dcb4de5f | |||
| d2b9a5c03f | |||
| 1b023fb6d7 | |||
| afe4553a7e | |||
| 139e2e29d7 | |||
| 638418432a | |||
| c6d2467fae | |||
| e9387db895 | |||
| 065d04ec98 | |||
| 869fc086d6 | |||
| 59c24aba7c | |||
| bc4806ac30 | |||
| 169ad5b025 | |||
| 0b33b5bc05 | |||
| 109efcaa27 | |||
| 2cc6d7add8 | |||
| 003d82fe8a | |||
| f75a07cb0d | |||
| 0d6720e499 | |||
| a97a1df5f1 | |||
| 822395c265 | |||
| 96d6e9b85e | |||
| 70e1194f90 | |||
| 710fcc697c | |||
| 58f9e95d2f | |||
| bc128359ab | |||
| af09713c8c | |||
| 35052ef38f | |||
| ec3adc6d1c | |||
| 67981a351e | |||
| 24448c7504 | |||
| d5b5d4fc27 | |||
| d8603cc961 | |||
| 943a290b83 | |||
| f4a4f034cf | |||
| a53934a5c9 | |||
| e859e3b9e0 | |||
| b51d818db3 | |||
| bfea742650 | |||
| 9951cbf194 | |||
| 5cb04d7ac1 | |||
| 75561079eb | |||
| d2c7f3f166 | |||
| a077766bff | |||
| a25e03d7cd | |||
| 2953d09ee1 | |||
| 28d15b1573 | |||
| 18108cb359 | |||
| 5dfa6a71a4 | |||
| 35270cd104 | |||
| 19d01ed575 | |||
| 2b7639c903 | |||
| c8269ca134 | |||
| da9fc5bfdf | |||
| efcfda8398 | |||
| ce2f69342c | |||
| 9be5597f4b | |||
| 26f6961c82 | |||
| bf5f83e97d | |||
| 8db0260a1a | |||
| edbae275ae | |||
| f43deb7940 | |||
| 2a3b9e2104 | |||
| 64c3ff8d6b | |||
| 4f7e7ec853 | |||
| d864e228e7 | |||
| f3ea02fdd0 | |||
| ed096013e4 | |||
| 2e376b32ec | |||
| f352b35e13 | |||
| 0f8d503df8 | |||
| 5010b08e83 | |||
| 933bbdfb84 | |||
| d25a9d56dc | |||
| 1d8c3f3faf | |||
| d7766280a9 | |||
| ae5eff2914 | |||
| b444874944 | |||
| 20efa82ad9 | |||
| bd9d4b3d0d | |||
| 4b4f4fd188 | |||
| 33815639f2 | |||
| 05e24df226 | |||
| d2d9164fa1 | |||
| f2eb395e8d | |||
| 133cb56cca | |||
| e752dba566 | |||
| c1a141d99f | |||
| ccedea55d6 | |||
| c929f83813 | |||
| 1b25d12e2e | |||
| 0c254285a1 | |||
| 8b274c3713 | |||
| 5a20a35c8b | |||
| 72635e98e6 | |||
| 7d55b7f69b | |||
| ca1ba277ad | |||
| 32562886aa | |||
| 7f07cb57a2 | |||
| 62dd1e41f9 | |||
| 446a9ade8c | |||
| a281b1e5be | |||
| 1b5600b025 | |||
| f0e513542d | |||
| 4797a5fc77 | |||
| f05035c0b7 | |||
| 75cfbd8970 | |||
| c4837f1943 | |||
| 0b3166dab5 | |||
| 603f7962a2 | |||
| 69d16dd1d4 | |||
| e2ffe06221 | |||
| 6e2ea3ac4b | |||
| f4c4a11277 | |||
| c28dc08f6a | |||
| 4ef82d5476 | |||
| 3487eb7857 | |||
| 87020348ce | |||
| 672ffacc5b | |||
| 218b0ea76a | |||
| 47ff6feaee | |||
| 8ac5a574f3 | |||
| aed76c7bcb | |||
| 7f9cd51f37 | |||
| 092b29ee63 | |||
| ee7e1959c9 | |||
| 092d9dce18 | |||
| 9274005cbb | |||
| 400a1c87bb | |||
| 93abbab44a | |||
| 18cf148bd1 | |||
| e0b33ee576 | |||
| 82749ffbd8 | |||
| 231aab6ef8 | |||
| f8077e2125 | |||
| 9d9435cce5 | |||
| 5deec264bc | |||
| 48c87a1902 | |||
| 06a3258b99 | |||
| bd2837250b | |||
| e1adb16c43 | |||
| d914ecb603 | |||
| 187be4737e | |||
| 870b91f17a | |||
| 6db92c9f49 | |||
| c41e6e0423 | |||
| 9f8e7be755 | |||
| cead37051e | |||
| fd99da96af | |||
| c526adf292 | |||
| ee5ae140c3 | |||
| 083ac0d840 | |||
| fbaaa08ec7 | |||
| b536c682a2 | |||
| c94823dd59 | |||
| 1a60f51460 | |||
| abf91a3a51 | |||
| 383806d155 | |||
| 7413e8bf7a | |||
| 7c1aa7622a | |||
| f94efaada8 | |||
| 283a762a9c | |||
| cef687464a | |||
| 8287355261 | |||
| 02d33267cc | |||
| b964bdbe90 | |||
| c832265e8a | |||
| 8819a0836e | |||
| 9338a54fe0 | |||
| a0e73bf4c2 | |||
| 8a0263093b | |||
| 9d8e99400f | |||
| 98b5350c65 | |||
| 3bd1c1f047 | |||
| 9712b8d205 | |||
| 597c09d2bc | |||
| d0606a72c3 | |||
| 0deecfac44 | |||
| f534203cbd | |||
| f521543b0a | |||
| 7da0d7589e | |||
| 09dea295a2 | |||
| d3773dc943 | |||
| 5db43f2607 | |||
| 4b3ddf8769 | |||
| 7a6ed86c95 | |||
| f0be9beeb4 | |||
| 4851f51d8b | |||
| e611979a8d | |||
| 05af318a1d | |||
| 142303b3de | |||
| 6c451a34d4 | |||
| fa4f1846ec | |||
| 4baf5ce99a | |||
| 533ec3bd9c | |||
| 572127b830 | |||
| f0afa67012 | |||
| dac2d43f55 | |||
| d06ec5ce0c | |||
| 148affb52e | |||
| afba1edae4 | |||
| f8c53f8a88 | |||
| 0b86714984 | |||
| 3e7acc377e | |||
| 4cb48e7310 | |||
| 8daef8ebce | |||
| 760e2b2ce9 | |||
| 902e7a4772 | |||
| 3b0b37628e | |||
| c973436870 | |||
| 41fb2826b3 | |||
| c15cbd06a4 | |||
| 8fbd4a7463 | |||
| e18d0fdc77 | |||
| 5a20f6322f | |||
| 0556e2a32c | |||
| 908b457dec | |||
| 71ed0844b9 | |||
| 394be7ba74 | |||
| 8225a924c1 | |||
| 8e2b19dc7a | |||
| 064d8cea44 | |||
| 241d557c90 | |||
| 8e5a5a57a9 | |||
| 22230c25e5 | |||
| 5b78cb8963 | |||
| f231c664e6 | |||
| 6d14dd5028 | |||
| f776741e77 | |||
| aa61c37442 | |||
| e38f4996ae | |||
| cd3035a692 | |||
| 0ccbc6126b | |||
| 9214be5645 | |||
| c1c316b379 | |||
| 7bbddcaebf | |||
| 9c66a14fec | |||
| 321e5c9cbd | |||
| 146e4da73b | |||
| 541e4ebe37 | |||
| c7aa261863 | |||
| c484d15de6 | |||
| 93e4ad8c5e | |||
| 85e70e579a | |||
| 10e038be5b | |||
| 76365e8560 | |||
| 595cb9cda5 | |||
| b6fd9f4211 | |||
| 98dda567c2 | |||
| c20282de06 | |||
| d995f27736 | |||
| 6bf2069fa7 | |||
| adf323713e | |||
| 293c3b7b9c | |||
| 949dd296b4 | |||
| ca0de9fb5a | |||
| 89b87c5268 | |||
| 4511fb7259 | |||
| 6b378bf78f | |||
| 671fa9cc84 | |||
| c360d7cd8c | |||
| 6d8216c54e | |||
| 5731ff34a4 | |||
| cbd9ff2dd9 | |||
| 504aa9f7bb | |||
| a2a7f5ef5c | |||
| 9427f06031 | |||
| dd11bb9079 | |||
| 828ce086cc | |||
| 682151554b | |||
| 3ea79dd31a | |||
| e404955394 | |||
| 14f6e6abad | |||
| f06097d9e8 | |||
| dce0c4ac73 | |||
| ae6d24c343 | |||
| 3c794ec5b6 | |||
| f7a9152ee3 | |||
| 77153a2529 | |||
| 2e61bb7d88 | |||
| 9ef9c75c6b | |||
| 98cd93c99c | |||
| 3a8cd2bb35 | |||
| e72608938a | |||
| e024b1b042 | |||
| 3099eaaf12 | |||
| b4d6c70b29 | |||
| 4080216fe3 | |||
| 1b415ac19a | |||
| a9f851897d | |||
| db94a95001 | |||
| 2d2ad83469 | |||
| b95e1cdef3 | |||
| 66197a967a | |||
| 5263d8a315 | |||
| 97c8bfc27f | |||
| b093acd94f | |||
| 21af0bd8be | |||
| 766a52f10e | |||
| 84c1cfea14 | |||
| 01f672fac9 | |||
| 5eb384b2c8 | |||
| 8ea1323a7c | |||
| 00d5b58850 | |||
| 65b8fb40f3 | |||
| ec4d4fb20f | |||
| fc201663c6 | |||
| d4347b6f4b | |||
| 2d913e3766 | |||
| e33aaec469 | |||
| 24231053af | |||
| 927e0b3fb8 | |||
| b0ceae5af0 | |||
| 6fde4e2ec5 | |||
| b42e9737b6 | |||
| 4164fc178c | |||
| b1a1d24f9c | |||
| d46530a4a0 | |||
| 3f7d4f7873 | |||
| b20d41a047 | |||
| 1797a0e90c | |||
| dd5e30f414 | |||
| 690299ac6b | |||
| 7ff0a1d820 | |||
| 3292d95d8b | |||
| 18658e354a | |||
| 02f2868d06 | |||
| 4ea4bd41cd | |||
| 593aa80abf | |||
| f43643f43b | |||
| e2453b5b2a | |||
| 439ae1e832 | |||
| 228c0c45e7 | |||
| 6520a501e3 | |||
| 140f009b4d | |||
| 579eaf325b | |||
| b56f8e7870 | |||
| 79f8a41b5b | |||
| abf5b79de6 | |||
| c60295fcca | |||
| 5bd2409e39 | |||
| 780c5c1129 | |||
| b98c908568 | |||
| f1e8633623 | |||
| 39de7c0aee | |||
| 8b942cf202 | |||
| ccfad1d800 | |||
| 347015cf86 | |||
| d96afad6c0 | |||
| d066468bc0 | |||
| b6dd55fbea | |||
| 8f2d31876d | |||
| 8e76c4e8f1 | |||
| 468fd792ed | |||
| c1bef2db59 | |||
| 53cc69a413 | |||
| 7e62b3b9aa | |||
| 54565fff74 | |||
| e2b28dfeb7 | |||
| cf18b952a4 | |||
| 929722e1c1 | |||
| c0ec349ad2 | |||
| 4d2eda6750 | |||
| db08263e5c | |||
| 33320ba13d | |||
| 4dad3f3287 | |||
| f87b561685 | |||
| d024d31f66 | |||
| 7b2eea0009 | |||
| 2ce11365ab | |||
| e936aa82c9 | |||
| a26ae119fe | |||
| 789a1acea1 | |||
| dd95c9cba9 | |||
| ae1cf2d00c | |||
| 978eda3ad2 | |||
| 0f028812e1 | |||
| c18e4cd43e | |||
| 7f10fe728f | |||
| 5c99116898 | |||
| 38a67b1f9e | |||
| fb4f6822a4 | |||
| 75b03fdba2 | |||
| 60a31992d0 | |||
| be09b6a3bd | |||
| 92a18159b5 | |||
| 7c01904cec | |||
| c9b86ec2e7 | |||
| 466ba6ec1f | |||
| 9cbc9aaad6 | |||
| 5ec2c3c7a0 | |||
| a8d8fecd15 | |||
| be09ffea7b | |||
| 18d104218e | |||
| a515631e21 | |||
| 28954a19af | |||
| b3f847a371 | |||
| c9da515d4c | |||
| 3d805d5fe7 | |||
| 7db0be0a6a | |||
| 091e542406 | |||
| 599e5c8f5d | |||
| 14b746c676 | |||
| 3e3db7435f | |||
| 2ef1d3f95c | |||
| 2cfdf7043b | |||
| 8ac47074fe | |||
| beb3a80d3d | |||
| dea6085a11 | |||
| ccc0906b0a | |||
| eb3a1dd673 | |||
| b12f15de52 | |||
| e1e4c006d2 | |||
| d3abbcf9d5 | |||
| 34f011d99f | |||
| 232216193f | |||
| 5a6b9caabc | |||
| ce675a7fe2 | |||
| 3d7eb11a49 | |||
| 8200e36b89 | |||
| 1ed257de62 | |||
| f0de382367 | |||
| 5f8956e5c6 | |||
| b93824915d | |||
| 7aea6458ce | |||
| 424af9e72c | |||
| c9c5e43389 | |||
| 46ad1feb96 | |||
| 8faed5de6f | |||
| 5c39287a59 | |||
| b9e48e86c1 | |||
| d39260c5c7 | |||
| d5192acabf | |||
| 1c8e074662 | |||
| 4bea33eae0 | |||
| c278172290 | |||
| fefb9d0c13 | |||
| c865c57f92 | |||
| f406faf08e | |||
| 0a8a685c12 | |||
| 32f83311f6 | |||
| 595adeb55f | |||
| 0079593446 | |||
| ebd9535cb4 | |||
| 318ad18894 | |||
| e9e21f280e | |||
| 86b2dbf5c8 | |||
| 7dfe8785a2 | |||
| 4448ea8e4b | |||
| cb0da79be3 | |||
| 18a5cd8765 | |||
| ad51ac28b1 | |||
| c6ae18503a | |||
| 2db8cdc7d1 | |||
| 8942c7afe8 | |||
| 402638ca54 | |||
| d5f6311bbc | |||
| 3eb67eaecf | |||
| bc918b7bf5 | |||
| 6d03257cc1 | |||
| a35a047cc2 | |||
| da7fdfef0a | |||
| a256eba856 | |||
| 8f276731ed | |||
| eb638ba68d | |||
| b54583f438 | |||
| 8ba27762d1 | |||
| d552d1e34d | |||
| 56a6db7d2a | |||
| f491029c21 | |||
| 0bc6662366 | |||
| 5b34a4f076 | |||
| 77f28584d4 | |||
| 07a941a65d | |||
| 2ab35e23f3 | |||
| 4a571a088e | |||
| 0c1bf1586d | |||
| c1256c61aa | |||
| 57e051d62b | |||
| a3ca3447d1 | |||
| 763f57a3dc | |||
| 16066c0b24 | |||
| 705938e622 | |||
| d668bd5646 | |||
| 135d035eb5 | |||
| ea3e81acc4 | |||
| 1fc5578349 | |||
| e33d447a0d | |||
| bf685c7703 | |||
| 642c11ff7d | |||
| 76957865bb | |||
| 1883e77d5c | |||
| 20a54bd2e9 | |||
| 76bb6c4341 | |||
| 49e5748a4f | |||
| 52a029a657 | |||
| 1ef528bbd7 | |||
| f80c3c6877 | |||
| a7d49e9042 | |||
| 3fcfae257f | |||
| 3ef507c137 | |||
| 12ea37e71e | |||
| aba3d25700 | |||
| 7b7ec52eee | |||
| 680a9245bd | |||
| 0c2d9f2f9c | |||
| 59c82f1f06 | |||
| 6445da33db | |||
| a2749a752a | |||
| 38f4e6b9a2 | |||
| fc7c485ba9 | |||
| f75783c2f1 | |||
| f11bba6b63 | |||
| 24bf3766bf | |||
| e3cb7f9055 | |||
| 3db1a6679a | |||
| 222f164247 | |||
| d91f4045c9 | |||
| 8b639682ff | |||
| 67ed017122 | |||
| f1c14f943e | |||
| 18aa3f3787 | |||
| 4a90c57dfe | |||
| 565a0006c9 | |||
| 78ee921244 | |||
| 78606e892a | |||
| f705da4f3b | |||
| fa38ab60eb | |||
| ad2e869dea | |||
| b4796e5b35 | |||
| e3b105d1c0 | |||
| 1d93acefeb | |||
| 19fc99944a | |||
| e283d15d7e | |||
| fbd311d352 | |||
| 15bc0e7629 | |||
| 8af7c3c264 | |||
| 0b1a4c6184 | |||
| 346ea4df89 | |||
| fdb49cf153 | |||
| 188f70b676 | |||
| 721332e87a | |||
| be85e8a6e0 | |||
| 945e7ffb7b | |||
| b597226eeb | |||
| c2e58898d8 | |||
| 476bab9333 | |||
| 1a6a7d04e5 | |||
| a2434988b4 | |||
| 70d30e31b9 | |||
| b46db25553 | |||
| e7e94cdef2 | |||
| 0ce50781d7 | |||
| 1768a1921d | |||
| 39a61d8559 | |||
| 4f4e55d120 | |||
| 5525730272 | |||
| babcf4a3f3 | |||
| 9360cd0014 | |||
| 2d48eb46a9 | |||
| 258b5464a2 | |||
| bfd32843ff | |||
| 72d8abe069 | |||
| 77a8f54973 | |||
| 13501b6d76 | |||
| d21dd710bb | |||
| 0be5aef1c6 | |||
| 4015b19484 | |||
| d081b64ce2 | |||
| 8d3dc3a92e | |||
| ef583314e0 | |||
| 394adf97f8 | |||
| 5ba1522ada | |||
| 6c055810ad | |||
| 3a260037cd | |||
| a7c669f90b | |||
| 449f4f7a26 | |||
| 6d769a738d | |||
| f82a3fa32b | |||
| 6fb51eb7bb | |||
| 23b227c352 | |||
| c54c3d963e | |||
| 4ba10fc5f7 | |||
| e6fd7276fc | |||
| c78bf3c4bf | |||
| 11b0ac0c66 | |||
| 4ddd7d4dfe | |||
| 70c93fcc86 | |||
| 4cd482c80e | |||
| e6e89dc243 | |||
| 3bca0ed9c2 | |||
| 74515e0b19 | |||
| a68ad856a3 | |||
| b9765fb59e | |||
| 12e324d34c | |||
| c970cddd14 | |||
| 56bda12192 | |||
| 58e31a9d3d | |||
| 44e04c57a9 | |||
| accba7614c | |||
| 028160d9ad | |||
| 70287de6d7 | |||
| 18859bec3d | |||
| f80e4ab04c | |||
| 713473b7d2 | |||
| 8c02b0028c | |||
| 8978915423 | |||
| d045b8975c | |||
| 96b1d6257e | |||
| e08cb10b7f | |||
| 80a16c8af2 | |||
| b654ba37f5 | |||
| b673ec88b2 | |||
| ab82d76332 | |||
| 4599e03121 | |||
| 122fdfa34b | |||
| 47ea3c343c | |||
| ac814abda4 | |||
| b7bea4941e | |||
| d1b352963f | |||
| 6ebb652481 | |||
| 3a9a5f5ed3 | |||
| 29630e68d2 | |||
| 3330359c4c | |||
| 3c51b38672 | |||
| 8653338f4e | |||
| 044b4f16bc | |||
| 836f9a88a2 | |||
| 05fe573974 | |||
| b068ba9f02 | |||
| d3911b9e20 | |||
| 06e7ab84cd | |||
| 9ca0038d39 | |||
| cc8dacba32 | |||
| 8cbcb77486 | |||
| 67cd250316 | |||
| 36fd81d190 | |||
| c5cd2223f7 | |||
| 085c452d77 | |||
| 76924e70f5 | |||
| f20f197930 | |||
| ecc7ba0e9d | |||
| 2c55f94d39 | |||
| 8aaab2fc76 | |||
| 85da275b06 | |||
| e251c77f3c | |||
| 4845331d70 | |||
| 45445606b4 | |||
| 8208a6128a | |||
| 032d843f5b | |||
| cd1956b971 | |||
| c308a2378f | |||
| 919bca6769 | |||
| 3718a9609d | |||
| 3c563e3001 | |||
| 5d6dca0faa | |||
| 1b0ac340c2 | |||
| 6e32f292c2 | |||
| d74b99635e | |||
| d3834d2dc5 | |||
| 11a411447c | |||
| 0a73048f0f | |||
| e3b0b55065 | |||
| e73c25e3e3 | |||
| 26036ad92c | |||
| 400d0f264e | |||
| ac21d4b6e8 | |||
| 414cb1d79c | |||
| a95632b9de | |||
| 1d906c7f68 | |||
| 2e3737c6f5 | |||
| bad8718a6b | |||
| c60bd4260f | |||
| b3a8d34af3 | |||
| 61138ff4fa | |||
| c81135c09e | |||
| cc9f32cdc9 | |||
| 10e1f0231c | |||
| e24a187699 | |||
| c323de4807 | |||
| 6391474d14 | |||
| 8de074b275 | |||
| 1be8e13828 | |||
| 6418712c47 | |||
| 21b1c0747c | |||
| 07e61bd347 | |||
| 4260b5e664 | |||
| ebc0346ff9 | |||
| 8d569fd46d | |||
| 690feb6040 | |||
| 5f97897418 | |||
| 5f2d4e416c | |||
| 464eda1d82 | |||
| ffe7b5a41c | |||
| aadea856c3 | |||
| 91d79491f4 | |||
| 61cce88ef8 | |||
| 1356e35ad1 | |||
| d54750ef19 | |||
| 283fad5f87 | |||
| b02e5184fd | |||
| e8ef1145a1 | |||
| f284dd1ebf | |||
| 3f0202f660 | |||
| b36977cf80 | |||
| 7515bd2275 | |||
| ed4013e7ee | |||
| f78d9cb072 | |||
| 2504d48a09 | |||
| 9cc3264807 | |||
| d0bee23432 | |||
| 7973bfeca2 | |||
| 717e26a7a9 | |||
| 2a8b47cab5 | |||
| d4d4907901 | |||
| 7550e03b22 | |||
| 59277d2c6c | |||
| eb3e4b2d33 | |||
| 54a04dd8cc | |||
| 328f731541 | |||
| e704a86e36 | |||
| 61872373ec | |||
| f401b6f2e0 | |||
| 3da078e98b | |||
| e58da006d0 | |||
| be09b290b7 | |||
| c140c64346 | |||
| 059cccd36f | |||
| 006c6f137c | |||
| 4c1e33110e | |||
| ff41aa4a17 | |||
| 6cd7d5ca5b | |||
| 63f6d61ac0 | |||
| 6b2211f135 | |||
| 2de9fedcc0 | |||
| d308442fef | |||
| 71775460c7 | |||
| 266b4d735c | |||
| 2f681b1ce8 | |||
| d222a311ad | |||
| 18cfe26e83 | |||
| f49c495a9a | |||
| 738dce8c06 | |||
| 2238d1a7fd | |||
| 9a440e4321 | |||
| 8e3ae07daf | |||
| f7bde4a5fc | |||
| 4674c10203 | |||
| 1c9c2d8e26 | |||
| 3334d783f3 | |||
| d863ab3271 | |||
| cad83f4e7b | |||
| 61b6b18148 | |||
| fc7373a6f5 | |||
| 71830d7c77 | |||
| ad2d1a15bb | |||
| f5a1f59290 | |||
| 9464967598 | |||
| 7ebbab7634 | |||
| e192bcbaee | |||
| 32528749f0 | |||
| 96348ced38 | |||
| 89ee67606f | |||
| d3aeff833d | |||
| 892ce64712 | |||
| 43bf47fa62 | |||
| 662f908c76 | |||
| cdd1fc4fb7 | |||
| 8acb60f4c7 | |||
| fc5a6127a5 | |||
| 4faccbcb4e | |||
| 7a0466f9fd | |||
| a75140b08c | |||
| 9fa1ad55ce | |||
| 3874fc9b3d | |||
| 28690be77c | |||
| 03411ead09 | |||
| b3662cc35c | |||
| e20232a869 | |||
| 1bb4583b3f | |||
| 2c0802372b | |||
| 7efff83410 | |||
| 6cd4e3069f | |||
| 5884e07c1f | |||
| 06953acfdf | |||
| f048c4ab0c | |||
| 7f8a59ae2f | |||
| ab6259b162 | |||
| 8f08f8dabf | |||
| 19a328ebeb | |||
| 95c4574549 | |||
| 2735c67ed9 | |||
| 802496aacb | |||
| 2d6f906b83 | |||
| edbf5d754d | |||
| 257061011c | |||
| bda4bd6313 | |||
| 69677b31e4 | |||
| 22d02da2f9 | |||
| 068f199bb0 | |||
| f15ac0ee2a | |||
| 34c0e6b24b | |||
| f0c192cdc0 | |||
| 5f6236dd65 | |||
| 14f9038419 | |||
| 2a743dcce6 | |||
| 8fe7d8e4be | |||
| 12a63396ab | |||
| 4976d53ed8 | |||
| 08cac861ae | |||
| dd52f4d82a | |||
| f9cbd425cb | |||
| 465f819c45 | |||
| 458d9b5d99 | |||
| a049e0e9bc | |||
| 2fd8b35ca9 | |||
| 933fa9732c | |||
| 73d6238b4c | |||
| f41e186da3 | |||
| 0e940741f6 | |||
| dc33cc9734 | |||
| 899b637979 | |||
| 7780efce0e | |||
| e50c6488d3 | |||
| 5c7a182897 | |||
| c599bb70ca | |||
| 2f839c75f7 | |||
| 6bd53b72b2 | |||
| 657d0775f5 | |||
| 614b08311f | |||
| 4ed2b4b475 | |||
| d529c98983 | |||
| 891c6f1c0a | |||
| 55ddd3137d | |||
| 9de4ed89e1 | |||
| 31ba1789f1 | |||
| 238c6ee653 | |||
| b9f1832578 | |||
| b8dda3bd65 | |||
| fa4453a476 | |||
| b9e0668d7d | |||
| cedd31c9ea | |||
| 7375e95bf5 | |||
| a2c06f5599 | |||
| 89f75d497c | |||
| 42478785fc | |||
| 87c1d610d1 | |||
| adc78fd408 | |||
| ae502c10c9 | |||
| fad5976dd2 | |||
| 19c5cafa51 | |||
| 434129a434 | |||
| 0942deec38 | |||
| aacfd42640 | |||
| a95b7fc4b6 | |||
| b929fb2bd3 | |||
| f8cd3bf8c4 | |||
| 21d887151d | |||
| 27f44ae2a0 | |||
| 2d02093f48 | |||
| 45a2c03030 | |||
| 70a8f02564 | |||
| 8c107dfe42 | |||
| 97c2d8dffc | |||
| 22e823df9a | |||
| 53a0f423c3 | |||
| 1977278426 | |||
| a5f8d7411e | |||
| 5b83feefa7 | |||
| 78ea35da29 | |||
| 874235eb0f | |||
| 9d7860feb0 | |||
| 4469cb7239 | |||
| 0adb8814dd | |||
| 133aa05bd8 | |||
| 6a0a22a636 | |||
| 1c081f8777 | |||
| 466033964f | |||
| 329d23828d | |||
| 4c9f86c7f7 | |||
| a99a804ea0 | |||
| 2337b9df7f | |||
| b7b5bac5c3 | |||
| 140afc8a51 | |||
| a7fc72e19f | |||
| 9315e3f0f0 | |||
| da9477667c | |||
| 0b3feef47a | |||
| 10a0ffe472 | |||
| fb7a8f0312 | |||
| f633f63a61 | |||
| 55e1451160 | |||
| 57aaea60da | |||
| 4a7f8d3895 | |||
| 35f6255dbd | |||
| aa9ed09f08 | |||
| 25aa1f288b | |||
| 72d874444e | |||
| ddd2b82d20 | |||
| 1ba5c1cf96 | |||
| 7e55ca8f39 | |||
| 5bebb7351d | |||
| b495dce043 | |||
| e52d2cb254 | |||
| 67e3431fe3 | |||
| 8c62b6e07a | |||
| 6662e2666d | |||
| 0d7c943bcd | |||
| de54698408 | |||
| 511aab5d3a | |||
| 214f2505a5 | |||
| 6df753d962 | |||
| 54e5037aaf | |||
| e2144051df | |||
| 2c719df32e | |||
| c060a5c798 | |||
| e538df0df3 | |||
| 7eea1a90af | |||
| aeb97ddcae | |||
| 47fb968009 | |||
| d93815ca0a | |||
| b40b77b228 | |||
| 05339441e7 | |||
| 6222d210be | |||
| d557ef96ac | |||
| a4b5d6cabe | |||
| bc9d75fcae | |||
| d9937b5bd4 | |||
| 187c2dcb27 | |||
| 4266020315 | |||
| 500d039856 | |||
| 22bc5a7373 | |||
| 9000aa3aac | |||
| dded458582 | |||
| 3005da78e2 | |||
| 0d32ec452d | |||
| 4dea755db6 | |||
| e6e540cf99 | |||
| cbd4003ddb | |||
| 0d28717647 | |||
| 24d328c4b7 | |||
| a62ad053d7 | |||
| 016bb01948 | |||
| a30bdb91f4 | |||
| 46c52254c2 | |||
| ee2fc260bf | |||
| abab370f82 | |||
| 5d413a2679 | |||
| 94e63b6171 | |||
| ba0a7b7f4a | |||
| 67f2b3cce4 | |||
| f887a8383b | |||
| a0f21393ee | |||
| b41e608909 | |||
| 3f5a9fdb3a | |||
| b37ed1f8d0 | |||
| fbde67d1bd | |||
| c3d7f0785c | |||
| b37241229c | |||
| 79c9582020 | |||
| d55a8ad02d | |||
| 518015f55b | |||
| 4b5e3aa8f4 | |||
| 4a4f9f7107 | |||
| f89eabaeff | |||
| d7327fefa2 | |||
| 41a96e4331 | |||
| fb41e4fc54 | |||
| e176974cfa | |||
| 46124654dd | |||
| 44cb509ebf | |||
| f72fa40a0f |
@@ -1,18 +1,29 @@
|
||||
{
|
||||
"presets": ["react", "env"],
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-flow",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "2",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"lodash",
|
||||
"styled-components",
|
||||
"transform-decorators-legacy",
|
||||
"transform-es2015-destructuring",
|
||||
"transform-object-rest-spread",
|
||||
"transform-regenerator",
|
||||
"transform-class-properties",
|
||||
"syntax-dynamic-import"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
"presets": ["react-hmre"]
|
||||
}
|
||||
}
|
||||
}
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
{
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-transform-destructuring",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
version: 2
|
||||
jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
|
||||
DATABASE_URL: postgres://root@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: install-deps
|
||||
command: yarn install --pure-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow check --max-workers 4
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build
|
||||
command: yarn build
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
curl --user ${CIRCLE_TOKEN}: \
|
||||
--request POST \
|
||||
--form revision=<ENTER COMMIT SHA HERE>\
|
||||
--form config=@config.yml \
|
||||
--form notify=false \
|
||||
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||
+37
-16
@@ -3,33 +3,51 @@
|
||||
# keys (for auth) and the SECRET_KEY.
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
SECRET_KEY=generate_a_new_key
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@postgres:5432/outline-test
|
||||
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
PORT=3000
|
||||
REDIS_URL=redis://redis:6379
|
||||
URL=http://localhost:3000
|
||||
DEPLOYMENT=self
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=sql,cache,presenters,events
|
||||
PORT=3000
|
||||
|
||||
# Third party credentials (required)
|
||||
SLACK_KEY=71315967491.XXXXXXXXXX
|
||||
SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Comma separated list of domains to be allowed (optional)
|
||||
# If not set, all Google apps domains are allowed by default
|
||||
GOOGLE_ALLOWED_DOMAINS=
|
||||
|
||||
# Third party credentials (optional)
|
||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
BUGSNAG_KEY=
|
||||
SENTRY_DSN=
|
||||
|
||||
# AWS credentials (optional in dev)
|
||||
AWS_ACCESS_KEY_ID=notcheckedindev
|
||||
AWS_SECRET_ACCESS_KEY=notcheckedindev
|
||||
# AWS credentials (optional in development)
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=outline-dev
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# Emails configuration (optional)
|
||||
SMTP_HOST=
|
||||
@@ -38,3 +56,6 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
@@ -6,23 +6,54 @@
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended"
|
||||
],
|
||||
"plugins": ["prettier", "flowtype"],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"flowtype"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"no-unused-vars": 2,
|
||||
// // Bring back after we remove CSS Modules 100%
|
||||
// "import/order": "warn",
|
||||
// Prettier automatically uses the least amount of parens possible, so this
|
||||
// does more harm than good.
|
||||
"no-mixed-operators": "off",
|
||||
// Temporary fix for a failing import lint
|
||||
"import/no-unresolved": [
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"ignore": ["slate-drop-or-paste-images"]
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
// Flow
|
||||
"flowtype/require-valid-file-annotation": [
|
||||
2,
|
||||
"always",
|
||||
@@ -30,22 +61,34 @@
|
||||
"annotationStyle": "line"
|
||||
}
|
||||
],
|
||||
"flowtype/space-after-type-colon": [2, "always"],
|
||||
"flowtype/space-before-type-colon": [2, "never"],
|
||||
// Enforce that code is formatted with prettier.
|
||||
"flowtype/space-after-type-colon": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"flowtype/space-before-type-colon": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": ["app", "."]
|
||||
"paths": [
|
||||
"app",
|
||||
"."
|
||||
]
|
||||
}
|
||||
},
|
||||
"flowtype": {
|
||||
@@ -56,12 +99,6 @@
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"__DEV__": true,
|
||||
"SLACK_KEY": true,
|
||||
"DEPLOYMENT": true,
|
||||
"BASE_URL": true,
|
||||
"BUGSNAG_KEY": true,
|
||||
"afterAll": true,
|
||||
"Bugsnag": true
|
||||
"EDITOR_VERSION": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -4,15 +4,14 @@
|
||||
.*/shared/.*
|
||||
|
||||
[ignore]
|
||||
.*/node_modules/tiny-cookie/flow/.*
|
||||
.*/node_modules/styled-components/.*
|
||||
.*/node_modules/polished/.*
|
||||
.*/node_modules/mobx/.*.flow
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/slate-edit-code/example/.*
|
||||
.*/node_modules/slate-edit-code/lib/.*
|
||||
.*/node_modules/slate-edit-list/.*
|
||||
.*/node_modules/slate-prism/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
[libs]
|
||||
@@ -23,19 +22,16 @@ emoji=true
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
|
||||
module.name_mapper='^\(.*\)\.s?css$' -> 'empty/object'
|
||||
module.name_mapper='^\(.*\)\.md$' -> 'empty/object'
|
||||
module.name_mapper='^shared\/\(.*\)$' -> '<PROJECT_ROOT>/shared/\1'
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.scss
|
||||
module.file_ext=.md
|
||||
module.file_ext=.json
|
||||
|
||||
esproposal.decorators=ignore
|
||||
esproposal.class_static_fields=enable
|
||||
esproposal.class_instance_fields=enable
|
||||
unsafe.enable_getters_and_setters=true
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
yarn lint:flow
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Request a feature to be added to the project
|
||||
- name: Self hosting questions
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Ask questions and discuss running Outline with community members
|
||||
@@ -1,6 +1,8 @@
|
||||
dist
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.format.enable": false
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
## Jan 28th 2018
|
||||
|
||||
- Fixed keyboard shortcuts on Windows
|
||||
- Fixed slack slash command and improved results formatting in Slack
|
||||
- Fixed headings now have stable anchors for external linking
|
||||
- Fixed several JS issues in the editor
|
||||
- Improved toolbar behavior for link editing
|
||||
|
||||
---
|
||||
|
||||
## Jan 23rd 2018
|
||||
|
||||
- Added 'about' page
|
||||
- Added dynamic prefetching of scripts for improved performance
|
||||
- Added more meta tags
|
||||
- Added new onboarding document
|
||||
|
||||
---
|
||||
|
||||
## Jan 15th 2018
|
||||
|
||||
- Fixed loading placeholders styling
|
||||
- Added members view to settings pages
|
||||
- Dynamic loading of editor JS for first load performance
|
||||
- Added event bus for future integrations
|
||||
- Improved handling of malformed links
|
||||
|
||||
---
|
||||
|
||||
## Jan 6th, 2018
|
||||
|
||||
- Improved the floating toolbar behavior in the editor
|
||||
- Added blockquote button to formatting toolbar
|
||||
- Fixed saving a new document no longer redirects to read-only mode
|
||||
- Fixed an exception when visiting a document without being signed in
|
||||
- Fixed a problem with invalid urls causing errors
|
||||
|
||||
---
|
||||
|
||||
## Dec 18th, 2017
|
||||
|
||||
- We now support automatic unfurling of Outline links posted to Slack
|
||||
- Added privacy policy for the hosted version at getoutline.com
|
||||
- Added ability to update profile picture in the settings
|
||||
|
||||
---
|
||||
|
||||
## Dec 11th, 2017
|
||||
|
||||
- Added ability to develop locally in Docker. This is now the preferred way to work on Outline.
|
||||
- Various improvements and fixes to search
|
||||
|
||||
---
|
||||
|
||||
## Nov 28th, 2017
|
||||
|
||||
- Added the changelog you’re looking at!
|
||||
- Fixed some issues with collection homepage
|
||||
|
||||
---
|
||||
|
||||
## Nov 23rd, 2017
|
||||
|
||||
- Initial open source release.
|
||||
+9
-2
@@ -1,10 +1,17 @@
|
||||
FROM node:latest
|
||||
FROM node:12-alpine
|
||||
|
||||
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
|
||||
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
|
||||
ENV APP_PATH /opt/outline
|
||||
RUN mkdir -p $APP_PATH
|
||||
|
||||
WORKDIR $APP_PATH
|
||||
COPY . $APP_PATH
|
||||
RUN yarn
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN yarn build
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,19 +1,103 @@
|
||||
Copyright (c) 2017 Outline (https://www.getoutline.com/) and individual contributors.
|
||||
All rights reserved.
|
||||
Business Source License 1.1
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
Parameters
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.46.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
A “Document Service” is a commercial offering that
|
||||
allows third parties (other than your employees and
|
||||
contractors) to access the functionality of the
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
3. Neither the name of the Outline nor the names of its contributors may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
Change Date: 2023-08-12
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Software,
|
||||
please visit: https://www.getoutline.com
|
||||
|
||||
Notice
|
||||
|
||||
The Business Source License (this document, or the “License”) is not an Open
|
||||
Source license. However, the Licensed Work will eventually be made available
|
||||
under an Open Source License, as stated in this License.
|
||||
|
||||
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||
“Business Source License” is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Business Source License 1.1
|
||||
|
||||
Terms
|
||||
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited
|
||||
production use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may vary
|
||||
for each version of the Licensed Work released by Licensor.
|
||||
|
||||
You must conspicuously display this License on each original or modified copy
|
||||
of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
MariaDB hereby grants you permission to use this License’s text to license
|
||||
your works, and to refer to it using the trademark “Business Source License”,
|
||||
as long as you comply with the Covenants of Licensor below.
|
||||
|
||||
Covenants of Licensor
|
||||
|
||||
In consideration of the right to use this License’s text and the “Business
|
||||
Source License” name and trademark, Licensor covenants to MariaDB, and to all
|
||||
other recipients of the licensed work to be provided by Licensor:
|
||||
|
||||
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
||||
or a license that is compatible with GPL Version 2.0 or a later version,
|
||||
where “compatible” means that software provided under the Change License can
|
||||
be included in a program with software provided under GPL Version 2.0 or a
|
||||
later version. Licensor may specify additional Change Licenses without
|
||||
limitation.
|
||||
|
||||
2. To either: (a) specify an additional grant of rights to use that does not
|
||||
impose any additional restriction on the right granted in this License, as
|
||||
the Additional Use Grant; or (b) insert the text “None”.
|
||||
|
||||
3. To specify a Change Date.
|
||||
|
||||
4. Not to modify this License in any other way.
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose run --rm outline bash -c "yarn && yarn sequelize db:migrate"
|
||||
docker-compose up outline
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev
|
||||
|
||||
build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
test:
|
||||
docker-compose run --rm outline yarn test
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
docker-compose stop
|
||||
docker-compose rm -f
|
||||
|
||||
.PHONY: up build destroy # let's go to reserve rules names
|
||||
.PHONY: up build destroy test watch # let's go to reserve rules names
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<br/>
|
||||
<img src="https://user-images.githubusercontent.com/31465/34456332-51e41eb0-ed9c-11e7-9fa9-20e7fa946494.jpg" alt="Outline" width="800" height="500">
|
||||
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a" alt="" data-canonical-src="" style="max-width:100%;">
|
||||
<a href="https://spectrum.chat/outline" rel="nofollow"><img src="https://withspectrum.github.io/badge/badge.svg" alt="Join the community on Spectrum"/></a>
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
@@ -20,26 +22,68 @@ If you'd like to run your own copy of Outline or contribute to development then
|
||||
|
||||
Outline requires the following dependencies:
|
||||
|
||||
- Node.js >= 12
|
||||
- Postgres >=9.5
|
||||
- Redis
|
||||
- Slack developer application
|
||||
- Redis >= 4
|
||||
- AWS S3 storage bucket for media and other attachments
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
### Development
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it.
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env` and fill out the Slack keys, everything
|
||||
else should work well for development.
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline.
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
1. Fill out the following fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
### Production
|
||||
|
||||
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn` or `npm install`
|
||||
|
||||
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
|
||||
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate `
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed in the `.env` file
|
||||
|
||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
### Server
|
||||
|
||||
To enable debugging statements, set the following env vars:
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
```
|
||||
|
||||
## Migrations
|
||||
@@ -47,7 +91,7 @@ DEBUG=sql,cache,presenters,events
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```
|
||||
yarn sequelize migration:create
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
@@ -59,16 +103,17 @@ yarn sequelize db:migrate --env test
|
||||
|
||||
## Structure
|
||||
|
||||
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are ran as pre-commit hooks.
|
||||
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
|
||||
|
||||
### Frontend
|
||||
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage. The editor is driven by [Slate](https://github.com/ianstormtaylor/slate) with several plugins.
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||
|
||||
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
||||
|
||||
- `app/` - Frontend React application
|
||||
- `app/scenes` - Full page views
|
||||
- `app/components` - Reusable React components
|
||||
- `app/components/Editor` - Text editor and its plugins
|
||||
- `app/stores` - Global state stores
|
||||
- `app/models` - State models
|
||||
- `app/types` - Flow types for non-models
|
||||
@@ -78,15 +123,18 @@ Outline's frontend is a React application compiled with [Webpack](https://webpac
|
||||
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
|
||||
|
||||
- `server/api` - API endpoints
|
||||
- `server/commands` - Domain logic, currently being refactored from /models
|
||||
- `server/emails` - React rendered email templates
|
||||
- `server/models` - Database models (Sequelize)
|
||||
- `server/pages` - Server-side rendered public pages (React)
|
||||
- `server/models` - Database models
|
||||
- `server/policies` - Authorization logic
|
||||
- `server/presenters` - API responses for database models
|
||||
- `server/test` - Test helps and support
|
||||
- `server/utils` - Utility methods
|
||||
- `shared` - Code shared between frontend and backend applications
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested, and it's generally good to add tests for backend features and code.
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
@@ -103,18 +151,17 @@ yarn test:app
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is still built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
||||
|
||||
If you're looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
* Bugs and other issues listed on GitHub
|
||||
* Helping others on Spectrum
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSD licensed](/blob/master/LICENSE).
|
||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
|
||||
|
||||
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
|
||||
|
||||
Report security bugs in third-party dependencies to the person or team maintaining the module. You can also report a vulnerability through the [Node Security Project](https://nodesecurity.io/report).
|
||||
@@ -1,2 +0,0 @@
|
||||
import idObj from 'identity-obj-proxy';
|
||||
export default idObj;
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"name": "Outline",
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
"quantity": 1,
|
||||
"size": "Hobby"
|
||||
}
|
||||
},
|
||||
"image": "heroku/node",
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_KEY": {
|
||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_VERIFICATION_TOKEN": {
|
||||
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_APP_ID": {
|
||||
"description": "A0XXXXXXXXX",
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Needed to save file uploads. Optional for development / testing",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
||||
"description": "yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_URL": {
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"value": "us-east-1",
|
||||
"description": "Region in which the above S3 bucket exists",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_ACL": {
|
||||
"value": "private",
|
||||
"description": "S3 canned ACL for document attachments",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_USERNAME": {
|
||||
"description": "me@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PASSWORD": {
|
||||
"description": "(optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_FROM_EMAIL": {
|
||||
"description": "wiki@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_REPLY_EMAIL": {
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
|
||||
"required": false
|
||||
},
|
||||
"TEAM_LOGO": {
|
||||
"description": "A logo that will be displayed on the signed out home page",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: auto;
|
||||
padding: 24px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Actions;
|
||||
@@ -1,45 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { layout, color } from 'shared/styles/constants';
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
|
||||
a {
|
||||
color: ${color.text};
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: ${color.slateLight};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 16px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
left: auto;
|
||||
padding: ${layout.vpadding} ${layout.hpadding} 8px 8px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Actions;
|
||||
@@ -1,4 +0,0 @@
|
||||
// @flow
|
||||
import Actions from './Actions';
|
||||
export { Action, Separator } from './Actions';
|
||||
export default Actions;
|
||||
@@ -1,38 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
type Props = {
|
||||
children: React.Element<*>,
|
||||
type?: 'info' | 'success' | 'warning' | 'danger' | 'offline',
|
||||
};
|
||||
|
||||
@observer
|
||||
class Alert extends React.Component {
|
||||
props: Props;
|
||||
defaultProps = {
|
||||
type: 'info',
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container align="center" justify="center" type={this.props.type}>
|
||||
{this.props.children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: $headerHeight;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
background-color: ${({ type }) => color[type]};
|
||||
`;
|
||||
|
||||
export default Alert;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Alert from './Alert';
|
||||
export default Alert;
|
||||
@@ -0,0 +1,40 @@
|
||||
// @flow
|
||||
/* global ga */
|
||||
import * as React from "react";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function () {
|
||||
// $FlowIssue
|
||||
(ga.q = ga.q || []).push(arguments);
|
||||
};
|
||||
|
||||
// $FlowIssue
|
||||
ga.l = +new Date();
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", { dimension1: "true" });
|
||||
ga("send", "pageview");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children || null;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Provider } from 'mobx-react';
|
||||
import stores from 'stores';
|
||||
import ApiKeysStore from 'stores/ApiKeysStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import CacheStore from 'stores/CacheStore';
|
||||
|
||||
type Props = {
|
||||
children?: React.Element<any>,
|
||||
};
|
||||
|
||||
let authenticatedStores;
|
||||
|
||||
const Auth = ({ children }: Props) => {
|
||||
if (stores.auth.authenticated && stores.auth.team && stores.auth.user) {
|
||||
// Only initialize stores once. Kept in global scope because otherwise they
|
||||
// will get overridden on route change
|
||||
if (!authenticatedStores) {
|
||||
// Stores for authenticated user
|
||||
const { user, team } = stores.auth;
|
||||
const cache = new CacheStore(user.id);
|
||||
authenticatedStores = {
|
||||
apiKeys: new ApiKeysStore(),
|
||||
users: new UsersStore(),
|
||||
documents: new DocumentsStore({
|
||||
ui: stores.ui,
|
||||
cache,
|
||||
}),
|
||||
collections: new CollectionsStore({
|
||||
ui: stores.ui,
|
||||
teamId: team.id,
|
||||
cache,
|
||||
}),
|
||||
};
|
||||
|
||||
if (window.Bugsnag) {
|
||||
Bugsnag.user = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
teamId: team.id,
|
||||
team: team.name,
|
||||
};
|
||||
}
|
||||
|
||||
stores.auth.fetch();
|
||||
authenticatedStores.collections.fetchPage({ limit: 100 });
|
||||
}
|
||||
|
||||
return <Provider {...authenticatedStores}>{children}</Provider>;
|
||||
}
|
||||
|
||||
stores.auth.logout();
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Auth;
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "shared/utils/domains";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
const Authenticated = observer(({ auth, children }: Props) => {
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
// If we're authenticated but viewing a subdomain that doesn't match the
|
||||
// currently authenticated team then kick the user to the teams subdomain.
|
||||
if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
});
|
||||
|
||||
export default inject("auth")(Authenticated);
|
||||
@@ -1,35 +1,66 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import placeholder from './placeholder.png';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Avatar extends Component {
|
||||
class Avatar extends React.Component<Props> {
|
||||
@observable error: boolean;
|
||||
|
||||
static defaultProps = {
|
||||
size: 24,
|
||||
};
|
||||
|
||||
handleError = () => {
|
||||
this.error = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, icon, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<CircleImg
|
||||
{...this.props}
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : this.props.src}
|
||||
/>
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
{...rest}
|
||||
/>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</AvatarWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${(props) => props.theme.primary};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${color.white};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import UserProfile from "scenes/UserProfile";
|
||||
import Avatar from "components/Avatar";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
isPresent: boolean,
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AvatarWithPresence extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
|
||||
handleOpenProfile = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
lastViewedAt,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
|
||||
<br />
|
||||
{isPresent
|
||||
? isEditing
|
||||
? "currently editing"
|
||||
: "currently viewing"
|
||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default AvatarWithPresence;
|
||||
@@ -1,3 +1,6 @@
|
||||
// @flow
|
||||
import Avatar from './Avatar';
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
|
||||
export { AvatarWithPresence };
|
||||
export default Avatar;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ primary, theme }) =>
|
||||
primary ? theme.primary : theme.textTertiary};
|
||||
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
href?: string,
|
||||
};
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<OutlineLogo size={16} />
|
||||
Outline
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled.a`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-top-right-radius: 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
@@ -0,0 +1,162 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
PadlockIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
ShapesIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
const collection = collections.get(document.collectionId);
|
||||
if (!collection) return <div />;
|
||||
|
||||
const path = collection.pathToDocument(document).slice(0, -1);
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.private && (
|
||||
<>
|
||||
<SmallPadlockIcon color="currentColor" size={16} />{" "}
|
||||
</>
|
||||
)}
|
||||
{collection.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isTemplate = document.isTemplate;
|
||||
const isDraft = !document.publishedAt && !isTemplate;
|
||||
const isNestedDocument = path.length > 1;
|
||||
const lastPath = path.length ? path[path.length - 1] : undefined;
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
{isTemplate && (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
{isDraft && (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
<span>{collection.name}</span>
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
<>
|
||||
<Slash />{" "}
|
||||
<Crumb to={lastPath.url} title={lastPath.title}>
|
||||
{lastPath.title}
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const SmallPadlockIcon = styled(PadlockIcon)`
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 15px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default inject("collections")(Breadcrumb);
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
path: Array<any>,
|
||||
};
|
||||
|
||||
export default class BreadcrumbMenu extends React.Component<Props> {
|
||||
render() {
|
||||
const { path } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu label={this.props.label} position="center">
|
||||
{path.map((item) => (
|
||||
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const RealButton = styled.button`
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.buttonBackground};
|
||||
color: ${(props) => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
||||
};
|
||||
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
|
||||
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
}
|
||||
`} ${(props) =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
|
||||
0px 3px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
fullwidth?: boolean,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef<Props, typeof Button>((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
@@ -1,114 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import { darken, lighten } from 'polished';
|
||||
|
||||
const RealButton = styled.button`
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${color.primary};
|
||||
color: ${color.white};
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
height: 36px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
&:hover {
|
||||
background: ${darken(0.05, color.primary)};
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 0.05em;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.light &&
|
||||
`
|
||||
color: ${color.text};
|
||||
background: ${lighten(0.08, color.slateLight)};
|
||||
|
||||
&:hover {
|
||||
background: ${color.slateLight};
|
||||
}
|
||||
`} ${props =>
|
||||
props.neutral &&
|
||||
`
|
||||
background: ${color.slate};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, color.slate)};
|
||||
}
|
||||
`} ${props =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${color.danger};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, color.danger)};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${props => props.hasIcon && 'padding-left: 2px;'};
|
||||
`;
|
||||
|
||||
const Inner = styled.span`
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
line-height: 28px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
${props =>
|
||||
props.hasIcon &&
|
||||
(props.small ? 'padding-left: 6px;' : 'padding-left: 10px;')};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
value?: string,
|
||||
icon?: React$Element<any>,
|
||||
className?: string,
|
||||
children?: React$Element<any>,
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
type = 'text',
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton {...rest}>
|
||||
<Inner hasIcon={hasIcon}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Button from './Button';
|
||||
export default Button;
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonLarge = styled(Button)`
|
||||
height: 40px;
|
||||
|
||||
${Inner} {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonLarge;
|
||||
+6
-5
@@ -1,17 +1,18 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.Element<any>,
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: 60px 20px;
|
||||
|
||||
${breakpoint('tablet')`
|
||||
${breakpoint("tablet")`
|
||||
padding: 60px;
|
||||
`};
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CenteredContent from './CenteredContent';
|
||||
export default CenteredContent;
|
||||
@@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
margin-left: ${(props) => (props.small ? "6px" : "10px")};
|
||||
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${(props) => (props.small ? "font-size: 14px" : "")};
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default function Checkbox({
|
||||
label,
|
||||
labelHidden,
|
||||
note,
|
||||
className,
|
||||
small,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
||||
@@ -0,0 +1,76 @@
|
||||
// @flow
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import { AvatarWithPresence } from "components/Avatar";
|
||||
import Facepile from "components/Facepile";
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const documentViews = views.inDocument(document.id);
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const mostRecentViewers = sortBy(
|
||||
documentViews.slice(0, MAX_AVATAR_DISPLAY),
|
||||
(view) => {
|
||||
return presentIds.includes(view.user.id);
|
||||
}
|
||||
);
|
||||
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
|
||||
return (
|
||||
<Facepile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
@@ -1,65 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Document from 'models/Document';
|
||||
|
||||
type Props = { document: Document };
|
||||
|
||||
const Collaborators = ({ document }: Props) => {
|
||||
const {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
createdBy,
|
||||
updatedBy,
|
||||
collaborators,
|
||||
} = document;
|
||||
let tooltip;
|
||||
|
||||
if (createdAt === updatedAt) {
|
||||
tooltip = `${createdBy.name} published ${distanceInWordsToNow(
|
||||
new Date(createdAt)
|
||||
)} ago`;
|
||||
} else {
|
||||
tooltip = `${updatedBy.name} modified ${distanceInWordsToNow(
|
||||
new Date(updatedAt)
|
||||
)} ago`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatars>
|
||||
<StyledTooltip tooltip={tooltip} placement="bottom">
|
||||
{collaborators.map(user => (
|
||||
<AvatarWrapper key={user.id}>
|
||||
<Avatar src={user.avatarUrl} />
|
||||
</AvatarWrapper>
|
||||
))}
|
||||
</StyledTooltip>
|
||||
</Avatars>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: -10px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
`;
|
||||
|
||||
export default Collaborators;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Collaborators from './Collaborators';
|
||||
export default Collaborators;
|
||||
@@ -0,0 +1,45 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import { icons } from "components/IconPicker";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
expanded?: boolean,
|
||||
size?: number,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color;
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.private) {
|
||||
return (
|
||||
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default inject("ui")(observer(ResolvedCollectionIcon));
|
||||
@@ -1,192 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { observable, computed, action } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { LabelText, Outline } from 'components/Input';
|
||||
import { color, fonts, fontWeight } from 'shared/styles/constants';
|
||||
import { validateColorHex } from 'shared/utils/color';
|
||||
|
||||
const colors = [
|
||||
'#4E5C6E',
|
||||
'#19B7FF',
|
||||
'#7F6BFF',
|
||||
'#FC7419',
|
||||
'#FC2D2D',
|
||||
'#FFE100',
|
||||
'#14CF9F',
|
||||
'#EE84F0',
|
||||
'#2F362F',
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onSelect: (color: string) => void,
|
||||
value?: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ColorPicker extends React.Component {
|
||||
props: Props;
|
||||
|
||||
@observable selectedColor: string = colors[0];
|
||||
@observable customColorValue: string = '';
|
||||
@observable customColorSelected: boolean;
|
||||
|
||||
componentWillMount() {
|
||||
const { value } = this.props;
|
||||
if (value && colors.includes(value)) {
|
||||
this.selectedColor = value;
|
||||
} else if (value) {
|
||||
this.customColorSelected = true;
|
||||
this.customColorValue = value.replace('#', '');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fireCallback();
|
||||
}
|
||||
|
||||
fireCallback = () => {
|
||||
this.props.onSelect(
|
||||
this.customColorSelected ? this.customColor : this.selectedColor
|
||||
);
|
||||
};
|
||||
|
||||
@computed
|
||||
get customColor(): string {
|
||||
return this.customColorValue &&
|
||||
validateColorHex(`#${this.customColorValue}`)
|
||||
? `#${this.customColorValue}`
|
||||
: colors[0];
|
||||
}
|
||||
|
||||
@action
|
||||
setColor = (color: string) => {
|
||||
this.selectedColor = color;
|
||||
this.customColorSelected = false;
|
||||
this.fireCallback();
|
||||
};
|
||||
|
||||
@action
|
||||
focusOnCustomColor = (event: SyntheticEvent) => {
|
||||
this.selectedColor = '';
|
||||
this.customColorSelected = true;
|
||||
this.fireCallback();
|
||||
};
|
||||
|
||||
@action
|
||||
setCustomColor = (event: SyntheticEvent) => {
|
||||
let target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const color = target.value;
|
||||
this.customColorValue = color.replace('#', '');
|
||||
this.fireCallback();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column>
|
||||
<LabelText>Color</LabelText>
|
||||
<StyledOutline justify="space-between">
|
||||
<Flex>
|
||||
{colors.map(color => (
|
||||
<Swatch
|
||||
key={color}
|
||||
color={color}
|
||||
active={
|
||||
color === this.selectedColor && !this.customColorSelected
|
||||
}
|
||||
onClick={() => this.setColor(color)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex justify="flex-end">
|
||||
<strong>Custom color:</strong>
|
||||
<HexHash>#</HexHash>
|
||||
<CustomColorInput
|
||||
placeholder="FFFFFF"
|
||||
onFocus={this.focusOnCustomColor}
|
||||
onChange={this.setCustomColor}
|
||||
value={this.customColorValue}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Swatch
|
||||
color={this.customColor}
|
||||
active={this.customColorSelected}
|
||||
/>
|
||||
</Flex>
|
||||
</StyledOutline>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type SwatchProps = {
|
||||
onClick?: () => void,
|
||||
color?: string,
|
||||
active?: boolean,
|
||||
};
|
||||
|
||||
const Swatch = ({ onClick, ...props }: SwatchProps) => (
|
||||
<SwatchOutset onClick={onClick} {...props}>
|
||||
<SwatchInset {...props} />
|
||||
</SwatchOutset>
|
||||
);
|
||||
|
||||
const SwatchOutset = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 5px;
|
||||
border: 2px solid ${({ active, color }) => (active ? color : 'transparent')};
|
||||
border-radius: 2px;
|
||||
background: ${({ color }) => color};
|
||||
${({ onClick }) => onClick && `cursor: pointer;`} &:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwatchInset = styled(Flex)`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
|
||||
border-radius: 2px;
|
||||
background: ${({ color }) => color};
|
||||
`;
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
padding: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
const HexHash = styled.div`
|
||||
margin-left: 12px;
|
||||
padding-bottom: 0;
|
||||
font-weight: ${fontWeight.medium};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CustomColorInput = styled.input`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
width: 65px;
|
||||
margin-right: 12px;
|
||||
padding-bottom: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
font-family: ${fonts.monospace};
|
||||
font-weight: ${fontWeight.medium};
|
||||
|
||||
&::placeholder {
|
||||
color: ${color.slate};
|
||||
font-family: ${fonts.monospace};
|
||||
font-weight: ${fontWeight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import ColorPicker from './ColorPicker';
|
||||
export default ColorPicker;
|
||||
+7
-9
@@ -1,27 +1,25 @@
|
||||
// @flow
|
||||
import React, { PureComponent } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
children?: React.Element<any>,
|
||||
children?: React.Node,
|
||||
onClick?: () => void,
|
||||
onCopy: () => void,
|
||||
};
|
||||
|
||||
class CopyToClipboard extends PureComponent {
|
||||
props: Props;
|
||||
|
||||
onClick = (ev: SyntheticEvent) => {
|
||||
class CopyToClipboard extends React.PureComponent<Props> {
|
||||
onClick = (ev: SyntheticEvent<>) => {
|
||||
const { text, onCopy, children } = this.props;
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: __DEV__,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === 'function') {
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CopyToClipboard from './CopyToClipboard';
|
||||
export default CopyToClipboard;
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
delay?: number,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
const [isShowing, setShowing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => setShowing(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isShowing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const Divider = () => {
|
||||
return (
|
||||
<Flex auto justify="center">
|
||||
<Content />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled.span`
|
||||
display: flex;
|
||||
width: 50%;
|
||||
margin: 20px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
`;
|
||||
|
||||
export default Divider;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Divider from './Divider';
|
||||
export default Divider;
|
||||
@@ -0,0 +1,149 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { type RouterHistory, type Match } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Flex from "components/Flex";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Revision from "./components/Revision";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentHistory extends React.Component<Props> {
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const results = await this.props.revisions.fetchPage({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
documentId: this.props.match.params.documentSlug,
|
||||
});
|
||||
|
||||
if (
|
||||
results &&
|
||||
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
|
||||
) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
selectFirstRevision = () => {
|
||||
if (this.revisions.length) {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return;
|
||||
|
||||
this.props.history.replace(
|
||||
documentHistoryUrl(document, this.revisions[0].id)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
get revisions() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return [];
|
||||
return this.props.revisions.getDocumentRevisions(document.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
const showLoading = (!this.isLoaded && this.isFetching) || !document;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<Wrapper column>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<ListPlaceholder count={5} />
|
||||
</Loading>
|
||||
) : (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.revisions.map((revision, index) => (
|
||||
<Revision
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
document={document}
|
||||
showMenu={index !== 0}
|
||||
selected={this.props.match.params.revisionId === revision.id}
|
||||
/>
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
)}
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Loading = styled.div`
|
||||
margin: 0 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default inject("documents", "revisions")(DocumentHistory);
|
||||
@@ -0,0 +1,89 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import RevisionMenu from "menus/RevisionMenu";
|
||||
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
theme: Object,
|
||||
showMenu: boolean,
|
||||
selected: boolean,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
};
|
||||
|
||||
class RevisionListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { revision, document, showMenu, selected, theme } = this.props;
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
to={documentHistoryUrl(document, revision.id)}
|
||||
activeStyle={{ background: theme.primary, color: theme.white }}
|
||||
>
|
||||
<Author>
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
|
||||
{revision.createdBy.name}
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt}>
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
border-color: transparent;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const StyledRevisionMenu = styled(RevisionMenu)`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 20px;
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Author = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Meta = styled.p`
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default withTheme(RevisionListItem);
|
||||
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
|
||||
return (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map((document) => (
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Document from 'models/Document';
|
||||
import DocumentPreview from 'components/DocumentPreview';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
|
||||
class DocumentList extends React.Component {
|
||||
props: {
|
||||
documents: Document[],
|
||||
showCollection?: boolean,
|
||||
limit?: number,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { limit, showCollection } = this.props;
|
||||
const documents = limit
|
||||
? this.props.documents.splice(0, limit)
|
||||
: this.props.documents;
|
||||
|
||||
return (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{documents.map(document => (
|
||||
<DocumentPreview
|
||||
key={document.id}
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
/>
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentList;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentList from './DocumentList';
|
||||
export default DocumentList;
|
||||
@@ -0,0 +1,123 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) =>
|
||||
props.highlight ? props.theme.text : props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
auth: AuthStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
};
|
||||
|
||||
function DocumentMeta({
|
||||
auth,
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
createdAt,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
// Currently the situation where this is true is rendering share links.
|
||||
if (!updatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
created <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("collections", "auth")(observer(DocumentMeta));
|
||||
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
|
||||
type Props = {|
|
||||
views: ViewsStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ views, to, isDraft, document }: Props) {
|
||||
const totalViews = views.countForDocument(document.id);
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
{totalViews && !isDraft ? (
|
||||
<>
|
||||
· Viewed{" "}
|
||||
{totalViews === 1 ? "once" : `${totalViews} times`}
|
||||
</>
|
||||
) : null}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("views")(DocumentMetaWithViews);
|
||||
@@ -1,27 +1,149 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Document from 'models/Document';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Highlight from 'components/Highlight';
|
||||
import StarredIcon from 'components/Icon/StarredIcon';
|
||||
import PublishingInfo from './components/PublishingInfo';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link, withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
innerRef?: Function,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
};
|
||||
|
||||
const StyledStar = styled(({ solid, ...props }) => (
|
||||
<StarredIcon color={solid ? color.black : color.text} {...props} />
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.props.history.push(
|
||||
newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = this.props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.handleUnstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.handleStar} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip tooltip="Only visible to you" delay={500} placement="top">
|
||||
<Badge>Draft</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>Template</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted && (
|
||||
<Button
|
||||
onClick={this.handleNewFromTemplate}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
New doc
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DocumentMenu document={document} showPin={showPin} />
|
||||
</SecondaryActions>
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
@@ -30,9 +152,10 @@ const StyledStar = styled(({ solid, ...props }) => (
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
`);
|
||||
|
||||
const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
const SecondaryActions = styled(Flex)`
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
@@ -41,27 +164,30 @@ const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${StyledDocumentMenu} {
|
||||
${SecondaryActions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${color.smokeLight};
|
||||
border: 2px solid ${color.smoke};
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${StyledStar}, ${StyledDocumentMenu} {
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${StyledStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
@@ -69,10 +195,6 @@ const DocumentLink = styled(Link)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: 2px solid ${color.slateDark};
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
@@ -81,6 +203,11 @@ const Heading = styled.h3`
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -88,53 +215,18 @@ const Actions = styled(Flex)`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends Component {
|
||||
props: Props;
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
star = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
unstar = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
innerRef,
|
||||
highlight,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<DocumentLink to={document.url} innerRef={innerRef} {...rest}>
|
||||
<Heading>
|
||||
<Highlight text={document.title} highlight={highlight} />
|
||||
{document.publishedAt && (
|
||||
<Actions>
|
||||
{document.starred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} />
|
||||
</Heading>
|
||||
<PublishingInfo
|
||||
document={document}
|
||||
collection={showCollection ? document.collection : undefined}
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentPreview;
|
||||
export default withRouter(DocumentPreview);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import Collection from 'models/Collection';
|
||||
import Document from 'models/Document';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${color.slate};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${props => (props.highlight ? color.slateDark : color.slate)};
|
||||
font-weight: ${props => (props.highlight ? '600' : '400')};
|
||||
`;
|
||||
|
||||
class PublishingInfo extends Component {
|
||||
props: {
|
||||
collection?: Collection,
|
||||
document: Document,
|
||||
views?: number,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, document } = this.props;
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
createdBy,
|
||||
updatedBy,
|
||||
publishedAt,
|
||||
} = document;
|
||||
|
||||
const timeAgo = `${distanceInWordsToNow(new Date(createdAt))} ago`;
|
||||
|
||||
return (
|
||||
<Container align="center">
|
||||
{publishedAt === updatedAt ? (
|
||||
<span>
|
||||
{createdBy.name} published {timeAgo}
|
||||
</span>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{updatedBy.name}
|
||||
{publishedAt ? (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
modified {timeAgo}
|
||||
</Modified>
|
||||
) : (
|
||||
<span> saved {timeAgo}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{collection && (
|
||||
<span>
|
||||
in <strong>{collection.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PublishingInfo;
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import DocumentPreview from './DocumentPreview';
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
export default DocumentPreview;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// @flow
|
||||
import { observable, action } from 'mobx';
|
||||
import invariant from 'invariant';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import type { User } from 'types';
|
||||
|
||||
type View = {
|
||||
user: User,
|
||||
count: number,
|
||||
};
|
||||
|
||||
class DocumentViewersStore {
|
||||
documentId: string;
|
||||
@observable viewers: Array<View>;
|
||||
@observable isFetching: boolean;
|
||||
|
||||
@action
|
||||
fetchViewers = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(
|
||||
'/views.list',
|
||||
{
|
||||
id: this.documentId,
|
||||
},
|
||||
{ cache: true }
|
||||
);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.viewers = res.data.users;
|
||||
} catch (e) {
|
||||
console.error('Something went wrong');
|
||||
}
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
constructor(documentId: string) {
|
||||
this.documentId = documentId;
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentViewersStore;
|
||||
@@ -1,72 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import Popover from 'components/Popover';
|
||||
import styled from 'styled-components';
|
||||
import DocumentViewers from './components/DocumentViewers';
|
||||
import DocumentViewersStore from './DocumentViewersStore';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
color: #ccc;
|
||||
|
||||
&:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
documentId: string,
|
||||
count: number,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentViews extends Component {
|
||||
@observable opened: boolean = false;
|
||||
anchor: HTMLElement;
|
||||
store: DocumentViewersStore;
|
||||
props: Props;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.store = new DocumentViewersStore(props.documentId);
|
||||
}
|
||||
|
||||
openPopover = () => {
|
||||
this.opened = true;
|
||||
};
|
||||
|
||||
closePopover = () => {
|
||||
this.opened = false;
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.anchor = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container align="center">
|
||||
<a ref={this.setRef} onClick={this.openPopover}>
|
||||
Viewed {this.props.count} {this.props.count === 1 ? 'time' : 'times'}
|
||||
</a>
|
||||
{this.opened && (
|
||||
<Popover anchor={this.anchor} onClose={this.closePopover}>
|
||||
<DocumentViewers
|
||||
onMount={this.store.fetchViewers}
|
||||
viewers={this.store.viewers}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentViews;
|
||||
@@ -1,54 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from 'styled-components';
|
||||
import map from 'lodash/map';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Scrollable from 'components/Scrollable';
|
||||
|
||||
type Props = {
|
||||
viewers: Array<Object>,
|
||||
onMount: Function,
|
||||
};
|
||||
|
||||
const List = styled.ul`
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
margin: -4px 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const UserName = styled.span`
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
class DocumentViewers extends Component {
|
||||
props: Props;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.onMount();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Scrollable>
|
||||
<List>
|
||||
{map(this.props.viewers, view => (
|
||||
<li key={view.user.id}>
|
||||
<Flex align="center">
|
||||
<Avatar src={view.user.avatarUrl} />{' '}
|
||||
<UserName>{view.user.name}</UserName>
|
||||
</Flex>
|
||||
</li>
|
||||
))}
|
||||
</List>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentViewers;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentViewers from './DocumentViewers';
|
||||
export default DocumentViewers;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentViews from './DocumentViews';
|
||||
export default DocumentViews;
|
||||
@@ -0,0 +1,113 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import importFile from "utils/importFile";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
location: Object,
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
svg { fill: ${(props) => props.theme.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${(props) => props.theme.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
|
||||
onDropAccepted = async (files = []) => {
|
||||
if (importingLock) return;
|
||||
|
||||
this.isImporting = true;
|
||||
importingLock = true;
|
||||
|
||||
try {
|
||||
let collectionId = this.props.collectionId;
|
||||
const documentId = this.props.documentId;
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, "Document not available");
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await importFile({
|
||||
documents: this.props.documents,
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept="text/markdown, text/plain"
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
disablePreview
|
||||
multiple
|
||||
{...rest}
|
||||
>
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("documents")(withRouter(DropToImport));
|
||||
@@ -1,105 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { injectGlobal } from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import importFile from 'utils/importFile';
|
||||
import invariant from 'invariant';
|
||||
import _ from 'lodash';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
|
||||
type Props = {
|
||||
children?: React$Element<any>,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
history: Object,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line
|
||||
injectGlobal`
|
||||
.activeDropZone {
|
||||
background: ${color.slateDark};
|
||||
svg { fill: ${color.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${color.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DropToImport extends Component {
|
||||
@observable isImporting: boolean = false;
|
||||
props: Props;
|
||||
|
||||
onDropAccepted = async (files = []) => {
|
||||
this.isImporting = true;
|
||||
|
||||
try {
|
||||
let collectionId = this.props.collectionId;
|
||||
const documentId = this.props.documentId;
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, 'Document not available');
|
||||
collectionId = document.collection.id;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await importFile({
|
||||
documents: this.props.documents,
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// TODO: show error alert.
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const props = _.omit(
|
||||
this.props,
|
||||
'history',
|
||||
'documentId',
|
||||
'collectionId',
|
||||
'documents',
|
||||
'disabled',
|
||||
'menuOpen'
|
||||
);
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept="text/markdown, text/plain"
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={{}}
|
||||
disableClick
|
||||
disablePreview
|
||||
multiple
|
||||
{...props}
|
||||
>
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('documents')(DropToImport);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DropToImport from './DropToImport';
|
||||
export default DropToImport;
|
||||
@@ -1,73 +1,226 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { PortalWithState } from 'react-portal';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label: React.Element<*>,
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: React.Element<*>,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: "left" | "right" | "center",
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends Component {
|
||||
props: Props;
|
||||
@observable top: number;
|
||||
@observable right: number;
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
handleOpen = (openPortal: SyntheticEvent => *) => {
|
||||
return (ev: SyntheticMouseEvent) => {
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: "left" | "right" | "center";
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, 'why you not here');
|
||||
invariant(document.body, "why you not here");
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
const bodyRect = document.body.getBoundingClientRect();
|
||||
const targetRect = currentTarget.getBoundingClientRect();
|
||||
this.top = targetRect.bottom - bodyRect.top;
|
||||
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || "left";
|
||||
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === "static") {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === "left") {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === "center") {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === "function") {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === "left" || this.position === "right") {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === "right") {
|
||||
this.position = "left";
|
||||
this.left = undefined;
|
||||
} else if (this.position === "left") {
|
||||
this.position = "right";
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, label, children } = this.props;
|
||||
const { className, hover, label, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.props.onOpen}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, portal }) => (
|
||||
<React.Fragment>
|
||||
<Label onClick={this.handleOpen(openPortal)}>{label}</Label>
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Menu
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}}
|
||||
style={this.props.style}
|
||||
<Position
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === "function"
|
||||
? undefined
|
||||
: (ev) => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
@@ -76,32 +229,58 @@ class DropdownMenu extends Component {
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: 'center',
|
||||
align: 'center',
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: 1000;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
|
||||
position: absolute;
|
||||
right: ${({ right }) => right}px;
|
||||
top: ${({ top }) => top}px;
|
||||
z-index: 1000;
|
||||
border: ${color.slateLight};
|
||||
background: ${color.white};
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
min-width: 160px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default DropdownMenu;
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick?: SyntheticEvent => void,
|
||||
children?: React.Element<any>,
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
};
|
||||
|
||||
const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => {
|
||||
const DropdownMenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<MenuItem onClick={onClick} {...rest}>
|
||||
<MenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? "transparent" : undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
@@ -19,27 +41,48 @@ const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => {
|
||||
const MenuItem = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
color: ${color.slateDark};
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
svg:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
svg {
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
color: ${color.white};
|
||||
background: ${color.primary};
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${color.white};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
outline: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default DropdownMenuItem;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu } from './DropdownMenu';
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem';
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
id?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
class Editor extends React.Component<PropsWithRef> {
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: this.props.id });
|
||||
return result.url;
|
||||
};
|
||||
|
||||
onClickLink = (href: string) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href)) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
this.props.history.push(navigateTo);
|
||||
} else {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
if (this.props.ui) {
|
||||
this.props.ui.showToast(message);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
flex-grow: ${(props) => (props.grow ? 1 : 0)};
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
.notice-block.tip,
|
||||
.notice-block.warning {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
|
||||
text-decoration: none !important;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${(props) => props.theme.text};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props}>
|
||||
<Span>{children}</Span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Span = styled.span`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
|
||||
|
||||
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
|
||||
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
|
||||
));
|
||||
@@ -1,351 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Value, Change } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import keydown from 'react-keydown';
|
||||
import type { SlateNodeProps, Plugin } from './types';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import ClickablePadding from './components/ClickablePadding';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import BlockInsert from './components/BlockInsert';
|
||||
import Placeholder from './components/Placeholder';
|
||||
import Contents from './components/Contents';
|
||||
import Markdown from './serializer';
|
||||
import createPlugins from './plugins';
|
||||
import { insertImageFile } from './changes';
|
||||
import renderMark from './marks';
|
||||
import createRenderNode from './nodes';
|
||||
import schema from './schema';
|
||||
import { isModKey } from './utils';
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
onChange: Change => *,
|
||||
onSave: ({ redirect?: boolean, publish?: boolean }) => *,
|
||||
onCancel: () => void,
|
||||
onImageUploadStart: () => void,
|
||||
onImageUploadStop: () => void,
|
||||
emoji?: string,
|
||||
readOnly: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
class MarkdownEditor extends Component {
|
||||
props: Props;
|
||||
editor: Editor;
|
||||
renderNode: SlateNodeProps => *;
|
||||
plugins: Plugin[];
|
||||
@observable editorValue: Value;
|
||||
@observable editorLoaded: boolean = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.renderNode = createRenderNode({
|
||||
onInsertImage: this.insertImageFile,
|
||||
});
|
||||
this.plugins = createPlugins({
|
||||
onImageUploadStart: props.onImageUploadStart,
|
||||
onImageUploadStop: props.onImageUploadStop,
|
||||
});
|
||||
|
||||
this.editorValue = Markdown.deserialize(props.text);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.readOnly) return;
|
||||
if (this.props.text) {
|
||||
this.focusAtEnd();
|
||||
} else {
|
||||
this.focusAtStart();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.readOnly && !this.props.readOnly) {
|
||||
this.focusAtEnd();
|
||||
}
|
||||
}
|
||||
|
||||
setEditorRef = (ref: Editor) => {
|
||||
this.editor = ref;
|
||||
// Force re-render to show ToC (<Content />)
|
||||
this.editorLoaded = true;
|
||||
};
|
||||
|
||||
onChange = (change: Change) => {
|
||||
if (this.editorValue !== change.value) {
|
||||
this.props.onChange(Markdown.serialize(change.value));
|
||||
this.editorValue = change.value;
|
||||
}
|
||||
};
|
||||
|
||||
handleDrop = async (ev: SyntheticEvent) => {
|
||||
if (this.props.readOnly) return;
|
||||
// check if this event was already handled by the Editor
|
||||
if (ev.isDefaultPrevented()) return;
|
||||
|
||||
// otherwise we'll handle this
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const files = getDataTransferFiles(ev);
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
await this.insertImageFile(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
insertImageFile = (file: window.File) => {
|
||||
this.editor.change(change =>
|
||||
change.call(
|
||||
insertImageFile,
|
||||
file,
|
||||
this.editor,
|
||||
this.props.onImageUploadStart,
|
||||
this.props.onImageUploadStop
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
cancelEvent = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
// Handling of keyboard shortcuts outside of editor focus
|
||||
@keydown('meta+s')
|
||||
onSave(ev: SyntheticKeyboardEvent) {
|
||||
if (this.props.readOnly) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onSave({ redirect: false });
|
||||
}
|
||||
|
||||
@keydown('meta+enter')
|
||||
onSaveAndExit(ev: SyntheticKeyboardEvent) {
|
||||
if (this.props.readOnly) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onSave({ redirect: true });
|
||||
}
|
||||
|
||||
@keydown('esc')
|
||||
onCancel() {
|
||||
if (this.props.readOnly) return;
|
||||
this.props.onCancel();
|
||||
}
|
||||
|
||||
// Handling of keyboard shortcuts within editor focus
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent, change: Change) => {
|
||||
if (!isModKey(ev)) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case 's':
|
||||
this.onSave(ev);
|
||||
return change;
|
||||
case 'Enter':
|
||||
this.onSaveAndExit(ev);
|
||||
return change;
|
||||
case 'Escape':
|
||||
this.onCancel();
|
||||
return change;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
focusAtStart = () => {
|
||||
this.editor.change(change =>
|
||||
change.collapseToStartOf(change.value.document).focus()
|
||||
);
|
||||
};
|
||||
|
||||
focusAtEnd = () => {
|
||||
this.editor.change(change =>
|
||||
change.collapseToEndOf(change.value.document).focus()
|
||||
);
|
||||
};
|
||||
|
||||
render = () => {
|
||||
const { readOnly, emoji, onSave } = this.props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onDrop={this.handleDrop}
|
||||
onDragOver={this.cancelEvent}
|
||||
onDragEnter={this.cancelEvent}
|
||||
align="flex-start"
|
||||
justify="center"
|
||||
auto
|
||||
>
|
||||
<MaxWidth column auto>
|
||||
<Header onClick={this.focusAtStart} readOnly={readOnly} />
|
||||
{readOnly &&
|
||||
this.editorLoaded &&
|
||||
this.editor && <Contents editor={this.editor} />}
|
||||
{!readOnly &&
|
||||
this.editor && (
|
||||
<Toolbar value={this.editorValue} editor={this.editor} />
|
||||
)}
|
||||
{!readOnly &&
|
||||
this.editor && (
|
||||
<BlockInsert
|
||||
editor={this.editor}
|
||||
onInsertImage={this.insertImageFile}
|
||||
/>
|
||||
)}
|
||||
<StyledEditor
|
||||
innerRef={this.setEditorRef}
|
||||
placeholder="Start with a title…"
|
||||
bodyPlaceholder="…the rest is your canvas"
|
||||
plugins={this.plugins}
|
||||
emoji={emoji}
|
||||
value={this.editorValue}
|
||||
renderNode={this.renderNode}
|
||||
renderMark={renderMark}
|
||||
schema={schema}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
onSave={onSave}
|
||||
readOnly={readOnly}
|
||||
spellCheck={!readOnly}
|
||||
/>
|
||||
<ClickablePadding
|
||||
onClick={!readOnly ? this.focusAtEnd : undefined}
|
||||
grow
|
||||
/>
|
||||
</MaxWidth>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const MaxWidth = styled(Flex)`
|
||||
padding: 0 20px;
|
||||
max-width: 100vw;
|
||||
height: 100%;
|
||||
|
||||
${breakpoint('tablet')`
|
||||
padding: 0;
|
||||
margin: 0 60px;
|
||||
max-width: 46em;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-end;
|
||||
${({ readOnly }) => !readOnly && 'cursor: text;'};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEditor = styled(Editor)`
|
||||
font-weight: 400;
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
width: 100%;
|
||||
color: #1b2830;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h1:first-of-type {
|
||||
${Placeholder} {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
p:nth-child(2) {
|
||||
${Placeholder} {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0.1em;
|
||||
padding-left: 1em;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: ${({ readOnly }) => (readOnly ? 'underline' : 'none')};
|
||||
}
|
||||
|
||||
li p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.todoList {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
||||
.todoList {
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.todo {
|
||||
span:last-child:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid #efefef;
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 5px 20px 5px 0;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -1,93 +0,0 @@
|
||||
// @flow
|
||||
import { Change } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import uuid from 'uuid';
|
||||
import EditList from './plugins/EditList';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
|
||||
const { changes } = EditList;
|
||||
|
||||
type Options = {
|
||||
type: string | Object,
|
||||
wrapper?: string | Object,
|
||||
};
|
||||
|
||||
export function splitAndInsertBlock(change: Change, options: Options) {
|
||||
const { type, wrapper } = options;
|
||||
const parent = change.value.document.getParent(change.value.startBlock.key);
|
||||
|
||||
// lists get some special treatment
|
||||
if (parent && parent.type === 'list-item') {
|
||||
change
|
||||
.collapseToStart()
|
||||
.call(changes.splitListItem)
|
||||
.collapseToEndOfPreviousBlock()
|
||||
.call(changes.unwrapList);
|
||||
}
|
||||
|
||||
if (wrapper) change.collapseToStartOfNextBlock();
|
||||
|
||||
// this is a hack as insertBlock with normalize: false does not appear to work
|
||||
change.insertBlock('paragraph').setBlock(type, { normalize: false });
|
||||
|
||||
if (wrapper) change.wrapBlock(wrapper);
|
||||
return change;
|
||||
}
|
||||
|
||||
export async function insertImageFile(
|
||||
change: Change,
|
||||
file: window.File,
|
||||
editor: Editor,
|
||||
onImageUploadStart: () => void,
|
||||
onImageUploadStop: () => void
|
||||
) {
|
||||
onImageUploadStart();
|
||||
try {
|
||||
// load the file as a data URL
|
||||
const id = uuid.v4();
|
||||
const alt = '';
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => {
|
||||
const src = reader.result;
|
||||
const node = {
|
||||
type: 'image',
|
||||
isVoid: true,
|
||||
data: { src, id, alt, loading: true },
|
||||
};
|
||||
|
||||
// insert / replace into document as uploading placeholder replacing
|
||||
// empty paragraphs if available.
|
||||
if (
|
||||
!change.value.startBlock.text &&
|
||||
change.value.startBlock.type === 'paragraph'
|
||||
) {
|
||||
change.setBlock(node);
|
||||
} else {
|
||||
change.insertBlock(node);
|
||||
}
|
||||
|
||||
editor.onChange(change);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// now we have a placeholder, start the upload
|
||||
const asset = await uploadFile(file);
|
||||
const src = asset.url;
|
||||
|
||||
// we dont use the original change provided to the callback here
|
||||
// as the state may have changed significantly in the time it took to
|
||||
// upload the file.
|
||||
const placeholder = editor.value.document.findDescendant(
|
||||
node => node.data && node.data.get('id') === id
|
||||
);
|
||||
|
||||
change.setNodeByKey(placeholder.key, {
|
||||
data: { src, alt, loading: false },
|
||||
});
|
||||
editor.onChange(change);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
onImageUploadStop();
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { Portal } from 'react-portal';
|
||||
import { Node } from 'slate';
|
||||
import { Editor, findDOMNode } from 'slate-react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import PlusIcon from 'components/Icon/PlusIcon';
|
||||
|
||||
type Props = {
|
||||
editor: Editor,
|
||||
};
|
||||
|
||||
function findClosestRootNode(value, ev) {
|
||||
let previous;
|
||||
|
||||
for (const node of value.document.nodes) {
|
||||
const element = findDOMNode(node);
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.top > ev.clientY) return previous;
|
||||
previous = { node, element, bounds };
|
||||
}
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class BlockInsert extends Component {
|
||||
props: Props;
|
||||
mouseMoveTimeout: number;
|
||||
mouseMovementSinceClick: number = 0;
|
||||
lastClientX: number = 0;
|
||||
lastClientY: number = 0;
|
||||
|
||||
@observable closestRootNode: Node;
|
||||
@observable active: boolean = false;
|
||||
@observable top: number;
|
||||
@observable left: number;
|
||||
|
||||
componentDidMount = () => {
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
};
|
||||
|
||||
setInactive = () => {
|
||||
this.active = false;
|
||||
};
|
||||
|
||||
handleMouseMove = (ev: SyntheticMouseEvent) => {
|
||||
const windowWidth = window.innerWidth / 2.5;
|
||||
const result = findClosestRootNode(this.props.editor.value, ev);
|
||||
const movementThreshold = 200;
|
||||
|
||||
this.mouseMovementSinceClick +=
|
||||
Math.abs(this.lastClientX - ev.clientX) +
|
||||
Math.abs(this.lastClientY - ev.clientY);
|
||||
this.lastClientX = ev.clientX;
|
||||
this.lastClientY = ev.clientY;
|
||||
|
||||
this.active =
|
||||
ev.clientX < windowWidth &&
|
||||
this.mouseMovementSinceClick > movementThreshold;
|
||||
|
||||
if (result) {
|
||||
this.closestRootNode = result.node;
|
||||
|
||||
// do not show block menu on title heading or editor
|
||||
const firstNode = this.props.editor.value.document.nodes.first();
|
||||
if (
|
||||
result.node === firstNode ||
|
||||
result.node.type === 'block-toolbar' ||
|
||||
!!result.node.text.trim()
|
||||
) {
|
||||
this.left = -1000;
|
||||
} else {
|
||||
this.left = Math.round(result.bounds.left - 20);
|
||||
this.top = Math.round(result.bounds.top + window.scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
clearTimeout(this.mouseMoveTimeout);
|
||||
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = (ev: SyntheticMouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.mouseMovementSinceClick = 0;
|
||||
this.active = false;
|
||||
|
||||
const { editor } = this.props;
|
||||
|
||||
editor.change(change => {
|
||||
// remove any existing toolbars in the document as a fail safe
|
||||
editor.value.document.nodes.forEach(node => {
|
||||
if (node.type === 'block-toolbar') {
|
||||
change.removeNodeByKey(node.key);
|
||||
}
|
||||
});
|
||||
|
||||
change.collapseToStartOf(this.closestRootNode);
|
||||
|
||||
// if we're on an empty paragraph then just replace it with the block
|
||||
// toolbar. Otherwise insert the toolbar as an extra Node.
|
||||
if (
|
||||
!this.closestRootNode.text.trim() &&
|
||||
this.closestRootNode.type === 'paragraph'
|
||||
) {
|
||||
change.setNodeByKey(this.closestRootNode.key, {
|
||||
type: 'block-toolbar',
|
||||
isVoid: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = { top: `${this.top}px`, left: `${this.left}px` };
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Trigger active={this.active} style={style}>
|
||||
<PlusIcon onClick={this.handleClick} color={color.slate} />
|
||||
</Trigger>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Trigger = styled.div`
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
background-color: ${color.white};
|
||||
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
line-height: 0;
|
||||
margin-left: -10px;
|
||||
box-shadow: inset 0 0 0 2px ${color.slate};
|
||||
border-radius: 100%;
|
||||
transform: scale(0.9);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${color.slate};
|
||||
|
||||
svg {
|
||||
fill: ${color.white};
|
||||
}
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
`
|
||||
transform: scale(1);
|
||||
opacity: .9;
|
||||
`};
|
||||
`;
|
||||
@@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
onClick?: ?Function,
|
||||
grow?: boolean,
|
||||
};
|
||||
|
||||
const ClickablePadding = (props: Props) => {
|
||||
return <Container grow={props.grow} onClick={props.onClick} />;
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 50vh;
|
||||
padding-top: 50px;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
|
||||
${({ grow }) => grow && `flex-grow: 1;`};
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
||||
@@ -1,52 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import CopyButton from './CopyButton';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
function getCopyText(node) {
|
||||
return node.nodes.reduce((memo, line) => `${memo}${line.text}\n`, '');
|
||||
}
|
||||
|
||||
export default function Code({
|
||||
children,
|
||||
node,
|
||||
readOnly,
|
||||
attributes,
|
||||
}: SlateNodeProps) {
|
||||
// TODO: There is a currently a bug in slate-prism that prevents code elements
|
||||
// with a language class name from formatting correctly on first load.
|
||||
// const language = node.data.get('language') || 'javascript';
|
||||
|
||||
return (
|
||||
<Container {...attributes} spellCheck={false}>
|
||||
{readOnly && <CopyButton text={getCopyText(node)} />}
|
||||
<code>{children}</code>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
position: relative;
|
||||
background: ${color.smokeLight};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.smokeDark};
|
||||
|
||||
code {
|
||||
display: block;
|
||||
overflow-x: scroll;
|
||||
padding: 0.5em 1em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,163 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Editor } from 'slate-react';
|
||||
import { Block } from 'slate';
|
||||
import { List } from 'immutable';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import headingToSlug from '../headingToSlug';
|
||||
|
||||
type Props = {
|
||||
editor: Editor,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Contents extends Component {
|
||||
props: Props;
|
||||
@observable activeHeading: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('scroll', this.updateActiveHeading);
|
||||
this.updateActiveHeading();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('scroll', this.updateActiveHeading);
|
||||
}
|
||||
|
||||
updateActiveHeading = () => {
|
||||
const elements = this.headingElements;
|
||||
if (!elements.length) return;
|
||||
|
||||
let activeHeading = elements[0].id;
|
||||
|
||||
for (const element of elements) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.top <= 0) activeHeading = element.id;
|
||||
}
|
||||
|
||||
this.activeHeading = activeHeading;
|
||||
};
|
||||
|
||||
get headingElements(): HTMLElement[] {
|
||||
const elements = [];
|
||||
const tagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
|
||||
for (const tagName of tagNames) {
|
||||
for (const ele of document.getElementsByTagName(tagName)) {
|
||||
elements.push(ele);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
get headings(): List<Block> {
|
||||
const { editor } = this.props;
|
||||
|
||||
return editor.value.document.nodes.filter((node: Block) => {
|
||||
if (!node.text) return false;
|
||||
return node.type.match(/^heading/);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { editor } = this.props;
|
||||
|
||||
// If there are one or less headings in the document no need for a minimap
|
||||
if (this.headings.size <= 1) return null;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Sections>
|
||||
{this.headings.map(heading => {
|
||||
const slug = headingToSlug(editor.value.document, heading);
|
||||
const active = this.activeHeading === slug;
|
||||
|
||||
return (
|
||||
<ListItem type={heading.type} active={active} key={slug}>
|
||||
<Anchor href={`#${slug}`} active={active}>
|
||||
{heading.text}
|
||||
</Anchor>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</Sections>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 150px;
|
||||
z-index: 100;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
display: block;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Anchor = styled.a`
|
||||
color: ${props => (props.active ? color.slateDark : color.slate)};
|
||||
font-weight: ${props => (props.active ? 500 : 400)};
|
||||
opacity: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
margin-right: -5px;
|
||||
padding: 2px 0;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
color: ${color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
position: relative;
|
||||
margin-left: ${props => (props.type.match(/heading[12]/) ? '8px' : '16px')};
|
||||
text-align: right;
|
||||
color: ${color.slate};
|
||||
padding-right: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:after {
|
||||
color: ${props => (props.active ? color.slateDark : color.slate)};
|
||||
content: "${props => (props.type.match(/heading[12]/) ? '—' : '–')}";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Sections = styled.ol`
|
||||
margin: 0 0 0 -8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
transition-delay: 1s;
|
||||
transition: width 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
width: 300px;
|
||||
transition-delay: 0s;
|
||||
|
||||
${Anchor} {
|
||||
opacity: 1;
|
||||
margin-right: 0;
|
||||
background: ${color.white};
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Contents;
|
||||
@@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import styled from 'styled-components';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
|
||||
@observer
|
||||
class CopyButton extends Component {
|
||||
@observable copied: boolean = false;
|
||||
copiedTimeout: ?number;
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.copiedTimeout);
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
this.copied = true;
|
||||
this.copiedTimeout = setTimeout(() => (this.copied = false), 3000);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StyledCopyToClipboard onCopy={this.handleCopy} {...this.props}>
|
||||
<span>{this.copied ? 'Copied!' : 'Copy'}</span>
|
||||
</StyledCopyToClipboard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledCopyToClipboard = styled(CopyToClipboard)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 50ms ease-in-out;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
background: ${color.smoke};
|
||||
border-radius: 0 2px 0 2px;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${color.smokeDark};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CopyButton;
|
||||
@@ -1,97 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Document } from 'slate';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import styled from 'styled-components';
|
||||
import headingToSlug from '../headingToSlug';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
type Props = SlateNodeProps & {
|
||||
component: string,
|
||||
className: string,
|
||||
placeholder: string,
|
||||
};
|
||||
|
||||
function Heading(props: Props) {
|
||||
const {
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
className,
|
||||
attributes,
|
||||
} = props;
|
||||
const parentIsDocument = parent instanceof Document;
|
||||
const firstHeading = parentIsDocument && parent.nodes.first() === node;
|
||||
const showPlaceholder = placeholder && firstHeading && !node.text;
|
||||
const slugish = headingToSlug(editor.value.document, node);
|
||||
const showHash = readOnly && !!slugish;
|
||||
const Component = component;
|
||||
const emoji = editor.props.emoji || '';
|
||||
const title = node.text.trim();
|
||||
const startsWithEmojiAndSpace =
|
||||
emoji && title.match(new RegExp(`^${emoji}\\s`));
|
||||
|
||||
return (
|
||||
<Component {...attributes} id={slugish} className={className}>
|
||||
<Wrapper hasEmoji={startsWithEmojiAndSpace}>{children}</Wrapper>
|
||||
{showPlaceholder && (
|
||||
<Placeholder contentEditable={false}>
|
||||
{editor.props.placeholder}
|
||||
</Placeholder>
|
||||
)}
|
||||
{showHash && (
|
||||
<Anchor name={slugish} href={`#${slugish}`}>
|
||||
#
|
||||
</Anchor>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: inline;
|
||||
margin-left: ${(props: SlateNodeProps) => (props.hasEmoji ? '-1.2em' : 0)};
|
||||
`;
|
||||
|
||||
const Anchor = styled.a`
|
||||
visibility: hidden;
|
||||
padding-left: 0.25em;
|
||||
color: #dedede;
|
||||
|
||||
&:hover {
|
||||
color: #cdcdcd;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledHeading = styled(Heading)`
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
${Anchor} {
|
||||
visibility: visible;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const Heading1 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h1" {...props} />
|
||||
);
|
||||
export const Heading2 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h2" {...props} />
|
||||
);
|
||||
export const Heading3 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h3" {...props} />
|
||||
);
|
||||
export const Heading4 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h4" {...props} />
|
||||
);
|
||||
export const Heading5 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h5" {...props} />
|
||||
);
|
||||
export const Heading6 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h6" {...props} />
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
function HorizontalRule(props: SlateNodeProps) {
|
||||
const { editor, node, attributes } = props;
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
return <StyledHr active={active} {...attributes} />;
|
||||
}
|
||||
|
||||
const StyledHr = styled.hr`
|
||||
padding-top: 0.75em;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid
|
||||
${props => (props.active ? color.slate : color.slateLight)};
|
||||
`;
|
||||
|
||||
export default HorizontalRule;
|
||||
@@ -1,104 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import ImageZoom from 'react-medium-image-zoom';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
class Image extends Component {
|
||||
props: SlateNodeProps;
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const alt = ev.target.value;
|
||||
const { editor, node } = this.props;
|
||||
const data = node.data.toObject();
|
||||
|
||||
editor.change(change =>
|
||||
change.setNodeByKey(node.key, { data: { ...data, alt } })
|
||||
);
|
||||
};
|
||||
|
||||
handleClick = (ev: SyntheticInputEvent) => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { attributes, editor, node, readOnly } = this.props;
|
||||
const loading = node.data.get('loading');
|
||||
const caption = node.data.get('alt');
|
||||
const src = node.data.get('src');
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
const showCaption = !readOnly || caption;
|
||||
|
||||
return (
|
||||
<CenteredImage>
|
||||
{!readOnly ? (
|
||||
<StyledImg
|
||||
{...attributes}
|
||||
src={src}
|
||||
alt={caption}
|
||||
active={active}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<ImageZoom
|
||||
image={{
|
||||
src,
|
||||
alt: caption,
|
||||
style: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
...attributes,
|
||||
}}
|
||||
shouldRespectMaxDimension
|
||||
/>
|
||||
)}
|
||||
{showCaption && (
|
||||
<Caption
|
||||
type="text"
|
||||
placeholder="Write a caption"
|
||||
onChange={this.handleChange}
|
||||
onClick={this.handleClick}
|
||||
defaultValue={caption}
|
||||
contentEditable={false}
|
||||
disabled={readOnly}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</CenteredImage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledImg = styled.img`
|
||||
max-width: 100%;
|
||||
box-shadow: ${props => (props.active ? `0 0 0 2px ${color.slate}` : '0')};
|
||||
border-radius: ${props => (props.active ? `2px` : '0')};
|
||||
opacity: ${props => (props.loading ? 0.5 : 1)};
|
||||
`;
|
||||
|
||||
const CenteredImage = styled.span`
|
||||
display: block;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Caption = styled.input`
|
||||
border: 0;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: ${color.slate};
|
||||
padding: 2px 0;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
background: none;
|
||||
|
||||
&::placeholder {
|
||||
color: ${color.slate};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Image;
|
||||
@@ -1,14 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
const InlineCode = styled.code.attrs({
|
||||
spellCheck: false,
|
||||
})`
|
||||
padding: 0.25em;
|
||||
background: ${color.smoke};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.smokeDark};
|
||||
`;
|
||||
|
||||
export default InlineCode;
|
||||
@@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Link as InternalLink } from 'react-router-dom';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
|
||||
function getPathFromUrl(href: string) {
|
||||
if (href[0] === '/') return href;
|
||||
|
||||
try {
|
||||
const parsed = new URL(href);
|
||||
return parsed.pathname;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalUrl(href: string) {
|
||||
if (href[0] === '/') return true;
|
||||
|
||||
try {
|
||||
const outline = new URL(BASE_URL);
|
||||
const parsed = new URL(href);
|
||||
return parsed.hostname === outline.hostname;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Link({
|
||||
attributes,
|
||||
node,
|
||||
children,
|
||||
readOnly,
|
||||
}: SlateNodeProps) {
|
||||
const href = node.data.get('href');
|
||||
const path = getPathFromUrl(href);
|
||||
|
||||
if (isInternalUrl(href) && readOnly) {
|
||||
return (
|
||||
<InternalLink {...attributes} to={path}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<a {...attributes} href={readOnly ? href : undefined} target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import TodoItem from './TodoItem';
|
||||
|
||||
export default function ListItem({
|
||||
children,
|
||||
node,
|
||||
attributes,
|
||||
...props
|
||||
}: SlateNodeProps) {
|
||||
const checked = node.data.get('checked');
|
||||
|
||||
if (checked !== undefined) {
|
||||
return (
|
||||
<TodoItem node={node} attributes={attributes} {...props}>
|
||||
{children}
|
||||
</TodoItem>
|
||||
);
|
||||
}
|
||||
return <li {...attributes}>{children}</li>;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Document } from 'slate';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
export default function Link({
|
||||
attributes,
|
||||
editor,
|
||||
node,
|
||||
parent,
|
||||
children,
|
||||
readOnly,
|
||||
}: SlateNodeProps) {
|
||||
const parentIsDocument = parent instanceof Document;
|
||||
const firstParagraph = parent && parent.nodes.get(1) === node;
|
||||
const lastParagraph = parent && parent.nodes.last() === node;
|
||||
const showPlaceholder =
|
||||
!readOnly &&
|
||||
parentIsDocument &&
|
||||
firstParagraph &&
|
||||
lastParagraph &&
|
||||
!node.text;
|
||||
|
||||
return (
|
||||
<p {...attributes}>
|
||||
{children}
|
||||
{showPlaceholder && (
|
||||
<Placeholder contentEditable={false}>
|
||||
{editor.props.bodyPlaceholder}
|
||||
</Placeholder>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.span`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
color: #b1becc;
|
||||
`;
|
||||
@@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
|
||||
export default class TodoItem extends Component {
|
||||
props: SlateNodeProps;
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const checked = ev.target.checked;
|
||||
const { editor, node } = this.props;
|
||||
editor.change(change =>
|
||||
change.setNodeByKey(node.key, { data: { checked } })
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, node, attributes, readOnly } = this.props;
|
||||
const checked = node.data.get('checked');
|
||||
|
||||
return (
|
||||
<ListItem checked={checked} {...attributes}>
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={this.handleChange}
|
||||
disabled={readOnly}
|
||||
contentEditable={false}
|
||||
/>
|
||||
{children}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ListItem = styled.li`
|
||||
padding-left: 1.4em;
|
||||
position: relative;
|
||||
|
||||
> p > span {
|
||||
color: ${props => (props.checked ? color.slateDark : 'inherit')};
|
||||
text-decoration: ${props => (props.checked ? 'line-through' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.4em;
|
||||
`;
|
||||
@@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
const TodoList = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0 !important;
|
||||
|
||||
ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
export default TodoList;
|
||||
@@ -1,220 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import keydown from 'react-keydown';
|
||||
import styled from 'styled-components';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
import Heading1Icon from 'components/Icon/Heading1Icon';
|
||||
import Heading2Icon from 'components/Icon/Heading2Icon';
|
||||
import BlockQuoteIcon from 'components/Icon/BlockQuoteIcon';
|
||||
import ImageIcon from 'components/Icon/ImageIcon';
|
||||
import CodeIcon from 'components/Icon/CodeIcon';
|
||||
import BulletedListIcon from 'components/Icon/BulletedListIcon';
|
||||
import OrderedListIcon from 'components/Icon/OrderedListIcon';
|
||||
import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon';
|
||||
import TodoListIcon from 'components/Icon/TodoListIcon';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import ToolbarButton from './components/ToolbarButton';
|
||||
import type { SlateNodeProps } from '../../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import { splitAndInsertBlock } from '../../changes';
|
||||
|
||||
type Props = SlateNodeProps & {
|
||||
onInsertImage: *,
|
||||
};
|
||||
|
||||
type Options = {
|
||||
type: string | Object,
|
||||
wrapper?: string | Object,
|
||||
};
|
||||
|
||||
class BlockToolbar extends Component {
|
||||
props: Props;
|
||||
bar: HTMLDivElement;
|
||||
file: HTMLInputElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
handleOutsideMouseClick = (ev: SyntheticMouseEvent) => {
|
||||
const element = findDOMNode(this.bar);
|
||||
|
||||
if (
|
||||
!element ||
|
||||
(ev.target instanceof Node && element.contains(ev.target)) ||
|
||||
(ev.button && ev.button !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.removeSelf(ev);
|
||||
};
|
||||
|
||||
@keydown('esc')
|
||||
removeSelf(ev: SyntheticEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.props.editor.change(change =>
|
||||
change.setNodeByKey(this.props.node.key, {
|
||||
type: 'paragraph',
|
||||
text: '',
|
||||
isVoid: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
insertBlock = (
|
||||
options: Options,
|
||||
cursorPosition: 'before' | 'on' | 'after' = 'on'
|
||||
) => {
|
||||
const { editor } = this.props;
|
||||
|
||||
editor.change(change => {
|
||||
change
|
||||
.collapseToEndOf(this.props.node)
|
||||
.call(splitAndInsertBlock, options)
|
||||
.removeNodeByKey(this.props.node.key)
|
||||
.collapseToEnd();
|
||||
|
||||
if (cursorPosition === 'before') change.collapseToStartOfPreviousBlock();
|
||||
if (cursorPosition === 'after') change.collapseToStartOfNextBlock();
|
||||
return change.focus();
|
||||
});
|
||||
};
|
||||
|
||||
handleClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
|
||||
switch (type) {
|
||||
case 'heading1':
|
||||
case 'heading2':
|
||||
case 'block-quote':
|
||||
case 'code':
|
||||
return this.insertBlock({ type });
|
||||
case 'horizontal-rule':
|
||||
return this.insertBlock(
|
||||
{
|
||||
type: { type: 'horizontal-rule', isVoid: true },
|
||||
},
|
||||
'after'
|
||||
);
|
||||
case 'bulleted-list':
|
||||
return this.insertBlock({
|
||||
type: 'list-item',
|
||||
wrapper: 'bulleted-list',
|
||||
});
|
||||
case 'ordered-list':
|
||||
return this.insertBlock({
|
||||
type: 'list-item',
|
||||
wrapper: 'ordered-list',
|
||||
});
|
||||
case 'todo-list':
|
||||
return this.insertBlock({
|
||||
type: { type: 'list-item', data: { checked: false } },
|
||||
wrapper: 'todo-list',
|
||||
});
|
||||
case 'image':
|
||||
return this.onPickImage();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
onPickImage = () => {
|
||||
// simulate a click on the file upload input element
|
||||
this.file.click();
|
||||
};
|
||||
|
||||
onImagePicked = async (ev: SyntheticEvent) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
for (const file of files) {
|
||||
await this.props.onInsertImage(file);
|
||||
}
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
return (
|
||||
<ToolbarButton onMouseDown={ev => this.handleClickBlock(ev, type)}>
|
||||
<IconClass color={color.text} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { editor, attributes, node } = this.props;
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
|
||||
return (
|
||||
<Bar active={active} {...attributes} ref={ref => (this.bar = ref)}>
|
||||
<HiddenInput
|
||||
type="file"
|
||||
innerRef={ref => (this.file = ref)}
|
||||
onChange={this.onImagePicked}
|
||||
accept="image/*"
|
||||
/>
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
|
||||
{this.renderBlockButton('ordered-list', OrderedListIcon)}
|
||||
{this.renderBlockButton('todo-list', TodoListIcon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('block-quote', BlockQuoteIcon)}
|
||||
{this.renderBlockButton('code', CodeIcon)}
|
||||
{this.renderBlockButton('horizontal-rule', HorizontalRuleIcon)}
|
||||
{this.renderBlockButton('image', ImageIcon)}
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: ${color.smokeDark};
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const Bar = styled(Flex)`
|
||||
z-index: 100;
|
||||
animation: ${fadeIn} 150ms ease-in-out;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
background: ${color.smoke};
|
||||
height: 44px;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: ${color.smoke};
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: auto;
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const HiddenInput = styled.input`
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export default BlockToolbar;
|
||||
@@ -1,186 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Portal } from 'react-portal';
|
||||
import { Editor, findDOMNode } from 'slate-react';
|
||||
import { Node, Value } from 'slate';
|
||||
import styled from 'styled-components';
|
||||
import FormattingToolbar from './components/FormattingToolbar';
|
||||
import LinkToolbar from './components/LinkToolbar';
|
||||
|
||||
function getLinkInSelection(value): any {
|
||||
try {
|
||||
const selectedLinks = value.document
|
||||
.getInlinesAtRange(value.selection)
|
||||
.filter(node => node.type === 'link');
|
||||
|
||||
if (selectedLinks.size) {
|
||||
const link = selectedLinks.first();
|
||||
if (value.selection.hasEdgeIn(link)) return link;
|
||||
}
|
||||
} catch (err) {
|
||||
// It's okay.
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class Toolbar extends Component {
|
||||
@observable active: boolean = false;
|
||||
@observable link: ?Node;
|
||||
@observable top: string = '';
|
||||
@observable left: string = '';
|
||||
@observable mouseDown: boolean = false;
|
||||
|
||||
props: {
|
||||
editor: Editor,
|
||||
value: Value,
|
||||
};
|
||||
|
||||
menu: HTMLElement;
|
||||
|
||||
componentDidMount = () => {
|
||||
this.update();
|
||||
window.addEventListener('mousedown', this.handleMouseDown);
|
||||
window.addEventListener('mouseup', this.handleMouseUp);
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener('mousedown', this.handleMouseDown);
|
||||
window.removeEventListener('mouseup', this.handleMouseUp);
|
||||
};
|
||||
|
||||
componentDidUpdate = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
hideLinkToolbar = () => {
|
||||
this.link = undefined;
|
||||
};
|
||||
|
||||
handleMouseDown = (e: SyntheticMouseEvent) => {
|
||||
this.mouseDown = true;
|
||||
};
|
||||
|
||||
handleMouseUp = (e: SyntheticMouseEvent) => {
|
||||
this.mouseDown = false;
|
||||
this.update();
|
||||
};
|
||||
|
||||
showLinkToolbar = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const link = getLinkInSelection(this.props.value);
|
||||
this.link = link;
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { value } = this.props;
|
||||
const link = getLinkInSelection(value);
|
||||
|
||||
if (value.isBlurred || (value.isCollapsed && !link)) {
|
||||
if (this.active && !this.link) {
|
||||
this.active = false;
|
||||
this.link = undefined;
|
||||
this.top = '';
|
||||
this.left = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// don't display toolbar for document title
|
||||
const firstNode = value.document.nodes.first();
|
||||
if (firstNode === value.startBlock) return;
|
||||
|
||||
// don't display toolbar for code blocks, code-lines inline code.
|
||||
if (value.startBlock.type.match(/code/)) return;
|
||||
|
||||
// don't show until user has released pointing device button
|
||||
if (this.mouseDown) return;
|
||||
|
||||
this.active = true;
|
||||
this.link = this.link || link;
|
||||
|
||||
const padding = 16;
|
||||
const selection = window.getSelection();
|
||||
let rect;
|
||||
|
||||
if (link) {
|
||||
rect = findDOMNode(link).getBoundingClientRect();
|
||||
} else if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
rect = range.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!rect || (rect.top === 0 && rect.left === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const left =
|
||||
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
|
||||
this.top = `${Math.round(
|
||||
rect.top + window.scrollY - this.menu.offsetHeight
|
||||
)}px`;
|
||||
this.left = `${Math.round(Math.max(padding, left))}px`;
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.menu = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Menu active={this.active} innerRef={this.setRef} style={style}>
|
||||
{this.link ? (
|
||||
<LinkToolbar
|
||||
{...this.props}
|
||||
link={this.link}
|
||||
onBlur={this.hideLinkToolbar}
|
||||
/>
|
||||
) : (
|
||||
<FormattingToolbar
|
||||
onCreateLink={this.showLinkToolbar}
|
||||
{...this.props}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Menu = styled.div`
|
||||
padding: 8px 16px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
background-color: #2f3336;
|
||||
border-radius: 4px;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 250ms;
|
||||
line-height: 0;
|
||||
height: 40px;
|
||||
min-width: 300px;
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(-6px) scale(1);
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fontWeight, color } from 'shared/styles/constants';
|
||||
import Document from 'models/Document';
|
||||
import NextIcon from 'components/Icon/NextIcon';
|
||||
|
||||
type Props = {
|
||||
innerRef?: Function,
|
||||
onClick: SyntheticEvent => void,
|
||||
document: Document,
|
||||
};
|
||||
|
||||
function DocumentResult({ document, ...rest }: Props) {
|
||||
return (
|
||||
<ListItem {...rest} href="">
|
||||
<i>
|
||||
<NextIcon light />
|
||||
</i>
|
||||
{document.title}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItem = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 6px 8px 6px 0;
|
||||
color: ${color.white};
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
font-weight: ${fontWeight.medium};
|
||||
outline: none;
|
||||
|
||||
i {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentResult;
|
||||
@@ -1,115 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Editor } from 'slate-react';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import BoldIcon from 'components/Icon/BoldIcon';
|
||||
import CodeIcon from 'components/Icon/CodeIcon';
|
||||
import Heading1Icon from 'components/Icon/Heading1Icon';
|
||||
import Heading2Icon from 'components/Icon/Heading2Icon';
|
||||
import ItalicIcon from 'components/Icon/ItalicIcon';
|
||||
import BlockQuoteIcon from 'components/Icon/BlockQuoteIcon';
|
||||
import LinkIcon from 'components/Icon/LinkIcon';
|
||||
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
||||
|
||||
class FormattingToolbar extends Component {
|
||||
props: {
|
||||
editor: Editor,
|
||||
onCreateLink: SyntheticEvent => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current selection has a mark with `type` in it.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasMark = (type: string) => {
|
||||
return this.props.editor.value.marks.some(mark => mark.type === type);
|
||||
};
|
||||
|
||||
isBlock = (type: string) => {
|
||||
const startBlock = this.props.editor.value.startBlock;
|
||||
return startBlock && startBlock.type === type;
|
||||
};
|
||||
|
||||
/**
|
||||
* When a mark button is clicked, toggle the current mark.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {String} type
|
||||
*/
|
||||
onClickMark = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
this.props.editor.change(change => change.toggleMark(type));
|
||||
};
|
||||
|
||||
onClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
this.props.editor.change(change => change.setBlock(type));
|
||||
};
|
||||
|
||||
handleCreateLink = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const data = { href: '' };
|
||||
this.props.editor.change(change => {
|
||||
change.wrapInline({ type: 'link', data });
|
||||
this.props.onCreateLink(ev);
|
||||
});
|
||||
};
|
||||
|
||||
renderMarkButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.hasMark(type);
|
||||
const onMouseDown = ev => this.onClickMark(ev, type);
|
||||
|
||||
return (
|
||||
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
|
||||
<IconClass light />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.isBlock(type);
|
||||
const onMouseDown = ev =>
|
||||
this.onClickBlock(ev, isActive ? 'paragraph' : type);
|
||||
|
||||
return (
|
||||
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
|
||||
<IconClass light />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
{this.renderMarkButton('bold', BoldIcon)}
|
||||
{this.renderMarkButton('italic', ItalicIcon)}
|
||||
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
||||
{this.renderMarkButton('code', CodeIcon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
{this.renderBlockButton('block-quote', BlockQuoteIcon)}
|
||||
<Separator />
|
||||
<ToolbarButton onMouseDown={this.handleCreateLink}>
|
||||
<LinkIcon light />
|
||||
</ToolbarButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: #fff;
|
||||
opacity: 0.2;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
export default FormattingToolbar;
|
||||
@@ -1,231 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Node } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import styled from 'styled-components';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import DocumentResult from './DocumentResult';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import keydown from 'react-keydown';
|
||||
import CloseIcon from 'components/Icon/CloseIcon';
|
||||
import OpenIcon from 'components/Icon/OpenIcon';
|
||||
import TrashIcon from 'components/Icon/TrashIcon';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
@keydown
|
||||
@observer
|
||||
class LinkToolbar extends Component {
|
||||
wrapper: HTMLSpanElement;
|
||||
input: HTMLInputElement;
|
||||
firstDocument: HTMLElement;
|
||||
|
||||
props: {
|
||||
editor: Editor,
|
||||
link: Node,
|
||||
documents: DocumentsStore,
|
||||
onBlur: () => *,
|
||||
};
|
||||
|
||||
originalValue: string = '';
|
||||
@observable isEditing: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable resultIds: string[] = [];
|
||||
@observable searchTerm: ?string = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.originalValue = this.props.link.data.get('href');
|
||||
this.isEditing = !!this.originalValue;
|
||||
|
||||
setImmediate(() =>
|
||||
window.addEventListener('click', this.handleOutsideMouseClick)
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
handleOutsideMouseClick = (ev: SyntheticMouseEvent) => {
|
||||
const element = findDOMNode(this.wrapper);
|
||||
|
||||
if (
|
||||
!element ||
|
||||
(ev.target instanceof HTMLElement && element.contains(ev.target)) ||
|
||||
(ev.button && ev.button !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
this.save(this.input.value);
|
||||
};
|
||||
|
||||
@action
|
||||
search = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
if (this.searchTerm) {
|
||||
try {
|
||||
this.resultIds = await this.props.documents.search(this.searchTerm);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
} else {
|
||||
this.resultIds = [];
|
||||
}
|
||||
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
selectDocument = (ev, document) => {
|
||||
ev.preventDefault();
|
||||
this.save(document.url);
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
switch (ev.keyCode) {
|
||||
case 13: // enter
|
||||
ev.preventDefault();
|
||||
return this.save(ev.target.value);
|
||||
case 27: // escape
|
||||
return this.save(this.originalValue);
|
||||
case 40: // down
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
const element = findDOMNode(this.firstDocument);
|
||||
if (element instanceof HTMLElement) element.focus();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
try {
|
||||
new URL(ev.target.value);
|
||||
} catch (err) {
|
||||
// this is not a valid url, show search suggestions
|
||||
this.searchTerm = ev.target.value;
|
||||
this.search();
|
||||
return;
|
||||
}
|
||||
this.resultIds = [];
|
||||
};
|
||||
|
||||
removeLink = () => {
|
||||
this.save('');
|
||||
};
|
||||
|
||||
openLink = () => {
|
||||
const href = this.props.link.data.get('href');
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
|
||||
save = (href: string) => {
|
||||
const { editor, link } = this.props;
|
||||
href = href.trim();
|
||||
|
||||
editor.change(change => {
|
||||
if (href) {
|
||||
change.setNodeByKey(link.key, { type: 'link', data: { href } });
|
||||
} else if (link) {
|
||||
change.unwrapInlineByKey(link.key);
|
||||
}
|
||||
change.deselect();
|
||||
this.props.onBlur();
|
||||
});
|
||||
};
|
||||
|
||||
setFirstDocumentRef = ref => {
|
||||
this.firstDocument = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const href = this.props.link.data.get('href');
|
||||
const hasResults = this.resultIds.length > 0;
|
||||
|
||||
return (
|
||||
<span ref={ref => (this.wrapper = ref)}>
|
||||
<LinkEditor>
|
||||
<Input
|
||||
innerRef={ref => (this.input = ref)}
|
||||
defaultValue={href}
|
||||
placeholder="Search or paste a link…"
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
autoFocus={href === ''}
|
||||
/>
|
||||
{this.isEditing && (
|
||||
<ToolbarButton onMouseDown={this.openLink}>
|
||||
<OpenIcon light />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<ToolbarButton onMouseDown={this.removeLink}>
|
||||
{this.isEditing ? <TrashIcon light /> : <CloseIcon light />}
|
||||
</ToolbarButton>
|
||||
</LinkEditor>
|
||||
{hasResults && (
|
||||
<SearchResults>
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.resultIds.map((id, index) => {
|
||||
const document = this.props.documents.getById(id);
|
||||
if (!document) return null;
|
||||
|
||||
return (
|
||||
<DocumentResult
|
||||
innerRef={ref =>
|
||||
index === 0 && this.setFirstDocumentRef(ref)
|
||||
}
|
||||
document={document}
|
||||
key={document.id}
|
||||
onClick={ev => this.selectDocument(ev, document)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ArrowKeyNavigation>
|
||||
</SearchResults>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SearchResults = styled.div`
|
||||
background: #2f3336;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
padding: 8px;
|
||||
margin-top: -3px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0 0 4px 4px;
|
||||
`;
|
||||
|
||||
const LinkEditor = styled(Flex)`
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
font-size: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export default withRouter(inject('documents')(LinkToolbar));
|
||||
@@ -1,26 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.button`
|
||||
display: inline-block;
|
||||
flex: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${({ active }) => active && 'opacity: 1;'};
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Toolbar from './Toolbar';
|
||||
export default Toolbar;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user