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