From d75742f4226a8ce36ca3f1f7d526cb71e335bc72 Mon Sep 17 00:00:00 2001 From: Kyohei Fukuda Date: Sat, 9 May 2026 17:57:37 +0900 Subject: [PATCH] feat(playground): add project rename duplicate save as (#1496) --- playground/e2e/playgroundProjects.test.ts | 47 ++++ playground/src/components/NavBar.tsx | 15 +- .../src/components/PlaygroundButton.tsx | 24 +- playground/src/lib/playgroundProjects.ts | 60 +++++ playground/src/routes/Designer.tsx | 23 +- playground/src/routes/FormAndViewer.tsx | 13 +- playground/src/routes/JsxPlayground.tsx | 29 ++- playground/src/routes/Md2Pdf.tsx | 19 +- playground/src/routes/Templates.tsx | 210 ++++++++++++++++-- 9 files changed, 374 insertions(+), 66 deletions(-) diff --git a/playground/e2e/playgroundProjects.test.ts b/playground/e2e/playgroundProjects.test.ts index 8fa5d8fb..ff82e286 100644 --- a/playground/e2e/playgroundProjects.test.ts +++ b/playground/e2e/playgroundProjects.test.ts @@ -1,8 +1,10 @@ import type { Template } from '@pdfme/common'; import { deletePlaygroundProject, + duplicatePlaygroundProject, getActivePlaygroundProject, readPlaygroundProjects, + renamePlaygroundProject, savePlaygroundProject, setPlaygroundProjectThumbnail, } from '../src/lib/playgroundProjects'; @@ -82,6 +84,51 @@ describe('playground project storage', () => { expect(getActivePlaygroundProject(storage)).toBeNull(); }); + it('renames and duplicates projects without changing the original content', () => { + const storage = new MemoryStorage(); + const saved = savePlaygroundProject( + { + inputs: [{ name: 'Ada' }], + kind: 'template', + template, + thumbnail: 'data:image/png;base64,abc', + title: 'Original', + }, + storage, + ); + + const renamed = renamePlaygroundProject(saved.id, ' Renamed project ', storage); + expect(renamed?.id).toBe(saved.id); + expect(renamed?.title).toBe('Renamed project'); + expect(readPlaygroundProjects(storage)).toHaveLength(1); + + const duplicated = duplicatePlaygroundProject(saved.id, ' Copy project ', storage); + expect(duplicated?.id).not.toBe(saved.id); + expect(duplicated?.title).toBe('Copy project'); + expect(duplicated?.template).toEqual(template); + expect(duplicated?.inputs).toEqual([{ name: 'Ada' }]); + expect(duplicated?.thumbnail).toBe('data:image/png;base64,abc'); + expect(getActivePlaygroundProject(storage)?.id).toBe(duplicated?.id); + + const duplicateWithSameTitle = duplicatePlaygroundProject(saved.id, ' Copy project ', storage); + expect(duplicateWithSameTitle?.title).toBe('Copy project 2'); + + const projects = readPlaygroundProjects(storage); + expect(projects).toHaveLength(3); + expect(projects.map((project) => project.title).sort()).toEqual([ + 'Copy project', + 'Copy project 2', + 'Renamed project', + ]); + }); + + it('returns null when project actions target a missing project', () => { + const storage = new MemoryStorage(); + + expect(renamePlaygroundProject('missing', 'Nope', storage)).toBeNull(); + expect(duplicatePlaygroundProject('missing', 'Nope', storage)).toBeNull(); + }); + it('migrates legacy localStorage template state into a project', () => { const storage = new MemoryStorage(); storage.setItem('template', JSON.stringify(template)); diff --git a/playground/src/components/NavBar.tsx b/playground/src/components/NavBar.tsx index 4ee81d0e..776b6f3a 100644 --- a/playground/src/components/NavBar.tsx +++ b/playground/src/components/NavBar.tsx @@ -16,17 +16,12 @@ export function NavBar({ items }: NavBarProps) { {({ open }) => ( <>
-
-
-
-
+
+
+
+
{items.map(({ label, content }, index) => ( -
+
{content}
diff --git a/playground/src/components/PlaygroundButton.tsx b/playground/src/components/PlaygroundButton.tsx index a4bf9dcc..981ded14 100644 --- a/playground/src/components/PlaygroundButton.tsx +++ b/playground/src/components/PlaygroundButton.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes } from 'react'; +import { forwardRef, type ButtonHTMLAttributes } from 'react'; type PlaygroundButtonVariant = 'danger' | 'ghost' | 'primary' | 'secondary'; @@ -18,15 +18,19 @@ const buttonVariants: Record = { const joinClassNames = (...classes: Array) => classes.filter(Boolean).join(' '); -export default function PlaygroundButton({ - className, - fullWidth = false, - type = 'button', - variant = 'secondary', - ...props -}: PlaygroundButtonProps) { +const PlaygroundButton = forwardRef(function PlaygroundButton( + { + className, + fullWidth = false, + type = 'button', + variant = 'secondary', + ...props + }, + ref, +) { return (
), diff --git a/playground/src/routes/JsxPlayground.tsx b/playground/src/routes/JsxPlayground.tsx index 4535a97c..61bf2b75 100644 --- a/playground/src/routes/JsxPlayground.tsx +++ b/playground/src/routes/JsxPlayground.tsx @@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { checkTemplate, type Template } from '@pdfme/common'; import type { RenderResult } from '@pdfme/jsx'; import { Viewer } from '@pdfme/ui'; -import { Download, ExternalLink, PencilRuler, Save } from 'lucide-react'; +import { Copy, Download, ExternalLink, PencilRuler, Save } from 'lucide-react'; import { toast } from 'react-toastify'; import CodeEditor from '../components/CodeEditor'; import PlaygroundButton from '../components/PlaygroundButton'; @@ -334,13 +334,15 @@ export default function JsxPlayground() { downloadJsonFile(template, 'jsx-template'); }; - const saveCurrentProject = async (title?: string) => { + const saveCurrentProject = async (title?: string, saveAs = false) => { if (!template) return null; + const currentTitle = projectRef.current?.title ?? `JSX - ${sourceTitle}`; const projectTitle = title ?? - projectRef.current?.title ?? - window.prompt('Project name', `JSX - ${sourceTitle}`) ?? + (saveAs + ? window.prompt('Save as', `${currentTitle} Copy`) + : projectRef.current?.title ?? window.prompt('Project name', currentTitle)) ?? ''; if (!projectTitle.trim()) return null; @@ -348,7 +350,7 @@ export default function JsxPlayground() { () => projectRef.current?.thumbnail, ); const savedProject = savePlaygroundProject({ - id: projectRef.current?.id, + id: saveAs ? undefined : projectRef.current?.id, inputs: inputsRef.current, kind: 'jsx', source: { @@ -379,6 +381,16 @@ export default function JsxPlayground() { } }; + const onSaveAsProject = async () => { + try { + checkTemplate(template); + const savedProject = await saveCurrentProject(undefined, true); + if (savedProject) toast.success(); + } catch (err) { + toast.error(getErrorMessage(err)); + } + }; + const onOpenDesigner = async () => { if (!template) return; @@ -435,6 +447,13 @@ export default function JsxPlayground() { Save Project + void onSaveAsProject()} + > + + Save As + void onOpenDesigner()} diff --git a/playground/src/routes/Md2Pdf.tsx b/playground/src/routes/Md2Pdf.tsx index dec78cb1..37aa9fad 100644 --- a/playground/src/routes/Md2Pdf.tsx +++ b/playground/src/routes/Md2Pdf.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'; import type { Template } from '@pdfme/common'; import { md2pdf } from '@pdfme/converter/md2pdf'; import { Viewer } from '@pdfme/ui'; -import { ExternalLink, Save } from 'lucide-react'; +import { Copy, ExternalLink, Save } from 'lucide-react'; import { toast } from 'react-toastify'; import { generatePDF, getFontsData } from '../helper'; import { getPlugins } from '../plugins'; @@ -173,11 +173,13 @@ export default function Md2Pdf() { } }; - const onSaveProject = async () => { + const onSaveProject = async (saveAs = false) => { if (!template) return; - const title = - projectRef.current?.title ?? window.prompt('Project name', `md2pdf - ${sourceTitle}`) ?? ''; + const currentTitle = projectRef.current?.title ?? `md2pdf - ${sourceTitle}`; + const title = saveAs + ? window.prompt('Save as', `${currentTitle} Copy`) ?? '' + : projectRef.current?.title ?? window.prompt('Project name', currentTitle) ?? ''; if (!title.trim()) return; try { @@ -185,7 +187,7 @@ export default function Md2Pdf() { () => projectRef.current?.thumbnail, ); const savedProject = savePlaygroundProject({ - id: projectRef.current?.id, + id: saveAs ? undefined : projectRef.current?.id, inputs, kind: 'md2pdf', source: { @@ -241,6 +243,13 @@ export default function Md2Pdf() { Save Project + void onSaveProject(true)} + > + + Save As + ( -
+
{thumbnail} @@ -217,6 +222,132 @@ const GalleryCard = ({
); +type ProjectActionHandler = (project: PlaygroundProject) => void; + +type ProjectMoreActionsProps = { + onDeleteProject: ProjectActionHandler; + onDownloadProjectTemplate: ProjectActionHandler; + onDuplicateProject: ProjectActionHandler; + onOpenDesigner: ProjectActionHandler; + onRenameProject: ProjectActionHandler; + project: PlaygroundProject; +}; + +const ProjectMenuItem = ({ + buttonRef, + children, + danger = false, + onClick, +}: { + buttonRef?: React.Ref; + children: React.ReactNode; + danger?: boolean; + onClick: () => void; +}) => ( + +); + +function ProjectMoreActions({ + onDeleteProject, + onDownloadProjectTemplate, + onDuplicateProject, + onOpenDesigner, + onRenameProject, + project, +}: ProjectMoreActionsProps) { + const [open, setOpen] = useState(false); + const firstMenuItemRef = React.useRef(null); + const triggerRef = React.useRef(null); + const runAction = (handler: ProjectActionHandler) => { + setOpen(false); + handler(project); + }; + + useEffect(() => { + if (!open) return; + + firstMenuItemRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open]); + + return ( +
+ setOpen((value) => !value)} + > + + + {open && ( + <> +
+ ); +} + // Author link component to avoid duplication const AuthorLink = ({ author }: { author: string }) => { if (author === DEVIN_AI_AUTHOR) { @@ -393,6 +524,34 @@ function TemplatesApp() { toast.info(`Deleted "${project.title}"`); }; + const onRenameProject = (project: PlaygroundProject) => { + const title = window.prompt('Project name', project.title) ?? ''; + if (!title.trim()) return; + + const renamedProject = renamePlaygroundProject(project.id, title); + if (!renamedProject) { + toast.error('Project not found'); + return; + } + + refreshProjects(); + toast.success(`Renamed to "${renamedProject.title}"`); + }; + + const onDuplicateProject = (project: PlaygroundProject) => { + const title = window.prompt('Duplicate as', `${project.title} Copy`) ?? ''; + if (!title.trim()) return; + + const duplicatedProject = duplicatePlaygroundProject(project.id, title); + if (!duplicatedProject) { + toast.error('Project not found'); + return; + } + + refreshProjects(); + toast.success(`Duplicated "${duplicatedProject.title}"`); + }; + const onDownloadProjectTemplate = (project: PlaygroundProject) => { const fileName = project.title.trim().replace(/[\\/:*?"<>|]+/g, '-') || 'template'; downloadJsonFile(project.template, fileName); @@ -447,33 +606,36 @@ function TemplatesApp() { } actions={ -
- {project.source && ( - navigateToProject(project, 'source')}> - - Source - - )} - navigateToProject(project, 'designer')}> - - Designer +
+ + navigateToProject(project, project.source ? 'source' : 'designer') + } + > + {project.source ? ( + <> + + Source + + ) : ( + <> + + Designer + + )} navigateToProject(project, 'form-viewer')}> - Form/Viewer - - onDownloadProjectTemplate(project)}> - - Template JSON - - onDeleteProject(project)} - variant="danger" - aria-label={`Delete ${project.title}`} - > - - Delete + Preview + navigateToProject(item, 'designer')} + onRenameProject={onRenameProject} + onDuplicateProject={onDuplicateProject} + onDownloadProjectTemplate={onDownloadProjectTemplate} + onDeleteProject={onDeleteProject} + />
} />