[codex] add mounted file workspace to Playground (#1511)

* docs(playground): plan file workspace

* docs(playground): refine file workspace plan

* feat(playground): add mounted file workspace

* fix(playground): address file workspace review

* chore(playground): remove stale workspace scaffolding

* fix(cli): read per-template example metadata

* fix(playground): add form link after designer save

* feat(playground): standardize template metadata

* feat(playground): manage mounted workspace metadata

* feat(playground): rename mounted folders from metadata title

* fix(playground): harden mounted workspace refresh
This commit is contained in:
Kyohei Fukuda
2026-05-18 14:54:46 +09:00
committed by GitHub
parent 82722e9de3
commit ab24257920
42 changed files with 3265 additions and 561 deletions

View File

@@ -11,7 +11,6 @@ const PRELOAD = pathToFileURL(join(__dirname, 'fixtures', 'fetch-fixture-loader.
const TMP = join(__dirname, '..', '.test-tmp-examples-integration');
const ASSETS_DIR = resolve(__dirname, '..', '..', '..', 'playground', 'public', 'template-assets');
const MANIFEST_PATH = join(ASSETS_DIR, 'manifest.json');
const METADATA_PATH = join(ASSETS_DIR, 'metadata.json');
const VERSIONED_MANIFEST_DIR = join(ASSETS_DIR, 'manifests');
const FONT_FIXTURES_DIR = resolve(
__dirname,
@@ -87,7 +86,9 @@ function readJson<T>(filePath: string): T {
function listPlaygroundTemplateNames(): string[] {
return readdirSync(ASSETS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(ASSETS_DIR, entry.name, 'template.json')))
.filter(
(entry) => entry.isDirectory() && existsSync(join(ASSETS_DIR, entry.name, 'template.json')),
)
.map((entry) => entry.name)
.sort();
}
@@ -99,12 +100,16 @@ function normalizeSchemas(rawSchemas: unknown): Array<Array<Record<string, unkno
return rawSchemas.map((page) => {
if (Array.isArray(page)) {
return page.filter((schema): schema is Record<string, unknown> => typeof schema === 'object' && schema !== null);
return page.filter(
(schema): schema is Record<string, unknown> =>
typeof schema === 'object' && schema !== null,
);
}
if (typeof page === 'object' && page !== null) {
return Object.values(page).filter(
(schema): schema is Record<string, unknown> => typeof schema === 'object' && schema !== null,
(schema): schema is Record<string, unknown> =>
typeof schema === 'object' && schema !== null,
);
}
@@ -115,7 +120,9 @@ function normalizeSchemas(rawSchemas: unknown): Array<Array<Record<string, unkno
function hasCjkContent(schemas: Array<Record<string, unknown>>): boolean {
return schemas.some((schema) =>
['content', 'title', 'placeholder'].some(
(key) => typeof schema[key] === 'string' && /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(schema[key]),
(key) =>
typeof schema[key] === 'string' &&
/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(schema[key]),
),
);
}
@@ -142,9 +149,8 @@ function inferSourceKind(name: string): string {
}
function readTemplateMetadata(name: string): Record<string, unknown> {
const metadata = readJson<Record<string, Record<string, unknown>>>(METADATA_PATH);
const itemMetadataPath = join(ASSETS_DIR, name, 'metadata.json');
if (!existsSync(itemMetadataPath)) return metadata[name] ?? {};
if (!existsSync(itemMetadataPath)) return {};
return readJson<Record<string, unknown>>(itemMetadataPath);
}
@@ -170,7 +176,8 @@ function buildExpectedManifestEntry(name: string): ExampleManifestEntry {
const entry: ExampleManifestEntry = {
name,
author: typeof template.author === 'string' && template.author.length > 0 ? template.author : 'pdfme',
author:
typeof template.author === 'string' && template.author.length > 0 ? template.author : 'pdfme',
path: `${name}/template.json`,
thumbnailPath: `${name}/thumbnail.png`,
...(sourcePath ? { sourcePath } : {}),
@@ -220,9 +227,12 @@ describe('examples integration smoke', () => {
const jobPath = join(TMP, 'invoice-job.json');
const pdfPath = join(TMP, 'invoice.pdf');
const examplesResult = runCli(['examples', 'invoice', '--withInputs', '-o', jobPath, '--json'], {
env,
});
const examplesResult = runCli(
['examples', 'invoice', '--withInputs', '-o', jobPath, '--json'],
{
env,
},
);
expect(examplesResult.exitCode).toBe(0);
const examplesPayload = JSON.parse(examplesResult.stdout);
@@ -289,7 +299,18 @@ describe('examples integration smoke', () => {
const result = spawnSync(
'node',
['--import', PRELOAD, CLI, 'examples', 'invoice', '--withInputs', '-o', jobPath, '-v', '--json'],
[
'--import',
PRELOAD,
CLI,
'examples',
'invoice',
'--withInputs',
'-o',
jobPath,
'-v',
'--json',
],
{
encoding: 'utf8',
timeout: 60000,
@@ -308,67 +329,63 @@ describe('examples integration smoke', () => {
expect(result.stderr).toContain(`Output: ${jobPath}`);
});
it(
'exports every playground example through examples -w and generates authoring starters',
() => {
mkdirSync(TMP, { recursive: true });
const env = createFixtureEnv(TMP);
const manifest = readJson<ExampleManifest>(MANIFEST_PATH);
const generatedTemplateNames: string[] = [];
it('exports every playground example through examples -w and generates authoring starters', () => {
mkdirSync(TMP, { recursive: true });
const env = createFixtureEnv(TMP);
const manifest = readJson<ExampleManifest>(MANIFEST_PATH);
const generatedTemplateNames: string[] = [];
for (const { name, sourceKind } of manifest.templates) {
const jobPath = join(TMP, `${name}.job.json`);
const pdfPath = join(TMP, `${name}.pdf`);
for (const { name, sourceKind } of manifest.templates) {
const jobPath = join(TMP, `${name}.job.json`);
const pdfPath = join(TMP, `${name}.pdf`);
const examplesResult = runCli(['examples', name, '--withInputs', '-o', jobPath, '--json'], {
env,
});
if (examplesResult.exitCode !== 0) {
throw new Error(
`Example "${name}" failed to export.\nstdout:\n${examplesResult.stdout}\nstderr:\n${examplesResult.stderr}`,
);
}
const examplePayload = JSON.parse(examplesResult.stdout);
expect(examplePayload.ok).toBe(true);
expect(examplePayload.command).toBe('examples');
expect(examplePayload.outputPath).toBe(jobPath);
const job = JSON.parse(readFileSync(jobPath, 'utf8'));
expect(job).toHaveProperty('template');
expect(Array.isArray(job.inputs)).toBe(true);
expect(existsSync(jobPath)).toBe(true);
// Full PDF rendering for every playground template is covered by the generator
// integration snapshots. Keep this CLI test focused on exported job validity for
// all examples, then run PDF generation for the generated authoring starters where
// sample input shape regressions are most likely.
if (sourceKind === 'designer') {
continue;
}
const generateResult = runCli(['generate', jobPath, '-o', pdfPath, '--json'], { env });
if (generateResult.exitCode !== 0) {
throw new Error(
`Example "${name}" failed to generate via CLI.\nJob:\n${JSON.stringify(job, null, 2)}\nstdout:\n${generateResult.stdout}\nstderr:\n${generateResult.stderr}`,
);
}
const payload = JSON.parse(generateResult.stdout);
expect(payload.ok).toBe(true);
expect(payload.command).toBe('generate');
expect(payload.outputPath).toBe(pdfPath);
expect(existsSync(pdfPath)).toBe(true);
generatedTemplateNames.push(name);
const examplesResult = runCli(['examples', name, '--withInputs', '-o', jobPath, '--json'], {
env,
});
if (examplesResult.exitCode !== 0) {
throw new Error(
`Example "${name}" failed to export.\nstdout:\n${examplesResult.stdout}\nstderr:\n${examplesResult.stderr}`,
);
}
expect(generatedTemplateNames.sort()).toEqual(
manifest.templates
.filter((entry) => entry.sourceKind !== 'designer')
.map((entry) => entry.name)
.sort(),
);
},
180000,
);
const examplePayload = JSON.parse(examplesResult.stdout);
expect(examplePayload.ok).toBe(true);
expect(examplePayload.command).toBe('examples');
expect(examplePayload.outputPath).toBe(jobPath);
const job = JSON.parse(readFileSync(jobPath, 'utf8'));
expect(job).toHaveProperty('template');
expect(Array.isArray(job.inputs)).toBe(true);
expect(existsSync(jobPath)).toBe(true);
// Full PDF rendering for every playground template is covered by the generator
// integration snapshots. Keep this CLI test focused on exported job validity for
// all examples, then run PDF generation for the generated authoring starters where
// sample input shape regressions are most likely.
if (sourceKind === 'designer') {
continue;
}
const generateResult = runCli(['generate', jobPath, '-o', pdfPath, '--json'], { env });
if (generateResult.exitCode !== 0) {
throw new Error(
`Example "${name}" failed to generate via CLI.\nJob:\n${JSON.stringify(job, null, 2)}\nstdout:\n${generateResult.stdout}\nstderr:\n${generateResult.stderr}`,
);
}
const payload = JSON.parse(generateResult.stdout);
expect(payload.ok).toBe(true);
expect(payload.command).toBe('generate');
expect(payload.outputPath).toBe(pdfPath);
expect(existsSync(pdfPath)).toBe(true);
generatedTemplateNames.push(name);
}
expect(generatedTemplateNames.sort()).toEqual(
manifest.templates
.filter((entry) => entry.sourceKind !== 'designer')
.map((entry) => entry.name)
.sort(),
);
}, 180000);
});

View File

@@ -34,9 +34,7 @@ Use this flow for normal pdfme templates:
- Example: `playground/public/template-assets/invoice-blue`.
2. Put `template.json` in that directory.
- You can create it with the playground Designer and download the Template JSON.
3. Add gallery metadata.
- Prefer `playground/public/template-assets/<template-name>/metadata.json` for new templates.
- `playground/public/template-assets/metadata.json` is still supported for existing shared metadata.
3. Add gallery metadata in `playground/public/template-assets/<template-name>/metadata.json`.
Example:

View File

@@ -26,17 +26,17 @@ export const readAuthoringStarterFixtures = (kind: 'jsx' | 'md2pdf'): AuthoringS
if (!fs.existsSync(sourcePath) || !fs.existsSync(metadataPath)) return [];
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as {
description?: string;
sourceKind?: string;
title?: string;
description: string;
sourceKind: string;
title: string;
};
if (metadata.sourceKind !== kind) return [];
return [
{
assetName: entry.name,
description: metadata.description ?? '',
label: metadata.title ?? entry.name,
description: metadata.description,
label: metadata.title,
source: fs.readFileSync(sourcePath, 'utf-8'),
},
];

View File

@@ -0,0 +1,281 @@
import type { Template } from '@pdfme/common';
import {
FileWorkspaceTemplateDeletedError,
FileWorkspaceTemplateInvalidError,
createBlankTemplateEntry,
createTemplateEntryFromTemplate,
readTemplateEntry,
scanTemplateCollection,
serializeTemplateForFileWorkspace,
writeTemplateEntry,
writeTemplateMetadata,
} from '../src/lib/fileWorkspace';
class MemoryFileHandle {
readonly kind = 'file';
lastModified = 1;
constructor(
readonly name: string,
private content: string,
) {}
async createWritable() {
const chunks: Array<Blob | BufferSource | string> = [];
return {
close: async () => {
const parts = await Promise.all(
chunks.map(async (chunk) => {
if (typeof chunk === 'string') return chunk;
if (chunk instanceof Blob) return chunk.text();
return new TextDecoder().decode(chunk as BufferSource);
}),
);
this.content = parts.join('');
this.lastModified += 1;
},
write: async (data: Blob | BufferSource | string) => {
chunks.push(data);
},
} as FileSystemWritableFileStream;
}
async getFile() {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.name.endsWith('.json') ? 'application/json' : 'application/octet-stream',
});
}
get text() {
return this.content;
}
set text(content: string) {
this.content = content;
this.lastModified += 1;
}
}
class MemoryDirectoryHandle {
readonly kind = 'directory';
private children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
constructor(readonly name: string) {}
addDirectory(name: string) {
const directory = new MemoryDirectoryHandle(name);
this.children.set(name, directory);
return directory;
}
addFile(name: string, content: string) {
const file = new MemoryFileHandle(name, content);
this.children.set(name, file);
return file;
}
async *entries() {
for (const entry of this.children.entries()) {
yield entry;
}
}
async getDirectoryHandle(name: string, options: { create?: boolean } = {}) {
const child = this.children.get(name);
if (child instanceof MemoryDirectoryHandle) return child;
if (!child && options.create) return this.addDirectory(name);
throw new Error(`Directory not found: ${name}`);
}
async getFileHandle(name: string, options: { create?: boolean } = {}) {
const child = this.children.get(name);
if (child instanceof MemoryFileHandle) return child;
if (!child && options.create) return this.addFile(name, '');
throw new Error(`File not found: ${name}`);
}
async removeEntry(name: string, _options: { recursive?: boolean } = {}) {
if (!this.children.delete(name)) throw new Error(`Entry not found: ${name}`);
}
}
const blankTemplate: Template = {
basePdf: { height: 100, padding: [0, 0, 0, 0], width: 100 },
schemas: [[]],
};
describe('file workspace helpers', () => {
it('scans one-level template directories and skips invalid template JSON', async () => {
const root = new MemoryDirectoryHandle('templates');
const invoice = root.addDirectory('invoice');
invoice.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate));
invoice.addFile(
'metadata.json',
JSON.stringify({
description: 'Invoice template',
sourceKind: 'designer',
tags: ['Invoice'],
title: 'Invoice',
}),
);
root
.addDirectory('.cache')
.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate));
root.addDirectory('broken').addFile('template.json', '{');
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
expect(collection.entries).toHaveLength(1);
expect(collection.entries[0]?.name).toBe('invoice');
expect(collection.entries[0]?.title).toBe('Invoice');
expect(collection.invalidEntries.map((entry) => entry.name)).toEqual(['broken']);
});
it('writes pretty template JSON back to the selected template file', async () => {
const root = new MemoryDirectoryHandle('templates');
const invoice = root.addDirectory('invoice');
const templateFile = invoice.addFile(
'template.json',
serializeTemplateForFileWorkspace(blankTemplate),
);
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
const entry = collection.entries[0];
if (!entry) throw new Error('Missing test entry');
const nextTemplate: Template = {
...blankTemplate,
pdfmeVersion: 'test',
};
const saved = await writeTemplateEntry(entry, nextTemplate);
expect(saved.template.pdfmeVersion).toBe('test');
expect(templateFile.text).toBe(serializeTemplateForFileWorkspace(nextTemplate));
});
it('updates editable metadata fields and renames the template directory', async () => {
const root = new MemoryDirectoryHandle('templates');
const invoice = root.addDirectory('invoice');
invoice.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate));
invoice.addFile(
'metadata.json',
JSON.stringify({
customField: 'keep me',
description: 'Old description',
order: 7,
sourceKind: 'designer',
tags: ['Old'],
title: 'Old title',
}),
);
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
const entry = collection.entries[0];
if (!entry) throw new Error('Missing test entry');
const updated = await writeTemplateMetadata(collection, entry, {
description: 'New description',
tags: ['Invoice', 'Business', 'Invoice'],
title: 'New title',
});
const renamedDirectory = await root.getDirectoryHandle('new-title');
const metadataFile = await renamedDirectory.getFileHandle('metadata.json');
expect(updated.name).toBe('new-title');
expect(updated.title).toBe('New title');
expect(updated.description).toBe('New description');
expect(updated.tags).toEqual(['Invoice', 'Business']);
await expect(root.getDirectoryHandle('invoice')).rejects.toThrow('Directory not found');
expect(JSON.parse(metadataFile.text)).toEqual({
title: 'New title',
description: 'New description',
sourceKind: 'designer',
tags: ['Invoice', 'Business'],
order: 7,
customField: 'keep me',
});
});
it('copies a template with metadata and source into a collection', async () => {
const root = new MemoryDirectoryHandle('templates');
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
const entry = await createTemplateEntryFromTemplate(collection, blankTemplate, 'JSX Project', {
description: 'Copied project',
source: {
content: 'export default <Document />;',
language: 'jsx',
},
sourceKind: 'jsx',
tags: ['JSX', 'Copied'],
});
const directory = await root.getDirectoryHandle('jsx-project');
const sourceFile = await directory.getFileHandle('source.tsx');
const metadataFile = await directory.getFileHandle('metadata.json');
expect(entry.name).toBe('jsx-project');
expect(entry.sourceKind).toBe('jsx');
expect(sourceFile.text).toBe('export default <Document />;');
expect(JSON.parse(metadataFile.text)).toEqual({
title: 'JSX Project',
description: 'Copied project',
sourceKind: 'jsx',
tags: ['JSX', 'Copied'],
});
});
it('reports disk version changes when template JSON changes externally', async () => {
const root = new MemoryDirectoryHandle('templates');
const invoice = root.addDirectory('invoice');
const templateFile = invoice.addFile(
'template.json',
serializeTemplateForFileWorkspace(blankTemplate),
);
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
const entry = collection.entries[0];
if (!entry) throw new Error('Missing test entry');
templateFile.text = serializeTemplateForFileWorkspace({
...blankTemplate,
pdfmeVersion: 'external',
});
const readResult = await readTemplateEntry(entry);
expect(readResult.diskVersion).not.toBe(entry.diskVersion);
expect(readResult.template.pdfmeVersion).toBe('external');
});
it('throws typed errors for deleted or invalid template JSON', async () => {
const root = new MemoryDirectoryHandle('templates');
const invoice = root.addDirectory('invoice');
const templateFile = invoice.addFile(
'template.json',
serializeTemplateForFileWorkspace(blankTemplate),
);
const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle);
const entry = collection.entries[0];
if (!entry) throw new Error('Missing test entry');
templateFile.text = '{';
await expect(readTemplateEntry(entry)).rejects.toBeInstanceOf(
FileWorkspaceTemplateInvalidError,
);
await invoice.removeEntry('template.json');
await expect(readTemplateEntry(entry)).rejects.toBeInstanceOf(
FileWorkspaceTemplateDeletedError,
);
});
it('creates an untitled blank template when a collection is empty', async () => {
const root = new MemoryDirectoryHandle('templates');
const entry = await createBlankTemplateEntry(root as unknown as FileSystemDirectoryHandle);
const directory = await root.getDirectoryHandle('untitled-template');
const templateFile = await directory.getFileHandle('template.json');
expect(entry.name).toBe('untitled-template');
expect(entry.sourceKind).toBe('designer');
expect(entry.title).toBe('Untitled Template');
expect(templateFile.text).toContain('"schemas": [');
});
});

View File

@@ -0,0 +1,49 @@
import type { Template } from '@pdfme/common';
import { reconcileInputsWithTemplate } from '../src/lib/templateInputs';
const templateWithFields = (
fields: Array<{ content?: string; name: string; readOnly?: boolean }>,
) =>
({
basePdf: { height: 100, padding: [0, 0, 0, 0], width: 100 },
schemas: [
fields.map((field) => ({
height: 10,
position: { x: 0, y: 0 },
type: 'text',
width: 10,
...field,
})),
],
}) as Template;
describe('template input reconciliation', () => {
it('keeps values for matching field names and uses defaults for new fields', () => {
const template = templateWithFields([
{ content: 'Default customer', name: 'customer' },
{ content: 'Default total', name: 'total' },
{ content: 'Internal', name: 'internal', readOnly: true },
]);
expect(reconcileInputsWithTemplate(template, [{ customer: 'Ada', old: 'removed' }])).toEqual([
{
customer: 'Ada',
total: 'Default total',
},
]);
});
it('falls back to generated defaults when there are no previous inputs', () => {
const template = templateWithFields([{ content: 'Hello', name: 'message' }]);
expect(reconcileInputsWithTemplate(template, null)).toEqual([{ message: 'Hello' }]);
});
it('drops extra previous input rows during template reload', () => {
const template = templateWithFields([{ content: 'Hello', name: 'message' }]);
expect(
reconcileInputsWithTemplate(template, [{ message: 'Ada' }, { message: 'Grace' }]),
).toEqual([{ message: 'Ada' }]);
});
});

View File

@@ -0,0 +1,10 @@
{
"title": "A4 Blank",
"description": "A clean blank A4 document for starting from scratch in Designer.",
"sourceKind": "designer",
"tags": [
"Blank",
"Starter"
],
"order": 130
}

View File

@@ -0,0 +1,9 @@
{
"title": "Address Label 10",
"description": "A 10-label address sheet for shipping and mailing workflows.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"
]
}

View File

@@ -0,0 +1,9 @@
{
"title": "Address Label 30",
"description": "A compact 30-label address sheet for dense mailing labels.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"
]
}

View File

@@ -0,0 +1,9 @@
{
"title": "Address Label 6",
"description": "A larger 6-label address sheet with room for longer addresses.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Certificate Black",
"description": "A high-contrast certificate layout with a formal dark theme.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"
],
"order": 120
}

View File

@@ -0,0 +1,9 @@
{
"title": "Certificate Blue",
"description": "A polished blue certificate layout for awards and completion documents.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"
]
}

View File

@@ -0,0 +1,9 @@
{
"title": "Certificate Gold",
"description": "A warm gold certificate layout with a classic presentation style.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"
]
}

View File

@@ -0,0 +1,9 @@
{
"title": "Certificate White",
"description": "A minimal certificate layout that works well with light branding.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Social Insurance Enrollment Form",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",
"Form"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Inline Markdown MVT",
"description": "A focused demo of inline markdown and MultiVariableText editing.",
"sourceKind": "designer",
"tags": [
"Markdown",
"MVT",
"Form"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Invoice Blue",
"description": "A blue invoice variant for a more branded business document.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Invoice Green",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Japanese Invoice Landscape",
"description": "A landscape Japanese invoice layout for wider table content.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"CJK"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Japanese Invoice",
"description": "A simple Japanese invoice layout with CJK font usage.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"CJK"
]
}

View File

@@ -0,0 +1,10 @@
{
"title": "Invoice White",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
}

View File

@@ -0,0 +1,12 @@
{
"title": "Invoice",
"description": "A practical invoice with customer details, line items, totals, and payment notes.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table",
"Visual"
],
"order": 90
}

View File

@@ -0,0 +1,9 @@
{
"title": "Location Arrow",
"description": "A location marker template that highlights points with arrow indicators.",
"sourceKind": "designer",
"tags": [
"Map",
"Visual"
]
}

View File

@@ -0,0 +1,9 @@
{
"title": "Location Number",
"description": "A location marker template that labels points with numbered badges.",
"sourceKind": "designer",
"tags": [
"Map",
"Visual"
]
}

View File

@@ -27,7 +27,8 @@
"Business",
"Table",
"Visual"
]
],
"title": "Invoice"
},
{
"name": "quotes",
@@ -54,7 +55,8 @@
"Business",
"Table",
"Visual"
]
],
"title": "Quotes"
},
{
"name": "pedigree",
@@ -80,7 +82,8 @@
"QR",
"Image",
"Visual"
]
],
"title": "Pedigree"
},
{
"name": "certificate-black",
@@ -103,7 +106,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Black"
},
{
"name": "a4-blank",
@@ -122,7 +126,8 @@
"tags": [
"Blank",
"Starter"
]
],
"title": "A4 Blank"
},
{
"name": "qr-lines",
@@ -144,7 +149,8 @@
"tags": [
"QR",
"Label"
]
],
"title": "QR Lines"
},
{
"name": "address-label-10",
@@ -164,7 +170,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 10"
},
{
"name": "address-label-30",
@@ -184,7 +191,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 30"
},
{
"name": "address-label-6",
@@ -204,7 +212,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 6"
},
{
"name": "md2pdf-article",
@@ -251,7 +260,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Blue"
},
{
"name": "certificate-gold",
@@ -273,7 +283,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Gold"
},
{
"name": "certificate-white",
@@ -295,7 +306,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate White"
},
{
"name": "jsx-form-fields",
@@ -325,29 +337,6 @@
],
"title": "Form fields"
},
{
"name": "hihokensha-shikaku-shutoku-todoke",
"author": "EedgeY",
"path": "hihokensha-shikaku-shutoku-todoke/template.json",
"thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png",
"pageCount": 1,
"fieldCount": 104,
"schemaTypes": [
"text"
],
"fontNames": [
"NotoSansJP"
],
"hasCJK": true,
"basePdfKind": "dataUri",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",
"Form"
]
},
{
"name": "inline-markdown-mvt",
"author": "pdfme",
@@ -370,7 +359,52 @@
"Markdown",
"MVT",
"Form"
]
],
"title": "Inline Markdown MVT"
},
{
"name": "invoice-blue",
"author": "pdfme",
"path": "invoice-blue/template.json",
"thumbnailPath": "invoice-blue/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A blue invoice variant for a more branded business document.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
],
"title": "Invoice Blue"
},
{
"name": "invoice-green",
"author": "pdfme",
"path": "invoice-green/template.json",
"thumbnailPath": "invoice-green/thumbnail.png",
"pageCount": 1,
"fieldCount": 25,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
],
"title": "Invoice Green"
},
{
"name": "jsx-invoice",
@@ -406,10 +440,10 @@
"title": "Invoice layout"
},
{
"name": "invoice-blue",
"name": "invoice-white",
"author": "pdfme",
"path": "invoice-blue/template.json",
"thumbnailPath": "invoice-blue/thumbnail.png",
"path": "invoice-white/template.json",
"thumbnailPath": "invoice-white/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
@@ -418,34 +452,14 @@
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A blue invoice variant for a more branded business document.",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
},
{
"name": "invoice-green",
"author": "pdfme",
"path": "invoice-green/template.json",
"thumbnailPath": "invoice-green/thumbnail.png",
"pageCount": 1,
"fieldCount": 25,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
"title": "Invoice White"
},
{
"name": "invoice-ja-simple",
@@ -472,7 +486,8 @@
"Invoice",
"Business",
"CJK"
]
],
"title": "Japanese Invoice"
},
{
"name": "invoice-ja-simple-landscape",
@@ -499,28 +514,8 @@
"Invoice",
"Business",
"CJK"
]
},
{
"name": "invoice-white",
"author": "pdfme",
"path": "invoice-white/template.json",
"thumbnailPath": "invoice-white/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
"title": "Japanese Invoice Landscape"
},
{
"name": "jsx-japanese-notice",
@@ -594,7 +589,8 @@
"tags": [
"Map",
"Visual"
]
],
"title": "Location Arrow"
},
{
"name": "location-number",
@@ -615,32 +611,8 @@
"tags": [
"Map",
"Visual"
]
},
{
"name": "new-sale-quotation",
"author": "pdfme",
"path": "new-sale-quotation/template.json",
"thumbnailPath": "new-sale-quotation/thumbnail.png",
"pageCount": 3,
"fieldCount": 49,
"schemaTypes": [
"image",
"line",
"rectangle",
"table",
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "blank",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table"
]
"title": "Location Number"
},
{
"name": "md2pdf-overview",
@@ -689,7 +661,8 @@
"tags": [
"QR",
"Label"
]
],
"title": "QR Title"
},
{
"name": "md2pdf-release-notes",
@@ -771,6 +744,56 @@
],
"title": "Research paper"
},
{
"name": "new-sale-quotation",
"author": "pdfme",
"path": "new-sale-quotation/template.json",
"thumbnailPath": "new-sale-quotation/thumbnail.png",
"pageCount": 3,
"fieldCount": 49,
"schemaTypes": [
"image",
"line",
"rectangle",
"table",
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "blank",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table"
],
"title": "Sales Quotation"
},
{
"name": "hihokensha-shikaku-shutoku-todoke",
"author": "EedgeY",
"path": "hihokensha-shikaku-shutoku-todoke/template.json",
"thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png",
"pageCount": 1,
"fieldCount": 104,
"schemaTypes": [
"text"
],
"fontNames": [
"NotoSansJP"
],
"hasCJK": true,
"basePdfKind": "dataUri",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",
"Form"
],
"title": "Social Insurance Enrollment Form"
},
{
"name": "z-fold-brochure",
"author": "hitomi-t260g",
@@ -796,7 +819,8 @@
"tags": [
"Brochure",
"Print"
]
],
"title": "Z-Fold Brochure"
}
]
}

View File

@@ -27,7 +27,8 @@
"Business",
"Table",
"Visual"
]
],
"title": "Invoice"
},
{
"name": "quotes",
@@ -54,7 +55,8 @@
"Business",
"Table",
"Visual"
]
],
"title": "Quotes"
},
{
"name": "pedigree",
@@ -80,7 +82,8 @@
"QR",
"Image",
"Visual"
]
],
"title": "Pedigree"
},
{
"name": "certificate-black",
@@ -103,7 +106,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Black"
},
{
"name": "a4-blank",
@@ -122,7 +126,8 @@
"tags": [
"Blank",
"Starter"
]
],
"title": "A4 Blank"
},
{
"name": "qr-lines",
@@ -144,7 +149,8 @@
"tags": [
"QR",
"Label"
]
],
"title": "QR Lines"
},
{
"name": "address-label-10",
@@ -164,7 +170,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 10"
},
{
"name": "address-label-30",
@@ -184,7 +191,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 30"
},
{
"name": "address-label-6",
@@ -204,7 +212,8 @@
"tags": [
"Labels",
"Shipping"
]
],
"title": "Address Label 6"
},
{
"name": "md2pdf-article",
@@ -251,7 +260,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Blue"
},
{
"name": "certificate-gold",
@@ -273,7 +283,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate Gold"
},
{
"name": "certificate-white",
@@ -295,7 +306,8 @@
"tags": [
"Certificate",
"Award"
]
],
"title": "Certificate White"
},
{
"name": "jsx-form-fields",
@@ -325,29 +337,6 @@
],
"title": "Form fields"
},
{
"name": "hihokensha-shikaku-shutoku-todoke",
"author": "EedgeY",
"path": "hihokensha-shikaku-shutoku-todoke/template.json",
"thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png",
"pageCount": 1,
"fieldCount": 104,
"schemaTypes": [
"text"
],
"fontNames": [
"NotoSansJP"
],
"hasCJK": true,
"basePdfKind": "dataUri",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",
"Form"
]
},
{
"name": "inline-markdown-mvt",
"author": "pdfme",
@@ -370,7 +359,52 @@
"Markdown",
"MVT",
"Form"
]
],
"title": "Inline Markdown MVT"
},
{
"name": "invoice-blue",
"author": "pdfme",
"path": "invoice-blue/template.json",
"thumbnailPath": "invoice-blue/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A blue invoice variant for a more branded business document.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
],
"title": "Invoice Blue"
},
{
"name": "invoice-green",
"author": "pdfme",
"path": "invoice-green/template.json",
"thumbnailPath": "invoice-green/thumbnail.png",
"pageCount": 1,
"fieldCount": 25,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
],
"title": "Invoice Green"
},
{
"name": "jsx-invoice",
@@ -406,10 +440,10 @@
"title": "Invoice layout"
},
{
"name": "invoice-blue",
"name": "invoice-white",
"author": "pdfme",
"path": "invoice-blue/template.json",
"thumbnailPath": "invoice-blue/thumbnail.png",
"path": "invoice-white/template.json",
"thumbnailPath": "invoice-white/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
@@ -418,34 +452,14 @@
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A blue invoice variant for a more branded business document.",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
},
{
"name": "invoice-green",
"author": "pdfme",
"path": "invoice-green/template.json",
"thumbnailPath": "invoice-green/thumbnail.png",
"pageCount": 1,
"fieldCount": 25,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
"title": "Invoice White"
},
{
"name": "invoice-ja-simple",
@@ -472,7 +486,8 @@
"Invoice",
"Business",
"CJK"
]
],
"title": "Japanese Invoice"
},
{
"name": "invoice-ja-simple-landscape",
@@ -499,28 +514,8 @@
"Invoice",
"Business",
"CJK"
]
},
{
"name": "invoice-white",
"author": "pdfme",
"path": "invoice-white/template.json",
"thumbnailPath": "invoice-white/thumbnail.png",
"pageCount": 1,
"fieldCount": 35,
"schemaTypes": [
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "dataUri",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table"
]
"title": "Japanese Invoice Landscape"
},
{
"name": "jsx-japanese-notice",
@@ -594,7 +589,8 @@
"tags": [
"Map",
"Visual"
]
],
"title": "Location Arrow"
},
{
"name": "location-number",
@@ -615,32 +611,8 @@
"tags": [
"Map",
"Visual"
]
},
{
"name": "new-sale-quotation",
"author": "pdfme",
"path": "new-sale-quotation/template.json",
"thumbnailPath": "new-sale-quotation/thumbnail.png",
"pageCount": 3,
"fieldCount": 49,
"schemaTypes": [
"image",
"line",
"rectangle",
"table",
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "blank",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table"
]
"title": "Location Number"
},
{
"name": "md2pdf-overview",
@@ -689,7 +661,8 @@
"tags": [
"QR",
"Label"
]
],
"title": "QR Title"
},
{
"name": "md2pdf-release-notes",
@@ -771,6 +744,56 @@
],
"title": "Research paper"
},
{
"name": "new-sale-quotation",
"author": "pdfme",
"path": "new-sale-quotation/template.json",
"thumbnailPath": "new-sale-quotation/thumbnail.png",
"pageCount": 3,
"fieldCount": 49,
"schemaTypes": [
"image",
"line",
"rectangle",
"table",
"text"
],
"fontNames": [],
"hasCJK": false,
"basePdfKind": "blank",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table"
],
"title": "Sales Quotation"
},
{
"name": "hihokensha-shikaku-shutoku-todoke",
"author": "EedgeY",
"path": "hihokensha-shikaku-shutoku-todoke/template.json",
"thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png",
"pageCount": 1,
"fieldCount": 104,
"schemaTypes": [
"text"
],
"fontNames": [
"NotoSansJP"
],
"hasCJK": true,
"basePdfKind": "dataUri",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",
"Form"
],
"title": "Social Insurance Enrollment Form"
},
{
"name": "z-fold-brochure",
"author": "hitomi-t260g",
@@ -796,7 +819,8 @@
"tags": [
"Brochure",
"Print"
]
],
"title": "Z-Fold Brochure"
}
]
}

View File

@@ -1,104 +0,0 @@
{
"a4-blank": {
"order": 130,
"description": "A clean blank A4 document for starting from scratch in Designer.",
"tags": ["Blank", "Starter"]
},
"address-label-10": {
"description": "A 10-label address sheet for shipping and mailing workflows.",
"tags": ["Labels", "Shipping"]
},
"address-label-30": {
"description": "A compact 30-label address sheet for dense mailing labels.",
"tags": ["Labels", "Shipping"]
},
"address-label-6": {
"description": "A larger 6-label address sheet with room for longer addresses.",
"tags": ["Labels", "Shipping"]
},
"certificate-black": {
"order": 120,
"description": "A high-contrast certificate layout with a formal dark theme.",
"tags": ["Certificate", "Award"]
},
"certificate-blue": {
"description": "A polished blue certificate layout for awards and completion documents.",
"tags": ["Certificate", "Award"]
},
"certificate-gold": {
"description": "A warm gold certificate layout with a classic presentation style.",
"tags": ["Certificate", "Award"]
},
"certificate-white": {
"description": "A minimal certificate layout that works well with light branding.",
"tags": ["Certificate", "Award"]
},
"hihokensha-shikaku-shutoku-todoke": {
"description": "A Japanese social insurance form template for structured government-style documents.",
"tags": ["Government", "CJK", "Form"]
},
"inline-markdown-mvt": {
"description": "A focused demo of inline markdown and MultiVariableText editing.",
"tags": ["Markdown", "MVT", "Form"]
},
"invoice": {
"order": 90,
"description": "A practical invoice with customer details, line items, totals, and payment notes.",
"tags": ["Invoice", "Business", "Table", "Visual"]
},
"invoice-blue": {
"description": "A blue invoice variant for a more branded business document.",
"tags": ["Invoice", "Business", "Table"]
},
"invoice-green": {
"description": "A green invoice variant with a calm accounting-oriented look.",
"tags": ["Invoice", "Business", "Table"]
},
"invoice-ja-simple": {
"description": "A simple Japanese invoice layout with CJK font usage.",
"tags": ["Invoice", "Business", "CJK"]
},
"invoice-ja-simple-landscape": {
"description": "A landscape Japanese invoice layout for wider table content.",
"tags": ["Invoice", "Business", "CJK"]
},
"invoice-white": {
"description": "A restrained white invoice layout with a clean printable style.",
"tags": ["Invoice", "Business", "Table"]
},
"location-arrow": {
"description": "A location marker template that highlights points with arrow indicators.",
"tags": ["Map", "Visual"]
},
"location-number": {
"description": "A location marker template that labels points with numbered badges.",
"tags": ["Map", "Visual"]
},
"new-sale-quotation": {
"description": "A sales quotation template with product rows and business summary fields.",
"tags": ["Quote", "Business", "Table"]
},
"pedigree": {
"order": 110,
"description": "A pedigree-style relationship chart for structured family or lineage data.",
"tags": ["Chart", "QR", "Image", "Visual"]
},
"qr-lines": {
"order": 140,
"description": "A QR code template with line-based metadata and compact supporting text.",
"tags": ["QR", "Label"]
},
"qr-title": {
"description": "A QR code template with a prominent title and simple scan instructions.",
"tags": ["QR", "Label"]
},
"quotes": {
"order": 100,
"description": "A quote document layout for proposals, estimates, and short commercial offers.",
"tags": ["Quote", "Business", "Table", "Visual"]
},
"z-fold-brochure": {
"description": "A z-fold brochure layout for tri-fold print and promotional material.",
"tags": ["Brochure", "Print"]
}
}

View File

@@ -0,0 +1,10 @@
{
"title": "Sales Quotation",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table"
]
}

View File

@@ -0,0 +1,12 @@
{
"title": "Pedigree",
"description": "A pedigree-style relationship chart for structured family or lineage data.",
"sourceKind": "designer",
"tags": [
"Chart",
"QR",
"Image",
"Visual"
],
"order": 110
}

View File

@@ -0,0 +1,10 @@
{
"title": "QR Lines",
"description": "A QR code template with line-based metadata and compact supporting text.",
"sourceKind": "designer",
"tags": [
"QR",
"Label"
],
"order": 140
}

View File

@@ -0,0 +1,9 @@
{
"title": "QR Title",
"description": "A QR code template with a prominent title and simple scan instructions.",
"sourceKind": "designer",
"tags": [
"QR",
"Label"
]
}

View File

@@ -0,0 +1,12 @@
{
"title": "Quotes",
"description": "A quote document layout for proposals, estimates, and short commercial offers.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table",
"Visual"
],
"order": 100
}

View File

@@ -0,0 +1,9 @@
{
"title": "Z-Fold Brochure",
"description": "A z-fold brochure layout for tri-fold print and promotional material.",
"sourceKind": "designer",
"tags": [
"Brochure",
"Print"
]
}

View File

@@ -8,7 +8,6 @@ const __dirname = path.dirname(__filename);
const templatesDir = path.join(__dirname, '..', 'public', 'template-assets');
const indexFilePath = path.join(templatesDir, 'index.json');
const metadataFilePath = path.join(templatesDir, 'metadata.json');
const manifestFilePath = path.join(templatesDir, 'manifest.json');
const versionedManifestDir = path.join(templatesDir, 'manifests');
@@ -68,20 +67,6 @@ function generateTemplatesListJson() {
function loadTemplateMetadata() {
const metadataByTemplate = {};
if (!fs.existsSync(metadataFilePath)) {
return loadPerTemplateMetadata(metadataByTemplate);
}
const parsed = JSON.parse(fs.readFileSync(metadataFilePath, 'utf8'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('template-assets/metadata.json must be an object keyed by template name.');
}
Object.assign(metadataByTemplate, parsed);
return loadPerTemplateMetadata(metadataByTemplate);
}
function loadPerTemplateMetadata(metadataByTemplate) {
const items = fs.readdirSync(templatesDir, { withFileTypes: true });
for (const item of items) {
if (!item.isDirectory() || item.name.startsWith('.')) continue;
@@ -105,7 +90,9 @@ function normalizeMetadata(rawMetadata) {
}
const metadata = {};
if (typeof rawMetadata.title === 'string') metadata.title = rawMetadata.title;
if (typeof rawMetadata.title === 'string' && rawMetadata.title.trim()) {
metadata.title = rawMetadata.title.trim();
}
if (typeof rawMetadata.description === 'string') metadata.description = rawMetadata.description;
if (typeof rawMetadata.order === 'number' && Number.isFinite(rawMetadata.order)) {
metadata.order = rawMetadata.order;
@@ -142,15 +129,21 @@ function validateTemplateMetadata(metadataByTemplate, templateDirs) {
for (const [name, rawMetadata] of Object.entries(metadataByTemplate)) {
const metadata = normalizeMetadata(rawMetadata);
if (!metadata.title) {
throw new Error(`template asset metadata entry "${name}" must include title.`);
}
if (!metadata.description) {
throw new Error(`template asset metadata entry "${name}" must include description.`);
}
if (!metadata.sourceKind) {
throw new Error(`template asset metadata entry "${name}" must include sourceKind.`);
}
if (!metadata.tags || metadata.tags.length === 0) {
throw new Error(`template asset metadata entry "${name}" must include tags.`);
}
const inferredSourceKind = inferSourceKind(name);
if (metadata.sourceKind && metadata.sourceKind !== inferredSourceKind) {
if (metadata.sourceKind !== inferredSourceKind) {
throw new Error(
`template asset metadata entry "${name}" has sourceKind "${metadata.sourceKind}", expected "${inferredSourceKind}".`,
);
@@ -182,7 +175,13 @@ function buildTemplateEntry(name, templateJson, rawMetadata) {
const schemas = normalizeSchemas(templateJson.schemas);
const flattenedSchemas = schemas.flat();
const metadata = normalizeMetadata(rawMetadata);
const sourceKind = metadata.sourceKind ?? inferSourceKind(name);
if (!metadata.title) {
throw new Error(`template asset metadata entry "${name}" must include title.`);
}
if (!metadata.sourceKind) {
throw new Error(`template asset metadata entry "${name}" must include sourceKind.`);
}
const sourceKind = metadata.sourceKind;
const schemaTypes = [
...new Set(flattenedSchemas.map((schema) => schema.type).filter(Boolean)),
].sort();
@@ -205,7 +204,7 @@ function buildTemplateEntry(name, templateJson, rawMetadata) {
description: metadata.description,
order: metadata.order,
sourceKind,
tags: metadata.tags ?? [],
tags: metadata.tags,
title: metadata.title,
};
}
@@ -227,9 +226,7 @@ function compareTemplateEntries(a, b) {
if (a.order != null) return -1;
if (b.order != null) return 1;
const aTitle = a.title ?? a.name;
const bTitle = b.title ?? b.name;
const titleResult = aTitle.localeCompare(bTitle);
const titleResult = a.title.localeCompare(b.title);
if (titleResult !== 0) return titleResult;
return a.name.localeCompare(b.name);

View File

@@ -1,19 +1,30 @@
import { Link } from 'react-router-dom';
type ProjectSavedToastProps = {
formPath?: string;
title: string;
};
export default function ProjectSavedToast({ title }: ProjectSavedToastProps) {
export default function ProjectSavedToast({ formPath, title }: ProjectSavedToastProps) {
return (
<div>
<p>Saved "{title}"</p>
<Link
to="/"
className="mt-1 inline-flex text-sm font-medium text-green-700 underline hover:text-green-600"
>
View in Templates
</Link>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1">
<Link
to="/"
className="inline-flex text-sm font-medium text-green-700 underline hover:text-green-600"
>
View in Templates
</Link>
{formPath && (
<Link
to={formPath}
className="inline-flex text-sm font-medium text-green-700 underline hover:text-green-600"
>
Open Form
</Link>
)}
</div>
</div>
);
}

View File

@@ -10,12 +10,20 @@ import { Form, Viewer, Designer } from '@pdfme/ui';
import { generate, generateForm } from '@pdfme/generator';
import { getPlugins } from './plugins';
export function fromKebabCase(str: string): string {
return str
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const templateAssetSourceKinds = ['designer', 'jsx', 'md2pdf'] as const;
type TemplateAssetSourceKind = (typeof templateAssetSourceKinds)[number];
export type TemplateAssetMetadata = {
description: string;
order?: number;
sourceKind: TemplateAssetSourceKind;
tags: string[];
title: string;
};
const isTemplateAssetSourceKind = (value: unknown): value is TemplateAssetSourceKind =>
templateAssetSourceKinds.includes(value as TemplateAssetSourceKind);
export const getFontsData = (): Font => ({
...getDefaultFont(),
@@ -150,4 +158,37 @@ export const getTemplateById = async (templateId: string): Promise<Template> =>
return template as Template;
};
export const getTemplateMetadataById = async (
templateId: string,
): Promise<TemplateAssetMetadata> => {
const response = await fetch(`/template-assets/${templateId}/metadata.json`);
if (!response.ok) {
throw new Error(`Failed to load template metadata: ${response.statusText}`);
}
const metadata = (await response.json()) as Partial<TemplateAssetMetadata>;
if (!metadata.title?.trim()) {
throw new Error(`Template metadata "${templateId}" must include title.`);
}
if (!metadata.description?.trim()) {
throw new Error(`Template metadata "${templateId}" must include description.`);
}
if (!isTemplateAssetSourceKind(metadata.sourceKind)) {
throw new Error(`Template metadata "${templateId}" must include sourceKind.`);
}
if (!metadata.tags || metadata.tags.length === 0) {
throw new Error(`Template metadata "${templateId}" must include tags.`);
}
return {
description: metadata.description,
order: metadata.order,
sourceKind: metadata.sourceKind,
tags: metadata.tags,
title: metadata.title.trim(),
};
};
export const getDefaultPlaygroundTemplate = () => getTemplateById(DEFAULT_PLAYGROUND_TEMPLATE_ID);
export const getDefaultPlaygroundTemplateMetadata = () =>
getTemplateMetadataById(DEFAULT_PLAYGROUND_TEMPLATE_ID);

View File

@@ -2,7 +2,7 @@ export type AuthoringStarterKind = 'jsx' | 'md2pdf';
export type AuthoringStarter = {
assetName: string;
description?: string;
description: string;
id: string;
kind: AuthoringStarterKind;
label: string;
@@ -10,11 +10,11 @@ export type AuthoringStarter = {
};
type TemplateAssetEntry = {
description?: string;
description: string;
name: string;
sourceKind?: string;
sourceKind: string;
sourcePath?: string;
title?: string;
title: string;
};
const TEMPLATE_ASSETS_BASE_PATH = '/template-assets';
@@ -40,7 +40,7 @@ export const loadAuthoringStarters = async (
description: entry.description,
id: getAuthoringStarterId(entry.name, kind),
kind,
label: entry.title ?? entry.name,
label: entry.title,
sourcePath: entry.sourcePath!,
}));
};

View File

@@ -0,0 +1,841 @@
import {
PAGE_SIZE_PRESETS,
checkTemplate,
getInputFromTemplate,
type Template,
} from '@pdfme/common';
import { createTemplateThumbnailDataUrl } from './templateThumbnails';
const DB_NAME = 'pdfme-playground-file-workspace';
const DB_VERSION = 1;
const STORE_NAME = 'workspace';
const ACTIVE_STATE_KEY = 'active';
const DEFAULT_COLLECTION_POLLING_INTERVAL_MS = 4000;
const DEFAULT_ENTRY_POLLING_INTERVAL_MS = 1500;
export type SourceKind = 'designer' | 'jsx' | 'md2pdf';
export type FileWorkspaceSourceInput = {
content: string;
language: 'jsx' | 'markdown';
};
export type FileWorkspaceMetadata = {
description?: string;
order?: number;
sourceKind: SourceKind;
tags: string[];
title?: string;
};
export type FileWorkspaceTemplateEntry = {
description?: string;
diskVersion: string;
name: string;
order?: number;
path: string;
sourceKind: SourceKind;
tags: string[];
template: Template;
templateDirectoryHandle: FileSystemDirectoryHandle;
thumbnailDataUrl?: string;
title: string;
updatedAt: number;
};
export type FileWorkspaceInvalidEntry = {
error: string;
name: string;
};
export type FileWorkspaceCollection = {
entries: FileWorkspaceTemplateEntry[];
invalidEntries: FileWorkspaceInvalidEntry[];
rootHandle: FileSystemDirectoryHandle;
rootName: string;
selectedTemplateName?: string;
};
export type FileWorkspaceTemplateRead = {
diskVersion: string;
template: Template;
templateFile: File;
};
type PersistedFileWorkspaceState = {
rootHandle: FileSystemDirectoryHandle;
selectedTemplateName?: string;
updatedAt: number;
};
type RestoreResult =
| { status: 'mounted'; collection: FileWorkspaceCollection }
| { status: 'none' }
| { rootName: string; selectedTemplateName?: string; status: 'permission-needed' }
| { error: unknown; rootName?: string; status: 'error' };
type FileWorkspaceSubscriptionOptions = {
intervalMs?: number;
onError?: (error: unknown) => void;
shouldSkip?: () => boolean;
};
type FileWorkspaceCollectionSubscriptionOptions = FileWorkspaceSubscriptionOptions & {
getCollection?: () => FileWorkspaceCollection | null | undefined;
};
type CreateTemplateEntryOptions = {
description?: string;
source?: FileWorkspaceSourceInput;
sourceKind?: SourceKind;
tags?: string[];
thumbnailDataUrl?: string;
};
export type EditableFileWorkspaceMetadata = {
description: string;
tags: string[];
title: string;
};
export class FileWorkspaceTemplateDeletedError extends Error {
constructor(name: string) {
super(`Template "${name}" was deleted from disk.`);
this.name = 'FileWorkspaceTemplateDeletedError';
}
}
export class FileWorkspaceTemplateInvalidError extends Error {
constructor(
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = 'FileWorkspaceTemplateInvalidError';
}
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const titleFromDirectoryName = (name: string) =>
name
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') || name;
const inferSourceKind = (name: string): SourceKind => {
if (name.startsWith('jsx-')) return 'jsx';
if (name.startsWith('md2pdf-')) return 'md2pdf';
return 'designer';
};
const normalizeMetadata = (value: unknown, name: string): FileWorkspaceMetadata => {
if (!isRecord(value)) return { sourceKind: inferSourceKind(name), tags: [] };
const sourceKind = ['designer', 'jsx', 'md2pdf'].includes(String(value.sourceKind))
? (value.sourceKind as SourceKind)
: inferSourceKind(name);
return {
description: typeof value.description === 'string' ? value.description : undefined,
order:
typeof value.order === 'number' && Number.isFinite(value.order) ? value.order : undefined,
sourceKind,
tags: Array.isArray(value.tags)
? [
...new Set(
value.tags.filter((tag): tag is string => typeof tag === 'string' && !!tag.trim()),
),
]
: [],
title: typeof value.title === 'string' ? value.title : undefined,
};
};
const normalizeTags = (tags: string[]) => [
...new Set(tags.map((tag) => tag.trim()).filter(Boolean)),
];
const getSourceKindLabel = (sourceKind: SourceKind) => {
if (sourceKind === 'jsx') return 'JSX';
if (sourceKind === 'md2pdf') return 'md2pdf';
return 'Designer';
};
const hashText = (value: string) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 33) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getDiskVersion = (file: File, rawJson: string) =>
`${file.lastModified}:${file.size}:${hashText(rawJson)}`;
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(String(reader.result)));
reader.addEventListener('error', () => reject(reader.error));
reader.readAsDataURL(blob);
});
const writeBlob = async (fileHandle: FileSystemFileHandle, blob: Blob) => {
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
};
const writeText = async (
fileHandle: FileSystemFileHandle,
text: string,
type = 'application/json',
) => {
await writeBlob(fileHandle, new Blob([text], { type }));
};
const readJsonFile = async (fileHandle: FileSystemFileHandle) => {
const file = await fileHandle.getFile();
return { file, raw: await file.text() };
};
const dataUrlToBlob = async (dataUrl: string) => {
const response = await fetch(dataUrl);
return response.blob();
};
const getFileHandleIfExists = async (directoryHandle: FileSystemDirectoryHandle, name: string) => {
try {
return await directoryHandle.getFileHandle(name);
} catch {
return undefined;
}
};
const readMetadata = async (
directoryHandle: FileSystemDirectoryHandle,
name: string,
): Promise<FileWorkspaceMetadata> => {
const handle = await getFileHandleIfExists(directoryHandle, 'metadata.json');
if (!handle) return normalizeMetadata(undefined, name);
try {
const { raw } = await readJsonFile(handle);
return normalizeMetadata(JSON.parse(raw), name);
} catch (error) {
console.warn(`Failed to read metadata for ${name}`, error);
return normalizeMetadata(undefined, name);
}
};
const readRawMetadata = async (directoryHandle: FileSystemDirectoryHandle) => {
const handle = await getFileHandleIfExists(directoryHandle, 'metadata.json');
if (!handle) return {};
try {
const { raw } = await readJsonFile(handle);
const parsed = JSON.parse(raw);
return isRecord(parsed) ? parsed : {};
} catch (error) {
console.warn('Failed to parse metadata.json', error);
return {};
}
};
const readThumbnail = async (directoryHandle: FileSystemDirectoryHandle) => {
const handle = await getFileHandleIfExists(directoryHandle, 'thumbnail.png');
if (!handle) return {};
try {
const file = await handle.getFile();
return { thumbnailDataUrl: await blobToDataUrl(file) };
} catch (error) {
console.warn('Failed to read template thumbnail', error);
return {};
}
};
const getDirectoryEntries = async (directoryHandle: FileSystemDirectoryHandle) => {
const entries: Array<[string, FileSystemDirectoryHandle | FileSystemFileHandle]> = [];
for await (const entry of directoryHandle.entries()) {
entries.push(entry);
}
return entries.sort(([a], [b]) => a.localeCompare(b));
};
const getChildDirectoryNames = async (directoryHandle: FileSystemDirectoryHandle) => {
const entries = await getDirectoryEntries(directoryHandle);
return entries.filter(([, handle]) => handle.kind === 'directory').map(([name]) => name);
};
const toDirectoryName = (value: string) => {
const normalized = value
.trim()
.toLowerCase()
.replace(/['"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'untitled-template';
};
const createUniqueDirectoryName = async (
rootHandle: FileSystemDirectoryHandle,
preferredName: string,
currentName?: string,
) => {
const baseName = toDirectoryName(preferredName);
if (baseName === currentName) return baseName;
const existing = new Set(await getChildDirectoryNames(rootHandle));
if (!existing.has(baseName)) return baseName;
for (let index = 2; ; index += 1) {
const candidate = `${baseName}-${index}`;
if (!existing.has(candidate)) return candidate;
}
};
const copyDirectoryContents = async (
sourceDirectoryHandle: FileSystemDirectoryHandle,
targetDirectoryHandle: FileSystemDirectoryHandle,
) => {
for (const [name, handle] of await getDirectoryEntries(sourceDirectoryHandle)) {
if (handle.kind === 'directory') {
const childTargetHandle = await targetDirectoryHandle.getDirectoryHandle(name, {
create: true,
});
await copyDirectoryContents(handle, childTargetHandle);
continue;
}
const sourceFile = await handle.getFile();
const targetFileHandle = await targetDirectoryHandle.getFileHandle(name, { create: true });
await writeBlob(targetFileHandle, sourceFile);
}
};
export const serializeTemplateForFileWorkspace = (template: Template) =>
`${JSON.stringify(template, null, 2)}\n`;
export const getBlankFileWorkspaceTemplate = (): Template => ({
basePdf: {
...PAGE_SIZE_PRESETS.A4,
padding: [20, 10, 20, 10],
},
schemas: [[]],
});
export const isFileWorkspaceSupported = () =>
typeof window !== 'undefined' &&
window.isSecureContext &&
typeof window.showDirectoryPicker === 'function' &&
typeof indexedDB !== 'undefined';
const queryFileWorkspacePermission = async (
handle: FileSystemHandle,
mode: 'read' | 'readwrite' = 'readwrite',
) => {
if (!handle.queryPermission) return 'granted' as PermissionState;
return handle.queryPermission({ mode });
};
const requestFileWorkspacePermission = async (
handle: FileSystemHandle,
mode: 'read' | 'readwrite' = 'readwrite',
) => {
if (!handle.requestPermission) return 'granted' as PermissionState;
return handle.requestPermission({ mode });
};
const openWorkspaceDb = () =>
new Promise<IDBDatabase>((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB is not available.'));
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.addEventListener('upgradeneeded', () => {
request.result.createObjectStore(STORE_NAME);
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
const idbRequest = <T>(request: IDBRequest<T>) =>
new Promise<T>((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
const idbGet = async <T>(key: string) => {
const db = await openWorkspaceDb();
try {
const transaction = db.transaction(STORE_NAME, 'readonly');
return await idbRequest<T | undefined>(transaction.objectStore(STORE_NAME).get(key));
} finally {
db.close();
}
};
const idbSet = async (key: string, value: unknown) => {
const db = await openWorkspaceDb();
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
await idbRequest(transaction.objectStore(STORE_NAME).put(value, key));
} finally {
db.close();
}
};
const idbDelete = async (key: string) => {
const db = await openWorkspaceDb();
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
await idbRequest(transaction.objectStore(STORE_NAME).delete(key));
} finally {
db.close();
}
};
const getPersistedFileWorkspaceState = () =>
idbGet<PersistedFileWorkspaceState>(ACTIVE_STATE_KEY).catch(() => undefined);
export const persistFileWorkspaceState = async (
rootHandle: FileSystemDirectoryHandle,
selectedTemplateName?: string,
) => {
await idbSet(ACTIVE_STATE_KEY, {
rootHandle,
selectedTemplateName,
updatedAt: Date.now(),
} satisfies PersistedFileWorkspaceState);
};
export const setSelectedFileWorkspaceTemplateName = async (
rootHandle: FileSystemDirectoryHandle,
selectedTemplateName: string,
) => {
await persistFileWorkspaceState(rootHandle, selectedTemplateName);
};
export const clearPersistedFileWorkspace = () => idbDelete(ACTIVE_STATE_KEY);
export const readTemplateEntry = async (
entry: Pick<FileWorkspaceTemplateEntry, 'name' | 'templateDirectoryHandle'>,
): Promise<FileWorkspaceTemplateRead> => {
let templateFileHandle: FileSystemFileHandle;
try {
templateFileHandle = await entry.templateDirectoryHandle.getFileHandle('template.json');
} catch {
throw new FileWorkspaceTemplateDeletedError(entry.name);
}
try {
const { file, raw } = await readJsonFile(templateFileHandle);
const parsed = JSON.parse(raw) as Template;
checkTemplate(parsed);
return {
diskVersion: getDiskVersion(file, raw),
template: parsed,
templateFile: file,
};
} catch (error) {
if (error instanceof FileWorkspaceTemplateDeletedError) throw error;
throw new FileWorkspaceTemplateInvalidError(
`Template "${entry.name}" is not valid template JSON.`,
error,
);
}
};
const buildTemplateEntry = async (
name: string,
directoryHandle: FileSystemDirectoryHandle,
): Promise<FileWorkspaceTemplateEntry> => {
const readResult = await readTemplateEntry({ name, templateDirectoryHandle: directoryHandle });
const metadata = await readMetadata(directoryHandle, name);
const { thumbnailDataUrl } = await readThumbnail(directoryHandle);
return {
description: metadata.description,
diskVersion: readResult.diskVersion,
name,
order: metadata.order,
path: `${name}/template.json`,
sourceKind: metadata.sourceKind,
tags: metadata.tags,
template: readResult.template,
templateDirectoryHandle: directoryHandle,
thumbnailDataUrl,
title: metadata.title ?? titleFromDirectoryName(name),
updatedAt: readResult.templateFile.lastModified,
};
};
export const scanTemplateCollection = async (
rootHandle: FileSystemDirectoryHandle,
selectedTemplateName?: string,
): Promise<FileWorkspaceCollection> => {
const entries: FileWorkspaceTemplateEntry[] = [];
const invalidEntries: FileWorkspaceInvalidEntry[] = [];
const directoryEntries = await getDirectoryEntries(rootHandle);
for (const [name, handle] of directoryEntries) {
if (handle.kind !== 'directory' || name.startsWith('.')) continue;
const templateFileHandle = await getFileHandleIfExists(handle, 'template.json');
if (!templateFileHandle) continue;
try {
entries.push(await buildTemplateEntry(name, handle));
} catch (error) {
invalidEntries.push({
error: error instanceof Error ? error.message : 'Invalid template.json',
name,
});
console.warn(`Skipped invalid template directory "${name}"`, error);
}
}
return {
entries: entries.sort((a, b) => {
if (a.order != null && b.order != null) return a.order - b.order;
if (a.order != null) return -1;
if (b.order != null) return 1;
return a.title.localeCompare(b.title) || a.name.localeCompare(b.name);
}),
invalidEntries,
rootHandle,
rootName: rootHandle.name,
selectedTemplateName,
};
};
export const openTemplateCollectionDirectory = async () => {
if (!window.showDirectoryPicker) {
throw new Error('Directory picker is not supported in this browser.');
}
const rootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
const permission = await requestFileWorkspacePermission(rootHandle);
if (permission !== 'granted') {
throw new Error('Read/write permission was not granted for this folder.');
}
const collection = await scanTemplateCollection(rootHandle);
await persistFileWorkspaceState(rootHandle, collection.selectedTemplateName);
return collection;
};
export const restorePersistedTemplateCollection = async ({
requestPermission = false,
}: {
requestPermission?: boolean;
} = {}): Promise<RestoreResult> => {
const persisted = await getPersistedFileWorkspaceState();
if (!persisted) return { status: 'none' };
try {
let permission = await queryFileWorkspacePermission(persisted.rootHandle);
if (permission !== 'granted' && requestPermission) {
permission = await requestFileWorkspacePermission(persisted.rootHandle);
}
if (permission !== 'granted') {
return {
rootName: persisted.rootHandle.name,
selectedTemplateName: persisted.selectedTemplateName,
status: 'permission-needed',
};
}
const collection = await scanTemplateCollection(
persisted.rootHandle,
persisted.selectedTemplateName,
);
return { collection, status: 'mounted' };
} catch (error) {
return {
error,
rootName: persisted.rootHandle.name,
status: 'error',
};
}
};
export const refreshTemplateCollection = (collection: FileWorkspaceCollection) =>
scanTemplateCollection(collection.rootHandle, collection.selectedTemplateName);
const subscribeFileSystemObserver = (
handle: FileSystemHandle,
callback: () => void | Promise<void>,
) => {
if (typeof window === 'undefined' || !window.FileSystemObserver) return undefined;
const observer = new window.FileSystemObserver(() => {
void callback();
});
void observer.observe(handle).catch((error) => {
console.warn('Failed to observe file workspace changes', error);
});
return () => observer.disconnect();
};
export const subscribeTemplateCollectionChanges = (
collection: FileWorkspaceCollection,
listener: (collection: FileWorkspaceCollection) => void,
options: FileWorkspaceCollectionSubscriptionOptions = {},
) => {
if (typeof window === 'undefined') return () => undefined;
let disposed = false;
let checking = false;
const check = async () => {
if (disposed || checking || options.shouldSkip?.()) return;
checking = true;
try {
const currentCollection = options.getCollection ? options.getCollection() : collection;
if (!currentCollection) return;
listener(await refreshTemplateCollection(currentCollection));
} catch (error) {
options.onError?.(error);
} finally {
checking = false;
}
};
const intervalId = window.setInterval(
check,
options.intervalMs ?? DEFAULT_COLLECTION_POLLING_INTERVAL_MS,
);
const disconnectObserver = subscribeFileSystemObserver(collection.rootHandle, check);
return () => {
disposed = true;
window.clearInterval(intervalId);
disconnectObserver?.();
};
};
export const subscribeTemplateEntryChanges = (
entry: FileWorkspaceTemplateEntry,
listener: (readResult: FileWorkspaceTemplateRead) => void,
options: FileWorkspaceSubscriptionOptions = {},
) => {
if (typeof window === 'undefined') return () => undefined;
let disposed = false;
let checking = false;
let lastDiskVersion = entry.diskVersion;
let hadError = false;
const check = async () => {
if (disposed || checking || options.shouldSkip?.()) return;
checking = true;
try {
const readResult = await readTemplateEntry(entry);
if (readResult.diskVersion === lastDiskVersion && !hadError) return;
hadError = false;
lastDiskVersion = readResult.diskVersion;
listener(readResult);
} catch (error) {
hadError = true;
options.onError?.(error);
} finally {
checking = false;
}
};
const intervalId = window.setInterval(
check,
options.intervalMs ?? DEFAULT_ENTRY_POLLING_INTERVAL_MS,
);
const disconnectObserver = subscribeFileSystemObserver(entry.templateDirectoryHandle, check);
return () => {
disposed = true;
window.clearInterval(intervalId);
disconnectObserver?.();
};
};
export const createBlankTemplateEntry = async (
rootHandle: FileSystemDirectoryHandle,
title = 'Untitled Template',
) => {
const name = await createUniqueDirectoryName(rootHandle, title);
const directoryHandle = await rootHandle.getDirectoryHandle(name, { create: true });
const templateFileHandle = await directoryHandle.getFileHandle('template.json', {
create: true,
});
const metadataFileHandle = await directoryHandle.getFileHandle('metadata.json', {
create: true,
});
const template = getBlankFileWorkspaceTemplate();
await writeText(templateFileHandle, serializeTemplateForFileWorkspace(template));
await writeText(
metadataFileHandle,
`${JSON.stringify(
{
description: 'A blank template created from the pdfme Playground.',
sourceKind: 'designer',
tags: ['Blank', 'Starter'],
title: title.trim() || titleFromDirectoryName(name),
},
null,
2,
)}\n`,
);
const entry = await buildTemplateEntry(name, directoryHandle);
await persistFileWorkspaceState(rootHandle, name).catch(() => undefined);
return entry;
};
export const createTemplateEntryFromTemplate = async (
collection: FileWorkspaceCollection,
template: Template,
title: string,
options: CreateTemplateEntryOptions = {},
) => {
checkTemplate(template);
const name = await createUniqueDirectoryName(collection.rootHandle, title);
const directoryHandle = await collection.rootHandle.getDirectoryHandle(name, { create: true });
const templateFileHandle = await directoryHandle.getFileHandle('template.json', {
create: true,
});
const metadataFileHandle = await directoryHandle.getFileHandle('metadata.json', {
create: true,
});
const sourceKind = options.sourceKind ?? 'designer';
const resolvedTitle = title.trim() || titleFromDirectoryName(name);
await writeText(templateFileHandle, serializeTemplateForFileWorkspace(template));
await writeText(
metadataFileHandle,
`${JSON.stringify(
{
description: options.description ?? 'A template saved from the pdfme Playground.',
sourceKind,
tags: normalizeTags(options.tags ?? [getSourceKindLabel(sourceKind)]),
title: resolvedTitle,
},
null,
2,
)}\n`,
);
if (options.source) {
const sourceFileName = options.source.language === 'markdown' ? 'source.md' : 'source.tsx';
const sourceFileHandle = await directoryHandle.getFileHandle(sourceFileName, { create: true });
await writeText(sourceFileHandle, options.source.content, 'text/plain');
}
if (options.thumbnailDataUrl) {
try {
const thumbnailFileHandle = await directoryHandle.getFileHandle('thumbnail.png', {
create: true,
});
await writeBlob(thumbnailFileHandle, await dataUrlToBlob(options.thumbnailDataUrl));
} catch (error) {
console.warn('Failed to copy template thumbnail', error);
}
}
const entry = await buildTemplateEntry(name, directoryHandle);
await persistFileWorkspaceState(collection.rootHandle, name).catch(() => undefined);
return entry;
};
export const writeTemplateMetadata = async (
collection: Pick<FileWorkspaceCollection, 'rootHandle'>,
entry: FileWorkspaceTemplateEntry,
metadata: EditableFileWorkspaceMetadata,
): Promise<FileWorkspaceTemplateEntry> => {
const nextTitle = metadata.title.trim() || entry.title;
const nextName = await createUniqueDirectoryName(collection.rootHandle, nextTitle, entry.name);
const shouldRename = nextName !== entry.name;
const targetDirectoryHandle = shouldRename
? await collection.rootHandle.getDirectoryHandle(nextName, { create: true })
: entry.templateDirectoryHandle;
if (shouldRename) {
await copyDirectoryContents(entry.templateDirectoryHandle, targetDirectoryHandle);
}
const rawMetadata = await readRawMetadata(entry.templateDirectoryHandle);
const {
description: _description,
sourceKind: _sourceKind,
tags: _tags,
title: _title,
...restMetadata
} = rawMetadata;
const sourceKind = normalizeMetadata(rawMetadata, entry.name).sourceKind;
const nextMetadata = {
title: nextTitle,
description: metadata.description.trim(),
sourceKind,
tags: normalizeTags(metadata.tags),
...restMetadata,
};
const metadataFileHandle = await targetDirectoryHandle.getFileHandle('metadata.json', {
create: true,
});
await writeText(metadataFileHandle, `${JSON.stringify(nextMetadata, null, 2)}\n`);
const updatedEntry = await buildTemplateEntry(nextName, targetDirectoryHandle);
if (shouldRename) {
await collection.rootHandle.removeEntry(entry.name, { recursive: true });
}
await persistFileWorkspaceState(collection.rootHandle, updatedEntry.name).catch(() => undefined);
return updatedEntry;
};
export const writeTemplateEntry = async (
entry: FileWorkspaceTemplateEntry,
template: Template,
): Promise<FileWorkspaceTemplateEntry> => {
checkTemplate(template);
const templateFileHandle = await entry.templateDirectoryHandle.getFileHandle('template.json', {
create: true,
});
await writeText(templateFileHandle, serializeTemplateForFileWorkspace(template));
const readResult = await readTemplateEntry(entry);
return {
...entry,
diskVersion: readResult.diskVersion,
template: readResult.template,
updatedAt: readResult.templateFile.lastModified,
};
};
export const writeTemplateThumbnail = async (
entry: FileWorkspaceTemplateEntry,
template: Template,
inputs = getInputFromTemplate(template),
) => {
const thumbnailDataUrl = await createTemplateThumbnailDataUrl(template, inputs);
const thumbnail = await dataUrlToBlob(thumbnailDataUrl);
const thumbnailFileHandle = await entry.templateDirectoryHandle.getFileHandle('thumbnail.png', {
create: true,
});
await writeBlob(thumbnailFileHandle, thumbnail);
return { thumbnailDataUrl };
};
export const findTemplateEntry = (
collection: FileWorkspaceCollection,
name: string | null | undefined,
) => collection.entries.find((entry) => entry.name === name) ?? null;

View File

@@ -0,0 +1,24 @@
import { getInputFromTemplate, type Template } from '@pdfme/common';
export type TemplateInput = Record<string, string>;
export const reconcileInputsWithTemplate = (
template: Template,
previousInputs: TemplateInput[] | null | undefined,
): TemplateInput[] => {
const defaultInputs = getInputFromTemplate(template);
if (!previousInputs || previousInputs.length === 0) return defaultInputs;
return defaultInputs.map((defaultInput, index) => {
const previousInput = previousInputs[index] ?? {};
const nextInput: TemplateInput = { ...defaultInput };
for (const name of Object.keys(nextInput)) {
if (previousInput[name] != null) {
nextInput[name] = previousInput[name];
}
}
return nextInput;
});
};

View File

@@ -5,11 +5,12 @@ import { Code2, Copy, Download, Save } from 'lucide-react';
import { cloneDeep, Template, checkTemplate, Lang, isBlankPdf } from '@pdfme/common';
import { Designer } from '@pdfme/ui';
import {
fromKebabCase,
getFontsData,
getTemplateById,
getTemplateMetadataById,
getBlankTemplate,
getDefaultPlaygroundTemplate,
getDefaultPlaygroundTemplateMetadata,
readFile,
generatePDF,
downloadJsonFile,
@@ -28,6 +29,23 @@ import {
type PlaygroundProject,
} from '../lib/playgroundProjects';
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
import {
FileWorkspaceTemplateDeletedError,
FileWorkspaceTemplateInvalidError,
createTemplateEntryFromTemplate,
findTemplateEntry,
readTemplateEntry,
refreshTemplateCollection,
restorePersistedTemplateCollection,
serializeTemplateForFileWorkspace,
setSelectedFileWorkspaceTemplateName,
subscribeTemplateEntryChanges,
writeTemplateEntry,
writeTemplateThumbnail,
type FileWorkspaceCollection,
type FileWorkspaceTemplateEntry,
type FileWorkspaceTemplateRead,
} from '../lib/fileWorkspace';
function destroyDesignerInstance(instance: Designer) {
try {
@@ -48,6 +66,15 @@ type DesignerLoadRequest = {
shouldCreateNewProject: boolean;
shouldConsumeQuery: boolean;
templateId: string | null;
workspaceTemplateName: string | null;
};
type FileWorkspaceStatus = 'deleted' | 'invalid' | null;
type FileWorkspaceConflict = {
incoming?: FileWorkspaceTemplateRead;
message: string;
saveTemplate?: Template;
};
function getDesignerLoadRequest(): DesignerLoadRequest {
@@ -55,6 +82,7 @@ function getDesignerLoadRequest(): DesignerLoadRequest {
const shouldCreateNewProject = searchParams.get('new') === '1';
const templateId = searchParams.get('template');
const projectId = searchParams.get('project');
const workspaceTemplateName = searchParams.get('workspace');
return {
projectId,
@@ -62,6 +90,7 @@ function getDesignerLoadRequest(): DesignerLoadRequest {
shouldCreateNewProject,
shouldConsumeQuery: shouldCreateNewProject || templateId != null || projectId != null,
templateId,
workspaceTemplateName,
};
}
@@ -98,27 +127,195 @@ function DesignerFileButton({
}
function DesignerApp() {
const [, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const designerRef = useRef<HTMLDivElement | null>(null);
const designer = useRef<Designer | null>(null);
const searchParamsRef = useRef(searchParams);
const projectRef = useRef<PlaygroundProject | null>(null);
const fileWorkspaceCollectionRef = useRef<FileWorkspaceCollection | null>(null);
const fileWorkspaceEntryRef = useRef<FileWorkspaceTemplateEntry | null>(null);
const diskVersionRef = useRef<string | null>(null);
const lastCleanSerializedTemplateRef = useRef<string | null>(null);
const isApplyingTemplateRef = useRef(false);
const isSavingFileWorkspaceRef = useRef(false);
const isFileWorkspaceDirtyRef = useRef(false);
const fileWorkspaceStatusRef = useRef<FileWorkspaceStatus>(null);
const projectTitleRef = useRef('Untitled Template');
const loadRequestRef = useRef<DesignerLoadRequest | null>(null);
const didCleanLoadQueryRef = useRef(false);
const [editingStaticSchemas, setEditingStaticSchemas] = useState(false);
const [fileWorkspaceEntry, setFileWorkspaceEntry] = useState<FileWorkspaceTemplateEntry | null>(
null,
);
const [fileWorkspaceStatus, setFileWorkspaceStatus] = useState<FileWorkspaceStatus>(null);
const [fileWorkspaceConflict, setFileWorkspaceConflict] = useState<FileWorkspaceConflict | null>(
null,
);
const [isFileWorkspaceDirty, setIsFileWorkspaceDirty] = useState(false);
const [originalTemplate, setOriginalTemplate] = useState<Template | null>(null);
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const [templateJsonSource, setTemplateJsonSource] = useState<Template | null>(null);
useEffect(() => {
searchParamsRef.current = searchParams;
}, [searchParams]);
const setCurrentProjectTitle = useCallback((title: string) => {
projectTitleRef.current = title;
}, []);
const setActiveFileWorkspaceEntry = useCallback(
(collection: FileWorkspaceCollection | null, entry: FileWorkspaceTemplateEntry | null) => {
fileWorkspaceCollectionRef.current = collection;
fileWorkspaceEntryRef.current = entry;
diskVersionRef.current = entry?.diskVersion ?? null;
lastCleanSerializedTemplateRef.current = entry
? serializeTemplateForFileWorkspace(entry.template)
: null;
setFileWorkspaceEntry(entry);
setFileWorkspaceStatus(null);
setFileWorkspaceConflict(null);
setIsFileWorkspaceDirty(false);
if (entry) setCurrentProjectTitle(entry.title);
},
[setCurrentProjectTitle],
);
const updateFileWorkspaceDirtyState = useCallback((template: Template) => {
const cleanTemplate = lastCleanSerializedTemplateRef.current;
const dirty =
cleanTemplate != null && serializeTemplateForFileWorkspace(template) !== cleanTemplate;
isFileWorkspaceDirtyRef.current = dirty;
setIsFileWorkspaceDirty(dirty);
}, []);
const applyTemplateFromDisk = useCallback(
(entry: FileWorkspaceTemplateEntry, readResult: FileWorkspaceTemplateRead) => {
if (!designer.current) return;
isApplyingTemplateRef.current = true;
try {
designer.current.updateTemplate(readResult.template);
} finally {
isApplyingTemplateRef.current = false;
}
const nextEntry: FileWorkspaceTemplateEntry = {
...entry,
diskVersion: readResult.diskVersion,
template: readResult.template,
updatedAt: readResult.templateFile.lastModified,
};
fileWorkspaceEntryRef.current = nextEntry;
diskVersionRef.current = readResult.diskVersion;
lastCleanSerializedTemplateRef.current = serializeTemplateForFileWorkspace(
readResult.template,
);
setFileWorkspaceEntry(nextEntry);
setFileWorkspaceStatus(null);
setFileWorkspaceConflict(null);
setIsFileWorkspaceDirty(false);
},
[],
);
const onSaveTemplate = useCallback(
async (template?: Template, saveAs = false) => {
if (!designer.current) return;
const currentFileEntry = fileWorkspaceEntryRef.current;
const currentFileCollection = fileWorkspaceCollectionRef.current;
if (currentFileEntry && currentFileCollection) {
const nextTemplate = template || designer.current.getTemplate();
let targetEntry = currentFileEntry;
if (saveAs) {
const title =
window.prompt('Save as', `${currentFileEntry.title || currentFileEntry.name} Copy`) ??
'';
if (!title.trim()) return;
targetEntry = await createTemplateEntryFromTemplate(
currentFileCollection,
nextTemplate,
title,
);
const nextSearchParams = new URLSearchParams(searchParamsRef.current);
nextSearchParams.set('workspace', targetEntry.name);
nextSearchParams.delete('new');
nextSearchParams.delete('template');
nextSearchParams.delete('project');
setSearchParams(nextSearchParams, { replace: true });
} else if (diskVersionRef.current) {
try {
const diskRead = await readTemplateEntry(currentFileEntry);
if (diskRead.diskVersion !== diskVersionRef.current) {
setFileWorkspaceConflict({
incoming: diskRead,
message: `${currentFileEntry.path} changed on disk since it was loaded.`,
saveTemplate: nextTemplate,
});
return;
}
} catch (error) {
if (
!(error instanceof FileWorkspaceTemplateDeletedError) &&
!(error instanceof FileWorkspaceTemplateInvalidError)
) {
throw error;
}
}
}
isSavingFileWorkspaceRef.current = true;
try {
const savedEntry = await writeTemplateEntry(targetEntry, nextTemplate);
let nextEntry = savedEntry;
try {
const thumbnail = await writeTemplateThumbnail(savedEntry, nextTemplate);
nextEntry = {
...savedEntry,
thumbnailDataUrl: thumbnail.thumbnailDataUrl,
};
} catch (error) {
console.warn(error);
toast.warn('Saved template, but thumbnail update failed');
}
const refreshedCollection = await refreshTemplateCollection({
...currentFileCollection,
selectedTemplateName: nextEntry.name,
}).catch(() => currentFileCollection);
const refreshedEntry =
findTemplateEntry(refreshedCollection, nextEntry.name) ?? nextEntry;
fileWorkspaceCollectionRef.current = refreshedCollection;
fileWorkspaceEntryRef.current = refreshedEntry;
diskVersionRef.current = refreshedEntry.diskVersion;
lastCleanSerializedTemplateRef.current = serializeTemplateForFileWorkspace(
refreshedEntry.template,
);
setFileWorkspaceEntry(refreshedEntry);
setFileWorkspaceStatus(null);
setFileWorkspaceConflict(null);
setIsFileWorkspaceDirty(false);
setCurrentProjectTitle(refreshedEntry.title);
await setSelectedFileWorkspaceTemplateName(
refreshedCollection.rootHandle,
refreshedEntry.name,
);
toast.success(
<ProjectSavedToast
formPath={`/form-viewer?workspace=${encodeURIComponent(refreshedEntry.name)}`}
title={refreshedEntry.path}
/>,
);
} finally {
isSavingFileWorkspaceRef.current = false;
}
return;
}
const currentProject = projectRef.current;
const nextTemplate = template || designer.current.getTemplate();
const currentTitle =
@@ -143,9 +340,14 @@ function DesignerApp() {
});
projectRef.current = savedProject;
setCurrentProjectTitle(savedProject.title);
toast.success(<ProjectSavedToast title={savedProject.title} />);
toast.success(
<ProjectSavedToast
formPath={`/form-viewer?project=${encodeURIComponent(savedProject.id)}`}
title={savedProject.title}
/>,
);
},
[setCurrentProjectTitle],
[setCurrentProjectTitle, setSearchParams],
);
const buildDesigner = useCallback(
@@ -161,27 +363,58 @@ function DesignerApp() {
shouldConsumeQuery,
shouldCreateNewProject,
templateId: templateIdFromQuery,
workspaceTemplateName,
} = loadRequestRef.current;
if (shouldCreateNewProject) {
if (workspaceTemplateName) {
const restored = await restorePersistedTemplateCollection();
if (restored.status !== 'mounted') {
throw new Error('Mounted folder is not available. Reopen it from Templates.');
}
const entry = findTemplateEntry(restored.collection, workspaceTemplateName);
if (!entry) {
throw new Error(
`Template "${workspaceTemplateName}" was not found in the mounted folder.`,
);
}
template = entry.template;
await setSelectedFileWorkspaceTemplateName(restored.collection.rootHandle, entry.name);
setActiveFileWorkspaceEntry(
{ ...restored.collection, selectedTemplateName: entry.name },
entry,
);
} else if (shouldCreateNewProject) {
setActiveFileWorkspaceEntry(null, null);
clearActivePlaygroundProject();
setCurrentProjectTitle('Untitled Template');
} else if (projectIdFromQuery) {
setActiveFileWorkspaceEntry(null, null);
project = getPlaygroundProject(projectIdFromQuery);
if (!project) throw new Error('Project not found');
template = project.template;
} else if (templateIdFromQuery) {
const templateJson = await getTemplateById(templateIdFromQuery);
setActiveFileWorkspaceEntry(null, null);
const [templateJson, metadata] = await Promise.all([
getTemplateById(templateIdFromQuery),
getTemplateMetadataById(templateIdFromQuery),
]);
checkTemplate(templateJson);
template = templateJson;
setCurrentProjectTitle(fromKebabCase(templateIdFromQuery));
setCurrentProjectTitle(metadata.title);
} else {
setActiveFileWorkspaceEntry(null, null);
project = getActivePlaygroundProject();
if (project) {
template = project.template;
} else {
template = await getDefaultPlaygroundTemplate();
setCurrentProjectTitle(fromKebabCase('invoice'));
const [defaultTemplate, metadata] = await Promise.all([
getDefaultPlaygroundTemplate(),
getDefaultPlaygroundTemplateMetadata(),
]);
template = defaultTemplate;
setCurrentProjectTitle(metadata.title);
}
}
@@ -221,15 +454,26 @@ function DesignerApp() {
});
designer.current = nextDesigner;
nextDesigner.onSaveTemplate(onSaveTemplate);
nextDesigner.onChangeTemplate((nextTemplate) => {
if (!fileWorkspaceEntryRef.current || isApplyingTemplateRef.current) return;
updateFileWorkspaceDirtyState(nextTemplate);
});
return nextDesigner;
} catch (error) {
if (isCancelled()) return null;
projectRef.current = null;
setActiveFileWorkspaceEntry(null, null);
console.error(error);
return null;
}
},
[onSaveTemplate, setCurrentProjectTitle, setSearchParams],
[
onSaveTemplate,
setActiveFileWorkspaceEntry,
setCurrentProjectTitle,
setSearchParams,
updateFileWorkspaceDirtyState,
],
);
const onChangeBasePDF = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -252,6 +496,19 @@ function DesignerApp() {
};
const onResetTemplate = () => {
if (fileWorkspaceEntryRef.current && designer.current) {
const entry = fileWorkspaceEntryRef.current;
isApplyingTemplateRef.current = true;
try {
designer.current.updateTemplate(entry.template);
} finally {
isApplyingTemplateRef.current = false;
}
lastCleanSerializedTemplateRef.current = serializeTemplateForFileWorkspace(entry.template);
setIsFileWorkspaceDirty(false);
return;
}
projectRef.current = null;
setCurrentProjectTitle('Untitled Template');
clearActivePlaygroundProject();
@@ -274,6 +531,56 @@ function DesignerApp() {
toast.success('Template JSON committed');
};
const onReloadConflictFromDisk = () => {
const currentEntry = fileWorkspaceEntryRef.current;
const incoming = fileWorkspaceConflict?.incoming;
if (!currentEntry || !incoming) {
setFileWorkspaceConflict(null);
return;
}
applyTemplateFromDisk(currentEntry, incoming);
};
const onKeepConflictEditing = useCallback(() => {
const incoming = fileWorkspaceConflict?.incoming;
if (incoming) {
diskVersionRef.current = incoming.diskVersion;
}
setFileWorkspaceConflict(null);
}, [fileWorkspaceConflict]);
const onSaveOverConflict = async () => {
if (!designer.current) return;
const currentConflict = fileWorkspaceConflict;
const template = fileWorkspaceConflict?.saveTemplate ?? designer.current.getTemplate();
if (fileWorkspaceConflict?.incoming) {
diskVersionRef.current = fileWorkspaceConflict.incoming.diskVersion;
}
try {
await onSaveTemplate(template);
setFileWorkspaceConflict(null);
} catch (error) {
console.error(error);
setFileWorkspaceConflict(currentConflict);
toast.error(error instanceof Error ? error.message : 'Failed to save over disk');
}
};
useEffect(() => {
if (!fileWorkspaceConflict) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onKeepConflictEditing();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [fileWorkspaceConflict, onKeepConflictEditing]);
const toggleEditingStaticSchemas = () => {
if (!designer.current) return;
@@ -349,6 +656,61 @@ function DesignerApp() {
};
}, [buildDesigner]);
useEffect(() => {
fileWorkspaceStatusRef.current = fileWorkspaceStatus;
}, [fileWorkspaceStatus]);
useEffect(() => {
isFileWorkspaceDirtyRef.current = isFileWorkspaceDirty;
}, [isFileWorkspaceDirty]);
useEffect(() => {
if (!fileWorkspaceEntry) return;
return subscribeTemplateEntryChanges(
fileWorkspaceEntry,
(readResult) => {
const currentEntry = fileWorkspaceEntryRef.current;
if (!currentEntry) return;
if (readResult.diskVersion === diskVersionRef.current) {
if (fileWorkspaceStatusRef.current) setFileWorkspaceStatus(null);
return;
}
if (isFileWorkspaceDirtyRef.current) {
setFileWorkspaceConflict({
incoming: readResult,
message: `${currentEntry.path} changed on disk while you were editing.`,
});
diskVersionRef.current = readResult.diskVersion;
return;
}
applyTemplateFromDisk(currentEntry, readResult);
toast.info(`Reloaded ${currentEntry.path} from disk`, {
toastId: `file-workspace-reload:${currentEntry.path}`,
});
},
{
onError: (error) => {
if (error instanceof FileWorkspaceTemplateDeletedError) {
setFileWorkspaceStatus('deleted');
return;
}
if (error instanceof FileWorkspaceTemplateInvalidError) {
setFileWorkspaceStatus('invalid');
return;
}
console.error(error);
},
shouldSkip: () => isSavingFileWorkspaceRef.current,
},
);
}, [applyTemplateFromDisk, fileWorkspaceEntry]);
const navItems: NavItem[] = [
{
label: 'Lang',
@@ -398,7 +760,7 @@ function DesignerApp() {
),
},
{
label: 'Project',
label: fileWorkspaceEntry ? 'Workspace' : 'Project',
content: (
<div className="flex gap-1">
<PlaygroundButton
@@ -407,7 +769,7 @@ function DesignerApp() {
onClick={() => void onSaveTemplate()}
>
<Save className="size-3.5" />
Save Project
{fileWorkspaceEntry ? `Save ${fileWorkspaceEntry.path}` : 'Save Project'}
</PlaygroundButton>
<PlaygroundButton
id="save-as"
@@ -460,7 +822,55 @@ function DesignerApp() {
return (
<>
<NavBar items={navItems} />
{fileWorkspaceEntry && (fileWorkspaceStatus || isFileWorkspaceDirty) && (
<div className="border-b border-yellow-200 bg-yellow-50 px-3 py-2 text-sm text-yellow-900">
{fileWorkspaceStatus === 'invalid' &&
`${fileWorkspaceEntry.path} is currently invalid on disk. The editor is keeping the last valid template.`}
{fileWorkspaceStatus === 'deleted' &&
`${fileWorkspaceEntry.path} was deleted on disk. Saving will recreate it.`}
{!fileWorkspaceStatus &&
isFileWorkspaceDirty &&
`${fileWorkspaceEntry.path} has unsaved changes.`}
</div>
)}
<div ref={designerRef} className="flex-1 w-full" />
{fileWorkspaceConflict && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
aria-describedby="file-workspace-conflict-description"
aria-labelledby="file-workspace-conflict-title"
aria-modal="true"
className="w-full max-w-lg rounded-lg bg-white p-5 shadow-xl"
role="dialog"
>
<h2 id="file-workspace-conflict-title" className="text-lg font-bold text-gray-900">
Template changed on disk
</h2>
<div
id="file-workspace-conflict-description"
className="mt-2 space-y-2 text-sm text-gray-600"
>
<p>{fileWorkspaceConflict.message}</p>
<p>Keeping your edits means the next save will overwrite the current disk version.</p>
</div>
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
<PlaygroundButton
disabled={!fileWorkspaceConflict.incoming}
onClick={onReloadConflictFromDisk}
variant="secondary"
>
Reload from disk
</PlaygroundButton>
<PlaygroundButton onClick={onKeepConflictEditing} variant="secondary">
Keep editing
</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveOverConflict()} variant="primary">
Save over disk
</PlaygroundButton>
</div>
</div>
</div>
)}
<TemplateJsonDialog
isOpen={jsonDialogOpen}
template={templateJsonSource}

View File

@@ -1,14 +1,15 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Template, checkTemplate, getInputFromTemplate, Lang } from '@pdfme/common';
import { Form, Viewer } from '@pdfme/ui';
import {
fromKebabCase,
getFontsData,
getTemplateById,
getTemplateMetadataById,
getBlankTemplate,
getDefaultPlaygroundTemplate,
getDefaultPlaygroundTemplateMetadata,
generatePDF,
isJsonString,
translations,
@@ -24,6 +25,16 @@ import {
type PlaygroundProject,
} from '../lib/playgroundProjects';
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
import {
FileWorkspaceTemplateDeletedError,
FileWorkspaceTemplateInvalidError,
findTemplateEntry,
restorePersistedTemplateCollection,
setSelectedFileWorkspaceTemplateName,
subscribeTemplateEntryChanges,
type FileWorkspaceTemplateEntry,
} from '../lib/fileWorkspace';
import { reconcileInputsWithTemplate } from '../lib/templateInputs';
type Mode = 'form' | 'viewer';
@@ -32,12 +43,21 @@ function FormAndViewerApp() {
const uiRef = useRef<HTMLDivElement | null>(null);
const ui = useRef<Form | Viewer | null>(null);
const projectRef = useRef<PlaygroundProject | null>(null);
const fileWorkspaceEntryRef = useRef<FileWorkspaceTemplateEntry | null>(null);
const diskVersionRef = useRef<string | null>(null);
const fileWorkspaceStatusRef = useRef<'deleted' | 'invalid' | null>(null);
const buildIdRef = useRef(0);
const currentSourceKeyRef = useRef<string | null>(null);
const currentTemplateRef = useRef<Template | null>(null);
const currentInputsRef = useRef<Record<string, string>[] | null>(null);
const [mode, setMode] = useState<Mode>((localStorage.getItem('mode') as Mode) ?? 'form');
const [fileWorkspaceEntry, setFileWorkspaceEntry] = useState<FileWorkspaceTemplateEntry | null>(
null,
);
const [fileWorkspaceStatus, setFileWorkspaceStatus] = useState<'deleted' | 'invalid' | null>(
null,
);
const [projectTitle, setProjectTitle] = useState('Untitled Template');
const snapshotCurrentUi = useCallback(() => {
@@ -66,34 +86,77 @@ function FormAndViewerApp() {
let inputs: Record<string, string>[] | null = null;
const templateIdFromQuery = searchParams.get('template');
const projectIdFromQuery = searchParams.get('project');
const workspaceTemplateName = searchParams.get('workspace');
const sourceKey = projectIdFromQuery
? `project:${projectIdFromQuery}`
: templateIdFromQuery
? `template:${templateIdFromQuery}`
: 'current-or-default';
: workspaceTemplateName
? `workspace:${workspaceTemplateName}`
: templateIdFromQuery
? `template:${templateIdFromQuery}`
: 'current-or-default';
if (currentSourceKeyRef.current === sourceKey && currentTemplateRef.current) {
template = currentTemplateRef.current;
inputs = ui.current?.getInputs() ?? currentInputsRef.current;
project = projectRef.current;
} else if (workspaceTemplateName) {
const restored = await restorePersistedTemplateCollection();
if (restored.status !== 'mounted') {
throw new Error('Mounted folder is not available. Reopen it from Templates.');
}
const entry = findTemplateEntry(restored.collection, workspaceTemplateName);
if (!entry) {
throw new Error(
`Template "${workspaceTemplateName}" was not found in the mounted folder.`,
);
}
template = entry.template;
inputs = getInputFromTemplate(template);
fileWorkspaceEntryRef.current = entry;
diskVersionRef.current = entry.diskVersion;
setFileWorkspaceEntry(entry);
setFileWorkspaceStatus(null);
setProjectTitle(entry.title);
await setSelectedFileWorkspaceTemplateName(restored.collection.rootHandle, entry.name);
} else if (projectIdFromQuery) {
fileWorkspaceEntryRef.current = null;
diskVersionRef.current = null;
setFileWorkspaceEntry(null);
setFileWorkspaceStatus(null);
project = getPlaygroundProject(projectIdFromQuery);
if (!project) throw new Error('Project not found');
template = project.template;
inputs = project.inputs;
} else if (templateIdFromQuery) {
const templateJson = await getTemplateById(templateIdFromQuery);
fileWorkspaceEntryRef.current = null;
diskVersionRef.current = null;
setFileWorkspaceEntry(null);
setFileWorkspaceStatus(null);
const [templateJson, metadata] = await Promise.all([
getTemplateById(templateIdFromQuery),
getTemplateMetadataById(templateIdFromQuery),
]);
checkTemplate(templateJson);
template = templateJson;
setProjectTitle(fromKebabCase(templateIdFromQuery));
setProjectTitle(metadata.title);
} else {
fileWorkspaceEntryRef.current = null;
diskVersionRef.current = null;
setFileWorkspaceEntry(null);
setFileWorkspaceStatus(null);
project = getActivePlaygroundProject();
if (project) {
template = project.template;
inputs = project.inputs;
} else {
template = await getDefaultPlaygroundTemplate();
setProjectTitle(fromKebabCase('invoice'));
const [defaultTemplate, metadata] = await Promise.all([
getDefaultPlaygroundTemplate(),
getDefaultPlaygroundTemplateMetadata(),
]);
template = defaultTemplate;
setProjectTitle(metadata.title);
}
}
@@ -126,6 +189,10 @@ function FormAndViewerApp() {
});
} catch (error) {
projectRef.current = null;
fileWorkspaceEntryRef.current = null;
diskVersionRef.current = null;
setFileWorkspaceEntry(null);
setFileWorkspaceStatus(null);
currentSourceKeyRef.current = null;
currentTemplateRef.current = null;
currentInputsRef.current = null;
@@ -188,7 +255,12 @@ function FormAndViewerApp() {
currentTemplateRef.current = nextTemplate;
currentInputsRef.current = nextInputs;
setProjectTitle(savedProject.title);
toast.success(<ProjectSavedToast title={savedProject.title} />);
toast.success(
<ProjectSavedToast
formPath={`/form-viewer?project=${encodeURIComponent(savedProject.id)}`}
title={savedProject.title}
/>,
);
};
const onResetInputs = () => {
@@ -208,6 +280,65 @@ function FormAndViewerApp() {
};
}, [mode, uiRef, buildUi, destroyCurrentUi]);
useEffect(() => {
fileWorkspaceStatusRef.current = fileWorkspaceStatus;
}, [fileWorkspaceStatus]);
useEffect(() => {
if (!fileWorkspaceEntry) return;
return subscribeTemplateEntryChanges(
fileWorkspaceEntry,
(readResult) => {
const currentEntry = fileWorkspaceEntryRef.current;
if (!currentEntry || !ui.current) return;
if (readResult.diskVersion === diskVersionRef.current) {
if (fileWorkspaceStatusRef.current) setFileWorkspaceStatus(null);
return;
}
const nextInputs = reconcileInputsWithTemplate(
readResult.template,
ui.current.getInputs() ?? currentInputsRef.current,
);
ui.current.updateTemplate(readResult.template);
ui.current.setInputs(nextInputs);
const nextEntry = {
...currentEntry,
diskVersion: readResult.diskVersion,
template: readResult.template,
updatedAt: readResult.templateFile.lastModified,
};
fileWorkspaceEntryRef.current = nextEntry;
diskVersionRef.current = readResult.diskVersion;
currentTemplateRef.current = readResult.template;
currentInputsRef.current = nextInputs;
setFileWorkspaceEntry(nextEntry);
setFileWorkspaceStatus(null);
toast.info(`Reloaded ${currentEntry.path} from disk`, {
toastId: `file-workspace-reload:${currentEntry.path}`,
});
},
{
onError: (error) => {
if (error instanceof FileWorkspaceTemplateDeletedError) {
setFileWorkspaceStatus('deleted');
return;
}
if (error instanceof FileWorkspaceTemplateInvalidError) {
setFileWorkspaceStatus('invalid');
return;
}
console.error(error);
},
},
);
}, [fileWorkspaceEntry]);
const navItems: NavItem[] = [
{
label: 'Lang',
@@ -248,8 +379,12 @@ function FormAndViewerApp() {
<div className="flex gap-1">
<PlaygroundButton onClick={onGetInputs}>Get</PlaygroundButton>
<PlaygroundButton onClick={onSetInputs}>Set</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs()}>Save</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs(true)}>Save As</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs()}>
{fileWorkspaceEntry ? 'Save Local Copy' : 'Save'}
</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs(true)}>
{fileWorkspaceEntry ? 'Save As Local Copy' : 'Save As'}
</PlaygroundButton>
<PlaygroundButton onClick={onResetInputs}>Reset</PlaygroundButton>
</div>
),
@@ -280,6 +415,14 @@ function FormAndViewerApp() {
return (
<>
<NavBar items={navItems} />
{fileWorkspaceEntry && fileWorkspaceStatus && (
<div className="border-b border-yellow-200 bg-yellow-50 px-3 py-2 text-sm text-yellow-900">
{fileWorkspaceStatus === 'invalid' &&
`${fileWorkspaceEntry.path} is currently invalid on disk. The viewer is keeping the last valid template.`}
{fileWorkspaceStatus === 'deleted' &&
`${fileWorkspaceEntry.path} was deleted on disk. The viewer is keeping the last loaded template.`}
</div>
)}
<div ref={uiRef} className="flex-1 w-full" />
</>
);

View File

@@ -7,6 +7,8 @@ import {
Download,
Eye,
FileText,
FolderOpen,
FolderX,
MoreHorizontal,
Pencil,
PencilRuler,
@@ -14,7 +16,7 @@ import {
Upload,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { downloadJsonFile, fromKebabCase, readFile } from '../helper';
import { downloadJsonFile, readFile } from '../helper';
import PlaygroundButton from '../components/PlaygroundButton';
import { getAuthoringStarterId, type AuthoringStarterKind } from '../lib/authoringStarters';
import {
@@ -30,6 +32,26 @@ import {
type PlaygroundProject,
} from '../lib/playgroundProjects';
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
import {
clearPersistedFileWorkspace,
createBlankTemplateEntry,
createTemplateEntryFromTemplate,
findTemplateEntry,
isFileWorkspaceSupported,
openTemplateCollectionDirectory,
persistFileWorkspaceState,
refreshTemplateCollection,
restorePersistedTemplateCollection,
setSelectedFileWorkspaceTemplateName,
subscribeTemplateCollectionChanges,
writeTemplateMetadata,
writeTemplateThumbnail,
type EditableFileWorkspaceMetadata,
type FileWorkspaceCollection,
type FileWorkspaceSourceInput,
type FileWorkspaceTemplateEntry,
type SourceKind,
} from '../lib/fileWorkspace';
declare global {
interface Window {
@@ -44,20 +66,21 @@ type TemplateData = {
name: string;
author: string;
basePdfKind?: string;
description?: string;
description: string;
fieldCount?: number;
fontNames?: string[];
hasCJK?: boolean;
pageCount?: number;
schemaTypes?: string[];
sourceKind?: Exclude<GenerationFilter, 'all'>;
sourceKind: Exclude<GenerationFilter, 'all'>;
sourcePath?: string;
tags?: string[];
title?: string;
tags: string[];
title: string;
};
type UIType = 'designer' | 'form-viewer';
type GenerationFilter = 'all' | 'designer' | 'jsx' | 'md2pdf';
type MountedCollectionWriteRunner = <T>(write: () => Promise<T>) => Promise<T>;
type AuthoringPreset = {
assetName: string;
@@ -96,7 +119,13 @@ const generationFilters: Array<{ label: string; value: GenerationFilter }> = [
];
const getTemplateGeneration = (template: TemplateData): Exclude<GenerationFilter, 'all'> =>
template.sourceKind ?? 'designer';
template.sourceKind;
const getGenerationLabel = (generation: Exclude<GenerationFilter, 'all'>) => {
if (generation === 'jsx') return 'JSX';
if (generation === 'md2pdf') return 'md2pdf';
return 'Designer';
};
const getAuthoringPreset = (template: TemplateData): AuthoringPreset | null => {
const kind = getTemplateGeneration(template);
@@ -109,7 +138,7 @@ const getAuthoringPreset = (template: TemplateData): AuthoringPreset | null => {
};
const getTemplateTags = (template: TemplateData) => {
const tags = new Set(template.tags ?? []);
const tags = new Set(template.tags);
return [...tags].sort((a, b) => {
const aIndex = tagSortOrder.indexOf(a);
@@ -174,6 +203,51 @@ const ProjectThumbnailImage = ({
return <ThumbnailImage alt={project.title} src={src} />;
};
const MountedThumbnailImage = ({
entry,
onCreated,
runWrite,
}: {
entry: FileWorkspaceTemplateEntry;
onCreated: () => void;
runWrite: MountedCollectionWriteRunner;
}) => {
const [src, setSrc] = useState(entry.thumbnailDataUrl);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSrc(entry.thumbnailDataUrl);
setError(null);
if (entry.thumbnailDataUrl) return;
let cancelled = false;
void runWrite(() => writeTemplateThumbnail(entry, entry.template))
.then(({ thumbnailDataUrl }) => {
if (cancelled) return;
setSrc(thumbnailDataUrl);
onCreated();
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to create thumbnail');
});
return () => {
cancelled = true;
};
}, [entry, onCreated, runWrite]);
if (error) {
return (
<div className="flex h-72 w-full items-center justify-center bg-yellow-50 p-4 text-center text-xs text-yellow-800">
Thumbnail unavailable
</div>
);
}
return <ThumbnailImage alt={entry.title} src={src} />;
};
const GalleryCard = ({
actions,
description,
@@ -216,13 +290,14 @@ const GalleryCard = ({
</div>
);
type ProjectActionHandler = (project: PlaygroundProject) => void;
type ProjectActionHandler = (project: PlaygroundProject) => Promise<void> | void;
type ProjectMoreActionsProps = {
onDeleteProject: ProjectActionHandler;
onDownloadProjectTemplate: ProjectActionHandler;
onDuplicateProject: ProjectActionHandler;
onOpenDesigner: ProjectActionHandler;
onCopyToMountedFolder?: ProjectActionHandler;
onRenameProject: ProjectActionHandler;
project: PlaygroundProject;
};
@@ -256,6 +331,7 @@ function ProjectMoreActions({
onDownloadProjectTemplate,
onDuplicateProject,
onOpenDesigner,
onCopyToMountedFolder,
onRenameProject,
project,
}: ProjectMoreActionsProps) {
@@ -264,7 +340,7 @@ function ProjectMoreActions({
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const runAction = (handler: ProjectActionHandler) => {
setOpen(false);
handler(project);
void handler(project);
};
useEffect(() => {
@@ -327,6 +403,12 @@ function ProjectMoreActions({
<Copy className="size-4" />
Duplicate
</ProjectMenuItem>
{onCopyToMountedFolder && (
<ProjectMenuItem onClick={() => runAction(onCopyToMountedFolder)}>
<FolderOpen className="size-4" />
Copy to Mounted Folder
</ProjectMenuItem>
)}
<ProjectMenuItem onClick={() => runAction(onDownloadProjectTemplate)}>
<Download className="size-4" />
Template JSON
@@ -342,6 +424,163 @@ function ProjectMoreActions({
);
}
const parseTagInput = (value: string) => [
...new Set(
value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
),
];
const getProjectSourceKind = (project: PlaygroundProject): SourceKind => {
if (project.kind === 'jsx') return 'jsx';
if (project.kind === 'md2pdf') return 'md2pdf';
return 'designer';
};
const getProjectSourceInput = (project: PlaygroundProject): FileWorkspaceSourceInput | undefined =>
project.source
? {
content: project.source.content,
language: project.source.language,
}
: undefined;
const MountedMetadataDialog = ({
entry,
onClose,
onSave,
}: {
entry: FileWorkspaceTemplateEntry;
onClose: () => void;
onSave: (
entry: FileWorkspaceTemplateEntry,
metadata: EditableFileWorkspaceMetadata,
) => Promise<void>;
}) => {
const [title, setTitle] = useState(entry.title);
const [description, setDescription] = useState(entry.description ?? '');
const [tags, setTags] = useState(entry.tags.join(', '));
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const titleInputRef = React.useRef<HTMLInputElement | null>(null);
useEffect(() => {
titleInputRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || isSaving) return;
event.preventDefault();
onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isSaving, onClose]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!title.trim()) {
setError('Title is required.');
return;
}
setIsSaving(true);
setError(null);
try {
await onSave(entry, {
description,
tags: parseTagInput(tags),
title,
});
} catch (saveError) {
console.error(saveError);
setError(saveError instanceof Error ? saveError.message : 'Failed to save metadata.');
} finally {
setIsSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<button
type="button"
aria-label="Close metadata editor"
className="absolute inset-0 cursor-default"
onClick={isSaving ? undefined : onClose}
/>
<form
aria-labelledby="mounted-metadata-dialog-title"
aria-modal="true"
className="relative z-10 w-full max-w-lg rounded-lg bg-white p-5 shadow-xl"
onSubmit={(event) => void onSubmit(event)}
role="dialog"
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 id="mounted-metadata-dialog-title" className="text-lg font-bold text-gray-900">
Edit Metadata
</h3>
<p className="mt-1 text-xs text-gray-500">{entry.name}/metadata.json</p>
<p className="mt-1 text-xs text-gray-500">
Changing the title also renames the template folder.
</p>
</div>
<PlaygroundButton disabled={isSaving} onClick={onClose} type="button" variant="ghost">
Close
</PlaygroundButton>
</div>
<div className="mt-4 space-y-4">
<label className="block text-sm font-medium text-gray-700">
Title
<input
ref={titleInputRef}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="block text-sm font-medium text-gray-700">
Description
<textarea
className="mt-1 min-h-24 w-full rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<label className="block text-sm font-medium text-gray-700">
Tags
<input
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
value={tags}
onChange={(event) => setTags(event.target.value)}
/>
</label>
</div>
{error && (
<div className="mt-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<PlaygroundButton disabled={isSaving} onClick={onClose} type="button" variant="secondary">
Cancel
</PlaygroundButton>
<PlaygroundButton disabled={isSaving} type="submit" variant="primary">
{isSaving ? 'Saving...' : 'Save Metadata'}
</PlaygroundButton>
</div>
</form>
</div>
);
};
// Author link component to avoid duplication
const AuthorLink = ({ author }: { author: string }) => {
if (author === DEVIN_AI_AUTHOR) {
@@ -372,14 +611,52 @@ const AuthorLink = ({ author }: { author: string }) => {
function TemplatesApp() {
const navigate = useNavigate();
const importTemplateInputRef = React.useRef<HTMLInputElement | null>(null);
const mountedCollectionRef = React.useRef<FileWorkspaceCollection | null>(null);
const mountedCollectionWriteCountRef = React.useRef(0);
const fileWorkspaceSupported = isFileWorkspaceSupported();
const [templates, setTemplates] = useState<TemplateData[]>([]);
const [avatarUrlMap, setAvatarUrlMap] = useState<{ [key: string]: string }>({});
const [projects, setProjects] = useState<PlaygroundProject[]>([]);
const [mountedCollection, setMountedCollection] = useState<FileWorkspaceCollection | null>(null);
const [editingMountedEntry, setEditingMountedEntry] = useState<FileWorkspaceTemplateEntry | null>(
null,
);
const [lastFolderName, setLastFolderName] = useState<string | null>(null);
const [isOpeningFolder, setIsOpeningFolder] = useState(false);
const [generationFilter, setGenerationFilter] = useState<GenerationFilter>('all');
const [tagFilter, setTagFilter] = useState('all');
const refreshProjects = useCallback(() => setProjects(readPlaygroundProjects()), []);
const runMountedCollectionWrite = useCallback(async <T,>(write: () => Promise<T>) => {
mountedCollectionWriteCountRef.current += 1;
try {
return await write();
} finally {
mountedCollectionWriteCountRef.current = Math.max(
0,
mountedCollectionWriteCountRef.current - 1,
);
}
}, []);
const shouldSkipMountedCollectionRefresh = useCallback(
() => mountedCollectionWriteCountRef.current > 0,
[],
);
const refreshMountedCollection = useCallback(() => {
const collection = mountedCollectionRef.current;
if (!collection) return;
void refreshTemplateCollection(collection)
.then((collection) => {
setMountedCollection(collection);
setLastFolderName(collection.rootName);
})
.catch((error) => {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to refresh folder');
});
}, []);
const tagOptions = useMemo(() => {
const tags = new Set<string>();
@@ -421,6 +698,52 @@ function TemplatesApp() {
return () => window.removeEventListener('focus', refreshProjects);
}, [refreshProjects]);
useEffect(() => {
if (!fileWorkspaceSupported) return;
let cancelled = false;
void restorePersistedTemplateCollection().then((result) => {
if (cancelled) return;
if (result.status === 'mounted') {
setMountedCollection(result.collection);
setLastFolderName(result.collection.rootName);
} else if (result.status === 'permission-needed') {
setLastFolderName(result.rootName);
} else if (result.status === 'error') {
setLastFolderName(result.rootName ?? null);
console.error(result.error);
}
});
return () => {
cancelled = true;
};
}, [fileWorkspaceSupported]);
useEffect(() => {
mountedCollectionRef.current = mountedCollection;
}, [mountedCollection]);
useEffect(() => {
const collection = mountedCollectionRef.current;
if (!collection) return;
return subscribeTemplateCollectionChanges(
collection,
(collection) => {
setMountedCollection(collection);
setLastFolderName(collection.rootName);
},
{
onError: (error) => {
console.error(error);
},
getCollection: () => mountedCollectionRef.current,
shouldSkip: shouldSkipMountedCollectionRefresh,
},
);
}, [mountedCollection?.rootHandle, shouldSkipMountedCollectionRefresh]);
// Fetch templates and author avatars
useEffect(() => {
fetch('/template-assets/index.json')
@@ -476,11 +799,184 @@ function TemplatesApp() {
navigate(`${path}?project=${encodeURIComponent(project.id)}`);
};
const navigateToMountedTemplate = async (
collection: FileWorkspaceCollection,
entry: FileWorkspaceTemplateEntry,
ui: UIType,
) => {
await setSelectedFileWorkspaceTemplateName(collection.rootHandle, entry.name);
const path = ui === 'designer' ? '/designer' : '/form-viewer';
navigate(`${path}?workspace=${encodeURIComponent(entry.name)}`);
};
const navigateToAuthoringPreset = (preset: AuthoringPreset) => {
const route = preset.kind === 'jsx' ? '/jsx' : '/md2pdf';
navigate(`${route}?preset=${encodeURIComponent(preset.id)}`);
};
const mountCollection = async (collection: FileWorkspaceCollection) => {
setMountedCollection(collection);
setLastFolderName(collection.rootName);
if (collection.entries.length > 0) {
await persistFileWorkspaceState(
collection.rootHandle,
collection.selectedTemplateName ?? collection.entries[0]?.name,
);
return;
}
const shouldCreate = window.confirm(
`"${collection.rootName}" has no valid template directories. Create a blank template now?`,
);
if (!shouldCreate) return;
const { nextCollection, nextEntry } = await runMountedCollectionWrite(async () => {
const entry = await createBlankTemplateEntry(collection.rootHandle);
const collectionAfterCreate = await refreshTemplateCollection({
...collection,
selectedTemplateName: entry.name,
});
return {
nextCollection: collectionAfterCreate,
nextEntry: findTemplateEntry(collectionAfterCreate, entry.name) ?? entry,
};
});
setMountedCollection(nextCollection);
await navigateToMountedTemplate(nextCollection, nextEntry, 'designer');
};
const onOpenFolder = async () => {
setIsOpeningFolder(true);
try {
await mountCollection(await openTemplateCollectionDirectory());
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to open folder');
} finally {
setIsOpeningFolder(false);
}
};
const onReopenFolder = async () => {
setIsOpeningFolder(true);
try {
const result = await restorePersistedTemplateCollection({ requestPermission: true });
if (result.status === 'mounted') {
await mountCollection(result.collection);
} else if (result.status === 'permission-needed') {
setLastFolderName(result.rootName);
toast.error('Folder permission was not granted');
} else if (result.status === 'none') {
setLastFolderName(null);
toast.info('No previous folder was found');
} else {
console.error(result.error);
toast.error('Failed to reopen folder');
}
} finally {
setIsOpeningFolder(false);
}
};
const onDisconnectFolder = async () => {
await clearPersistedFileWorkspace();
setMountedCollection(null);
setLastFolderName(null);
toast.info('Disconnected mounted folder');
};
const onCreateMountedTemplate = async () => {
const collection = mountedCollectionRef.current;
if (!collection) return;
const title = window.prompt('Template name', 'Untitled Template') ?? '';
if (!title.trim()) return;
try {
const { nextCollection, nextEntry } = await runMountedCollectionWrite(async () => {
const entry = await createBlankTemplateEntry(collection.rootHandle, title);
const collectionAfterCreate = await refreshTemplateCollection({
...collection,
selectedTemplateName: entry.name,
});
return {
nextCollection: collectionAfterCreate,
nextEntry: findTemplateEntry(collectionAfterCreate, entry.name) ?? entry,
};
});
setMountedCollection(nextCollection);
setLastFolderName(nextCollection.rootName);
await navigateToMountedTemplate(nextCollection, nextEntry, 'designer');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to create template');
}
};
const onSaveMountedMetadata = async (
entry: FileWorkspaceTemplateEntry,
metadata: EditableFileWorkspaceMetadata,
) => {
const collection = mountedCollectionRef.current;
if (!collection) throw new Error('Mounted folder is not available.');
const { nextCollection, updatedEntry } = await runMountedCollectionWrite(async () => {
const updatedEntry = await writeTemplateMetadata(collection, entry, metadata);
const collectionAfterSave = await refreshTemplateCollection({
...collection,
selectedTemplateName: updatedEntry.name,
});
return { nextCollection: collectionAfterSave, updatedEntry };
});
setMountedCollection(nextCollection);
setLastFolderName(nextCollection.rootName);
setEditingMountedEntry(null);
toast.success(`Updated "${updatedEntry.title}" metadata`);
};
const onCopyProjectToMountedFolder = async (project: PlaygroundProject) => {
const collection = mountedCollectionRef.current;
if (!collection) {
toast.error('Open a mounted folder first');
return;
}
try {
const { nextCollection } = await runMountedCollectionWrite(async () => {
const sourceKind = getProjectSourceKind(project);
const entry = await createTemplateEntryFromTemplate(
collection,
project.template,
project.title,
{
description: 'A template copied from Browser Projects.',
source: getProjectSourceInput(project),
sourceKind,
tags: [getGenerationLabel(sourceKind)],
thumbnailDataUrl: project.thumbnail,
},
);
const collectionAfterCopy = await refreshTemplateCollection({
...collection,
selectedTemplateName: entry.name,
});
return { nextCollection: collectionAfterCopy };
});
setMountedCollection(nextCollection);
setLastFolderName(nextCollection.rootName);
toast.success(`Copied "${project.title}" to ${nextCollection.rootName}`);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to copy project');
}
};
const onImportTemplateJson = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
@@ -555,91 +1051,250 @@ function TemplatesApp() {
<div className="bg-white">
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-12 lg:max-w-7xl lg:px-8">
<div className="mb-10 rounded-lg border border-green-200 bg-green-50 p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">My Workspace</h2>
<p className="mt-2 max-w-3xl text-sm text-green-900">
Save templates from Designer, JSX, or md2pdf as local projects. A project keeps the
generated template, inputs, and source when available.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<PlaygroundButton
onClick={() => importTemplateInputRef.current?.click()}
variant="secondary"
>
<Upload className="size-4" />
Import Template JSON
</PlaygroundButton>
<input
ref={importTemplateInputRef}
type="file"
accept="application/json"
className="sr-only"
onChange={onImportTemplateJson}
/>
<PlaygroundButton onClick={() => navigate('/designer?new=1')} variant="primary">
<PencilRuler className="size-4" />
New Template
</PlaygroundButton>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">My Workspace</h2>
<p className="mt-2 max-w-3xl text-sm text-green-900">
Work with templates saved in this browser, or mount a folder to edit template files
directly on disk.
</p>
</div>
{projects.length > 0 ? (
<div className="mt-5 grid grid-cols-1 gap-y-8 sm:grid-cols-2 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
{projects.map((project) => (
<GalleryCard
key={project.id}
tag={getProjectKindLabel(project.kind)}
title={project.title}
description={
<p className="text-xs text-gray-500">
Updated {new Date(project.updatedAt).toLocaleString()}
</p>
}
thumbnail={
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
}
actions={
<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" />
Preview
</PlaygroundButton>
<ProjectMoreActions
project={project}
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
onRenameProject={onRenameProject}
onDuplicateProject={onDuplicateProject}
onDownloadProjectTemplate={onDownloadProjectTemplate}
onDeleteProject={onDeleteProject}
/>
</div>
}
<div className="mt-5 border-t border-green-200 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">Browser Projects</h3>
<p className="mt-1 text-sm text-green-900">
Drafts stored in this browser. They include template JSON, form inputs, and source
code when available.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<PlaygroundButton
onClick={() => importTemplateInputRef.current?.click()}
variant="secondary"
>
<Upload className="size-4" />
Import Template JSON
</PlaygroundButton>
<input
ref={importTemplateInputRef}
type="file"
accept="application/json"
className="sr-only"
onChange={onImportTemplateJson}
/>
))}
<PlaygroundButton onClick={() => navigate('/designer?new=1')} variant="primary">
<PencilRuler className="size-4" />
New Local Template
</PlaygroundButton>
</div>
</div>
) : (
<div className="mt-5 rounded-md border border-dashed border-green-300 bg-white px-4 py-6 text-sm text-green-900">
No local projects yet. Start from a sample, JSX, md2pdf, or a blank Designer template.
{projects.length > 0 ? (
<div className="mt-5 grid grid-cols-1 gap-y-8 sm:grid-cols-2 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
{projects.map((project) => (
<GalleryCard
key={project.id}
tag={`Local ${getProjectKindLabel(project.kind)}`}
title={project.title}
description={
<p className="text-xs text-gray-500">
Updated {new Date(project.updatedAt).toLocaleString()}
</p>
}
thumbnail={
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
}
actions={
<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')}>
Preview
</PlaygroundButton>
<ProjectMoreActions
project={project}
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
onRenameProject={onRenameProject}
onDuplicateProject={onDuplicateProject}
onCopyToMountedFolder={
mountedCollection ? onCopyProjectToMountedFolder : undefined
}
onDownloadProjectTemplate={onDownloadProjectTemplate}
onDeleteProject={onDeleteProject}
/>
</div>
}
/>
))}
</div>
) : (
<div className="mt-5 rounded-md border border-dashed border-green-300 bg-white px-4 py-6 text-sm text-green-900">
No browser projects yet. Create a local template, import JSON, or save from
Designer, JSX, or md2pdf.
</div>
)}
</div>
<div className="mt-6 border-t border-green-200 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">Mounted Folder</h3>
<p className="mt-1 text-sm text-green-900">
Templates in this section are read from and saved back to template files on disk.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{!mountedCollection && (
<PlaygroundButton
disabled={!fileWorkspaceSupported || isOpeningFolder}
onClick={() => void onOpenFolder()}
variant="primary"
>
<FolderOpen className="size-4" />
{isOpeningFolder ? 'Opening...' : 'Open Folder'}
</PlaygroundButton>
)}
{!mountedCollection && lastFolderName && (
<PlaygroundButton
disabled={!fileWorkspaceSupported || isOpeningFolder}
onClick={() => void onReopenFolder()}
title={lastFolderName}
variant="secondary"
>
<FolderOpen className="size-4" />
Reopen Folder
</PlaygroundButton>
)}
{mountedCollection && (
<PlaygroundButton onClick={() => void onDisconnectFolder()} variant="secondary">
<FolderX className="size-4" />
Disconnect
</PlaygroundButton>
)}
{mountedCollection && (
<PlaygroundButton
onClick={() => void onCreateMountedTemplate()}
variant="primary"
>
<PencilRuler className="size-4" />
New Mounted Template
</PlaygroundButton>
)}
</div>
</div>
)}
{!fileWorkspaceSupported && (
<div className="mt-4 rounded-md border border-dashed border-green-300 bg-white px-4 py-4 text-sm text-green-900">
Folder workspaces need a Chromium browser in a secure context. Browser projects,
JSON import, and JSON download are still available.
</div>
)}
{mountedCollection && (
<>
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm text-green-900">
<span className="font-semibold">{mountedCollection.rootName}</span>
<span className="text-green-700">
{mountedCollection.entries.length} template
{mountedCollection.entries.length === 1 ? '' : 's'}
</span>
{mountedCollection.invalidEntries.length > 0 && (
<span className="rounded border border-yellow-300 bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-800">
{mountedCollection.invalidEntries.length} invalid skipped
</span>
)}
</div>
{mountedCollection.entries.length > 0 ? (
<div className="mt-5 grid grid-cols-1 gap-y-8 sm:grid-cols-2 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
{mountedCollection.entries.map((entry) => (
<GalleryCard
key={entry.name}
tag={`Disk ${getGenerationLabel(entry.sourceKind)}`}
title={entry.title}
tags={entry.tags}
description={
<div className="space-y-2">
<p>
{entry.description ??
`${entry.name}/template.json from the mounted folder.`}
</p>
<p className="text-xs text-gray-500">
Updated {new Date(entry.updatedAt).toLocaleString()}
</p>
</div>
}
thumbnail={
<MountedThumbnailImage
entry={entry}
onCreated={refreshMountedCollection}
runWrite={runMountedCollectionWrite}
/>
}
actions={
<div className="space-y-2">
<PlaygroundButton
fullWidth
onClick={() =>
void navigateToMountedTemplate(mountedCollection, entry, 'designer')
}
>
<PencilRuler className="size-4" />
Designer
</PlaygroundButton>
<PlaygroundButton
fullWidth
onClick={() =>
void navigateToMountedTemplate(
mountedCollection,
entry,
'form-viewer',
)
}
>
Form/Viewer
</PlaygroundButton>
<PlaygroundButton
fullWidth
onClick={() => setEditingMountedEntry(entry)}
variant="secondary"
>
<Pencil className="size-4" />
Metadata
</PlaygroundButton>
</div>
}
/>
))}
</div>
) : (
<div className="mt-4 rounded-md border border-dashed border-green-300 bg-white px-4 py-4 text-sm text-green-900">
No valid template directories are mounted. Create a mounted template to write a
new folder with template.json.
</div>
)}
</>
)}
{!mountedCollection && fileWorkspaceSupported && !lastFolderName && (
<div className="mt-4 rounded-md border border-dashed border-green-300 bg-white px-4 py-4 text-sm text-green-900">
Open a folder that contains directories like{' '}
<code className="rounded bg-green-100 px-1">invoice/template.json</code>.
</div>
)}
</div>
</div>
<section>
<div className="border-b border-dashed border-gray-200 pb-2">
@@ -706,7 +1361,7 @@ function TemplatesApp() {
{filteredTemplates.map((template, index) => {
const { name, author } = template;
const authoringPreset = getAuthoringPreset(template);
const title = template.title ?? fromKebabCase(name);
const title = template.title;
const generation = getTemplateGeneration(template);
const tag =
generation === 'jsx' ? 'JSX' : generation === 'md2pdf' ? 'md2pdf' : 'Designer';
@@ -728,7 +1383,7 @@ function TemplatesApp() {
tags={tags}
description={
<div className="space-y-3">
<p>{template.description ?? 'A ready-to-edit pdfme sample template.'}</p>
<p>{template.description}</p>
<p className="flex items-center gap-2 text-xs text-gray-500">
by{' '}
{avatarUrlMap[author] && (
@@ -792,6 +1447,13 @@ function TemplatesApp() {
</div>
</section>
</div>
{editingMountedEntry && (
<MountedMetadataDialog
entry={editingMountedEntry}
onClose={() => setEditingMountedEntry(null)}
onSave={onSaveMountedMetadata}
/>
)}
</div>
);
}

View File

@@ -1 +1,51 @@
/// <reference types="vite/client" />
interface FileSystemHandlePermissionDescriptor {
mode?: 'read' | 'readwrite';
}
interface FileSystemHandle {
readonly kind: 'directory' | 'file';
readonly name: string;
isSameEntry?(other: FileSystemHandle): Promise<boolean>;
queryPermission?(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
requestPermission?(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
}
interface FileSystemWritableFileStream extends WritableStream {
write(data: Blob | BufferSource | string): Promise<void>;
close(): Promise<void>;
}
interface FileSystemFileHandle extends FileSystemHandle {
readonly kind: 'file';
createWritable(): Promise<FileSystemWritableFileStream>;
getFile(): Promise<File>;
}
interface FileSystemDirectoryHandle extends FileSystemHandle {
readonly kind: 'directory';
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
getDirectoryHandle(
name: string,
options?: { create?: boolean },
): Promise<FileSystemDirectoryHandle>;
getFileHandle(name: string, options?: { create?: boolean }): Promise<FileSystemFileHandle>;
keys(): AsyncIterableIterator<string>;
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
values(): AsyncIterableIterator<FileSystemDirectoryHandle | FileSystemFileHandle>;
}
interface FileSystemObserver {
disconnect(): void;
observe(handle: FileSystemHandle, options?: { recursive?: boolean }): Promise<void>;
}
interface Window {
FileSystemObserver?: new (
callback: (records: unknown[], observer: FileSystemObserver) => void,
) => FileSystemObserver;
showDirectoryPicker?: (options?: {
mode?: 'read' | 'readwrite';
}) => Promise<FileSystemDirectoryHandle>;
}