Skip to content

Commit 79331b0

Browse files
authored
Merge pull request #89 from xconnio/misc
Improve window manager and app dock interactions
2 parents 8fe01ce + 5d11b5f commit 79331b0

6 files changed

Lines changed: 406 additions & 69 deletions

File tree

src/components/AppDock.vue

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -105,43 +105,66 @@ const openPopoverAppId = ref<string | null>(null)
105105
const popoverRef = ref<HTMLElement | null>(null)
106106
107107
// Fixed pixel coordinates, computed from the icon's rect at open-time. The
108-
// popover is teleported to <body> and positioned this way (rather than
109-
// nested + position:absolute inside the dock) because the dock's icon strip
110-
// scrolls (overflow-x/y: auto) when there are many pinned icons — a
111-
// popover positioned relative to a scrollable ancestor gets clipped by it.
108+
// popover (and tooltip, below) are teleported to <body> and positioned this
109+
// way (rather than nested + position:absolute inside the dock) because the
110+
// dock's icon strip scrolls (overflow-x/y: auto) when there are many pinned
111+
// icons — a popover positioned relative to a scrollable ancestor gets
112+
// clipped by it.
112113
const popoverStyle = ref<Record<string, string>>({})
113114
const POPOVER_GAP = 10
114115
115-
function computePopoverStyle(appId: string) {
116+
// Anchors a flyout (popover or tooltip) to the icon's edge that faces away
117+
// from the dock, offset by `gap`.
118+
function anchoredStyleFor(appId: string, gap: number): Record<string, string> | null {
116119
const iconEl = iconEls.get(appId)
117-
if (!iconEl) return
120+
if (!iconEl) return null
118121
const rect = iconEl.getBoundingClientRect()
119122
120123
switch (props.position) {
121124
case 'left':
122-
popoverStyle.value = {
123-
left: `${rect.right + POPOVER_GAP}px`,
125+
return {
126+
left: `${rect.right + gap}px`,
124127
top: `${rect.top + rect.height / 2}px`,
125128
transform: 'translateY(-50%)',
126129
}
127-
break
128130
case 'right':
129-
popoverStyle.value = {
130-
left: `${rect.left - POPOVER_GAP}px`,
131+
return {
132+
left: `${rect.left - gap}px`,
131133
top: `${rect.top + rect.height / 2}px`,
132134
transform: 'translate(-100%, -50%)',
133135
}
134-
break
135136
default:
136-
popoverStyle.value = {
137+
return {
137138
left: `${rect.left + rect.width / 2}px`,
138-
top: `${rect.top - POPOVER_GAP}px`,
139+
top: `${rect.top - gap}px`,
139140
transform: 'translate(-50%, -100%)',
140141
}
141142
}
142143
}
143144
145+
function computePopoverStyle(appId: string) {
146+
const style = anchoredStyleFor(appId, POPOVER_GAP)
147+
if (style) popoverStyle.value = style
148+
}
149+
150+
const hoveredAppId = ref<string | null>(null)
151+
const tooltipStyle = ref<Record<string, string>>({})
152+
const TOOLTIP_GAP = 8
153+
154+
function showTooltip(appId: string) {
155+
if (dragState || openPopoverAppId.value) return
156+
const style = anchoredStyleFor(appId, TOOLTIP_GAP)
157+
if (!style) return
158+
tooltipStyle.value = style
159+
hoveredAppId.value = appId
160+
}
161+
162+
function hideTooltip() {
163+
hoveredAppId.value = null
164+
}
165+
144166
function togglePopover(appId: string) {
167+
hideTooltip()
145168
if (openPopoverAppId.value === appId) {
146169
openPopoverAppId.value = null
147170
return
@@ -201,6 +224,7 @@ let dragState: DragState | null = null
201224
202225
function onIconPointerDown(appId: string, e: PointerEvent) {
203226
if (e.button !== 0) return
227+
hideTooltip()
204228
dragState = { appId, startX: e.clientX, startY: e.clientY, moved: false }
205229
window.addEventListener('pointermove', onIconPointerMove)
206230
window.addEventListener('pointerup', onIconPointerUp)
@@ -302,15 +326,25 @@ onUnmounted(() => window.removeEventListener('click', onWindowClick, true))
302326
class="dock-icon"
303327
:class="{ 'dock-icon-focused': isAppFocused(app.id), 'dock-icon-disabled': offline }"
304328
:style="{ color: app.iconColor, background: app.iconBg }"
305-
:title="offline ? `${app.label} (offline)` : app.label"
329+
:aria-label="offline ? `${app.label} (offline)` : app.label"
306330
@pointerdown="onIconPointerDown(app.id, $event)"
307331
@contextmenu.prevent="handleIconContextMenu(app.id)"
332+
@mouseenter="showTooltip(app.id)"
333+
@mouseleave="hideTooltip"
308334
>
309335
<i class="bi" :class="app.icon"></i>
310-
<span v-if="dotCount(app.id) > 0" class="dock-dots">
311-
<span v-for="n in dotCount(app.id)" :key="n" class="dock-dot"></span>
312-
</span>
313336
</button>
337+
<span v-if="dotCount(app.id) > 0" class="dock-dots">
338+
<span v-for="n in dotCount(app.id)" :key="n" class="dock-dot"></span>
339+
</span>
340+
341+
<Teleport to="body">
342+
<div
343+
v-if="hoveredAppId === app.id"
344+
class="dock-tooltip"
345+
:style="tooltipStyle"
346+
>{{ offline ? `${app.label} (offline)` : app.label }}</div>
347+
</Teleport>
314348

315349
<Teleport to="body">
316350
<div
@@ -341,7 +375,7 @@ onUnmounted(() => window.removeEventListener('click', onWindowClick, true))
341375
<span class="dock-popover-icon dock-popover-icon-new">
342376
<i class="bi bi-plus-lg"></i>
343377
</span>
344-
<span class="dock-popover-label">New window</span>
378+
<span class="dock-popover-label">New Window</span>
345379
</button>
346380
<button
347381
v-if="(instancesByApp.get(app.id)?.length ?? 0) > 0"
@@ -352,7 +386,7 @@ onUnmounted(() => window.removeEventListener('click', onWindowClick, true))
352386
<i class="bi bi-power"></i>
353387
</span>
354388
<span class="dock-popover-label">
355-
{{ instancesByApp.get(app.id)!.length === 1 ? 'Quit window' : `Quit ${instancesByApp.get(app.id)!.length} windows` }}
389+
{{ instancesByApp.get(app.id)!.length === 1 ? 'Quit' : `Quit ${instancesByApp.get(app.id)!.length} Windows` }}
356390
</span>
357391
</button>
358392
</div>
@@ -501,35 +535,52 @@ onUnmounted(() => window.removeEventListener('click', onWindowClick, true))
501535
position: absolute;
502536
display: flex;
503537
gap: 3px;
538+
pointer-events: none;
504539
}
505540
506-
/* Indicator sits on the icon's edge that touches the screen border */
541+
/* Indicator sits just outside the icon square, on the edge that touches the
542+
screen border — not on top of the glyph, which fills the square. */
507543
.dock-bottom .dock-dots {
508-
bottom: 3px;
544+
bottom: -5px;
509545
left: 50%;
510546
transform: translateX(-50%);
511547
flex-direction: row;
512548
}
513549
514550
.dock-left .dock-dots {
515-
left: 3px;
551+
left: -5px;
516552
top: 50%;
517553
transform: translateY(-50%);
518554
flex-direction: column;
519555
}
520556
521557
.dock-right .dock-dots {
522-
right: 3px;
558+
right: -5px;
523559
top: 50%;
524560
transform: translateY(-50%);
525561
flex-direction: column;
526562
}
527563
528564
.dock-dot {
529-
width: 5px;
530-
height: 5px;
565+
width: 6.5px;
566+
height: 6.5px;
531567
border-radius: 50%;
532-
background: #475569;
568+
background: #f97316;
569+
}
570+
571+
/* App-name tooltip — same teleport-and-anchor approach as .dock-popover below. */
572+
.dock-tooltip {
573+
position: fixed;
574+
padding: 0.3rem 0.6rem;
575+
background: rgba(20, 20, 22, 0.92);
576+
color: #fff;
577+
font-size: 0.72rem;
578+
font-weight: 500;
579+
white-space: nowrap;
580+
border-radius: 6px;
581+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
582+
pointer-events: none;
583+
z-index: 2100;
533584
}
534585
535586
/* Instance switcher popover — teleported to <body> and positioned via an

src/components/DesktopSessionHost.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ onUnmounted(() => {
450450
</script>
451451

452452
<template>
453-
<div class="launcher-wrapper fade-in-up">
453+
<div class="launcher-wrapper fade-in-up" @contextmenu.prevent>
454454
<div v-if="isOffline" class="offline-banner">
455455
<i class="bi bi-wifi-off"></i>
456456
<span>{{ desktopName }} is offline — apps aren't available right now.</span>

src/components/FilePreviewModal.vue

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,23 @@ function goToEntry(target: PreviewEntry) {
6060
openFile()
6161
}
6262
63+
// Drives which way the preview-image transition slides in from (see template).
64+
const navDirection = ref<'prev' | 'next'>('next')
65+
6366
function goPrev() {
6467
if (!canGoPrev.value) return
6568
const target = imageEntries.value[currentImageIndex.value - 1]
66-
if (target) goToEntry(target)
69+
if (!target) return
70+
navDirection.value = 'prev'
71+
goToEntry(target)
6772
}
6873
6974
function goNext() {
7075
if (!canGoNext.value) return
7176
const target = imageEntries.value[currentImageIndex.value + 1]
72-
if (target) goToEntry(target)
77+
if (!target) return
78+
navDirection.value = 'next'
79+
goToEntry(target)
7380
}
7481
7582
function handleKeydown(e: KeyboardEvent) {
@@ -80,6 +87,10 @@ function handleKeydown(e: KeyboardEvent) {
8087
8188
const previewType = ref<FilePreviewType>('none')
8289
const previewBlobUrl = ref('')
90+
// True while navigating to a sibling image and its data is still loading — the
91+
// outgoing image stays on screen instead of the loading state taking over, so
92+
// there's no blank/white flash between images (see openFile).
93+
const imageSwapping = ref(false)
8394
const previewTextContent = ref('')
8495
const previewLoading = ref(false)
8596
const previewError = ref('')
@@ -293,9 +304,12 @@ async function openFile(isRetry = false) {
293304
// Full-buffer fallback: images/pdf/text, and audio/video retried once after a
294305
// playback error (see handleMediaError).
295306
stopActiveStream()
296-
if (previewBlobUrl.value) { URL.revokeObjectURL(previewBlobUrl.value); previewBlobUrl.value = '' }
307+
const outgoingBlobUrl = previewBlobUrl.value
308+
const swappingImage = pt === 'image' && previewType.value === 'image' && !!outgoingBlobUrl
309+
if (!swappingImage && outgoingBlobUrl) { URL.revokeObjectURL(outgoingBlobUrl); previewBlobUrl.value = '' }
297310
previewType.value = pt
298-
previewLoading.value = true
311+
previewLoading.value = !swappingImage
312+
imageSwapping.value = swappingImage
299313
previewExpectedBytes.value = currentEntry.value.size
300314
previewReceivedBytes.value = 0
301315
previewError.value = ''
@@ -309,10 +323,12 @@ async function openFile(isRetry = false) {
309323
const blob = new Blob([data.slice()], { type: getMimeType(currentEntry.value.name) })
310324
previewBlobUrl.value = URL.createObjectURL(blob)
311325
}
326+
if (swappingImage) URL.revokeObjectURL(outgoingBlobUrl)
312327
} catch (err) {
313328
if (mounted) previewError.value = err instanceof Error ? err.message : 'Failed to load file'
329+
if (swappingImage) { URL.revokeObjectURL(outgoingBlobUrl); previewBlobUrl.value = '' }
314330
} finally {
315-
if (mounted) previewLoading.value = false
331+
if (mounted) { previewLoading.value = false; imageSwapping.value = false }
316332
}
317333
}
318334
@@ -820,7 +836,12 @@ onUnmounted(() => {
820836
<button v-if="canGoPrev" class="preview-nav-btn preview-nav-btn--prev" @click="goPrev" title="Previous image">
821837
<i class="bi bi-chevron-left"></i>
822838
</button>
823-
<img :src="previewBlobUrl" :alt="currentEntry.name" class="preview-image" />
839+
<Transition :name="`image-slide-${navDirection}`">
840+
<img :key="previewBlobUrl" :src="previewBlobUrl" :alt="currentEntry.name" class="preview-image" />
841+
</Transition>
842+
<div v-if="imageSwapping" class="preview-image-swap-spinner">
843+
<div class="spinner-border spinner-border-sm text-light" role="status"><span class="visually-hidden">Loading…</span></div>
844+
</div>
824845
<button v-if="canGoNext" class="preview-nav-btn preview-nav-btn--next" @click="goNext" title="Next image">
825846
<i class="bi bi-chevron-right"></i>
826847
</button>
@@ -964,9 +985,41 @@ onUnmounted(() => {
964985
.preview-state { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; padding: 2rem; color: #617182; min-height: 220px; }
965986
.preview-progress-text { font-size: 0.85rem; color: #94a3b8; margin: 0; }
966987
967-
.preview-image-wrap { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; padding: 1rem; background: #0d0d0d; min-height: 300px; }
988+
.preview-image-wrap { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; padding: 1rem; background: #0d0d0d; min-height: 300px; overflow: hidden; }
968989
.preview-image { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
969990
991+
.preview-image-swap-spinner {
992+
position: absolute;
993+
bottom: 0.75rem;
994+
right: 0.75rem;
995+
display: flex;
996+
align-items: center;
997+
justify-content: center;
998+
width: 32px;
999+
height: 32px;
1000+
border-radius: 50%;
1001+
background: rgba(15, 23, 42, 0.45);
1002+
}
1003+
1004+
.image-slide-next-enter-active,
1005+
.image-slide-prev-enter-active,
1006+
.image-slide-next-leave-active,
1007+
.image-slide-prev-leave-active {
1008+
transition: opacity 0.22s ease, transform 0.22s ease;
1009+
}
1010+
/* Leaving image overlaps the entering one (both centered) instead of taking
1011+
part in layout, so the crossfade doesn't shift/collapse the container. */
1012+
.image-slide-next-leave-active,
1013+
.image-slide-prev-leave-active {
1014+
position: absolute;
1015+
inset: 1rem;
1016+
margin: auto;
1017+
}
1018+
.image-slide-next-leave-to { opacity: 0; transform: translateX(-28px); }
1019+
.image-slide-prev-leave-to { opacity: 0; transform: translateX(28px); }
1020+
.image-slide-next-enter-from { opacity: 0; transform: translateX(28px); }
1021+
.image-slide-prev-enter-from { opacity: 0; transform: translateX(-28px); }
1022+
9701023
.preview-nav-btn {
9711024
position: absolute;
9721025
top: 50%;

0 commit comments

Comments
 (0)