mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-16 18:29:17 -04:00
feat(playground): add mounted file workspace
This commit is contained in:
147
playground/e2e/fileWorkspace.test.ts
Normal file
147
playground/e2e/fileWorkspace.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
import {
|
||||
createBlankTemplateEntry,
|
||||
scanTemplateCollection,
|
||||
serializeTemplateForFileWorkspace,
|
||||
writeTemplateEntry,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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', 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('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(templateFile.text).toContain('"schemas": [');
|
||||
});
|
||||
});
|
||||
41
playground/e2e/templateInputs.test.ts
Normal file
41
playground/e2e/templateInputs.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
8
playground/public/template-assets/a4-blank/metadata.json
Normal file
8
playground/public/template-assets/a4-blank/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"order": 130,
|
||||
"description": "A clean blank A4 document for starting from scratch in Designer.",
|
||||
"tags": [
|
||||
"Blank",
|
||||
"Starter"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A 10-label address sheet for shipping and mailing workflows.",
|
||||
"tags": [
|
||||
"Labels",
|
||||
"Shipping"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A compact 30-label address sheet for dense mailing labels.",
|
||||
"tags": [
|
||||
"Labels",
|
||||
"Shipping"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A larger 6-label address sheet with room for longer addresses.",
|
||||
"tags": [
|
||||
"Labels",
|
||||
"Shipping"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"order": 120,
|
||||
"description": "A high-contrast certificate layout with a formal dark theme.",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A polished blue certificate layout for awards and completion documents.",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A warm gold certificate layout with a classic presentation style.",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A minimal certificate layout that works well with light branding.",
|
||||
"tags": [
|
||||
"Certificate",
|
||||
"Award"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A Japanese social insurance form template for structured government-style documents.",
|
||||
"tags": [
|
||||
"Government",
|
||||
"CJK",
|
||||
"Form"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A focused demo of inline markdown and MultiVariableText editing.",
|
||||
"tags": [
|
||||
"Markdown",
|
||||
"MVT",
|
||||
"Form"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A blue invoice variant for a more branded business document.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A green invoice variant with a calm accounting-oriented look.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A landscape Japanese invoice layout for wider table content.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"CJK"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A simple Japanese invoice layout with CJK font usage.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"CJK"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A restrained white invoice layout with a clean printable style.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
10
playground/public/template-assets/invoice/metadata.json
Normal file
10
playground/public/template-assets/invoice/metadata.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 90,
|
||||
"description": "A practical invoice with customer details, line items, totals, and payment notes.",
|
||||
"tags": [
|
||||
"Invoice",
|
||||
"Business",
|
||||
"Table",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A location marker template that highlights points with arrow indicators.",
|
||||
"tags": [
|
||||
"Map",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A location marker template that labels points with numbered badges.",
|
||||
"tags": [
|
||||
"Map",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "A sales quotation template with product rows and business summary fields.",
|
||||
"tags": [
|
||||
"Quote",
|
||||
"Business",
|
||||
"Table"
|
||||
]
|
||||
}
|
||||
10
playground/public/template-assets/pedigree/metadata.json
Normal file
10
playground/public/template-assets/pedigree/metadata.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 110,
|
||||
"description": "A pedigree-style relationship chart for structured family or lineage data.",
|
||||
"tags": [
|
||||
"Chart",
|
||||
"QR",
|
||||
"Image",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
8
playground/public/template-assets/qr-lines/metadata.json
Normal file
8
playground/public/template-assets/qr-lines/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"order": 140,
|
||||
"description": "A QR code template with line-based metadata and compact supporting text.",
|
||||
"tags": [
|
||||
"QR",
|
||||
"Label"
|
||||
]
|
||||
}
|
||||
7
playground/public/template-assets/qr-title/metadata.json
Normal file
7
playground/public/template-assets/qr-title/metadata.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A QR code template with a prominent title and simple scan instructions.",
|
||||
"tags": [
|
||||
"QR",
|
||||
"Label"
|
||||
]
|
||||
}
|
||||
10
playground/public/template-assets/quotes/metadata.json
Normal file
10
playground/public/template-assets/quotes/metadata.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 100,
|
||||
"description": "A quote document layout for proposals, estimates, and short commercial offers.",
|
||||
"tags": [
|
||||
"Quote",
|
||||
"Business",
|
||||
"Table",
|
||||
"Visual"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "A z-fold brochure layout for tri-fold print and promotional material.",
|
||||
"tags": [
|
||||
"Brochure",
|
||||
"Print"
|
||||
]
|
||||
}
|
||||
611
playground/src/lib/fileWorkspace.ts
Normal file
611
playground/src/lib/fileWorkspace.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
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';
|
||||
|
||||
type SourceKind = 'designer' | 'jsx' | 'md2pdf';
|
||||
|
||||
export type FileWorkspaceMetadata = {
|
||||
description?: string;
|
||||
order?: number;
|
||||
sourceKind?: SourceKind;
|
||||
tags: string[];
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type FileWorkspaceTemplateEntry = {
|
||||
description?: string;
|
||||
diskVersion: string;
|
||||
metadataFileHandle?: FileSystemFileHandle;
|
||||
name: string;
|
||||
order?: number;
|
||||
path: string;
|
||||
rawJson: string;
|
||||
sourceKind: SourceKind;
|
||||
tags: string[];
|
||||
template: Template;
|
||||
templateDirectoryHandle: FileSystemDirectoryHandle;
|
||||
templateFileHandle: FileSystemFileHandle;
|
||||
thumbnailDataUrl?: string;
|
||||
thumbnailFileHandle?: FileSystemFileHandle;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type FileWorkspaceInvalidEntry = {
|
||||
error: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FileWorkspaceCollection = {
|
||||
entries: FileWorkspaceTemplateEntry[];
|
||||
invalidEntries: FileWorkspaceInvalidEntry[];
|
||||
rootHandle: FileSystemDirectoryHandle;
|
||||
rootName: string;
|
||||
scannedAt: number;
|
||||
selectedTemplateName?: string;
|
||||
};
|
||||
|
||||
export type FileWorkspaceTemplateRead = {
|
||||
diskVersion: string;
|
||||
rawJson: string;
|
||||
template: Template;
|
||||
templateFile: File;
|
||||
templateFileHandle: FileSystemFileHandle;
|
||||
};
|
||||
|
||||
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' };
|
||||
|
||||
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 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) => {
|
||||
await writeBlob(fileHandle, new Blob([text], { type: 'application/json' }));
|
||||
};
|
||||
|
||||
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<{ handle?: FileSystemFileHandle; metadata: FileWorkspaceMetadata }> => {
|
||||
const handle = await getFileHandleIfExists(directoryHandle, 'metadata.json');
|
||||
if (!handle) return { metadata: normalizeMetadata(undefined, name) };
|
||||
|
||||
try {
|
||||
const { raw } = await readJsonFile(handle);
|
||||
return { handle, metadata: normalizeMetadata(JSON.parse(raw), name) };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read metadata for ${name}`, error);
|
||||
return { handle, metadata: normalizeMetadata(undefined, name) };
|
||||
}
|
||||
};
|
||||
|
||||
const readThumbnail = async (directoryHandle: FileSystemDirectoryHandle) => {
|
||||
const handle = await getFileHandleIfExists(directoryHandle, 'thumbnail.png');
|
||||
if (!handle) return {};
|
||||
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
return { handle, thumbnailDataUrl: await blobToDataUrl(file) };
|
||||
} catch (error) {
|
||||
console.warn('Failed to read template thumbnail', error);
|
||||
return { handle };
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
) => {
|
||||
const baseName = toDirectoryName(preferredName);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
export const queryFileWorkspacePermission = async (
|
||||
handle: FileSystemHandle,
|
||||
mode: 'read' | 'readwrite' = 'readwrite',
|
||||
) => {
|
||||
if (!handle.queryPermission) return 'granted' as PermissionState;
|
||||
return handle.queryPermission({ mode });
|
||||
};
|
||||
|
||||
export 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();
|
||||
}
|
||||
};
|
||||
|
||||
export 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),
|
||||
rawJson: raw,
|
||||
template: parsed,
|
||||
templateFile: file,
|
||||
templateFileHandle,
|
||||
};
|
||||
} 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 { handle: metadataFileHandle, metadata } = await readMetadata(directoryHandle, name);
|
||||
const { handle: thumbnailFileHandle, thumbnailDataUrl } = await readThumbnail(directoryHandle);
|
||||
|
||||
return {
|
||||
description: metadata.description,
|
||||
diskVersion: readResult.diskVersion,
|
||||
metadataFileHandle,
|
||||
name,
|
||||
order: metadata.order,
|
||||
path: `${name}/template.json`,
|
||||
rawJson: readResult.rawJson,
|
||||
sourceKind: metadata.sourceKind ?? inferSourceKind(name),
|
||||
tags: metadata.tags,
|
||||
template: readResult.template,
|
||||
templateDirectoryHandle: directoryHandle,
|
||||
templateFileHandle: readResult.templateFileHandle,
|
||||
thumbnailDataUrl,
|
||||
thumbnailFileHandle,
|
||||
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,
|
||||
scannedAt: Date.now(),
|
||||
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);
|
||||
|
||||
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.',
|
||||
tags: ['Blank', 'Starter'],
|
||||
},
|
||||
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,
|
||||
) => {
|
||||
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,
|
||||
});
|
||||
|
||||
await writeText(templateFileHandle, serializeTemplateForFileWorkspace(template));
|
||||
await writeText(
|
||||
metadataFileHandle,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
description: 'A template saved from the pdfme Playground.',
|
||||
tags: ['Designer'],
|
||||
title: title.trim() || titleFromDirectoryName(name),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const entry = await buildTemplateEntry(name, directoryHandle);
|
||||
await persistFileWorkspaceState(collection.rootHandle, name).catch(() => undefined);
|
||||
return entry;
|
||||
};
|
||||
|
||||
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,
|
||||
rawJson: readResult.rawJson,
|
||||
template: readResult.template,
|
||||
templateFileHandle,
|
||||
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, thumbnailFileHandle };
|
||||
};
|
||||
|
||||
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] ?? previousInputs[0] ?? {};
|
||||
const nextInput: TemplateInput = { ...defaultInput };
|
||||
|
||||
for (const name of Object.keys(nextInput)) {
|
||||
if (previousInput[name] != null) {
|
||||
nextInput[name] = previousInput[name];
|
||||
}
|
||||
}
|
||||
|
||||
return nextInput;
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,22 @@ import {
|
||||
type PlaygroundProject,
|
||||
} from '../lib/playgroundProjects';
|
||||
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
|
||||
import {
|
||||
FileWorkspaceTemplateDeletedError,
|
||||
FileWorkspaceTemplateInvalidError,
|
||||
createTemplateEntryFromTemplate,
|
||||
findTemplateEntry,
|
||||
readTemplateEntry,
|
||||
refreshTemplateCollection,
|
||||
restorePersistedTemplateCollection,
|
||||
serializeTemplateForFileWorkspace,
|
||||
setSelectedFileWorkspaceTemplateName,
|
||||
writeTemplateEntry,
|
||||
writeTemplateThumbnail,
|
||||
type FileWorkspaceCollection,
|
||||
type FileWorkspaceTemplateEntry,
|
||||
type FileWorkspaceTemplateRead,
|
||||
} from '../lib/fileWorkspace';
|
||||
|
||||
function destroyDesignerInstance(instance: Designer) {
|
||||
try {
|
||||
@@ -48,6 +64,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 +80,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 +88,7 @@ function getDesignerLoadRequest(): DesignerLoadRequest {
|
||||
shouldCreateNewProject,
|
||||
shouldConsumeQuery: shouldCreateNewProject || templateId != null || projectId != null,
|
||||
templateId,
|
||||
workspaceTemplateName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,11 +129,25 @@ function DesignerApp() {
|
||||
const designerRef = useRef<HTMLDivElement | null>(null);
|
||||
const designer = useRef<Designer | null>(null);
|
||||
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 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);
|
||||
@@ -115,10 +156,151 @@ function DesignerApp() {
|
||||
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;
|
||||
setIsFileWorkspaceDirty(
|
||||
cleanTemplate != null && serializeTemplateForFileWorkspace(template) !== cleanTemplate,
|
||||
);
|
||||
}, []);
|
||||
|
||||
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,
|
||||
rawJson: readResult.rawJson,
|
||||
template: readResult.template,
|
||||
templateFileHandle: readResult.templateFileHandle,
|
||||
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,
|
||||
);
|
||||
setSearchParams(new URLSearchParams([['workspace', targetEntry.name]]), {
|
||||
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,
|
||||
thumbnailFileHandle: thumbnail.thumbnailFileHandle,
|
||||
};
|
||||
} 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(`Saved ${refreshedEntry.path}`);
|
||||
} finally {
|
||||
isSavingFileWorkspaceRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentProject = projectRef.current;
|
||||
const nextTemplate = template || designer.current.getTemplate();
|
||||
const currentTitle =
|
||||
@@ -145,7 +327,7 @@ function DesignerApp() {
|
||||
setCurrentProjectTitle(savedProject.title);
|
||||
toast.success(<ProjectSavedToast title={savedProject.title} />);
|
||||
},
|
||||
[setCurrentProjectTitle],
|
||||
[setCurrentProjectTitle, setSearchParams],
|
||||
);
|
||||
|
||||
const buildDesigner = useCallback(
|
||||
@@ -161,21 +343,45 @@ 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) {
|
||||
setActiveFileWorkspaceEntry(null, null);
|
||||
const templateJson = await getTemplateById(templateIdFromQuery);
|
||||
checkTemplate(templateJson);
|
||||
template = templateJson;
|
||||
setCurrentProjectTitle(fromKebabCase(templateIdFromQuery));
|
||||
} else {
|
||||
setActiveFileWorkspaceEntry(null, null);
|
||||
project = getActivePlaygroundProject();
|
||||
if (project) {
|
||||
template = project.template;
|
||||
@@ -221,15 +427,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 +469,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 +504,36 @@ 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 = () => {
|
||||
const incoming = fileWorkspaceConflict?.incoming;
|
||||
if (incoming) {
|
||||
diskVersionRef.current = incoming.diskVersion;
|
||||
}
|
||||
setFileWorkspaceConflict(null);
|
||||
};
|
||||
|
||||
const onSaveOverConflict = async () => {
|
||||
if (!designer.current) return;
|
||||
|
||||
const template = fileWorkspaceConflict?.saveTemplate ?? designer.current.getTemplate();
|
||||
if (fileWorkspaceConflict?.incoming) {
|
||||
diskVersionRef.current = fileWorkspaceConflict.incoming.diskVersion;
|
||||
}
|
||||
setFileWorkspaceConflict(null);
|
||||
await onSaveTemplate(template);
|
||||
};
|
||||
|
||||
const toggleEditingStaticSchemas = () => {
|
||||
if (!designer.current) return;
|
||||
|
||||
@@ -349,6 +609,50 @@ function DesignerApp() {
|
||||
};
|
||||
}, [buildDesigner]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileWorkspaceEntry) return;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const currentEntry = fileWorkspaceEntryRef.current;
|
||||
if (!currentEntry || isSavingFileWorkspaceRef.current) return;
|
||||
|
||||
void readTemplateEntry(currentEntry)
|
||||
.then((readResult) => {
|
||||
if (readResult.diskVersion === diskVersionRef.current) {
|
||||
if (fileWorkspaceStatus) setFileWorkspaceStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFileWorkspaceDirty) {
|
||||
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`);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof FileWorkspaceTemplateDeletedError) {
|
||||
setFileWorkspaceStatus('deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof FileWorkspaceTemplateInvalidError) {
|
||||
setFileWorkspaceStatus('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [applyTemplateFromDisk, fileWorkspaceEntry, fileWorkspaceStatus, isFileWorkspaceDirty]);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Lang',
|
||||
@@ -398,7 +702,7 @@ function DesignerApp() {
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Project',
|
||||
label: fileWorkspaceEntry ? 'Workspace' : 'Project',
|
||||
content: (
|
||||
<div className="flex gap-1">
|
||||
<PlaygroundButton
|
||||
@@ -407,7 +711,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 +764,41 @@ 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 className="w-full max-w-lg rounded-lg bg-white p-5 shadow-xl">
|
||||
<h2 className="text-lg font-bold text-gray-900">Template changed on disk</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">{fileWorkspaceConflict.message}</p>
|
||||
<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,4 +1,4 @@
|
||||
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';
|
||||
@@ -24,6 +24,16 @@ import {
|
||||
type PlaygroundProject,
|
||||
} from '../lib/playgroundProjects';
|
||||
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
|
||||
import {
|
||||
FileWorkspaceTemplateDeletedError,
|
||||
FileWorkspaceTemplateInvalidError,
|
||||
findTemplateEntry,
|
||||
readTemplateEntry,
|
||||
restorePersistedTemplateCollection,
|
||||
setSelectedFileWorkspaceTemplateName,
|
||||
type FileWorkspaceTemplateEntry,
|
||||
} from '../lib/fileWorkspace';
|
||||
import { reconcileInputsWithTemplate } from '../lib/templateInputs';
|
||||
|
||||
type Mode = 'form' | 'viewer';
|
||||
|
||||
@@ -32,12 +42,20 @@ 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 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,27 +84,63 @@ 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) {
|
||||
fileWorkspaceEntryRef.current = null;
|
||||
diskVersionRef.current = null;
|
||||
setFileWorkspaceEntry(null);
|
||||
setFileWorkspaceStatus(null);
|
||||
const templateJson = await getTemplateById(templateIdFromQuery);
|
||||
checkTemplate(templateJson);
|
||||
template = templateJson;
|
||||
setProjectTitle(fromKebabCase(templateIdFromQuery));
|
||||
} else {
|
||||
fileWorkspaceEntryRef.current = null;
|
||||
diskVersionRef.current = null;
|
||||
setFileWorkspaceEntry(null);
|
||||
setFileWorkspaceStatus(null);
|
||||
project = getActivePlaygroundProject();
|
||||
if (project) {
|
||||
template = project.template;
|
||||
@@ -126,6 +180,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;
|
||||
@@ -208,6 +266,61 @@ function FormAndViewerApp() {
|
||||
};
|
||||
}, [mode, uiRef, buildUi, destroyCurrentUi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileWorkspaceEntry) return;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const currentEntry = fileWorkspaceEntryRef.current;
|
||||
if (!currentEntry || !ui.current) return;
|
||||
|
||||
void readTemplateEntry(currentEntry)
|
||||
.then((readResult) => {
|
||||
if (readResult.diskVersion === diskVersionRef.current) {
|
||||
if (fileWorkspaceStatus) 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,
|
||||
rawJson: readResult.rawJson,
|
||||
template: readResult.template,
|
||||
templateFileHandle: readResult.templateFileHandle,
|
||||
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`);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof FileWorkspaceTemplateDeletedError) {
|
||||
setFileWorkspaceStatus('deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof FileWorkspaceTemplateInvalidError) {
|
||||
setFileWorkspaceStatus('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [fileWorkspaceEntry, fileWorkspaceStatus]);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Lang',
|
||||
@@ -280,6 +393,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,9 +7,12 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
FolderX,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
PencilRuler,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
@@ -30,6 +33,20 @@ import {
|
||||
type PlaygroundProject,
|
||||
} from '../lib/playgroundProjects';
|
||||
import { createTemplateThumbnailDataUrl } from '../lib/templateThumbnails';
|
||||
import {
|
||||
clearPersistedFileWorkspace,
|
||||
createBlankTemplateEntry,
|
||||
findTemplateEntry,
|
||||
isFileWorkspaceSupported,
|
||||
openTemplateCollectionDirectory,
|
||||
persistFileWorkspaceState,
|
||||
refreshTemplateCollection,
|
||||
restorePersistedTemplateCollection,
|
||||
setSelectedFileWorkspaceTemplateName,
|
||||
writeTemplateThumbnail,
|
||||
type FileWorkspaceCollection,
|
||||
type FileWorkspaceTemplateEntry,
|
||||
} from '../lib/fileWorkspace';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -174,6 +191,49 @@ const ProjectThumbnailImage = ({
|
||||
return <ThumbnailImage alt={project.title} src={src} />;
|
||||
};
|
||||
|
||||
const MountedThumbnailImage = ({
|
||||
entry,
|
||||
onCreated,
|
||||
}: {
|
||||
entry: FileWorkspaceTemplateEntry;
|
||||
onCreated: () => void;
|
||||
}) => {
|
||||
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 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]);
|
||||
|
||||
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,
|
||||
@@ -372,14 +432,34 @@ const AuthorLink = ({ author }: { author: string }) => {
|
||||
function TemplatesApp() {
|
||||
const navigate = useNavigate();
|
||||
const importTemplateInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
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 [lastFolderName, setLastFolderName] = useState<string | null>(null);
|
||||
const [isOpeningFolder, setIsOpeningFolder] = useState(false);
|
||||
const [isRefreshingFolder, setIsRefreshingFolder] = useState(false);
|
||||
const [generationFilter, setGenerationFilter] = useState<GenerationFilter>('all');
|
||||
const [tagFilter, setTagFilter] = useState('all');
|
||||
|
||||
const refreshProjects = useCallback(() => setProjects(readPlaygroundProjects()), []);
|
||||
const refreshMountedCollection = useCallback(() => {
|
||||
if (!mountedCollection) return;
|
||||
|
||||
setIsRefreshingFolder(true);
|
||||
void refreshTemplateCollection(mountedCollection)
|
||||
.then((collection) => {
|
||||
setMountedCollection(collection);
|
||||
setLastFolderName(collection.rootName);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to refresh folder');
|
||||
})
|
||||
.finally(() => setIsRefreshingFolder(false));
|
||||
}, [mountedCollection]);
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
@@ -421,6 +501,45 @@ 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(() => {
|
||||
if (!mountedCollection) return;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshTemplateCollection(mountedCollection)
|
||||
.then((collection) => {
|
||||
setMountedCollection(collection);
|
||||
setLastFolderName(collection.rootName);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [mountedCollection]);
|
||||
|
||||
// Fetch templates and author avatars
|
||||
useEffect(() => {
|
||||
fetch('/template-assets/index.json')
|
||||
@@ -476,11 +595,89 @@ 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 entry = await createBlankTemplateEntry(collection.rootHandle);
|
||||
const nextCollection = await refreshTemplateCollection({
|
||||
...collection,
|
||||
selectedTemplateName: entry.name,
|
||||
});
|
||||
const nextEntry = findTemplateEntry(nextCollection, 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 onImportTemplateJson = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
@@ -564,6 +761,14 @@ function TemplatesApp() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<PlaygroundButton
|
||||
disabled={!fileWorkspaceSupported || isOpeningFolder}
|
||||
onClick={() => void onOpenFolder()}
|
||||
variant="secondary"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
{isOpeningFolder ? 'Opening...' : 'Open Folder'}
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
onClick={() => importTemplateInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
@@ -640,6 +845,131 @@ function TemplatesApp() {
|
||||
No local projects yet. Start from a sample, JSX, md2pdf, or a blank Designer template.
|
||||
</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">
|
||||
Edit a template-assets style folder directly on disk.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{mountedCollection && (
|
||||
<PlaygroundButton
|
||||
disabled={isRefreshingFolder}
|
||||
onClick={() => void refreshMountedCollection()}
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</PlaygroundButton>
|
||||
)}
|
||||
{!mountedCollection && lastFolderName && (
|
||||
<PlaygroundButton
|
||||
disabled={!fileWorkspaceSupported || isOpeningFolder}
|
||||
onClick={() => void onReopenFolder()}
|
||||
variant="secondary"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
Reopen last folder
|
||||
</PlaygroundButton>
|
||||
)}
|
||||
{mountedCollection && (
|
||||
<PlaygroundButton onClick={() => void onDisconnectFolder()} variant="secondary">
|
||||
<FolderX className="size-4" />
|
||||
Disconnect
|
||||
</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. Template JSON import
|
||||
and 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="Mounted"
|
||||
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}
|
||||
/>
|
||||
}
|
||||
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',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
Form/Viewer
|
||||
</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.
|
||||
</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">
|
||||
|
||||
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