diff --git a/src/lib/components/desktop/Desktop.svelte b/src/lib/components/desktop/Desktop.svelte index 2fba975..2c1d490 100644 --- a/src/lib/components/desktop/Desktop.svelte +++ b/src/lib/components/desktop/Desktop.svelte @@ -4,6 +4,8 @@ import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte'; import { closePage } from '#lib/desktop/actions'; import Terminal from '#lib/components/terminal/Terminal.svelte'; + import ImageViewer from '#lib/components/viewer/ImageViewer.svelte'; + import { imageFileName } from '#lib/desktop/imageViewer'; import DesktopIcons from './DesktopIcons.svelte'; import Window from './Window.svelte'; @@ -22,7 +24,7 @@ let areaHeight = $state(720); const isNarrow = $derived(areaWidth < narrowWidth); - const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.terminals.map((terminal) => terminal.id)]); + const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.extraWindowIds]); const layout = $derived.by(() => { const rects = new Map(); @@ -85,12 +87,30 @@ focused={windowManager.focusedId === terminal.id} tileStyle={styleFor(terminal.id)} onfocus={() => windowManager.focus(terminal.id)} - onclose={() => windowManager.closeTerminal(terminal.id)} + onclose={() => windowManager.closeWindow(terminal.id)} > windowManager.closeTerminal(terminal.id)} + onexit={() => windowManager.closeWindow(terminal.id)} + /> + + {/each} + + {#each windowManager.imageViewers as viewer (viewer.id)} + windowManager.focus(viewer.id)} + onclose={() => windowManager.closeWindow(viewer.id)} + > + windowManager.closeWindow(viewer.id)} /> {/each} diff --git a/src/lib/components/viewer/ImageViewer.svelte b/src/lib/components/viewer/ImageViewer.svelte new file mode 100644 index 0000000..b29a9b8 --- /dev/null +++ b/src/lib/components/viewer/ImageViewer.svelte @@ -0,0 +1,260 @@ + + + + +
+ + + + + {#if image.alt} +
{image.alt}
+ {/if} + + {#if hasMultiple} + + {/if} +
+ + diff --git a/src/lib/components/viewer/ViewerThumbnails.svelte b/src/lib/components/viewer/ViewerThumbnails.svelte new file mode 100644 index 0000000..bf20352 --- /dev/null +++ b/src/lib/components/viewer/ViewerThumbnails.svelte @@ -0,0 +1,63 @@ + + +
+ {#each images as image, index (image.src)} + + {/each} +
+ + diff --git a/src/lib/components/viewer/ViewerToolbar.svelte b/src/lib/components/viewer/ViewerToolbar.svelte new file mode 100644 index 0000000..bb2d973 --- /dev/null +++ b/src/lib/components/viewer/ViewerToolbar.svelte @@ -0,0 +1,103 @@ + + +
+
+ {#if count > 1} + + {position} / {count} + + {/if} + {fileName} +
+ +
+ + {zoomPercent}% + + + + + +
+
+ + diff --git a/src/lib/desktop/imageViewer.ts b/src/lib/desktop/imageViewer.ts new file mode 100644 index 0000000..516d8d9 --- /dev/null +++ b/src/lib/desktop/imageViewer.ts @@ -0,0 +1,44 @@ +import { windowManager, type ViewerImage } from './windows.svelte'; + +const viewableArea = '.window-content'; +const imageGroupSelector = '[data-image-group]'; +const optOutSelector = '[data-no-viewer]'; +const viewerSelector = '.image-viewer'; + +export function imageFileName(src: string): string { + const path = src.split(/[?#]/)[0]; + return decodeURIComponent(path.split('/').filter(Boolean).at(-1) ?? 'image'); +} + +function toViewerImage(image: HTMLImageElement): ViewerImage { + return { src: image.currentSrc || image.src, alt: image.alt }; +} + +function linksElsewhere(image: HTMLImageElement): boolean { + const link = image.closest('a'); + if (!link) return false; + return link.href !== image.src && link.href !== image.currentSrc; +} + +function findClickedImage(event: MouseEvent): HTMLImageElement | null { + if (!(event.target instanceof Element)) return null; + const image = event.target.closest('img'); + if (!image || !image.closest(viewableArea)) return null; + if (image.closest(viewerSelector) || image.closest(optOutSelector)) return null; + if (linksElsewhere(image)) return null; + return image; +} + +export function handleImageClick(event: MouseEvent) { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + + const image = findClickedImage(event); + if (!image) return; + + event.preventDefault(); + event.stopPropagation(); + + const group = image.closest(imageGroupSelector); + const groupImages = group ? [...group.querySelectorAll('img')] : [image]; + windowManager.openImageViewer(groupImages.map(toViewerImage), Math.max(0, groupImages.indexOf(image))); +} diff --git a/src/lib/desktop/keybinds.ts b/src/lib/desktop/keybinds.ts index 5d793a2..89f3237 100644 --- a/src/lib/desktop/keybinds.ts +++ b/src/lib/desktop/keybinds.ts @@ -5,7 +5,7 @@ import { pageWindowId, windowManager } from './windows.svelte'; function closeFocusedWindow() { if (windowManager.focusedId === pageWindowId) closePage(); - else windowManager.closeTerminal(windowManager.focusedId); + else windowManager.closeWindow(windowManager.focusedId); } export function handleKeybind(event: KeyboardEvent, openWindowIds: string[]) { diff --git a/src/lib/desktop/windows.svelte.ts b/src/lib/desktop/windows.svelte.ts index 3f0ba1d..1587202 100644 --- a/src/lib/desktop/windows.svelte.ts +++ b/src/lib/desktop/windows.svelte.ts @@ -5,13 +5,30 @@ export interface TerminalWindow { startupCommand: string; } +export interface ViewerImage { + src: string; + alt: string; +} + +export interface ImageViewerWindow { + id: string; + images: ViewerImage[]; + currentIndex: number; +} + class WindowManager { terminals = $state([]); + imageViewers = $state([]); focusedId = $state(pageWindowId); maximizedId = $state(null); launcherOpen = $state(false); private nextTerminalNumber = 1; + private nextImageViewerNumber = 1; + + get extraWindowIds(): string[] { + return [...this.terminals.map((terminal) => terminal.id), ...this.imageViewers.map((viewer) => viewer.id)]; + } openTerminal(startupCommand = '') { const terminal = { id: `kitty-${this.nextTerminalNumber++}`, startupCommand }; @@ -19,16 +36,23 @@ class WindowManager { this.focus(terminal.id); } - closeTerminal(id: string) { + openImageViewer(images: ViewerImage[], currentIndex: number) { + const viewer = { id: `imv-${this.nextImageViewerNumber++}`, images, currentIndex }; + this.imageViewers.push(viewer); + this.focus(viewer.id); + } + + closeWindow(id: string) { this.terminals = this.terminals.filter((terminal) => terminal.id !== id); + this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id); if (this.maximizedId === id) this.maximizedId = null; - if (this.focusedId === id) this.focusedId = this.terminals.at(-1)?.id ?? pageWindowId; + if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; } closeAllTerminals() { this.terminals = []; - this.maximizedId = null; - this.focusedId = pageWindowId; + if (this.maximizedId?.startsWith('kitty-')) this.maximizedId = null; + if (this.focusedId.startsWith('kitty-')) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; } focus(id: string) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0333711..ad631ab 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { themeState } from '#lib/theme/theme.svelte'; import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte'; import { handleKeybind } from '#lib/desktop/keybinds'; + import { handleImageClick } from '#lib/desktop/imageViewer'; import { findSitePage } from '#lib/site/pages'; import Bar from '#lib/components/shell/Bar.svelte'; import Desktop from '#lib/components/desktop/Desktop.svelte'; @@ -22,12 +23,14 @@ const pageIcon = $derived( findSitePage(pathname)?.icon ?? findSitePage(pathname.split('/')[1] ?? '')?.icon ?? 'mdi:file-document-outline' ); - const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.terminals.map((terminal) => terminal.id)]); + const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.extraWindowIds]); onMount(() => { themeState.restore(); const isWideScreen = window.innerWidth >= 768; if (!pageOpen && isWideScreen && desktop.startupCommand) windowManager.openTerminal(desktop.startupCommand); + window.addEventListener('click', handleImageClick, true); + return () => window.removeEventListener('click', handleImageClick, true); }); diff --git a/src/routes/layout.css b/src/routes/layout.css index 6fb04f3..49dc4f0 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -64,6 +64,20 @@ button { scrollbar-color: var(--muted) transparent; } +.window-content img { + cursor: zoom-in; +} + +.window-content a[href] img, +.window-content [data-no-viewer] img, +.window-content .image-viewer img { + cursor: inherit; +} + +.window-content [data-image-group] a[href] img { + cursor: zoom-in; +} + @keyframes window-pop-in { from { opacity: 0;