mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
feat(ui): open gallery when clicking images in chat messages
Clicking an image in a chat message now opens the same ImageGalleryModal used by the attachments gallery. Matches by contentPath or assetId. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c830c64727
commit
d0920da459
3 changed files with 45 additions and 5 deletions
|
|
@ -80,6 +80,7 @@ interface IssueChatMessageContext {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
|
|
@ -184,6 +185,7 @@ interface IssueChatThreadProps {
|
||||||
includeSucceededRunsWithoutOutput?: boolean;
|
includeSucceededRunsWithoutOutput?: boolean;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
|
|
@ -246,8 +248,9 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
|
const { onImageClick } = useContext(IssueChatCtx);
|
||||||
return (
|
return (
|
||||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
|
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
|
||||||
{text}
|
{text}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
);
|
);
|
||||||
|
|
@ -1604,6 +1607,7 @@ export function IssueChatThread({
|
||||||
includeSucceededRunsWithoutOutput = false,
|
includeSucceededRunsWithoutOutput = false,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
|
onImageClick,
|
||||||
}: IssueChatThreadProps) {
|
}: IssueChatThreadProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
|
|
@ -1731,6 +1735,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
|
|
@ -1741,6 +1746,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface MarkdownBodyProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
resolveImageSrc?: (src: string) => string | null;
|
||||||
|
/** Called when a user clicks an inline image */
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||||
|
|
@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
|
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const components: Components = {
|
const components: Components = {
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
|
|
@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (resolveImageSrc) {
|
if (resolveImageSrc || onImageClick) {
|
||||||
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||||
const resolved = src ? resolveImageSrc(src) : null;
|
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||||
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
|
const finalSrc = resolved ?? src;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...imgProps}
|
||||||
|
src={finalSrc}
|
||||||
|
alt={alt ?? ""}
|
||||||
|
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||||
|
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1356,6 +1356,28 @@ export function IssueDetail() {
|
||||||
const attachmentList = attachments ?? [];
|
const attachmentList = attachments ?? [];
|
||||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||||
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
||||||
|
|
||||||
|
const handleChatImageClick = useCallback(
|
||||||
|
(src: string) => {
|
||||||
|
// Try exact contentPath match first
|
||||||
|
let idx = imageAttachments.findIndex((a) => a.contentPath === src);
|
||||||
|
if (idx < 0) {
|
||||||
|
// Try matching by asset ID extracted from /api/assets/{assetId}/content URLs
|
||||||
|
const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/);
|
||||||
|
if (assetMatch) {
|
||||||
|
idx = imageAttachments.findIndex((a) => a.assetId === assetMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
setGalleryIndex(idx);
|
||||||
|
setGalleryOpen(true);
|
||||||
|
} else {
|
||||||
|
// Image not in attachment list — open in new tab
|
||||||
|
window.open(src, "_blank");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageAttachments],
|
||||||
|
);
|
||||||
const hasAttachments = attachmentList.length > 0;
|
const hasAttachments = attachmentList.length > 0;
|
||||||
const attachmentUploadButton = (
|
const attachmentUploadButton = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1897,6 +1919,7 @@ export function IssueDetail() {
|
||||||
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
|
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
|
||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
|
onImageClick={handleChatImageClick}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue