Skip to content

Commit

Permalink
Merge pull request #587 from refly-ai/feat/duplicate-canvas
Browse files Browse the repository at this point in the history
Feat/duplicate canvas
  • Loading branch information
mrcfps authored Mar 11, 2025
2 parents 40140cf + 220ebbf commit f0e5392
Show file tree
Hide file tree
Showing 15 changed files with 149 additions and 53 deletions.
19 changes: 11 additions & 8 deletions apps/api/src/canvas/canvas.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,7 @@ export class CanvasService {
const newTitle = title || canvas.title;
this.logger.log(`Duplicating canvas ${canvasId} to ${newCanvasId} with ${newTitle}`);

const doc = new Y.Doc();
doc.getText('title').insert(0, newTitle);
const stateStorageKey = `state/${newCanvasId}`;
await this.saveCanvasYDoc(stateStorageKey, doc);

const newCanvas = await this.prisma.canvas.create({
data: {
Expand All @@ -207,14 +204,15 @@ export class CanvasService {
uid: user.uid,
sourceCanvasId: canvasId,
targetCanvasId: newCanvasId,
title: newTitle,
duplicateEntities,
});

return newCanvas;
}

async _duplicateCanvas(jobData: DuplicateCanvasJobData) {
const { uid, sourceCanvasId, targetCanvasId, duplicateEntities } = jobData;
const { uid, sourceCanvasId, targetCanvasId, duplicateEntities, title } = jobData;

const user = await this.prisma.user.findUnique({
where: { uid },
Expand All @@ -232,11 +230,13 @@ export class CanvasService {
throw new CanvasNotFoundError();
}

const readable = await this.minio.client.getObject(sourceCanvas.stateStorageKey);
const state = await streamToBuffer(readable);

const doc = new Y.Doc();
Y.applyUpdate(doc, state);

if (sourceCanvas.stateStorageKey) {
const readable = await this.minio.client.getObject(sourceCanvas.stateStorageKey);
const state = await streamToBuffer(readable);
Y.applyUpdate(doc, state);
}

const nodes: CanvasNode[] = doc.getArray('nodes').toJSON();
this.logger.log(
Expand Down Expand Up @@ -303,6 +303,9 @@ export class CanvasService {
}

doc.transact(() => {
doc.getText('title').delete(0, doc.getText('title').length);
doc.getText('title').insert(0, title);

doc.getArray('nodes').delete(0, doc.getArray('nodes').length);
doc.getArray('nodes').insert(0, nodes);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,62 @@
import { useEffect } from 'react';
import { Form, Input, Modal } from 'antd';
import { useEffect, useState } from 'react';
import { Checkbox, Form, Input, Modal, message } from 'antd';
import { useTranslation } from 'react-i18next';
import { useDuplicateCanvas } from '@refly-packages/ai-workspace-common/hooks/use-duplicate-canvas';
import getClient from '@refly-packages/ai-workspace-common/requests/proxiedRequest';
import { useNavigate } from 'react-router-dom';
import { useHandleSiderData } from '@refly-packages/ai-workspace-common/hooks/use-handle-sider-data';

type FieldType = {
title: string;
duplicateEntities?: boolean;
};

interface DuplicateCanvasModalProps {
canvasId: string;
canvasName?: string;
visible: boolean;
setVisible: (visible: boolean) => void;
}

export const DuplicateCanvasModal = ({
canvasId,
canvasName,
visible,
setVisible,
}: DuplicateCanvasModalProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [form] = Form.useForm();
const { duplicateCanvas, loading } = useDuplicateCanvas();
const [loading, setLoading] = useState(false);
const { getCanvasList } = useHandleSiderData();

const onSubmit = () => {
form.validateFields().then((values) => {
const { title } = values;
console.log('title', title);
duplicateCanvas(canvasId, () => {
setVisible(false);
const onSubmit = async () => {
form.validateFields().then(async (values) => {
if (loading) return;
setLoading(true);
const { title, duplicateEntities } = values;
const { data } = await getClient().duplicateCanvas({
body: {
canvasId,
title,
duplicateEntities,
},
});
setLoading(false);

if (data?.success && data?.data?.canvasId) {
message.success(t('canvas.action.duplicateSuccess'));
setVisible(false);
getCanvasList();
navigate(`/canvas/${data.data.canvasId}`);
}
});
};

useEffect(() => {
if (visible) {
form.resetFields();
form.setFieldValue('duplicateEntities', false);
form.setFieldValue('title', canvasName);
}
}, [visible]);

Expand All @@ -45,16 +71,23 @@ export const DuplicateCanvasModal = ({
cancelText={t('common.cancel')}
title={t('template.duplicateCanvas')}
>
<div className="w-full h-full overflow-y-auto">
<Form form={form}>
<Form.Item
<div className="w-full h-full overflow-y-auto mt-3">
<Form form={form} autoComplete="off">
<Form.Item<FieldType>
required
label={t('template.canvasTitle')}
name="title"
className="mb-3"
rules={[{ required: true, message: t('common.required') }]}
>
<Input placeholder={t('template.duplicateCanvasTitlePlaceholder')} />
</Form.Item>

<Form.Item className="ml-2.5" name="duplicateEntities" valuePropName="checked">
<Checkbox>
<span className="text-sm">{t('template.duplicateCanvasEntities')}</span>
</Checkbox>
</Form.Item>
</Form>
</div>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ interface ZoomControlsProps {
currentZoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
canZoomIn: boolean;
canZoomOut: boolean;
t: TFunction;
Expand Down Expand Up @@ -167,7 +168,15 @@ ModeSelector.displayName = 'ModeSelector';

// Create a memoized zoom controls component
const ZoomControls = memo(
({ currentZoom, onZoomIn, onZoomOut, canZoomIn, canZoomOut, t }: ZoomControlsProps) => (
({
currentZoom,
onZoomIn,
onZoomOut,
onZoomReset,
canZoomIn,
canZoomOut,
t,
}: ZoomControlsProps) => (
<>
<TooltipButton
tooltip={t('canvas.toolbar.tooltip.zoomOut')}
Expand All @@ -179,10 +188,12 @@ const ZoomControls = memo(
</TooltipButton>

<TooltipButton
tooltip={t('canvas.toolbar.tooltip.zoom')}
className={`${buttonClass} pointer-events-none mx-1.5`}
tooltip={t('canvas.toolbar.tooltip.zoomReset')}
className={`${buttonClass} mx-1.5`}
>
<div className="text-xs">{Math.round(currentZoom * 100)}%</div>
<div className="text-xs" onClick={onZoomReset}>
{Math.round(currentZoom * 100)}%
</div>
</TooltipButton>

<TooltipButton
Expand Down Expand Up @@ -257,6 +268,10 @@ export const LayoutControl: React.FC<LayoutControlProps> = memo(
}
}, [currentZoom, reactFlowInstance]);

const handleZoomReset = useCallback(() => {
reactFlowInstance?.zoomTo(1);
}, [reactFlowInstance]);

const handleFitView = useCallback(() => {
reactFlowInstance?.fitView();
}, [reactFlowInstance]);
Expand Down Expand Up @@ -341,6 +356,7 @@ export const LayoutControl: React.FC<LayoutControlProps> = memo(
currentZoom={currentZoom}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomReset={handleZoomReset}
canZoomIn={canZoomIn}
canZoomOut={canZoomOut}
t={t}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const CodeArtifactNodePreviewComponent = ({ node, artifactId }: CodeArtifactNode
const [isShowingCodeViewer, setIsShowingCodeViewer] = useState(true);
const setNodeDataByEntity = useSetNodeDataByEntity();
const { addNode } = useAddNode();
const { readonly } = useCanvasContext();
const { readonly: canvasReadOnly } = useCanvasContext();
// Use activeTab from node metadata with fallback to 'code'
const { activeTab = 'code', type = 'text/html', language = 'html' } = node.data?.metadata || {};
const [currentTab, setCurrentTab] = useState<'code' | 'preview'>(activeTab as 'code' | 'preview');
Expand Down Expand Up @@ -212,7 +212,7 @@ const CodeArtifactNodePreviewComponent = ({ node, artifactId }: CodeArtifactNode
onClose={handleClose}
onRequestFix={handleRequestFix}
onChange={handleCodeChange}
readOnly={readonly}
canvasReadOnly={canvasReadOnly}
type={currentType as CodeArtifactType}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const NodeContent = memo(

// Use isOperating for UI state (disabled controls when operating)
const isReadOnly = !!isOperating;
const { readonly: canvasReadOnly } = useCanvasContext();

// Sync local state with metadata changes
useEffect(() => {
Expand Down Expand Up @@ -218,6 +219,7 @@ const NodeContent = memo(
}
}}
readOnly={isReadOnly}
canvasReadOnly={canvasReadOnly}
type={currentType}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const CanvasTitle = memo(
{canvasTitle || t('common.untitled')}
</Typography.Text>
)}
<IconEdit />
<IconEdit className="text-gray-500 flex items-center justify-center" />
</div>

<CanvasRename
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { CanvasTitle, ReadonlyCanvasTitle } from './canvas-title';
import { ToolbarButtons, WarningButton } from './buttons';
import { CanvasActionDropdown } from '@refly-packages/ai-workspace-common/components/workspace/canvas-list-modal/canvasActionDropdown';
import ShareSettings from './share-settings';
import { DuplicateCanvasModal } from '@refly-packages/ai-workspace-common/components/canvas-template/duplicate-canvas-modal';
import { useUserStoreShallow } from '@refly-packages/ai-workspace-common/stores/user';
import './index.scss';
import { IconLink } from '@refly-packages/ai-workspace-common/components/common/icon';
Expand Down Expand Up @@ -94,7 +93,6 @@ export const TopToolbar: FC<TopToolbarProps> = memo(({ canvasId }) => {
const hasCanvasSynced = config?.localSyncedAt > 0 && config?.remoteSyncedAt > 0;
const showWarning = connectionTimeout && !hasCanvasSynced && provider?.status !== 'connected';

const [showDuplicateModal, setShowDuplicateModal] = useState(false);
const { duplicateCanvas, loading: duplicating } = useDuplicateCanvas();
const handleDuplicate = () => {
if (!isLogin) {
Expand Down Expand Up @@ -187,11 +185,6 @@ export const TopToolbar: FC<TopToolbarProps> = memo(({ canvasId }) => {
)}
</div>
</div>
<DuplicateCanvasModal
canvasId={canvasId}
visible={showDuplicateModal}
setVisible={setShowDuplicateModal}
/>
</>
);
});
4 changes: 2 additions & 2 deletions packages/ai-workspace-common/src/components/common/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
LuLink,
LuShare2,
LuCirclePlay,
LuPencilLine,
} from 'react-icons/lu';
import {
RiErrorWarningLine,
Expand All @@ -56,7 +57,6 @@ import { TiDocumentDelete } from 'react-icons/ti';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
import { BiText } from 'react-icons/bi';
import { BsDiscord, BsTwitterX, BsGithub, BsEnvelope } from 'react-icons/bs';
import { PiNotePencil } from 'react-icons/pi';
import { VscNotebookTemplate } from 'react-icons/vsc';

import { TfiBlackboard } from 'react-icons/tfi';
Expand Down Expand Up @@ -119,7 +119,7 @@ export const IconReply = HiOutlineReply;
export const IconMoreHorizontal = IoIosMore;
export const IconPin = LuPin;
export const IconUnpin = LuPinOff;
export const IconEdit = PiNotePencil;
export const IconEdit = LuPencilLine;
export const IconDelete = LuTrash;
export const IconSearch = LuSearch;
export const IconError = RiErrorWarningLine;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IconDelete,
IconEdit,
IconPlayOutline,
IconCopy,
} from '@refly-packages/ai-workspace-common/components/common/icon';
import { useDeleteCanvas } from '@refly-packages/ai-workspace-common/hooks/canvas/use-delete-canvas';
import { useTranslation } from 'react-i18next';
Expand All @@ -13,6 +14,7 @@ import { useSiderStoreShallow } from '@refly-packages/ai-workspace-common/stores
import { useUpdateCanvas } from '@refly-packages/ai-workspace-common/queries';
import { IoAlertCircle } from 'react-icons/io5';
import { useSubscriptionUsage } from '@refly-packages/ai-workspace-common/hooks/use-subscription-usage';
import { DuplicateCanvasModal } from '@refly-packages/ai-workspace-common/components/canvas-template/duplicate-canvas-modal';

interface CanvasActionDropdown {
canvasId: string;
Expand Down Expand Up @@ -47,6 +49,8 @@ export const CanvasActionDropdown = (props: CanvasActionDropdown) => {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleteFile, setIsDeleteFile] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);

const onChange: CheckboxProps['onChange'] = (e) => {
setIsDeleteFile(e.target.checked);
};
Expand Down Expand Up @@ -100,6 +104,22 @@ export const CanvasActionDropdown = (props: CanvasActionDropdown) => {
),
key: 'rename',
},
{
label: (
<div
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
setIsDuplicateModalOpen(true);
setPopupVisible(false);
}}
>
<IconCopy size={14} className="mr-2" />
{t('canvas.toolbar.duplicate')}
</div>
),
key: 'duplicate',
},
{
label: (
<div
Expand Down Expand Up @@ -205,6 +225,13 @@ export const CanvasActionDropdown = (props: CanvasActionDropdown) => {
</Checkbox>
</div>
</Modal>

<DuplicateCanvasModal
canvasId={canvasId}
visible={isDuplicateModalOpen}
setVisible={setIsDuplicateModalOpen}
canvasName={canvasName}
/>
</div>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const ActionDropdown = ({ doc, afterDelete }: { doc: Document; afterDelete: () =
const items: MenuProps['items'] = [
!isShareCanvas && {
label: (
<div className="flex items-center">
<div className="flex items-center flex-grow">
<LuPlus size={16} className="mr-2" />
{t('workspace.addToCanvas')}
</div>
Expand All @@ -82,7 +82,7 @@ const ActionDropdown = ({ doc, afterDelete }: { doc: Document; afterDelete: () =
cancelText={t('common.cancel')}
overlayStyle={{ maxWidth: '300px' }}
>
<div className="flex items-center text-red-600">
<div className="flex items-center text-red-600 flex-grow">
<IconDelete size={16} className="mr-2" />
{t('workspace.deleteDropdownMenu.delete')}
</div>
Expand Down
Loading

0 comments on commit f0e5392

Please sign in to comment.