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