feat(playground): add project rename duplicate save as (#1496)

This commit is contained in:
Kyohei Fukuda
2026-05-09 17:57:37 +09:00
committed by GitHub
parent 097ed8ea19
commit d75742f422
9 changed files with 374 additions and 66 deletions

View File

@@ -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));

View File

@@ -16,17 +16,12 @@ export function NavBar({ items }: NavBarProps) {
{({ open }) => (
<>
<div className="mx-auto px-2">
<div className="relative flex h-16 items-center justify-between">
<div className="flex flex-1 items-center justify-start lg:items-stretch lg:justify-start">
<div className="hidden lg:block">
<div
className={'grid gap-4 text-sm items-end justify-items-center'}
style={{
gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,
}}
>
<div className="relative flex min-h-16 items-center justify-between py-2 lg:py-0">
<div className="flex min-w-0 flex-1 items-center justify-start lg:items-stretch lg:justify-start">
<div className="hidden min-w-0 lg:block lg:w-full">
<div className="flex min-w-0 items-end gap-5 overflow-x-auto text-sm">
{items.map(({ label, content }, index) => (
<div key={label || String(index)}>
<div key={label || String(index)} className="shrink-0">
<label className="block mb-1 font-medium text-gray-700">{label}</label>
{content}
</div>

View File

@@ -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<PlaygroundButtonVariant, string> = {
const joinClassNames = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ');
export default function PlaygroundButton({
className,
fullWidth = false,
type = 'button',
variant = 'secondary',
...props
}: PlaygroundButtonProps) {
const PlaygroundButton = forwardRef<HTMLButtonElement, PlaygroundButtonProps>(function PlaygroundButton(
{
className,
fullWidth = false,
type = 'button',
variant = 'secondary',
...props
},
ref,
) {
return (
<button
ref={ref}
type={type}
className={joinClassNames(
'inline-flex min-w-0 items-center justify-center gap-1 whitespace-nowrap rounded border px-2 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:px-3',
@@ -37,4 +41,6 @@ export default function PlaygroundButton({
{...props}
/>
);
}
});
export default PlaygroundButton;

View File

@@ -54,6 +54,17 @@ const normalizeTitle = (title: string, fallback: string) => {
return normalized || fallback;
};
const createUniqueProjectTitle = (title: string, projects: PlaygroundProject[]) => {
const normalizedTitle = normalizeTitle(title, 'Untitled Project');
const existingTitles = new Set(projects.map((project) => project.title));
if (!existingTitles.has(normalizedTitle)) return normalizedTitle;
for (let index = 2; ; index += 1) {
const candidate = `${normalizedTitle} ${index}`;
if (!existingTitles.has(candidate)) return candidate;
}
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
@@ -212,6 +223,55 @@ export const deletePlaygroundProject = (projectId: string, storage = getStorage(
}
};
export const renamePlaygroundProject = (
projectId: string,
title: string,
storage = getStorage(),
) => {
const projects = readPlaygroundProjects(storage);
const project = projects.find((item) => item.id === projectId);
if (!project) return null;
const updatedProject: PlaygroundProject = {
...project,
title: normalizeTitle(title, project.title),
updatedAt: Date.now(),
};
writePlaygroundProjects(
projects
.map((item) => (item.id === projectId ? updatedProject : item))
.sort((a, b) => b.updatedAt - a.updatedAt),
storage,
);
return updatedProject;
};
export const duplicatePlaygroundProject = (
projectId: string,
title?: string,
storage = getStorage(),
) => {
const projects = readPlaygroundProjects(storage);
const project = projects.find((item) => item.id === projectId);
if (!project) return null;
const now = Date.now();
const duplicatedProject: PlaygroundProject = {
...project,
createdAt: now,
id: createProjectId(),
title: createUniqueProjectTitle(title ?? `${project.title} Copy`, projects),
updatedAt: now,
};
writePlaygroundProjects(
[duplicatedProject, ...projects].sort((a, b) => b.updatedAt - a.updatedAt),
storage,
);
setActivePlaygroundProjectId(duplicatedProject.id, storage);
return duplicatedProject;
};
export const setPlaygroundProjectThumbnail = (
projectId: string,
thumbnail: string,

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Code2, Download, Save } from 'lucide-react';
import { Code2, Copy, Download, Save } from 'lucide-react';
import { cloneDeep, Template, checkTemplate, Lang, isBlankPdf } from '@pdfme/common';
import { Designer } from '@pdfme/ui';
import {
@@ -116,15 +116,16 @@ function DesignerApp() {
}, []);
const onSaveTemplate = useCallback(
async (template?: Template) => {
async (template?: Template, saveAs = false) => {
if (!designer.current) return;
const currentProject = projectRef.current;
const nextTemplate = template || designer.current.getTemplate();
const title =
currentProject?.title ??
window.prompt('Project name', projectTitleRef.current || 'Untitled Template') ??
'';
const currentTitle =
(currentProject?.title ?? projectTitleRef.current) || 'Untitled Template';
const title = saveAs
? window.prompt('Save as', `${currentTitle} Copy`) ?? ''
: currentProject?.title ?? window.prompt('Project name', currentTitle) ?? '';
if (!title.trim()) return;
const thumbnail = await createTemplateThumbnailDataUrl(
@@ -132,7 +133,7 @@ function DesignerApp() {
currentProject?.inputs,
).catch(() => currentProject?.thumbnail);
const savedProject = savePlaygroundProject({
id: currentProject?.id,
id: saveAs ? undefined : currentProject?.id,
inputs: currentProject?.inputs,
kind: currentProject?.kind ?? 'template',
source: currentProject?.source,
@@ -405,6 +406,14 @@ function DesignerApp() {
<Save className="size-3.5" />
Save Project
</PlaygroundButton>
<PlaygroundButton
id="save-as"
disabled={editingStaticSchemas}
onClick={() => void onSaveTemplate(undefined, true)}
>
<Copy className="size-3.5" />
Save As
</PlaygroundButton>
<PlaygroundButton
id="reset-template"
disabled={editingStaticSchemas}

View File

@@ -160,23 +160,23 @@ function FormAndViewerApp() {
}
};
const onSaveInputs = async () => {
const onSaveInputs = async (saveAs = false) => {
if (!ui.current) return;
const currentProject = projectRef.current;
const nextInputs = ui.current.getInputs();
const nextTemplate = ui.current.getTemplate();
const title =
currentProject?.title ??
window.prompt('Project name', projectTitle || 'Untitled Template') ??
'';
const currentTitle = (currentProject?.title ?? projectTitle) || 'Untitled Template';
const title = saveAs
? window.prompt('Save as', `${currentTitle} Copy`) ?? ''
: currentProject?.title ?? window.prompt('Project name', currentTitle) ?? '';
if (!title.trim()) return;
const thumbnail = await createTemplateThumbnailDataUrl(nextTemplate, nextInputs).catch(
() => currentProject?.thumbnail,
);
const savedProject = savePlaygroundProject({
id: currentProject?.id,
id: saveAs ? undefined : currentProject?.id,
inputs: nextInputs,
kind: currentProject?.kind ?? 'template',
source: currentProject?.source,
@@ -251,6 +251,7 @@ function FormAndViewerApp() {
<PlaygroundButton onClick={() => void onSaveInputs()}>
Save
</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs(true)}>Save As</PlaygroundButton>
<PlaygroundButton onClick={onResetInputs}>Reset</PlaygroundButton>
</div>
),

View File

@@ -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(<ProjectSavedToast title={savedProject.title} />);
} catch (err) {
toast.error(getErrorMessage(err));
}
};
const onOpenDesigner = async () => {
if (!template) return;
@@ -435,6 +447,13 @@ export default function JsxPlayground() {
<Save className="size-4" />
Save Project
</PlaygroundButton>
<PlaygroundButton
disabled={!template || Boolean(error)}
onClick={() => void onSaveAsProject()}
>
<Copy className="size-4" />
Save As
</PlaygroundButton>
<PlaygroundButton
disabled={!template || Boolean(error)}
onClick={() => void onOpenDesigner()}

View File

@@ -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 className="size-4" />
Save Project
</PlaygroundButton>
<PlaygroundButton
disabled={!template || Boolean(error)}
onClick={() => void onSaveProject(true)}
>
<Copy className="size-4" />
Save As
</PlaygroundButton>
<PlaygroundButton
disabled={!template || Boolean(error) || isGeneratingPdf}
onClick={onGeneratePdf}

View File

@@ -3,9 +3,12 @@ import { useNavigate } from 'react-router-dom';
import { checkTemplate, getInputFromTemplate, type Template } from '@pdfme/common';
import {
Code2,
Copy,
Download,
Eye,
FileText,
MoreHorizontal,
Pencil,
PencilRuler,
Trash2,
Upload,
@@ -17,9 +20,11 @@ import { jsxPlaygroundPresets } from './jsxPlaygroundExamples';
import { md2PdfPresets } from './md2PdfPresets';
import {
deletePlaygroundProject,
duplicatePlaygroundProject,
getProjectAuthoringPath,
getProjectKindLabel,
readPlaygroundProjects,
renamePlaygroundProject,
savePlaygroundProject,
setActivePlaygroundProjectId,
setPlaygroundProjectThumbnail,
@@ -190,7 +195,7 @@ const GalleryCard = ({
thumbnail: React.ReactNode;
title: string;
}) => (
<div className="relative flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="relative flex h-full flex-col rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="relative overflow-hidden rounded border border-gray-100 bg-gray-100">
{thumbnail}
<span className="absolute left-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-green-700 shadow-sm">
@@ -217,6 +222,132 @@ const GalleryCard = ({
</div>
);
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<HTMLButtonElement>;
children: React.ReactNode;
danger?: boolean;
onClick: () => void;
}) => (
<button
ref={buttonRef}
type="button"
role="menuitem"
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition ${
danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'
}`}
onClick={onClick}
>
{children}
</button>
);
function ProjectMoreActions({
onDeleteProject,
onDownloadProjectTemplate,
onDuplicateProject,
onOpenDesigner,
onRenameProject,
project,
}: ProjectMoreActionsProps) {
const [open, setOpen] = useState(false);
const firstMenuItemRef = React.useRef<HTMLButtonElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(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 (
<div className="relative">
<PlaygroundButton
ref={triggerRef}
aria-expanded={open}
aria-haspopup="menu"
aria-label={`More actions for ${project.title}`}
className="px-2 sm:px-2"
onClick={() => setOpen((value) => !value)}
>
<MoreHorizontal className="size-4" />
</PlaygroundButton>
{open && (
<>
<button
type="button"
aria-label="Close project actions"
className="fixed inset-0 z-10 cursor-default"
onClick={() => setOpen(false)}
/>
<div
role="menu"
className="absolute right-0 z-20 mt-2 w-52 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg"
>
{project.source && (
<ProjectMenuItem
buttonRef={firstMenuItemRef}
onClick={() => runAction(onOpenDesigner)}
>
<PencilRuler className="size-4" />
Designer
</ProjectMenuItem>
)}
<ProjectMenuItem
buttonRef={project.source ? undefined : firstMenuItemRef}
onClick={() => runAction(onRenameProject)}
>
<Pencil className="size-4" />
Rename
</ProjectMenuItem>
<ProjectMenuItem onClick={() => runAction(onDuplicateProject)}>
<Copy className="size-4" />
Duplicate
</ProjectMenuItem>
<ProjectMenuItem onClick={() => runAction(onDownloadProjectTemplate)}>
<Download className="size-4" />
Template JSON
</ProjectMenuItem>
<ProjectMenuItem danger onClick={() => runAction(onDeleteProject)}>
<Trash2 className="size-4" />
Delete
</ProjectMenuItem>
</div>
</>
)}
</div>
);
}
// 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() {
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
}
actions={
<div className="grid grid-cols-2 gap-2">
{project.source && (
<PlaygroundButton onClick={() => navigateToProject(project, 'source')}>
<Code2 className="size-4" />
Source
</PlaygroundButton>
)}
<PlaygroundButton onClick={() => navigateToProject(project, 'designer')}>
<PencilRuler className="size-4" />
Designer
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<PlaygroundButton
onClick={() =>
navigateToProject(project, project.source ? 'source' : 'designer')
}
>
{project.source ? (
<>
<Code2 className="size-4" />
Source
</>
) : (
<>
<PencilRuler className="size-4" />
Designer
</>
)}
</PlaygroundButton>
<PlaygroundButton onClick={() => navigateToProject(project, 'form-viewer')}>
<Eye className="size-4" />
Form/Viewer
</PlaygroundButton>
<PlaygroundButton onClick={() => onDownloadProjectTemplate(project)}>
<Download className="size-4" />
Template JSON
</PlaygroundButton>
<PlaygroundButton
onClick={() => onDeleteProject(project)}
variant="danger"
aria-label={`Delete ${project.title}`}
>
<Trash2 className="size-4" />
Delete
Preview
</PlaygroundButton>
<ProjectMoreActions
project={project}
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
onRenameProject={onRenameProject}
onDuplicateProject={onDuplicateProject}
onDownloadProjectTemplate={onDownloadProjectTemplate}
onDeleteProject={onDeleteProject}
/>
</div>
}
/>