Add image viewer
This commit is contained in:
1 parent
40604b4a04
commit
d7476cff98
9 files changed
+540
-9
No files matched your search
@@ -4,6 +4,8 @@
|
|||||||
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
||||||
import { closePage } from '#lib/desktop/actions';
|
import { closePage } from '#lib/desktop/actions';
|
||||||
import Terminal from '#lib/components/terminal/Terminal.svelte';
|
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 DesktopIcons from './DesktopIcons.svelte';
|
||||||
import Window from './Window.svelte';
|
import Window from './Window.svelte';
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
let areaHeight = $state(720);
|
let areaHeight = $state(720);
|
||||||
|
|
||||||
const isNarrow = $derived(areaWidth < narrowWidth);
|
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 layout = $derived.by(() => {
|
||||||
const rects = new Map<string, TileRect>();
|
const rects = new Map<string, TileRect>();
|
||||||
@@ -85,12 +87,30 @@
|
|||||||
focused={windowManager.focusedId === terminal.id}
|
focused={windowManager.focusedId === terminal.id}
|
||||||
tileStyle={styleFor(terminal.id)}
|
tileStyle={styleFor(terminal.id)}
|
||||||
onfocus={() => windowManager.focus(terminal.id)}
|
onfocus={() => windowManager.focus(terminal.id)}
|
||||||
onclose={() => windowManager.closeTerminal(terminal.id)}
|
onclose={() => windowManager.closeWindow(terminal.id)}
|
||||||
>
|
>
|
||||||
<Terminal
|
<Terminal
|
||||||
startupCommand={terminal.startupCommand}
|
startupCommand={terminal.startupCommand}
|
||||||
focused={windowManager.focusedId === terminal.id}
|
focused={windowManager.focusedId === terminal.id}
|
||||||
onexit={() => windowManager.closeTerminal(terminal.id)}
|
onexit={() => windowManager.closeWindow(terminal.id)}
|
||||||
|
/>
|
||||||
|
</Window>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each windowManager.imageViewers as viewer (viewer.id)}
|
||||||
|
<Window
|
||||||
|
title="imv"
|
||||||
|
path={imageFileName(viewer.images[viewer.currentIndex]?.src ?? '')}
|
||||||
|
icon="ph:image-duotone"
|
||||||
|
focused={windowManager.focusedId === viewer.id}
|
||||||
|
tileStyle={styleFor(viewer.id)}
|
||||||
|
onfocus={() => windowManager.focus(viewer.id)}
|
||||||
|
onclose={() => windowManager.closeWindow(viewer.id)}
|
||||||
|
>
|
||||||
|
<ImageViewer
|
||||||
|
{viewer}
|
||||||
|
focused={windowManager.focusedId === viewer.id}
|
||||||
|
onclose={() => windowManager.closeWindow(viewer.id)}
|
||||||
/>
|
/>
|
||||||
</Window>
|
</Window>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ImageViewerWindow } from '#lib/desktop/windows.svelte';
|
||||||
|
import { imageFileName } from '#lib/desktop/imageViewer';
|
||||||
|
import ViewerToolbar from './ViewerToolbar.svelte';
|
||||||
|
import ViewerThumbnails from './ViewerThumbnails.svelte';
|
||||||
|
|
||||||
|
let { viewer, focused, onclose }: { viewer: ImageViewerWindow; focused: boolean; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
const minZoom = 0.25;
|
||||||
|
const maxZoom = 8;
|
||||||
|
const zoomStep = 1.25;
|
||||||
|
const swipeDistance = 60;
|
||||||
|
|
||||||
|
let zoom = $state(1);
|
||||||
|
let rotation = $state(0);
|
||||||
|
let offsetX = $state(0);
|
||||||
|
let offsetY = $state(0);
|
||||||
|
let dragStart = $state<{ pointerX: number; pointerY: number; offsetX: number; offsetY: number } | null>(null);
|
||||||
|
let pinchStart: { distance: number; zoom: number } | null = null;
|
||||||
|
|
||||||
|
const activePointers = new Map<number, { x: number; y: number }>();
|
||||||
|
|
||||||
|
const image = $derived(viewer.images[viewer.currentIndex]);
|
||||||
|
const hasMultiple = $derived(viewer.images.length > 1);
|
||||||
|
const transform = $derived(`translate(${offsetX}px, ${offsetY}px) scale(${zoom}) rotate(${rotation}deg)`);
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
zoom = 1;
|
||||||
|
rotation = 0;
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showImage(index: number) {
|
||||||
|
const count = viewer.images.length;
|
||||||
|
viewer.currentIndex = (index + count) % count;
|
||||||
|
resetView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(nextZoom: number) {
|
||||||
|
zoom = Math.min(maxZoom, Math.max(minZoom, nextZoom));
|
||||||
|
if (zoom <= 1) {
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = {
|
||||||
|
previous: () => showImage(viewer.currentIndex - 1),
|
||||||
|
next: () => showImage(viewer.currentIndex + 1),
|
||||||
|
zoomIn: () => setZoom(zoom * zoomStep),
|
||||||
|
zoomOut: () => setZoom(zoom / zoomStep),
|
||||||
|
fit: resetView,
|
||||||
|
rotateLeft: () => (rotation -= 90),
|
||||||
|
rotateRight: () => (rotation += 90)
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleWheel(event: WheelEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setZoom(event.deltaY < 0 ? zoom * zoomStep : zoom / zoomStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceBetweenPointers(): number {
|
||||||
|
const [first, second] = [...activePointers.values()];
|
||||||
|
return Math.hypot(first.x - second.x, first.y - second.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (event.target instanceof Element && event.target.closest('button')) return;
|
||||||
|
if (event.currentTarget instanceof HTMLElement) event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
|
if (activePointers.size === 2) {
|
||||||
|
pinchStart = { distance: distanceBetweenPointers(), zoom };
|
||||||
|
dragStart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragStart = { pointerX: event.clientX, pointerY: event.clientY, offsetX, offsetY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
if (!activePointers.has(event.pointerId)) return;
|
||||||
|
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
|
if (pinchStart && activePointers.size === 2) {
|
||||||
|
setZoom(pinchStart.zoom * (distanceBetweenPointers() / pinchStart.distance));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dragStart) return;
|
||||||
|
offsetX = dragStart.offsetX + event.clientX - dragStart.pointerX;
|
||||||
|
offsetY = dragStart.offsetY + event.clientY - dragStart.pointerY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerEnd(event: PointerEvent) {
|
||||||
|
activePointers.delete(event.pointerId);
|
||||||
|
if (activePointers.size < 2) pinchStart = null;
|
||||||
|
|
||||||
|
if (dragStart && zoom === 1) {
|
||||||
|
const movedX = event.clientX - dragStart.pointerX;
|
||||||
|
const movedY = event.clientY - dragStart.pointerY;
|
||||||
|
const isSwipe = hasMultiple && Math.abs(movedX) > swipeDistance && Math.abs(movedX) > Math.abs(movedY);
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = 0;
|
||||||
|
if (isSwipe) showImage(viewer.currentIndex + (movedX < 0 ? 1 : -1));
|
||||||
|
}
|
||||||
|
dragStart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick() {
|
||||||
|
if (zoom === 1) setZoom(2);
|
||||||
|
else resetView();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyActions: Record<string, () => void> = {
|
||||||
|
ArrowLeft: controls.previous,
|
||||||
|
ArrowRight: controls.next,
|
||||||
|
'+': controls.zoomIn,
|
||||||
|
'=': controls.zoomIn,
|
||||||
|
'-': controls.zoomOut,
|
||||||
|
'0': controls.fit,
|
||||||
|
r: controls.rotateRight,
|
||||||
|
R: controls.rotateLeft,
|
||||||
|
Escape: onclose
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!focused || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
const action = keyActions[event.key];
|
||||||
|
if (!action) return;
|
||||||
|
event.preventDefault();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="image-viewer">
|
||||||
|
<ViewerToolbar
|
||||||
|
{controls}
|
||||||
|
zoomPercent={Math.round(zoom * 100)}
|
||||||
|
position={viewer.currentIndex + 1}
|
||||||
|
count={viewer.images.length}
|
||||||
|
fileName={imageFileName(image.src)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="stage"
|
||||||
|
class:dragging={!!dragStart}
|
||||||
|
role="presentation"
|
||||||
|
onwheel={handleWheel}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerEnd}
|
||||||
|
onpointercancel={handlePointerEnd}
|
||||||
|
ondblclick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{#key image.src}
|
||||||
|
<img src={image.src} alt={image.alt} style:transform draggable="false" />
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#if hasMultiple}
|
||||||
|
<button class="nav-arrow previous" title="Previous (Left arrow)" onclick={controls.previous}>‹</button>
|
||||||
|
<button class="nav-arrow next" title="Next (Right arrow)" onclick={controls.next}>›</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if image.alt}
|
||||||
|
<div class="caption">{image.alt}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasMultiple}
|
||||||
|
<ViewerThumbnails images={viewer.images} currentIndex={viewer.currentIndex} onselect={showImage} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--dark-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
animation: image-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage.dragging img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 56px;
|
||||||
|
background: color-mix(in srgb, var(--background) 70%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow.previous {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow.next {
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes image-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ViewerImage } from '#lib/desktop/windows.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
images,
|
||||||
|
currentIndex,
|
||||||
|
onselect
|
||||||
|
}: { images: ViewerImage[]; currentIndex: number; onselect: (index: number) => void } = $props();
|
||||||
|
|
||||||
|
let stripElement = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeThumbnail = stripElement?.children[currentIndex];
|
||||||
|
activeThumbnail?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="thumbnails" bind:this={stripElement}>
|
||||||
|
{#each images as image, index (image.src)}
|
||||||
|
<button class="thumbnail" class:active={index === currentIndex} title={image.alt} onclick={() => onselect(index)}>
|
||||||
|
<img src={image.src} alt="" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.thumbnails {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--background);
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition:
|
||||||
|
opacity 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
interface ViewerControls {
|
||||||
|
previous: () => void;
|
||||||
|
next: () => void;
|
||||||
|
zoomIn: () => void;
|
||||||
|
zoomOut: () => void;
|
||||||
|
fit: () => void;
|
||||||
|
rotateLeft: () => void;
|
||||||
|
rotateRight: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
controls,
|
||||||
|
zoomPercent,
|
||||||
|
position,
|
||||||
|
count,
|
||||||
|
fileName
|
||||||
|
}: { controls: ViewerControls; zoomPercent: number; position: number; count: number; fileName: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="group">
|
||||||
|
{#if count > 1}
|
||||||
|
<button title="Previous (Left arrow)" onclick={controls.previous}><Icon icon="mdi:chevron-left" width="18" /></button>
|
||||||
|
<span class="counter">{position} / {count}</span>
|
||||||
|
<button title="Next (Right arrow)" onclick={controls.next}><Icon icon="mdi:chevron-right" width="18" /></button>
|
||||||
|
{/if}
|
||||||
|
<span class="file-name" title={fileName}>{fileName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<button title="Zoom out (-)" onclick={controls.zoomOut}><Icon icon="mdi:magnify-minus-outline" width="18" /></button>
|
||||||
|
<span class="zoom">{zoomPercent}%</span>
|
||||||
|
<button title="Zoom in (+)" onclick={controls.zoomIn}><Icon icon="mdi:magnify-plus-outline" width="18" /></button>
|
||||||
|
<button title="Fit to window (0)" onclick={controls.fit}><Icon icon="mdi:fit-to-screen-outline" width="18" /></button>
|
||||||
|
<span class="divider"></span>
|
||||||
|
<button title="Rotate left (Shift+R)" onclick={controls.rotateLeft}><Icon icon="mdi:rotate-left" width="18" /></button>
|
||||||
|
<button title="Rotate right (R)" onclick={controls.rotateRight}><Icon icon="mdi:rotate-right" width="18" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: var(--background);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter,
|
||||||
|
.zoom {
|
||||||
|
min-width: 3.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
max-width: 16rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { pageWindowId, windowManager } from './windows.svelte';
|
|||||||
|
|
||||||
function closeFocusedWindow() {
|
function closeFocusedWindow() {
|
||||||
if (windowManager.focusedId === pageWindowId) closePage();
|
if (windowManager.focusedId === pageWindowId) closePage();
|
||||||
else windowManager.closeTerminal(windowManager.focusedId);
|
else windowManager.closeWindow(windowManager.focusedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleKeybind(event: KeyboardEvent, openWindowIds: string[]) {
|
export function handleKeybind(event: KeyboardEvent, openWindowIds: string[]) {
|
||||||
|
|||||||
@@ -5,13 +5,30 @@ export interface TerminalWindow {
|
|||||||
startupCommand: string;
|
startupCommand: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewerImage {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageViewerWindow {
|
||||||
|
id: string;
|
||||||
|
images: ViewerImage[];
|
||||||
|
currentIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
class WindowManager {
|
class WindowManager {
|
||||||
terminals = $state<TerminalWindow[]>([]);
|
terminals = $state<TerminalWindow[]>([]);
|
||||||
|
imageViewers = $state<ImageViewerWindow[]>([]);
|
||||||
focusedId = $state<string>(pageWindowId);
|
focusedId = $state<string>(pageWindowId);
|
||||||
maximizedId = $state<string | null>(null);
|
maximizedId = $state<string | null>(null);
|
||||||
launcherOpen = $state(false);
|
launcherOpen = $state(false);
|
||||||
|
|
||||||
private nextTerminalNumber = 1;
|
private nextTerminalNumber = 1;
|
||||||
|
private nextImageViewerNumber = 1;
|
||||||
|
|
||||||
|
get extraWindowIds(): string[] {
|
||||||
|
return [...this.terminals.map((terminal) => terminal.id), ...this.imageViewers.map((viewer) => viewer.id)];
|
||||||
|
}
|
||||||
|
|
||||||
openTerminal(startupCommand = '') {
|
openTerminal(startupCommand = '') {
|
||||||
const terminal = { id: `kitty-${this.nextTerminalNumber++}`, startupCommand };
|
const terminal = { id: `kitty-${this.nextTerminalNumber++}`, startupCommand };
|
||||||
@@ -19,16 +36,23 @@ class WindowManager {
|
|||||||
this.focus(terminal.id);
|
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.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.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() {
|
closeAllTerminals() {
|
||||||
this.terminals = [];
|
this.terminals = [];
|
||||||
this.maximizedId = null;
|
if (this.maximizedId?.startsWith('kitty-')) this.maximizedId = null;
|
||||||
this.focusedId = pageWindowId;
|
if (this.focusedId.startsWith('kitty-')) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(id: string) {
|
focus(id: string) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { themeState } from '#lib/theme/theme.svelte';
|
import { themeState } from '#lib/theme/theme.svelte';
|
||||||
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
||||||
import { handleKeybind } from '#lib/desktop/keybinds';
|
import { handleKeybind } from '#lib/desktop/keybinds';
|
||||||
|
import { handleImageClick } from '#lib/desktop/imageViewer';
|
||||||
import { findSitePage } from '#lib/site/pages';
|
import { findSitePage } from '#lib/site/pages';
|
||||||
import Bar from '#lib/components/shell/Bar.svelte';
|
import Bar from '#lib/components/shell/Bar.svelte';
|
||||||
import Desktop from '#lib/components/desktop/Desktop.svelte';
|
import Desktop from '#lib/components/desktop/Desktop.svelte';
|
||||||
@@ -22,12 +23,14 @@
|
|||||||
const pageIcon = $derived(
|
const pageIcon = $derived(
|
||||||
findSitePage(pathname)?.icon ?? findSitePage(pathname.split('/')[1] ?? '')?.icon ?? 'mdi:file-document-outline'
|
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(() => {
|
onMount(() => {
|
||||||
themeState.restore();
|
themeState.restore();
|
||||||
const isWideScreen = window.innerWidth >= 768;
|
const isWideScreen = window.innerWidth >= 768;
|
||||||
if (!pageOpen && isWideScreen && desktop.startupCommand) windowManager.openTerminal(desktop.startupCommand);
|
if (!pageOpen && isWideScreen && desktop.startupCommand) windowManager.openTerminal(desktop.startupCommand);
|
||||||
|
window.addEventListener('click', handleImageClick, true);
|
||||||
|
return () => window.removeEventListener('click', handleImageClick, true);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ button {
|
|||||||
scrollbar-color: var(--muted) transparent;
|
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 {
|
@keyframes window-pop-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in new issue
Block a user