diff --git a/playground/e2e/fileWorkspace.test.ts b/playground/e2e/fileWorkspace.test.ts new file mode 100644 index 00000000..8c04ca11 --- /dev/null +++ b/playground/e2e/fileWorkspace.test.ts @@ -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 = []; + 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(); + + 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": ['); + }); +}); diff --git a/playground/e2e/templateInputs.test.ts b/playground/e2e/templateInputs.test.ts new file mode 100644 index 00000000..a9ee132f --- /dev/null +++ b/playground/e2e/templateInputs.test.ts @@ -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' }]); + }); +}); diff --git a/playground/public/template-assets/a4-blank/metadata.json b/playground/public/template-assets/a4-blank/metadata.json new file mode 100644 index 00000000..6733f0bc --- /dev/null +++ b/playground/public/template-assets/a4-blank/metadata.json @@ -0,0 +1,8 @@ +{ + "order": 130, + "description": "A clean blank A4 document for starting from scratch in Designer.", + "tags": [ + "Blank", + "Starter" + ] +} diff --git a/playground/public/template-assets/address-label-10/metadata.json b/playground/public/template-assets/address-label-10/metadata.json new file mode 100644 index 00000000..ddd84cb2 --- /dev/null +++ b/playground/public/template-assets/address-label-10/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A 10-label address sheet for shipping and mailing workflows.", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/address-label-30/metadata.json b/playground/public/template-assets/address-label-30/metadata.json new file mode 100644 index 00000000..ed472d27 --- /dev/null +++ b/playground/public/template-assets/address-label-30/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A compact 30-label address sheet for dense mailing labels.", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/address-label-6/metadata.json b/playground/public/template-assets/address-label-6/metadata.json new file mode 100644 index 00000000..717c114b --- /dev/null +++ b/playground/public/template-assets/address-label-6/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A larger 6-label address sheet with room for longer addresses.", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/certificate-black/metadata.json b/playground/public/template-assets/certificate-black/metadata.json new file mode 100644 index 00000000..735ee049 --- /dev/null +++ b/playground/public/template-assets/certificate-black/metadata.json @@ -0,0 +1,8 @@ +{ + "order": 120, + "description": "A high-contrast certificate layout with a formal dark theme.", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/certificate-blue/metadata.json b/playground/public/template-assets/certificate-blue/metadata.json new file mode 100644 index 00000000..3790fe7f --- /dev/null +++ b/playground/public/template-assets/certificate-blue/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A polished blue certificate layout for awards and completion documents.", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/certificate-gold/metadata.json b/playground/public/template-assets/certificate-gold/metadata.json new file mode 100644 index 00000000..a94d7491 --- /dev/null +++ b/playground/public/template-assets/certificate-gold/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A warm gold certificate layout with a classic presentation style.", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/certificate-white/metadata.json b/playground/public/template-assets/certificate-white/metadata.json new file mode 100644 index 00000000..87869ab2 --- /dev/null +++ b/playground/public/template-assets/certificate-white/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A minimal certificate layout that works well with light branding.", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json b/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json new file mode 100644 index 00000000..cc6a2fb4 --- /dev/null +++ b/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A Japanese social insurance form template for structured government-style documents.", + "tags": [ + "Government", + "CJK", + "Form" + ] +} diff --git a/playground/public/template-assets/inline-markdown-mvt/metadata.json b/playground/public/template-assets/inline-markdown-mvt/metadata.json new file mode 100644 index 00000000..aebb82fe --- /dev/null +++ b/playground/public/template-assets/inline-markdown-mvt/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A focused demo of inline markdown and MultiVariableText editing.", + "tags": [ + "Markdown", + "MVT", + "Form" + ] +} diff --git a/playground/public/template-assets/invoice-blue/metadata.json b/playground/public/template-assets/invoice-blue/metadata.json new file mode 100644 index 00000000..38a53d2c --- /dev/null +++ b/playground/public/template-assets/invoice-blue/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A blue invoice variant for a more branded business document.", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice-green/metadata.json b/playground/public/template-assets/invoice-green/metadata.json new file mode 100644 index 00000000..de8c8b12 --- /dev/null +++ b/playground/public/template-assets/invoice-green/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A green invoice variant with a calm accounting-oriented look.", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json b/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json new file mode 100644 index 00000000..e22c917a --- /dev/null +++ b/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A landscape Japanese invoice layout for wider table content.", + "tags": [ + "Invoice", + "Business", + "CJK" + ] +} diff --git a/playground/public/template-assets/invoice-ja-simple/metadata.json b/playground/public/template-assets/invoice-ja-simple/metadata.json new file mode 100644 index 00000000..5d5359d2 --- /dev/null +++ b/playground/public/template-assets/invoice-ja-simple/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A simple Japanese invoice layout with CJK font usage.", + "tags": [ + "Invoice", + "Business", + "CJK" + ] +} diff --git a/playground/public/template-assets/invoice-white/metadata.json b/playground/public/template-assets/invoice-white/metadata.json new file mode 100644 index 00000000..6a4914c9 --- /dev/null +++ b/playground/public/template-assets/invoice-white/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A restrained white invoice layout with a clean printable style.", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice/metadata.json b/playground/public/template-assets/invoice/metadata.json new file mode 100644 index 00000000..fe786efc --- /dev/null +++ b/playground/public/template-assets/invoice/metadata.json @@ -0,0 +1,10 @@ +{ + "order": 90, + "description": "A practical invoice with customer details, line items, totals, and payment notes.", + "tags": [ + "Invoice", + "Business", + "Table", + "Visual" + ] +} diff --git a/playground/public/template-assets/location-arrow/metadata.json b/playground/public/template-assets/location-arrow/metadata.json new file mode 100644 index 00000000..c641fd65 --- /dev/null +++ b/playground/public/template-assets/location-arrow/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A location marker template that highlights points with arrow indicators.", + "tags": [ + "Map", + "Visual" + ] +} diff --git a/playground/public/template-assets/location-number/metadata.json b/playground/public/template-assets/location-number/metadata.json new file mode 100644 index 00000000..83a8b943 --- /dev/null +++ b/playground/public/template-assets/location-number/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A location marker template that labels points with numbered badges.", + "tags": [ + "Map", + "Visual" + ] +} diff --git a/playground/public/template-assets/new-sale-quotation/metadata.json b/playground/public/template-assets/new-sale-quotation/metadata.json new file mode 100644 index 00000000..f8978d2a --- /dev/null +++ b/playground/public/template-assets/new-sale-quotation/metadata.json @@ -0,0 +1,8 @@ +{ + "description": "A sales quotation template with product rows and business summary fields.", + "tags": [ + "Quote", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/pedigree/metadata.json b/playground/public/template-assets/pedigree/metadata.json new file mode 100644 index 00000000..98d69f62 --- /dev/null +++ b/playground/public/template-assets/pedigree/metadata.json @@ -0,0 +1,10 @@ +{ + "order": 110, + "description": "A pedigree-style relationship chart for structured family or lineage data.", + "tags": [ + "Chart", + "QR", + "Image", + "Visual" + ] +} diff --git a/playground/public/template-assets/qr-lines/metadata.json b/playground/public/template-assets/qr-lines/metadata.json new file mode 100644 index 00000000..6390ab51 --- /dev/null +++ b/playground/public/template-assets/qr-lines/metadata.json @@ -0,0 +1,8 @@ +{ + "order": 140, + "description": "A QR code template with line-based metadata and compact supporting text.", + "tags": [ + "QR", + "Label" + ] +} diff --git a/playground/public/template-assets/qr-title/metadata.json b/playground/public/template-assets/qr-title/metadata.json new file mode 100644 index 00000000..5fde2616 --- /dev/null +++ b/playground/public/template-assets/qr-title/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A QR code template with a prominent title and simple scan instructions.", + "tags": [ + "QR", + "Label" + ] +} diff --git a/playground/public/template-assets/quotes/metadata.json b/playground/public/template-assets/quotes/metadata.json new file mode 100644 index 00000000..481b3e07 --- /dev/null +++ b/playground/public/template-assets/quotes/metadata.json @@ -0,0 +1,10 @@ +{ + "order": 100, + "description": "A quote document layout for proposals, estimates, and short commercial offers.", + "tags": [ + "Quote", + "Business", + "Table", + "Visual" + ] +} diff --git a/playground/public/template-assets/z-fold-brochure/metadata.json b/playground/public/template-assets/z-fold-brochure/metadata.json new file mode 100644 index 00000000..edf9c406 --- /dev/null +++ b/playground/public/template-assets/z-fold-brochure/metadata.json @@ -0,0 +1,7 @@ +{ + "description": "A z-fold brochure layout for tri-fold print and promotional material.", + "tags": [ + "Brochure", + "Print" + ] +} diff --git a/playground/src/lib/fileWorkspace.ts b/playground/src/lib/fileWorkspace.ts new file mode 100644 index 00000000..ae570380 --- /dev/null +++ b/playground/src/lib/fileWorkspace.ts @@ -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 => + 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((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((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 = (request: IDBRequest) => + new Promise((resolve, reject) => { + request.addEventListener('success', () => resolve(request.result)); + request.addEventListener('error', () => reject(request.error)); + }); + +const idbGet = async (key: string) => { + const db = await openWorkspaceDb(); + try { + const transaction = db.transaction(STORE_NAME, 'readonly'); + return await idbRequest(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(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, +): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; diff --git a/playground/src/lib/templateInputs.ts b/playground/src/lib/templateInputs.ts new file mode 100644 index 00000000..3ac8f665 --- /dev/null +++ b/playground/src/lib/templateInputs.ts @@ -0,0 +1,24 @@ +import { getInputFromTemplate, type Template } from '@pdfme/common'; + +export type TemplateInput = Record; + +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; + }); +}; diff --git a/playground/src/routes/Designer.tsx b/playground/src/routes/Designer.tsx index d5fc9d78..c0f05020 100644 --- a/playground/src/routes/Designer.tsx +++ b/playground/src/routes/Designer.tsx @@ -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(null); const designer = useRef(null); const projectRef = useRef(null); + const fileWorkspaceCollectionRef = useRef(null); + const fileWorkspaceEntryRef = useRef(null); + const diskVersionRef = useRef(null); + const lastCleanSerializedTemplateRef = useRef(null); + const isApplyingTemplateRef = useRef(false); + const isSavingFileWorkspaceRef = useRef(false); const projectTitleRef = useRef('Untitled Template'); const loadRequestRef = useRef(null); const didCleanLoadQueryRef = useRef(false); const [editingStaticSchemas, setEditingStaticSchemas] = useState(false); + const [fileWorkspaceEntry, setFileWorkspaceEntry] = useState( + null, + ); + const [fileWorkspaceStatus, setFileWorkspaceStatus] = useState(null); + const [fileWorkspaceConflict, setFileWorkspaceConflict] = useState( + null, + ); + const [isFileWorkspaceDirty, setIsFileWorkspaceDirty] = useState(false); const [originalTemplate, setOriginalTemplate] = useState