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