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 if(photo.metadata){ 243 photo.onMetaLoaded = () => {} 244 245 try{ 246 // Try JSON format ( VRCX ) 247 let meta = JSON.parse(photo.metadata); 248 249 allowedToOpenTray = true; 250 trayButton.style.display = 'flex'; 251 252 authorProfileButton!.style.display = 'none'; 253 254 photoTray.innerHTML = ''; 255 photoTray.appendChild( 256 <div class="photo-tray-columns"> 257 <div class="photo-tray-column" style={{ width: '20%' }}><br /> 258 <div class="tray-heading">People</div> 259 260 <For each={meta.players}> 261 {( item ) => 262 <div> 263 { item.displayName } 264 <Show when={item.id}> 265 <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' }} /> 266 </Show> 267 </div> 268 } 269 </For><br /> 270 </div> 271 <div class="photo-tray-column"><br /> 272 <div class="tray-heading">World</div> 273 274 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 275 </div> 276 </div> as Node 277 ); 278 279 window.WorldCacheManager.getWorldById(meta.world.id) 280 .then(worldData => { 281 if(worldData) 282 loadWorldData(worldData); 283 }); 284 } catch(e){ 285 try{ 286 // Not json lets try XML (vrc prints) 287 let parser = new DOMParser(); 288 let doc = parser.parseFromString(photo.metadata, "text/xml"); 289 290 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML; 291 292 authorProfileButton!.style.display = 'flex'; 293 authorProfileButton!.onclick = () => 294 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id }); 295 } catch(e){ 296 console.error(e); 297 console.log('Couldn\'t decode metadata') 298 299 authorProfileButton!.style.display = 'none'; 300 } 301 302 trayButton.style.display = 'none'; 303 closeTray(); 304 } 305 } else{ 306 trayButton.style.display = 'none'; 307 closeTray(); 308 } 309 } 310 311 handleMetaDataLoaded(); 312 } 313 314 if(photo && !isOpen){ 315 viewer.style.display = 'flex'; 316 317 anime({ 318 targets: viewer, 319 opacity: 1, 320 easing: 'easeInOutQuad', 321 duration: 150 322 }); 323 324 anime({ 325 targets: '.navbar', 326 top: '-50px' 327 }) 328 329 anime.set('.prev-button', { left: '-50px', top: '50%' }); 330 anime.set('.next-button', { right: '-50px', top: '50%' }); 331 332 anime({ targets: '.prev-button', left: '0', easing: 'easeInOutQuad', duration: 100 }); 333 anime({ targets: '.next-button', right: '0', easing: 'easeInOutQuad', duration: 100 }); 334 335 window.CloseAllPopups.forEach(p => p()); 336 } else if(!photo && isOpen){ 337 anime({ 338 targets: viewer, 339 opacity: 0, 340 easing: 'easeInOutQuad', 341 duration: 150, 342 complete: () => { 343 viewer.style.display = 'none'; 344 } 345 }); 346 347 anime({ 348 targets: '.navbar', 349 top: '0px' 350 }) 351 352 window.CloseAllPopups.forEach(p => p()); 353 354 anime({ targets: '.prev-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 355 anime({ targets: '.next-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 356 } 357 358 isOpen = photo != null; 359 }) 360 }) 361 362 onCleanup(() => { 363 window.removeEventListener('keyup', switchPhotoWithKey); 364 }) 365 366 let loadWorldData = ( data: WorldCache ) => { 367 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata; 368 if(!meta)return; 369 370 worldInfoContainer.innerHTML = ''; 371 worldInfoContainer.appendChild( 372 <div> 373 <Show when={ data.worldData.found == false && meta }> 374 <div> 375 <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> 376 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div> 377 </div> 378 </Show> 379 <Show when={ data.worldData.found == true }> 380 <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> 381 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div> 382 383 <br /> 384 <div class="world-tags"> 385 <For each={JSON.parse(data.worldData.tags.split('\\\\').join("").split('\\').join("").slice(1, -1))}> 386 {( tag ) => 387 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div> 388 } 389 </For> 390 </div><br /> 391 </Show> 392 </div> as Node 393 ) 394 } 395 396 return ( 397 <div class="photo-viewer" ref={( el ) => viewer = el}> 398 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}> 399 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div> 400 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div> 401 </div> 402 403 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}> 404 <div class="icon" style={{ width: '10px', margin: '0' }}> 405 <img draggable="false" src="/icon/x-solid.svg"></img> 406 </div> 407 </div> 408 <img class="image-container" ref={( el ) => imageViewer = el} /> 409 410 <div class="prev-button" onClick={() => { 411 window.CloseAllPopups.forEach(p => p()); 412 window.PhotoViewerManager.PreviousPhoto(); 413 }}> 414 <div class="icon" style={{ width: '15px', margin: '0' }}> 415 <img draggable="false" src="/icon/arrow-left-solid.svg"></img> 416 </div> 417 </div> 418 419 <div class="next-button" onClick={() => { 420 window.CloseAllPopups.forEach(p => p()); 421 window.PhotoViewerManager.NextPhoto(); 422 }}> 423 <div class="icon" style={{ width: '15px', margin: '0' }}> 424 <img draggable="false" src="/icon/arrow-right-solid.svg"></img> 425 </div> 426 </div> 427 428 <div class="photo-tray" ref={( el ) => photoTray = el}></div> 429 430 <div class="photo-tray-close" 431 onClick={() => closeTray()} 432 ref={( el ) => photoTrayCloseBtn = el} 433 > 434 <div class="icon" style={{ width: '12px', margin: '0' }}> 435 <img draggable="false" src="/icon/angle-down-solid.svg"></img> 436 </div> 437 </div> 438 439 <div class="control-buttons" ref={( el ) => photoControls = el}> 440 <div class="viewer-button" 441 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 442 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 443 onClick={() => { copyImage(); }}> 444 <div class="icon" style={{ width: '12px', margin: '0' }}> 445 <img draggable="false" src="/icon/copy-solid.svg"></img> 446 </div> 447 </div> 448 <div class="viewer-button" style={{ width: '50px' }} 449 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })} 450 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })} 451 ref={( el ) => trayButton = el} 452 onClick={() => openTray()} 453 > 454 <div class="icon" style={{ width: '12px', margin: '0' }}> 455 <img draggable="false" src="/icon/angle-up-solid.svg"></img> 456 </div> 457 </div> 458 459 <div class="viewer-button" 460 ref={authorProfileButton!} 461 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 462 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 463 > 464 <div class="icon" style={{ width: '12px', margin: '0' }}> 465 <img draggable="false" src="/icon/user-solid.svg"></img> 466 </div> 467 </div> 468 469 <div class="viewer-button" 470 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 471 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 472 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => { invoke("delete_photo", { 473 path: window.PhotoViewerManager.CurrentPhoto()?.path, 474 token: (await invoke('get_config_value_string', { key: 'token' })) || "none", 475 isSyncing: window.AccountManager.hasAccount() ? window.AccountManager.Storage()?.isSyncing : false 476 }); 477 })}> 478 <div class="icon" style={{ width: '12px', margin: '0' }}> 479 <img draggable="false" src="/icon/trash-solid.svg"></img> 480 </div> 481 </div> 482 </div> 483 </div> 484 ) 485} 486 487export default PhotoViewer