refactor: Componentize HorizontalScrollCarousel
This commit is contained in:
parent
1bf2cb751b
commit
0894c1fbb6
|
|
@ -2,14 +2,15 @@
|
||||||
import {
|
import {
|
||||||
ChatAttachmentMcpPrompt,
|
ChatAttachmentMcpPrompt,
|
||||||
ChatAttachmentThumbnailImage,
|
ChatAttachmentThumbnailImage,
|
||||||
ChatAttachmentThumbnailFile
|
ChatAttachmentThumbnailFile,
|
||||||
|
HorizontalScrollCarousel,
|
||||||
|
DialogChatAttachmentPreview,
|
||||||
|
DialogChatAttachmentsViewAll
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
|
||||||
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
|
||||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
|
||||||
import { AttachmentType } from '$lib/enums';
|
import { AttachmentType } from '$lib/enums';
|
||||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||||
|
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -47,12 +48,10 @@
|
||||||
|
|
||||||
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
||||||
|
|
||||||
let canScrollLeft = $state(false);
|
let carouselRef: HorizontalScrollCarousel | undefined = $state();
|
||||||
let canScrollRight = $state(false);
|
|
||||||
let isScrollable = $state(false);
|
let isScrollable = $state(false);
|
||||||
let previewDialogOpen = $state(false);
|
let previewDialogOpen = $state(false);
|
||||||
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
|
||||||
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||||
let viewAllDialogOpen = $state(false);
|
let viewAllDialogOpen = $state(false);
|
||||||
|
|
||||||
|
|
@ -71,41 +70,9 @@
|
||||||
previewDialogOpen = true;
|
previewDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollLeft(event?: MouseEvent) {
|
|
||||||
event?.stopPropagation();
|
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollRight(event?: MouseEvent) {
|
|
||||||
event?.stopPropagation();
|
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScrollButtons() {
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
|
|
||||||
|
|
||||||
canScrollLeft = scrollLeft > 0;
|
|
||||||
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
|
|
||||||
isScrollable = scrollWidth > clientWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (scrollContainer && displayItems.length) {
|
if (carouselRef && displayItems.length) {
|
||||||
scrollContainer.scrollLeft = 0;
|
carouselRef.resetScroll();
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
updateScrollButtons();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -113,93 +80,66 @@
|
||||||
{#if displayItems.length > 0}
|
{#if displayItems.length > 0}
|
||||||
<div class={className} {style}>
|
<div class={className} {style}>
|
||||||
{#if limitToSingleRow}
|
{#if limitToSingleRow}
|
||||||
<div class="relative">
|
<HorizontalScrollCarousel
|
||||||
<button
|
bind:this={carouselRef}
|
||||||
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
|
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
|
||||||
? 'opacity-100'
|
>
|
||||||
: 'pointer-events-none opacity-0'}"
|
{#each displayItems as item (item.id)}
|
||||||
onclick={scrollLeft}
|
{#if item.isMcpPrompt}
|
||||||
aria-label="Scroll left"
|
{@const mcpPrompt =
|
||||||
>
|
item.attachment?.type === AttachmentType.MCP_PROMPT
|
||||||
<ChevronLeft class="h-4 w-4" />
|
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||||
</button>
|
: item.uploadedFile?.mcpPrompt
|
||||||
|
? {
|
||||||
<div
|
type: AttachmentType.MCP_PROMPT as const,
|
||||||
class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
|
name: item.name,
|
||||||
bind:this={scrollContainer}
|
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||||
onscroll={updateScrollButtons}
|
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||||
>
|
content: item.textContent ?? '',
|
||||||
{#each displayItems as item (item.id)}
|
arguments: item.uploadedFile.mcpPrompt.arguments
|
||||||
{#if item.isMcpPrompt}
|
}
|
||||||
{@const mcpPrompt =
|
: null}
|
||||||
item.attachment?.type === AttachmentType.MCP_PROMPT
|
{#if mcpPrompt}
|
||||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
<ChatAttachmentMcpPrompt
|
||||||
: item.uploadedFile?.mcpPrompt
|
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
|
||||||
? {
|
|
||||||
type: AttachmentType.MCP_PROMPT as const,
|
|
||||||
name: item.name,
|
|
||||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
|
||||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
|
||||||
content: item.textContent ?? '',
|
|
||||||
arguments: item.uploadedFile.mcpPrompt.arguments
|
|
||||||
}
|
|
||||||
: null}
|
|
||||||
{#if mcpPrompt}
|
|
||||||
<ChatAttachmentMcpPrompt
|
|
||||||
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
|
|
||||||
? 'first:ml-4 last:mr-4'
|
|
||||||
: ''}"
|
|
||||||
prompt={mcpPrompt}
|
|
||||||
{readonly}
|
|
||||||
isLoading={item.isLoading}
|
|
||||||
loadError={item.loadError}
|
|
||||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{:else if item.isImage && item.preview}
|
|
||||||
<ChatAttachmentThumbnailImage
|
|
||||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
|
||||||
? 'first:ml-4 last:mr-4'
|
? 'first:ml-4 last:mr-4'
|
||||||
: ''}"
|
: ''}"
|
||||||
id={item.id}
|
prompt={mcpPrompt}
|
||||||
name={item.name}
|
|
||||||
preview={item.preview}
|
|
||||||
{readonly}
|
{readonly}
|
||||||
onRemove={onFileRemove}
|
isLoading={item.isLoading}
|
||||||
height={imageHeight}
|
loadError={item.loadError}
|
||||||
width={imageWidth}
|
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||||
{imageClass}
|
|
||||||
onClick={(event) => openPreview(item, event)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ChatAttachmentThumbnailFile
|
|
||||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
|
||||||
? 'first:ml-4 last:mr-4'
|
|
||||||
: ''}"
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
size={item.size}
|
|
||||||
{readonly}
|
|
||||||
onRemove={onFileRemove}
|
|
||||||
textContent={item.textContent}
|
|
||||||
attachment={item.attachment}
|
|
||||||
uploadedFile={item.uploadedFile}
|
|
||||||
onClick={(event) => openPreview(item, event)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{:else if item.isImage && item.preview}
|
||||||
</div>
|
<ChatAttachmentThumbnailImage
|
||||||
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
<button
|
id={item.id}
|
||||||
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
|
name={item.name}
|
||||||
? 'opacity-100'
|
preview={item.preview}
|
||||||
: 'pointer-events-none opacity-0'}"
|
{readonly}
|
||||||
onclick={scrollRight}
|
onRemove={onFileRemove}
|
||||||
aria-label="Scroll right"
|
height={imageHeight}
|
||||||
>
|
width={imageWidth}
|
||||||
<ChevronRight class="h-4 w-4" />
|
{imageClass}
|
||||||
</button>
|
onClick={(event) => openPreview(item, event)}
|
||||||
</div>
|
/>
|
||||||
|
{:else}
|
||||||
|
<ChatAttachmentThumbnailFile
|
||||||
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
size={item.size}
|
||||||
|
{readonly}
|
||||||
|
onRemove={onFileRemove}
|
||||||
|
textContent={item.textContent}
|
||||||
|
attachment={item.attachment}
|
||||||
|
uploadedFile={item.uploadedFile}
|
||||||
|
onClick={(event) => openPreview(item, event)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</HorizontalScrollCarousel>
|
||||||
|
|
||||||
{#if showViewAll}
|
{#if showViewAll}
|
||||||
<div class="mt-2 -mr-2 flex justify-end px-4">
|
<div class="mt-2 -mr-2 flex justify-end px-4">
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,10 @@ export { default as TruncatedText } from './TruncatedText.svelte';
|
||||||
* - Mode-specific UI (export vs import)
|
* - Mode-specific UI (export vs import)
|
||||||
*/
|
*/
|
||||||
export { default as ConversationSelection } from './ConversationSelection.svelte';
|
export { default as ConversationSelection } from './ConversationSelection.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal scrollable carousel with navigation arrows.
|
||||||
|
* Used for displaying items in a horizontally scrollable container
|
||||||
|
* with left/right navigation buttons that appear on hover.
|
||||||
|
*/
|
||||||
|
export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue