feat(playground): add mounted file workspace

This commit is contained in:
hand-dot
2026-05-15 15:50:44 +09:00
parent 8af8c1bdb2
commit 7b28679dfd
32 changed files with 1859 additions and 9 deletions

View 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": [');
});
});

View 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' }]);
});
});

View File

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

View File

@@ -0,0 +1,7 @@
{
"description": "A 10-label address sheet for shipping and mailing workflows.",
"tags": [
"Labels",
"Shipping"
]
}

View File

@@ -0,0 +1,7 @@
{
"description": "A compact 30-label address sheet for dense mailing labels.",
"tags": [
"Labels",
"Shipping"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
{
"description": "A Japanese social insurance form template for structured government-style documents.",
"tags": [
"Government",
"CJK",
"Form"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
]
}

View File

@@ -0,0 +1,7 @@
{
"description": "A location marker template that highlights points with arrow indicators.",
"tags": [
"Map",
"Visual"
]
}

View File

@@ -0,0 +1,7 @@
{
"description": "A location marker template that labels points with numbered badges.",
"tags": [
"Map",
"Visual"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

@@ -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}

View File

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

View File

@@ -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">

View File

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