mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 11:56:01 +01:00 
			
		
		
		
	Compare commits
	
		
			749 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					fd25c735c1 | ||
| 
						 | 
					7de33907c5 | ||
| 
						 | 
					a3014434cf | ||
| 
						 | 
					3ebab2c126 | ||
| 
						 | 
					954619bd36 | ||
| 
						 | 
					6995fbfd06 | ||
| 
						 | 
					83b72eafa6 | ||
| 
						 | 
					757a6777be | ||
| 
						 | 
					d003e91b89 | ||
| 
						 | 
					4a35df745a | ||
| 
						 | 
					713a0f5b09 | ||
| 
						 | 
					2cf9c98b43 | ||
| 
						 | 
					d7af196a0c | ||
| 
						 | 
					c363be57b7 | ||
| 
						 | 
					10645790de | ||
| 
						 | 
					8b18cf382c | ||
| 
						 | 
					7a131e0bcc | ||
| 
						 | 
					3d264379cc | ||
| 
						 | 
					f405682ec1 | ||
| 
						 | 
					3debf3ce1c | ||
| 
						 | 
					5a76883969 | ||
| 
						 | 
					6f51c5e0cc | ||
| 
						 | 
					2c730d1f0b | ||
| 
						 | 
					d487da0b2f | ||
| 
						 | 
					cb8a5cbb62 | ||
| 
						 | 
					ceb08593d8 | ||
| 
						 | 
					9dd0eb7b9b | ||
| 
						 | 
					ebff644d24 | ||
| 
						 | 
					beb1c15fa5 | ||
| 
						 | 
					40a5eee211 | ||
| 
						 | 
					8f393d0bae | ||
| 
						 | 
					94dad49e2f | ||
| 
						 | 
					409638151c | ||
| 
						 | 
					0d3de92890 | ||
| 
						 | 
					5d619131ec | ||
| 
						 | 
					e2c8443778 | ||
| 
						 | 
					daa4743967 | ||
| 
						 | 
					56553078ef | ||
| 
						 | 
					5584a06cb3 | ||
| 
						 | 
					cfeb69ace6 | ||
| 
						 | 
					b0c8f110de | ||
| 
						 | 
					aba1266c45 | ||
| 
						 | 
					c331e0103d | ||
| 
						 | 
					13978574e0 | ||
| 
						 | 
					be85963558 | ||
| 
						 | 
					8c19261ced | ||
| 
						 | 
					7ca17fa609 | ||
| 
						 | 
					3d107572df | ||
| 
						 | 
					f7488655a7 | ||
| 
						 | 
					876e0a29d4 | ||
| 
						 | 
					af74375695 | ||
| 
						 | 
					896965fec5 | ||
| 
						 | 
					ba5ef93c1a | ||
| 
						 | 
					ef1153d336 | ||
| 
						 | 
					0d347f8823 | ||
| 
						 | 
					897cdc26ae | ||
| 
						 | 
					aba621c099 | ||
| 
						 | 
					839813ebde | ||
| 
						 | 
					545e2ddbfc | ||
| 
						 | 
					1d63a5903a | ||
| 
						 | 
					2b34c00a0c | ||
| 
						 | 
					123068062a | ||
| 
						 | 
					9a668e8709 | ||
| 
						 | 
					f6f8937d64 | ||
| 
						 | 
					c9f53a2880 | ||
| 
						 | 
					2887e712c3 | ||
| 
						 | 
					5d3a0ed1b4 | ||
| 
						 | 
					334b6319de | ||
| 
						 | 
					4c118c0fd4 | ||
| 
						 | 
					db00d60684 | ||
| 
						 | 
					25b74af363 | ||
| 
						 | 
					eb57cf97ad | ||
| 
						 | 
					c92e24363f | ||
| 
						 | 
					8d5d00ac0f | ||
| 
						 | 
					8b457384ba | ||
| 
						 | 
					fab2d53ece | ||
| 
						 | 
					774f27d8d2 | ||
| 
						 | 
					d7f02ef1b3 | ||
| 
						 | 
					97eaa6294c | ||
| 
						 | 
					dc02bb0850 | ||
| 
						 | 
					2c8c041e1c | ||
| 
						 | 
					874b1c6654 | ||
| 
						 | 
					fb982c7097 | ||
| 
						 | 
					b7f5ce600e | ||
| 
						 | 
					91604c9e26 | ||
| 
						 | 
					c874333a37 | ||
| 
						 | 
					1298b968f2 | ||
| 
						 | 
					6fe5a854a7 | ||
| 
						 | 
					aba3b5cb19 | ||
| 
						 | 
					282aed22b5 | ||
| 
						 | 
					669a3d9dcf | ||
| 
						 | 
					9d7455d28a | ||
| 
						 | 
					4f0c8b081c | ||
| 
						 | 
					a5db5298a0 | ||
| 
						 | 
					876c6e9252 | ||
| 
						 | 
					aef824d262 | ||
| 
						 | 
					a25ce42490 | ||
| 
						 | 
					8b0fdaccf4 | ||
| 
						 | 
					bd840a2421 | ||
| 
						 | 
					27d515f289 | ||
| 
						 | 
					df3b9faf8d | ||
| 
						 | 
					0f129734ae | ||
| 
						 | 
					275aacfba9 | ||
| 
						 | 
					e7f47a0663 | ||
| 
						 | 
					66486541fe | ||
| 
						 | 
					34f1a84769 | ||
| 
						 | 
					2244f0368f | ||
| 
						 | 
					9d85005255 | ||
| 
						 | 
					ad8629dca6 | ||
| 
						 | 
					cccfe0e05a | ||
| 
						 | 
					a8874257e8 | ||
| 
						 | 
					f689c55f56 | ||
| 
						 | 
					853c7be8b8 | ||
| 
						 | 
					823df1e12d | ||
| 
						 | 
					7570f818e9 | ||
| 
						 | 
					03aa5aea2c | ||
| 
						 | 
					a4e86ac353 | ||
| 
						 | 
					cf6efc050a | ||
| 
						 | 
					3e0802176b | ||
| 
						 | 
					697954d4d9 | ||
| 
						 | 
					741f6c1114 | ||
| 
						 | 
					b2237ffa51 | ||
| 
						 | 
					7b6d11bffa | ||
| 
						 | 
					97565e8f36 | ||
| 
						 | 
					c0dfee8439 | ||
| 
						 | 
					fc98240614 | ||
| 
						 | 
					169d1203c2 | ||
| 
						 | 
					f3350bc8f5 | ||
| 
						 | 
					504a19275c | ||
| 
						 | 
					14cdc52670 | ||
| 
						 | 
					cf8063f311 | ||
| 
						 | 
					aa8902f5b9 | ||
| 
						 | 
					7cd0e664ac | ||
| 
						 | 
					a04804d3fa | ||
| 
						 | 
					86f90e6685 | ||
| 
						 | 
					8131a4b3d2 | ||
| 
						 | 
					b91a3e13b0 | ||
| 
						 | 
					5a7a0d32d1 | ||
| 
						 | 
					3f5df18d6c | ||
| 
						 | 
					df2cede075 | ||
| 
						 | 
					4321c161ac | ||
| 
						 | 
					b1f0c64ef2 | ||
| 
						 | 
					c9b37dcc77 | ||
| 
						 | 
					ab093ed9a0 | ||
| 
						 | 
					cf31367acd | ||
| 
						 | 
					e3d306cac3 | ||
| 
						 | 
					960d321019 | ||
| 
						 | 
					2d4ac93221 | ||
| 
						 | 
					d4a4f15416 | ||
| 
						 | 
					504a842d37 | ||
| 
						 | 
					ded5b1f5d2 | ||
| 
						 | 
					fcbbc21a80 | ||
| 
						 | 
					38fce25b86 | ||
| 
						 | 
					4cc2fa5300 | ||
| 
						 | 
					4a82c3f65a | ||
| 
						 | 
					b255d70e18 | ||
| 
						 | 
					caa842cd55 | ||
| 
						 | 
					cd338085fb | ||
| 
						 | 
					e703ce92a8 | ||
| 
						 | 
					84479a2c2a | ||
| 
						 | 
					c13969217c | ||
| 
						 | 
					402540f483 | ||
| 
						 | 
					8c56315313 | ||
| 
						 | 
					b29c3eff6e | ||
| 
						 | 
					ec7dacfc9b | ||
| 
						 | 
					5f9a6a9f76 | ||
| 
						 | 
					28f4aea3d5 | ||
| 
						 | 
					8d29c5fe1b | ||
| 
						 | 
					ccd935b562 | ||
| 
						 | 
					d77a49857b | ||
| 
						 | 
					e30478e5d4 | ||
| 
						 | 
					71863752cd | ||
| 
						 | 
					e4a2a8e56d | ||
| 
						 | 
					0f1c505823 | ||
| 
						 | 
					1ecce11113 | ||
| 
						 | 
					2287d67fb5 | ||
| 
						 | 
					5b4f17ef3d | ||
| 
						 | 
					3720ab6df6 | ||
| 
						 | 
					3c893d69e5 | ||
| 
						 | 
					b93a4a3e42 | ||
| 
						 | 
					23cef0ab94 | ||
| 
						 | 
					c8ffb8d694 | ||
| 
						 | 
					08e08d8920 | ||
| 
						 | 
					7acd300163 | ||
| 
						 | 
					d8d95db4ec | ||
| 
						 | 
					af97d3ef1d | ||
| 
						 | 
					c65ec14943 | ||
| 
						 | 
					adfdc7edb4 | ||
| 
						 | 
					8cced607eb | ||
| 
						 | 
					5dd5af90c2 | ||
| 
						 | 
					7a48333b4f | ||
| 
						 | 
					7044533398 | ||
| 
						 | 
					560aad8df6 | ||
| 
						 | 
					36c2099b2e | ||
| 
						 | 
					6c157675d7 | ||
| 
						 | 
					458d66cb21 | ||
| 
						 | 
					201e8911c5 | ||
| 
						 | 
					1b1ed2408f | ||
| 
						 | 
					62487d21d8 | ||
| 
						 | 
					bc752bdb0b | ||
| 
						 | 
					9e00d421fb | ||
| 
						 | 
					e7f02fe22b | ||
| 
						 | 
					6d694f8e53 | ||
| 
						 | 
					977befd0a7 | ||
| 
						 | 
					1566ae4fbd | ||
| 
						 | 
					4e97490cc6 | ||
| 
						 | 
					446d5a0fcc | ||
| 
						 | 
					1fd6465012 | ||
| 
						 | 
					6cea8e3b87 | ||
| 
						 | 
					28a63e0326 | ||
| 
						 | 
					b73da46111 | ||
| 
						 | 
					abafa8c2d2 | ||
| 
						 | 
					4ae3272cdf | ||
| 
						 | 
					6aa3b8dbd7 | ||
| 
						 | 
					395e9b2228 | ||
| 
						 | 
					be33f68c52 | ||
| 
						 | 
					29d96381fa | ||
| 
						 | 
					da8eecf774 | ||
| 
						 | 
					de91326c12 | ||
| 
						 | 
					ee1c3c35d7 | ||
| 
						 | 
					70eece1429 | ||
| 
						 | 
					b4f2be332b | ||
| 
						 | 
					23fe76989b | ||
| 
						 | 
					275d07659d | ||
| 
						 | 
					a901e92573 | ||
| 
						 | 
					6ead31b45f | ||
| 
						 | 
					d4ce12dca9 | ||
| 
						 | 
					bb6e22cdb7 | ||
| 
						 | 
					2c9fc4812e | ||
| 
						 | 
					60f4554afa | ||
| 
						 | 
					3c486bfd1b | ||
| 
						 | 
					26b9a95bb2 | ||
| 
						 | 
					f7c9217cea | ||
| 
						 | 
					e92022b73c | ||
| 
						 | 
					61ff2353c8 | ||
| 
						 | 
					c8cca26ca4 | ||
| 
						 | 
					aa556ed4d5 | ||
| 
						 | 
					5d694a7bdf | ||
| 
						 | 
					c4787dae23 | ||
| 
						 | 
					9f5f329c53 | ||
| 
						 | 
					f82b96fcc4 | ||
| 
						 | 
					d4b24fa427 | ||
| 
						 | 
					c852f67c59 | ||
| 
						 | 
					92c228a3c9 | ||
| 
						 | 
					42f948e2b3 | ||
| 
						 | 
					13e8932117 | ||
| 
						 | 
					910d34bd42 | ||
| 
						 | 
					b204ba29e7 | ||
| 
						 | 
					d49244cbc8 | ||
| 
						 | 
					ef2f2f17b4 | ||
| 
						 | 
					b9f21dcf4c | ||
| 
						 | 
					808fe690cc | ||
| 
						 | 
					901eec04e5 | ||
| 
						 | 
					9272394ada | ||
| 
						 | 
					4457982fae | ||
| 
						 | 
					7f67b2b461 | ||
| 
						 | 
					7f3934f4c3 | ||
| 
						 | 
					a3b80a2cc4 | ||
| 
						 | 
					6d967e5e51 | ||
| 
						 | 
					b674ca90d1 | ||
| 
						 | 
					95edb60a84 | ||
| 
						 | 
					40add78ccb | ||
| 
						 | 
					1029c24c06 | ||
| 
						 | 
					94d94fe8fb | ||
| 
						 | 
					49489c0f45 | ||
| 
						 | 
					215833a2c9 | ||
| 
						 | 
					a7471a3d47 | ||
| 
						 | 
					909aaefbd7 | ||
| 
						 | 
					15c2f56bf2 | ||
| 
						 | 
					84cdfec415 | ||
| 
						 | 
					91572ab8b9 | ||
| 
						 | 
					ed758f4c92 | ||
| 
						 | 
					f1fc15e115 | ||
| 
						 | 
					22300e8151 | ||
| 
						 | 
					292646e14a | ||
| 
						 | 
					b4921a20d8 | ||
| 
						 | 
					54be79a725 | ||
| 
						 | 
					4fc47370fe | ||
| 
						 | 
					9e30bcf233 | ||
| 
						 | 
					e5712c54e6 | ||
| 
						 | 
					2a4fe21a39 | ||
| 
						 | 
					b259558f0f | ||
| 
						 | 
					e2f6d9e0d6 | ||
| 
						 | 
					4fc2b0fa5e | ||
| 
						 | 
					8dca79ecf2 | ||
| 
						 | 
					c7f49f0e21 | ||
| 
						 | 
					bce2094fb2 | ||
| 
						 | 
					65c33e1aa0 | ||
| 
						 | 
					8e108bc5e2 | ||
| 
						 | 
					4e75ce7fdb | ||
| 
						 | 
					1e42574d28 | ||
| 
						 | 
					85ebaf6afa | ||
| 
						 | 
					661c7e4056 | ||
| 
						 | 
					1e8ea54dbc | ||
| 
						 | 
					ddbe7e9936 | ||
| 
						 | 
					cab86175ef | ||
| 
						 | 
					ec7414b174 | ||
| 
						 | 
					8343a5d1dd | ||
| 
						 | 
					18c55784c7 | ||
| 
						 | 
					39eac83d38 | ||
| 
						 | 
					55bd6fb57d | ||
| 
						 | 
					6fdec52332 | ||
| 
						 | 
					824a3c5fcc | ||
| 
						 | 
					87da644027 | ||
| 
						 | 
					4f42f543d8 | ||
| 
						 | 
					97ea3ac3fc | ||
| 
						 | 
					f04b75fd36 | ||
| 
						 | 
					f5bffc38f1 | ||
| 
						 | 
					27738acefc | ||
| 
						 | 
					59ce2072c5 | ||
| 
						 | 
					ed68dda70b | ||
| 
						 | 
					892ab02f06 | ||
| 
						 | 
					7d9196d5e1 | ||
| 
						 | 
					dccdb5ceb7 | ||
| 
						 | 
					f961698e44 | ||
| 
						 | 
					278fe3262e | ||
| 
						 | 
					1fc860b052 | ||
| 
						 | 
					88a8311173 | ||
| 
						 | 
					63dc5697dd | ||
| 
						 | 
					b595d1fade | ||
| 
						 | 
					d91c59b7d0 | ||
| 
						 | 
					aa2ab0da31 | ||
| 
						 | 
					91f94106fb | ||
| 
						 | 
					308f319138 | ||
| 
						 | 
					fa0c01591a | ||
| 
						 | 
					cb5a771490 | ||
| 
						 | 
					0c17a13462 | ||
| 
						 | 
					04593cb2d7 | ||
| 
						 | 
					b6f50b6af0 | ||
| 
						 | 
					fc454cba03 | ||
| 
						 | 
					6f165df29e | ||
| 
						 | 
					d16468071d | ||
| 
						 | 
					20a492523f | ||
| 
						 | 
					1216f51c78 | ||
| 
						 | 
					ea3ac1041b | ||
| 
						 | 
					d838e8baf0 | ||
| 
						 | 
					60a7347d7d | ||
| 
						 | 
					4e05e79426 | ||
| 
						 | 
					aa872f47f2 | ||
| 
						 | 
					fbd833ad86 | ||
| 
						 | 
					bee65ed32c | ||
| 
						 | 
					5adca76a9a | ||
| 
						 | 
					e7467f6446 | ||
| 
						 | 
					e49473fbd3 | ||
| 
						 | 
					bfec44aa5a | ||
| 
						 | 
					55b3bf6036 | ||
| 
						 | 
					c9c07f0cb0 | ||
| 
						 | 
					e25727441d | ||
| 
						 | 
					51b7955ccd | ||
| 
						 | 
					196bba9cda | ||
| 
						 | 
					430ed78d85 | ||
| 
						 | 
					2d11ed805d | ||
| 
						 | 
					f55426bdb0 | ||
| 
						 | 
					87b5068fec | ||
| 
						 | 
					9ddd1a4ae2 | ||
| 
						 | 
					736bc9c9bd | ||
| 
						 | 
					5a2da62992 | ||
| 
						 | 
					1a72eb91ee | ||
| 
						 | 
					0d3c5b06e2 | ||
| 
						 | 
					035b72a08d | ||
| 
						 | 
					fc4a595725 | ||
| 
						 | 
					444969bcf4 | ||
| 
						 | 
					2cb6b14eca | ||
| 
						 | 
					468b5022a4 | ||
| 
						 | 
					c1897563ca | ||
| 
						 | 
					5e533896b9 | ||
| 
						 | 
					d3ceb7cfc1 | ||
| 
						 | 
					731f74f421 | ||
| 
						 | 
					46d82651a3 | ||
| 
						 | 
					b3108c7e2b | ||
| 
						 | 
					0cb988470e | ||
| 
						 | 
					5a030014b0 | ||
| 
						 | 
					2a43ef4dae | ||
| 
						 | 
					6b5f9fc6ff | ||
| 
						 | 
					b3a156c20d | ||
| 
						 | 
					24340d3a8e | ||
| 
						 | 
					2fac2a8c5e | ||
| 
						 | 
					decb0c702d | ||
| 
						 | 
					d45ff6cca5 | ||
| 
						 | 
					83833e668c | ||
| 
						 | 
					2cc181d1ac | ||
| 
						 | 
					a946ce3534 | ||
| 
						 | 
					3e9f476b37 | ||
| 
						 | 
					de65c748a4 | ||
| 
						 | 
					8a2bfb9d7b | ||
| 
						 | 
					a1ced31fea | ||
| 
						 | 
					989a9f506e | ||
| 
						 | 
					59d55e2489 | ||
| 
						 | 
					2b312a9234 | ||
| 
						 | 
					16d9b982c2 | ||
| 
						 | 
					a5600e75f5 | ||
| 
						 | 
					f91dea62b6 | ||
| 
						 | 
					4915ffcf2a | ||
| 
						 | 
					9dbea2aa18 | ||
| 
						 | 
					45f6a70fb8 | ||
| 
						 | 
					96b4c611cc | ||
| 
						 | 
					4e559d6594 | ||
| 
						 | 
					db1a599f95 | ||
| 
						 | 
					040964bbb7 | ||
| 
						 | 
					dc6a303154 | ||
| 
						 | 
					f88f14c983 | ||
| 
						 | 
					f870649256 | ||
| 
						 | 
					ed4dc30a6e | ||
| 
						 | 
					ce9010ff13 | ||
| 
						 | 
					994e9fa852 | ||
| 
						 | 
					9df7d6227e | ||
| 
						 | 
					242a576548 | ||
| 
						 | 
					c1a5808f37 | ||
| 
						 | 
					5c6bb99d78 | ||
| 
						 | 
					63c408c45b | ||
| 
						 | 
					2a665dffbc | ||
| 
						 | 
					6509acd6ee | ||
| 
						 | 
					4853d45609 | ||
| 
						 | 
					fe78c1fee3 | ||
| 
						 | 
					8102172557 | ||
| 
						 | 
					a1341e6036 | ||
| 
						 | 
					d31af2ddc2 | ||
| 
						 | 
					a563330136 | ||
| 
						 | 
					a58e5789bc | ||
| 
						 | 
					68e258f23b | ||
| 
						 | 
					dd18866156 | ||
| 
						 | 
					1b1f1957c3 | ||
| 
						 | 
					ff6b4effbd | ||
| 
						 | 
					06fa59239c | ||
| 
						 | 
					557bfbd1d6 | ||
| 
						 | 
					f5a6dfa629 | ||
| 
						 | 
					ce33dfb003 | ||
| 
						 | 
					7b1c058d29 | ||
| 
						 | 
					04c8f8a123 | ||
| 
						 | 
					d15fccb1d8 | ||
| 
						 | 
					229dd9cd18 | ||
| 
						 | 
					a4faaa406b | ||
| 
						 | 
					b6d2de54b2 | ||
| 
						 | 
					d5e81d77a2 | ||
| 
						 | 
					939e99637f | ||
| 
						 | 
					579a261612 | ||
| 
						 | 
					6d03304cbb | ||
| 
						 | 
					b8d41b3421 | ||
| 
						 | 
					6a5bb1f5c8 | ||
| 
						 | 
					cd742a4617 | ||
| 
						 | 
					54063b97ad | ||
| 
						 | 
					7abb67e737 | ||
| 
						 | 
					00fd1ba137 | ||
| 
						 | 
					7ea37b9eb9 | ||
| 
						 | 
					b749de8fe1 | ||
| 
						 | 
					8efef6842d | ||
| 
						 | 
					dc206f38d5 | ||
| 
						 | 
					29a00a6c0e | ||
| 
						 | 
					fe678230a8 | ||
| 
						 | 
					9cdbeb061f | ||
| 
						 | 
					6c308f35c1 | ||
| 
						 | 
					34b89cf2e8 | ||
| 
						 | 
					b566a188dc | ||
| 
						 | 
					998432e236 | ||
| 
						 | 
					1af8edfe4d | ||
| 
						 | 
					5bf01106c5 | ||
| 
						 | 
					a45289e385 | ||
| 
						 | 
					4ffd005b09 | ||
| 
						 | 
					e6ca89fea8 | ||
| 
						 | 
					2225aea756 | ||
| 
						 | 
					bfc4a84020 | ||
| 
						 | 
					5390bfdcab | ||
| 
						 | 
					301211ff41 | ||
| 
						 | 
					64139e4e08 | ||
| 
						 | 
					e6485cde92 | ||
| 
						 | 
					891f6ba66f | ||
| 
						 | 
					5d3c1e3fec | ||
| 
						 | 
					087e755390 | ||
| 
						 | 
					025dc1ce75 | ||
| 
						 | 
					703200338d | ||
| 
						 | 
					377c93ca0b | ||
| 
						 | 
					69394ffe29 | ||
| 
						 | 
					f85231d74a | ||
| 
						 | 
					b93d8b0159 | ||
| 
						 | 
					67b9329903 | ||
| 
						 | 
					c0edd4ea4f | ||
| 
						 | 
					8eaf2786e8 | ||
| 
						 | 
					25622df464 | ||
| 
						 | 
					a48900e178 | ||
| 
						 | 
					ac8b0535d2 | ||
| 
						 | 
					6ce25a825b | ||
| 
						 | 
					b3f56851b8 | ||
| 
						 | 
					4b86fedce1 | ||
| 
						 | 
					1ebb70c4d2 | ||
| 
						 | 
					3de7b81be8 | ||
| 
						 | 
					d08225339c | ||
| 
						 | 
					ba22d0706f | ||
| 
						 | 
					ef80f104c0 | ||
| 
						 | 
					af296a1e4e | ||
| 
						 | 
					28a755306a | ||
| 
						 | 
					461e085eff | ||
| 
						 | 
					fbda049c32 | ||
| 
						 | 
					4ded5e2b98 | ||
| 
						 | 
					63537aff20 | ||
| 
						 | 
					0f7a2adf15 | ||
| 
						 | 
					60963abe2c | ||
| 
						 | 
					08cf95aa38 | ||
| 
						 | 
					e5b10ab16a | ||
| 
						 | 
					7f5a1ee45a | ||
| 
						 | 
					15c593f68e | ||
| 
						 | 
					5f8ef0395b | ||
| 
						 | 
					513636e1e0 | ||
| 
						 | 
					ae9b2c08a9 | ||
| 
						 | 
					d5327b3b4a | ||
| 
						 | 
					323e3d3cac | ||
| 
						 | 
					01b2257063 | ||
| 
						 | 
					c69ef611a0 | ||
| 
						 | 
					dcad23316d | ||
| 
						 | 
					e411f9932f | ||
| 
						 | 
					854969e1b8 | ||
| 
						 | 
					4ac7b6e9e8 | ||
| 
						 | 
					ac70908c5a | ||
| 
						 | 
					45ac70b78f | ||
| 
						 | 
					a4664576fe | ||
| 
						 | 
					b293643398 | ||
| 
						 | 
					a2e197facd | ||
| 
						 | 
					8614d39ef4 | ||
| 
						 | 
					6456bb34ae | ||
| 
						 | 
					f5dc4de1c1 | ||
| 
						 | 
					d869056910 | ||
| 
						 | 
					821e4b17cb | ||
| 
						 | 
					d8cb5efd2d | ||
| 
						 | 
					f90e2fb484 | ||
| 
						 | 
					2c9a7144da | ||
| 
						 | 
					88d1af7210 | ||
| 
						 | 
					300e5a5528 | ||
| 
						 | 
					4418fefe4b | ||
| 
						 | 
					fe5d1cac9a | ||
| 
						 | 
					49d17fff9b | ||
| 
						 | 
					557c6d2d8b | ||
| 
						 | 
					45fc62357d | ||
| 
						 | 
					840e3cc22f | ||
| 
						 | 
					c158c7fc88 | ||
| 
						 | 
					bc6f8fc2dd | ||
| 
						 | 
					117730acb2 | ||
| 
						 | 
					595a7dac83 | ||
| 
						 | 
					64a4d70df4 | ||
| 
						 | 
					be36199fe1 | ||
| 
						 | 
					e46ad25677 | ||
| 
						 | 
					d5ee663922 | ||
| 
						 | 
					a7ab4be055 | ||
| 
						 | 
					6bbf29e75a | ||
| 
						 | 
					0a06c60cb7 | ||
| 
						 | 
					03658575eb | ||
| 
						 | 
					38114bddb9 | ||
| 
						 | 
					0711a197db | ||
| 
						 | 
					f8f818b211 | ||
| 
						 | 
					988932209c | ||
| 
						 | 
					2aa56cec30 | ||
| 
						 | 
					93d493650c | ||
| 
						 | 
					c6162ddcb4 | ||
| 
						 | 
					038517eda4 | ||
| 
						 | 
					30a9db73ab | ||
| 
						 | 
					a50aa41bdb | ||
| 
						 | 
					cbb322fdb8 | ||
| 
						 | 
					026e2a020d | ||
| 
						 | 
					07aab1d005 | ||
| 
						 | 
					26f0f7b188 | ||
| 
						 | 
					1efde3b86b | ||
| 
						 | 
					8c1318f379 | ||
| 
						 | 
					40e67e8e17 | ||
| 
						 | 
					04466f52fd | ||
| 
						 | 
					06baa5fb57 | ||
| 
						 | 
					04e1657628 | ||
| 
						 | 
					7816c8cab0 | ||
| 
						 | 
					6636e658a4 | ||
| 
						 | 
					2a06f0daef | ||
| 
						 | 
					883cfa588c | ||
| 
						 | 
					68011a0b5a | ||
| 
						 | 
					5247d1a371 | ||
| 
						 | 
					cabdd528d4 | ||
| 
						 | 
					2bacbb796b | ||
| 
						 | 
					aa0ed6434a | ||
| 
						 | 
					5b2215d646 | ||
| 
						 | 
					0e760e25f2 | ||
| 
						 | 
					acbb85b409 | ||
| 
						 | 
					ea1d4b97ad | ||
| 
						 | 
					a81839c13f | ||
| 
						 | 
					b9d4668d4d | ||
| 
						 | 
					42b27f5965 | ||
| 
						 | 
					9cc8222b1c | ||
| 
						 | 
					e8479338df | ||
| 
						 | 
					fa9e6c9fc0 | ||
| 
						 | 
					5366173b52 | ||
| 
						 | 
					63520c55b3 | ||
| 
						 | 
					86f6d9b14a | ||
| 
						 | 
					5270cf6284 | ||
| 
						 | 
					4f46d81e1b | ||
| 
						 | 
					294a2e6fdb | ||
| 
						 | 
					b20a8bc90b | ||
| 
						 | 
					68bdd1336f | ||
| 
						 | 
					e62ccd932d | ||
| 
						 | 
					d6c188df6e | ||
| 
						 | 
					004000b5d2 | ||
| 
						 | 
					633c8a3444 | ||
| 
						 | 
					2f59a20b6b | ||
| 
						 | 
					593c435f75 | ||
| 
						 | 
					20ec45be57 | ||
| 
						 | 
					d2a0e12409 | ||
| 
						 | 
					33eebe117b | ||
| 
						 | 
					ef0cfc2e7c | ||
| 
						 | 
					b6e17ae543 | ||
| 
						 | 
					8a33e2be89 | ||
| 
						 | 
					5f91097987 | ||
| 
						 | 
					0fd4f02951 | ||
| 
						 | 
					106e78ed62 | ||
| 
						 | 
					8855868b27 | ||
| 
						 | 
					bfc3e8a907 | ||
| 
						 | 
					154371e052 | ||
| 
						 | 
					ab4a4d3d72 | ||
| 
						 | 
					5a4de02db7 | ||
| 
						 | 
					43cbc8c6e8 | ||
| 
						 | 
					5938aa7b50 | ||
| 
						 | 
					a49252b2f5 | ||
| 
						 | 
					0be885d9bf | ||
| 
						 | 
					ae1e8353f2 | ||
| 
						 | 
					98fe88581f | ||
| 
						 | 
					d66475576f | ||
| 
						 | 
					65ff7be776 | ||
| 
						 | 
					190b079494 | ||
| 
						 | 
					b020a30bd4 | ||
| 
						 | 
					81f8453c38 | ||
| 
						 | 
					533e3cf42d | ||
| 
						 | 
					69ee73492d | ||
| 
						 | 
					4a902d04b2 | ||
| 
						 | 
					2e48e316c2 | ||
| 
						 | 
					bbe5dddb83 | ||
| 
						 | 
					7c943fe4ac | ||
| 
						 | 
					2cbb49681a | ||
| 
						 | 
					84db4ed57c | ||
| 
						 | 
					e155642ce4 | ||
| 
						 | 
					87c4df60d3 | ||
| 
						 | 
					ff412835e4 | ||
| 
						 | 
					ad15828157 | ||
| 
						 | 
					b2fc7f934e | ||
| 
						 | 
					2fac4d91d6 | ||
| 
						 | 
					125cd96354 | ||
| 
						 | 
					af02e6b714 | ||
| 
						 | 
					0c87b25244 | ||
| 
						 | 
					e87ada6e79 | ||
| 
						 | 
					282c8e58bd | ||
| 
						 | 
					475b66b115 | ||
| 
						 | 
					5bb971e61a | ||
| 
						 | 
					ebad9ba723 | ||
| 
						 | 
					6ece2a839e | ||
| 
						 | 
					8d6527fb75 | ||
| 
						 | 
					6bfff38182 | ||
| 
						 | 
					9e446717fa | ||
| 
						 | 
					408b48f606 | ||
| 
						 | 
					8d077ad46d | ||
| 
						 | 
					db72465e0b | ||
| 
						 | 
					ba9f5e1688 | ||
| 
						 | 
					caf40cd272 | ||
| 
						 | 
					3edccd224a | ||
| 
						 | 
					f48931a969 | ||
| 
						 | 
					84f23aa997 | ||
| 
						 | 
					1965da6a85 | ||
| 
						 | 
					441ae3e25b | ||
| 
						 | 
					7f612711a0 | ||
| 
						 | 
					92eb4aa822 | ||
| 
						 | 
					08ec522ae7 | ||
| 
						 | 
					c5cc1fcc1e | ||
| 
						 | 
					cedf91ea1a | ||
| 
						 | 
					51b462f043 | ||
| 
						 | 
					727eeb6c74 | ||
| 
						 | 
					a114fba062 | ||
| 
						 | 
					cf322b5c2a | ||
| 
						 | 
					92116f1671 | ||
| 
						 | 
					bc479248d7 | ||
| 
						 | 
					8ee12f2950 | ||
| 
						 | 
					dcea4c30ef | ||
| 
						 | 
					e7ca56e061 | ||
| 
						 | 
					09b800b9ad | ||
| 
						 | 
					9a6a8580de | ||
| 
						 | 
					a31ac17792 | ||
| 
						 | 
					0e27cd0801 | ||
| 
						 | 
					bc36676fa1 | ||
| 
						 | 
					3d2db23f33 | ||
| 
						 | 
					56d366a286 | ||
| 
						 | 
					4a26f30d65 | ||
| 
						 | 
					8e51469de5 | ||
| 
						 | 
					50ebcd552c | ||
| 
						 | 
					ada39cd3c7 | ||
| 
						 | 
					b2d20af51a | ||
| 
						 | 
					f528fa25d1 | ||
| 
						 | 
					e09a7fb6e0 | ||
| 
						 | 
					30f7939616 | ||
| 
						 | 
					16b9375b9d | ||
| 
						 | 
					4ef93569a1 | ||
| 
						 | 
					1ce2aaeaf1 | ||
| 
						 | 
					6bfe8dfcf0 | ||
| 
						 | 
					8d8f4795e2 | ||
| 
						 | 
					6f6d06377b | ||
| 
						 | 
					f22823fcf6 | ||
| 
						 | 
					93ce57ee1a | ||
| 
						 | 
					97dd747252 | ||
| 
						 | 
					bc8c136458 | ||
| 
						 | 
					0774252dc1 | ||
| 
						 | 
					ae30ae4be6 | ||
| 
						 | 
					a2b8935763 | ||
| 
						 | 
					703efb74d3 | ||
| 
						 | 
					b2c6062e9a | ||
| 
						 | 
					c9e7e461b1 | ||
| 
						 | 
					6aaddfc5a4 | ||
| 
						 | 
					7f2c41940d | ||
| 
						 | 
					d31ba39a91 | ||
| 
						 | 
					c058673e33 | ||
| 
						 | 
					44ce6a5169 | ||
| 
						 | 
					0fb0be4ffc | ||
| 
						 | 
					e70ba00929 | ||
| 
						 | 
					fe1dbb4cbf | ||
| 
						 | 
					31df2341c3 | ||
| 
						 | 
					9d99da14e1 | ||
| 
						 | 
					f8e10f36db | ||
| 
						 | 
					bb0f384a39 | ||
| 
						 | 
					6a0b24f032 | ||
| 
						 | 
					80d5536503 | ||
| 
						 | 
					9dcd79bd94 | ||
| 
						 | 
					c5020b8884 | ||
| 
						 | 
					0b74de275c | ||
| 
						 | 
					e66aef17df | ||
| 
						 | 
					19eff5e6d6 | ||
| 
						 | 
					88b4fc73de | ||
| 
						 | 
					70694542eb | ||
| 
						 | 
					360e5e3102 | ||
| 
						 | 
					6e89a232e6 | ||
| 
						 | 
					ecd3b7039f | ||
| 
						 | 
					4a22e3d2d4 | ||
| 
						 | 
					dcb4ebe5d9 | ||
| 
						 | 
					dd379bf18d | ||
| 
						 | 
					c9b556160f | ||
| 
						 | 
					168e224d3e | ||
| 
						 | 
					9e57c14130 | ||
| 
						 | 
					9c137a1c48 | ||
| 
						 | 
					ccb9b7e5fb | ||
| 
						 | 
					c7b16cd043 | ||
| 
						 | 
					7e20e41521 | ||
| 
						 | 
					66761a69d3 | ||
| 
						 | 
					fb32d26479 | ||
| 
						 | 
					b6398fdb5d | ||
| 
						 | 
					d9443527ee | ||
| 
						 | 
					7c175da9f1 | ||
| 
						 | 
					05aa087851 | ||
| 
						 | 
					592e968f9f | ||
| 
						 | 
					894a26cc67 | ||
| 
						 | 
					1b5dd4638d | ||
| 
						 | 
					a19186c508 | ||
| 
						 | 
					5450bdeae9 | ||
| 
						 | 
					fcd71957ff | 
							
								
								
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							@@ -2,3 +2,5 @@
 | 
			
		||||
 | 
			
		||||
github: [eliandoran]
 | 
			
		||||
custom: ["https://paypal.me/eliandoran"]
 | 
			
		||||
liberapay: ElianDoran
 | 
			
		||||
buy_me_a_coffee: eliandoran
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								.github/workflows/checks.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/checks.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
name: Checks
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
  pull_request_target:
 | 
			
		||||
    types: [synchronize]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  main:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    permissions:
 | 
			
		||||
      contents: write
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Check if PRs have conflicts
 | 
			
		||||
        uses: eps1lon/actions-label-merge-conflict@v3
 | 
			
		||||
        with:
 | 
			
		||||
          dirtyLabel: "merge-conflicts"
 | 
			
		||||
          repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
 | 
			
		||||
							
								
								
									
										4
									
								
								.mailmap
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								.mailmap
									
									
									
									
									
								
							@@ -1,2 +1,2 @@
 | 
			
		||||
Adam Zivner <adam.zivner@gmail.com>
 | 
			
		||||
Adam Zivner <zadam.apps@gmail.com>
 | 
			
		||||
zadam <adam.zivner@gmail.com>
 | 
			
		||||
zadam <zadam.apps@gmail.com>
 | 
			
		||||
							
								
								
									
										9
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@@ -28,5 +28,12 @@
 | 
			
		||||
    "typescript.validate.enable": true,
 | 
			
		||||
    "typescript.tsserver.experimental.enableProjectDiagnostics": true,
 | 
			
		||||
    "typescript.tsdk": "node_modules/typescript/lib",
 | 
			
		||||
    "typescript.enablePromptUseWorkspaceTsdk": true
 | 
			
		||||
    "typescript.enablePromptUseWorkspaceTsdk": true,
 | 
			
		||||
    "search.exclude": {
 | 
			
		||||
        "**/node_modules": true,
 | 
			
		||||
        "docs/**/*.html": true,
 | 
			
		||||
        "docs/**/*.png": true,
 | 
			
		||||
        "apps/server/src/assets/doc_notes/**": true,
 | 
			
		||||
        "apps/edit-docs/demo/**": true
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								README.md
									
									
									
									
									
								
							@@ -1,6 +1,7 @@
 | 
			
		||||
# Trilium Notes
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
Donate:  
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
 | 
			
		||||
@@ -119,8 +120,8 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
 | 
			
		||||
 | 
			
		||||
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
 | 
			
		||||
```shell
 | 
			
		||||
git clone https://github.com/TriliumNext/Notes.git
 | 
			
		||||
cd Notes
 | 
			
		||||
git clone https://github.com/TriliumNext/Trilium.git
 | 
			
		||||
cd Trilium
 | 
			
		||||
pnpm install
 | 
			
		||||
pnpm run server:start
 | 
			
		||||
```
 | 
			
		||||
@@ -129,8 +130,8 @@ pnpm run server:start
 | 
			
		||||
 | 
			
		||||
Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:
 | 
			
		||||
```shell
 | 
			
		||||
git clone https://github.com/TriliumNext/Notes.git
 | 
			
		||||
cd Notes
 | 
			
		||||
git clone https://github.com/TriliumNext/Trilium.git
 | 
			
		||||
cd Trilium
 | 
			
		||||
pnpm install
 | 
			
		||||
pnpm nx run edit-docs:edit-docs
 | 
			
		||||
```
 | 
			
		||||
@@ -138,8 +139,8 @@ pnpm nx run edit-docs:edit-docs
 | 
			
		||||
### Building the Executable
 | 
			
		||||
Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows:
 | 
			
		||||
```shell
 | 
			
		||||
git clone https://github.com/TriliumNext/Notes.git
 | 
			
		||||
cd Notes
 | 
			
		||||
git clone https://github.com/TriliumNext/Trilium.git
 | 
			
		||||
cd Trilium
 | 
			
		||||
pnpm install
 | 
			
		||||
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -35,13 +35,13 @@
 | 
			
		||||
    "chore:generate-openapi": "tsx bin/generate-openapi.js"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {    
 | 
			
		||||
    "@playwright/test": "1.53.1",
 | 
			
		||||
    "@stylistic/eslint-plugin": "5.0.0",        
 | 
			
		||||
    "@playwright/test": "1.54.1",
 | 
			
		||||
    "@stylistic/eslint-plugin": "5.2.0",        
 | 
			
		||||
    "@types/express": "5.0.3",    
 | 
			
		||||
    "@types/node": "22.15.33",    
 | 
			
		||||
    "@types/node": "22.16.5",    
 | 
			
		||||
    "@types/yargs": "17.0.33",
 | 
			
		||||
    "@vitest/coverage-v8": "3.2.4",
 | 
			
		||||
    "eslint": "9.29.0",
 | 
			
		||||
    "eslint": "9.31.0",
 | 
			
		||||
    "eslint-plugin-simple-import-sort": "12.1.1",
 | 
			
		||||
    "esm": "3.2.25",
 | 
			
		||||
    "jsdoc": "4.0.4",
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
    "rcedit": "4.0.1",
 | 
			
		||||
    "rimraf": "6.0.1",    
 | 
			
		||||
    "tslib": "2.8.1",    
 | 
			
		||||
    "typedoc": "0.28.5",
 | 
			
		||||
    "typedoc": "0.28.7",
 | 
			
		||||
    "typedoc-plugin-missing-exports": "4.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "optionalDependencies": {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,3 +2,4 @@
 | 
			
		||||
# Note: This key must only be used for the Trilium Notes project.
 | 
			
		||||
# Expires on: 2025-09-13
 | 
			
		||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NTc3MjE1OTksImp0aSI6ImFiN2E0NjZmLWJlZGMtNDNiYy1iMzU4LTk0NGQ0YWJhY2I3ZiIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOlsic2giLCJkcnVwYWwiXSwid2hpdGVMYWJlbCI6dHJ1ZSwiZmVhdHVyZXMiOlsiRFJVUCIsIkNNVCIsIkRPIiwiRlAiLCJTQyIsIlRPQyIsIlRQTCIsIlBPRSIsIkNDIiwiTUYiLCJTRUUiLCJFQ0giLCJFSVMiXSwidmMiOiI1MzlkOWY5YyJ9.2rvKPql4hmukyXhEtWPZ8MLxKvzPIwzCdykO653g7IxRRZy2QJpeRszElZx9DakKYZKXekVRAwQKgHxwkgbE_w
 | 
			
		||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@triliumnext/client",
 | 
			
		||||
  "version": "0.96.0",
 | 
			
		||||
  "version": "0.97.1",
 | 
			
		||||
  "description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "license": "AGPL-3.0-only",
 | 
			
		||||
@@ -10,16 +10,16 @@
 | 
			
		||||
    "url": "https://github.com/TriliumNext/Notes"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@eslint/js": "9.29.0",
 | 
			
		||||
    "@eslint/js": "9.31.0",
 | 
			
		||||
    "@excalidraw/excalidraw": "0.18.0",
 | 
			
		||||
    "@fullcalendar/core": "6.1.17",
 | 
			
		||||
    "@fullcalendar/daygrid": "6.1.17",
 | 
			
		||||
    "@fullcalendar/interaction": "6.1.17",
 | 
			
		||||
    "@fullcalendar/list": "6.1.17",
 | 
			
		||||
    "@fullcalendar/multimonth": "6.1.17",
 | 
			
		||||
    "@fullcalendar/timegrid": "6.1.17",
 | 
			
		||||
    "@fullcalendar/core": "6.1.18",
 | 
			
		||||
    "@fullcalendar/daygrid": "6.1.18",
 | 
			
		||||
    "@fullcalendar/interaction": "6.1.18",
 | 
			
		||||
    "@fullcalendar/list": "6.1.18",
 | 
			
		||||
    "@fullcalendar/multimonth": "6.1.18",
 | 
			
		||||
    "@fullcalendar/timegrid": "6.1.18",
 | 
			
		||||
    "@mermaid-js/layout-elk": "0.1.8",
 | 
			
		||||
    "@mind-elixir/node-menu": "1.0.5",
 | 
			
		||||
    "@mind-elixir/node-menu": "5.0.0",
 | 
			
		||||
    "@popperjs/core": "2.11.8",
 | 
			
		||||
    "@triliumnext/ckeditor5": "workspace:*",
 | 
			
		||||
    "@triliumnext/codemirror": "workspace:*",
 | 
			
		||||
@@ -33,9 +33,9 @@
 | 
			
		||||
    "dayjs-plugin-utc": "0.1.2",
 | 
			
		||||
    "debounce": "2.2.0",
 | 
			
		||||
    "draggabilly": "3.0.0",
 | 
			
		||||
    "force-graph": "1.49.6",
 | 
			
		||||
    "globals": "16.2.0",
 | 
			
		||||
    "i18next": "25.2.1",
 | 
			
		||||
    "force-graph": "1.50.1",
 | 
			
		||||
    "globals": "16.3.0",
 | 
			
		||||
    "i18next": "25.3.2",
 | 
			
		||||
    "i18next-http-backend": "3.0.2",
 | 
			
		||||
    "jquery": "3.7.1",
 | 
			
		||||
    "jquery-hotkeys": "0.2.2",
 | 
			
		||||
@@ -46,27 +46,29 @@
 | 
			
		||||
    "leaflet": "1.9.4",
 | 
			
		||||
    "leaflet-gpx": "2.2.0",
 | 
			
		||||
    "mark.js": "8.11.1",
 | 
			
		||||
    "marked": "15.0.12",
 | 
			
		||||
    "mermaid": "11.7.0",
 | 
			
		||||
    "mind-elixir": "4.6.1",
 | 
			
		||||
    "marked": "16.1.1",
 | 
			
		||||
    "mermaid": "11.9.0",
 | 
			
		||||
    "mind-elixir": "5.0.2",
 | 
			
		||||
    "normalize.css": "8.0.1",
 | 
			
		||||
    "panzoom": "9.4.3",
 | 
			
		||||
    "preact": "10.26.9",
 | 
			
		||||
    "split.js": "1.6.5",
 | 
			
		||||
    "svg-pan-zoom": "3.6.2",
 | 
			
		||||
    "tabulator-tables": "6.3.1",
 | 
			
		||||
    "vanilla-js-wheel-zoom": "9.0.4"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@ckeditor/ckeditor5-inspector": "4.1.0",
 | 
			
		||||
    "@ckeditor/ckeditor5-inspector": "5.0.0",
 | 
			
		||||
    "@types/bootstrap": "5.2.10",
 | 
			
		||||
    "@types/jquery": "3.5.32",
 | 
			
		||||
    "@types/leaflet": "1.9.19",
 | 
			
		||||
    "@types/leaflet": "1.9.20",
 | 
			
		||||
    "@types/leaflet-gpx": "1.3.7",
 | 
			
		||||
    "@types/mark.js": "8.11.12",
 | 
			
		||||
    "@types/tabulator-tables": "6.2.7",
 | 
			
		||||
    "copy-webpack-plugin": "13.0.0",
 | 
			
		||||
    "happy-dom": "18.0.1",
 | 
			
		||||
    "script-loader": "0.7.2",
 | 
			
		||||
    "vite-plugin-static-copy": "3.1.0"
 | 
			
		||||
    "vite-plugin-static-copy": "3.1.1"
 | 
			
		||||
  },
 | 
			
		||||
  "nx": {
 | 
			
		||||
    "name": "client",
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,8 @@ import TouchBarComponent from "./touch_bar.js";
 | 
			
		||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
 | 
			
		||||
import type CodeMirror from "@triliumnext/codemirror";
 | 
			
		||||
import { StartupChecks } from "./startup_checks.js";
 | 
			
		||||
import type { CreateNoteOpts } from "../services/note_create.js";
 | 
			
		||||
import { ColumnComponent } from "tabulator-tables";
 | 
			
		||||
 | 
			
		||||
interface Layout {
 | 
			
		||||
    getRootWidget: (appContext: AppContext) => RootWidget;
 | 
			
		||||
@@ -122,6 +124,7 @@ export type CommandMappings = {
 | 
			
		||||
    showImportDialog: CommandData & { noteId: string };
 | 
			
		||||
    openNewNoteSplit: NoteCommandData;
 | 
			
		||||
    openInWindow: NoteCommandData;
 | 
			
		||||
    openInPopup: CommandData & { noteIdOrPath: string; };
 | 
			
		||||
    openNoteInNewTab: CommandData;
 | 
			
		||||
    openNoteInNewSplit: CommandData;
 | 
			
		||||
    openNoteInNewWindow: CommandData;
 | 
			
		||||
@@ -140,6 +143,7 @@ export type CommandMappings = {
 | 
			
		||||
    };
 | 
			
		||||
    openInTab: ContextMenuCommandData;
 | 
			
		||||
    openNoteInSplit: ContextMenuCommandData;
 | 
			
		||||
    openNoteInPopup: ContextMenuCommandData;
 | 
			
		||||
    toggleNoteHoisting: ContextMenuCommandData;
 | 
			
		||||
    insertNoteAfter: ContextMenuCommandData;
 | 
			
		||||
    insertChildNote: ContextMenuCommandData;
 | 
			
		||||
@@ -261,7 +265,6 @@ export type CommandMappings = {
 | 
			
		||||
 | 
			
		||||
    // Geomap
 | 
			
		||||
    deleteFromMap: { noteId: string };
 | 
			
		||||
    openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent };
 | 
			
		||||
 | 
			
		||||
    toggleZenMode: CommandData;
 | 
			
		||||
 | 
			
		||||
@@ -275,6 +278,21 @@ export type CommandMappings = {
 | 
			
		||||
 | 
			
		||||
    geoMapCreateChildNote: CommandData;
 | 
			
		||||
 | 
			
		||||
    // Table view
 | 
			
		||||
    addNewRow: CommandData & {
 | 
			
		||||
        customOpts: CreateNoteOpts;
 | 
			
		||||
        parentNotePath?: string;
 | 
			
		||||
    };
 | 
			
		||||
    addNewTableColumn: CommandData & {
 | 
			
		||||
        columnToEdit?: ColumnComponent;
 | 
			
		||||
        referenceColumn?: ColumnComponent;
 | 
			
		||||
        direction?: "before" | "after";
 | 
			
		||||
        type?: "label" | "relation";
 | 
			
		||||
    };
 | 
			
		||||
    deleteTableColumn: CommandData & {
 | 
			
		||||
        columnToDelete?: ColumnComponent;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    buildTouchBar: CommandData & {
 | 
			
		||||
        TouchBar: typeof TouchBar;
 | 
			
		||||
        buildIcon(name: string): NativeImage;
 | 
			
		||||
 
 | 
			
		||||
@@ -93,11 +93,7 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
 | 
			
		||||
 | 
			
		||||
        if (fun) {
 | 
			
		||||
            return this.callMethod(fun, data);
 | 
			
		||||
        } else {
 | 
			
		||||
            if (!this.parent) {
 | 
			
		||||
                throw new Error(`Component "${this.componentId}" does not have a parent attached to propagate a command.`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        } else if (this.parent) {
 | 
			
		||||
            return this.parent.triggerCommand(name, data);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -315,14 +315,38 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hasNoteList() {
 | 
			
		||||
        return (
 | 
			
		||||
            this.note &&
 | 
			
		||||
            ["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") &&
 | 
			
		||||
            (this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") &&
 | 
			
		||||
            ["book", "text", "code"].includes(this.note.type) &&
 | 
			
		||||
            this.note.mime !== "text/x-sqlite;schema=trilium" &&
 | 
			
		||||
            !this.note.isLabelTruthy("hideChildrenOverview")
 | 
			
		||||
        );
 | 
			
		||||
        const note = this.note;
 | 
			
		||||
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Some book types must always display a note list, even if no children.
 | 
			
		||||
        if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!note.hasChildren()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!["book", "text", "code"].includes(note.type)) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (note.mime === "text/x-sqlite;schema=trilium") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (note.isLabelTruthy("hideChildrenOverview")) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getTextEditor(callback?: GetTextEditorCallback) {
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,6 @@ const NOTE_TYPE_ICONS = {
 | 
			
		||||
    doc: "bx bxs-file-doc",
 | 
			
		||||
    contentWidget: "bx bxs-widget",
 | 
			
		||||
    mindMap: "bx bx-sitemap",
 | 
			
		||||
    geoMap: "bx bx-map-alt",
 | 
			
		||||
    aiChat: "bx bx-bot"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +35,7 @@ const NOTE_TYPE_ICONS = {
 | 
			
		||||
 * end user. Those types should be used only for checking against, they are
 | 
			
		||||
 * not for direct use.
 | 
			
		||||
 */
 | 
			
		||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap" | "aiChat";
 | 
			
		||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat";
 | 
			
		||||
 | 
			
		||||
export interface NotePathRecord {
 | 
			
		||||
    isArchived: boolean;
 | 
			
		||||
@@ -257,6 +256,20 @@ class FNote {
 | 
			
		||||
        return this.children;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getSubtreeNoteIds() {
 | 
			
		||||
        let noteIds: (string | string[])[] = [];
 | 
			
		||||
        for (const child of await this.getChildNotes()) {
 | 
			
		||||
            noteIds.push(child.noteId);
 | 
			
		||||
            noteIds.push(await child.getSubtreeNoteIds());
 | 
			
		||||
        }
 | 
			
		||||
        return noteIds.flat();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getSubtreeNotes() {
 | 
			
		||||
        const noteIds = await this.getSubtreeNoteIds();
 | 
			
		||||
        return this.froca.getNotes(noteIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getChildNotes() {
 | 
			
		||||
        return await this.froca.getNotes(this.children);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -46,28 +46,7 @@ import SharedInfoWidget from "../widgets/shared_info.js";
 | 
			
		||||
import FindWidget from "../widgets/find.js";
 | 
			
		||||
import TocWidget from "../widgets/toc.js";
 | 
			
		||||
import HighlightsListWidget from "../widgets/highlights_list.js";
 | 
			
		||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
 | 
			
		||||
import AboutDialog from "../widgets/dialogs/about.js";
 | 
			
		||||
import HelpDialog from "../widgets/dialogs/help.js";
 | 
			
		||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
 | 
			
		||||
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
 | 
			
		||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
 | 
			
		||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
 | 
			
		||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
 | 
			
		||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
 | 
			
		||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
 | 
			
		||||
import AddLinkDialog from "../widgets/dialogs/add_link.js";
 | 
			
		||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
 | 
			
		||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
 | 
			
		||||
import ImportDialog from "../widgets/dialogs/import.js";
 | 
			
		||||
import ExportDialog from "../widgets/dialogs/export.js";
 | 
			
		||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
 | 
			
		||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
 | 
			
		||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
 | 
			
		||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
 | 
			
		||||
import InfoDialog from "../widgets/dialogs/info.js";
 | 
			
		||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
 | 
			
		||||
import PromptDialog from "../widgets/dialogs/prompt.js";
 | 
			
		||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
 | 
			
		||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
 | 
			
		||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
 | 
			
		||||
@@ -83,7 +62,7 @@ import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_ref
 | 
			
		||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
 | 
			
		||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
 | 
			
		||||
import options from "../services/options.js";
 | 
			
		||||
import utils, { hasTouchBar } from "../services/utils.js";
 | 
			
		||||
import utils from "../services/utils.js";
 | 
			
		||||
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
 | 
			
		||||
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
 | 
			
		||||
import CloseZenButton from "../widgets/close_zen_button.js";
 | 
			
		||||
@@ -229,7 +208,7 @@ export default class DesktopLayout {
 | 
			
		||||
                                                                .child(new PromotedAttributesWidget())
 | 
			
		||||
                                                                .child(new SqlTableSchemasWidget())
 | 
			
		||||
                                                                .child(new NoteDetailWidget())
 | 
			
		||||
                                                                .child(new NoteListWidget())
 | 
			
		||||
                                                                .child(new NoteListWidget(false))
 | 
			
		||||
                                                                .child(new SearchResultWidget())
 | 
			
		||||
                                                                .child(new SqlResultWidget())
 | 
			
		||||
                                                                .child(new ScrollPaddingWidget())
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,14 @@ import RevisionsDialog from "../widgets/dialogs/revisions.js";
 | 
			
		||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
 | 
			
		||||
import InfoDialog from "../widgets/dialogs/info.js";
 | 
			
		||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
 | 
			
		||||
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
 | 
			
		||||
import FlexContainer from "../widgets/containers/flex_container.js";
 | 
			
		||||
import NoteIconWidget from "../widgets/note_icon.js";
 | 
			
		||||
import NoteTitleWidget from "../widgets/note_title.js";
 | 
			
		||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
 | 
			
		||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
 | 
			
		||||
import NoteDetailWidget from "../widgets/note_detail.js";
 | 
			
		||||
import NoteListWidget from "../widgets/note_list.js";
 | 
			
		||||
 | 
			
		||||
export function applyModals(rootContainer: RootContainer) {
 | 
			
		||||
    rootContainer
 | 
			
		||||
@@ -47,4 +55,15 @@ export function applyModals(rootContainer: RootContainer) {
 | 
			
		||||
        .child(new ConfirmDialog())
 | 
			
		||||
        .child(new PromptDialog())
 | 
			
		||||
        .child(new IncorrectCpuArchDialog())
 | 
			
		||||
        .child(new PopupEditorDialog()
 | 
			
		||||
                .child(new FlexContainer("row")
 | 
			
		||||
                    .class("title-row")
 | 
			
		||||
                    .css("align-items", "center")
 | 
			
		||||
                    .cssBlock(".title-row > * { margin: 5px; }")
 | 
			
		||||
                    .child(new NoteIconWidget())
 | 
			
		||||
                    .child(new NoteTitleWidget()))
 | 
			
		||||
                .child(new ClassicEditorToolbar())
 | 
			
		||||
                .child(new PromotedAttributesWidget())
 | 
			
		||||
                .child(new NoteDetailWidget())
 | 
			
		||||
                .child(new NoteListWidget(true)))
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -162,7 +162,7 @@ export default class MobileLayout {
 | 
			
		||||
                                    .filling()
 | 
			
		||||
                                    .contentSized()
 | 
			
		||||
                                    .child(new NoteDetailWidget())
 | 
			
		||||
                                    .child(new NoteListWidget())
 | 
			
		||||
                                    .child(new NoteListWidget(false))
 | 
			
		||||
                                    .child(new FilePropertiesWidget().css("font-size", "smaller"))
 | 
			
		||||
                            )
 | 
			
		||||
                            .child(new MobileEditorToolbar())
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import keyboardActionService from "../services/keyboard_actions.js";
 | 
			
		||||
import note_tooltip from "../services/note_tooltip.js";
 | 
			
		||||
import utils from "../services/utils.js";
 | 
			
		||||
 | 
			
		||||
interface ContextMenuOptions<T> {
 | 
			
		||||
export interface ContextMenuOptions<T> {
 | 
			
		||||
    x: number;
 | 
			
		||||
    y: number;
 | 
			
		||||
    orientation?: "left";
 | 
			
		||||
@@ -17,17 +17,30 @@ interface MenuSeparatorItem {
 | 
			
		||||
    title: "----";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface MenuItemBadge {
 | 
			
		||||
    title: string;
 | 
			
		||||
    className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface MenuCommandItem<T> {
 | 
			
		||||
    title: string;
 | 
			
		||||
    command?: T;
 | 
			
		||||
    type?: string;
 | 
			
		||||
    /**
 | 
			
		||||
     * The icon to display in the menu item.
 | 
			
		||||
     *
 | 
			
		||||
     * If not set, no icon is displayed and the item will appear shifted slightly to the left if there are other items with icons. To avoid this, use `bx bx-empty`.
 | 
			
		||||
     */
 | 
			
		||||
    uiIcon?: string;
 | 
			
		||||
    badges?: MenuItemBadge[];
 | 
			
		||||
    templateNoteId?: string;
 | 
			
		||||
    enabled?: boolean;
 | 
			
		||||
    handler?: MenuHandler<T>;
 | 
			
		||||
    items?: MenuItem<T>[] | null;
 | 
			
		||||
    shortcut?: string;
 | 
			
		||||
    spellingSuggestion?: string;
 | 
			
		||||
    checked?: boolean;
 | 
			
		||||
    columns?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
 | 
			
		||||
@@ -146,17 +159,32 @@ class ContextMenu {
 | 
			
		||||
            } else {
 | 
			
		||||
                const $icon = $("<span>");
 | 
			
		||||
 | 
			
		||||
                if ("uiIcon" in item && item.uiIcon) {
 | 
			
		||||
                    $icon.addClass(item.uiIcon);
 | 
			
		||||
                if ("uiIcon" in item || "checked" in item) {
 | 
			
		||||
                    const icon = (item.checked ? "bx bx-check" : item.uiIcon);
 | 
			
		||||
                    if (icon) {
 | 
			
		||||
                        $icon.addClass(icon);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        $icon.append(" ");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const $link = $("<span>")
 | 
			
		||||
                    .append($icon)
 | 
			
		||||
                    .append("   ") // some space between icon and text
 | 
			
		||||
                    .append(item.title);
 | 
			
		||||
 | 
			
		||||
                if ("badges" in item && item.badges) {
 | 
			
		||||
                    for (let badge of item.badges) {
 | 
			
		||||
                        const badgeElement = $(`<span class="badge">`).text(badge.title);
 | 
			
		||||
 | 
			
		||||
                        if (badge.className) {
 | 
			
		||||
                            badgeElement.addClass(badge.className);
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        $link.append(badgeElement);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if ("shortcut" in item && item.shortcut) {
 | 
			
		||||
                    $link.append($("<kbd>").text(item.shortcut));
 | 
			
		||||
                }
 | 
			
		||||
@@ -213,6 +241,9 @@ class ContextMenu {
 | 
			
		||||
                    $link.addClass("dropdown-toggle");
 | 
			
		||||
 | 
			
		||||
                    const $subMenu = $("<ul>").addClass("dropdown-menu");
 | 
			
		||||
                    if (!this.isMobile && item.columns) {
 | 
			
		||||
                        $subMenu.css("column-count", item.columns);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    this.addItems($subMenu, item.items);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,8 @@ function getItems(): MenuItem<CommandNames>[] {
 | 
			
		||||
    return [
 | 
			
		||||
        { title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" },
 | 
			
		||||
        { title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" },
 | 
			
		||||
        { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }
 | 
			
		||||
        { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" },
 | 
			
		||||
        { title: t("link_context_menu.open_note_in_popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit" }
 | 
			
		||||
    ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -40,6 +41,8 @@ function handleLinkContextMenuItem(command: string | undefined, notePath: string
 | 
			
		||||
        appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope });
 | 
			
		||||
    } else if (command === "openNoteInNewWindow") {
 | 
			
		||||
        appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
 | 
			
		||||
    } else if (command === "openNoteInPopup") {
 | 
			
		||||
        appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -70,8 +70,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
 | 
			
		||||
        const items: (MenuItem<TreeCommandNames> | null)[] = [
 | 
			
		||||
            { title: `${t("tree-context-menu.open-in-a-new-tab")}`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
 | 
			
		||||
 | 
			
		||||
            { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
 | 
			
		||||
            { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes },
 | 
			
		||||
 | 
			
		||||
            isHoisted
 | 
			
		||||
                ? null
 | 
			
		||||
@@ -92,7 +92,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
                command: "insertNoteAfter",
 | 
			
		||||
                uiIcon: "bx bx-plus",
 | 
			
		||||
                items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
 | 
			
		||||
                enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp
 | 
			
		||||
                enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp,
 | 
			
		||||
                columns: 2
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            {
 | 
			
		||||
@@ -100,7 +101,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
                command: "insertChildNote",
 | 
			
		||||
                uiIcon: "bx bx-plus",
 | 
			
		||||
                items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
 | 
			
		||||
                enabled: notSearch && noSelectedNotes && notOptionsOrHelp
 | 
			
		||||
                enabled: notSearch && noSelectedNotes && notOptionsOrHelp,
 | 
			
		||||
                columns: 2
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
@@ -127,12 +129,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
                        enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
 | 
			
		||||
                    },
 | 
			
		||||
                    { title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
 | 
			
		||||
                    {
 | 
			
		||||
                        title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`,
 | 
			
		||||
                        command: "duplicateSubtree",
 | 
			
		||||
                        uiIcon: "bx bx-outline",
 | 
			
		||||
                        enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
 | 
			
		||||
                    },
 | 
			
		||||
                    
 | 
			
		||||
                    { title: "----" },
 | 
			
		||||
 | 
			
		||||
@@ -186,6 +182,13 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
 | 
			
		||||
            { title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
 | 
			
		||||
 | 
			
		||||
            {
 | 
			
		||||
                title: `${t("tree-context-menu.duplicate")} <kbd data-command="duplicateSubtree">`,
 | 
			
		||||
                command: "duplicateSubtree",
 | 
			
		||||
                uiIcon: "bx bx-outline",
 | 
			
		||||
                enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            {
 | 
			
		||||
                title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
 | 
			
		||||
                command: "deleteNotes",
 | 
			
		||||
@@ -244,6 +247,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
 | 
			
		||||
            const { ntxId } = subContexts?.[subContexts.length - 1] ?? {};
 | 
			
		||||
 | 
			
		||||
            this.treeWidget.triggerCommand("openNewNoteSplit", { ntxId, notePath });
 | 
			
		||||
        } else if (command === "openNoteInPopup") {
 | 
			
		||||
            appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath })
 | 
			
		||||
        } else if (command === "convertNoteToAttachment") {
 | 
			
		||||
            if (!(await dialogService.confirm(t("tree-context-menu.convert-to-attachment-confirm")))) {
 | 
			
		||||
                return;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,21 @@ import froca from "./froca.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import type { AttributeRow } from "./load_results.js";
 | 
			
		||||
 | 
			
		||||
async function addLabel(noteId: string, name: string, value: string = "") {
 | 
			
		||||
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
 | 
			
		||||
    await server.put(`notes/${noteId}/attribute`, {
 | 
			
		||||
        type: "label",
 | 
			
		||||
        name: name,
 | 
			
		||||
        value: value
 | 
			
		||||
        value: value,
 | 
			
		||||
        isInheritable
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function setLabel(noteId: string, name: string, value: string = "") {
 | 
			
		||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
 | 
			
		||||
    await server.put(`notes/${noteId}/set-attribute`, {
 | 
			
		||||
        type: "label",
 | 
			
		||||
        name: name,
 | 
			
		||||
        value: value
 | 
			
		||||
        value: value,
 | 
			
		||||
        isInheritable
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -49,7 +51,7 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
 | 
			
		||||
 * @param name the name of the attribute to set.
 | 
			
		||||
 * @param value the value of the attribute to set.
 | 
			
		||||
 */
 | 
			
		||||
async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
 | 
			
		||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
 | 
			
		||||
    if (value) {
 | 
			
		||||
        // Create or update the attribute.
 | 
			
		||||
        await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
 | 
			
		||||
 
 | 
			
		||||
@@ -95,7 +95,15 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false) {
 | 
			
		||||
/**
 | 
			
		||||
 * Shows the delete confirmation screen
 | 
			
		||||
 *
 | 
			
		||||
 * @param branchIdsToDelete the list of branch IDs to delete.
 | 
			
		||||
 * @param forceDeleteAllClones whether to check by default the "Delete also all clones" checkbox.
 | 
			
		||||
 * @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s).
 | 
			
		||||
 * @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded.
 | 
			
		||||
 */
 | 
			
		||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) {
 | 
			
		||||
    branchIdsToDelete = filterRootNote(branchIdsToDelete);
 | 
			
		||||
 | 
			
		||||
    if (branchIdsToDelete.length === 0) {
 | 
			
		||||
@@ -110,11 +118,13 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (moveToParent) {
 | 
			
		||||
        try {
 | 
			
		||||
            await activateParentNotePath();
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            console.error(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const taskId = utils.randomString(10);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,8 @@ import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation
 | 
			
		||||
import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
 | 
			
		||||
import { t } from "./i18n.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import toast from "./toast.js";
 | 
			
		||||
import { BulkAction } from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
const ACTION_GROUPS = [
 | 
			
		||||
    {
 | 
			
		||||
@@ -89,6 +91,17 @@ function parseActions(note: FNote) {
 | 
			
		||||
        .filter((action) => !!action);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function executeBulkActions(parentNoteId: string, actions: BulkAction[]) {
 | 
			
		||||
    await server.post("bulk-action/execute", {
 | 
			
		||||
        noteIds: [ parentNoteId ],
 | 
			
		||||
        includeDescendants: true,
 | 
			
		||||
        actions
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await ws.waitForMaxKnownEntityChangeId();
 | 
			
		||||
    toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    addAction,
 | 
			
		||||
    parseActions,
 | 
			
		||||
 
 | 
			
		||||
@@ -118,8 +118,17 @@ async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HT
 | 
			
		||||
async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>) {
 | 
			
		||||
    const blob = await note.getBlob();
 | 
			
		||||
 | 
			
		||||
    let content = blob?.content || "";
 | 
			
		||||
    if (note.mime === "application/json") {
 | 
			
		||||
        try {
 | 
			
		||||
            content = JSON.stringify(JSON.parse(content), null, 4);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            // Ignore JSON parsing errors.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const $codeBlock = $("<code>");
 | 
			
		||||
    $codeBlock.text(blob?.content || "");
 | 
			
		||||
    $codeBlock.text(content);
 | 
			
		||||
    $renderedContent.append($("<pre>").append($codeBlock));
 | 
			
		||||
    await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
 | 
			
		||||
}
 | 
			
		||||
@@ -301,7 +310,7 @@ function getRenderingType(entity: FNote | FAttachment) {
 | 
			
		||||
 | 
			
		||||
    if (type === "file" && mime === "application/pdf") {
 | 
			
		||||
        type = "pdf";
 | 
			
		||||
    } else if (type === "file" && mime && CODE_MIME_TYPES.has(mime)) {
 | 
			
		||||
    } else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime)) {
 | 
			
		||||
        type = "code";
 | 
			
		||||
    } else if (type === "file" && mime && mime.startsWith("audio/")) {
 | 
			
		||||
        type = "audio";
 | 
			
		||||
 
 | 
			
		||||
@@ -4,14 +4,14 @@ import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptio
 | 
			
		||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
 | 
			
		||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
 | 
			
		||||
 | 
			
		||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
 | 
			
		||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
 | 
			
		||||
    if (closeActDialog) {
 | 
			
		||||
        closeActiveDialog();
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    saveFocusedElement();
 | 
			
		||||
    Modal.getOrCreateInstance($dialog[0]).show();
 | 
			
		||||
    Modal.getOrCreateInstance($dialog[0], config).show();
 | 
			
		||||
 | 
			
		||||
    $dialog.on("hidden.bs.modal", () => {
 | 
			
		||||
        const $autocompleteEl = $(".aa-input");
 | 
			
		||||
@@ -41,8 +41,14 @@ async function info(message: string) {
 | 
			
		||||
    return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Displays a confirmation dialog with the given message.
 | 
			
		||||
 *
 | 
			
		||||
 * @param message the message to display in the dialog.
 | 
			
		||||
 * @returns A promise that resolves to true if the user confirmed, false otherwise.
 | 
			
		||||
 */
 | 
			
		||||
async function confirm(message: string) {
 | 
			
		||||
    return new Promise((res) =>
 | 
			
		||||
    return new Promise<boolean>((res) =>
 | 
			
		||||
        appContext.triggerCommand("showConfirmDialog", <ConfirmWithMessageOptions>{
 | 
			
		||||
            message,
 | 
			
		||||
            callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed)
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,13 @@ function setupGlobs() {
 | 
			
		||||
        const string = e?.reason?.message?.toLowerCase();
 | 
			
		||||
 | 
			
		||||
        let message = "Uncaught error: ";
 | 
			
		||||
        let errorObjectString;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            errorObjectString = JSON.stringify(e.reason)
 | 
			
		||||
        } catch (error: any) {
 | 
			
		||||
            errorObjectString = error.toString();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string?.includes("script error")) {
 | 
			
		||||
            message += "No details available";
 | 
			
		||||
@@ -57,7 +64,7 @@ function setupGlobs() {
 | 
			
		||||
                `Message: ${e.reason.message}`,
 | 
			
		||||
                `Line: ${e.reason.lineNumber}`,
 | 
			
		||||
                `Column: ${e.reason.columnNumber}`,
 | 
			
		||||
                `Error object: ${JSON.stringify(e.reason)}`,
 | 
			
		||||
                `Error object: ${errorObjectString}`,
 | 
			
		||||
                `Stack: ${e.reason && e.reason.stack}`
 | 
			
		||||
            ].join(", ");
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -231,6 +231,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
 | 
			
		||||
    let ntxId: string | null = null;
 | 
			
		||||
    let hoistedNoteId: string | null = null;
 | 
			
		||||
    let searchString: string | null = null;
 | 
			
		||||
    let openInPopup = false;
 | 
			
		||||
 | 
			
		||||
    if (paramString) {
 | 
			
		||||
        for (const pair of paramString.split("&")) {
 | 
			
		||||
@@ -246,6 +247,8 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
 | 
			
		||||
                searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla
 | 
			
		||||
            } else if (["viewMode", "attachmentId"].includes(name)) {
 | 
			
		||||
                (viewScope as any)[name] = value;
 | 
			
		||||
            } else if (name === "popup") {
 | 
			
		||||
                openInPopup = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                console.warn(`Unrecognized hash parameter '${name}'.`);
 | 
			
		||||
            }
 | 
			
		||||
@@ -266,7 +269,8 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
 | 
			
		||||
        ntxId,
 | 
			
		||||
        hoistedNoteId,
 | 
			
		||||
        viewScope,
 | 
			
		||||
        searchString
 | 
			
		||||
        searchString,
 | 
			
		||||
        openInPopup
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -277,13 +281,21 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
 | 
			
		||||
    return goToLinkExt(evt, hrefLink, $link);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement>, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
 | 
			
		||||
/**
 | 
			
		||||
 * Handles navigation to a link, which can be an internal note path (e.g., `#root/1234`) or an external URL (e.g., `https://example.com`).
 | 
			
		||||
 *
 | 
			
		||||
 * @param evt the event that triggered the link navigation, or `null` if the link was clicked programmatically. Used to determine if the link should be opened in a new tab/window, based on the button presses.
 | 
			
		||||
 * @param hrefLink the link to navigate to, which can be a note path (e.g., `#root/1234`) or an external URL with any supported protocol (e.g., `https://example.com`).
 | 
			
		||||
 * @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
 | 
			
		||||
 * @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
 | 
			
		||||
 */
 | 
			
		||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
 | 
			
		||||
    if (hrefLink?.startsWith("data:")) {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    evt.stopPropagation();
 | 
			
		||||
    evt?.preventDefault();
 | 
			
		||||
    evt?.stopPropagation();
 | 
			
		||||
 | 
			
		||||
    if (hrefLink && hrefLink.startsWith("#") && !hrefLink.startsWith("#root/") && $link) {
 | 
			
		||||
        if (handleAnchor(hrefLink, $link)) {
 | 
			
		||||
@@ -291,19 +303,22 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink);
 | 
			
		||||
    const { notePath, viewScope, openInPopup } = parseNavigationStateFromUrl(hrefLink);
 | 
			
		||||
 | 
			
		||||
    const ctrlKey = utils.isCtrlKey(evt);
 | 
			
		||||
    const shiftKey = evt.shiftKey;
 | 
			
		||||
    const isLeftClick = "which" in evt && evt.which === 1;
 | 
			
		||||
    const isMiddleClick = "which" in evt && evt.which === 2;
 | 
			
		||||
    const ctrlKey = evt && utils.isCtrlKey(evt);
 | 
			
		||||
    const shiftKey = evt?.shiftKey;
 | 
			
		||||
    const isLeftClick = !evt || ("which" in evt && evt.which === 1);
 | 
			
		||||
    // Right click is handled separately.
 | 
			
		||||
    const isMiddleClick = evt && "which" in evt && evt.which === 2;
 | 
			
		||||
    const targetIsBlank = ($link?.attr("target") === "_blank");
 | 
			
		||||
    const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank;
 | 
			
		||||
    const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey);
 | 
			
		||||
    const openInNewWindow = isLeftClick && evt.shiftKey && !ctrlKey;
 | 
			
		||||
    const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
 | 
			
		||||
 | 
			
		||||
    if (notePath) {
 | 
			
		||||
        if (openInNewWindow) {
 | 
			
		||||
        if (isLeftClick && openInPopup) {
 | 
			
		||||
            appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
 | 
			
		||||
        } else if (openInNewWindow) {
 | 
			
		||||
            appContext.triggerCommand("openInWindow", { notePath, viewScope });
 | 
			
		||||
        } else if (openInNewTab) {
 | 
			
		||||
            appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
 | 
			
		||||
@@ -311,7 +326,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
 | 
			
		||||
                viewScope
 | 
			
		||||
            });
 | 
			
		||||
        } else if (isLeftClick) {
 | 
			
		||||
            const ntxId = $(evt.target as any)
 | 
			
		||||
            const ntxId = $(evt?.target as any)
 | 
			
		||||
                .closest("[data-ntx-id]")
 | 
			
		||||
                .attr("data-ntx-id");
 | 
			
		||||
 | 
			
		||||
@@ -379,6 +394,12 @@ function linkContextMenu(e: PointerEvent) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (utils.isCtrlKey(e) && e.button === 2) {
 | 
			
		||||
        appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
 | 
			
		||||
 
 | 
			
		||||
@@ -40,7 +40,10 @@ interface Options {
 | 
			
		||||
    allowCreatingNotes?: boolean;
 | 
			
		||||
    allowJumpToSearchNotes?: boolean;
 | 
			
		||||
    allowExternalLinks?: boolean;
 | 
			
		||||
    /** If set, hides the right-side button corresponding to go to selected note. */
 | 
			
		||||
    hideGoToSelectedNoteButton?: boolean;
 | 
			
		||||
    /** If set, hides all right-side buttons in the autocomplete dropdown */
 | 
			
		||||
    hideAllButtons?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function autocompleteSourceForCKEditor(queryText: string) {
 | 
			
		||||
@@ -190,9 +193,11 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
 | 
			
		||||
 | 
			
		||||
    const $goToSelectedNoteButton = $("<a>").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right");
 | 
			
		||||
 | 
			
		||||
    if (!options.hideAllButtons) {
 | 
			
		||||
        $el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!options.hideGoToSelectedNoteButton) {
 | 
			
		||||
    if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) {
 | 
			
		||||
        $el.after($goToSelectedNoteButton);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ import type FBranch from "../entities/fbranch.js";
 | 
			
		||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
 | 
			
		||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
 | 
			
		||||
 | 
			
		||||
interface CreateNoteOpts {
 | 
			
		||||
export interface CreateNoteOpts {
 | 
			
		||||
    isProtected?: boolean;
 | 
			
		||||
    saveSelection?: boolean;
 | 
			
		||||
    title?: string | null;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,38 +1,29 @@
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import CalendarView from "../widgets/view_widgets/calendar_view.js";
 | 
			
		||||
import GeoView from "../widgets/view_widgets/geo_view/index.js";
 | 
			
		||||
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
 | 
			
		||||
import TableView from "../widgets/view_widgets/table_view/index.js";
 | 
			
		||||
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
 | 
			
		||||
import type ViewMode from "../widgets/view_widgets/view_mode.js";
 | 
			
		||||
 | 
			
		||||
export type ViewTypeOptions = "list" | "grid" | "calendar";
 | 
			
		||||
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
 | 
			
		||||
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
 | 
			
		||||
 | 
			
		||||
export default class NoteListRenderer {
 | 
			
		||||
 | 
			
		||||
    private viewType: ViewTypeOptions;
 | 
			
		||||
    public viewMode: ViewMode | null;
 | 
			
		||||
    private args: ArgsWithoutNoteId;
 | 
			
		||||
    public viewMode?: ViewMode<any>;
 | 
			
		||||
 | 
			
		||||
    constructor($parent: JQuery<HTMLElement>, parentNote: FNote, noteIds: string[], showNotePath: boolean = false) {
 | 
			
		||||
        this.viewType = this.#getViewType(parentNote);
 | 
			
		||||
        const args: ViewModeArgs = {
 | 
			
		||||
            $parent,
 | 
			
		||||
            parentNote,
 | 
			
		||||
            noteIds,
 | 
			
		||||
            showNotePath
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (this.viewType === "list" || this.viewType === "grid") {
 | 
			
		||||
            this.viewMode = new ListOrGridView(this.viewType, args);
 | 
			
		||||
        } else if (this.viewType === "calendar") {
 | 
			
		||||
            this.viewMode = new CalendarView(args);
 | 
			
		||||
        } else {
 | 
			
		||||
            this.viewMode = null;
 | 
			
		||||
        }
 | 
			
		||||
    constructor(args: ArgsWithoutNoteId) {
 | 
			
		||||
        this.args = args;
 | 
			
		||||
        this.viewType = this.#getViewType(args.parentNote);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #getViewType(parentNote: FNote): ViewTypeOptions {
 | 
			
		||||
        const viewType = parentNote.getLabelValue("viewType");
 | 
			
		||||
 | 
			
		||||
        if (!["list", "grid", "calendar"].includes(viewType || "")) {
 | 
			
		||||
        if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
 | 
			
		||||
            // when not explicitly set, decide based on the note type
 | 
			
		||||
            return parentNote.type === "search" ? "list" : "grid";
 | 
			
		||||
        } else {
 | 
			
		||||
@@ -41,15 +32,36 @@ export default class NoteListRenderer {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get isFullHeight() {
 | 
			
		||||
        return this.viewMode?.isFullHeight;
 | 
			
		||||
        switch (this.viewType) {
 | 
			
		||||
            case "list":
 | 
			
		||||
            case "grid":
 | 
			
		||||
                return false;
 | 
			
		||||
            default:
 | 
			
		||||
                return true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderList() {
 | 
			
		||||
        if (!this.viewMode) {
 | 
			
		||||
            return null;
 | 
			
		||||
        const args = this.args;
 | 
			
		||||
        const viewMode = this.#buildViewMode(args);
 | 
			
		||||
        this.viewMode = viewMode;
 | 
			
		||||
        await viewMode.beforeRender();
 | 
			
		||||
        return await viewMode.renderList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        return await this.viewMode.renderList();
 | 
			
		||||
    #buildViewMode(args: ViewModeArgs) {
 | 
			
		||||
        switch (this.viewType) {
 | 
			
		||||
            case "calendar":
 | 
			
		||||
                return new CalendarView(args);
 | 
			
		||||
            case "table":
 | 
			
		||||
                return new TableView(args);
 | 
			
		||||
            case "geoMap":
 | 
			
		||||
                return new GeoView(args);
 | 
			
		||||
            case "list":
 | 
			
		||||
            case "grid":
 | 
			
		||||
            default:
 | 
			
		||||
                return new ListOrGridView(this.viewType, args);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ let dismissTimer: ReturnType<typeof setTimeout>;
 | 
			
		||||
 | 
			
		||||
function setupGlobalTooltip() {
 | 
			
		||||
    $(document).on("mouseenter", "a", mouseEnterHandler);
 | 
			
		||||
    $(document).on("mouseenter", "[data-href]", mouseEnterHandler);
 | 
			
		||||
 | 
			
		||||
    // close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
 | 
			
		||||
    $(document).on("click", (e) => {
 | 
			
		||||
@@ -167,7 +168,10 @@ async function renderTooltip(note: FNote | null) {
 | 
			
		||||
        if (isContentEmpty) {
 | 
			
		||||
            classes.push("note-no-content");
 | 
			
		||||
        }
 | 
			
		||||
        content = `<h5 class="${classes.join(" ")}"><a href="#${note.noteId}" data-no-context-menu="true">${noteTitleWithPathAsSuffix.prop("outerHTML")}</a></h5>`;
 | 
			
		||||
        content = `\
 | 
			
		||||
            <h5 class="${classes.join(" ")}">
 | 
			
		||||
                <a href="#${note.noteId}" data-no-context-menu="true">${noteTitleWithPathAsSuffix.prop("outerHTML")}</a>
 | 
			
		||||
            </h5>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    content = `${content}<div class="note-tooltip-attributes">${$renderedAttributes[0].outerHTML}</div>`;
 | 
			
		||||
@@ -175,6 +179,7 @@ async function renderTooltip(note: FNote | null) {
 | 
			
		||||
        content += $renderedContent[0].outerHTML;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    content += `<a class="open-popup-button" title="${t("note_tooltip.quick-edit")}" href="#${note.noteId}?popup"><span class="bx bx-edit" /></a>`;
 | 
			
		||||
    return content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,118 @@
 | 
			
		||||
import server from "./server.js";
 | 
			
		||||
import froca from "./froca.js";
 | 
			
		||||
import { t } from "./i18n.js";
 | 
			
		||||
import type { MenuItem } from "../menus/context_menu.js";
 | 
			
		||||
import froca from "./froca.js";
 | 
			
		||||
import server from "./server.js";
 | 
			
		||||
import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
 | 
			
		||||
import type { NoteType } from "../entities/fnote.js";
 | 
			
		||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
 | 
			
		||||
 | 
			
		||||
export interface NoteTypeMapping {
 | 
			
		||||
    type: NoteType;
 | 
			
		||||
    mime?: string;
 | 
			
		||||
    title: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    /** Indicates whether this type should be marked as a newly introduced feature. */
 | 
			
		||||
    isNew?: boolean;
 | 
			
		||||
    /** Indicates that this note type is part of a beta feature. */
 | 
			
		||||
    isBeta?: boolean;
 | 
			
		||||
    /** Indicates that this note type cannot be created by the user. */
 | 
			
		||||
    reserved?: boolean;
 | 
			
		||||
    /** Indicates that once a note of this type is created, its type can no longer be changed. */
 | 
			
		||||
    static?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const NOTE_TYPES: NoteTypeMapping[] = [
 | 
			
		||||
    // The suggested note type ordering method: insert the item into the corresponding group,
 | 
			
		||||
    // then ensure the items within the group are ordered alphabetically.
 | 
			
		||||
 | 
			
		||||
    // The default note type (always the first item)
 | 
			
		||||
    { type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
 | 
			
		||||
 | 
			
		||||
    // Text notes group
 | 
			
		||||
    { type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
 | 
			
		||||
 | 
			
		||||
    // Graphic notes
 | 
			
		||||
    { type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" },
 | 
			
		||||
    { type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" },
 | 
			
		||||
 | 
			
		||||
    // Map notes
 | 
			
		||||
    { type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" },
 | 
			
		||||
    { type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true },
 | 
			
		||||
    { type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
 | 
			
		||||
 | 
			
		||||
    // Misc note types
 | 
			
		||||
    { type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
 | 
			
		||||
    { type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
 | 
			
		||||
    { type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
 | 
			
		||||
 | 
			
		||||
    // Code notes
 | 
			
		||||
    { type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
 | 
			
		||||
 | 
			
		||||
    // Reserved types (cannot be created by the user)
 | 
			
		||||
    { type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
 | 
			
		||||
    { type: "doc", mime: "", title: t("note_types.doc"), reserved: true },
 | 
			
		||||
    { type: "file", title: t("note_types.file"), reserved: true },
 | 
			
		||||
    { type: "image", title: t("note_types.image"), reserved: true },
 | 
			
		||||
    { type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true },
 | 
			
		||||
    { type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
/** The maximum age in days for a template to be marked with the "New" badge */
 | 
			
		||||
const NEW_TEMPLATE_MAX_AGE = 3;
 | 
			
		||||
 | 
			
		||||
/** The length of a day in milliseconds. */
 | 
			
		||||
const DAY_LENGTH = 1000 * 60 * 60 * 24;
 | 
			
		||||
 | 
			
		||||
/** The menu item badge used to mark new note types and templates */
 | 
			
		||||
const NEW_BADGE: MenuItemBadge = {
 | 
			
		||||
    title: t("note_types.new-feature"),
 | 
			
		||||
    className: "new-note-type-badge"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** The menu item badge used to mark note types that are part of a beta feature */
 | 
			
		||||
const BETA_BADGE = {
 | 
			
		||||
    title: t("note_types.beta-feature")
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const SEPARATOR = { title: "----" };
 | 
			
		||||
 | 
			
		||||
const creationDateCache = new Map<string, Date>();
 | 
			
		||||
let rootCreationDate: Date | undefined;
 | 
			
		||||
 | 
			
		||||
async function getNoteTypeItems(command?: TreeCommandNames) {
 | 
			
		||||
    const items: MenuItem<TreeCommandNames>[] = [
 | 
			
		||||
        { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
 | 
			
		||||
        { title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" },
 | 
			
		||||
        { title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" },
 | 
			
		||||
        { title: t("note_types.relation-map"), command, type: "relationMap", uiIcon: "bx bxs-network-chart" },
 | 
			
		||||
        { title: t("note_types.note-map"), command, type: "noteMap", uiIcon: "bx bxs-network-chart" },
 | 
			
		||||
        { title: t("note_types.render-note"), command, type: "render", uiIcon: "bx bx-extension" },
 | 
			
		||||
        { title: t("note_types.book"), command, type: "book", uiIcon: "bx bx-book" },
 | 
			
		||||
        { title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" },
 | 
			
		||||
        { title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" },
 | 
			
		||||
        { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
 | 
			
		||||
        { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
 | 
			
		||||
        { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
 | 
			
		||||
        ...await getBuiltInTemplates(command),
 | 
			
		||||
        ...getBlankNoteTypes(command),
 | 
			
		||||
        ...await getBuiltInTemplates(t("note_types.collections"), command, true),
 | 
			
		||||
        ...await getBuiltInTemplates(null, command, false),
 | 
			
		||||
        ...await getUserTemplates(command)
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return items;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
 | 
			
		||||
    return NOTE_TYPES
 | 
			
		||||
        .filter((nt) => !nt.reserved && nt.type !== "book")
 | 
			
		||||
        .map((nt) => {
 | 
			
		||||
            const menuItem: MenuCommandItem<TreeCommandNames> = {
 | 
			
		||||
                title: nt.title,
 | 
			
		||||
                command,
 | 
			
		||||
                type: nt.type,
 | 
			
		||||
                uiIcon: "bx " + nt.icon,
 | 
			
		||||
                badges: []
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (nt.isNew) {
 | 
			
		||||
                menuItem.badges?.push(NEW_BADGE);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (nt.isBeta) {
 | 
			
		||||
                menuItem.badges?.push(BETA_BADGE);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return menuItem;
 | 
			
		||||
        });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getUserTemplates(command?: TreeCommandNames) {
 | 
			
		||||
    const templateNoteIds = await server.get<string[]>("search-templates");
 | 
			
		||||
    const templateNotes = await froca.getNotes(templateNoteIds);
 | 
			
		||||
@@ -37,19 +123,26 @@ async function getUserTemplates(command?: TreeCommandNames) {
 | 
			
		||||
    const items: MenuItem<TreeCommandNames>[] = [
 | 
			
		||||
        SEPARATOR
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const templateNote of templateNotes) {
 | 
			
		||||
        items.push({
 | 
			
		||||
        const item: MenuItem<TreeCommandNames> = {
 | 
			
		||||
            title: templateNote.title,
 | 
			
		||||
            uiIcon: templateNote.getIcon(),
 | 
			
		||||
            command: command,
 | 
			
		||||
            type: templateNote.type,
 | 
			
		||||
            templateNoteId: templateNote.noteId
 | 
			
		||||
        });
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (await isNewTemplate(templateNote.noteId)) {
 | 
			
		||||
            item.badges = [NEW_BADGE];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        items.push(item);
 | 
			
		||||
    }
 | 
			
		||||
    return items;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getBuiltInTemplates(command?: TreeCommandNames) {
 | 
			
		||||
async function getBuiltInTemplates(title: string | null, command: TreeCommandNames | undefined, filterCollections: boolean) {
 | 
			
		||||
    const templatesRoot = await froca.getNote("_templates");
 | 
			
		||||
    if (!templatesRoot) {
 | 
			
		||||
        console.warn("Unable to find template root.");
 | 
			
		||||
@@ -61,21 +154,85 @@ async function getBuiltInTemplates(command?: TreeCommandNames) {
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const items: MenuItem<TreeCommandNames>[] = [
 | 
			
		||||
        SEPARATOR
 | 
			
		||||
    ];
 | 
			
		||||
    for (const templateNote of childNotes) {
 | 
			
		||||
    const items: MenuItem<TreeCommandNames>[] = [];
 | 
			
		||||
    if (title) {
 | 
			
		||||
        items.push({
 | 
			
		||||
            title: title,
 | 
			
		||||
            enabled: false,
 | 
			
		||||
            uiIcon: "bx bx-empty"
 | 
			
		||||
        });
 | 
			
		||||
    } else {
 | 
			
		||||
        items.push(SEPARATOR);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const templateNote of childNotes) {
 | 
			
		||||
        if (templateNote.hasLabel("collection") !== filterCollections) {
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const item: MenuItem<TreeCommandNames> = {
 | 
			
		||||
            title: templateNote.title,
 | 
			
		||||
            uiIcon: templateNote.getIcon(),
 | 
			
		||||
            command: command,
 | 
			
		||||
            type: templateNote.type,
 | 
			
		||||
            templateNoteId: templateNote.noteId
 | 
			
		||||
        });
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (await isNewTemplate(templateNote.noteId)) {
 | 
			
		||||
            item.badges = [NEW_BADGE];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        items.push(item);
 | 
			
		||||
    }
 | 
			
		||||
    return items;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function isNewTemplate(templateNoteId) {
 | 
			
		||||
    if (rootCreationDate === undefined) {
 | 
			
		||||
        // Retrieve the root note creation date
 | 
			
		||||
        try {
 | 
			
		||||
            let rootNoteInfo: any = await server.get("notes/root");
 | 
			
		||||
            if ("dateCreated" in rootNoteInfo) {
 | 
			
		||||
                rootCreationDate = new Date(rootNoteInfo.dateCreated);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (ex) {
 | 
			
		||||
            console.error(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Try to retrieve the template's creation date from the cache
 | 
			
		||||
    let creationDate: Date | undefined = creationDateCache.get(templateNoteId);
 | 
			
		||||
 | 
			
		||||
    if (creationDate === undefined) {
 | 
			
		||||
        // The creation date isn't available in the cache, try to retrieve it from the server
 | 
			
		||||
        try {
 | 
			
		||||
            const noteInfo: any = await server.get("notes/" + templateNoteId);
 | 
			
		||||
            if ("dateCreated" in noteInfo) {
 | 
			
		||||
                creationDate = new Date(noteInfo.dateCreated);
 | 
			
		||||
                creationDateCache.set(templateNoteId, creationDate);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (ex) {
 | 
			
		||||
            console.error(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (creationDate) {
 | 
			
		||||
        if (rootCreationDate && creationDate.getTime() - rootCreationDate.getTime() < 30000) {
 | 
			
		||||
            // Ignore templates created within 30 seconds after the root note is created.
 | 
			
		||||
            // This is useful to prevent predefined templates from being marked
 | 
			
		||||
            // as 'New' after setting up a new database.
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Determine the difference in days between now and the template's creation date
 | 
			
		||||
        const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
 | 
			
		||||
        // Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
 | 
			
		||||
        return (age <= NEW_TEMPLATE_MAX_AGE);
 | 
			
		||||
    } else {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    getNoteTypeItems
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
 | 
			
		||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
 | 
			
		||||
type Multiplicity = "single" | "multi";
 | 
			
		||||
 | 
			
		||||
export interface DefinitionObject {
 | 
			
		||||
 
 | 
			
		||||
@@ -81,8 +81,8 @@ body {
 | 
			
		||||
 | 
			
		||||
    /* -- Overrides the default colors used by the ckeditor5-image package. --------------------- */
 | 
			
		||||
 | 
			
		||||
    --ck-color-image-caption-background: var(--main-background-color);
 | 
			
		||||
    --ck-color-image-caption-text: var(--main-text-color);
 | 
			
		||||
    --ck-content-color-image-caption-background: var(--main-background-color);
 | 
			
		||||
    --ck-content-color-image-caption-text: var(--main-text-color);
 | 
			
		||||
 | 
			
		||||
    /* -- Overrides the default colors used by the ckeditor5-widget package. -------------------- */
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -192,6 +192,13 @@ samp {
 | 
			
		||||
    font-family: var(--monospace-font-family) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.badge {
 | 
			
		||||
    --bs-badge-color: var(--muted-text-color);
 | 
			
		||||
 | 
			
		||||
    margin-left: 8px;
 | 
			
		||||
    background: var(--accented-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.input-group-text {
 | 
			
		||||
    background-color: var(--accented-background-color) !important;
 | 
			
		||||
    color: var(--muted-text-color) !important;
 | 
			
		||||
@@ -320,7 +327,8 @@ button kbd {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-menu {
 | 
			
		||||
.dropdown-menu,
 | 
			
		||||
.tabulator-popup-container {
 | 
			
		||||
    color: var(--menu-text-color) !important;
 | 
			
		||||
    font-size: inherit;
 | 
			
		||||
    background-color: var(--menu-background-color) !important;
 | 
			
		||||
@@ -330,7 +338,13 @@ button kbd {
 | 
			
		||||
    --bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body.desktop .dropdown-menu {
 | 
			
		||||
.dropdown-menu .dropdown-divider {
 | 
			
		||||
    break-before: avoid;
 | 
			
		||||
    break-after: avoid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body.desktop .dropdown-menu,
 | 
			
		||||
body.desktop .tabulator-popup-container {
 | 
			
		||||
    border: 1px solid var(--dropdown-border-color);
 | 
			
		||||
    box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
 | 
			
		||||
    animation: dropdown-menu-opening 100ms ease-in;
 | 
			
		||||
@@ -373,7 +387,8 @@ body.desktop .dropdown-menu {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-menu a:hover:not(.disabled),
 | 
			
		||||
.dropdown-item:hover:not(.disabled, .dropdown-item-container) {
 | 
			
		||||
.dropdown-item:hover:not(.disabled, .dropdown-item-container),
 | 
			
		||||
.tabulator-menu-item:hover {
 | 
			
		||||
    color: var(--hover-item-text-color) !important;
 | 
			
		||||
    background-color: var(--hover-item-background-color) !important;
 | 
			
		||||
    border-color: var(--hover-item-border-color) !important;
 | 
			
		||||
@@ -528,6 +543,7 @@ button.btn-sm {
 | 
			
		||||
    /* Making this narrower because https://github.com/zadam/trilium/issues/502 (problem only in smaller font sizes) */
 | 
			
		||||
    min-width: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    z-index: 1000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre:not(.hljs) {
 | 
			
		||||
@@ -759,6 +775,14 @@ table.promoted-attributes-in-tooltip th {
 | 
			
		||||
    font-size: small;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-tooltip-content .open-popup-button {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 15px;
 | 
			
		||||
    bottom: 8px;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    color: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-tooltip-attributes {
 | 
			
		||||
    display: -webkit-box;
 | 
			
		||||
    -webkit-box-orient: vertical;
 | 
			
		||||
@@ -900,6 +924,13 @@ div[data-notify="container"] {
 | 
			
		||||
    font-family: var(--monospace-font-family);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ck-content {
 | 
			
		||||
    --ck-content-font-family: var(--detail-font-family);
 | 
			
		||||
    --ck-content-font-size: 1.1em;
 | 
			
		||||
    --ck-content-font-color: var(--main-text-color);
 | 
			
		||||
    --ck-content-line-height: var(--bs-body-line-height);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ck-content .table table th {
 | 
			
		||||
    background-color: var(--accented-background-color);
 | 
			
		||||
}
 | 
			
		||||
@@ -1186,12 +1217,14 @@ body.mobile .dropdown-submenu > .dropdown-menu {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#context-menu-container,
 | 
			
		||||
#context-menu-container .dropdown-menu {
 | 
			
		||||
    padding: 3px 0 0;
 | 
			
		||||
#context-menu-container .dropdown-menu,
 | 
			
		||||
.tabulator-popup-container {
 | 
			
		||||
    padding: 3px 0;
 | 
			
		||||
    z-index: 2000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#context-menu-container .dropdown-item {
 | 
			
		||||
#context-menu-container .dropdown-item,
 | 
			
		||||
.tabulator-menu .tabulator-menu-item {
 | 
			
		||||
    padding: 0 7px 0 10px;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										199
									
								
								apps/client/src/stylesheets/table.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								apps/client/src/stylesheets/table.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,199 @@
 | 
			
		||||
.tabulator {
 | 
			
		||||
    --table-background-color: var(--main-background-color);
 | 
			
		||||
 | 
			
		||||
    --col-header-background-color: var(--main-background-color);
 | 
			
		||||
    --col-header-hover-background-color: var(--accented-background-color);
 | 
			
		||||
    --col-header-text-color: var(--main-text-color);
 | 
			
		||||
    --col-header-arrow-active-color: var(--main-text-color);
 | 
			
		||||
    --col-header-arrow-inactive-color: var(--more-accented-background-color);
 | 
			
		||||
    --col-header-separator-border: none;
 | 
			
		||||
    --col-header-bottom-border: 2px solid var(--main-border-color);
 | 
			
		||||
 | 
			
		||||
    --row-background-color: var(--main-background-color);
 | 
			
		||||
    --row-alternate-background-color: var(--main-background-color);
 | 
			
		||||
    --row-moving-background-color: var(--accented-background-color);
 | 
			
		||||
    --row-text-color: var(--main-text-color);
 | 
			
		||||
    --row-delimiter-color: var(--more-accented-background-color);
 | 
			
		||||
    
 | 
			
		||||
    --cell-horiz-padding-size: 8px;
 | 
			
		||||
    --cell-vert-padding-size: 8px;
 | 
			
		||||
    
 | 
			
		||||
    --cell-editable-hover-outline-color: var(--main-border-color);
 | 
			
		||||
    --cell-read-only-text-color: var(--muted-text-color);
 | 
			
		||||
    
 | 
			
		||||
    --cell-editing-border-color: var(--main-border-color);
 | 
			
		||||
    --cell-editing-border-width: 2px;
 | 
			
		||||
    --cell-editing-background-color: var(--ck-color-selector-focused-cell-background);
 | 
			
		||||
    --cell-editing-text-color: initial;
 | 
			
		||||
 | 
			
		||||
    background: unset;
 | 
			
		||||
    border: unset;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-tableholder .tabulator-table {
 | 
			
		||||
    background: var(--table-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Column headers */
 | 
			
		||||
 | 
			
		||||
.tabulator div.tabulator-header {
 | 
			
		||||
    border-bottom: var(--col-header-bottom-border);
 | 
			
		||||
    background: var(--col-header-background-color);
 | 
			
		||||
    color: var(--col-header-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-col-content {
 | 
			
		||||
    padding: 8px 4px !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (hover: hover) and (pointer: fine) {
 | 
			
		||||
  .tabulator .tabulator-header .tabulator-col.tabulator-sortable.tabulator-col-sorter-element:hover {
 | 
			
		||||
    background-color: var(--col-header-hover-background-color);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator div.tabulator-header .tabulator-col.tabulator-moving {
 | 
			
		||||
    border: none;
 | 
			
		||||
    background: var(--col-header-hover-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
 | 
			
		||||
    border-bottom-color: var(--col-header-arrow-active-color);
 | 
			
		||||
    border-top-color: var(--col-header-arrow-active-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
 | 
			
		||||
    border-bottom-color: var(--col-header-arrow-inactive-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator div.tabulator-header .tabulator-frozen.tabulator-frozen-left {
 | 
			
		||||
    margin-left: var(--cell-editing-border-width);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator div.tabulator-header .tabulator-col,
 | 
			
		||||
.tabulator div.tabulator-header .tabulator-frozen.tabulator-frozen-left {
 | 
			
		||||
    background: var(--col-header-background-color);
 | 
			
		||||
    border-right: var(--col-header-separator-border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Table body */
 | 
			
		||||
 | 
			
		||||
.tabulator-tableholder {
 | 
			
		||||
    padding-top: 10px;
 | 
			
		||||
    height: unset !important; /* Don't extend on the full height */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Rows */
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell {
 | 
			
		||||
    padding: var(--cell-vert-padding-size) var(--cell-horiz-padding-size);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell input {
 | 
			
		||||
    padding-left: var(--cell-horiz-padding-size) !important;
 | 
			
		||||
    padding-right: var(--cell-horiz-padding-size) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row {
 | 
			
		||||
    background: transparent;
 | 
			
		||||
    border-top: none;
 | 
			
		||||
    border-bottom: 1px solid var(--row-delimiter-color);
 | 
			
		||||
    color: var(--row-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row.tabulator-row-odd {
 | 
			
		||||
    background: var(--row-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row.tabulator-row-even {
 | 
			
		||||
    background: var(--row-alternate-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row.tabulator-moving {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
    background-color: var(--row-moving-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Cell */
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left {
 | 
			
		||||
    margin-right: var(--cell-editing-border-width);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left,
 | 
			
		||||
.tabulator-row .tabulator-cell {
 | 
			
		||||
    border-right-color: transparent;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell:not(.tabulator-editable) {
 | 
			
		||||
    color: var(--cell-read-only-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator:not(.tabulator-editing) .tabulator-row .tabulator-cell.tabulator-editable:hover {
 | 
			
		||||
    outline: 2px solid var(--cell-editable-hover-outline-color);
 | 
			
		||||
    outline-offset: -1px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row .tabulator-cell.tabulator-editing {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row:not(.tabulator-moving) .tabulator-cell.tabulator-editing {
 | 
			
		||||
    outline: calc(var(--cell-editing-border-width) - 1px) solid var(--cell-editing-border-color);
 | 
			
		||||
    border-color: var(--cell-editing-border-color);
 | 
			
		||||
    background: var(--cell-editing-background-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-row:not(.tabulator-moving) .tabulator-cell.tabulator-editing > * {
 | 
			
		||||
    color: var(--cell-editing-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tree-collapse,
 | 
			
		||||
.tabulator .tree-expand {
 | 
			
		||||
    color: var(--row-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Align items without children/expander to the ones with. */
 | 
			
		||||
.tabulator-cell[tabulator-field="title"] > span:first-child,         /* 1st level */
 | 
			
		||||
.tabulator-cell[tabulator-field="title"] > div:first-child + span {  /* sub-level */
 | 
			
		||||
    padding-left: 21px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Checkbox cells */
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-cell:has(svg),
 | 
			
		||||
.tabulator .tabulator-cell:has(input[type="checkbox"]) {
 | 
			
		||||
    padding-left: 8px;
 | 
			
		||||
    display: inline-flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-cell input[type="checkbox"] {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator .tabulator-footer {
 | 
			
		||||
    color: var(--main-text-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Context menus */
 | 
			
		||||
 | 
			
		||||
.tabulator-popup-container {
 | 
			
		||||
    min-width: 10em;
 | 
			
		||||
    border-radius: var(--bs-border-radius);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabulator-menu .tabulator-menu-item {
 | 
			
		||||
    border: 1px solid transparent;
 | 
			
		||||
    color: var(--menu-text-color);
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Footer */
 | 
			
		||||
 | 
			
		||||
:root .tabulator .tabulator-footer {
 | 
			
		||||
    border-top: unset;
 | 
			
		||||
    padding: 10px 0;
 | 
			
		||||
}
 | 
			
		||||
@@ -178,6 +178,9 @@
 | 
			
		||||
 | 
			
		||||
    --alert-bar-background: #6b6b6b3b;
 | 
			
		||||
 | 
			
		||||
    --badge-background-color: #ffffff1a;
 | 
			
		||||
    --badge-text-color: var(--muted-text-color);
 | 
			
		||||
 | 
			
		||||
    --promoted-attribute-card-background-color: var(--card-background-color);
 | 
			
		||||
    --promoted-attribute-card-shadow-color: #000000b3;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -171,6 +171,9 @@
 | 
			
		||||
 | 
			
		||||
    --alert-bar-background: #32637b29;
 | 
			
		||||
 | 
			
		||||
    --badge-background-color: #00000011;
 | 
			
		||||
    --badge-text-color: var(--muted-text-color);
 | 
			
		||||
 | 
			
		||||
    --promoted-attribute-card-background-color: var(--card-background-color);
 | 
			
		||||
    --promoted-attribute-card-shadow-color: #00000033;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@
 | 
			
		||||
@import url(./pages.css);
 | 
			
		||||
@import url(./ribbon.css);
 | 
			
		||||
@import url(./notes/text.css);
 | 
			
		||||
@import url(./notes/collections/table.css);
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
    font-family: "Inter";
 | 
			
		||||
@@ -171,9 +172,19 @@ html body .dropdown-item[disabled] {
 | 
			
		||||
    opacity: var(--menu-item-disabled-opacity);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Badges */
 | 
			
		||||
:root .badge {
 | 
			
		||||
    --bs-badge-color: var(--badge-text-color);
 | 
			
		||||
    --bs-badge-font-weight: 500;
 | 
			
		||||
 | 
			
		||||
    background: var(--badge-background-color);
 | 
			
		||||
    text-transform: uppercase;
 | 
			
		||||
    letter-spacing: .2pt;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Menu item icon */
 | 
			
		||||
.dropdown-item .bx {
 | 
			
		||||
    transform: translateY(var(--menu-item-icon-vert-offset));
 | 
			
		||||
    translate: 0 var(--menu-item-icon-vert-offset);
 | 
			
		||||
    color: var(--menu-item-icon-color) !important;
 | 
			
		||||
    font-size: 1.1em;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -382,6 +382,10 @@ div.tn-tool-dialog {
 | 
			
		||||
 | 
			
		||||
/* DELETE NOTE PREVIEW DIALOG */
 | 
			
		||||
 | 
			
		||||
.delete-notes-dialog .modal-dialog {
 | 
			
		||||
    --bs-modal-width: fit-content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.delete-notes-list .note-path {
 | 
			
		||||
    padding-left: 8px;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,13 @@
 | 
			
		||||
:root .tabulator {
 | 
			
		||||
    --col-header-hover-background-color: var(--hover-item-background-color);
 | 
			
		||||
    --col-header-arrow-active-color: var(--active-item-text-color);
 | 
			
		||||
    --col-header-arrow-inactive-color: var(--main-border-color);
 | 
			
		||||
 | 
			
		||||
    --row-moving-background-color: var(--more-accented-background-color);
 | 
			
		||||
 | 
			
		||||
    --cell-editable-hover-outline-color: var(--input-focus-outline-color);
 | 
			
		||||
 | 
			
		||||
    --cell-editing-border-color: var(--input-focus-outline-color);
 | 
			
		||||
    --cell-editing-background-color: var(--input-background-color);
 | 
			
		||||
    --cell-editing-text-color: var(--input-text-color);
 | 
			
		||||
}
 | 
			
		||||
@@ -46,6 +46,12 @@ div.promoted-attributes-container {
 | 
			
		||||
.image-properties > div:first-child > span > strong {
 | 
			
		||||
    opacity: 0.65;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.note-info-widget-table td,
 | 
			
		||||
.file-properties-widget .file-table td {
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.file-properties-widget {
 | 
			
		||||
 
 | 
			
		||||
@@ -71,12 +71,13 @@ body.background-effects.platform-win32.layout-vertical #vertical-main-container
 | 
			
		||||
/* #endregion */
 | 
			
		||||
 | 
			
		||||
/* Matches when the left pane is collapsed */
 | 
			
		||||
:has(.layout-vertical #left-pane.hidden-int) {
 | 
			
		||||
#horizontal-main-container.left-pane-hidden {
 | 
			
		||||
    --center-pane-border-radius: 0;
 | 
			
		||||
    --tab-first-item-horiz-offset: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:has(#left-pane.hidden-int) #launcher-pane.vertical {
 | 
			
		||||
/* Add a border to the vertical launch bar if collapsed. */
 | 
			
		||||
body.layout-vertical #horizontal-main-container.left-pane-hidden #launcher-pane.vertical {
 | 
			
		||||
    border-right: 2px solid var(--left-pane-collapsed-border-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -1300,9 +1301,9 @@ div.promoted-attribute-cell .tn-checkbox {
 | 
			
		||||
    height: 1cap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The <div> containing the checkbox for a promoted boolean attribute */
 | 
			
		||||
div.promoted-attribute-cell div:has(input[type="checkbox"]) {
 | 
			
		||||
    order: -1; /* Relocate the checkbox before the label */
 | 
			
		||||
/* Relocate the checkbox before the label */
 | 
			
		||||
div.promoted-attribute-cell.promoted-attribute-label-boolean > div:first-of-type {
 | 
			
		||||
    order: -1;
 | 
			
		||||
    margin-right: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -754,7 +754,7 @@
 | 
			
		||||
    "expand_all_children": "展开所有子项",
 | 
			
		||||
    "collapse": "折叠",
 | 
			
		||||
    "expand": "展开",
 | 
			
		||||
    "book_properties": "书籍属性",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "invalid_view_type": "无效的查看类型 '{{type}}'",
 | 
			
		||||
    "calendar": "日历"
 | 
			
		||||
  },
 | 
			
		||||
@@ -1431,7 +1431,6 @@
 | 
			
		||||
    "move-to": "移动到...",
 | 
			
		||||
    "paste-into": "粘贴到里面",
 | 
			
		||||
    "paste-after": "粘贴到后面",
 | 
			
		||||
    "duplicate-subtree": "复制子树",
 | 
			
		||||
    "export": "导出",
 | 
			
		||||
    "import-into-note": "导入到笔记",
 | 
			
		||||
    "apply-bulk-actions": "应用批量操作",
 | 
			
		||||
 
 | 
			
		||||
@@ -750,7 +750,7 @@
 | 
			
		||||
    "expand_all_children": "Unternotizen ausklappen",
 | 
			
		||||
    "collapse": "Einklappen",
 | 
			
		||||
    "expand": "Ausklappen",
 | 
			
		||||
    "book_properties": "Bucheigenschaften",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "invalid_view_type": "Ungültiger Ansichtstyp „{{type}}“",
 | 
			
		||||
    "calendar": "Kalender"
 | 
			
		||||
  },
 | 
			
		||||
@@ -1384,7 +1384,7 @@
 | 
			
		||||
    "move-to": "Verschieben nach...",
 | 
			
		||||
    "paste-into": "Als Unternotiz einfügen",
 | 
			
		||||
    "paste-after": "Danach einfügen",
 | 
			
		||||
    "duplicate-subtree": "Notizbaum duplizieren",
 | 
			
		||||
    "duplicate": "Duplizieren",
 | 
			
		||||
    "export": "Exportieren",
 | 
			
		||||
    "import-into-note": "In Notiz importieren",
 | 
			
		||||
    "apply-bulk-actions": "Massenaktionen ausführen",
 | 
			
		||||
 
 | 
			
		||||
@@ -758,9 +758,11 @@
 | 
			
		||||
    "expand_all_children": "Expand all children",
 | 
			
		||||
    "collapse": "Collapse",
 | 
			
		||||
    "expand": "Expand",
 | 
			
		||||
    "book_properties": "Book Properties",
 | 
			
		||||
    "book_properties": "Collection Properties",
 | 
			
		||||
    "invalid_view_type": "Invalid view type '{{type}}'",
 | 
			
		||||
    "calendar": "Calendar"
 | 
			
		||||
    "calendar": "Calendar",
 | 
			
		||||
    "table": "Table",
 | 
			
		||||
    "geo-map": "Geo Map"
 | 
			
		||||
  },
 | 
			
		||||
  "edited_notes": {
 | 
			
		||||
    "no_edited_notes_found": "No edited notes on this day yet...",
 | 
			
		||||
@@ -960,7 +962,7 @@
 | 
			
		||||
    "no_attachments": "This note has no attachments."
 | 
			
		||||
  },
 | 
			
		||||
  "book": {
 | 
			
		||||
    "no_children_help": "This note of type Book doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
 | 
			
		||||
    "no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
 | 
			
		||||
  },
 | 
			
		||||
  "editable_code": {
 | 
			
		||||
    "placeholder": "Type the content of your code note here..."
 | 
			
		||||
@@ -1023,7 +1025,7 @@
 | 
			
		||||
    "title": "Consistency Checks",
 | 
			
		||||
    "find_and_fix_button": "Find and fix consistency issues",
 | 
			
		||||
    "finding_and_fixing_message": "Finding and fixing consistency issues...",
 | 
			
		||||
    "issues_fixed_message": "Consistency issues should be fixed."
 | 
			
		||||
    "issues_fixed_message": "Any consistency issue which may have been found is now fixed."
 | 
			
		||||
  },
 | 
			
		||||
  "database_anonymization": {
 | 
			
		||||
    "title": "Database Anonymization",
 | 
			
		||||
@@ -1593,12 +1595,13 @@
 | 
			
		||||
    "move-to": "Move to...",
 | 
			
		||||
    "paste-into": "Paste into",
 | 
			
		||||
    "paste-after": "Paste after",
 | 
			
		||||
    "duplicate-subtree": "Duplicate subtree",
 | 
			
		||||
    "duplicate": "Duplicate",
 | 
			
		||||
    "export": "Export",
 | 
			
		||||
    "import-into-note": "Import into note",
 | 
			
		||||
    "apply-bulk-actions": "Apply bulk actions",
 | 
			
		||||
    "converted-to-attachments": "{{count}} notes have been converted to attachments.",
 | 
			
		||||
    "convert-to-attachment-confirm": "Are you sure you want to convert note selected notes into attachments of their parent notes?"
 | 
			
		||||
    "convert-to-attachment-confirm": "Are you sure you want to convert note selected notes into attachments of their parent notes?",
 | 
			
		||||
    "open-in-popup": "Quick edit"
 | 
			
		||||
  },
 | 
			
		||||
  "shared_info": {
 | 
			
		||||
    "shared_publicly": "This note is shared publicly on",
 | 
			
		||||
@@ -1626,7 +1629,9 @@
 | 
			
		||||
    "geo-map": "Geo Map",
 | 
			
		||||
    "beta-feature": "Beta",
 | 
			
		||||
    "ai-chat": "AI Chat",
 | 
			
		||||
    "task-list": "Task List"
 | 
			
		||||
    "task-list": "Task List",
 | 
			
		||||
    "new-feature": "New",
 | 
			
		||||
    "collections": "Collections"
 | 
			
		||||
  },
 | 
			
		||||
  "protect_note": {
 | 
			
		||||
    "toggle-on": "Protect the note",
 | 
			
		||||
@@ -1828,7 +1833,8 @@
 | 
			
		||||
  "link_context_menu": {
 | 
			
		||||
    "open_note_in_new_tab": "Open note in a new tab",
 | 
			
		||||
    "open_note_in_new_split": "Open note in a new split",
 | 
			
		||||
    "open_note_in_new_window": "Open note in a new window"
 | 
			
		||||
    "open_note_in_new_window": "Open note in a new window",
 | 
			
		||||
    "open_note_in_popup": "Quick edit"
 | 
			
		||||
  },
 | 
			
		||||
  "electron_integration": {
 | 
			
		||||
    "desktop-application": "Desktop Application",
 | 
			
		||||
@@ -1848,7 +1854,8 @@
 | 
			
		||||
    "full-text-search": "Full text search"
 | 
			
		||||
  },
 | 
			
		||||
  "note_tooltip": {
 | 
			
		||||
    "note-has-been-deleted": "Note has been deleted."
 | 
			
		||||
    "note-has-been-deleted": "Note has been deleted.",
 | 
			
		||||
    "quick-edit": "Quick edit"
 | 
			
		||||
  },
 | 
			
		||||
  "geo-map": {
 | 
			
		||||
    "create-child-note-title": "Create a new child note and add it to the map",
 | 
			
		||||
@@ -1857,7 +1864,8 @@
 | 
			
		||||
  },
 | 
			
		||||
  "geo-map-context": {
 | 
			
		||||
    "open-location": "Open location",
 | 
			
		||||
    "remove-from-map": "Remove from map"
 | 
			
		||||
    "remove-from-map": "Remove from map",
 | 
			
		||||
    "add-note": "Add a marker at this location"
 | 
			
		||||
  },
 | 
			
		||||
  "help-button": {
 | 
			
		||||
    "title": "Open the relevant help page"
 | 
			
		||||
@@ -1933,5 +1941,32 @@
 | 
			
		||||
    "title": "Features",
 | 
			
		||||
    "emoji_completion_enabled": "Enable Emoji auto-completion",
 | 
			
		||||
    "note_completion_enabled": "Enable note auto-completion"
 | 
			
		||||
  },
 | 
			
		||||
  "table_view": {
 | 
			
		||||
    "new-row": "New row",
 | 
			
		||||
    "new-column": "New column",
 | 
			
		||||
    "sort-column-by": "Sort by \"{{title}}\"",
 | 
			
		||||
    "sort-column-ascending": "Ascending",
 | 
			
		||||
    "sort-column-descending": "Descending",
 | 
			
		||||
    "sort-column-clear": "Clear sorting",
 | 
			
		||||
    "hide-column": "Hide column \"{{title}}\"",
 | 
			
		||||
    "show-hide-columns": "Show/hide columns",
 | 
			
		||||
    "row-insert-above": "Insert row above",
 | 
			
		||||
    "row-insert-below": "Insert row below",
 | 
			
		||||
    "row-insert-child": "Insert child note",
 | 
			
		||||
    "add-column-to-the-left": "Add column to the left",
 | 
			
		||||
    "add-column-to-the-right": "Add column to the right",
 | 
			
		||||
    "edit-column": "Edit column",
 | 
			
		||||
    "delete_column_confirmation": "Are you sure you want to delete this column? The corresponding attribute will be removed from all notes.",
 | 
			
		||||
    "delete-column": "Delete column",
 | 
			
		||||
    "new-column-label": "Label",
 | 
			
		||||
    "new-column-relation": "Relation"
 | 
			
		||||
  },
 | 
			
		||||
  "book_properties_config": {
 | 
			
		||||
    "hide-weekends": "Hide weekends",
 | 
			
		||||
    "display-week-numbers": "Display week numbers"
 | 
			
		||||
  },
 | 
			
		||||
  "table_context_menu": {
 | 
			
		||||
    "delete_row": "Delete row"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -758,7 +758,7 @@
 | 
			
		||||
    "expand_all_children": "Ampliar todas las subnotas",
 | 
			
		||||
    "collapse": "Colapsar",
 | 
			
		||||
    "expand": "Expandir",
 | 
			
		||||
    "book_properties": "Propiedades del libro",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "invalid_view_type": "Tipo de vista inválida '{{type}}'",
 | 
			
		||||
    "calendar": "Calendario"
 | 
			
		||||
  },
 | 
			
		||||
@@ -1593,7 +1593,7 @@
 | 
			
		||||
    "move-to": "Mover a...",
 | 
			
		||||
    "paste-into": "Pegar en",
 | 
			
		||||
    "paste-after": "Pegar después de",
 | 
			
		||||
    "duplicate-subtree": "Duplicar subárbol",
 | 
			
		||||
    "duplicate": "Duplicar",
 | 
			
		||||
    "export": "Exportar",
 | 
			
		||||
    "import-into-note": "Importar a nota",
 | 
			
		||||
    "apply-bulk-actions": "Aplicar acciones en lote",
 | 
			
		||||
 
 | 
			
		||||
@@ -753,7 +753,7 @@
 | 
			
		||||
    "expand_all_children": "Développer tous les enfants",
 | 
			
		||||
    "collapse": "Réduire",
 | 
			
		||||
    "expand": "Développer",
 | 
			
		||||
    "book_properties": "Propriétés du livre",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "invalid_view_type": "Type de vue non valide '{{type}}'",
 | 
			
		||||
    "calendar": "Calendrier"
 | 
			
		||||
  },
 | 
			
		||||
@@ -1389,7 +1389,7 @@
 | 
			
		||||
    "move-to": "Déplacer vers...",
 | 
			
		||||
    "paste-into": "Coller dans",
 | 
			
		||||
    "paste-after": "Coller après",
 | 
			
		||||
    "duplicate-subtree": "Dupliquer le sous-arbre",
 | 
			
		||||
    "duplicate": "Dupliquer",
 | 
			
		||||
    "export": "Exporter",
 | 
			
		||||
    "import-into-note": "Importer dans la note",
 | 
			
		||||
    "apply-bulk-actions": "Appliquer des Actions groupées",
 | 
			
		||||
 
 | 
			
		||||
@@ -274,7 +274,7 @@
 | 
			
		||||
    "no_children_help": "Această notiță de tip Carte nu are nicio subnotiță așadar nu este nimic de afișat. Vedeți <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> pentru detalii."
 | 
			
		||||
  },
 | 
			
		||||
  "book_properties": {
 | 
			
		||||
    "book_properties": "Proprietăți carte",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "collapse": "Minimizează",
 | 
			
		||||
    "collapse_all_notes": "Minimizează toate notițele",
 | 
			
		||||
    "expand": "Expandează",
 | 
			
		||||
@@ -1349,7 +1349,7 @@
 | 
			
		||||
    "copy-note-path-to-clipboard": "Copiază calea notiței în clipboard",
 | 
			
		||||
    "cut": "Decupează",
 | 
			
		||||
    "delete": "Șterge",
 | 
			
		||||
    "duplicate-subtree": "Dublifică ierarhia",
 | 
			
		||||
    "duplicate": "Dublifică",
 | 
			
		||||
    "edit-branch-prefix": "Editează prefixul ramurii",
 | 
			
		||||
    "expand-subtree": "Expandează subnotițele",
 | 
			
		||||
    "export": "Exportă",
 | 
			
		||||
 
 | 
			
		||||
@@ -718,7 +718,7 @@
 | 
			
		||||
    "expand_all_children": "展開所有子項",
 | 
			
		||||
    "collapse": "折疊",
 | 
			
		||||
    "expand": "展開",
 | 
			
		||||
    "book_properties": "書籍屬性",
 | 
			
		||||
    "book_properties": "",
 | 
			
		||||
    "invalid_view_type": "無效的查看類型 '{{type}}'"
 | 
			
		||||
  },
 | 
			
		||||
  "edited_notes": {
 | 
			
		||||
@@ -1336,7 +1336,6 @@
 | 
			
		||||
    "move-to": "移動到...",
 | 
			
		||||
    "paste-into": "貼上到裡面",
 | 
			
		||||
    "paste-after": "貼上到後面",
 | 
			
		||||
    "duplicate-subtree": "複製子樹",
 | 
			
		||||
    "export": "匯出",
 | 
			
		||||
    "import-into-note": "匯入到筆記",
 | 
			
		||||
    "apply-bulk-actions": "應用批量操作",
 | 
			
		||||
 
 | 
			
		||||
@@ -78,7 +78,7 @@ const TPL = /*html*/`
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
 | 
			
		||||
    <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
 | 
			
		||||
        <h5 class="attr-detail-title">${t("attribute_detail.attr_detail_title")}</h5>
 | 
			
		||||
 | 
			
		||||
        <span class="bx bx-x close-attr-detail-button tn-tool-button" title="${t("attribute_detail.close_button_title")}"></span>
 | 
			
		||||
@@ -295,6 +295,8 @@ interface AttributeDetailOpts {
 | 
			
		||||
    x: number;
 | 
			
		||||
    y: number;
 | 
			
		||||
    focus?: "name";
 | 
			
		||||
    parent?: HTMLElement;
 | 
			
		||||
    hideMultiplicity?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface SearchRelatedResponse {
 | 
			
		||||
@@ -477,7 +479,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus }: AttributeDetailOpts) {
 | 
			
		||||
    async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus, hideMultiplicity }: AttributeDetailOpts) {
 | 
			
		||||
        if (!attribute) {
 | 
			
		||||
            this.hide();
 | 
			
		||||
 | 
			
		||||
@@ -528,7 +530,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
 | 
			
		||||
        this.$rowPromotedAlias.toggle(!!definition.isPromoted);
 | 
			
		||||
        this.$inputPromotedAlias.val(definition.promotedAlias || "").attr("disabled", disabledFn);
 | 
			
		||||
 | 
			
		||||
        this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || ""));
 | 
			
		||||
        this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || "") && !hideMultiplicity);
 | 
			
		||||
        this.$inputMultiplicity.val(definition.multiplicity || "").attr("disabled", disabledFn);
 | 
			
		||||
 | 
			
		||||
        this.$rowLabelType.toggle(this.attrType === "label-definition");
 | 
			
		||||
@@ -560,18 +562,21 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
        this.toggleInt(true);
 | 
			
		||||
 | 
			
		||||
        const offset = this.parent?.$widget.offset() || { top: 0, left: 0 };
 | 
			
		||||
        const offset = this.parent?.$widget?.offset() || { top: 0, left: 0 };
 | 
			
		||||
        const detPosition = this.getDetailPosition(x, offset);
 | 
			
		||||
        const outerHeight = this.$widget.outerHeight();
 | 
			
		||||
        const height = $(window).height();
 | 
			
		||||
 | 
			
		||||
        if (detPosition && outerHeight && height) {
 | 
			
		||||
        if (!detPosition || !outerHeight || !height) {
 | 
			
		||||
            console.warn("Can't position popup, is it attached?");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.$widget
 | 
			
		||||
            .css("left", detPosition.left)
 | 
			
		||||
            .css("right", detPosition.right)
 | 
			
		||||
            .css("top", y - offset.top + 70)
 | 
			
		||||
            .css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (focus === "name") {
 | 
			
		||||
            this.$inputName.trigger("focus").trigger("select");
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ import noteAutocompleteService, { type Suggestion } from "../../services/note_au
 | 
			
		||||
import server from "../../services/server.js";
 | 
			
		||||
import contextMenuService from "../../menus/context_menu.js";
 | 
			
		||||
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
 | 
			
		||||
import { AttributeEditor, type EditorConfig, type Element, type MentionFeed, type Node, type Position } from "@triliumnext/ckeditor5";
 | 
			
		||||
import { AttributeEditor, type EditorConfig, type ModelElement, type MentionFeed, type ModelNode, type ModelPosition } from "@triliumnext/ckeditor5";
 | 
			
		||||
import froca from "../../services/froca.js";
 | 
			
		||||
import attributeRenderer from "../../services/attribute_renderer.js";
 | 
			
		||||
import noteCreateService from "../../services/note_create.js";
 | 
			
		||||
@@ -417,16 +417,16 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
 | 
			
		||||
        this.$editor.tooltip("show");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getClickIndex(pos: Position) {
 | 
			
		||||
    getClickIndex(pos: ModelPosition) {
 | 
			
		||||
        let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0);
 | 
			
		||||
 | 
			
		||||
        let curNode: Node | Text | Element | null = pos.textNode;
 | 
			
		||||
        let curNode: ModelNode | Text | ModelElement | null = pos.textNode;
 | 
			
		||||
 | 
			
		||||
        while (curNode?.previousSibling) {
 | 
			
		||||
            curNode = curNode.previousSibling;
 | 
			
		||||
 | 
			
		||||
            if ((curNode as Element).name === "reference") {
 | 
			
		||||
                clickIndex += (curNode.getAttribute("notePath") as string).length + 1;
 | 
			
		||||
            if ((curNode as ModelElement).name === "reference") {
 | 
			
		||||
                clickIndex += (curNode.getAttribute("href") as string).length + 1;
 | 
			
		||||
            } else if ("data" in curNode) {
 | 
			
		||||
                clickIndex += (curNode.data as string).length;
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -189,7 +189,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
 | 
			
		||||
        this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
 | 
			
		||||
 | 
			
		||||
        this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
 | 
			
		||||
        this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type));
 | 
			
		||||
        this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
 | 
			
		||||
 | 
			
		||||
        const canPrint = ["text", "code"].includes(note.type);
 | 
			
		||||
        this.toggleDisabled(this.$printActiveNoteButton, canPrint);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import type { default as Component, TypedComponent } from "../../components/component.js";
 | 
			
		||||
import BasicWidget, { TypedBasicWidget } from "../basic_widget.js";
 | 
			
		||||
import type { TypedComponent } from "../../components/component.js";
 | 
			
		||||
import { TypedBasicWidget } from "../basic_widget.js";
 | 
			
		||||
 | 
			
		||||
export default class Container<T extends TypedComponent<any>> extends TypedBasicWidget<T> {
 | 
			
		||||
    doRender() {
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ export default class LeftPaneContainer extends FlexContainer<Component> {
 | 
			
		||||
        this.currentLeftPaneVisible = leftPaneVisible ?? !this.currentLeftPaneVisible;
 | 
			
		||||
        const visible = this.isEnabled();
 | 
			
		||||
        this.toggleInt(visible);
 | 
			
		||||
        this.parent?.$widget.toggleClass("left-pane-hidden", !visible);
 | 
			
		||||
 | 
			
		||||
        if (visible) {
 | 
			
		||||
            this.triggerEvent("focusTree", {});
 | 
			
		||||
 
 | 
			
		||||
@@ -154,13 +154,21 @@ export default class NoteTypeChooserDialog extends BasicWidget {
 | 
			
		||||
                this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
 | 
			
		||||
            } else {
 | 
			
		||||
                const commandItem = noteType as MenuCommandItem<CommandNames>;
 | 
			
		||||
                this.$noteTypeDropdown.append(
 | 
			
		||||
                    $('<a class="dropdown-item" tabindex="0">')
 | 
			
		||||
                const listItem = $('<a class="dropdown-item" tabindex="0">')
 | 
			
		||||
                    .attr("data-note-type", commandItem.type || "")
 | 
			
		||||
                    .attr("data-template-note-id", commandItem.templateNoteId || "")
 | 
			
		||||
                    .append($("<span>").addClass(commandItem.uiIcon || ""))
 | 
			
		||||
                        .append(` ${noteType.title}`)
 | 
			
		||||
                );
 | 
			
		||||
                    .append(` ${noteType.title}`);
 | 
			
		||||
 | 
			
		||||
                if (commandItem.badges) {
 | 
			
		||||
                    for (let badge of commandItem.badges) {
 | 
			
		||||
                        listItem.append($(`<span class="badge">`)
 | 
			
		||||
                                .addClass(badge.className || "")
 | 
			
		||||
                                .text(badge.title));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.$noteTypeDropdown.append(listItem);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										157
									
								
								apps/client/src/widgets/dialogs/popup_editor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								apps/client/src/widgets/dialogs/popup_editor.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,157 @@
 | 
			
		||||
import type { EventNames, EventData } from "../../components/app_context.js";
 | 
			
		||||
import NoteContext from "../../components/note_context.js";
 | 
			
		||||
import { openDialog } from "../../services/dialog.js";
 | 
			
		||||
import BasicWidget from "../basic_widget.js";
 | 
			
		||||
import Container from "../containers/container.js";
 | 
			
		||||
import TypeWidget from "../type_widgets/type_widget.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`\
 | 
			
		||||
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
 | 
			
		||||
    <style>
 | 
			
		||||
        body.desktop .modal.popup-editor-dialog .modal-dialog {
 | 
			
		||||
            max-width: 75vw;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .modal-header .modal-title {
 | 
			
		||||
            font-size: 1.1em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .modal-body {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            height: 75vh;
 | 
			
		||||
            overflow: auto;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .note-detail-editable-text {
 | 
			
		||||
            padding: 0 1em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .title-row,
 | 
			
		||||
        .modal.popup-editor-dialog .modal-title,
 | 
			
		||||
        .modal.popup-editor-dialog .note-icon-widget {
 | 
			
		||||
            height: 32px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .note-icon-widget {
 | 
			
		||||
            width: 32px;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            padding: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .note-icon-widget button.note-icon,
 | 
			
		||||
        .modal.popup-editor-dialog .note-title-widget input.note-title {
 | 
			
		||||
            font-size: 1em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .classic-toolbar-widget {
 | 
			
		||||
            position: sticky;
 | 
			
		||||
            top: 0;
 | 
			
		||||
            left: 0;
 | 
			
		||||
            right: 0;
 | 
			
		||||
            background: var(--modal-background-color);
 | 
			
		||||
            z-index: 998;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .modal.popup-editor-dialog .note-detail-file {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="modal-dialog modal-lg" role="document">
 | 
			
		||||
        <div class="modal-content">
 | 
			
		||||
            <div class="modal-header">
 | 
			
		||||
                <div class="modal-title">
 | 
			
		||||
                    <!-- This is where the first child will be injected -->
 | 
			
		||||
                </div>
 | 
			
		||||
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="modal-body">
 | 
			
		||||
                <!-- This is where all but the first child will be injected. -->
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
export default class PopupEditorDialog extends Container<BasicWidget> {
 | 
			
		||||
 | 
			
		||||
    private noteContext: NoteContext;
 | 
			
		||||
    private $modalHeader!: JQuery<HTMLElement>;
 | 
			
		||||
    private $modalBody!: JQuery<HTMLElement>;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.noteContext = new NoteContext("_popup-editor");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        // This will populate this.$widget with the content of the children.
 | 
			
		||||
        super.doRender();
 | 
			
		||||
 | 
			
		||||
        // Now we wrap it in the modal.
 | 
			
		||||
        const $newWidget = $(TPL);
 | 
			
		||||
        this.$modalHeader = $newWidget.find(".modal-title");
 | 
			
		||||
        this.$modalBody = $newWidget.find(".modal-body");
 | 
			
		||||
 | 
			
		||||
        const children = this.$widget.children();
 | 
			
		||||
        this.$modalHeader.append(children[0]);
 | 
			
		||||
        this.$modalBody.append(children.slice(1));
 | 
			
		||||
        this.$widget = $newWidget;
 | 
			
		||||
        this.setVisibility(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async openInPopupEvent({ noteIdOrPath }: EventData<"openInPopup">) {
 | 
			
		||||
        const $dialog = await openDialog(this.$widget, false, {
 | 
			
		||||
            focus: false
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await this.noteContext.setNote(noteIdOrPath);
 | 
			
		||||
 | 
			
		||||
        const activeEl = document.activeElement;
 | 
			
		||||
        if (activeEl && "blur" in activeEl) {
 | 
			
		||||
            (activeEl as HTMLElement).blur();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $dialog.on("shown.bs.modal", async () => {
 | 
			
		||||
            // Reduce the z-index of modals so that ckeditor popups are properly shown on top of it.
 | 
			
		||||
            // The backdrop instance is not shared so it's OK to make a one-off modification.
 | 
			
		||||
            $("body > .modal-backdrop").css("z-index", "998");
 | 
			
		||||
            $dialog.css("z-index", "999");
 | 
			
		||||
 | 
			
		||||
            await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext });
 | 
			
		||||
            this.setVisibility(true);
 | 
			
		||||
            await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId });
 | 
			
		||||
        });
 | 
			
		||||
        $dialog.on("hidden.bs.modal", () => {
 | 
			
		||||
            const $typeWidgetEl = $dialog.find(".note-detail-printable");
 | 
			
		||||
            if ($typeWidgetEl.length) {
 | 
			
		||||
                const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget;
 | 
			
		||||
                typeWidget.cleanup();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.setVisibility(false);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setVisibility(visible: boolean) {
 | 
			
		||||
        const $bodyItems = this.$modalBody.find("> div");
 | 
			
		||||
        if (visible) {
 | 
			
		||||
            $bodyItems.fadeIn();
 | 
			
		||||
            this.$modalHeader.children().show();
 | 
			
		||||
        } else {
 | 
			
		||||
            $bodyItems.hide();
 | 
			
		||||
            this.$modalHeader.children().hide();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
 | 
			
		||||
        // Avoid events related to the current tab interfere with our popup.
 | 
			
		||||
        if (["noteSwitched", "noteSwitchedAndActivated"].includes(name)) {
 | 
			
		||||
            return Promise.resolve();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return super.handleEventInChildren(name, data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -23,7 +23,9 @@ const TPL = /*html*/`\
 | 
			
		||||
export default class GeoMapButtons extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    isEnabled() {
 | 
			
		||||
        return super.isEnabled() && this.note?.type === "geoMap";
 | 
			
		||||
        return super.isEnabled()
 | 
			
		||||
            && this.note?.getLabelValue("viewType") === "geoMap"
 | 
			
		||||
            && !this.note.hasLabel("readOnly");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
 | 
			
		||||
    contentWidget: null,
 | 
			
		||||
    doc: null,
 | 
			
		||||
    file: null,
 | 
			
		||||
    geoMap: "81SGnPGMk7Xc",
 | 
			
		||||
    image: null,
 | 
			
		||||
    launcher: null,
 | 
			
		||||
    mermaid: null,
 | 
			
		||||
@@ -32,9 +31,11 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
 | 
			
		||||
    list: null,
 | 
			
		||||
    grid: null,
 | 
			
		||||
    calendar: "xWbu3jpNWapp"
 | 
			
		||||
    list: "mULW0Q3VojwY",
 | 
			
		||||
    grid: "8QqnMzx393bx",
 | 
			
		||||
    calendar: "xWbu3jpNWapp",
 | 
			
		||||
    table: "2FvYrpmOXm29",
 | 
			
		||||
    geoMap: "81SGnPGMk7Xc"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default class ContextualHelpButton extends NoteContextAwareWidget {
 | 
			
		||||
 
 | 
			
		||||
@@ -39,10 +39,20 @@ export default class ToggleReadOnlyButton extends OnClickButtonWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isEnabled() {
 | 
			
		||||
        return super.isEnabled()
 | 
			
		||||
            && this.note?.type === "mermaid"
 | 
			
		||||
            && this.note?.isContentAvailable()
 | 
			
		||||
            && this.noteContext?.viewScope?.viewMode === "default";
 | 
			
		||||
        if (!super.isEnabled()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this?.note?.isContentAvailable()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.noteContext?.viewScope?.viewMode !== "default") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.note.type === "mermaid" ||
 | 
			
		||||
            (this.note.getLabelValue("viewType") === "geoMap");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,58 +0,0 @@
 | 
			
		||||
import type { Map } from "leaflet";
 | 
			
		||||
import L from "leaflet";
 | 
			
		||||
import "leaflet/dist/leaflet.css";
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`\
 | 
			
		||||
<div class="geo-map-widget">
 | 
			
		||||
    <style>
 | 
			
		||||
        .note-detail-geo-map,
 | 
			
		||||
        .geo-map-widget,
 | 
			
		||||
        .geo-map-container {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .leaflet-top,
 | 
			
		||||
        .leaflet-bottom {
 | 
			
		||||
            z-index: 900;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="geo-map-container"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
export type Leaflet = typeof L;
 | 
			
		||||
export type InitCallback = (L: Leaflet) => void;
 | 
			
		||||
 | 
			
		||||
export default class GeoMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    map?: Map;
 | 
			
		||||
    $container!: JQuery<HTMLElement>;
 | 
			
		||||
    private initCallback?: InitCallback;
 | 
			
		||||
 | 
			
		||||
    constructor(widgetMode: "type", initCallback?: InitCallback) {
 | 
			
		||||
        super();
 | 
			
		||||
        this.initCallback = initCallback;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
 | 
			
		||||
        this.$container = this.$widget.find(".geo-map-container");
 | 
			
		||||
 | 
			
		||||
        const map = L.map(this.$container[0], {
 | 
			
		||||
            worldCopyJump: true
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.map = map;
 | 
			
		||||
        if (this.initCallback) {
 | 
			
		||||
            this.initCallback(L);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
 | 
			
		||||
            attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 | 
			
		||||
            detectRetina: true
 | 
			
		||||
        }).addTo(map);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -28,7 +28,6 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
 | 
			
		||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
 | 
			
		||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
 | 
			
		||||
import MindMapWidget from "./type_widgets/mind_map.js";
 | 
			
		||||
import GeoMapTypeWidget from "./type_widgets/geo_map.js";
 | 
			
		||||
import utils from "../services/utils.js";
 | 
			
		||||
import type { NoteType } from "../entities/fnote.js";
 | 
			
		||||
import type TypeWidget from "./type_widgets/type_widget.js";
 | 
			
		||||
@@ -71,7 +70,6 @@ const typeWidgetClasses = {
 | 
			
		||||
    attachmentDetail: AttachmentDetailTypeWidget,
 | 
			
		||||
    attachmentList: AttachmentListTypeWidget,
 | 
			
		||||
    mindMap: MindMapWidget,
 | 
			
		||||
    geoMap: GeoMapTypeWidget,
 | 
			
		||||
    aiChat: AiChatTypeWidget,
 | 
			
		||||
 | 
			
		||||
    // Split type editors
 | 
			
		||||
@@ -197,7 +195,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
 | 
			
		||||
        // https://github.com/zadam/trilium/issues/2522
 | 
			
		||||
        const isBackendNote = this.noteContext?.noteId === "_backendLog";
 | 
			
		||||
        const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
 | 
			
		||||
        const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "geoMap", "mermaid"].includes(this.type ?? "");
 | 
			
		||||
        const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file"].includes(this.type ?? "");
 | 
			
		||||
        const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
 | 
			
		||||
            || this.noteContext?.viewScope?.viewMode === "attachments"
 | 
			
		||||
            || isBackendNote;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
import NoteListRenderer from "../services/note_list_renderer.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import type { CommandListener, CommandListenerData, EventData } from "../components/app_context.js";
 | 
			
		||||
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js";
 | 
			
		||||
import type ViewMode from "./view_widgets/view_mode.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
@@ -36,10 +36,31 @@ export default class NoteListWidget extends NoteContextAwareWidget {
 | 
			
		||||
    private isIntersecting?: boolean;
 | 
			
		||||
    private noteIdRefreshed?: string;
 | 
			
		||||
    private shownNoteId?: string | null;
 | 
			
		||||
    private viewMode?: ViewMode | null;
 | 
			
		||||
    private viewMode?: ViewMode<any> | null;
 | 
			
		||||
    private displayOnlyCollections: boolean;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param displayOnlyCollections if set to `true` then only collection-type views are displayed such as geo-map and the calendar. The original book types grid and list will be ignored.
 | 
			
		||||
     */
 | 
			
		||||
    constructor(displayOnlyCollections: boolean) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.displayOnlyCollections = displayOnlyCollections;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isEnabled() {
 | 
			
		||||
        return super.isEnabled() && this.noteContext?.hasNoteList();
 | 
			
		||||
        if (!super.isEnabled()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.displayOnlyCollections && this.note?.type !== "book") {
 | 
			
		||||
            const viewType = this.note?.getLabelValue("viewType");
 | 
			
		||||
            if (!viewType || ["grid", "list"].includes(viewType)) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.noteContext?.hasNoteList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
@@ -76,7 +97,11 @@ export default class NoteListWidget extends NoteContextAwareWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderNoteList(note: FNote) {
 | 
			
		||||
        const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds());
 | 
			
		||||
        const noteListRenderer = new NoteListRenderer({
 | 
			
		||||
            $parent: this.$content,
 | 
			
		||||
            parentNote: note,
 | 
			
		||||
            parentNotePath: this.notePath
 | 
			
		||||
        });
 | 
			
		||||
        this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
 | 
			
		||||
        await noteListRenderer.renderList();
 | 
			
		||||
        this.viewMode = noteListRenderer.viewMode;
 | 
			
		||||
@@ -120,12 +145,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
            this.checkRenderStatus();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Inform the view mode of changes and refresh if needed.
 | 
			
		||||
        if (this.viewMode && this.viewMode.onEntitiesReloaded(e)) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
            this.checkRenderStatus();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
 | 
			
		||||
@@ -134,4 +153,26 @@ export default class NoteListWidget extends NoteContextAwareWidget {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    triggerCommand<K extends CommandNames>(name: K, data?: CommandMappings[K]): Promise<unknown> | undefined | null {
 | 
			
		||||
        // Pass the commands to the view mode, which is not actually attached to the hierarchy.
 | 
			
		||||
        if (this.viewMode?.triggerCommand(name, data)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return super.triggerCommand(name, data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
 | 
			
		||||
        super.handleEventInChildren(name, data);
 | 
			
		||||
 | 
			
		||||
        if (this.viewMode) {
 | 
			
		||||
            const ret = this.viewMode.handleEvent(name, data);
 | 
			
		||||
            if (ret) {
 | 
			
		||||
                return ret;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -324,7 +324,13 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const mapRootNoteId = this.getMapRootNoteId();
 | 
			
		||||
        const data = await this.loadNotesAndRelations(mapRootNoteId);
 | 
			
		||||
 | 
			
		||||
        const labelValues = (name: string) => this.note?.getLabels(name).map(l => l.value) ?? [];
 | 
			
		||||
 | 
			
		||||
        const excludeRelations = labelValues("mapExcludeRelation");
 | 
			
		||||
        const includeRelations = labelValues("mapIncludeRelation");
 | 
			
		||||
 | 
			
		||||
        const data = await this.loadNotesAndRelations(mapRootNoteId, excludeRelations, includeRelations);
 | 
			
		||||
 | 
			
		||||
        const nodeLinkRatio = data.nodes.length / data.links.length;
 | 
			
		||||
        const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
 | 
			
		||||
@@ -473,8 +479,10 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
        ctx.restore();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async loadNotesAndRelations(mapRootNoteId: string): Promise<NotesAndRelationsData> {
 | 
			
		||||
        const resp = await server.post<PostNotesMapResponse>(`note-map/${mapRootNoteId}/${this.mapType}`);
 | 
			
		||||
    async loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[]): Promise<NotesAndRelationsData> {
 | 
			
		||||
        const resp = await server.post<PostNotesMapResponse>(`note-map/${mapRootNoteId}/${this.mapType}`, {
 | 
			
		||||
            excludeRelations, includeRelations
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.calculateNodeSizes(resp);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -186,6 +186,15 @@ interface RefreshContext {
 | 
			
		||||
    noteIdsToReload: Set<string>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The information contained within a drag event.
 | 
			
		||||
 */
 | 
			
		||||
export interface DragData {
 | 
			
		||||
    noteId: string;
 | 
			
		||||
    branchId: string;
 | 
			
		||||
    title: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class NoteTreeWidget extends NoteContextAwareWidget {
 | 
			
		||||
    private $tree!: JQuery<HTMLElement>;
 | 
			
		||||
    private $treeActions!: JQuery<HTMLElement>;
 | 
			
		||||
@@ -231,15 +240,21 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
 | 
			
		||||
        this.$tree.on("mousedown", ".fancytree-title", (e) => {
 | 
			
		||||
            if (e.which === 2) {
 | 
			
		||||
                const node = $.ui.fancytree.getNode(e as unknown as Event);
 | 
			
		||||
 | 
			
		||||
                const notePath = treeService.getNotePath(node);
 | 
			
		||||
 | 
			
		||||
                if (notePath) {
 | 
			
		||||
                    e.stopPropagation();
 | 
			
		||||
                    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
                    appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
 | 
			
		||||
                        activate: e.shiftKey ? true : false
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        this.$tree.on("mouseup", ".fancytree-title", (e) => {
 | 
			
		||||
            // Prevent middle click from pasting in the editor.
 | 
			
		||||
            if (e.which === 2) {
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
            }
 | 
			
		||||
@@ -698,7 +713,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            this.$tree.on("contextmenu", ".fancytree-node", (e) => {
 | 
			
		||||
                if (!utils.isCtrlKey(e)) {
 | 
			
		||||
                    this.showContextMenu(e);
 | 
			
		||||
                } else {
 | 
			
		||||
                    const node = $.ui.fancytree.getNode(e as unknown as Event);
 | 
			
		||||
                    const notePath = treeService.getNotePath(node);
 | 
			
		||||
                    appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
 | 
			
		||||
                }
 | 
			
		||||
                return false; // blocks default browser right click menu
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,59 +1,15 @@
 | 
			
		||||
import server from "../services/server.js";
 | 
			
		||||
import { Dropdown } from "bootstrap";
 | 
			
		||||
import { NOTE_TYPES } from "../services/note_types.js";
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import dialogService from "../services/dialog.js";
 | 
			
		||||
import mimeTypesService from "../services/mime_types.js";
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
import dialogService from "../services/dialog.js";
 | 
			
		||||
import { t } from "../services/i18n.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
import type { NoteType } from "../entities/fnote.js";
 | 
			
		||||
import server from "../services/server.js";
 | 
			
		||||
import type { EventData } from "../components/app_context.js";
 | 
			
		||||
import { Dropdown } from "bootstrap";
 | 
			
		||||
import type { NoteType } from "../entities/fnote.js";
 | 
			
		||||
import type FNote from "../entities/fnote.js";
 | 
			
		||||
 | 
			
		||||
interface NoteTypeMapping {
 | 
			
		||||
    type: NoteType;
 | 
			
		||||
    mime?: string;
 | 
			
		||||
    title: string;
 | 
			
		||||
    isBeta?: boolean;
 | 
			
		||||
    selectable: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NOTE_TYPES: NoteTypeMapping[] = [
 | 
			
		||||
    // The suggested note type ordering method: insert the item into the corresponding group,
 | 
			
		||||
    // then ensure the items within the group are ordered alphabetically.
 | 
			
		||||
 | 
			
		||||
    // The default note type (always the first item)
 | 
			
		||||
    { type: "text", mime: "text/html", title: t("note_types.text"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Text notes group
 | 
			
		||||
    { type: "book", mime: "", title: t("note_types.book"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Graphic notes
 | 
			
		||||
    { type: "canvas", mime: "application/json", title: t("note_types.canvas"), selectable: true },
 | 
			
		||||
    { type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Map notes
 | 
			
		||||
    { type: "geoMap", mime: "application/json", title: t("note_types.geo-map"), isBeta: true, selectable: true },
 | 
			
		||||
    { type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), selectable: true },
 | 
			
		||||
    { type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Misc note types
 | 
			
		||||
    { type: "render", mime: "", title: t("note_types.render-note"), selectable: true },
 | 
			
		||||
    { type: "webView", mime: "", title: t("note_types.web-view"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Code notes
 | 
			
		||||
    { type: "code", mime: "text/plain", title: t("note_types.code"), selectable: true },
 | 
			
		||||
 | 
			
		||||
    // Reserved types (cannot be created by the user)
 | 
			
		||||
    { type: "contentWidget", mime: "", title: t("note_types.widget"), selectable: false },
 | 
			
		||||
    { type: "doc", mime: "", title: t("note_types.doc"), selectable: false },
 | 
			
		||||
    { type: "file", title: t("note_types.file"), selectable: false },
 | 
			
		||||
    { type: "image", title: t("note_types.image"), selectable: false },
 | 
			
		||||
    { type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false },
 | 
			
		||||
    { type: "noteMap", mime: "", title: t("note_types.note-map"), selectable: false },
 | 
			
		||||
    { type: "search", title: t("note_types.saved-search"), selectable: false },
 | 
			
		||||
    { type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), selectable: false }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type);
 | 
			
		||||
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type);
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="dropdown note-type-widget">
 | 
			
		||||
@@ -63,13 +19,6 @@ const TPL = /*html*/`
 | 
			
		||||
            overflow-y: auto;
 | 
			
		||||
            overflow-x: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .note-type-dropdown .badge {
 | 
			
		||||
            margin-left: 8px;
 | 
			
		||||
            background: var(--accented-background-color);
 | 
			
		||||
            font-weight: normal;
 | 
			
		||||
            color: var(--menu-text-color);
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    <button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle select-button note-type-button">
 | 
			
		||||
        <span class="note-type-desc"></span>
 | 
			
		||||
@@ -116,10 +65,15 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const noteType of NOTE_TYPES.filter((nt) => nt.selectable)) {
 | 
			
		||||
        for (const noteType of NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static)) {
 | 
			
		||||
            let $typeLink: JQuery<HTMLElement>;
 | 
			
		||||
 | 
			
		||||
            const $title = $("<span>").text(noteType.title);
 | 
			
		||||
 | 
			
		||||
            if (noteType.isNew) {
 | 
			
		||||
                $title.append($(`<span class="badge new-note-type-badge">`).text(t("note_types.new-feature")));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (noteType.isBeta) {
 | 
			
		||||
                $title.append($(`<span class="badge">`).text(t("note_types.beta-feature")));
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #isFullWidthNote(note: FNote) {
 | 
			
		||||
        if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap", "geoMap"].includes(note.type)) {
 | 
			
		||||
        if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,8 @@ import attributeService from "../../services/attributes.js";
 | 
			
		||||
import { t } from "../../services/i18n.js";
 | 
			
		||||
import type FNote from "../../entities/fnote.js";
 | 
			
		||||
import type { EventData } from "../../components/app_context.js";
 | 
			
		||||
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
 | 
			
		||||
import attributes from "../../services/attributes.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="book-properties-widget">
 | 
			
		||||
@@ -15,6 +17,24 @@ const TPL = /*html*/`
 | 
			
		||||
        .book-properties-widget > * {
 | 
			
		||||
            margin-right: 15px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .book-properties-container {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .book-properties-container > div {
 | 
			
		||||
            margin-right: 15px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .book-properties-container > .type-number > label {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: baseline;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .book-properties-container input[type="checkbox"] {
 | 
			
		||||
            margin-right: 5px;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div style="display: flex; align-items: baseline">
 | 
			
		||||
@@ -24,33 +44,21 @@ const TPL = /*html*/`
 | 
			
		||||
            <option value="grid">${t("book_properties.grid")}</option>
 | 
			
		||||
            <option value="list">${t("book_properties.list")}</option>
 | 
			
		||||
            <option value="calendar">${t("book_properties.calendar")}</option>
 | 
			
		||||
            <option value="table">${t("book_properties.table")}</option>
 | 
			
		||||
            <option value="geoMap">${t("book_properties.geo-map")}</option>
 | 
			
		||||
        </select>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button"
 | 
			
		||||
            class="collapse-all-button btn btn-sm"
 | 
			
		||||
            title="${t("book_properties.collapse_all_notes")}">
 | 
			
		||||
 | 
			
		||||
        <span class="bx bx-layer-minus"></span>
 | 
			
		||||
 | 
			
		||||
        ${t("book_properties.collapse")}
 | 
			
		||||
    </button>
 | 
			
		||||
 | 
			
		||||
    <button type="button"
 | 
			
		||||
            class="expand-children-button btn btn-sm"
 | 
			
		||||
            title="${t("book_properties.expand_all_children")}">
 | 
			
		||||
        <span class="bx bx-move-vertical"></span>
 | 
			
		||||
 | 
			
		||||
        ${t("book_properties.expand")}
 | 
			
		||||
    </button>
 | 
			
		||||
    <div class="book-properties-container">
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    private $viewTypeSelect!: JQuery<HTMLElement>;
 | 
			
		||||
    private $expandChildrenButton!: JQuery<HTMLElement>;
 | 
			
		||||
    private $collapseAllButton!: JQuery<HTMLElement>;
 | 
			
		||||
    private $propertiesContainer!: JQuery<HTMLElement>;
 | 
			
		||||
    private labelsToWatch: string[] = [];
 | 
			
		||||
 | 
			
		||||
    get name() {
 | 
			
		||||
        return "bookProperties";
 | 
			
		||||
@@ -67,7 +75,6 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
    getTitle() {
 | 
			
		||||
        return {
 | 
			
		||||
            show: this.isEnabled(),
 | 
			
		||||
            activate: true,
 | 
			
		||||
            title: t("book_properties.book_properties"),
 | 
			
		||||
            icon: "bx bx-book"
 | 
			
		||||
        };
 | 
			
		||||
@@ -80,32 +87,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
        this.$viewTypeSelect = this.$widget.find(".view-type-select");
 | 
			
		||||
        this.$viewTypeSelect.on("change", () => this.toggleViewType(String(this.$viewTypeSelect.val())));
 | 
			
		||||
 | 
			
		||||
        this.$expandChildrenButton = this.$widget.find(".expand-children-button");
 | 
			
		||||
        this.$expandChildrenButton.on("click", async () => {
 | 
			
		||||
            if (!this.noteId || !this.note) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!this.note?.isLabelTruthy("expanded")) {
 | 
			
		||||
                await attributeService.addLabel(this.noteId, "expanded");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.triggerCommand("refreshNoteList", { noteId: this.noteId });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.$collapseAllButton = this.$widget.find(".collapse-all-button");
 | 
			
		||||
        this.$collapseAllButton.on("click", async () => {
 | 
			
		||||
            if (!this.noteId || !this.note) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // owned is important - we shouldn't remove inherited expanded labels
 | 
			
		||||
            for (const expandedAttr of this.note.getOwnedLabels("expanded")) {
 | 
			
		||||
                await attributeService.removeAttributeById(this.noteId, expandedAttr.attributeId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.triggerCommand("refreshNoteList", { noteId: this.noteId });
 | 
			
		||||
        });
 | 
			
		||||
        this.$propertiesContainer = this.$widget.find(".book-properties-container");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async refreshWithNote(note: FNote) {
 | 
			
		||||
@@ -117,8 +99,15 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
        this.$viewTypeSelect.val(viewType);
 | 
			
		||||
 | 
			
		||||
        this.$expandChildrenButton.toggle(viewType === "list");
 | 
			
		||||
        this.$collapseAllButton.toggle(viewType === "list");
 | 
			
		||||
        this.$propertiesContainer.empty();
 | 
			
		||||
 | 
			
		||||
        const bookPropertiesData = bookPropertiesConfig[viewType];
 | 
			
		||||
        if (bookPropertiesData) {
 | 
			
		||||
            for (const property of bookPropertiesData.properties) {
 | 
			
		||||
                this.$propertiesContainer.append(this.renderBookProperty(property));
 | 
			
		||||
                this.labelsToWatch.push(property.bindToLabel);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async toggleViewType(type: string) {
 | 
			
		||||
@@ -126,7 +115,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!["list", "grid", "calendar"].includes(type)) {
 | 
			
		||||
        if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
 | 
			
		||||
            throw new Error(t("book_properties.invalid_view_type", { type }));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -134,8 +123,82 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
 | 
			
		||||
        if (loadResults.getAttributeRows().find((attr) =>
 | 
			
		||||
                attr.noteId === this.noteId
 | 
			
		||||
                && (attr.name === "viewType" || this.labelsToWatch.includes(attr.name ?? "")))) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderBookProperty(property: BookProperty) {
 | 
			
		||||
        const $container = $("<div>");
 | 
			
		||||
        $container.addClass(`type-${property.type}`);
 | 
			
		||||
        const note = this.note;
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            return $container;
 | 
			
		||||
        }
 | 
			
		||||
        switch (property.type) {
 | 
			
		||||
            case "checkbox":
 | 
			
		||||
                const $label = $("<label>").text(property.label);
 | 
			
		||||
                const $checkbox = $("<input>", {
 | 
			
		||||
                    type: "checkbox",
 | 
			
		||||
                    class: "form-check-input",
 | 
			
		||||
                });
 | 
			
		||||
                $checkbox.on("change", () => {
 | 
			
		||||
                    if ($checkbox.prop("checked")) {
 | 
			
		||||
                        attributes.setLabel(note.noteId, property.bindToLabel);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        attributes.removeOwnedLabelByName(note, property.bindToLabel);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
                $checkbox.prop("checked", note.hasOwnedLabel(property.bindToLabel));
 | 
			
		||||
                $label.prepend($checkbox);
 | 
			
		||||
                $container.append($label);
 | 
			
		||||
                break;
 | 
			
		||||
            case "button":
 | 
			
		||||
                const $button = $("<button>", {
 | 
			
		||||
                    type: "button",
 | 
			
		||||
                    class: "btn btn-sm"
 | 
			
		||||
                }).text(property.label);
 | 
			
		||||
                if (property.title) {
 | 
			
		||||
                    $button.attr("title", property.title);
 | 
			
		||||
                }
 | 
			
		||||
                if (property.icon) {
 | 
			
		||||
                    $button.prepend($("<span>", { class: property.icon }));
 | 
			
		||||
                }
 | 
			
		||||
                $button.on("click", () => {
 | 
			
		||||
                    property.onClick({
 | 
			
		||||
                        note,
 | 
			
		||||
                        triggerCommand: this.triggerCommand.bind(this)
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
                $container.append($button);
 | 
			
		||||
                break;
 | 
			
		||||
            case "number":
 | 
			
		||||
                const $numberInput = $("<input>", {
 | 
			
		||||
                    type: "number",
 | 
			
		||||
                    class: "form-control form-control-sm",
 | 
			
		||||
                    value: note.getLabelValue(property.bindToLabel) || "",
 | 
			
		||||
                    width: property.width ?? 100,
 | 
			
		||||
                    min: property.min ?? 0
 | 
			
		||||
                });
 | 
			
		||||
                $numberInput.on("change", () => {
 | 
			
		||||
                    const value = $numberInput.val();
 | 
			
		||||
                    if (value === "") {
 | 
			
		||||
                        attributes.removeOwnedLabelByName(note, property.bindToLabel);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        attributes.setLabel(note.noteId, property.bindToLabel, String(value));
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
                $container.append($("<label>")
 | 
			
		||||
                    .text(property.label)
 | 
			
		||||
                    .append(" ".repeat(2))
 | 
			
		||||
                    .append($numberInput));
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $container;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										105
									
								
								apps/client/src/widgets/ribbon_widgets/book_properties_config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								apps/client/src/widgets/ribbon_widgets/book_properties_config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import FNote from "../../entities/fnote";
 | 
			
		||||
import attributes from "../../services/attributes";
 | 
			
		||||
import { ViewTypeOptions } from "../../services/note_list_renderer"
 | 
			
		||||
import NoteContextAwareWidget from "../note_context_aware_widget";
 | 
			
		||||
 | 
			
		||||
interface BookConfig {
 | 
			
		||||
    properties: BookProperty[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface CheckBoxProperty {
 | 
			
		||||
    type: "checkbox",
 | 
			
		||||
    label: string;
 | 
			
		||||
    bindToLabel: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ButtonProperty {
 | 
			
		||||
    type: "button",
 | 
			
		||||
    label: string;
 | 
			
		||||
    title?: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    onClick: (context: BookContext) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface NumberProperty {
 | 
			
		||||
    type: "number",
 | 
			
		||||
    label: string;
 | 
			
		||||
    bindToLabel: string;
 | 
			
		||||
    width?: number;
 | 
			
		||||
    min?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty;
 | 
			
		||||
 | 
			
		||||
interface BookContext {
 | 
			
		||||
    note: FNote;
 | 
			
		||||
    triggerCommand: NoteContextAwareWidget["triggerCommand"];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
 | 
			
		||||
    grid: {
 | 
			
		||||
        properties: []
 | 
			
		||||
    },
 | 
			
		||||
    list: {
 | 
			
		||||
        properties: [
 | 
			
		||||
            {
 | 
			
		||||
                label: t("book_properties.collapse"),
 | 
			
		||||
                title: t("book_properties.collapse_all_notes"),
 | 
			
		||||
                type: "button",
 | 
			
		||||
                icon: "bx bx-layer-minus",
 | 
			
		||||
                async onClick({ note, triggerCommand }) {
 | 
			
		||||
                    const { noteId } = note;
 | 
			
		||||
 | 
			
		||||
                    // owned is important - we shouldn't remove inherited expanded labels
 | 
			
		||||
                    for (const expandedAttr of note.getOwnedLabels("expanded")) {
 | 
			
		||||
                        await attributes.removeAttributeById(noteId, expandedAttr.attributeId);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    triggerCommand("refreshNoteList", { noteId: noteId });
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                label: t("book_properties.expand"),
 | 
			
		||||
                title: t("book_properties.expand_all_children"),
 | 
			
		||||
                type: "button",
 | 
			
		||||
                icon: "bx bx-move-vertical",
 | 
			
		||||
                async onClick({ note, triggerCommand }) {
 | 
			
		||||
                    const { noteId } = note;
 | 
			
		||||
                    if (!note.isLabelTruthy("expanded")) {
 | 
			
		||||
                        await attributes.addLabel(noteId, "expanded");
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    triggerCommand("refreshNoteList", { noteId });
 | 
			
		||||
                },
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    calendar: {
 | 
			
		||||
        properties: [
 | 
			
		||||
            {
 | 
			
		||||
                label: t("book_properties_config.hide-weekends"),
 | 
			
		||||
                type: "checkbox",
 | 
			
		||||
                bindToLabel: "calendar:hideWeekends"
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                label: t("book_properties_config.display-week-numbers"),
 | 
			
		||||
                type: "checkbox",
 | 
			
		||||
                bindToLabel: "calendar:weekNumbers"
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    geoMap: {
 | 
			
		||||
        properties: []
 | 
			
		||||
    },
 | 
			
		||||
    table: {
 | 
			
		||||
        properties: [
 | 
			
		||||
            {
 | 
			
		||||
                label: "Max nesting depth:",
 | 
			
		||||
                type: "number",
 | 
			
		||||
                bindToLabel: "maxNestingDepth",
 | 
			
		||||
                width: 65
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
@@ -48,6 +48,18 @@ export default class ClassicEditorToolbar extends NoteContextAwareWidget {
 | 
			
		||||
        this.contentSized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isEnabled(): boolean | null | undefined {
 | 
			
		||||
        if (options.get("textNoteEditorType") !== "ckeditor-classic") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this.note || this.note.type !== "text") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getTitle() {
 | 
			
		||||
        return {
 | 
			
		||||
            show: await this.#shouldDisplay(),
 | 
			
		||||
@@ -58,11 +70,7 @@ export default class ClassicEditorToolbar extends NoteContextAwareWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #shouldDisplay() {
 | 
			
		||||
        if (options.get("textNoteEditorType") !== "ckeditor-classic") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this.note || this.note.type !== "text") {
 | 
			
		||||
        if (!this.isEnabled()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -69,11 +69,6 @@ interface AttributeResult {
 | 
			
		||||
    attributeId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This widget is quite special because it's used in the desktop ribbon, but in mobile outside of ribbon.
 | 
			
		||||
 * This works without many issues (apart from autocomplete), but it should be kept in mind when changing things
 | 
			
		||||
 * and testing.
 | 
			
		||||
 */
 | 
			
		||||
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
 | 
			
		||||
 | 
			
		||||
    private $container!: JQuery<HTMLElement>;
 | 
			
		||||
@@ -117,7 +112,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
 | 
			
		||||
        // the order of attributes is important as well
 | 
			
		||||
        ownedAttributes.sort((a, b) => a.position - b.position);
 | 
			
		||||
 | 
			
		||||
        if (promotedDefAttrs.length === 0) {
 | 
			
		||||
        if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
 | 
			
		||||
            this.toggleInt(false);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
@@ -188,6 +183,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
 | 
			
		||||
            .append($multiplicityCell);
 | 
			
		||||
 | 
			
		||||
        if (valueAttr.type === "label") {
 | 
			
		||||
            $wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
 | 
			
		||||
            if (definition.labelType === "text") {
 | 
			
		||||
                $input.prop("type", "text");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -65,7 +65,11 @@ export default class SearchResultWidget extends NoteContextAwareWidget {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds(), true);
 | 
			
		||||
        const noteListRenderer = new NoteListRenderer({
 | 
			
		||||
            $parent: this.$content,
 | 
			
		||||
            parentNote: note,
 | 
			
		||||
            showNotePath: true
 | 
			
		||||
        });
 | 
			
		||||
        await noteListRenderer.renderList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,22 @@ export default class BookTypeWidget extends TypeWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async doRefresh(note: FNote) {
 | 
			
		||||
        this.$helpNoChildren.toggle(!this.note?.hasChildren() && this.note?.getAttributeValue("label", "viewType") !== "calendar");
 | 
			
		||||
        this.$helpNoChildren.toggle(this.shouldDisplayNoChildrenWarning());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    shouldDisplayNoChildrenWarning() {
 | 
			
		||||
        if (this.note?.hasChildren()) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        switch (this.note?.getAttributeValue("label", "viewType")) {
 | 
			
		||||
            case "calendar":
 | 
			
		||||
            case "table":
 | 
			
		||||
            case "geoMap":
 | 
			
		||||
                return false;
 | 
			
		||||
            default:
 | 
			
		||||
                return true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) {
 | 
			
		||||
    const templateNoteIds = new Set(templateCache.keys());
 | 
			
		||||
    const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds);
 | 
			
		||||
 | 
			
		||||
    await froca.getNotes(affectedNoteIds);
 | 
			
		||||
    await froca.getNotes(affectedNoteIds, true);
 | 
			
		||||
 | 
			
		||||
    let fullReloadNeeded = false;
 | 
			
		||||
    for (const affectedTemplateNoteId of affectedTemplateNoteIds) {
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,10 @@ const TPL = /*html*/`<div class="note-detail-doc note-detail-printable">
 | 
			
		||||
            border-radius: 5px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .note-detail-doc-content code {
 | 
			
		||||
            font-variant: none;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .note-detail-doc-content pre:not(.hljs) {
 | 
			
		||||
            background-color: var(--accented-background-color);
 | 
			
		||||
            border: 1px solid var(--main-border-color);
 | 
			
		||||
 
 | 
			
		||||
@@ -178,13 +178,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (isClassicEditor) {
 | 
			
		||||
                let $classicToolbarWidget;
 | 
			
		||||
                if (!utils.isMobile()) {
 | 
			
		||||
                    const $parentSplit = this.$widget.parents(".note-split.type-text");
 | 
			
		||||
                    $classicToolbarWidget = $parentSplit.find("> .ribbon-container .classic-toolbar-widget");
 | 
			
		||||
                } else {
 | 
			
		||||
                    $classicToolbarWidget = $("body").find(".classic-toolbar-widget");
 | 
			
		||||
                }
 | 
			
		||||
                const $classicToolbarWidget = this.findClassicToolbar();
 | 
			
		||||
 | 
			
		||||
                $classicToolbarWidget.empty();
 | 
			
		||||
                if ($classicToolbarWidget.length) {
 | 
			
		||||
@@ -271,8 +265,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    focus() {
 | 
			
		||||
        const editor = this.watchdog.editor;
 | 
			
		||||
        if (editor) {
 | 
			
		||||
            editor.editing.view.focus();
 | 
			
		||||
        } else {
 | 
			
		||||
            this.$editor.trigger("focus");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    scrollToEnd() {
 | 
			
		||||
        this.watchdog?.editor?.model.change((writer) => {
 | 
			
		||||
@@ -515,6 +514,22 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    findClassicToolbar(): JQuery<HTMLElement> {
 | 
			
		||||
        if (!utils.isMobile()) {
 | 
			
		||||
            const $parentSplit = this.$widget.parents(".note-split.type-text");
 | 
			
		||||
 | 
			
		||||
            if ($parentSplit.length) {
 | 
			
		||||
                // The editor is in a normal tab.
 | 
			
		||||
                return $parentSplit.find("> .ribbon-container .classic-toolbar-widget");
 | 
			
		||||
            } else {
 | 
			
		||||
                // The editor is in a popup.
 | 
			
		||||
                return this.$widget.closest(".modal-body").find(".classic-toolbar-widget");
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            return $("body").find(".classic-toolbar-widget");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
 | 
			
		||||
        const { TouchBar, buildIcon } = data;
 | 
			
		||||
        const { TouchBarSegmentedControl, TouchBarGroup, TouchBarButton } = TouchBar;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ import TypeWidget from "./type_widget.js";
 | 
			
		||||
import appContext from "../../components/app_context.js";
 | 
			
		||||
import searchService from "../../services/search.js";
 | 
			
		||||
import { t } from "../../services/i18n.js";
 | 
			
		||||
import type FNote from "../../entities/fnote.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="note-detail-empty note-detail-printable">
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,8 @@ const TPL = /*html*/`
 | 
			
		||||
            padding: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .note-split.full-content-width .note-detail-file[data-preview-type="video"] {
 | 
			
		||||
        .note-detail.full-height .note-detail-file[data-preview-type="pdf"],
 | 
			
		||||
        .note-detail.full-height .note-detail-file[data-preview-type="video"] {
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,447 +0,0 @@
 | 
			
		||||
import { GPX, Marker, type LatLng, type LeafletMouseEvent } from "leaflet";
 | 
			
		||||
import type FNote from "../../entities/fnote.js";
 | 
			
		||||
import GeoMapWidget, { type InitCallback, type Leaflet } from "../geo_map.js";
 | 
			
		||||
import TypeWidget from "./type_widget.js";
 | 
			
		||||
import server from "../../services/server.js";
 | 
			
		||||
import toastService from "../../services/toast.js";
 | 
			
		||||
import dialogService from "../../services/dialog.js";
 | 
			
		||||
import type { CommandListenerData, EventData } from "../../components/app_context.js";
 | 
			
		||||
import { t } from "../../services/i18n.js";
 | 
			
		||||
import attributes from "../../services/attributes.js";
 | 
			
		||||
import openContextMenu from "./geo_map_context_menu.js";
 | 
			
		||||
import link from "../../services/link.js";
 | 
			
		||||
import note_tooltip from "../../services/note_tooltip.js";
 | 
			
		||||
import appContext from "../../components/app_context.js";
 | 
			
		||||
 | 
			
		||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
 | 
			
		||||
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
 | 
			
		||||
import { hasTouchBar } from "../../services/utils.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`\
 | 
			
		||||
<div class="note-detail-geo-map note-detail-printable">
 | 
			
		||||
    <style>
 | 
			
		||||
        .leaflet-pane {
 | 
			
		||||
            z-index: 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container.placing-note {
 | 
			
		||||
            cursor: crosshair;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .marker-pin {
 | 
			
		||||
            position: relative;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon {
 | 
			
		||||
            position: relative;
 | 
			
		||||
            background: transparent;
 | 
			
		||||
            border: 0;
 | 
			
		||||
            overflow: visible;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .icon-shadow {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 0;
 | 
			
		||||
            left: 0;
 | 
			
		||||
            z-index: -1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .bx {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 3px;
 | 
			
		||||
            left: 2px;
 | 
			
		||||
            background-color: white;
 | 
			
		||||
            color: black;
 | 
			
		||||
            padding: 2px;
 | 
			
		||||
            border-radius: 50%;
 | 
			
		||||
            font-size: 17px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .title-label {
 | 
			
		||||
            display: block;
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 100%;
 | 
			
		||||
            left: 50%;
 | 
			
		||||
            transform: translateX(-50%);
 | 
			
		||||
            font-size: 0.75rem;
 | 
			
		||||
            height: 1rem;
 | 
			
		||||
            color: black;
 | 
			
		||||
            width: 100px;
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            text-overflow: ellipsis;
 | 
			
		||||
            text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white;
 | 
			
		||||
            white-space: no-wrap;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
const LOCATION_ATTRIBUTE = "geolocation";
 | 
			
		||||
const CHILD_NOTE_ICON = "bx bx-pin";
 | 
			
		||||
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
 | 
			
		||||
const DEFAULT_ZOOM = 2;
 | 
			
		||||
 | 
			
		||||
interface MapData {
 | 
			
		||||
    view?: {
 | 
			
		||||
        center?: LatLng | [number, number];
 | 
			
		||||
        zoom?: number;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: Deduplicate
 | 
			
		||||
interface CreateChildResponse {
 | 
			
		||||
    note: {
 | 
			
		||||
        noteId: string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
enum State {
 | 
			
		||||
    Normal,
 | 
			
		||||
    NewNote
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class GeoMapTypeWidget extends TypeWidget {
 | 
			
		||||
 | 
			
		||||
    private geoMapWidget: GeoMapWidget;
 | 
			
		||||
    private _state: State;
 | 
			
		||||
    private L!: Leaflet;
 | 
			
		||||
    private currentMarkerData: Record<string, Marker>;
 | 
			
		||||
    private currentTrackData: Record<string, GPX>;
 | 
			
		||||
    private gpxLoaded?: boolean;
 | 
			
		||||
    private ignoreNextZoomEvent?: boolean;
 | 
			
		||||
 | 
			
		||||
    static getType() {
 | 
			
		||||
        return "geoMap";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.geoMapWidget = new GeoMapWidget("type", (L: Leaflet) => this.#onMapInitialized(L));
 | 
			
		||||
        this.currentMarkerData = {};
 | 
			
		||||
        this.currentTrackData = {};
 | 
			
		||||
        this._state = State.Normal;
 | 
			
		||||
 | 
			
		||||
        this.child(this.geoMapWidget);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        super.doRender();
 | 
			
		||||
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
        this.$widget.append(this.geoMapWidget.render());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #onMapInitialized(L: Leaflet) {
 | 
			
		||||
        this.L = L;
 | 
			
		||||
        const map = this.geoMapWidget.map;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            throw new Error(t("geo-map.unable-to-load-map"));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.#restoreViewportAndZoom();
 | 
			
		||||
 | 
			
		||||
        // Restore markers.
 | 
			
		||||
        await this.#reloadMarkers();
 | 
			
		||||
 | 
			
		||||
        // This fixes an issue with the map appearing cut off at the beginning, due to the container not being properly attached
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            map.invalidateSize();
 | 
			
		||||
        }, 100);
 | 
			
		||||
 | 
			
		||||
        const updateFn = () => this.spacedUpdate.scheduleUpdate();
 | 
			
		||||
        map.on("moveend", updateFn);
 | 
			
		||||
        map.on("zoomend", updateFn);
 | 
			
		||||
        map.on("click", (e) => this.#onMapClicked(e));
 | 
			
		||||
 | 
			
		||||
        if (hasTouchBar) {
 | 
			
		||||
            map.on("zoom", () => {
 | 
			
		||||
                if (!this.ignoreNextZoomEvent) {
 | 
			
		||||
                    this.triggerCommand("refreshTouchBar");
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.ignoreNextZoomEvent = false;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #restoreViewportAndZoom() {
 | 
			
		||||
        const map = this.geoMapWidget.map;
 | 
			
		||||
        if (!map || !this.note) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const blob = await this.note.getBlob();
 | 
			
		||||
 | 
			
		||||
        let parsedContent: MapData = {};
 | 
			
		||||
        if (blob && blob.content) {
 | 
			
		||||
            parsedContent = JSON.parse(blob.content);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Restore viewport position & zoom
 | 
			
		||||
        const center = parsedContent.view?.center ?? DEFAULT_COORDINATES;
 | 
			
		||||
        const zoom = parsedContent.view?.zoom ?? DEFAULT_ZOOM;
 | 
			
		||||
        map.setView(center, zoom);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #reloadMarkers() {
 | 
			
		||||
        if (!this.note) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Delete all existing markers
 | 
			
		||||
        for (const marker of Object.values(this.currentMarkerData)) {
 | 
			
		||||
            marker.remove();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Delete all existing tracks
 | 
			
		||||
        for (const track of Object.values(this.currentTrackData)) {
 | 
			
		||||
            track.remove();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add the new markers.
 | 
			
		||||
        this.currentMarkerData = {};
 | 
			
		||||
        const childNotes = await this.note.getChildNotes();
 | 
			
		||||
        for (const childNote of childNotes) {
 | 
			
		||||
            if (childNote.mime === "application/gpx+xml") {
 | 
			
		||||
                this.#processNoteWithGpxTrack(childNote);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE);
 | 
			
		||||
            if (latLng) {
 | 
			
		||||
                this.#processNoteWithMarker(childNote, latLng);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #processNoteWithGpxTrack(note: FNote) {
 | 
			
		||||
        if (!this.L || !this.geoMapWidget.map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this.gpxLoaded) {
 | 
			
		||||
            await import("leaflet-gpx");
 | 
			
		||||
            this.gpxLoaded = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const xmlResponse = await server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true);
 | 
			
		||||
        let stringResponse: string;
 | 
			
		||||
        if (xmlResponse instanceof Uint8Array) {
 | 
			
		||||
            stringResponse = new TextDecoder().decode(xmlResponse);
 | 
			
		||||
        } else {
 | 
			
		||||
            stringResponse = xmlResponse;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const track = new this.L.GPX(stringResponse, {
 | 
			
		||||
            markers: {
 | 
			
		||||
                startIcon: this.#buildIcon(note.getIcon(), note.getColorClass(), note.title),
 | 
			
		||||
                endIcon: this.#buildIcon("bxs-flag-checkered"),
 | 
			
		||||
                wptIcons: {
 | 
			
		||||
                    "": this.#buildIcon("bx bx-pin")
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            polyline_options: {
 | 
			
		||||
                color: note.getLabelValue("color") ?? "blue"
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        track.addTo(this.geoMapWidget.map);
 | 
			
		||||
        this.currentTrackData[note.noteId] = track;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #processNoteWithMarker(note: FNote, latLng: string) {
 | 
			
		||||
        const map = this.geoMapWidget.map;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const [lat, lng] = latLng.split(",", 2).map((el) => parseFloat(el));
 | 
			
		||||
        const L = this.L;
 | 
			
		||||
        const icon = this.#buildIcon(note.getIcon(), note.getColorClass(), note.title);
 | 
			
		||||
 | 
			
		||||
        const marker = L.marker(L.latLng(lat, lng), {
 | 
			
		||||
            icon,
 | 
			
		||||
            draggable: true,
 | 
			
		||||
            autoPan: true,
 | 
			
		||||
            autoPanSpeed: 5
 | 
			
		||||
        })
 | 
			
		||||
            .addTo(map)
 | 
			
		||||
            .on("moveend", (e) => {
 | 
			
		||||
                this.moveMarker(note.noteId, (e.target as Marker).getLatLng());
 | 
			
		||||
            });
 | 
			
		||||
        marker.on("mousedown", ({ originalEvent }) => {
 | 
			
		||||
            // Middle click to open in new tab
 | 
			
		||||
            if (originalEvent.button === 1) {
 | 
			
		||||
                const hoistedNoteId = this.hoistedNoteId;
 | 
			
		||||
                //@ts-ignore, fix once tab manager is ported.
 | 
			
		||||
                appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        marker.on("contextmenu", (e) => {
 | 
			
		||||
            openContextMenu(note.noteId, e.originalEvent);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const el = marker.getElement();
 | 
			
		||||
        if (el) {
 | 
			
		||||
            const $el = $(el);
 | 
			
		||||
            $el.attr("data-href", `#${note.noteId}`);
 | 
			
		||||
            note_tooltip.setupElementTooltip($($el));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.currentMarkerData[note.noteId] = marker;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #buildIcon(bxIconClass: string, colorClass?: string, title?: string) {
 | 
			
		||||
        return this.L.divIcon({
 | 
			
		||||
            html: /*html*/`\
 | 
			
		||||
                <img class="icon" src="${markerIcon}" />
 | 
			
		||||
                <img class="icon-shadow" src="${markerIconShadow}" />
 | 
			
		||||
                <span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
 | 
			
		||||
                <span class="title-label">${title ?? ""}</span>`,
 | 
			
		||||
            iconSize: [25, 41],
 | 
			
		||||
            iconAnchor: [12, 41]
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #changeState(newState: State) {
 | 
			
		||||
        this._state = newState;
 | 
			
		||||
        this.geoMapWidget.$container.toggleClass("placing-note", newState === State.NewNote);
 | 
			
		||||
        if (hasTouchBar) {
 | 
			
		||||
            this.triggerCommand("refreshTouchBar");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #onMapClicked(e: LeafletMouseEvent) {
 | 
			
		||||
        if (this._state !== State.NewNote) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        toastService.closePersistent("geo-new-note");
 | 
			
		||||
        const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
 | 
			
		||||
 | 
			
		||||
        if (title?.trim()) {
 | 
			
		||||
            const { note } = await server.post<CreateChildResponse>(`notes/${this.noteId}/children?target=into`, {
 | 
			
		||||
                title,
 | 
			
		||||
                content: "",
 | 
			
		||||
                type: "text"
 | 
			
		||||
            });
 | 
			
		||||
            attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
 | 
			
		||||
            this.moveMarker(note.noteId, e.latlng);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.#changeState(State.Normal);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async moveMarker(noteId: string, latLng: LatLng | null) {
 | 
			
		||||
        const value = latLng ? [latLng.lat, latLng.lng].join(",") : "";
 | 
			
		||||
        await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getData(): any {
 | 
			
		||||
        const map = this.geoMapWidget.map;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const data: MapData = {
 | 
			
		||||
            view: {
 | 
			
		||||
                center: map.getBounds().getCenter(),
 | 
			
		||||
                zoom: map.getZoom()
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            content: JSON.stringify(data)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) {
 | 
			
		||||
        if (!this.isNoteContext(ntxId)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        toastService.showPersistent({
 | 
			
		||||
            icon: "plus",
 | 
			
		||||
            id: "geo-new-note",
 | 
			
		||||
            title: "New note",
 | 
			
		||||
            message: t("geo-map.create-child-note-instruction")
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.#changeState(State.NewNote);
 | 
			
		||||
 | 
			
		||||
        const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => {
 | 
			
		||||
            if (e.key !== "Escape") {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.#changeState(State.Normal);
 | 
			
		||||
 | 
			
		||||
            window.removeEventListener("keydown", globalKeyListener);
 | 
			
		||||
            toastService.closePersistent("geo-new-note");
 | 
			
		||||
        };
 | 
			
		||||
        window.addEventListener("keydown", globalKeyListener);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async doRefresh(note: FNote) {
 | 
			
		||||
        await this.geoMapWidget.refresh();
 | 
			
		||||
        this.#restoreViewportAndZoom();
 | 
			
		||||
        await this.#reloadMarkers();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        // If any of the children branches are altered.
 | 
			
		||||
        if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.noteId)) {
 | 
			
		||||
            this.#reloadMarkers();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If any of note has its location attribute changed.
 | 
			
		||||
        // TODO: Should probably filter by parent here as well.
 | 
			
		||||
        const attributeRows = loadResults.getAttributeRows();
 | 
			
		||||
        if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
 | 
			
		||||
            this.#reloadMarkers();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    openGeoLocationEvent({ noteId, event }: EventData<"openGeoLocation">) {
 | 
			
		||||
        const marker = this.currentMarkerData[noteId];
 | 
			
		||||
        if (!marker) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const latLng = this.currentMarkerData[noteId].getLatLng();
 | 
			
		||||
        const url = `geo:${latLng.lat},${latLng.lng}`;
 | 
			
		||||
        link.goToLinkExt(event, url);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) {
 | 
			
		||||
        this.moveMarker(noteId, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) {
 | 
			
		||||
        const map = this.geoMapWidget.map;
 | 
			
		||||
        const that = this;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            new TouchBar.TouchBarSlider({
 | 
			
		||||
                label: "Zoom",
 | 
			
		||||
                value: map.getZoom(),
 | 
			
		||||
                minValue: map.getMinZoom(),
 | 
			
		||||
                maxValue: map.getMaxZoom(),
 | 
			
		||||
                change(newValue) {
 | 
			
		||||
                    that.ignoreNextZoomEvent = true;
 | 
			
		||||
                    map.setZoom(newValue);
 | 
			
		||||
                },
 | 
			
		||||
            }),
 | 
			
		||||
            new TouchBar.TouchBarButton({
 | 
			
		||||
                label: "New geo note",
 | 
			
		||||
                click: () => this.triggerCommand("geoMapCreateChildNote", { ntxId: this.ntxId }),
 | 
			
		||||
                enabled: (this._state === State.Normal)
 | 
			
		||||
            })
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,32 +0,0 @@
 | 
			
		||||
import appContext from "../../components/app_context.js";
 | 
			
		||||
import type { ContextMenuEvent } from "../../menus/context_menu.js";
 | 
			
		||||
import contextMenu from "../../menus/context_menu.js";
 | 
			
		||||
import linkContextMenu from "../../menus/link_context_menu.js";
 | 
			
		||||
import { t } from "../../services/i18n.js";
 | 
			
		||||
 | 
			
		||||
export default function openContextMenu(noteId: string, e: ContextMenuEvent) {
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        x: e.pageX,
 | 
			
		||||
        y: e.pageY,
 | 
			
		||||
        items: [
 | 
			
		||||
            ...linkContextMenu.getItems(),
 | 
			
		||||
            { title: t("geo-map-context.open-location"), command: "openGeoLocation", uiIcon: "bx bx-map-alt" },
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            { title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" }
 | 
			
		||||
        ],
 | 
			
		||||
        selectMenuItemHandler: ({ command }, e) => {
 | 
			
		||||
            if (command === "deleteFromMap") {
 | 
			
		||||
                appContext.triggerCommand(command, { noteId });
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (command === "openGeoLocation") {
 | 
			
		||||
                appContext.triggerCommand(command, { noteId, event: e });
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Pass the events to the link context menu
 | 
			
		||||
            linkContextMenu.handleLinkContextMenuItem(command, noteId);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
@@ -1,11 +1,12 @@
 | 
			
		||||
import TypeWidget from "./type_widget.js";
 | 
			
		||||
import utils from "../../services/utils.js";
 | 
			
		||||
import type { MindElixirCtor, MindElixirInstance } from "mind-elixir";
 | 
			
		||||
import type { MindElixirInstance } from "mind-elixir";
 | 
			
		||||
import nodeMenu from "@mind-elixir/node-menu";
 | 
			
		||||
import type FNote from "../../entities/fnote.js";
 | 
			
		||||
import type { EventData } from "../../components/app_context.js";
 | 
			
		||||
 | 
			
		||||
// allow node-menu plugin css to be bundled by webpack
 | 
			
		||||
import "mind-elixir/style";
 | 
			
		||||
import "@mind-elixir/node-menu/dist/style.css";
 | 
			
		||||
 | 
			
		||||
const NEW_TOPIC_NAME = "";
 | 
			
		||||
 
 | 
			
		||||
@@ -71,6 +71,17 @@ export default abstract class TypeWidget extends NoteContextAwareWidget {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    activeNoteChangedEvent() {
 | 
			
		||||
        if (!this.isActiveNoteContext()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
 | 
			
		||||
        if (!document.activeElement?.classList.contains("fancytree-title")) {
 | 
			
		||||
            this.focus();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * {@inheritdoc}
 | 
			
		||||
     *
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ const TPL = /*html*/`
 | 
			
		||||
 | 
			
		||||
function buildElement() {
 | 
			
		||||
    if (!utils.isElectron()) {
 | 
			
		||||
        return `<iframe class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts"></iframe>`;
 | 
			
		||||
        return `<iframe class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`;
 | 
			
		||||
    } else {
 | 
			
		||||
        return `<webview class="note-detail-web-view-content"></webview>`;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -109,32 +109,24 @@ const CALENDAR_VIEWS = [
 | 
			
		||||
    "listMonth"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export default class CalendarView extends ViewMode {
 | 
			
		||||
export default class CalendarView extends ViewMode<{}> {
 | 
			
		||||
 | 
			
		||||
    private $root: JQuery<HTMLElement>;
 | 
			
		||||
    private $calendarContainer: JQuery<HTMLElement>;
 | 
			
		||||
    private noteIds: string[];
 | 
			
		||||
    private parentNote: FNote;
 | 
			
		||||
    private calendar?: Calendar;
 | 
			
		||||
    private isCalendarRoot: boolean;
 | 
			
		||||
    private lastView?: string;
 | 
			
		||||
    private debouncedSaveView?: DebouncedFunction<() => void>;
 | 
			
		||||
 | 
			
		||||
    constructor(args: ViewModeArgs) {
 | 
			
		||||
        super(args);
 | 
			
		||||
        super(args, "calendar");
 | 
			
		||||
 | 
			
		||||
        this.$root = $(TPL);
 | 
			
		||||
        this.$calendarContainer = this.$root.find(".calendar-container");
 | 
			
		||||
        this.noteIds = args.noteIds;
 | 
			
		||||
        this.parentNote = args.parentNote;
 | 
			
		||||
        this.isCalendarRoot = false;
 | 
			
		||||
        args.$parent.append(this.$root);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get isFullHeight(): boolean {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderList(): Promise<JQuery<HTMLElement> | undefined> {
 | 
			
		||||
        this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot");
 | 
			
		||||
        const isEditable = !this.isCalendarRoot;
 | 
			
		||||
@@ -227,6 +219,7 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
                    $(mainContainer ?? e.el).append($(promotedAttributesHtml));
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            // Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
 | 
			
		||||
            dateClick: async (e) => {
 | 
			
		||||
                if (!this.isCalendarRoot) {
 | 
			
		||||
                    return;
 | 
			
		||||
@@ -234,7 +227,8 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
 | 
			
		||||
                const note = await date_notes.getDayNote(e.dateStr);
 | 
			
		||||
                if (note) {
 | 
			
		||||
                    appContext.tabManager.getActiveContext()?.setNote(note.noteId);
 | 
			
		||||
                    appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId });
 | 
			
		||||
                    appContext.triggerCommand("refreshNoteList", { noteId: this.parentNote.noteId });
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            datesSet: (e) => this.#onDatesSet(e),
 | 
			
		||||
@@ -396,7 +390,7 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
    async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        // Refresh note IDs if they got changed.
 | 
			
		||||
        if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) {
 | 
			
		||||
            this.noteIds = this.parentNote.getChildNoteIds();
 | 
			
		||||
@@ -407,9 +401,14 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Refresh on note title change.
 | 
			
		||||
        if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) {
 | 
			
		||||
            this.calendar?.refetchEvents();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Refresh dataset on subnote change.
 | 
			
		||||
        if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
 | 
			
		||||
            this.calendar.refetchEvents();
 | 
			
		||||
        if (loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
 | 
			
		||||
            this.calendar?.refetchEvents();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -438,7 +437,7 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
            events.push(await CalendarView.buildEvent(dateNote, { startDate }));
 | 
			
		||||
 | 
			
		||||
            if (dateNote.hasChildren()) {
 | 
			
		||||
                const childNoteIds = dateNote.getChildNoteIds();
 | 
			
		||||
                const childNoteIds = await dateNote.getSubtreeNoteIds();
 | 
			
		||||
                for (const childNoteId of childNoteIds) {
 | 
			
		||||
                    childNoteToDateMapping[childNoteId] = startDate;
 | 
			
		||||
                }
 | 
			
		||||
@@ -464,13 +463,6 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
        for (const note of notes) {
 | 
			
		||||
            const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate");
 | 
			
		||||
 | 
			
		||||
            if (note.hasChildren()) {
 | 
			
		||||
                const childrenEventData = await this.buildEvents(note.getChildNoteIds());
 | 
			
		||||
                if (childrenEventData.length > 0) {
 | 
			
		||||
                    events.push(childrenEventData);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!startDate) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
@@ -535,7 +527,7 @@ export default class CalendarView extends ViewMode {
 | 
			
		||||
            const eventData: EventInput = {
 | 
			
		||||
                title: title,
 | 
			
		||||
                start: startDate,
 | 
			
		||||
                url: `#${note.noteId}`,
 | 
			
		||||
                url: `#${note.noteId}?popup`,
 | 
			
		||||
                noteId: note.noteId,
 | 
			
		||||
                color: color ?? undefined,
 | 
			
		||||
                iconClass: note.getLabelValue("iconClass"),
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,85 @@
 | 
			
		||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
 | 
			
		||||
import appContext, { type CommandMappings } from "../../../components/app_context.js";
 | 
			
		||||
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
 | 
			
		||||
import linkContextMenu from "../../../menus/link_context_menu.js";
 | 
			
		||||
import { t } from "../../../services/i18n.js";
 | 
			
		||||
import { createNewNote } from "./editing.js";
 | 
			
		||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
 | 
			
		||||
import link from "../../../services/link.js";
 | 
			
		||||
 | 
			
		||||
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
 | 
			
		||||
    let items: MenuItem<keyof CommandMappings>[] = [
 | 
			
		||||
        ...buildGeoLocationItem(e),
 | 
			
		||||
        { title: "----" },
 | 
			
		||||
        ...linkContextMenu.getItems(),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (isEditable) {
 | 
			
		||||
        items = [
 | 
			
		||||
            ...items,
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            { title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" }
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        x: e.originalEvent.pageX,
 | 
			
		||||
        y: e.originalEvent.pageY,
 | 
			
		||||
        items,
 | 
			
		||||
        selectMenuItemHandler: ({ command }, e) => {
 | 
			
		||||
            if (command === "deleteFromMap") {
 | 
			
		||||
                appContext.triggerCommand(command, { noteId });
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Pass the events to the link context menu
 | 
			
		||||
            linkContextMenu.handleLinkContextMenuItem(command, noteId);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
 | 
			
		||||
    let items: MenuItem<keyof CommandMappings>[] = [
 | 
			
		||||
        ...buildGeoLocationItem(e)
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (isEditable) {
 | 
			
		||||
        items = [
 | 
			
		||||
            ...items,
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("geo-map-context.add-note"),
 | 
			
		||||
                handler: () => createNewNote(noteId, e),
 | 
			
		||||
                uiIcon: "bx bx-plus"
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        x: e.originalEvent.pageX,
 | 
			
		||||
        y: e.originalEvent.pageY,
 | 
			
		||||
        items,
 | 
			
		||||
        selectMenuItemHandler: () => {
 | 
			
		||||
            // Nothing to do, as the commands handle themselves.
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildGeoLocationItem(e: LeafletMouseEvent) {
 | 
			
		||||
    function formatGeoLocation(latlng: LatLng, precision: number = 6) {
 | 
			
		||||
        return `${latlng.lat.toFixed(precision)}, ${latlng.lng.toFixed(precision)}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return [
 | 
			
		||||
        {
 | 
			
		||||
            title: formatGeoLocation(e.latlng),
 | 
			
		||||
            uiIcon: "bx bx-current-location",
 | 
			
		||||
            handler: () => copyTextWithToast(formatGeoLocation(e.latlng, 15))
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: t("geo-map-context.open-location"),
 | 
			
		||||
            uiIcon: "bx bx-map-alt",
 | 
			
		||||
            handler: () => link.goToLinkExt(null, `geo:${e.latlng.lat},${e.latlng.lng}`)
 | 
			
		||||
        }
 | 
			
		||||
    ];
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										80
									
								
								apps/client/src/widgets/view_widgets/geo_view/editing.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								apps/client/src/widgets/view_widgets/geo_view/editing.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
import { LatLng, LeafletMouseEvent } from "leaflet";
 | 
			
		||||
import attributes from "../../../services/attributes";
 | 
			
		||||
import { LOCATION_ATTRIBUTE } from "./index.js";
 | 
			
		||||
import dialog from "../../../services/dialog";
 | 
			
		||||
import server from "../../../services/server";
 | 
			
		||||
import { t } from "../../../services/i18n";
 | 
			
		||||
import type { Map } from "leaflet";
 | 
			
		||||
import type { DragData } from "../../note_tree.js";
 | 
			
		||||
import froca from "../../../services/froca.js";
 | 
			
		||||
import branches from "../../../services/branches.js";
 | 
			
		||||
 | 
			
		||||
const CHILD_NOTE_ICON = "bx bx-pin";
 | 
			
		||||
 | 
			
		||||
// TODO: Deduplicate
 | 
			
		||||
interface CreateChildResponse {
 | 
			
		||||
    note: {
 | 
			
		||||
        noteId: string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function moveMarker(noteId: string, latLng: LatLng | null) {
 | 
			
		||||
    const value = latLng ? [latLng.lat, latLng.lng].join(",") : "";
 | 
			
		||||
    await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
 | 
			
		||||
    const title = await dialog.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
 | 
			
		||||
 | 
			
		||||
    if (title?.trim()) {
 | 
			
		||||
        const { note } = await server.post<CreateChildResponse>(`notes/${noteId}/children?target=into`, {
 | 
			
		||||
            title,
 | 
			
		||||
            content: "",
 | 
			
		||||
            type: "text"
 | 
			
		||||
        });
 | 
			
		||||
        attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
 | 
			
		||||
        moveMarker(note.noteId, e.latlng);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function setupDragging($container: JQuery<HTMLElement>, map: Map, mapNoteId: string) {
 | 
			
		||||
    $container.on("dragover", (e) => {
 | 
			
		||||
        // Allow drag.
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
    });
 | 
			
		||||
    $container.on("drop", async (e) => {
 | 
			
		||||
        if (!e.originalEvent) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const data = e.originalEvent.dataTransfer?.getData('text');
 | 
			
		||||
        if (!data) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const parsedData = JSON.parse(data) as DragData[];
 | 
			
		||||
            if (!parsedData.length) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { noteId } = parsedData[0];
 | 
			
		||||
 | 
			
		||||
            const offset = $container.offset();
 | 
			
		||||
            const x = e.originalEvent.clientX - (offset?.left ?? 0);
 | 
			
		||||
            const y = e.originalEvent.clientY - (offset?.top ?? 0);
 | 
			
		||||
            const latlng = map.containerPointToLatLng([ x, y ]);
 | 
			
		||||
 | 
			
		||||
            const note = await froca.getNote(noteId, true);
 | 
			
		||||
            const parents = note?.getParentNoteIds();
 | 
			
		||||
            if (parents?.includes(mapNoteId)) {
 | 
			
		||||
                await moveMarker(noteId, latlng);
 | 
			
		||||
            } else {
 | 
			
		||||
                await branches.cloneNoteToParentNote(noteId, mapNoteId);
 | 
			
		||||
                await moveMarker(noteId, latlng);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            console.warn(e);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										332
									
								
								apps/client/src/widgets/view_widgets/geo_view/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										332
									
								
								apps/client/src/widgets/view_widgets/geo_view/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,332 @@
 | 
			
		||||
import ViewMode, { ViewModeArgs } from "../view_mode.js";
 | 
			
		||||
import L from "leaflet";
 | 
			
		||||
import type { GPX, LatLng, LeafletMouseEvent, Map, Marker } from "leaflet";
 | 
			
		||||
import "leaflet/dist/leaflet.css";
 | 
			
		||||
import SpacedUpdate from "../../../services/spaced_update.js";
 | 
			
		||||
import { t } from "../../../services/i18n.js";
 | 
			
		||||
import processNoteWithMarker, { processNoteWithGpxTrack } from "./markers.js";
 | 
			
		||||
import { hasTouchBar } from "../../../services/utils.js";
 | 
			
		||||
import toast from "../../../services/toast.js";
 | 
			
		||||
import { CommandListenerData, EventData } from "../../../components/app_context.js";
 | 
			
		||||
import { createNewNote, moveMarker, setupDragging } from "./editing.js";
 | 
			
		||||
import { openMapContextMenu } from "./context_menu.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="geo-view">
 | 
			
		||||
    <style>
 | 
			
		||||
        .geo-view {
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            position: relative;
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .leaflet-pane {
 | 
			
		||||
            z-index: 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .leaflet-top,
 | 
			
		||||
        .leaflet-bottom {
 | 
			
		||||
            z-index: 997;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container.placing-note {
 | 
			
		||||
            cursor: crosshair;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .marker-pin {
 | 
			
		||||
            position: relative;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon {
 | 
			
		||||
            position: relative;
 | 
			
		||||
            background: transparent;
 | 
			
		||||
            border: 0;
 | 
			
		||||
            overflow: visible;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .icon-shadow {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 0;
 | 
			
		||||
            left: 0;
 | 
			
		||||
            z-index: -1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .bx {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 3px;
 | 
			
		||||
            left: 2px;
 | 
			
		||||
            background-color: white;
 | 
			
		||||
            color: black;
 | 
			
		||||
            padding: 2px;
 | 
			
		||||
            border-radius: 50%;
 | 
			
		||||
            font-size: 17px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .geo-map-container .leaflet-div-icon .title-label {
 | 
			
		||||
            display: block;
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 100%;
 | 
			
		||||
            left: 50%;
 | 
			
		||||
            transform: translateX(-50%);
 | 
			
		||||
            font-size: 0.75rem;
 | 
			
		||||
            height: 1rem;
 | 
			
		||||
            color: black;
 | 
			
		||||
            width: 100px;
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            text-overflow: ellipsis;
 | 
			
		||||
            text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white;
 | 
			
		||||
            white-space: no-wrap;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="geo-map-container"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
interface MapData {
 | 
			
		||||
    view?: {
 | 
			
		||||
        center?: LatLng | [number, number];
 | 
			
		||||
        zoom?: number;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
 | 
			
		||||
const DEFAULT_ZOOM = 2;
 | 
			
		||||
export const LOCATION_ATTRIBUTE = "geolocation";
 | 
			
		||||
 | 
			
		||||
enum State {
 | 
			
		||||
    Normal,
 | 
			
		||||
    NewNote
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class GeoView extends ViewMode<MapData> {
 | 
			
		||||
 | 
			
		||||
    private $root: JQuery<HTMLElement>;
 | 
			
		||||
    private $container!: JQuery<HTMLElement>;
 | 
			
		||||
    private map?: Map;
 | 
			
		||||
    private spacedUpdate: SpacedUpdate;
 | 
			
		||||
    private _state: State;
 | 
			
		||||
    private ignoreNextZoomEvent?: boolean;
 | 
			
		||||
 | 
			
		||||
    private currentMarkerData: Record<string, Marker>;
 | 
			
		||||
    private currentTrackData: Record<string, GPX>;
 | 
			
		||||
 | 
			
		||||
    constructor(args: ViewModeArgs) {
 | 
			
		||||
        super(args, "geoMap");
 | 
			
		||||
        this.$root = $(TPL);
 | 
			
		||||
        this.$container = this.$root.find(".geo-map-container");
 | 
			
		||||
        this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
 | 
			
		||||
 | 
			
		||||
        this.currentMarkerData = {};
 | 
			
		||||
        this.currentTrackData = {};
 | 
			
		||||
        this._state = State.Normal;
 | 
			
		||||
 | 
			
		||||
        args.$parent.append(this.$root);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderList() {
 | 
			
		||||
        this.renderMap();
 | 
			
		||||
        return this.$root;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderMap() {
 | 
			
		||||
        const map = L.map(this.$container[0], {
 | 
			
		||||
            worldCopyJump: true
 | 
			
		||||
        });
 | 
			
		||||
        L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
 | 
			
		||||
            attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 | 
			
		||||
            detectRetina: true
 | 
			
		||||
        }).addTo(map);
 | 
			
		||||
 | 
			
		||||
        this.map = map;
 | 
			
		||||
 | 
			
		||||
        this.#onMapInitialized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #onMapInitialized() {
 | 
			
		||||
        const map = this.map;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            throw new Error(t("geo-map.unable-to-load-map"));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.#restoreViewportAndZoom();
 | 
			
		||||
 | 
			
		||||
        const isEditable = !this.isReadOnly;
 | 
			
		||||
        const updateFn = () => this.spacedUpdate.scheduleUpdate();
 | 
			
		||||
        map.on("moveend", updateFn);
 | 
			
		||||
        map.on("zoomend", updateFn);
 | 
			
		||||
        map.on("click", (e) => this.#onMapClicked(e))
 | 
			
		||||
        map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable));
 | 
			
		||||
 | 
			
		||||
        if (isEditable) {
 | 
			
		||||
            setupDragging(this.$container, map, this.parentNote.noteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.#reloadMarkers();
 | 
			
		||||
 | 
			
		||||
        if (hasTouchBar) {
 | 
			
		||||
            map.on("zoom", () => {
 | 
			
		||||
                if (!this.ignoreNextZoomEvent) {
 | 
			
		||||
                    this.triggerCommand("refreshTouchBar");
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.ignoreNextZoomEvent = false;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #restoreViewportAndZoom() {
 | 
			
		||||
        const map = this.map;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const parsedContent = await this.viewStorage.restore();
 | 
			
		||||
 | 
			
		||||
        // Restore viewport position & zoom
 | 
			
		||||
        const center = parsedContent?.view?.center ?? DEFAULT_COORDINATES;
 | 
			
		||||
        const zoom = parsedContent?.view?.zoom ?? DEFAULT_ZOOM;
 | 
			
		||||
        map.setView(center, zoom);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private onSave() {
 | 
			
		||||
        const map = this.map;
 | 
			
		||||
        let data: MapData = {};
 | 
			
		||||
        if (map) {
 | 
			
		||||
            data = {
 | 
			
		||||
                view: {
 | 
			
		||||
                    center: map.getBounds().getCenter(),
 | 
			
		||||
                    zoom: map.getZoom()
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.viewStorage.store(data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #reloadMarkers() {
 | 
			
		||||
        if (!this.map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Delete all existing markers
 | 
			
		||||
        for (const marker of Object.values(this.currentMarkerData)) {
 | 
			
		||||
            marker.remove();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Delete all existing tracks
 | 
			
		||||
        for (const track of Object.values(this.currentTrackData)) {
 | 
			
		||||
            track.remove();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add the new markers.
 | 
			
		||||
        this.currentMarkerData = {};
 | 
			
		||||
        const notes = await this.parentNote.getSubtreeNotes();
 | 
			
		||||
        const draggable = !this.isReadOnly;
 | 
			
		||||
        for (const childNote of notes) {
 | 
			
		||||
            if (childNote.mime === "application/gpx+xml") {
 | 
			
		||||
                const track = await processNoteWithGpxTrack(this.map, childNote);
 | 
			
		||||
                this.currentTrackData[childNote.noteId] = track;
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE);
 | 
			
		||||
            if (latLng) {
 | 
			
		||||
                const marker = processNoteWithMarker(this.map, childNote, latLng, draggable);
 | 
			
		||||
                this.currentMarkerData[childNote.noteId] = marker;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #changeState(newState: State) {
 | 
			
		||||
        this._state = newState;
 | 
			
		||||
        this.$container.toggleClass("placing-note", newState === State.NewNote);
 | 
			
		||||
        if (hasTouchBar) {
 | 
			
		||||
            this.triggerCommand("refreshTouchBar");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        // If any of the children branches are altered.
 | 
			
		||||
        if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) {
 | 
			
		||||
            this.#reloadMarkers();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If any of note has its location attribute changed.
 | 
			
		||||
        // TODO: Should probably filter by parent here as well.
 | 
			
		||||
        const attributeRows = loadResults.getAttributeRows();
 | 
			
		||||
        if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
 | 
			
		||||
            this.#reloadMarkers();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) {
 | 
			
		||||
        toast.showPersistent({
 | 
			
		||||
            icon: "plus",
 | 
			
		||||
            id: "geo-new-note",
 | 
			
		||||
            title: "New note",
 | 
			
		||||
            message: t("geo-map.create-child-note-instruction")
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.#changeState(State.NewNote);
 | 
			
		||||
 | 
			
		||||
        const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => {
 | 
			
		||||
            if (e.key !== "Escape") {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.#changeState(State.Normal);
 | 
			
		||||
 | 
			
		||||
            window.removeEventListener("keydown", globalKeyListener);
 | 
			
		||||
            toast.closePersistent("geo-new-note");
 | 
			
		||||
        };
 | 
			
		||||
        window.addEventListener("keydown", globalKeyListener);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async #onMapClicked(e: LeafletMouseEvent) {
 | 
			
		||||
        if (this._state !== State.NewNote) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        toast.closePersistent("geo-new-note");
 | 
			
		||||
        await createNewNote(this.parentNote.noteId, e);
 | 
			
		||||
        this.#changeState(State.Normal);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) {
 | 
			
		||||
        moveMarker(noteId, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) {
 | 
			
		||||
        const map = this.map;
 | 
			
		||||
        const that = this;
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            new TouchBar.TouchBarSlider({
 | 
			
		||||
                label: "Zoom",
 | 
			
		||||
                value: map.getZoom(),
 | 
			
		||||
                minValue: map.getMinZoom(),
 | 
			
		||||
                maxValue: map.getMaxZoom(),
 | 
			
		||||
                change(newValue) {
 | 
			
		||||
                    that.ignoreNextZoomEvent = true;
 | 
			
		||||
                    map.setZoom(newValue);
 | 
			
		||||
                },
 | 
			
		||||
            }),
 | 
			
		||||
            new TouchBar.TouchBarButton({
 | 
			
		||||
                label: "New geo note",
 | 
			
		||||
                click: () => this.triggerCommand("geoMapCreateChildNote"),
 | 
			
		||||
                enabled: (this._state === State.Normal)
 | 
			
		||||
            })
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										99
									
								
								apps/client/src/widgets/view_widgets/geo_view/markers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								apps/client/src/widgets/view_widgets/geo_view/markers.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
 | 
			
		||||
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
 | 
			
		||||
import { marker, latLng, divIcon, Map, type Marker } from "leaflet";
 | 
			
		||||
import type FNote from "../../../entities/fnote.js";
 | 
			
		||||
import openContextMenu from "./context_menu.js";
 | 
			
		||||
import server from "../../../services/server.js";
 | 
			
		||||
import { moveMarker } from "./editing.js";
 | 
			
		||||
import appContext from "../../../components/app_context.js";
 | 
			
		||||
import L from "leaflet";
 | 
			
		||||
 | 
			
		||||
let gpxLoaded = false;
 | 
			
		||||
 | 
			
		||||
export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) {
 | 
			
		||||
    const [lat, lng] = location.split(",", 2).map((el) => parseFloat(el));
 | 
			
		||||
    const icon = buildIcon(note.getIcon(), note.getColorClass(), note.title, note.noteId);
 | 
			
		||||
 | 
			
		||||
    const newMarker = marker(latLng(lat, lng), {
 | 
			
		||||
        icon,
 | 
			
		||||
        draggable: isEditable,
 | 
			
		||||
        autoPan: true,
 | 
			
		||||
        autoPanSpeed: 5
 | 
			
		||||
    }).addTo(map);
 | 
			
		||||
 | 
			
		||||
    if (isEditable) {
 | 
			
		||||
        newMarker.on("moveend", (e) => {
 | 
			
		||||
            moveMarker(note.noteId, (e.target as Marker).getLatLng());
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    newMarker.on("mousedown", ({ originalEvent }) => {
 | 
			
		||||
        // Middle click to open in new tab
 | 
			
		||||
        if (originalEvent.button === 1) {
 | 
			
		||||
            const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
 | 
			
		||||
            //@ts-ignore, fix once tab manager is ported.
 | 
			
		||||
            appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    newMarker.on("contextmenu", (e) => {
 | 
			
		||||
        openContextMenu(note.noteId, e, isEditable);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!isEditable) {
 | 
			
		||||
        newMarker.on("click", (e) => {
 | 
			
		||||
            appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return newMarker;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function processNoteWithGpxTrack(map: Map, note: FNote) {
 | 
			
		||||
    if (!gpxLoaded) {
 | 
			
		||||
        const GPX = await import("leaflet-gpx");
 | 
			
		||||
        gpxLoaded = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const xmlResponse = await server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true);
 | 
			
		||||
    let stringResponse: string;
 | 
			
		||||
    if (xmlResponse instanceof Uint8Array) {
 | 
			
		||||
        stringResponse = new TextDecoder().decode(xmlResponse);
 | 
			
		||||
    } else {
 | 
			
		||||
        stringResponse = xmlResponse;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const track = new L.GPX(stringResponse, {
 | 
			
		||||
        markers: {
 | 
			
		||||
            startIcon: buildIcon(note.getIcon(), note.getColorClass(), note.title),
 | 
			
		||||
            endIcon: buildIcon("bxs-flag-checkered"),
 | 
			
		||||
            wptIcons: {
 | 
			
		||||
                "": buildIcon("bx bx-pin")
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        polyline_options: {
 | 
			
		||||
            color: note.getLabelValue("color") ?? "blue"
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    track.addTo(map);
 | 
			
		||||
    return track;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) {
 | 
			
		||||
    let html = /*html*/`\
 | 
			
		||||
        <img class="icon" src="${markerIcon}" />
 | 
			
		||||
        <img class="icon-shadow" src="${markerIconShadow}" />
 | 
			
		||||
        <span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
 | 
			
		||||
        <span class="title-label">${title ?? ""}</span>`;
 | 
			
		||||
 | 
			
		||||
    if (noteIdLink) {
 | 
			
		||||
        html = `<div data-href="#root/${noteIdLink}">${html}</div>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return divIcon({
 | 
			
		||||
        html,
 | 
			
		||||
        iconSize: [25, 41],
 | 
			
		||||
        iconAnchor: [12, 41]
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
@@ -6,6 +6,7 @@ import treeService from "../../services/tree.js";
 | 
			
		||||
import utils from "../../services/utils.js";
 | 
			
		||||
import type FNote from "../../entities/fnote.js";
 | 
			
		||||
import ViewMode, { type ViewModeArgs } from "./view_mode.js";
 | 
			
		||||
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="note-list">
 | 
			
		||||
@@ -157,33 +158,22 @@ const TPL = /*html*/`
 | 
			
		||||
    </div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
class ListOrGridView extends ViewMode {
 | 
			
		||||
class ListOrGridView extends ViewMode<{}> {
 | 
			
		||||
    private $noteList: JQuery<HTMLElement>;
 | 
			
		||||
 | 
			
		||||
    private parentNote: FNote;
 | 
			
		||||
    private noteIds: string[];
 | 
			
		||||
    private filteredNoteIds!: string[];
 | 
			
		||||
    private page?: number;
 | 
			
		||||
    private pageSize?: number;
 | 
			
		||||
    private viewType?: string | null;
 | 
			
		||||
    private showNotePath?: boolean;
 | 
			
		||||
    private highlightRegex?: RegExp | null;
 | 
			
		||||
 | 
			
		||||
    /*
 | 
			
		||||
     * We're using noteIds so that it's not necessary to load all notes at once when paging
 | 
			
		||||
     */
 | 
			
		||||
    constructor(viewType: string, args: ViewModeArgs) {
 | 
			
		||||
        super(args);
 | 
			
		||||
    constructor(viewType: ViewTypeOptions, args: ViewModeArgs) {
 | 
			
		||||
        super(args, viewType);
 | 
			
		||||
        this.$noteList = $(TPL);
 | 
			
		||||
        this.viewType = viewType;
 | 
			
		||||
 | 
			
		||||
        this.parentNote = args.parentNote;
 | 
			
		||||
        const includedNoteIds = this.getIncludedNoteIds();
 | 
			
		||||
 | 
			
		||||
        this.noteIds = args.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
 | 
			
		||||
 | 
			
		||||
        if (this.noteIds.length === 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        args.$parent.append(this.$noteList);
 | 
			
		||||
 | 
			
		||||
@@ -207,8 +197,14 @@ class ListOrGridView extends ViewMode {
 | 
			
		||||
        return new Set(includedLinks.map((rel) => rel.value));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async beforeRender() {
 | 
			
		||||
        super.beforeRender();
 | 
			
		||||
        const includedNoteIds = this.getIncludedNoteIds();
 | 
			
		||||
        this.filteredNoteIds = this.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderList() {
 | 
			
		||||
        if (this.noteIds.length === 0 || !this.page || !this.pageSize) {
 | 
			
		||||
        if (this.filteredNoteIds.length === 0 || !this.page || !this.pageSize) {
 | 
			
		||||
            this.$noteList.hide();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
@@ -229,7 +225,7 @@ class ListOrGridView extends ViewMode {
 | 
			
		||||
        const startIdx = (this.page - 1) * this.pageSize;
 | 
			
		||||
        const endIdx = startIdx + this.pageSize;
 | 
			
		||||
 | 
			
		||||
        const pageNoteIds = this.noteIds.slice(startIdx, Math.min(endIdx, this.noteIds.length));
 | 
			
		||||
        const pageNoteIds = this.filteredNoteIds.slice(startIdx, Math.min(endIdx, this.filteredNoteIds.length));
 | 
			
		||||
        const pageNotes = await froca.getNotes(pageNoteIds);
 | 
			
		||||
 | 
			
		||||
        for (const note of pageNotes) {
 | 
			
		||||
@@ -249,7 +245,7 @@ class ListOrGridView extends ViewMode {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const pageCount = Math.ceil(this.noteIds.length / this.pageSize);
 | 
			
		||||
        const pageCount = Math.ceil(this.filteredNoteIds.length / this.pageSize);
 | 
			
		||||
 | 
			
		||||
        $pager.toggle(pageCount > 1);
 | 
			
		||||
 | 
			
		||||
@@ -260,7 +256,7 @@ class ListOrGridView extends ViewMode {
 | 
			
		||||
                lastPrinted = true;
 | 
			
		||||
 | 
			
		||||
                const startIndex = (i - 1) * this.pageSize + 1;
 | 
			
		||||
                const endIndex = Math.min(this.noteIds.length, i * this.pageSize);
 | 
			
		||||
                const endIndex = Math.min(this.filteredNoteIds.length, i * this.pageSize);
 | 
			
		||||
 | 
			
		||||
                $pager.append(
 | 
			
		||||
                    i === this.page
 | 
			
		||||
@@ -282,7 +278,7 @@ class ListOrGridView extends ViewMode {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all
 | 
			
		||||
        $pager.append(`<span class="note-list-pager-total-count">(${this.noteIds.length} notes)</span>`);
 | 
			
		||||
        $pager.append(`<span class="note-list-pager-total-count">(${this.filteredNoteIds.length} notes)</span>`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderNote(note: FNote, expand: boolean = false) {
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,31 @@
 | 
			
		||||
import { executeBulkActions } from "../../../services/bulk_action.js";
 | 
			
		||||
 | 
			
		||||
export async function renameColumn(parentNoteId: string, type: "label" | "relation", originalName: string, newName: string) {
 | 
			
		||||
    if (type === "label") {
 | 
			
		||||
        return executeBulkActions(parentNoteId, [{
 | 
			
		||||
            name: "renameLabel",
 | 
			
		||||
            oldLabelName: originalName,
 | 
			
		||||
            newLabelName: newName
 | 
			
		||||
        }]);
 | 
			
		||||
    } else {
 | 
			
		||||
        return executeBulkActions(parentNoteId, [{
 | 
			
		||||
            name: "renameRelation",
 | 
			
		||||
            oldRelationName: originalName,
 | 
			
		||||
            newRelationName: newName
 | 
			
		||||
        }]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) {
 | 
			
		||||
    if (type === "label") {
 | 
			
		||||
        return executeBulkActions(parentNoteId, [{
 | 
			
		||||
            name: "deleteLabel",
 | 
			
		||||
            labelName: columnName
 | 
			
		||||
        }]);
 | 
			
		||||
    } else {
 | 
			
		||||
        return executeBulkActions(parentNoteId, [{
 | 
			
		||||
            name: "deleteRelation",
 | 
			
		||||
            relationName: columnName
 | 
			
		||||
        }]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										152
									
								
								apps/client/src/widgets/view_widgets/table_view/col_editing.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								apps/client/src/widgets/view_widgets/table_view/col_editing.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,152 @@
 | 
			
		||||
import { Tabulator } from "tabulator-tables";
 | 
			
		||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
 | 
			
		||||
import { Attribute } from "../../../services/attribute_parser";
 | 
			
		||||
import Component from "../../../components/component";
 | 
			
		||||
import { CommandListenerData, EventData } from "../../../components/app_context";
 | 
			
		||||
import attributes from "../../../services/attributes";
 | 
			
		||||
import FNote from "../../../entities/fnote";
 | 
			
		||||
import { deleteColumn, renameColumn } from "./bulk_actions";
 | 
			
		||||
import dialog from "../../../services/dialog";
 | 
			
		||||
import { t } from "../../../services/i18n";
 | 
			
		||||
 | 
			
		||||
export default class TableColumnEditing extends Component {
 | 
			
		||||
 | 
			
		||||
    private attributeDetailWidget: AttributeDetailWidget;
 | 
			
		||||
    private api: Tabulator;
 | 
			
		||||
    private parentNote: FNote;
 | 
			
		||||
 | 
			
		||||
    private newAttribute?: Attribute;
 | 
			
		||||
    private newAttributePosition?: number;
 | 
			
		||||
    private existingAttributeToEdit?: Attribute;
 | 
			
		||||
 | 
			
		||||
    constructor($parent: JQuery<HTMLElement>, parentNote: FNote, api: Tabulator) {
 | 
			
		||||
        super();
 | 
			
		||||
        const parentComponent = glob.getComponentByEl($parent[0]);
 | 
			
		||||
        this.attributeDetailWidget = new AttributeDetailWidget()
 | 
			
		||||
                .contentSized()
 | 
			
		||||
                .setParent(parentComponent);
 | 
			
		||||
        $parent.append(this.attributeDetailWidget.render());
 | 
			
		||||
        this.api = api;
 | 
			
		||||
        this.parentNote = parentNote;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) {
 | 
			
		||||
        let attr: Attribute | undefined;
 | 
			
		||||
 | 
			
		||||
        this.existingAttributeToEdit = undefined;
 | 
			
		||||
        if (columnToEdit) {
 | 
			
		||||
            attr = this.getAttributeFromField(columnToEdit.getField());
 | 
			
		||||
            if (attr) {
 | 
			
		||||
                this.existingAttributeToEdit = { ...attr };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!attr) {
 | 
			
		||||
            attr = {
 | 
			
		||||
                type: "label",
 | 
			
		||||
                name: `${type ?? "label"}:myLabel`,
 | 
			
		||||
                value: "promoted,single,text",
 | 
			
		||||
                isInheritable: true
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (referenceColumn && this.api) {
 | 
			
		||||
            this.newAttributePosition = this.api.getColumns().indexOf(referenceColumn);
 | 
			
		||||
 | 
			
		||||
            if (direction === "after") {
 | 
			
		||||
                this.newAttributePosition++;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            this.newAttributePosition = undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.attributeDetailWidget!.showAttributeDetail({
 | 
			
		||||
            attribute: attr,
 | 
			
		||||
            allAttributes: [ attr ],
 | 
			
		||||
            isOwned: true,
 | 
			
		||||
            x: 0,
 | 
			
		||||
            y: 150,
 | 
			
		||||
            focus: "name",
 | 
			
		||||
            hideMultiplicity: true
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
 | 
			
		||||
        this.newAttribute = attributes[0];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async saveAttributesCommand() {
 | 
			
		||||
        if (!this.newAttribute) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { name, value, isInheritable } = this.newAttribute;
 | 
			
		||||
 | 
			
		||||
        this.api.blockRedraw();
 | 
			
		||||
        const isRename = (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name);
 | 
			
		||||
        try {
 | 
			
		||||
            if (isRename) {
 | 
			
		||||
                const oldName = this.existingAttributeToEdit!.name.split(":")[1];
 | 
			
		||||
                const [ type, newName ] = name.split(":");
 | 
			
		||||
                await renameColumn(this.parentNote.noteId, type as "label" | "relation", oldName, newName);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.existingAttributeToEdit && (isRename || this.existingAttributeToEdit.isInheritable !== isInheritable)) {
 | 
			
		||||
                attributes.removeOwnedLabelByName(this.parentNote, this.existingAttributeToEdit.name);
 | 
			
		||||
            }
 | 
			
		||||
            attributes.setLabel(this.parentNote.noteId, name, value, isInheritable);
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.api.restoreRedraw();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) {
 | 
			
		||||
        if (!columnToDelete || !await dialog.confirm(t("table_view.delete_column_confirmation"))) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let [ type, name ] = columnToDelete.getField()?.split(".", 2);
 | 
			
		||||
        if (!type || !name) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        type = type.replace("s", "");
 | 
			
		||||
 | 
			
		||||
        this.api.blockRedraw();
 | 
			
		||||
        try {
 | 
			
		||||
            await deleteColumn(this.parentNote.noteId, type as "label" | "relation", name);
 | 
			
		||||
            attributes.removeOwnedLabelByName(this.parentNote, `${type}:${name}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.api.restoreRedraw();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getNewAttributePosition() {
 | 
			
		||||
        return this.newAttributePosition;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    resetNewAttributePosition() {
 | 
			
		||||
        this.newAttribute = undefined;
 | 
			
		||||
        this.newAttributePosition = undefined;
 | 
			
		||||
        this.existingAttributeToEdit = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getFAttributeFromField(field: string) {
 | 
			
		||||
        const [ type, name ] = field.split(".", 2);
 | 
			
		||||
        const attrName = `${type.replace("s", "")}:${name}`;
 | 
			
		||||
        return this.parentNote.getLabel(attrName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getAttributeFromField(field: string): Attribute | undefined {
 | 
			
		||||
        const fAttribute = this.getFAttributeFromField(field);
 | 
			
		||||
        if (fAttribute) {
 | 
			
		||||
            return {
 | 
			
		||||
                name: fAttribute.name,
 | 
			
		||||
                value: fAttribute.value,
 | 
			
		||||
                type: fAttribute.type,
 | 
			
		||||
                isInheritable: fAttribute.isInheritable
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										133
									
								
								apps/client/src/widgets/view_widgets/table_view/columns.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								apps/client/src/widgets/view_widgets/table_view/columns.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,133 @@
 | 
			
		||||
import { describe, expect, it } from "vitest";
 | 
			
		||||
import { restoreExistingData } from "./columns";
 | 
			
		||||
import type { ColumnDefinition } from "tabulator-tables";
 | 
			
		||||
 | 
			
		||||
describe("restoreExistingData", () => {
 | 
			
		||||
    it("maintains important columns properties", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", formatter: "color", visible: false }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true },
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored[0].editor).toBe("input");
 | 
			
		||||
        expect(restored[1].formatter).toBe("color");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("should restore existing column data", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true },
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored[0].width).toBe(300);
 | 
			
		||||
        expect(restored[1].width).toBe(200);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("restores order of columns", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true },
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored[0].field).toBe("noteId");
 | 
			
		||||
        expect(restored[1].field).toBe("title");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("inserts new columns at given position", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false },
 | 
			
		||||
            { field: "newColumn", title: "New Column", editor: "input" }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true },
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs, 0);
 | 
			
		||||
        expect(restored.length).toBe(3);
 | 
			
		||||
        expect(restored[0].field).toBe("newColumn");
 | 
			
		||||
        expect(restored[1].field).toBe("title");
 | 
			
		||||
        expect(restored[2].field).toBe("noteId");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("inserts new columns at the end if no position is specified", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false },
 | 
			
		||||
            { field: "newColumn", title: "New Column", editor: "input" }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true },
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored.length).toBe(3);
 | 
			
		||||
        expect(restored[0].field).toBe("title");
 | 
			
		||||
        expect(restored[1].field).toBe("noteId");
 | 
			
		||||
        expect(restored[2].field).toBe("newColumn");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("supports a rename", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", editor: "input" },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false },
 | 
			
		||||
            { field: "newColumn", title: "New Column", editor: "input" }
 | 
			
		||||
        ];
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { field: "title", title: "Title", width: 300, visible: true },
 | 
			
		||||
            { field: "noteId", title: "Note ID", width: 200, visible: true },
 | 
			
		||||
            { field: "oldColumn", title: "New Column", editor: "input" }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored.length).toBe(3);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("doesn't alter the existing order", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", headerSort: false, hozAlign: "center", resizable: false, frozen: true, rowHandle: false },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false },
 | 
			
		||||
            { field: "title", title: "Title", editor: "input", width: 400 }
 | 
			
		||||
        ]
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", headerSort: false, hozAlign: "center", resizable: false, rowHandle: false },
 | 
			
		||||
            { field: "noteId", title: "Note ID", visible: false },
 | 
			
		||||
            { field: "title", title: "Title", editor: "input", width: 400 }
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored).toStrictEqual(newDefs);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("allows hiding the row number column", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", headerSort: false, hozAlign: "center", resizable: false, frozen: true, rowHandle: false },
 | 
			
		||||
        ]
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", headerSort: false, hozAlign: "center", resizable: false, rowHandle: false, visible: false },
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored[0].visible).toStrictEqual(false);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("enforces size for non-resizable columns", () => {
 | 
			
		||||
        const newDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", resizable: false, width: "100px" },
 | 
			
		||||
        ]
 | 
			
		||||
        const oldDefs: ColumnDefinition[] = [
 | 
			
		||||
            { title: "#", resizable: false, width: "120px" },
 | 
			
		||||
        ];
 | 
			
		||||
        const restored = restoreExistingData(newDefs, oldDefs);
 | 
			
		||||
        expect(restored[0].width).toStrictEqual("100px");
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										147
									
								
								apps/client/src/widgets/view_widgets/table_view/columns.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								apps/client/src/widgets/view_widgets/table_view/columns.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,147 @@
 | 
			
		||||
import { RelationEditor } from "./relation_editor.js";
 | 
			
		||||
import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js";
 | 
			
		||||
import type { ColumnDefinition } from "tabulator-tables";
 | 
			
		||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
 | 
			
		||||
 | 
			
		||||
type ColumnType = LabelType | "relation";
 | 
			
		||||
 | 
			
		||||
export interface AttributeDefinitionInformation {
 | 
			
		||||
    name: string;
 | 
			
		||||
    title?: string;
 | 
			
		||||
    type?: ColumnType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
 | 
			
		||||
    text: {
 | 
			
		||||
        editor: "input"
 | 
			
		||||
    },
 | 
			
		||||
    boolean: {
 | 
			
		||||
        formatter: "tickCross",
 | 
			
		||||
        editor: "tickCross"
 | 
			
		||||
    },
 | 
			
		||||
    date: {
 | 
			
		||||
        editor: "date",
 | 
			
		||||
    },
 | 
			
		||||
    datetime: {
 | 
			
		||||
        editor: "datetime"
 | 
			
		||||
    },
 | 
			
		||||
    number: {
 | 
			
		||||
        editor: "number"
 | 
			
		||||
    },
 | 
			
		||||
    time: {
 | 
			
		||||
        editor: "input"
 | 
			
		||||
    },
 | 
			
		||||
    url: {
 | 
			
		||||
        formatter: "link",
 | 
			
		||||
        editor: "input"
 | 
			
		||||
    },
 | 
			
		||||
    relation: {
 | 
			
		||||
        editor: RelationEditor,
 | 
			
		||||
        formatter: NoteFormatter
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface BuildColumnArgs {
 | 
			
		||||
    info: AttributeDefinitionInformation[];
 | 
			
		||||
    movableRows: boolean;
 | 
			
		||||
    existingColumnData: ColumnDefinition[] | undefined;
 | 
			
		||||
    rowNumberHint: number;
 | 
			
		||||
    position?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) {
 | 
			
		||||
    let columnDefs: ColumnDefinition[] = [
 | 
			
		||||
        {
 | 
			
		||||
            title: "#",
 | 
			
		||||
            headerSort: false,
 | 
			
		||||
            hozAlign: "center",
 | 
			
		||||
            resizable: false,
 | 
			
		||||
            frozen: true,
 | 
			
		||||
            rowHandle: movableRows,
 | 
			
		||||
            width: calculateIndexColumnWidth(rowNumberHint, movableRows),
 | 
			
		||||
            formatter: RowNumberFormatter(movableRows)
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            field: "noteId",
 | 
			
		||||
            title: "Note ID",
 | 
			
		||||
            formatter: MonospaceFormatter,
 | 
			
		||||
            visible: false
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            field: "title",
 | 
			
		||||
            title: "Title",
 | 
			
		||||
            editor: "input",
 | 
			
		||||
            formatter: NoteTitleFormatter,
 | 
			
		||||
            width: 400
 | 
			
		||||
        }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    const seenFields = new Set<string>();
 | 
			
		||||
    for (const { name, title, type } of info) {
 | 
			
		||||
        const prefix = (type === "relation" ? "relations" : "labels");
 | 
			
		||||
        const field = `${prefix}.${name}`;
 | 
			
		||||
 | 
			
		||||
        if (seenFields.has(field)) {
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        columnDefs.push({
 | 
			
		||||
            field,
 | 
			
		||||
            title: title ?? name,
 | 
			
		||||
            editor: "input",
 | 
			
		||||
            rowHandle: false,
 | 
			
		||||
            ...labelTypeMappings[type ?? "text"],
 | 
			
		||||
        });
 | 
			
		||||
        seenFields.add(field);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (existingColumnData) {
 | 
			
		||||
        columnDefs = restoreExistingData(columnDefs, existingColumnData, position);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return columnDefs;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[], position?: number) {
 | 
			
		||||
    // 1. Keep existing columns, but restore their properties like width, visibility and order.
 | 
			
		||||
    const newItemsByField = new Map<string, ColumnDefinition>(
 | 
			
		||||
        newDefs.map(def => [def.field!, def])
 | 
			
		||||
    );
 | 
			
		||||
    const existingColumns = oldDefs
 | 
			
		||||
        .filter(item => (item.field && newItemsByField.has(item.field!)) || item.title === "#")
 | 
			
		||||
        .map(oldItem => {
 | 
			
		||||
            const data = newItemsByField.get(oldItem.field!)!;
 | 
			
		||||
            if (oldItem.resizable !== false && oldItem.width !== undefined) {
 | 
			
		||||
                data.width = oldItem.width;
 | 
			
		||||
            }
 | 
			
		||||
            if (oldItem.visible !== undefined) {
 | 
			
		||||
                data.visible = oldItem.visible;
 | 
			
		||||
            }
 | 
			
		||||
            return data;
 | 
			
		||||
        }) as ColumnDefinition[];
 | 
			
		||||
 | 
			
		||||
    // 2. Determine new columns.
 | 
			
		||||
    const existingFields = new Set(existingColumns.map(item => item.field));
 | 
			
		||||
    const newColumns = newDefs
 | 
			
		||||
        .filter(item => !existingFields.has(item.field!));
 | 
			
		||||
 | 
			
		||||
    // Clamp position to a valid range
 | 
			
		||||
    const insertPos = position !== undefined
 | 
			
		||||
        ? Math.min(Math.max(position, 0), existingColumns.length)
 | 
			
		||||
        : existingColumns.length;
 | 
			
		||||
 | 
			
		||||
    // 3. Insert new columns at the specified position
 | 
			
		||||
    return [
 | 
			
		||||
        ...existingColumns.slice(0, insertPos),
 | 
			
		||||
        ...newColumns,
 | 
			
		||||
        ...existingColumns.slice(insertPos)
 | 
			
		||||
    ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean): number {
 | 
			
		||||
    let columnWidth = 16 * (rowNumberHint.toString().length || 1);
 | 
			
		||||
    if (movableRows) {
 | 
			
		||||
        columnWidth += 32;
 | 
			
		||||
    }
 | 
			
		||||
    return columnWidth;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										277
									
								
								apps/client/src/widgets/view_widgets/table_view/context_menu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								apps/client/src/widgets/view_widgets/table_view/context_menu.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,277 @@
 | 
			
		||||
import { ColumnComponent, RowComponent, Tabulator } from "tabulator-tables";
 | 
			
		||||
import contextMenu, { MenuItem } from "../../../menus/context_menu.js";
 | 
			
		||||
import { TableData } from "./rows.js";
 | 
			
		||||
import branches from "../../../services/branches.js";
 | 
			
		||||
import { t } from "../../../services/i18n.js";
 | 
			
		||||
import link_context_menu from "../../../menus/link_context_menu.js";
 | 
			
		||||
import type FNote from "../../../entities/fnote.js";
 | 
			
		||||
import froca from "../../../services/froca.js";
 | 
			
		||||
import type Component from "../../../components/component.js";
 | 
			
		||||
 | 
			
		||||
export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
 | 
			
		||||
    tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator));
 | 
			
		||||
    tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, parentNote, tabulator));
 | 
			
		||||
    tabulator.on("renderComplete", () => {
 | 
			
		||||
        const headerRow = tabulator.element.querySelector(".tabulator-header-contents");
 | 
			
		||||
        headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(e, tabulator));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
 | 
			
		||||
    if (tabulator.options.dataTree) {
 | 
			
		||||
        const dismissContextMenu = () => contextMenu.hide();
 | 
			
		||||
        tabulator.on("dataTreeRowExpanded", dismissContextMenu);
 | 
			
		||||
        tabulator.on("dataTreeRowCollapsed", dismissContextMenu);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
 | 
			
		||||
    const e = _e as MouseEvent;
 | 
			
		||||
    const { title, field } = column.getDefinition();
 | 
			
		||||
 | 
			
		||||
    const sorters = tabulator.getSorters();
 | 
			
		||||
    const sorter = sorters.find(sorter => sorter.field === field);
 | 
			
		||||
    const isUserDefinedColumn = (!!field && (field?.startsWith("labels.") || field?.startsWith("relations.")));
 | 
			
		||||
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        items: [
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.sort-column-by", { title }),
 | 
			
		||||
                enabled: !!field,
 | 
			
		||||
                uiIcon: "bx bx-sort-alt-2",
 | 
			
		||||
                items: [
 | 
			
		||||
                    {
 | 
			
		||||
                        title: t("table_view.sort-column-ascending"),
 | 
			
		||||
                        checked: (sorter?.dir === "asc"),
 | 
			
		||||
                        uiIcon: "bx bx-empty",
 | 
			
		||||
                        handler: () => tabulator.setSort([
 | 
			
		||||
                            {
 | 
			
		||||
                                column: field!,
 | 
			
		||||
                                dir: "asc",
 | 
			
		||||
                            }
 | 
			
		||||
                        ])
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        title: t("table_view.sort-column-descending"),
 | 
			
		||||
                        checked: (sorter?.dir === "desc"),
 | 
			
		||||
                        uiIcon: "bx bx-empty",
 | 
			
		||||
                        handler: () => tabulator.setSort([
 | 
			
		||||
                            {
 | 
			
		||||
                                column: field!,
 | 
			
		||||
                                dir: "desc"
 | 
			
		||||
                            }
 | 
			
		||||
                        ])
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.sort-column-clear"),
 | 
			
		||||
                enabled: sorters.length > 0,
 | 
			
		||||
                uiIcon: "bx bx-x-circle",
 | 
			
		||||
                handler: () => tabulator.clearSort()
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: "----"
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.hide-column", { title }),
 | 
			
		||||
                uiIcon: "bx bx-hide",
 | 
			
		||||
                handler: () => column.hide()
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.show-hide-columns"),
 | 
			
		||||
                uiIcon: "bx bx-columns",
 | 
			
		||||
                items: buildColumnItems(tabulator)
 | 
			
		||||
            },
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.add-column-to-the-left"),
 | 
			
		||||
                uiIcon: "bx bx-horizontal-left",
 | 
			
		||||
                enabled: !column.getDefinition().frozen,
 | 
			
		||||
                items: buildInsertSubmenu(e, column, "before"),
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
 | 
			
		||||
                    referenceColumn: column
 | 
			
		||||
                })
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.add-column-to-the-right"),
 | 
			
		||||
                uiIcon: "bx bx-horizontal-right",
 | 
			
		||||
                items: buildInsertSubmenu(e, column, "after"),
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
 | 
			
		||||
                    referenceColumn: column,
 | 
			
		||||
                    direction: "after"
 | 
			
		||||
                })
 | 
			
		||||
            },
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.edit-column"),
 | 
			
		||||
                uiIcon: "bx bxs-edit-alt",
 | 
			
		||||
                enabled: isUserDefinedColumn,
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
 | 
			
		||||
                    referenceColumn: column,
 | 
			
		||||
                    columnToEdit: column
 | 
			
		||||
                })
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.delete-column"),
 | 
			
		||||
                uiIcon: "bx bx-trash",
 | 
			
		||||
                enabled: isUserDefinedColumn,
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("deleteTableColumn", {
 | 
			
		||||
                    columnToDelete: column
 | 
			
		||||
                })
 | 
			
		||||
            }
 | 
			
		||||
        ],
 | 
			
		||||
        selectMenuItemHandler() {},
 | 
			
		||||
        x: e.pageX,
 | 
			
		||||
        y: e.pageY
 | 
			
		||||
    });
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Shows a context menu which has options dedicated to the header area (the part where the columns are, but in the empty space).
 | 
			
		||||
 * Provides generic options such as toggling columns.
 | 
			
		||||
 */
 | 
			
		||||
function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
 | 
			
		||||
    const e = _e as MouseEvent;
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        items: [
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.show-hide-columns"),
 | 
			
		||||
                uiIcon: "bx bx-columns",
 | 
			
		||||
                items: buildColumnItems(tabulator)
 | 
			
		||||
            },
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.new-column"),
 | 
			
		||||
                uiIcon: "bx bx-empty",
 | 
			
		||||
                enabled: false
 | 
			
		||||
            },
 | 
			
		||||
            ...buildInsertSubmenu(e)
 | 
			
		||||
        ],
 | 
			
		||||
        selectMenuItemHandler() {},
 | 
			
		||||
        x: e.pageX,
 | 
			
		||||
        y: e.pageY
 | 
			
		||||
    });
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
 | 
			
		||||
    const e = _e as MouseEvent;
 | 
			
		||||
    const rowData = row.getData() as TableData;
 | 
			
		||||
 | 
			
		||||
    let parentNoteId: string = parentNote.noteId;
 | 
			
		||||
 | 
			
		||||
    if (tabulator.options.dataTree) {
 | 
			
		||||
        const parentRow = row.getTreeParent();
 | 
			
		||||
        if (parentRow) {
 | 
			
		||||
            parentNoteId = parentRow.getData().noteId as string;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    contextMenu.show({
 | 
			
		||||
        items: [
 | 
			
		||||
            ...link_context_menu.getItems(),
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.row-insert-above"),
 | 
			
		||||
                uiIcon: "bx bx-horizontal-left bx-rotate-90",
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
 | 
			
		||||
                    parentNotePath: parentNoteId,
 | 
			
		||||
                    customOpts: {
 | 
			
		||||
                        target: "before",
 | 
			
		||||
                        targetBranchId: rowData.branchId,
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.row-insert-child"),
 | 
			
		||||
                uiIcon: "bx bx-subdirectory-right",
 | 
			
		||||
                handler: async () => {
 | 
			
		||||
                    const branchId = row.getData().branchId;
 | 
			
		||||
                    const note = await froca.getBranch(branchId)?.getNote();
 | 
			
		||||
                    getParentComponent(e)?.triggerCommand("addNewRow", {
 | 
			
		||||
                        parentNotePath: note?.noteId,
 | 
			
		||||
                        customOpts: {
 | 
			
		||||
                            target: "after",
 | 
			
		||||
                            targetBranchId: branchId,
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_view.row-insert-below"),
 | 
			
		||||
                uiIcon: "bx bx-horizontal-left bx-rotate-270",
 | 
			
		||||
                handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
 | 
			
		||||
                    parentNotePath: parentNoteId,
 | 
			
		||||
                    customOpts: {
 | 
			
		||||
                        target: "after",
 | 
			
		||||
                        targetBranchId: rowData.branchId,
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
            },
 | 
			
		||||
            { title: "----" },
 | 
			
		||||
            {
 | 
			
		||||
                title: t("table_context_menu.delete_row"),
 | 
			
		||||
                uiIcon: "bx bx-trash",
 | 
			
		||||
                handler: () => branches.deleteNotes([ rowData.branchId ], false, false)
 | 
			
		||||
            }
 | 
			
		||||
        ],
 | 
			
		||||
        selectMenuItemHandler: ({ command }) =>  link_context_menu.handleLinkContextMenuItem(command, rowData.noteId),
 | 
			
		||||
        x: e.pageX,
 | 
			
		||||
        y: e.pageY
 | 
			
		||||
    });
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getParentComponent(e: MouseEvent) {
 | 
			
		||||
    if (!e.target) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $(e.target)
 | 
			
		||||
        .closest(".component")
 | 
			
		||||
        .prop("component") as Component;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildColumnItems(tabulator: Tabulator) {
 | 
			
		||||
    const items: MenuItem<unknown>[] = [];
 | 
			
		||||
    for (const column of tabulator.getColumns()) {
 | 
			
		||||
        const { title } = column.getDefinition();
 | 
			
		||||
 | 
			
		||||
        items.push({
 | 
			
		||||
            title,
 | 
			
		||||
            checked: column.isVisible(),
 | 
			
		||||
            uiIcon: "bx bx-empty",
 | 
			
		||||
            handler: () => column.toggle()
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return items;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
 | 
			
		||||
    return [
 | 
			
		||||
        {
 | 
			
		||||
            title: t("table_view.new-column-label"),
 | 
			
		||||
            uiIcon: "bx bx-hash",
 | 
			
		||||
            handler: () => {
 | 
			
		||||
                getParentComponent(e)?.triggerCommand("addNewTableColumn", {
 | 
			
		||||
                    referenceColumn,
 | 
			
		||||
                    type: "label",
 | 
			
		||||
                    direction
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            title: t("table_view.new-column-relation"),
 | 
			
		||||
            uiIcon: "bx bx-transfer",
 | 
			
		||||
            handler: () => {
 | 
			
		||||
                getParentComponent(e)?.triggerCommand("addNewTableColumn", {
 | 
			
		||||
                    referenceColumn,
 | 
			
		||||
                    type: "relation",
 | 
			
		||||
                    direction
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								apps/client/src/widgets/view_widgets/table_view/dragging.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								apps/client/src/widgets/view_widgets/table_view/dragging.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import type { Tabulator } from "tabulator-tables";
 | 
			
		||||
import type FNote from "../../../entities/fnote.js";
 | 
			
		||||
import branches from "../../../services/branches.js";
 | 
			
		||||
 | 
			
		||||
export function canReorderRows(parentNote: FNote) {
 | 
			
		||||
    return !parentNote.hasLabel("sorted")
 | 
			
		||||
        && parentNote.type !== "search";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function configureReorderingRows(tabulator: Tabulator) {
 | 
			
		||||
    tabulator.on("rowMoved", (row) => {
 | 
			
		||||
        const branchIdsToMove = [ row.getData().branchId ];
 | 
			
		||||
 | 
			
		||||
        const prevRow = row.getPrevRow();
 | 
			
		||||
        if (prevRow) {
 | 
			
		||||
            branches.moveAfterBranch(branchIdsToMove, prevRow.getData().branchId);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const nextRow = row.getNextRow();
 | 
			
		||||
        if (nextRow) {
 | 
			
		||||
            branches.moveBeforeBranch(branchIdsToMove, nextRow.getData().branchId);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								apps/client/src/widgets/view_widgets/table_view/footer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								apps/client/src/widgets/view_widgets/table_view/footer.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import FNote from "../../../entities/fnote.js";
 | 
			
		||||
import { t } from "../../../services/i18n.js";
 | 
			
		||||
 | 
			
		||||
function shouldDisplayFooter(parentNote: FNote) {
 | 
			
		||||
    return (parentNote.type !== "search");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function buildFooter(parentNote: FNote) {
 | 
			
		||||
    if (!shouldDisplayFooter(parentNote)) {
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return /*html*/`\
 | 
			
		||||
        <button class="btn btn-sm" data-trigger-command="addNewRow">
 | 
			
		||||
            <span class="bx bx-plus"></span> ${t("table_view.new-row")}
 | 
			
		||||
        </button>
 | 
			
		||||
 | 
			
		||||
        <button class="btn btn-sm" data-trigger-command="addNewTableColumn">
 | 
			
		||||
            <span class="bx bx-carousel"></span> ${t("table_view.new-column")}
 | 
			
		||||
        </button>
 | 
			
		||||
    `.trimStart();
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,89 @@
 | 
			
		||||
import { CellComponent } from "tabulator-tables";
 | 
			
		||||
import froca from "../../../services/froca.js";
 | 
			
		||||
import FNote from "../../../entities/fnote.js";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Custom formatter to represent a note, with the icon and note title being rendered.
 | 
			
		||||
 *
 | 
			
		||||
 * The value of the cell must be the note ID.
 | 
			
		||||
 */
 | 
			
		||||
export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered): string {
 | 
			
		||||
    let noteId = cell.getValue();
 | 
			
		||||
    if (!noteId) {
 | 
			
		||||
        return "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function buildLink(note: FNote | undefined) {
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const iconClass = note.getIcon();
 | 
			
		||||
        const title = note.title;
 | 
			
		||||
        const { $noteRef } = buildNoteLink(noteId, title, iconClass, note.getColorClass());
 | 
			
		||||
        return $noteRef[0];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const cachedNote = froca.getNoteFromCache(noteId);
 | 
			
		||||
    if (cachedNote) {
 | 
			
		||||
        // Cache hit, build the link immediately
 | 
			
		||||
        const el = buildLink(cachedNote);
 | 
			
		||||
        return el?.outerHTML ?? "";
 | 
			
		||||
    } else {
 | 
			
		||||
        // Cache miss, load the note asynchronously
 | 
			
		||||
        onRendered(async () => {
 | 
			
		||||
            const note = await froca.getNote(noteId);
 | 
			
		||||
            if (!note) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const el = buildLink(note);
 | 
			
		||||
            if (el) {
 | 
			
		||||
                cell.getElement().appendChild(el);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return "";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Custom formatter for the note title that is quite similar to {@link NoteFormatter}, but where the title and icons are read from separate fields.
 | 
			
		||||
 */
 | 
			
		||||
export function NoteTitleFormatter(cell: CellComponent) {
 | 
			
		||||
    const { noteId, iconClass, colorClass } = cell.getRow().getData();
 | 
			
		||||
    if (!noteId) {
 | 
			
		||||
        return "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { $noteRef } = buildNoteLink(noteId, cell.getValue(), iconClass, colorClass);
 | 
			
		||||
    return $noteRef[0].outerHTML;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RowNumberFormatter(draggableRows: boolean) {
 | 
			
		||||
    return (cell: CellComponent) => {
 | 
			
		||||
        let html = "";
 | 
			
		||||
        if (draggableRows) {
 | 
			
		||||
            html += `<span class="bx bx-dots-vertical-rounded"></span> `;
 | 
			
		||||
        }
 | 
			
		||||
        html += cell.getRow().getPosition(true);
 | 
			
		||||
        return html;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function MonospaceFormatter(cell: CellComponent) {
 | 
			
		||||
    return `<code>${cell.getValue()}</code>`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) {
 | 
			
		||||
    const $noteRef = $("<span>");
 | 
			
		||||
    const href = `#root/${noteId}`;
 | 
			
		||||
    $noteRef.addClass("reference-link");
 | 
			
		||||
    $noteRef.attr("data-href", href);
 | 
			
		||||
    $noteRef.text(title);
 | 
			
		||||
    $noteRef.prepend($("<span>").addClass(iconClass));
 | 
			
		||||
    if (colorClass) {
 | 
			
		||||
        $noteRef.addClass(colorClass);
 | 
			
		||||
    }
 | 
			
		||||
    return { $noteRef, href };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										272
									
								
								apps/client/src/widgets/view_widgets/table_view/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								apps/client/src/widgets/view_widgets/table_view/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,272 @@
 | 
			
		||||
import ViewMode, { type ViewModeArgs } from "../view_mode.js";
 | 
			
		||||
import attributes from "../../../services/attributes.js";
 | 
			
		||||
import SpacedUpdate from "../../../services/spaced_update.js";
 | 
			
		||||
import type { EventData } from "../../../components/app_context.js";
 | 
			
		||||
import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent, ColumnComponent} from 'tabulator-tables';
 | 
			
		||||
import "tabulator-tables/dist/css/tabulator.css";
 | 
			
		||||
import "../../../../src/stylesheets/table.css";
 | 
			
		||||
import { canReorderRows, configureReorderingRows } from "./dragging.js";
 | 
			
		||||
import buildFooter from "./footer.js";
 | 
			
		||||
import getAttributeDefinitionInformation, { buildRowDefinitions } from "./rows.js";
 | 
			
		||||
import { AttributeDefinitionInformation, buildColumnDefinitions } from "./columns.js";
 | 
			
		||||
import { setupContextMenu } from "./context_menu.js";
 | 
			
		||||
import TableColumnEditing from "./col_editing.js";
 | 
			
		||||
import TableRowEditing from "./row_editing.js";
 | 
			
		||||
 | 
			
		||||
const TPL = /*html*/`
 | 
			
		||||
<div class="table-view">
 | 
			
		||||
    <style>
 | 
			
		||||
    .table-view {
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        position: relative;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
        padding: 0 5px 0 10px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .table-view-container {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .search-result-widget-content .table-view {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator-cell .autocomplete {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 50%;
 | 
			
		||||
        transform: translateY(-50%);
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        outline: none !important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator .tabulator-header {
 | 
			
		||||
        border-top: unset;
 | 
			
		||||
        border-bottom-width: 1px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator .tabulator-header .tabulator-frozen.tabulator-frozen-left,
 | 
			
		||||
    .tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left {
 | 
			
		||||
        border-right-width: 1px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator .tabulator-footer {
 | 
			
		||||
        background-color: unset;
 | 
			
		||||
        padding: 5px 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator .tabulator-footer .tabulator-footer-contents {
 | 
			
		||||
        justify-content: left;
 | 
			
		||||
        gap: 0.5em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator button.tree-expand,
 | 
			
		||||
    .tabulator button.tree-collapse {
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
        appearance: none;
 | 
			
		||||
        border: 0;
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        width: 1.5em;
 | 
			
		||||
        position: relative;
 | 
			
		||||
        vertical-align: middle;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .tabulator button.tree-expand span,
 | 
			
		||||
    .tabulator button.tree-collapse span {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        font-size: 1.5em;
 | 
			
		||||
        transform: translateY(-50%);
 | 
			
		||||
    }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <div class="table-view-container"></div>
 | 
			
		||||
</div>
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
export interface StateInfo {
 | 
			
		||||
    tableData?: {
 | 
			
		||||
        columns?: ColumnDefinition[];
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class TableView extends ViewMode<StateInfo> {
 | 
			
		||||
 | 
			
		||||
    private $root: JQuery<HTMLElement>;
 | 
			
		||||
    private $container: JQuery<HTMLElement>;
 | 
			
		||||
    private spacedUpdate: SpacedUpdate;
 | 
			
		||||
    private api?: Tabulator;
 | 
			
		||||
    private persistentData: StateInfo["tableData"];
 | 
			
		||||
    private colEditing?: TableColumnEditing;
 | 
			
		||||
    private rowEditing?: TableRowEditing;
 | 
			
		||||
    private maxDepth: number = -1;
 | 
			
		||||
    private rowNumberHint: number = 1;
 | 
			
		||||
 | 
			
		||||
    constructor(args: ViewModeArgs) {
 | 
			
		||||
        super(args, "table");
 | 
			
		||||
 | 
			
		||||
        this.$root = $(TPL);
 | 
			
		||||
        this.$container = this.$root.find(".table-view-container");
 | 
			
		||||
        this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
 | 
			
		||||
        this.persistentData = {};
 | 
			
		||||
        args.$parent.append(this.$root);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderList() {
 | 
			
		||||
        this.$container.empty();
 | 
			
		||||
        this.renderTable(this.$container[0]);
 | 
			
		||||
        return this.$root;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async renderTable(el: HTMLElement) {
 | 
			
		||||
        const info = getAttributeDefinitionInformation(this.parentNote);
 | 
			
		||||
        const modules = [ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ];
 | 
			
		||||
        for (const module of modules) {
 | 
			
		||||
            Tabulator.registerModule(module);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.initialize(el, info);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async initialize(el: HTMLElement, info: AttributeDefinitionInformation[]) {
 | 
			
		||||
        const viewStorage = await this.viewStorage.restore();
 | 
			
		||||
        this.persistentData = viewStorage?.tableData || {};
 | 
			
		||||
 | 
			
		||||
        this.maxDepth = parseInt(this.parentNote.getLabelValue("maxNestingDepth") ?? "-1", 10);
 | 
			
		||||
        const { definitions: rowData, hasSubtree: hasChildren, rowNumber } = await buildRowDefinitions(this.parentNote, info, this.maxDepth);
 | 
			
		||||
        this.rowNumberHint = rowNumber;
 | 
			
		||||
        const movableRows = canReorderRows(this.parentNote) && !hasChildren;
 | 
			
		||||
        const columnDefs = buildColumnDefinitions({
 | 
			
		||||
            info,
 | 
			
		||||
            movableRows,
 | 
			
		||||
            existingColumnData: this.persistentData.columns,
 | 
			
		||||
            rowNumberHint: this.rowNumberHint
 | 
			
		||||
        });
 | 
			
		||||
        let opts: Options = {
 | 
			
		||||
            layout: "fitDataFill",
 | 
			
		||||
            index: "branchId",
 | 
			
		||||
            columns: columnDefs,
 | 
			
		||||
            data: rowData,
 | 
			
		||||
            persistence: true,
 | 
			
		||||
            movableColumns: true,
 | 
			
		||||
            movableRows,
 | 
			
		||||
            footerElement: buildFooter(this.parentNote),
 | 
			
		||||
            persistenceWriterFunc: (_id, type: string, data: object) => {
 | 
			
		||||
                (this.persistentData as Record<string, {}>)[type] = data;
 | 
			
		||||
                this.spacedUpdate.scheduleUpdate();
 | 
			
		||||
            },
 | 
			
		||||
            persistenceReaderFunc: (_id, type: string) => this.persistentData?.[type],
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (hasChildren) {
 | 
			
		||||
            opts = {
 | 
			
		||||
                ...opts,
 | 
			
		||||
                dataTree: hasChildren,
 | 
			
		||||
                dataTreeStartExpanded: true,
 | 
			
		||||
                dataTreeBranchElement: false,
 | 
			
		||||
                dataTreeElementColumn: "title",
 | 
			
		||||
                dataTreeChildIndent: 20,
 | 
			
		||||
                dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
 | 
			
		||||
                dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.api = new Tabulator(el, opts);
 | 
			
		||||
 | 
			
		||||
        this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api);
 | 
			
		||||
        this.rowEditing = new TableRowEditing(this.api, this.args.parentNotePath!);
 | 
			
		||||
 | 
			
		||||
        if (movableRows) {
 | 
			
		||||
            configureReorderingRows(this.api);
 | 
			
		||||
        }
 | 
			
		||||
        setupContextMenu(this.api, this.parentNote);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private onSave() {
 | 
			
		||||
        this.viewStorage.store({
 | 
			
		||||
            tableData: this.persistentData,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
 | 
			
		||||
        if (!this.api) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Force a refresh if sorted is changed since we need to disable reordering.
 | 
			
		||||
        if (loadResults.getAttributeRows().find(a => a.name === "sorted" && attributes.isAffecting(a, this.parentNote))) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Refresh if promoted attributes get changed.
 | 
			
		||||
        if (loadResults.getAttributeRows().find(attr =>
 | 
			
		||||
            attr.type === "label" &&
 | 
			
		||||
            (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) &&
 | 
			
		||||
            attributes.isAffecting(attr, this.parentNote))) {
 | 
			
		||||
            this.#manageColumnUpdate();
 | 
			
		||||
            return await this.#manageRowsUpdate();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Refresh max depth
 | 
			
		||||
        if (loadResults.getAttributeRows().find(attr => attr.type === "label" && attr.name === "maxNestingDepth" && attributes.isAffecting(attr, this.parentNote))) {
 | 
			
		||||
            this.maxDepth = parseInt(this.parentNote.getLabelValue("maxNestingDepth") ?? "-1", 10);
 | 
			
		||||
            return await this.#manageRowsUpdate();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))
 | 
			
		||||
            || loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))
 | 
			
		||||
            || loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!))) {
 | 
			
		||||
            return await this.#manageRowsUpdate();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #manageColumnUpdate() {
 | 
			
		||||
        if (!this.api) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const info = getAttributeDefinitionInformation(this.parentNote);
 | 
			
		||||
        const columnDefs = buildColumnDefinitions({
 | 
			
		||||
            info,
 | 
			
		||||
            movableRows: !!this.api.options.movableRows,
 | 
			
		||||
            existingColumnData: this.persistentData?.columns,
 | 
			
		||||
            rowNumberHint: this.rowNumberHint,
 | 
			
		||||
            position: this.colEditing?.getNewAttributePosition()
 | 
			
		||||
        });
 | 
			
		||||
        this.api.setColumns(columnDefs);
 | 
			
		||||
        this.colEditing?.resetNewAttributePosition();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addNewRowCommand(e) { this.rowEditing?.addNewRowCommand(e); }
 | 
			
		||||
    addNewTableColumnCommand(e) { this.colEditing?.addNewTableColumnCommand(e); }
 | 
			
		||||
    deleteTableColumnCommand(e) { this.colEditing?.deleteTableColumnCommand(e); }
 | 
			
		||||
    updateAttributeListCommand(e) { this.colEditing?.updateAttributeListCommand(e); }
 | 
			
		||||
    saveAttributesCommand() { this.colEditing?.saveAttributesCommand(); }
 | 
			
		||||
 | 
			
		||||
    async #manageRowsUpdate() {
 | 
			
		||||
        if (!this.api) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const info = getAttributeDefinitionInformation(this.parentNote);
 | 
			
		||||
        const { definitions, hasSubtree, rowNumber } = await buildRowDefinitions(this.parentNote, info, this.maxDepth);
 | 
			
		||||
        this.rowNumberHint = rowNumber;
 | 
			
		||||
 | 
			
		||||
        // Force a refresh if the data tree needs enabling/disabling.
 | 
			
		||||
        if (this.api.options.dataTree !== hasSubtree) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.api.replaceData(definitions);
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,56 @@
 | 
			
		||||
import { CellComponent } from "tabulator-tables";
 | 
			
		||||
import note_autocomplete from "../../../services/note_autocomplete";
 | 
			
		||||
import froca from "../../../services/froca";
 | 
			
		||||
 | 
			
		||||
export function RelationEditor(cell: CellComponent, onRendered, success, cancel, editorParams){
 | 
			
		||||
    //cell - the cell component for the editable cell
 | 
			
		||||
    //onRendered - function to call when the editor has been rendered
 | 
			
		||||
    //success - function to call to pass thesuccessfully updated value to Tabulator
 | 
			
		||||
    //cancel - function to call to abort the edit and return to a normal cell
 | 
			
		||||
    //editorParams - params object passed into the editorParams column definition property
 | 
			
		||||
 | 
			
		||||
    //create and style editor
 | 
			
		||||
    const editor = document.createElement("input");
 | 
			
		||||
 | 
			
		||||
    const $editor = $(editor);
 | 
			
		||||
    editor.classList.add("form-control");
 | 
			
		||||
 | 
			
		||||
    //create and style input
 | 
			
		||||
    editor.style.padding = "3px";
 | 
			
		||||
    editor.style.width = "100%";
 | 
			
		||||
    editor.style.boxSizing = "border-box";
 | 
			
		||||
 | 
			
		||||
    //Set value of editor to the current value of the cell
 | 
			
		||||
    const originalNoteId = cell.getValue();
 | 
			
		||||
    if (originalNoteId) {
 | 
			
		||||
        const note = froca.getNoteFromCache(originalNoteId);
 | 
			
		||||
        editor.value = note.title;
 | 
			
		||||
    } else {
 | 
			
		||||
        editor.value = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //set focus on the select box when the editor is selected
 | 
			
		||||
    onRendered(function(){
 | 
			
		||||
        let newNoteId = originalNoteId;
 | 
			
		||||
 | 
			
		||||
        note_autocomplete.initNoteAutocomplete($editor, {
 | 
			
		||||
            allowCreatingNotes: true,
 | 
			
		||||
            hideAllButtons: true
 | 
			
		||||
        }).on("autocomplete:noteselected", (event, suggestion, dataset) => {
 | 
			
		||||
            const notePath = suggestion.notePath;
 | 
			
		||||
            newNoteId = (notePath ?? "").split("/").at(-1);
 | 
			
		||||
        }).on("blur", () => {
 | 
			
		||||
            if (!editor.value) {
 | 
			
		||||
                newNoteId = "";
 | 
			
		||||
            }
 | 
			
		||||
            success(newNoteId);
 | 
			
		||||
        });
 | 
			
		||||
        editor.focus();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const container = document.createElement("div");
 | 
			
		||||
    container.classList.add("input-group");
 | 
			
		||||
    container.classList.add("autocomplete");
 | 
			
		||||
    container.appendChild(editor);
 | 
			
		||||
    return container;
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,97 @@
 | 
			
		||||
import { RowComponent, Tabulator } from "tabulator-tables";
 | 
			
		||||
import Component from "../../../components/component.js";
 | 
			
		||||
import { setAttribute, setLabel } from "../../../services/attributes.js";
 | 
			
		||||
import server from "../../../services/server.js";
 | 
			
		||||
import froca from "../../../services/froca.js";
 | 
			
		||||
import note_create, { CreateNoteOpts } from "../../../services/note_create.js";
 | 
			
		||||
import { CommandListenerData } from "../../../components/app_context.js";
 | 
			
		||||
 | 
			
		||||
export default class TableRowEditing extends Component {
 | 
			
		||||
 | 
			
		||||
    private parentNotePath: string;
 | 
			
		||||
    private api: Tabulator;
 | 
			
		||||
 | 
			
		||||
    constructor(api: Tabulator, parentNotePath: string) {
 | 
			
		||||
        super();
 | 
			
		||||
        this.api = api;
 | 
			
		||||
        this.parentNotePath = parentNotePath;
 | 
			
		||||
        api.on("cellEdited", async (cell) => {
 | 
			
		||||
            const noteId = cell.getRow().getData().noteId;
 | 
			
		||||
            const field = cell.getField();
 | 
			
		||||
            let newValue = cell.getValue();
 | 
			
		||||
 | 
			
		||||
            if (field === "title") {
 | 
			
		||||
                server.put(`notes/${noteId}/title`, { title: newValue });
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (field.includes(".")) {
 | 
			
		||||
                const [ type, name ] = field.split(".", 2);
 | 
			
		||||
                if (type === "labels") {
 | 
			
		||||
                    if (typeof newValue === "boolean") {
 | 
			
		||||
                        newValue = newValue ? "true" : "false";
 | 
			
		||||
                    }
 | 
			
		||||
                    setLabel(noteId, name, newValue);
 | 
			
		||||
                } else if (type === "relations") {
 | 
			
		||||
                    const note = await froca.getNote(noteId);
 | 
			
		||||
                    if (note) {
 | 
			
		||||
                        setAttribute(note, "relation", name, newValue);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
 | 
			
		||||
        const parentNotePath = customNotePath ?? this.parentNotePath;
 | 
			
		||||
        if (parentNotePath) {
 | 
			
		||||
            const opts: CreateNoteOpts = {
 | 
			
		||||
                activate: false,
 | 
			
		||||
                ...customOpts
 | 
			
		||||
            }
 | 
			
		||||
            note_create.createNote(parentNotePath, opts).then(({ branch }) => {
 | 
			
		||||
                if (branch) {
 | 
			
		||||
                    setTimeout(() => {
 | 
			
		||||
                        this.focusOnBranch(branch?.branchId);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    focusOnBranch(branchId: string) {
 | 
			
		||||
        if (!this.api) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const row = findRowDataById(this.api.getRows(), branchId);
 | 
			
		||||
        if (!row) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Expand the parent tree if any.
 | 
			
		||||
        if (this.api.options.dataTree) {
 | 
			
		||||
            const parent = row.getTreeParent();
 | 
			
		||||
            if (parent) {
 | 
			
		||||
                parent.treeExpand();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        row.getCell("title").edit();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
 | 
			
		||||
    for (let row of rows) {
 | 
			
		||||
        const item = row.getIndex() as string;
 | 
			
		||||
 | 
			
		||||
        if (item === branchId) {
 | 
			
		||||
            return row;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let found = findRowDataById(row.getTreeChildren(), branchId);
 | 
			
		||||
        if (found) return found;
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								apps/client/src/widgets/view_widgets/table_view/rows.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								apps/client/src/widgets/view_widgets/table_view/rows.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,94 @@
 | 
			
		||||
import FNote from "../../../entities/fnote.js";
 | 
			
		||||
import type { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
 | 
			
		||||
import type { AttributeDefinitionInformation } from "./columns.js";
 | 
			
		||||
 | 
			
		||||
export type TableData = {
 | 
			
		||||
    iconClass: string;
 | 
			
		||||
    noteId: string;
 | 
			
		||||
    title: string;
 | 
			
		||||
    labels: Record<string, boolean | string | null>;
 | 
			
		||||
    relations: Record<string, boolean | string | null>;
 | 
			
		||||
    branchId: string;
 | 
			
		||||
    colorClass: string | undefined;
 | 
			
		||||
    _children?: TableData[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], maxDepth = -1, currentDepth = 0) {
 | 
			
		||||
    const definitions: TableData[] = [];
 | 
			
		||||
    const childBranches = parentNote.getChildBranches();
 | 
			
		||||
    let hasSubtree = false;
 | 
			
		||||
    let rowNumber = childBranches.length;
 | 
			
		||||
 | 
			
		||||
    for (const branch of childBranches) {
 | 
			
		||||
        const note = await branch.getNote();
 | 
			
		||||
        if (!note) {
 | 
			
		||||
            continue; // Skip if the note is not found
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const labels: typeof definitions[0]["labels"] = {};
 | 
			
		||||
        const relations: typeof definitions[0]["relations"] = {};
 | 
			
		||||
        for (const { name, type } of infos) {
 | 
			
		||||
            if (type === "relation") {
 | 
			
		||||
                relations[name] = note.getRelationValue(name);
 | 
			
		||||
            } else {
 | 
			
		||||
                labels[name] = note.getLabelValue(name);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const def: TableData = {
 | 
			
		||||
            iconClass: note.getIcon(),
 | 
			
		||||
            noteId: note.noteId,
 | 
			
		||||
            title: note.title,
 | 
			
		||||
            labels,
 | 
			
		||||
            relations,
 | 
			
		||||
            branchId: branch.branchId,
 | 
			
		||||
            colorClass: note.getColorClass()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
 | 
			
		||||
            const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, maxDepth, currentDepth + 1));
 | 
			
		||||
            def._children = definitions;
 | 
			
		||||
            hasSubtree = true;
 | 
			
		||||
            rowNumber += subRowNumber;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        definitions.push(def);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        definitions,
 | 
			
		||||
        hasSubtree,
 | 
			
		||||
        rowNumber
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function getAttributeDefinitionInformation(parentNote: FNote) {
 | 
			
		||||
    const info: AttributeDefinitionInformation[] = [];
 | 
			
		||||
    const attrDefs = parentNote.getAttributes()
 | 
			
		||||
        .filter(attr => attr.isDefinition());
 | 
			
		||||
    for (const attrDef of attrDefs) {
 | 
			
		||||
        const def = attrDef.getDefinition();
 | 
			
		||||
        if (def.multiplicity !== "single") {
 | 
			
		||||
            console.warn("Multiple values are not supported for now");
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const [ labelType, name ] = attrDef.name.split(":", 2);
 | 
			
		||||
        if (attrDef.type !== "label") {
 | 
			
		||||
            console.warn("Relations are not supported for now");
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let type: LabelType | "relation" = def.labelType || "text";
 | 
			
		||||
        if (labelType === "relation") {
 | 
			
		||||
            type = "relation";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        info.push({
 | 
			
		||||
            name,
 | 
			
		||||
            title: def.promotedAlias,
 | 
			
		||||
            type
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    return info;
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user