fix(ui): render image attachment preview in portal
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
@@ -43,6 +44,57 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
|
||||||
|
type ImagePreviewState = {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
anchor: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||||
|
|
||||||
|
const getImagePreviewPosition = () => {
|
||||||
|
const state = imagePreview()
|
||||||
|
if (!state) return null
|
||||||
|
|
||||||
|
const rect = state.anchor.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Outer box: 320px image + 8px padding on each side.
|
||||||
|
const padding = 8
|
||||||
|
const maxImage = 320
|
||||||
|
const gap = 8
|
||||||
|
const chrome = padding * 2
|
||||||
|
const outerWidth = maxImage + chrome
|
||||||
|
const outerHeight = maxImage + chrome
|
||||||
|
|
||||||
|
const viewportW = window.innerWidth
|
||||||
|
const viewportH = window.innerHeight
|
||||||
|
|
||||||
|
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
|
||||||
|
|
||||||
|
const fitsAbove = rect.top >= outerHeight + gap + 8
|
||||||
|
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
|
||||||
|
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
|
||||||
|
|
||||||
|
return { left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const active = imagePreview()
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
|
||||||
|
// Hide the popover to avoid showing it in the wrong place.
|
||||||
|
const hide = () => setImagePreview(null)
|
||||||
|
window.addEventListener("scroll", hide, true)
|
||||||
|
window.addEventListener("resize", hide)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("scroll", hide, true)
|
||||||
|
window.removeEventListener("resize", hide)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
||||||
|
|
||||||
let topRowEl: HTMLDivElement | undefined
|
let topRowEl: HTMLDivElement | undefined
|
||||||
@@ -178,6 +230,11 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
|
||||||
|
if (!url) return
|
||||||
|
setImagePreview({ anchor, url, name })
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.error) return null
|
if (!info || info.role !== "assistant" || !info.error) return null
|
||||||
@@ -521,6 +578,12 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<div
|
<div
|
||||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
||||||
title={name}
|
title={name}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isImage) return
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
showImagePreview(el, attachment.url || "", name)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setImagePreview(null)}
|
||||||
>
|
>
|
||||||
<Show when={isImage} fallback={
|
<Show when={isImage} fallback={
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -549,11 +612,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isImage}>
|
|
||||||
<div class="attachment-chip-preview">
|
|
||||||
<img src={attachment.url} alt={name} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -561,6 +619,31 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={imagePreview()}>
|
||||||
|
{(stateAccessor) => {
|
||||||
|
const state = stateAccessor()
|
||||||
|
const pos = () => getImagePreviewPosition()
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Show when={pos()}>
|
||||||
|
{(posAccessor) => {
|
||||||
|
const coords = posAccessor()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="attachment-image-popover"
|
||||||
|
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<img src={state.url} alt={state.name} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={props.record.status === "sending"}>
|
<Show when={props.record.status === "sending"}>
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||||
|
|||||||
@@ -203,6 +203,27 @@
|
|||||||
border-top: 1px solid var(--border-base);
|
border-top: 1px solid var(--border-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image attachment preview popover.
|
||||||
|
Rendered via a Portal to avoid being clipped by the message stream scroller. */
|
||||||
|
.attachment-image-popover {
|
||||||
|
position: fixed;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--popover-shadow);
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-image-popover img {
|
||||||
|
display: block;
|
||||||
|
max-width: 320px;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.message-error {
|
.message-error {
|
||||||
@apply text-xs mt-1;
|
@apply text-xs mt-1;
|
||||||
color: var(--status-error);
|
color: var(--status-error);
|
||||||
|
|||||||
Reference in New Issue
Block a user