Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: support native Artifacts just like Claude #3985

Merged
merged 24 commits into from
Sep 18, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
✨ feat: 支持下载和复制图片
arvinxx committed Sep 18, 2024
commit 875b5ba059be77e4521c700f49e6e0ae7ed55ce8
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Icon, Tooltip } from '@lobehub/ui';
import { App, Button, Dropdown, Space } from 'antd';
import { css, cx } from 'antd-style';
import { CopyIcon, DownloadIcon } from 'lucide-react';
import { domToPng } from 'modern-screenshot';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';

import { BRANDING_NAME } from '@/const/branding';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { copyImageToClipboard } from '@/utils/clipboard';

const svgContainer = css`
width: 100%;
height: 100%;
@@ -11,14 +21,92 @@ const svgContainer = css`
}
`;

const actions = css`
position: absolute;
inset-block-end: 8px;
inset-inline-end: 8px;
`;

const DOM_ID = 'artfact-svg';
interface SVGRendererProps {
content: string;
}

const SVGRenderer = ({ content }: SVGRendererProps) => {
const { t } = useTranslation('portal');
const { message } = App.useApp();

const generatePng = async () => {
return domToPng(document.querySelector(`#${DOM_ID}`) as HTMLDivElement, {
features: {
// 不启用移除控制符,否则会导致 safari emoji 报错
removeControlCharacter: false,
},
scale: 2,
});
};

const downloadImage = async (type: string) => {
let dataUrl = '';
if (type === 'png') dataUrl = await generatePng();
else if (type === 'svg') {
const blob = new Blob([content], { type: 'image/svg+xml' });

dataUrl = URL.createObjectURL(blob);
}

const title = chatPortalSelectors.artifactTitle(useChatStore.getState());

const link = document.createElement('a');
link.download = `${BRANDING_NAME}_${title}.${type}`;
link.href = dataUrl;
link.click();
link.remove();
};

return (
<Flexbox align={'center'} className="svg-renderer" height={'100%'}>
<Center className={cx(svgContainer)} dangerouslySetInnerHTML={{ __html: content }} />
<Flexbox
align={'center'}
className="svg-renderer"
height={'100%'}
style={{ position: 'relative' }}
>
<Center
className={cx(svgContainer)}
dangerouslySetInnerHTML={{ __html: content }}
id={DOM_ID}
/>
<Flexbox className={cx(actions)}>
<Space.Compact>
<Dropdown
menu={{
items: [
{ key: 'png', label: t('artifacts.svg.download.png') },
{ key: 'svg', label: t('artifacts.svg.download.svg') },
],
onClick: ({ key }) => {
downloadImage(key);
},
}}
>
<Button icon={<Icon icon={DownloadIcon} />} />
</Dropdown>
<Tooltip title={t('artifacts.svg.copyAsImage')}>
<Button
icon={<Icon icon={CopyIcon} />}
onClick={async () => {
const dataUrl = await generatePng();
try {
await copyImageToClipboard(dataUrl);
message.success(t('artifacts.svg.copySuccess'));
} catch (e) {
message.error(t('artifacts.svg.copyFail', { error: e }));
}
}}
/>
</Tooltip>
</Space.Compact>
</Flexbox>
</Flexbox>
);
};
8 changes: 7 additions & 1 deletion src/app/(main)/chat/(workspace)/@portal/Artifacts/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { ConfigProvider, Segmented, Typography } from 'antd';
import { cx } from 'antd-style';
import { ArrowLeft, CodeIcon, EyeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';

import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { oneLineEllipsis } from '@/styles';

const Header = () => {
const { t } = useTranslation('portal');
@@ -25,7 +27,11 @@ const Header = () => {
<Flexbox align={'center'} flex={1} gap={12} horizontal justify={'space-between'} width={'100%'}>
<Flexbox align={'center'} gap={4} horizontal>
<ActionIcon icon={ArrowLeft} onClick={() => closeArtifact()} />
<Typography.Text style={{ fontSize: 16 }} type={'secondary'}>
<Typography.Text
className={cx(oneLineEllipsis)}
style={{ fontSize: 16 }}
type={'secondary'}
>
{artifactTitle}
</Typography.Text>
</Flexbox>
9 changes: 9 additions & 0 deletions src/locales/default/portal.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,15 @@ export default {
code: '代码',
preview: '预览',
},
svg: {
copyAsImage: '复制为图片',
copyFail: '复制失败,错误原因:{{error}}',
copySuccess: '图片复制成功',
download: {
png: '下载为 PNG',
svg: '下载为 SVG',
},
},
},
emptyArtifactList: '当前 Artifacts 列表为空,请在会话中按需使用插件后再查看',
emptyKnowledgeList: '当前知识列表为空',
53 changes: 53 additions & 0 deletions src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const copyUsingFallback = (imageUrl: string) => {
const img = new Image();
img.addEventListener('load', function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx!.drawImage(img, 0, 0);

try {
canvas.toBlob(function (blob) {
// @ts-ignore
const item = new ClipboardItem({ 'image/png': blob });
navigator.clipboard.write([item]).then(function () {
console.log('Image copied to clipboard successfully using canvas and modern API');
});
});
} catch {
// 如果 toBlob 或 ClipboardItem 不被支持,使用 data URL
const dataURL = canvas.toDataURL('image/png');
const textarea = document.createElement('textarea');
textarea.value = dataURL;
document.body.append(textarea);
textarea.select();

document.execCommand('copy');

textarea.remove();
}
});
img.src = imageUrl;
};

const copyUsingModernAPI = async (imageUrl: string) => {
try {
const base64Response = await fetch(imageUrl);
const blob = await base64Response.blob();
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
} catch (error) {
console.error('Failed to copy image using modern API:', error);
copyUsingFallback(imageUrl);
}
};

export const copyImageToClipboard = async (imageUrl: string) => {
// 检查是否支持现代 Clipboard API
if (navigator.clipboard && 'write' in navigator.clipboard) {
await copyUsingModernAPI(imageUrl);
} else {
copyUsingFallback(imageUrl);
}
};