A photo manager for VRChat.
1import { For, Show, createEffect, onCleanup, onMount } from "solid-js"; 2import { invoke } from '@tauri-apps/api/core'; 3import anime from 'animejs'; 4import { WorldCache } from "./Structs/WorldCache"; 5 6let PhotoViewer = () => { 7 let viewer: HTMLElement; 8 let imageViewer: HTMLImageElement; 9 let isOpen = false; 10 let trayOpen = false; 11 12 let trayButton: HTMLElement; 13 14 let photoTray: HTMLElement; 15 let photoControls: HTMLElement; 16 let photoTrayCloseBtn: HTMLElement; 17 18 let worldInfoContainer: HTMLElement; 19 20 let viewerContextMenu: HTMLElement; 21 let viewerContextMenuButtons: HTMLElement[] = []; 22 23 let allowedToOpenTray = false; 24 let trayInAnimation = false; 25 26 let authorProfileButton: HTMLDivElement; 27 28 let switchPhotoWithKey = ( e: KeyboardEvent ) => { 29 switch(e.key){ 30 case 'Escape': 31 window.PhotoViewerManager.Close(); 32 33 break; 34 case 'ArrowUp': 35 if(allowedToOpenTray) 36 openTray(); 37 38 break; 39 case 'ArrowDown': 40 closeTray(); 41 break; 42 case 'ArrowLeft': 43 window.CloseAllPopups.forEach(p => p()); 44 window.PhotoViewerManager.PreviousPhoto(); 45 46 break; 47 case 'ArrowRight': 48 window.CloseAllPopups.forEach(p => p()); 49 window.PhotoViewerManager.NextPhoto(); 50 51 break; 52 } 53 } 54 55 let openTray = () => { 56 if(trayOpen || trayInAnimation)return; 57 58 trayOpen = true; 59 trayInAnimation = true; 60 61 window.CloseAllPopups.forEach(p => p()); 62 anime({ targets: photoTray, bottom: '0px', duration: 500 }); 63 64 anime({ 65 targets: photoControls, 66 bottom: '160px', 67 scale: '0.75', 68 opacity: 0, 69 duration: 500, 70 complete: () => { 71 photoControls.style.display = 'none'; 72 trayInAnimation = false; 73 } 74 }); 75 76 photoTrayCloseBtn.style.display = 'flex'; 77 anime({ 78 targets: photoTrayCloseBtn, 79 bottom: '160px', 80 opacity: 1, 81 scale: 1, 82 duration: 500 83 }) 84 } 85 86 let copyImage = () => { 87 let canvas = document.createElement('canvas'); 88 let ctx = canvas.getContext('2d')!; 89 90 canvas.width = window.PhotoViewerManager.CurrentPhoto()?.width || 0; 91 canvas.height = window.PhotoViewerManager.CurrentPhoto()?.height || 0; 92 93 ctx.drawImage(imageViewer, 0, 0); 94 95 canvas.toBlob(( blob ) => { 96 navigator.clipboard.write([ 97 new ClipboardItem({ 98 'image/png': blob! 99 }) 100 ]); 101 102 canvas.remove(); 103 104 anime.set('.copy-notif', { translateX: '-50%', translateY: '-100px' }); 105 anime({ 106 targets: '.copy-notif', 107 opacity: 1, 108 translateY: '0px' 109 }); 110 111 setTimeout(() => { 112 anime({ 113 targets: '.copy-notif', 114 opacity: 0, 115 translateY: '-100px' 116 }); 117 }, 2000); 118 }); 119 } 120 121 let closeTray = () => { 122 if(!trayOpen || trayInAnimation)return; 123 trayInAnimation = true; 124 125 window.CloseAllPopups.forEach(p => p()); 126 anime({ targets: photoTray, bottom: '-150px', duration: 500 }); 127 128 anime({ 129 targets: photoTrayCloseBtn, 130 bottom: '10px', 131 scale: '0.75', 132 opacity: 0, 133 duration: 500, 134 complete: () => { 135 photoTrayCloseBtn.style.display = 'none'; 136 trayOpen = false; 137 trayInAnimation = false; 138 } 139 }); 140 141 photoControls.style.display = 'flex'; 142 anime({ 143 targets: photoControls, 144 bottom: '10px', 145 opacity: 1, 146 scale: 1, 147 duration: 500, 148 }) 149 } 150 151 onMount(() => { 152 anime.set(photoControls, { translateX: '-50%' }); 153 anime.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' }); 154 155 window.addEventListener('keyup', switchPhotoWithKey); 156 157 let contextMenuOpen = false; 158 window.CloseAllPopups.push(() => { 159 contextMenuOpen = false; 160 anime.set(viewerContextMenu, { opacity: 1, rotate: '0deg' }); 161 162 anime({ 163 targets: viewerContextMenu, 164 opacity: 0, 165 easing: 'easeInOutQuad', 166 rotate: '30deg', 167 duration: 100, 168 complete: () => { 169 viewerContextMenu.style.display = 'none'; 170 } 171 }) 172 }); 173 174 viewerContextMenuButtons[0].onclick = async () => { 175 window.CloseAllPopups.forEach(p => p()); 176 // Context Menu -> Open file location 177 178 let path = await invoke('get_user_photos_path') + '\\' + window.PhotoViewerManager.CurrentPhoto()?.path; 179 invoke('open_folder', { url: path }); 180 } 181 182 viewerContextMenuButtons[1].onclick = () => { 183 window.CloseAllPopups.forEach(p => p()); 184 // Context Menu -> Copy image 185 copyImage(); 186 } 187 188 imageViewer.oncontextmenu = ( e ) => { 189 if(contextMenuOpen){ 190 contextMenuOpen = false; 191 192 anime.set(viewerContextMenu, { opacity: 1, rotate: '0deg' }); 193 194 anime({ 195 targets: viewerContextMenu, 196 opacity: 0, 197 rotate: '30deg', 198 easing: 'easeInOutQuad', 199 duration: 100, 200 complete: () => { 201 viewerContextMenu.style.display = 'none'; 202 } 203 }) 204 } else{ 205 contextMenuOpen = true; 206 207 viewerContextMenu.style.top = e.clientY + 'px'; 208 viewerContextMenu.style.left = e.clientX + 'px'; 209 viewerContextMenu.style.display = 'block'; 210 211 anime.set(viewerContextMenu, { opacity: 0, rotate: '-30deg' }); 212 213 anime({ 214 targets: viewerContextMenu, 215 opacity: 1, 216 rotate: '0deg', 217 easing: 'easeInOutQuad', 218 duration: 100 219 }) 220 } 221 } 222 223 createEffect(() => { 224 let photo = window.PhotoViewerManager.CurrentPhoto(); 225 allowedToOpenTray = false; 226 227 imageViewer.style.opacity = '0'; 228 229 if(photo){ 230 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + window.PhotoViewerManager.CurrentPhoto()?.path.split('\\').join('/') + "?full"; 231 imageViewer.crossOrigin = 'anonymous'; 232 233 anime({ 234 targets: imageViewer, 235 opacity: 1, 236 delay: 50, 237 duration: 150, 238 easing: 'easeInOutQuad' 239 }) 240 241 let handleMetaDataLoaded = () => { 242 console.log(photo.metadata); 243 if(photo.metadata){ 244 photo.onMetaLoaded = () => {} 245 246 try{ 247 // Try JSON format ( VRCX ) 248 let meta = JSON.parse(photo.metadata); 249 250 allowedToOpenTray = true; 251 trayButton.style.display = 'flex'; 252 253 authorProfileButton!.style.display = 'none'; 254 255 photoTray.innerHTML = ''; 256 photoTray.appendChild( 257 <div class="photo-tray-columns"> 258 <div class="photo-tray-column" style={{ width: '20%' }}><br /> 259 <div class="tray-heading">People</div> 260 261 <For each={meta.players}> 262 {( item ) => 263 <div> 264 { item.displayName } 265 <Show when={item.id}> 266 <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/user/' + item.id })} style={{ "margin-left": '10px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /> 267 </Show> 268 </div> 269 } 270 </For><br /> 271 </div> 272 <div class="photo-tray-column"><br /> 273 <div class="tray-heading">World</div> 274 275 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 276 </div> 277 </div> as Node 278 ); 279 280 window.WorldCacheManager.getWorldById(meta.world.id) 281 .then(worldData => { 282 if(worldData) 283 loadWorldData(worldData); 284 }); 285 } catch(e){ 286 try{ 287 // Not json lets try XML (vrc prints) 288 let parser = new DOMParser(); 289 let doc = parser.parseFromString(photo.metadata, "text/xml"); 290 291 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML; 292 293 authorProfileButton!.style.display = 'flex'; 294 authorProfileButton!.onclick = () => 295 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id }); 296 } catch(e){ 297 console.error(e); 298 console.log('Couldn\'t decode metadata') 299 300 authorProfileButton!.style.display = 'none'; 301 } 302 303 trayButton.style.display = 'none'; 304 closeTray(); 305 } 306 } else{ 307 trayButton.style.display = 'none'; 308 closeTray(); 309 } 310 } 311 312 handleMetaDataLoaded(); 313 } 314 315 if(photo && !isOpen){ 316 viewer.style.display = 'flex'; 317 318 anime({ 319 targets: viewer, 320 opacity: 1, 321 easing: 'easeInOutQuad', 322 duration: 150 323 }); 324 325 anime({ 326 targets: '.navbar', 327 top: '-50px' 328 }) 329 330 anime.set('.prev-button', { left: '-50px', top: '50%' }); 331 anime.set('.next-button', { right: '-50px', top: '50%' }); 332 333 anime({ targets: '.prev-button', left: '0', easing: 'easeInOutQuad', duration: 100 }); 334 anime({ targets: '.next-button', right: '0', easing: 'easeInOutQuad', duration: 100 }); 335 336 window.CloseAllPopups.forEach(p => p()); 337 } else if(!photo && isOpen){ 338 anime({ 339 targets: viewer, 340 opacity: 0, 341 easing: 'easeInOutQuad', 342 duration: 150, 343 complete: () => { 344 viewer.style.display = 'none'; 345 } 346 }); 347 348 anime({ 349 targets: '.navbar', 350 top: '0px' 351 }) 352 353 window.CloseAllPopups.forEach(p => p()); 354 355 anime({ targets: '.prev-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 356 anime({ targets: '.next-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 357 } 358 359 isOpen = photo != null; 360 }) 361 }) 362 363 onCleanup(() => { 364 window.removeEventListener('keyup', switchPhotoWithKey); 365 }) 366 367 let loadWorldData = ( data: WorldCache ) => { 368 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata; 369 if(!meta)return; 370 371 worldInfoContainer.innerHTML = ''; 372 worldInfoContainer.appendChild( 373 <div> 374 <Show when={ data.worldData.found == false && meta }> 375 <div> 376 <div class="world-name">{ JSON.parse(meta).world.name } <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /></div> 377 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div> 378 </div> 379 </Show> 380 <Show when={ data.worldData.found == true }> 381 <div class="world-name">{ data.worldData.name } <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /></div> 382 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div> 383 384 <br /> 385 <div class="world-tags"> 386 <For each={data.worldData.tags}> 387 {( tag ) => 388 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div> 389 } 390 </For> 391 </div><br /> 392 </Show> 393 </div> as Node 394 ) 395 } 396 397 return ( 398 <div class="photo-viewer" ref={( el ) => viewer = el}> 399 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}> 400 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div> 401 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div> 402 </div> 403 404 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}> 405 <div class="icon" style={{ width: '10px', margin: '0' }}> 406 <img draggable="false" src="/icon/x-solid.svg"></img> 407 </div> 408 </div> 409 <img class="image-container" ref={( el ) => imageViewer = el} /> 410 411 <div class="prev-button" onClick={() => { 412 window.CloseAllPopups.forEach(p => p()); 413 window.PhotoViewerManager.PreviousPhoto(); 414 }}> 415 <div class="icon" style={{ width: '15px', margin: '0' }}> 416 <img draggable="false" src="/icon/arrow-left-solid.svg"></img> 417 </div> 418 </div> 419 420 <div class="next-button" onClick={() => { 421 window.CloseAllPopups.forEach(p => p()); 422 window.PhotoViewerManager.NextPhoto(); 423 }}> 424 <div class="icon" style={{ width: '15px', margin: '0' }}> 425 <img draggable="false" src="/icon/arrow-right-solid.svg"></img> 426 </div> 427 </div> 428 429 <div class="photo-tray" ref={( el ) => photoTray = el}></div> 430 431 <div class="photo-tray-close" 432 onClick={() => closeTray()} 433 ref={( el ) => photoTrayCloseBtn = el} 434 > 435 <div class="icon" style={{ width: '12px', margin: '0' }}> 436 <img draggable="false" src="/icon/angle-down-solid.svg"></img> 437 </div> 438 </div> 439 440 <div class="control-buttons" ref={( el ) => photoControls = el}> 441 <div class="viewer-button" 442 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 443 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 444 onClick={() => { copyImage(); }}> 445 <div class="icon" style={{ width: '12px', margin: '0' }}> 446 <img draggable="false" src="/icon/copy-solid.svg"></img> 447 </div> 448 </div> 449 <div class="viewer-button" style={{ width: '50px' }} 450 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })} 451 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })} 452 ref={( el ) => trayButton = el} 453 onClick={() => openTray()} 454 > 455 <div class="icon" style={{ width: '12px', margin: '0' }}> 456 <img draggable="false" src="/icon/angle-up-solid.svg"></img> 457 </div> 458 </div> 459 460 <div class="viewer-button" 461 ref={authorProfileButton!} 462 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 463 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 464 > 465 <div class="icon" style={{ width: '12px', margin: '0' }}> 466 <img draggable="false" src="/icon/user-solid.svg"></img> 467 </div> 468 </div> 469 470 <div class="viewer-button" 471 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 472 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 473 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => { invoke("delete_photo", { 474 path: window.PhotoViewerManager.CurrentPhoto()?.path, 475 token: (await invoke('get_config_value_string', { key: 'token' })) || "none", 476 isSyncing: window.AccountManager.hasAccount() ? window.AccountManager.Storage()?.isSyncing : false 477 }); 478 })}> 479 <div class="icon" style={{ width: '12px', margin: '0' }}> 480 <img draggable="false" src="/icon/trash-solid.svg"></img> 481 </div> 482 </div> 483 </div> 484 </div> 485 ) 486} 487 488export default PhotoViewer