A photo manager for VRChat.
at main 21 kB view raw
1import { For, Show, createEffect, onCleanup, onMount } from "solid-js"; 2import { invoke } from '@tauri-apps/api/core'; 3import { WorldCache } from "./Structs/WorldCache"; 4import { animate, 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 let resizeImage = () => { 154 let dstWidth; 155 let dstHeight; 156 157 let imgHeight = imageViewer.naturalHeight; 158 let imgWidth = imageViewer.naturalWidth; 159 160 if( 161 imgWidth / window.innerWidth < 162 imgHeight / window.innerHeight 163 ) { 164 dstWidth = imgWidth * (window.innerHeight / imgHeight); 165 dstHeight = window.innerHeight; 166 } else{ 167 dstWidth = window.innerWidth; 168 dstHeight = imgHeight * (window.innerWidth / imgWidth); 169 } 170 171 imageViewer.style.width = dstWidth + 'px'; 172 imageViewer.style.height = dstHeight + 'px'; 173 } 174 175 onMount(() => { 176 utils.set(photoControls, { translateX: '-50%' }); 177 utils.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' }); 178 utils.set(photoLayerManager, { translateY: '20px', opacity: 0, display: 'none' }); 179 180 window.addEventListener('keyup', switchPhotoWithKey); 181 window.addEventListener('resize', () => resizeImage()); 182 183 let contextMenuOpen = false; 184 window.CloseAllPopups.push(() => { 185 contextMenuOpen = false; 186 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' }); 187 188 animate(viewerContextMenu, { 189 opacity: 0, 190 easing: 'easeInOutQuad', 191 rotate: '30deg', 192 duration: 100, 193 onComplete: () => { 194 viewerContextMenu.style.display = 'none'; 195 } 196 }) 197 }); 198 199 window.CloseAllPopups.push(() => { 200 layerManagerOpen = false; 201 if(layerManagerAnimation)layerManagerAnimation.cancel(); 202 203 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) }); 204 }); 205 206 viewerContextMenuButtons[0].onclick = async () => { 207 window.CloseAllPopups.forEach(p => p()); 208 // Context Menu -> Open file location 209 210 let path = await invoke('get_user_photos_path') + '\\' + window.PhotoViewerManager.CurrentPhoto()?.path; 211 invoke('open_folder', { url: path }); 212 } 213 214 viewerContextMenuButtons[1].onclick = () => { 215 window.CloseAllPopups.forEach(p => p()); 216 // Context Menu -> Copy image 217 copyImage(); 218 } 219 220 imageViewer.oncontextmenu = ( e ) => { 221 if(contextMenuOpen){ 222 contextMenuOpen = false; 223 224 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' }); 225 226 animate(viewerContextMenu, { 227 opacity: 0, 228 rotate: '30deg', 229 easing: 'easeInOutQuad', 230 duration: 100, 231 onComplete: () => { 232 viewerContextMenu.style.display = 'none'; 233 } 234 }) 235 } else{ 236 contextMenuOpen = true; 237 238 viewerContextMenu.style.top = e.clientY + 'px'; 239 viewerContextMenu.style.left = e.clientX + 'px'; 240 viewerContextMenu.style.display = 'block'; 241 242 utils.set(viewerContextMenu, { opacity: 0, rotate: '-30deg' }); 243 244 animate(viewerContextMenu, { 245 opacity: 1, 246 rotate: '0deg', 247 easing: 'easeInOutQuad', 248 duration: 100 249 }) 250 } 251 } 252 253 createEffect(() => { 254 let photo = window.PhotoViewerManager.CurrentPhoto(); 255 allowedToOpenTray = false; 256 257 imageViewer.style.opacity = '0'; 258 259 if(photo){ 260 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + window.PhotoViewerManager.CurrentPhoto()?.path.split('\\').join('/') + "?full"; 261 imageViewer.crossOrigin = 'anonymous'; 262 263 imageViewer.onload = () => { resizeImage(); } 264 265 animate(imageViewer, { 266 opacity: 1, 267 delay: 50, 268 duration: 150, 269 easing: 'easeInOutQuad' 270 }) 271 272 let handleMetaDataLoaded = () => { 273 console.log(photo.metadata); 274 if(photo.metadata){ 275 photo.onMetaLoaded = () => {} 276 277 try{ 278 // Try JSON format ( VRCX ) 279 let meta = JSON.parse(photo.metadata); 280 281 allowedToOpenTray = true; 282 trayButton.style.display = 'flex'; 283 284 authorProfileButton!.style.display = 'none'; 285 286 photoTray.innerHTML = ''; 287 photoTray.appendChild( 288 <div class="photo-tray-columns"> 289 <div class="photo-tray-column" style={{ width: '20%' }}><br /> 290 <div class="tray-heading">People</div> 291 292 <For each={meta.players}> 293 {( item ) => 294 <div> 295 { item.displayName } 296 <Show when={item.id}> 297 <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' }} /> 298 </Show> 299 </div> 300 } 301 </For><br /> 302 </div> 303 <div class="photo-tray-column"><br /> 304 <div class="tray-heading">World</div> 305 306 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 307 </div> 308 </div> as Node 309 ); 310 311 window.WorldCacheManager.getWorldById(meta.world.id) 312 .then(worldData => { 313 if(worldData) 314 loadWorldData(worldData); 315 }); 316 } catch(e){ 317 try{ 318 // Not json lets try XML (vrc prints) 319 let parser = new DOMParser(); 320 let doc = parser.parseFromString(photo.metadata, "text/xml"); 321 322 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML; 323 324 authorProfileButton!.style.display = 'flex'; 325 authorProfileButton!.onclick = () => { 326 console.log(id); 327 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id }); 328 } 329 } catch(e){ 330 console.error(e); 331 console.log('Couldn\'t decode metadata') 332 333 authorProfileButton!.style.display = 'none'; 334 } 335 336 trayButton.style.display = 'none'; 337 closeTray(); 338 } 339 } else{ 340 trayButton.style.display = 'none'; 341 authorProfileButton!.style.display = 'none'; 342 343 closeTray(); 344 } 345 } 346 347 handleMetaDataLoaded(); 348 } 349 350 if(photo && !isOpen){ 351 viewer.style.display = 'flex'; 352 353 animate(viewer, { 354 opacity: 1, 355 easing: 'easeInOutQuad', 356 duration: 150 357 }); 358 359 utils.set('.prev-button', { left: '-50px', top: '50%' }); 360 utils.set('.next-button', { right: '-50px', top: '50%' }); 361 362 animate('.prev-button', { left: '0', easing: 'easeInOutQuad', duration: 100 }); 363 animate('.next-button', { right: '0', easing: 'easeInOutQuad', duration: 100 }); 364 365 window.CloseAllPopups.forEach(p => p()); 366 } else if(!photo && isOpen){ 367 animate(viewer, { 368 opacity: 0, 369 easing: 'easeInOutQuad', 370 duration: 150, 371 onComplete: () => { 372 viewer.style.display = 'none'; 373 } 374 }); 375 376 window.CloseAllPopups.forEach(p => p()); 377 378 animate('.prev-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 }); 379 animate('.next-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 }); 380 } 381 382 isOpen = photo != null; 383 }) 384 }) 385 386 onCleanup(() => { 387 window.removeEventListener('keyup', switchPhotoWithKey); 388 }) 389 390 let loadWorldData = ( data: WorldCache ) => { 391 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata; 392 if(!meta)return; 393 394 worldInfoContainer.innerHTML = ''; 395 worldInfoContainer.appendChild( 396 <div> 397 <Show when={ data.worldData.found == false && meta }> 398 <div> 399 <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> 400 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div> 401 </div> 402 </Show> 403 <Show when={ data.worldData.found == true }> 404 <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> 405 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div> 406 407 <br /> 408 <div class="world-tags"> 409 <For each={data.worldData.tags}> 410 {( tag ) => 411 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div> 412 } 413 </For> 414 </div><br /> 415 </Show> 416 </div> as Node 417 ) 418 } 419 420 enum LayerManagerView{ 421 DEFAULT, 422 PLAYER, 423 ENVIRONMENT 424 } 425 426 let layerManagerOpen = false; 427 let layerManagerAnimation: null | JSAnimation = null; 428 let layerManagerViewing = LayerManagerView.DEFAULT; 429 430 let toggleLayerManager = () => { 431 if(layerManagerOpen){ 432 // Close 433 layerManagerOpen = false; 434 if(layerManagerAnimation)layerManagerAnimation.cancel(); 435 436 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) }); 437 } else{ 438 // Open 439 layerManagerOpen = true; 440 if(layerManagerAnimation)layerManagerAnimation.cancel(); 441 442 utils.set(photoLayerManager, { display: 'block' }); 443 layerManagerAnimation = animate(photoLayerManager, { translateY: '0px', opacity: 1, duration: 100 }); 444 } 445 } 446 447 return ( 448 <div class="photo-viewer" ref={( el ) => viewer = el}> 449 <div class="photo-layer-manager" ref={photoLayerManager}> 450 <Show when={window.PhotoViewerManager.CurrentPhoto()?.playerLayer}> 451 <div class="photo-layer-manager-layer" onClick={() => { 452 let photo = window.PhotoViewerManager.CurrentPhoto()?.playerLayer; 453 if(!photo)return; 454 455 layerManagerViewing = LayerManagerView.PLAYER; 456 457 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full"; 458 imageViewer.crossOrigin = 'anonymous'; 459 }}>Player Layer</div> 460 </Show> 461 <Show when={window.PhotoViewerManager.CurrentPhoto()?.environmentLayer}> 462 <div class="photo-layer-manager-layer" onClick={() => { 463 let photo = window.PhotoViewerManager.CurrentPhoto()?.environmentLayer; 464 if(!photo)return; 465 466 layerManagerViewing = LayerManagerView.ENVIRONMENT; 467 468 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full"; 469 imageViewer.crossOrigin = 'anonymous'; 470 }}>Environment Layer</div> 471 </Show> 472 <div class="photo-layer-manager-layer" onClick={() => { 473 let photo = window.PhotoViewerManager.CurrentPhoto(); 474 if(!photo)return; 475 476 layerManagerViewing = LayerManagerView.DEFAULT; 477 478 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full"; 479 imageViewer.crossOrigin = 'anonymous'; 480 }}>Default Layer</div> 481 </div> 482 483 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}> 484 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div> 485 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div> 486 </div> 487 488 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}> 489 <div class="icon-small" style={{ width: '10px', margin: '0' }}> 490 <img draggable="false" src="/icon/x-solid.svg"></img> 491 </div> 492 </div> 493 494 <div style={{ 495 width: '100%', 496 height: '100%', 497 display: 'flex', 498 "justify-content": 'center', 499 'align-items': 'center' 500 }}> 501 <img class="image-container" ref={( el ) => imageViewer = el} /> 502 </div> 503 504 <div class="prev-button" onClick={() => { 505 window.CloseAllPopups.forEach(p => p()); 506 window.PhotoViewerManager.PreviousPhoto(); 507 }}> 508 <div class="icon-small" style={{ width: '15px', margin: '0' }}> 509 <img draggable="false" src="/icon/arrow-left-solid.svg"></img> 510 </div> 511 </div> 512 513 <div class="next-button" onClick={() => { 514 window.CloseAllPopups.forEach(p => p()); 515 window.PhotoViewerManager.NextPhoto(); 516 }}> 517 <div class="icon-small" style={{ width: '15px', margin: '0' }}> 518 <img draggable="false" src="/icon/arrow-right-solid.svg"></img> 519 </div> 520 </div> 521 522 <div class="photo-tray" ref={( el ) => photoTray = el}></div> 523 524 <div class="photo-tray-close" 525 onClick={() => closeTray()} 526 ref={( el ) => photoTrayCloseBtn = el} 527 > 528 <div class="icon-small" style={{ width: '12px', margin: '0' }}> 529 <img draggable="false" src="/icon/angle-down-solid.svg"></img> 530 </div> 531 </div> 532 533 <div class="control-buttons" ref={( el ) => photoControls = el}> 534 <div class="viewer-button" 535 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 536 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 537 onClick={() => { copyImage(); }}> 538 <div class="icon-small" style={{ width: '12px', margin: '0' }}> 539 <img draggable="false" src="/icon/copy-solid.svg"></img> 540 </div> 541 </div> 542 <div class="viewer-button" style={{ width: '50px' }} 543 onMouseOver={( el ) => animate(el.currentTarget, { width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })} 544 onMouseLeave={( el ) => animate(el.currentTarget, { width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })} 545 ref={( el ) => trayButton = el} 546 onClick={() => openTray()} 547 > 548 <div class="icon-small" style={{ width: '12px', margin: '0' }}> 549 <img draggable="false" src="/icon/angle-up-solid.svg"></img> 550 </div> 551 </div> 552 553 <div class="viewer-button" 554 ref={authorProfileButton!} 555 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 556 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 557 > 558 <div class="icon-small" style={{ width: '12px', margin: '0' }}> 559 <img draggable="false" src="/icon/user-solid.svg"></img> 560 </div> 561 </div> 562 563 <Show when={window.PhotoViewerManager.CurrentPhoto()?.isMultiLayer}> 564 <div class="viewer-button" 565 onClick={toggleLayerManager} 566 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 567 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 568 > 569 <div class="icon-small" style={{ width: '17px', margin: '0' }}> 570 <img draggable="false" src="/icon/layer-group-solid-full.svg"></img> 571 </div> 572 </div> 573 </Show> 574 575 <div class="viewer-button" 576 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 577 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 578 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => { 579 let photo = window.PhotoViewerManager.CurrentPhoto(); 580 if(!photo)return; 581 582 invoke("delete_photo", { path: photo.path }); 583 584 if(photo.playerLayer)invoke("delete_photo", { path: photo.playerLayer.path }); 585 if(photo.environmentLayer)invoke("delete_photo", { path: photo.environmentLayer.path }); 586 })}> 587 <div class="icon-small" style={{ width: '12px', margin: '0' }}> 588 <img draggable="false" src="/icon/trash-solid.svg"></img> 589 </div> 590 </div> 591 </div> 592 </div> 593 ) 594} 595 596export default PhotoViewer