mirror of
https://github.com/pdfme/pdfme.git
synced 2026-05-19 12:15:08 -04:00
[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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
281
playground/e2e/fileWorkspace.test.ts
Normal file
281
playground/e2e/fileWorkspace.test.ts
Normal 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": [');
|
||||
});
|
||||
});
|
||||
49
playground/e2e/templateInputs.test.ts
Normal file
49
playground/e2e/templateInputs.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
10
playground/public/template-assets/a4-blank/metadata.json
Normal file
10
playground/public/template-assets/a4-blank/metadata.json
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Address Label 10",
|
||||
"description": "A 10-label address sheet for shipping and mailing workflows.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Labels",
|
||||
"Shipping"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Address Label 30",
|
||||
"description": "A compact 30-label address sheet for dense mailing labels.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Labels",
|
||||
"Shipping"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Certificate Blue",
|
||||
"description": "A polished blue certificate layout for awards and completion documents.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Certificate Gold",
|
||||
"description": "A warm gold certificate layout with a classic presentation style.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Certificate White",
|
||||
"description": "A minimal certificate layout that works well with light branding.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Inline Markdown MVT",
|
||||
"description": "A focused demo of inline markdown and MultiVariableText editing.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Markdown",
|
||||
"MVT",
|
||||
"Form"
|
||||
]
|
||||
}
|
||||
10
playground/public/template-assets/invoice-blue/metadata.json
Normal file
10
playground/public/template-assets/invoice-blue/metadata.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Invoice Green",
|
||||
"description": "A green invoice variant with a calm accounting-oriented look.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Japanese Invoice Landscape",
|
||||
"description": "A landscape Japanese invoice layout for wider table content.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"CJK"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Japanese Invoice",
|
||||
"description": "A simple Japanese invoice layout with CJK font usage.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"CJK"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Invoice White",
|
||||
"description": "A restrained white invoice layout with a clean printable style.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
12
playground/public/template-assets/invoice/metadata.json
Normal file
12
playground/public/template-assets/invoice/metadata.json
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Location Arrow",
|
||||
"description": "A location marker template that highlights points with arrow indicators.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Map",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Location Number",
|
||||
"description": "A location marker template that labels points with numbered badges.",
|
||||
"sourceKind": "designer",
|
||||
"tags": [
|
||||
"Map",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
12
playground/public/template-assets/pedigree/metadata.json
Normal file
12
playground/public/template-assets/pedigree/metadata.json
Normal 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
|
||||
}
|
||||
10
playground/public/template-assets/qr-lines/metadata.json
Normal file
10
playground/public/template-assets/qr-lines/metadata.json
Normal 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
|
||||
}
|
||||
9
playground/public/template-assets/qr-title/metadata.json
Normal file
9
playground/public/template-assets/qr-title/metadata.json
Normal 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"
|
||||
]
|
||||
}
|
||||
12
playground/public/template-assets/quotes/metadata.json
Normal file
12
playground/public/template-assets/quotes/metadata.json
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!,
|
||||
}));
|
||||
};
|
||||
|
||||
841
playground/src/lib/fileWorkspace.ts
Normal file
841
playground/src/lib/fileWorkspace.ts
Normal 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;
|
||||
24
playground/src/lib/templateInputs.ts
Normal file
24
playground/src/lib/templateInputs.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
50
playground/src/vite-env.d.ts
vendored
50
playground/src/vite-env.d.ts
vendored
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user