feat: add share message as image functionality (#553)
* feat: add share message as image functionality - Add html-to-image dependency for DOM-to-PNG conversion - Add share button to agent message footer with loading state - Generate high-quality PNG images with 24px padding for better readability - Use theme-aware background colors - Handle errors gracefully with toast notifications * fix: align lockfile workspace versions to 1.8.1 --------- Co-authored-by: Jovines <jovines@qq.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Jovines
Bohdan Triapitsyn
parent
be413771dc
commit
934af638fe
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.7.5",
|
||||
"version": "1.8.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.3.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.7.5",
|
||||
"version": "1.8.1",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
@@ -162,6 +162,7 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"html-to-image": "^1.11.13",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -210,7 +211,7 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.7.5",
|
||||
"version": "1.8.1",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.2.10",
|
||||
@@ -233,7 +234,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.7.5",
|
||||
"version": "1.8.1",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -1859,6 +1860,8 @@
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"html-to-image": "^1.11.13",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
|
||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine } from '@remixicon/react';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { toast } from '@/components/ui';
|
||||
import { formatTimestampForDisplay } from './timeFormat';
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
@@ -779,6 +781,66 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
[assistantTextParts, isTTSPlaying, playTTS, stopTTS]
|
||||
);
|
||||
|
||||
const [isSharing, setIsSharing] = React.useState(false);
|
||||
|
||||
const handleShareImage = React.useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!messageContentRef.current || isSharing) return;
|
||||
|
||||
setIsSharing(true);
|
||||
let wrapper: HTMLDivElement | null = null;
|
||||
try {
|
||||
const originalElement = messageContentRef.current;
|
||||
const computedStyle = window.getComputedStyle(originalElement);
|
||||
const paddingSize = 24;
|
||||
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = `
|
||||
padding: ${paddingSize}px;
|
||||
background-color: var(--surface-background);
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const clone = originalElement.cloneNode(true) as HTMLElement;
|
||||
clone.style.cssText = `
|
||||
${computedStyle.cssText}
|
||||
transform: none;
|
||||
contain: none;
|
||||
`;
|
||||
|
||||
wrapper.appendChild(clone);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
const dataUrl = await toPng(wrapper, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
backgroundColor: 'var(--surface-background)',
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `message-${messageId}.png`;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success('Image saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate image:', error);
|
||||
toast.error('Failed to generate image');
|
||||
} finally {
|
||||
if (wrapper && wrapper.parentNode) {
|
||||
wrapper.parentNode.removeChild(wrapper);
|
||||
}
|
||||
setIsSharing(false);
|
||||
}
|
||||
},
|
||||
[messageId, isSharing]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearCopyHintTimeout();
|
||||
@@ -1172,25 +1234,48 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleForkClick}
|
||||
>
|
||||
<RiChatNewLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isSharing || !hasCopyableText}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
(!hasCopyableText || isSharing) && 'opacity-50'
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleShareImage}
|
||||
>
|
||||
{isSharing ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiShare2Line className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleForkClick}
|
||||
>
|
||||
<RiChatNewLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user