mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
1589 lines
52 KiB
TypeScript
1589 lines
52 KiB
TypeScript
import { showAlert } from '@/js/ui.js';
|
||
import { createWorkflowEditor, updateNodeDisplay } from '@/js/workflow/editor';
|
||
import type { WorkflowEditor } from '@/js/workflow/editor';
|
||
import { executeWorkflow } from '@/js/workflow/engine';
|
||
import { translateCategory, translateNodeLabel } from '@/js/workflow/i18n';
|
||
import type { NodeCategory } from '@/js/workflow/types';
|
||
import {
|
||
type BaseWorkflowNode,
|
||
nodeRegistry,
|
||
getNodesByCategory,
|
||
createNodeByType,
|
||
PDFInputNode,
|
||
EncryptedPDFError,
|
||
ImageInputNode,
|
||
WordToPdfNode,
|
||
ExcelToPdfNode,
|
||
PowerPointToPdfNode,
|
||
TextToPdfNode,
|
||
SvgToPdfNode,
|
||
EpubToPdfNode,
|
||
EmailToPdfNode,
|
||
DigitalSignNode,
|
||
XpsToPdfNode,
|
||
MobiToPdfNode,
|
||
Fb2ToPdfNode,
|
||
CbzToPdfNode,
|
||
MarkdownToPdfNode,
|
||
JsonToPdfNode,
|
||
XmlToPdfNode,
|
||
WpdToPdfNode,
|
||
WpsToPdfNode,
|
||
PagesToPdfNode,
|
||
OdgToPdfNode,
|
||
PubToPdfNode,
|
||
VsdToPdfNode,
|
||
} from '@/js/workflow/nodes';
|
||
import {
|
||
saveWorkflow,
|
||
loadWorkflow,
|
||
exportWorkflow,
|
||
importWorkflow,
|
||
getSavedTemplateNames,
|
||
templateNameExists,
|
||
deleteTemplate,
|
||
} from '@/js/workflow/serialization';
|
||
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
|
||
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
|
||
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
|
||
|
||
let workflowEditor: WorkflowEditor | null = null;
|
||
let selectedNodeId: string | null = null;
|
||
let deleteNodeHandler: EventListener | null = null;
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initializePage);
|
||
} else {
|
||
initializePage();
|
||
}
|
||
|
||
async function initializePage() {
|
||
const container = document.getElementById('rete-container');
|
||
if (!container) return;
|
||
|
||
workflowEditor = await createWorkflowEditor(container);
|
||
const { editor, area, engine } = workflowEditor;
|
||
|
||
buildToolbox();
|
||
|
||
editor.addPipe((context) => {
|
||
if (context.type === 'nodecreated' || context.type === 'noderemoved') {
|
||
updateNodeCount();
|
||
}
|
||
if (
|
||
context.type === 'connectioncreated' ||
|
||
context.type === 'connectionremoved'
|
||
) {
|
||
const conn = context.data;
|
||
updateNodeDisplay(conn.source, editor, area);
|
||
updateNodeDisplay(conn.target, editor, area);
|
||
}
|
||
return context;
|
||
});
|
||
|
||
document.getElementById('run-btn')?.addEventListener('click', async () => {
|
||
const allNodes = editor.getNodes() as BaseWorkflowNode[];
|
||
if (allNodes.length === 0) {
|
||
showAlert('Error', 'Add at least one node to run the workflow.');
|
||
return;
|
||
}
|
||
const hasInput = allNodes.some((n) => n.category === 'Input');
|
||
const hasOutput = allNodes.some((n) => n.category === 'Output');
|
||
if (!hasInput || !hasOutput) {
|
||
showAlert(
|
||
'Error',
|
||
'Your workflow needs at least one input node and one output node to run.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
const statusText = document.getElementById('status-text');
|
||
const runBtn = document.getElementById('run-btn') as HTMLButtonElement;
|
||
runBtn.disabled = true;
|
||
runBtn.classList.add('opacity-50', 'pointer-events-none');
|
||
|
||
try {
|
||
await executeWorkflow(editor, engine, area, (progress) => {
|
||
const msg = progress.message || `Processing ${progress.nodeName}...`;
|
||
if (statusText) statusText.textContent = msg;
|
||
});
|
||
if (statusText) statusText.textContent = 'Workflow completed';
|
||
} catch (err) {
|
||
if (statusText) statusText.textContent = 'Error during execution';
|
||
showAlert('Error', (err as Error).message);
|
||
} finally {
|
||
runBtn.disabled = false;
|
||
runBtn.classList.remove('opacity-50', 'pointer-events-none');
|
||
}
|
||
});
|
||
|
||
document.getElementById('clear-btn')?.addEventListener('click', async () => {
|
||
await editor.clear();
|
||
updateNodeCount();
|
||
const statusText = document.getElementById('status-text');
|
||
if (statusText) statusText.textContent = 'Ready';
|
||
document.getElementById('settings-sidebar')?.classList.add('hidden');
|
||
});
|
||
|
||
document.getElementById('close-settings')?.addEventListener('click', () => {
|
||
document.getElementById('settings-sidebar')?.classList.add('hidden');
|
||
});
|
||
|
||
document.getElementById('save-btn')?.addEventListener('click', () => {
|
||
showSaveTemplateModal(editor, area);
|
||
});
|
||
|
||
document.getElementById('load-btn')?.addEventListener('click', () => {
|
||
showLoadTemplateModal(editor, area);
|
||
});
|
||
|
||
document.getElementById('export-btn')?.addEventListener('click', () => {
|
||
exportWorkflow(editor, area);
|
||
});
|
||
|
||
document.getElementById('import-btn')?.addEventListener('click', async () => {
|
||
await importWorkflow(editor, area);
|
||
updateNodeCount();
|
||
});
|
||
|
||
// Mobile toolbox sidebar toggle
|
||
const toolboxSidebar = document.getElementById('toolbox-sidebar');
|
||
const toolboxBackdrop = document.getElementById('toolbox-backdrop');
|
||
|
||
function closeToolbox() {
|
||
toolboxSidebar?.classList.add('hidden');
|
||
toolboxSidebar?.classList.remove('flex');
|
||
toolboxBackdrop?.classList.add('hidden');
|
||
}
|
||
|
||
function openToolbox() {
|
||
toolboxSidebar?.classList.remove('hidden');
|
||
toolboxSidebar?.classList.add('flex');
|
||
toolboxBackdrop?.classList.remove('hidden');
|
||
}
|
||
|
||
document.getElementById('toolbox-toggle')?.addEventListener('click', () => {
|
||
if (toolboxSidebar?.classList.contains('hidden')) {
|
||
openToolbox();
|
||
} else {
|
||
closeToolbox();
|
||
}
|
||
});
|
||
|
||
toolboxBackdrop?.addEventListener('click', closeToolbox);
|
||
|
||
document.getElementById('node-search')?.addEventListener('input', (e) => {
|
||
const query = (e.target as HTMLInputElement).value.toLowerCase();
|
||
const items = document.querySelectorAll<HTMLElement>('.toolbox-node-item');
|
||
const categories =
|
||
document.querySelectorAll<HTMLElement>('.toolbox-category');
|
||
|
||
items.forEach((item) => {
|
||
const label = item.dataset.label?.toLowerCase() ?? '';
|
||
item.style.display = label.includes(query) ? '' : 'none';
|
||
});
|
||
|
||
categories.forEach((cat) => {
|
||
const itemsContainer = cat.querySelector<HTMLElement>('.toolbox-items');
|
||
if (itemsContainer) itemsContainer.style.display = '';
|
||
const visibleItems = cat.querySelectorAll<HTMLElement>(
|
||
'.toolbox-node-item:not([style*="display: none"])'
|
||
);
|
||
cat.style.display = visibleItems.length > 0 ? '' : 'none';
|
||
});
|
||
});
|
||
|
||
let justPicked = false;
|
||
let dragDistance = 0;
|
||
let pickedNodeId: string | null = null;
|
||
|
||
area.addPipe((context) => {
|
||
if (context.type === 'nodepicked') {
|
||
const nodeId = context.data.id;
|
||
selectedNodeId = nodeId;
|
||
justPicked = true;
|
||
pickedNodeId = nodeId;
|
||
dragDistance = 0;
|
||
}
|
||
if (context.type === 'nodetranslated') {
|
||
const dx = context.data.position.x - context.data.previous.x;
|
||
const dy = context.data.position.y - context.data.previous.y;
|
||
dragDistance += Math.abs(dx) + Math.abs(dy);
|
||
}
|
||
if (context.type === 'nodedragged') {
|
||
if (pickedNodeId && dragDistance < 5) {
|
||
const node = editor.getNode(pickedNodeId) as BaseWorkflowNode;
|
||
if (node) {
|
||
showNodeSettings(node);
|
||
}
|
||
}
|
||
pickedNodeId = null;
|
||
}
|
||
if (context.type === 'translated') {
|
||
container.classList.add('is-panning');
|
||
}
|
||
return context;
|
||
});
|
||
|
||
container.addEventListener('mouseup', () =>
|
||
container.classList.remove('is-panning')
|
||
);
|
||
container.addEventListener('mouseleave', () =>
|
||
container.classList.remove('is-panning')
|
||
);
|
||
|
||
container.addEventListener('click', (e) => {
|
||
if (justPicked) {
|
||
justPicked = false;
|
||
return;
|
||
}
|
||
if ((e.target as HTMLElement).closest('[data-testid="node"]')) return;
|
||
selectedNodeId = null;
|
||
document.getElementById('settings-sidebar')?.classList.add('hidden');
|
||
});
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!selectedNodeId || !workflowEditor) return;
|
||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
const tag = (e.target as HTMLElement).tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
e.preventDefault();
|
||
deleteSelectedNode();
|
||
}
|
||
});
|
||
|
||
if (deleteNodeHandler) {
|
||
document.removeEventListener('wf-delete-node', deleteNodeHandler);
|
||
}
|
||
deleteNodeHandler = ((e: CustomEvent) => {
|
||
const nodeId = e.detail?.nodeId;
|
||
if (nodeId) deleteNodeById(nodeId);
|
||
}) as EventListener;
|
||
document.addEventListener('wf-delete-node', deleteNodeHandler);
|
||
}
|
||
|
||
async function deleteNodeById(nodeId: string) {
|
||
if (!workflowEditor) return;
|
||
const { editor } = workflowEditor;
|
||
|
||
const conns = editor
|
||
.getConnections()
|
||
.filter((c) => c.source === nodeId || c.target === nodeId);
|
||
for (const conn of conns) {
|
||
await editor.removeConnection(conn.id);
|
||
}
|
||
await editor.removeNode(nodeId);
|
||
|
||
if (selectedNodeId === nodeId) {
|
||
selectedNodeId = null;
|
||
document.getElementById('settings-sidebar')?.classList.add('hidden');
|
||
}
|
||
updateNodeCount();
|
||
}
|
||
|
||
async function deleteSelectedNode() {
|
||
if (!selectedNodeId) return;
|
||
await deleteNodeById(selectedNodeId);
|
||
}
|
||
|
||
function updateNodeCount() {
|
||
if (!workflowEditor) return;
|
||
const count = workflowEditor.editor.getNodes().length;
|
||
const el = document.getElementById('node-count');
|
||
if (el) el.textContent = `${count} node${count !== 1 ? 's' : ''}`;
|
||
}
|
||
|
||
function showSaveTemplateModal(
|
||
editor: WorkflowEditor['editor'],
|
||
area: WorkflowEditor['area']
|
||
) {
|
||
const modal = document.getElementById('save-template-modal')!;
|
||
const nameInput = document.getElementById(
|
||
'save-template-name'
|
||
) as HTMLInputElement;
|
||
const errorEl = document.getElementById('save-template-error')!;
|
||
const confirmBtn = document.getElementById('save-template-confirm')!;
|
||
const cancelBtn = document.getElementById('save-template-cancel')!;
|
||
|
||
nameInput.value = '';
|
||
errorEl.classList.add('hidden');
|
||
modal.classList.remove('hidden');
|
||
nameInput.focus();
|
||
|
||
const cleanup = () => {
|
||
modal.classList.add('hidden');
|
||
confirmBtn.replaceWith(confirmBtn.cloneNode(true));
|
||
cancelBtn.replaceWith(cancelBtn.cloneNode(true));
|
||
nameInput.removeEventListener('keydown', onKeydown);
|
||
};
|
||
|
||
const doSave = () => {
|
||
const name = nameInput.value.trim();
|
||
if (!name) {
|
||
errorEl.textContent = 'Please enter a name.';
|
||
errorEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
if (templateNameExists(name)) {
|
||
errorEl.textContent = 'A template with this name already exists.';
|
||
errorEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
saveWorkflow(editor, area, name);
|
||
cleanup();
|
||
showAlert('Saved', `Template "${name}" saved.`, 'success');
|
||
};
|
||
|
||
const onKeydown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Enter') doSave();
|
||
if (e.key === 'Escape') cleanup();
|
||
};
|
||
|
||
nameInput.addEventListener('keydown', onKeydown);
|
||
document
|
||
.getElementById('save-template-confirm')!
|
||
.addEventListener('click', doSave);
|
||
document
|
||
.getElementById('save-template-cancel')!
|
||
.addEventListener('click', cleanup);
|
||
}
|
||
|
||
function showLoadTemplateModal(
|
||
editor: WorkflowEditor['editor'],
|
||
area: WorkflowEditor['area']
|
||
) {
|
||
const modal = document.getElementById('load-template-modal')!;
|
||
const listEl = document.getElementById('load-template-list')!;
|
||
const emptyEl = document.getElementById('load-template-empty')!;
|
||
const cancelBtn = document.getElementById('load-template-cancel')!;
|
||
|
||
const names = getSavedTemplateNames();
|
||
listEl.innerHTML = '';
|
||
|
||
if (names.length === 0) {
|
||
emptyEl.classList.remove('hidden');
|
||
} else {
|
||
emptyEl.classList.add('hidden');
|
||
for (const name of names) {
|
||
const row = document.createElement('div');
|
||
row.className =
|
||
'group flex items-center gap-2 bg-gray-900/60 hover:bg-gray-700/50 rounded-lg px-3 py-2.5 border border-gray-700/50 transition-colors cursor-pointer';
|
||
|
||
const icon = document.createElement('i');
|
||
icon.className = 'ph ph-file-text text-base text-gray-500 flex-shrink-0';
|
||
row.appendChild(icon);
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'text-gray-200 text-sm truncate flex-1';
|
||
label.textContent = name;
|
||
row.appendChild(label);
|
||
|
||
const loadBtn = document.createElement('button');
|
||
loadBtn.className =
|
||
'bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium px-3 py-1.5 rounded-md transition-colors flex-shrink-0';
|
||
loadBtn.textContent = 'Load';
|
||
loadBtn.addEventListener('click', async () => {
|
||
const loaded = await loadWorkflow(editor, area, name);
|
||
cleanup();
|
||
if (loaded) {
|
||
updateNodeCount();
|
||
showAlert('Loaded', `Template "${name}" loaded.`, 'success');
|
||
} else {
|
||
showAlert('Error', 'Failed to load template.');
|
||
}
|
||
});
|
||
row.appendChild(loadBtn);
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.className =
|
||
'text-gray-600 hover:text-red-400 transition-colors flex-shrink-0';
|
||
delBtn.innerHTML = '<i class="ph ph-trash text-sm"></i>';
|
||
delBtn.addEventListener('click', () => {
|
||
deleteTemplate(name);
|
||
row.remove();
|
||
const remaining = getSavedTemplateNames();
|
||
if (remaining.length === 0) emptyEl.classList.remove('hidden');
|
||
});
|
||
row.appendChild(delBtn);
|
||
|
||
listEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
|
||
const cleanup = () => {
|
||
modal.classList.add('hidden');
|
||
cancelBtn.replaceWith(cancelBtn.cloneNode(true));
|
||
};
|
||
|
||
document
|
||
.getElementById('load-template-cancel')!
|
||
.addEventListener('click', cleanup);
|
||
}
|
||
|
||
function buildToolbox() {
|
||
const container = document.getElementById('toolbox-categories');
|
||
if (!container) return;
|
||
|
||
const categorized = getNodesByCategory();
|
||
const categoryOrder: Array<{
|
||
key: NodeCategory;
|
||
color: string;
|
||
}> = [
|
||
{ key: 'Input', color: 'text-blue-400' },
|
||
{ key: 'Edit & Annotate', color: 'text-indigo-300' },
|
||
{ key: 'Organize & Manage', color: 'text-violet-300' },
|
||
{ key: 'Optimize & Repair', color: 'text-amber-300' },
|
||
{ key: 'Secure PDF', color: 'text-rose-300' },
|
||
{ key: 'Output', color: 'text-teal-300' },
|
||
];
|
||
|
||
for (const cat of categoryOrder) {
|
||
const entries = (categorized[cat.key] ?? []).filter(
|
||
(entry) => !entry.toolPageId || !isToolDisabled(entry.toolPageId)
|
||
);
|
||
if (entries.length === 0) continue;
|
||
|
||
const section = document.createElement('div');
|
||
section.className = 'toolbox-category';
|
||
|
||
const header = document.createElement('button');
|
||
header.className = `w-full flex items-center justify-between text-xs font-bold uppercase tracking-wider ${cat.color} mb-1.5 px-1 hover:opacity-80 transition-opacity`;
|
||
header.type = 'button';
|
||
|
||
const headerLabel = document.createElement('span');
|
||
headerLabel.textContent = translateCategory(cat.key);
|
||
header.appendChild(headerLabel);
|
||
|
||
const chevronWrap = document.createElement('span');
|
||
chevronWrap.className = 'flex-shrink-0';
|
||
chevronWrap.innerHTML = '<i class="ph ph-caret-down text-xs"></i>';
|
||
header.appendChild(chevronWrap);
|
||
|
||
const itemsContainer = document.createElement('div');
|
||
itemsContainer.className = 'toolbox-items';
|
||
|
||
header.addEventListener('click', () => {
|
||
const collapsed = itemsContainer.style.display === 'none';
|
||
itemsContainer.style.display = collapsed ? '' : 'none';
|
||
const iconName = collapsed ? 'ph-caret-down' : 'ph-caret-up';
|
||
chevronWrap.innerHTML = `<i class="ph ${iconName} text-xs"></i>`;
|
||
});
|
||
|
||
section.appendChild(header);
|
||
|
||
for (const entry of entries) {
|
||
const item = document.createElement('button');
|
||
item.className =
|
||
'toolbox-node-item w-full text-left px-2 py-1.5 rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors text-xs flex items-center gap-2';
|
||
const nodeType = Object.keys(nodeRegistry).find(
|
||
(k) => nodeRegistry[k] === entry
|
||
)!;
|
||
const translatedLabel = translateNodeLabel(nodeType, entry);
|
||
item.dataset.label = translatedLabel;
|
||
item.dataset.type = nodeType;
|
||
|
||
const iconEl = document.createElement('i');
|
||
iconEl.className = `ph ${entry.icon} text-sm flex-shrink-0`;
|
||
item.appendChild(iconEl);
|
||
|
||
const labelEl = document.createElement('span');
|
||
labelEl.textContent = translatedLabel;
|
||
item.appendChild(labelEl);
|
||
|
||
item.addEventListener('click', () => {
|
||
addNodeToCanvas(item.dataset.type!);
|
||
if (window.innerWidth < 768) {
|
||
document.getElementById('toolbox-sidebar')?.classList.add('hidden');
|
||
document.getElementById('toolbox-sidebar')?.classList.remove('flex');
|
||
document.getElementById('toolbox-backdrop')?.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
item.draggable = true;
|
||
item.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer?.setData(
|
||
'application/rete-node-type',
|
||
item.dataset.type!
|
||
);
|
||
e.dataTransfer!.effectAllowed = 'copy';
|
||
});
|
||
|
||
itemsContainer.appendChild(item);
|
||
}
|
||
|
||
section.appendChild(itemsContainer);
|
||
container.appendChild(section);
|
||
}
|
||
|
||
const reteContainer = document.getElementById('rete-container');
|
||
if (reteContainer) {
|
||
reteContainer.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer!.dropEffect = 'copy';
|
||
});
|
||
reteContainer.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
const nodeType = e.dataTransfer?.getData('application/rete-node-type');
|
||
if (!nodeType || !workflowEditor) return;
|
||
|
||
const { area } = workflowEditor;
|
||
const rect = reteContainer.getBoundingClientRect();
|
||
const { x: tx, y: ty, k } = area.area.transform;
|
||
const x = (e.clientX - rect.left - tx) / k;
|
||
const y = (e.clientY - rect.top - ty) / k;
|
||
addNodeToCanvas(nodeType, { x, y });
|
||
});
|
||
}
|
||
}
|
||
|
||
async function addNodeToCanvas(
|
||
type: string,
|
||
position?: { x: number; y: number }
|
||
) {
|
||
if (!workflowEditor) return;
|
||
const { editor, area } = workflowEditor;
|
||
|
||
try {
|
||
const node = createNodeByType(type);
|
||
if (!node) {
|
||
console.error(
|
||
'Node type not found in registry:',
|
||
String(type).replace(/[\r\n]+/g, ' ')
|
||
);
|
||
return;
|
||
}
|
||
await editor.addNode(node);
|
||
|
||
const pos = position || getCanvasCenter(area);
|
||
await area.translate(node.id, pos);
|
||
} catch (err) {
|
||
console.error('Failed to add node to canvas:', err);
|
||
}
|
||
}
|
||
|
||
function getCanvasCenter(area: WorkflowEditor['area']): {
|
||
x: number;
|
||
y: number;
|
||
} {
|
||
const container = area.container;
|
||
const rect = container.getBoundingClientRect();
|
||
const { x: tx, y: ty, k } = area.area.transform;
|
||
const cx = (rect.width / 2 - tx) / k;
|
||
const cy = (rect.height / 2 - ty) / k;
|
||
return {
|
||
x: cx + (Math.random() - 0.5) * 100,
|
||
y: cy + (Math.random() - 0.5) * 100,
|
||
};
|
||
}
|
||
|
||
function buildFileList(
|
||
container: HTMLElement,
|
||
filenames: string[],
|
||
onRemove: (index: number) => void
|
||
) {
|
||
const list = document.createElement('div');
|
||
list.className = 'flex flex-col gap-1.5 mb-2';
|
||
|
||
filenames.forEach((name, i) => {
|
||
const row = document.createElement('div');
|
||
row.className =
|
||
'flex items-center justify-between bg-gray-900 rounded-lg px-3 py-2';
|
||
|
||
const nameEl = document.createElement('span');
|
||
nameEl.className = 'text-sm text-white truncate flex-1 mr-2';
|
||
nameEl.textContent = name;
|
||
row.appendChild(nameEl);
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className =
|
||
'text-gray-500 hover:text-red-400 text-lg leading-none flex-shrink-0';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.addEventListener('click', () => onRemove(i));
|
||
row.appendChild(removeBtn);
|
||
|
||
list.appendChild(row);
|
||
});
|
||
|
||
container.appendChild(list);
|
||
}
|
||
|
||
function promptPdfPassword(filename: string): Promise<string | null> {
|
||
return new Promise((resolve) => {
|
||
const modal = document.getElementById('pdf-password-modal')!;
|
||
const filenameEl = document.getElementById('pdf-password-filename')!;
|
||
const input = document.getElementById(
|
||
'pdf-password-input'
|
||
) as HTMLInputElement;
|
||
const errorEl = document.getElementById('pdf-password-error')!;
|
||
const skipBtn = document.getElementById('pdf-password-skip')!;
|
||
const unlockBtn = document.getElementById('pdf-password-unlock')!;
|
||
|
||
filenameEl.textContent = filename;
|
||
input.value = '';
|
||
errorEl.classList.add('hidden');
|
||
modal.classList.remove('hidden');
|
||
input.focus();
|
||
|
||
const cleanup = () => {
|
||
modal.classList.add('hidden');
|
||
skipBtn.replaceWith(skipBtn.cloneNode(true));
|
||
unlockBtn.replaceWith(unlockBtn.cloneNode(true));
|
||
input.removeEventListener('keydown', onKeydown);
|
||
};
|
||
|
||
const onKeydown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
cleanup();
|
||
resolve(input.value || null);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
cleanup();
|
||
resolve(null);
|
||
}
|
||
};
|
||
|
||
input.addEventListener('keydown', onKeydown);
|
||
document
|
||
.getElementById('pdf-password-skip')!
|
||
.addEventListener('click', () => {
|
||
cleanup();
|
||
resolve(null);
|
||
});
|
||
document
|
||
.getElementById('pdf-password-unlock')!
|
||
.addEventListener('click', () => {
|
||
cleanup();
|
||
resolve(input.value || null);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showNodeSettings(node: BaseWorkflowNode) {
|
||
const sidebar = document.getElementById('settings-sidebar');
|
||
const title = document.getElementById('settings-title');
|
||
const content = document.getElementById('settings-content');
|
||
if (!sidebar || !title || !content) return;
|
||
|
||
sidebar.classList.remove('hidden');
|
||
title.textContent = node.label;
|
||
content.innerHTML = '';
|
||
|
||
if (node instanceof PDFInputNode) {
|
||
const fileSection = document.createElement('div');
|
||
|
||
const label = document.createElement('label');
|
||
label.className = 'block text-xs text-gray-400 mb-1';
|
||
label.textContent = 'PDF Files';
|
||
fileSection.appendChild(label);
|
||
|
||
if (node.hasFile()) {
|
||
buildFileList(fileSection, node.getFilenames(), (index) => {
|
||
node.removeFile(index);
|
||
showNodeSettings(node);
|
||
});
|
||
}
|
||
|
||
const uploadBtn = document.createElement('button');
|
||
uploadBtn.className =
|
||
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
|
||
uploadBtn.textContent = node.hasFile() ? 'Add More Files' : 'Upload PDFs';
|
||
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = '.pdf';
|
||
fileInput.multiple = true;
|
||
fileInput.className = 'hidden';
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||
if (files.length === 0) return;
|
||
for (const file of files) {
|
||
try {
|
||
await node.addFile(file);
|
||
} catch (err) {
|
||
if (err instanceof EncryptedPDFError) {
|
||
const password = await promptPdfPassword(file.name);
|
||
if (password) {
|
||
try {
|
||
await node.addDecryptedFile(file, password);
|
||
} catch {
|
||
showAlert(
|
||
'Error',
|
||
`Wrong password or failed to decrypt "${file.name}".`
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
showAlert('Error', 'Failed to load PDF: ' + (err as Error).message);
|
||
}
|
||
}
|
||
}
|
||
showNodeSettings(node);
|
||
});
|
||
|
||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||
fileSection.appendChild(uploadBtn);
|
||
fileSection.appendChild(fileInput);
|
||
content.appendChild(fileSection);
|
||
return;
|
||
}
|
||
|
||
if (node instanceof ImageInputNode) {
|
||
const fileSection = document.createElement('div');
|
||
|
||
const label = document.createElement('label');
|
||
label.className = 'block text-xs text-gray-400 mb-1';
|
||
label.textContent = 'Images';
|
||
fileSection.appendChild(label);
|
||
|
||
const formatHint = document.createElement('p');
|
||
formatHint.className = 'text-[10px] text-gray-500 mb-2';
|
||
formatHint.textContent =
|
||
'Supported: JPG, PNG, BMP, GIF, TIFF, WebP, HEIC, PSD, SVG, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2';
|
||
fileSection.appendChild(formatHint);
|
||
|
||
if (node.hasFile()) {
|
||
buildFileList(fileSection, node.getFilenames(), (index) => {
|
||
node.removeFile(index);
|
||
showNodeSettings(node);
|
||
});
|
||
}
|
||
|
||
const uploadBtn = document.createElement('button');
|
||
uploadBtn.className =
|
||
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
|
||
uploadBtn.textContent = node.hasFile()
|
||
? 'Add More Images'
|
||
: 'Upload Images';
|
||
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = IMAGE_ACCEPT;
|
||
fileInput.multiple = true;
|
||
fileInput.className = 'hidden';
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||
if (files.length === 0) return;
|
||
try {
|
||
await node.addFiles(files);
|
||
showNodeSettings(node);
|
||
} catch (err) {
|
||
showAlert('Error', 'Failed to load images: ' + (err as Error).message);
|
||
}
|
||
});
|
||
|
||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||
fileSection.appendChild(uploadBtn);
|
||
fileSection.appendChild(fileInput);
|
||
content.appendChild(fileSection);
|
||
return;
|
||
}
|
||
|
||
if (node instanceof DigitalSignNode) {
|
||
const certSection = document.createElement('div');
|
||
|
||
const certLabel = document.createElement('label');
|
||
certLabel.className = 'block text-xs text-gray-400 mb-1';
|
||
certLabel.textContent = 'Certificate (.pfx, .p12, .pem)';
|
||
certSection.appendChild(certLabel);
|
||
|
||
if (node.hasCertFile()) {
|
||
const certFileDiv = document.createElement('div');
|
||
certFileDiv.className =
|
||
'flex items-center justify-between bg-gray-700 px-3 py-2 rounded-lg mb-2';
|
||
|
||
const certName = document.createElement('span');
|
||
certName.className = 'text-xs text-gray-200 truncate flex-1';
|
||
certName.textContent = node.getCertFilename();
|
||
|
||
const statusDot = document.createElement('span');
|
||
statusDot.className = `w-2 h-2 rounded-full flex-shrink-0 mx-2 ${node.hasCert() ? 'bg-green-400' : 'bg-yellow-400'}`;
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className =
|
||
'text-red-400 hover:text-red-300 text-xs flex-shrink-0';
|
||
removeBtn.textContent = 'Remove';
|
||
removeBtn.addEventListener('click', () => {
|
||
node.removeCert();
|
||
showNodeSettings(node);
|
||
});
|
||
|
||
certFileDiv.append(certName, statusDot, removeBtn);
|
||
certSection.appendChild(certFileDiv);
|
||
|
||
if (node.needsPassword()) {
|
||
const pwSection = document.createElement('div');
|
||
pwSection.className = 'mb-2';
|
||
|
||
const pwLabel = document.createElement('label');
|
||
pwLabel.className = 'block text-xs text-gray-400 mb-1';
|
||
pwLabel.textContent = 'Certificate Password';
|
||
pwSection.appendChild(pwLabel);
|
||
|
||
const pwRow = document.createElement('div');
|
||
pwRow.className = 'flex gap-2';
|
||
|
||
const pwInput = document.createElement('input');
|
||
pwInput.type = 'password';
|
||
pwInput.placeholder = 'Enter password...';
|
||
pwInput.className =
|
||
'flex-1 bg-gray-700 border border-gray-600 text-white text-xs px-3 py-2 rounded-lg focus:outline-none focus:border-indigo-500';
|
||
|
||
const unlockBtn = document.createElement('button');
|
||
unlockBtn.className =
|
||
'bg-indigo-600 hover:bg-indigo-500 text-white text-xs px-3 py-2 rounded-lg transition-colors flex-shrink-0';
|
||
unlockBtn.textContent = 'Unlock';
|
||
|
||
const statusMsg = document.createElement('div');
|
||
statusMsg.className = 'text-xs mt-1 hidden';
|
||
|
||
const doUnlock = async () => {
|
||
const pw = pwInput.value;
|
||
if (!pw) return;
|
||
unlockBtn.textContent = 'Unlocking...';
|
||
unlockBtn.disabled = true;
|
||
const success = await node.unlockCert(pw);
|
||
if (success) {
|
||
showNodeSettings(node);
|
||
} else {
|
||
unlockBtn.textContent = 'Unlock';
|
||
unlockBtn.disabled = false;
|
||
statusMsg.textContent = 'Incorrect password';
|
||
statusMsg.className = 'text-xs mt-1 text-red-400';
|
||
statusMsg.classList.remove('hidden');
|
||
}
|
||
};
|
||
|
||
unlockBtn.addEventListener('click', doUnlock);
|
||
pwInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') doUnlock();
|
||
});
|
||
|
||
pwRow.append(pwInput, unlockBtn);
|
||
pwSection.append(pwRow, statusMsg);
|
||
certSection.appendChild(pwSection);
|
||
} else if (node.hasCert()) {
|
||
const okMsg = document.createElement('div');
|
||
okMsg.className = 'text-xs text-green-400 mb-2';
|
||
okMsg.textContent = 'Certificate unlocked';
|
||
certSection.appendChild(okMsg);
|
||
}
|
||
}
|
||
|
||
const uploadBtn = document.createElement('button');
|
||
uploadBtn.className =
|
||
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
|
||
uploadBtn.textContent = node.hasCertFile()
|
||
? 'Change Certificate'
|
||
: 'Upload Certificate';
|
||
|
||
const certInput = document.createElement('input');
|
||
certInput.type = 'file';
|
||
certInput.accept = '.pfx,.p12,.pem';
|
||
certInput.className = 'hidden';
|
||
certInput.addEventListener('change', (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
node.setCertFile(file);
|
||
|
||
const isPem = file.name.toLowerCase().endsWith('.pem');
|
||
if (isPem) {
|
||
file.text().then(async (pemContent) => {
|
||
const isEncrypted = pemContent.includes('ENCRYPTED');
|
||
if (!isEncrypted) {
|
||
await node.unlockCert('');
|
||
}
|
||
showNodeSettings(node);
|
||
});
|
||
} else {
|
||
showNodeSettings(node);
|
||
}
|
||
});
|
||
|
||
uploadBtn.addEventListener('click', () => certInput.click());
|
||
certSection.append(uploadBtn, certInput);
|
||
content.appendChild(certSection);
|
||
|
||
const divider = document.createElement('div');
|
||
divider.className = 'border-t border-gray-700 my-3';
|
||
content.appendChild(divider);
|
||
}
|
||
|
||
interface FileInputNode extends BaseWorkflowNode {
|
||
hasFile(): boolean;
|
||
getFilenames(): string[];
|
||
removeFile(index: number): void;
|
||
addFiles(files: File[]): Promise<void>;
|
||
}
|
||
|
||
const fileInputConfigs: {
|
||
cls: new (...args: unknown[]) => FileInputNode;
|
||
label: string;
|
||
accept: string;
|
||
btnLabel: string;
|
||
hint?: string;
|
||
}[] = [
|
||
{
|
||
cls: WordToPdfNode,
|
||
label: 'Word Documents',
|
||
accept: '.doc,.docx,.odt,.rtf',
|
||
btnLabel: 'Documents',
|
||
hint: 'Supported: DOC, DOCX, ODT, RTF',
|
||
},
|
||
{
|
||
cls: ExcelToPdfNode,
|
||
label: 'Spreadsheets',
|
||
accept: '.xlsx,.xls,.ods,.csv',
|
||
btnLabel: 'Spreadsheets',
|
||
hint: 'Supported: XLSX, XLS, ODS, CSV',
|
||
},
|
||
{
|
||
cls: PowerPointToPdfNode,
|
||
label: 'Presentations',
|
||
accept: '.ppt,.pptx,.odp',
|
||
btnLabel: 'Presentations',
|
||
hint: 'Supported: PPT, PPTX, ODP',
|
||
},
|
||
{
|
||
cls: TextToPdfNode,
|
||
label: 'Text Files',
|
||
accept: '.txt',
|
||
btnLabel: 'Text Files',
|
||
},
|
||
{
|
||
cls: SvgToPdfNode,
|
||
label: 'SVG Files',
|
||
accept: '.svg',
|
||
btnLabel: 'SVG Files',
|
||
},
|
||
{
|
||
cls: EpubToPdfNode,
|
||
label: 'EPUB Files',
|
||
accept: '.epub',
|
||
btnLabel: 'EPUB Files',
|
||
},
|
||
{
|
||
cls: EmailToPdfNode,
|
||
label: 'Email Files',
|
||
accept: '.eml,.msg',
|
||
btnLabel: 'Email Files',
|
||
hint: 'Supported: EML, MSG',
|
||
},
|
||
{
|
||
cls: XpsToPdfNode,
|
||
label: 'XPS Files',
|
||
accept: '.xps,.oxps',
|
||
btnLabel: 'XPS Files',
|
||
hint: 'Supported: XPS, OXPS',
|
||
},
|
||
{
|
||
cls: MobiToPdfNode,
|
||
label: 'MOBI Files',
|
||
accept: '.mobi',
|
||
btnLabel: 'MOBI Files',
|
||
},
|
||
{
|
||
cls: Fb2ToPdfNode,
|
||
label: 'FB2 Files',
|
||
accept: '.fb2',
|
||
btnLabel: 'FB2 Files',
|
||
},
|
||
{
|
||
cls: CbzToPdfNode,
|
||
label: 'Comic Archives',
|
||
accept: '.cbz,.cbr',
|
||
btnLabel: 'Comics',
|
||
hint: 'Supported: CBZ, CBR',
|
||
},
|
||
{
|
||
cls: MarkdownToPdfNode,
|
||
label: 'Markdown Files',
|
||
accept: '.md,.markdown',
|
||
btnLabel: 'Markdown Files',
|
||
},
|
||
{
|
||
cls: JsonToPdfNode,
|
||
label: 'JSON Files',
|
||
accept: '.json',
|
||
btnLabel: 'JSON Files',
|
||
},
|
||
{
|
||
cls: XmlToPdfNode,
|
||
label: 'XML Files',
|
||
accept: '.xml',
|
||
btnLabel: 'XML Files',
|
||
},
|
||
{
|
||
cls: WpdToPdfNode,
|
||
label: 'WordPerfect Files',
|
||
accept: '.wpd',
|
||
btnLabel: 'WPD Files',
|
||
},
|
||
{
|
||
cls: WpsToPdfNode,
|
||
label: 'WPS Files',
|
||
accept: '.wps',
|
||
btnLabel: 'WPS Files',
|
||
},
|
||
{
|
||
cls: PagesToPdfNode,
|
||
label: 'Pages Files',
|
||
accept: '.pages',
|
||
btnLabel: 'Pages Files',
|
||
},
|
||
{
|
||
cls: OdgToPdfNode,
|
||
label: 'ODG Files',
|
||
accept: '.odg',
|
||
btnLabel: 'ODG Files',
|
||
},
|
||
{
|
||
cls: PubToPdfNode,
|
||
label: 'Publisher Files',
|
||
accept: '.pub',
|
||
btnLabel: 'PUB Files',
|
||
},
|
||
{
|
||
cls: VsdToPdfNode,
|
||
label: 'Visio Files',
|
||
accept: '.vsd,.vsdx',
|
||
btnLabel: 'Visio Files',
|
||
hint: 'Supported: VSD, VSDX',
|
||
},
|
||
];
|
||
|
||
const fileInputConfig = fileInputConfigs.find((c) => node instanceof c.cls);
|
||
if (fileInputConfig) {
|
||
const fileNode = node as InstanceType<typeof fileInputConfig.cls>;
|
||
const fileSection = document.createElement('div');
|
||
|
||
const label = document.createElement('label');
|
||
label.className = 'block text-xs text-gray-400 mb-1';
|
||
label.textContent = fileInputConfig.label;
|
||
fileSection.appendChild(label);
|
||
|
||
if (fileInputConfig.hint) {
|
||
const hint = document.createElement('p');
|
||
hint.className = 'text-[10px] text-gray-500 mb-2';
|
||
hint.textContent = fileInputConfig.hint;
|
||
fileSection.appendChild(hint);
|
||
}
|
||
|
||
if (fileNode.hasFile()) {
|
||
buildFileList(fileSection, fileNode.getFilenames(), (index) => {
|
||
fileNode.removeFile(index);
|
||
showNodeSettings(node);
|
||
});
|
||
}
|
||
|
||
const uploadBtn = document.createElement('button');
|
||
uploadBtn.className =
|
||
'w-full bg-gray-700 hover:bg-gray-600 text-white text-xs px-3 py-2 rounded-lg transition-colors';
|
||
uploadBtn.textContent = fileNode.hasFile()
|
||
? `Add More ${fileInputConfig.btnLabel}`
|
||
: `Upload ${fileInputConfig.btnLabel}`;
|
||
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = fileInputConfig.accept;
|
||
fileInput.multiple = true;
|
||
fileInput.className = 'hidden';
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||
if (files.length === 0) return;
|
||
try {
|
||
await fileNode.addFiles(files);
|
||
showNodeSettings(node);
|
||
} catch (err) {
|
||
showAlert('Error', `Failed to load files: ${(err as Error).message}`);
|
||
}
|
||
});
|
||
|
||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||
fileSection.appendChild(uploadBtn);
|
||
fileSection.appendChild(fileInput);
|
||
content.appendChild(fileSection);
|
||
|
||
const controlEntries = Object.entries(node.controls);
|
||
if (controlEntries.length > 0) {
|
||
const divider = document.createElement('div');
|
||
divider.className = 'border-t border-gray-700 my-3';
|
||
content.appendChild(divider);
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const controlEntries = Object.entries(node.controls);
|
||
if (controlEntries.length === 0) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'text-xs text-gray-500';
|
||
empty.textContent = 'No configurable settings for this node.';
|
||
content.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const dropdownOptions: Record<string, { label: string; value: string }[]> = {
|
||
format: [
|
||
{ label: 'JPG', value: 'jpg' },
|
||
{ label: 'PNG', value: 'png' },
|
||
{ label: 'WebP', value: 'webp' },
|
||
{ label: 'SVG', value: 'svg' },
|
||
],
|
||
position: [
|
||
{ label: 'Bottom Center', value: 'bottom-center' },
|
||
{ label: 'Bottom Left', value: 'bottom-left' },
|
||
{ label: 'Bottom Right', value: 'bottom-right' },
|
||
{ label: 'Top Center', value: 'top-center' },
|
||
{ label: 'Top Left', value: 'top-left' },
|
||
{ label: 'Top Right', value: 'top-right' },
|
||
],
|
||
orientation: [
|
||
{ label: 'Auto (Keep Original)', value: 'auto' },
|
||
{ label: 'Portrait', value: 'portrait' },
|
||
{ label: 'Landscape', value: 'landscape' },
|
||
],
|
||
direction: [
|
||
{ label: 'Vertical', value: 'vertical' },
|
||
{ label: 'Horizontal', value: 'horizontal' },
|
||
],
|
||
pagesPerSheet: [
|
||
{ label: '2', value: '2' },
|
||
{ label: '4', value: '4' },
|
||
{ label: '9', value: '9' },
|
||
{ label: '16', value: '16' },
|
||
],
|
||
fontFamily: [
|
||
{ label: 'Helvetica', value: 'helv' },
|
||
{ label: 'Times Roman', value: 'times' },
|
||
{ label: 'Courier', value: 'cour' },
|
||
{ label: 'Times Italic', value: 'tiro' },
|
||
],
|
||
pageSize: [
|
||
{ label: 'A4', value: 'a4' },
|
||
{ label: 'Letter', value: 'letter' },
|
||
{ label: 'Legal', value: 'legal' },
|
||
],
|
||
targetSize: [
|
||
{ label: 'A4', value: 'A4' },
|
||
{ label: 'Letter', value: 'Letter' },
|
||
{ label: 'Legal', value: 'Legal' },
|
||
{ label: 'A3', value: 'A3' },
|
||
{ label: 'A5', value: 'A5' },
|
||
{ label: 'Tabloid', value: 'Tabloid' },
|
||
{ label: 'Custom', value: 'Custom' },
|
||
],
|
||
scalingMode: [
|
||
{ label: 'Fit (keep full page visible)', value: 'fit' },
|
||
{ label: 'Fill (cover full target page)', value: 'fill' },
|
||
],
|
||
customUnits: [
|
||
{ label: 'Millimeters (mm)', value: 'mm' },
|
||
{ label: 'Inches (in)', value: 'in' },
|
||
],
|
||
numberFormat: [
|
||
{ label: 'Simple (1, 2, 3)', value: 'simple' },
|
||
{ label: 'Page X of Y', value: 'page_x_of_y' },
|
||
],
|
||
angle: [
|
||
{ label: '90° Clockwise', value: '90' },
|
||
{ label: '180°', value: '180' },
|
||
{ label: '90° Counter-clockwise', value: '270' },
|
||
],
|
||
blankPosition: [
|
||
{ label: 'End', value: 'end' },
|
||
{ label: 'Beginning', value: 'start' },
|
||
{ label: 'After Page...', value: 'after' },
|
||
],
|
||
resolution: [
|
||
{ label: 'Standard (192 DPI)', value: '2.0' },
|
||
{ label: 'High (288 DPI)', value: '3.0' },
|
||
{ label: 'Ultra (384 DPI)', value: '4.0' },
|
||
],
|
||
language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({
|
||
label: name,
|
||
value: code,
|
||
})),
|
||
gridMode: [
|
||
{ label: '1x2 (Booklet)', value: '1x2' },
|
||
{ label: '2x2 (4-up)', value: '2x2' },
|
||
{ label: '2x4 (8-up)', value: '2x4' },
|
||
{ label: '4x4 (16-up)', value: '4x4' },
|
||
],
|
||
paperSize: [
|
||
{ label: 'Letter', value: 'Letter' },
|
||
{ label: 'A4', value: 'A4' },
|
||
{ label: 'A3', value: 'A3' },
|
||
{ label: 'Tabloid', value: 'Tabloid' },
|
||
{ label: 'Legal', value: 'Legal' },
|
||
],
|
||
rasterizeDpi: [
|
||
{ label: '72 (Screen)', value: '72' },
|
||
{ label: '150 (Default)', value: '150' },
|
||
{ label: '200 (Good)', value: '200' },
|
||
{ label: '300 (Print)', value: '300' },
|
||
{ label: '600 (High Quality)', value: '600' },
|
||
],
|
||
imageFormat: [
|
||
{ label: 'PNG (Lossless)', value: 'png' },
|
||
{ label: 'JPEG (Smaller file size)', value: 'jpeg' },
|
||
],
|
||
skewThreshold: [
|
||
{ label: '0.1° (Very Sensitive)', value: '0.1' },
|
||
{ label: '0.5° (Default)', value: '0.5' },
|
||
{ label: '1.0° (Normal)', value: '1.0' },
|
||
{ label: '2.0° (Less Sensitive)', value: '2.0' },
|
||
],
|
||
processingDpi: [
|
||
{ label: '100 (Fast)', value: '100' },
|
||
{ label: '150 (Default)', value: '150' },
|
||
{ label: '200 (Better)', value: '200' },
|
||
{ label: '300 (Best Quality)', value: '300' },
|
||
],
|
||
level: [
|
||
{ label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' },
|
||
{ label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' },
|
||
{ label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' },
|
||
],
|
||
algorithm: [
|
||
{ label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' },
|
||
{ label: 'Photon (Rasterize pages)', value: 'photon' },
|
||
],
|
||
compressionLevel: [
|
||
{ label: 'Light', value: 'light' },
|
||
{ label: 'Balanced', value: 'balanced' },
|
||
{ label: 'Aggressive', value: 'aggressive' },
|
||
{ label: 'Extreme', value: 'extreme' },
|
||
],
|
||
redactMode: [
|
||
{ label: 'Search Text', value: 'text' },
|
||
{ label: 'Area (Coordinates)', value: 'area' },
|
||
],
|
||
};
|
||
|
||
const booleanControls = new Set([
|
||
'grayscale',
|
||
'border',
|
||
'margins',
|
||
'separator',
|
||
'sepia',
|
||
'includeCcBcc',
|
||
'includeAttachments',
|
||
'binarize',
|
||
'preFlatten',
|
||
'flattenForms',
|
||
'removeMetadata',
|
||
'removeAnnotations',
|
||
'removeJavascript',
|
||
'removeEmbeddedFiles',
|
||
'removeLayers',
|
||
'removeLinks',
|
||
'removeStructureTree',
|
||
'removeMarkInfo',
|
||
'removeFonts',
|
||
'subsetFonts',
|
||
'convertToGrayscale',
|
||
'removeThumbnails',
|
||
'retainPageLabels',
|
||
]);
|
||
const multiSelectDropdowns = new Set(['language']);
|
||
const advancedControls = new Set(['resolution', 'binarize', 'whitelist']);
|
||
|
||
const colorControls = new Set([
|
||
'color',
|
||
'borderColor',
|
||
'backgroundColor',
|
||
'separatorColor',
|
||
'fontColor',
|
||
'fillColor',
|
||
]);
|
||
|
||
const controlHints: Record<string, string> = {
|
||
pages: 'e.g. 1-3, 5, 7-9',
|
||
whitelist: 'Limit recognized characters (leave empty for all)',
|
||
afterPage: 'Insert blank pages after this page number',
|
||
x0: 'Left edge in points (1 inch = 72 pts)',
|
||
y0: 'Top edge in points',
|
||
x1: 'Right edge in points',
|
||
y1: 'Bottom edge in points',
|
||
retainPageLabels:
|
||
"Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels).",
|
||
};
|
||
|
||
const inputClass =
|
||
'w-full bg-gray-900 border border-gray-600 text-white rounded-md px-2 py-1.5 text-xs focus:border-indigo-500 focus:outline-none';
|
||
|
||
const conditionalVisibility: Record<string, Record<string, string[]>> = {
|
||
redactMode: {
|
||
text: ['text'],
|
||
area: ['x0', 'y0', 'x1', 'y1'],
|
||
},
|
||
targetSize: {
|
||
Custom: ['customWidth', 'customHeight', 'customUnits'],
|
||
},
|
||
};
|
||
|
||
const controlWrappers: Record<string, HTMLElement> = {};
|
||
const hasAdvanced = controlEntries.some(([key]) => advancedControls.has(key));
|
||
const advancedWrappers: HTMLElement[] = [];
|
||
|
||
for (const [key, control] of controlEntries) {
|
||
const wrapper = document.createElement('div');
|
||
controlWrappers[key] = wrapper;
|
||
const ctrl = control as { value?: unknown; type?: string };
|
||
const currentValue = String(ctrl.value ?? '');
|
||
|
||
const controlLabel = document.createElement('label');
|
||
controlLabel.className = 'block text-xs text-gray-400 mb-1';
|
||
controlLabel.textContent = formatLabel(key);
|
||
wrapper.appendChild(controlLabel);
|
||
|
||
if (dropdownOptions[key] && multiSelectDropdowns.has(key)) {
|
||
const selectedValues = new Set(
|
||
currentValue ? currentValue.split('+') : []
|
||
);
|
||
const container = document.createElement('div');
|
||
container.className = 'flex flex-col gap-1';
|
||
|
||
const searchInput = document.createElement('input');
|
||
searchInput.type = 'text';
|
||
searchInput.placeholder = 'Search languages...';
|
||
searchInput.className = inputClass;
|
||
container.appendChild(searchInput);
|
||
|
||
const tagsDiv = document.createElement('div');
|
||
tagsDiv.className = 'flex flex-wrap gap-1 min-h-[24px]';
|
||
container.appendChild(tagsDiv);
|
||
|
||
const listDiv = document.createElement('div');
|
||
listDiv.className =
|
||
'max-h-32 overflow-y-auto bg-gray-800 rounded border border-gray-600 mt-1';
|
||
container.appendChild(listDiv);
|
||
|
||
function updateTags() {
|
||
tagsDiv.innerHTML = '';
|
||
for (const val of selectedValues) {
|
||
const opt = dropdownOptions[key].find((o) => o.value === val);
|
||
if (!opt) continue;
|
||
const tag = document.createElement('span');
|
||
tag.className =
|
||
'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-indigo-600 text-white text-[10px]';
|
||
tag.textContent = opt.label;
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.textContent = '\u00d7';
|
||
removeBtn.className =
|
||
'text-white/70 hover:text-white text-xs leading-none';
|
||
removeBtn.addEventListener('click', () => {
|
||
selectedValues.delete(val);
|
||
updateTags();
|
||
updateCtrl();
|
||
renderList(searchInput.value);
|
||
});
|
||
tag.appendChild(removeBtn);
|
||
tagsDiv.appendChild(tag);
|
||
}
|
||
}
|
||
|
||
function updateCtrl() {
|
||
(ctrl as { value: string }).value =
|
||
Array.from(selectedValues).join('+');
|
||
}
|
||
|
||
function renderList(filter: string) {
|
||
listDiv.innerHTML = '';
|
||
const lowerFilter = filter.toLowerCase();
|
||
for (const opt of dropdownOptions[key]) {
|
||
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
|
||
continue;
|
||
const label = document.createElement('label');
|
||
label.className =
|
||
'flex items-center gap-2 px-2 py-1 hover:bg-gray-700 cursor-pointer text-xs text-gray-300';
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.checked = selectedValues.has(opt.value);
|
||
cb.className =
|
||
'w-3 h-3 rounded text-indigo-600 bg-gray-700 border-gray-600';
|
||
cb.addEventListener('change', () => {
|
||
if (cb.checked) {
|
||
selectedValues.add(opt.value);
|
||
} else {
|
||
selectedValues.delete(opt.value);
|
||
}
|
||
updateTags();
|
||
updateCtrl();
|
||
});
|
||
label.appendChild(cb);
|
||
label.appendChild(document.createTextNode(opt.label));
|
||
listDiv.appendChild(label);
|
||
}
|
||
}
|
||
|
||
searchInput.addEventListener('input', () => {
|
||
renderList(searchInput.value);
|
||
});
|
||
|
||
updateTags();
|
||
renderList('');
|
||
wrapper.appendChild(container);
|
||
} else if (dropdownOptions[key]) {
|
||
const select = document.createElement('select');
|
||
select.className = inputClass;
|
||
for (const opt of dropdownOptions[key]) {
|
||
const option = document.createElement('option');
|
||
option.value = opt.value;
|
||
option.textContent = opt.label;
|
||
if (currentValue === opt.value) option.selected = true;
|
||
select.appendChild(option);
|
||
}
|
||
select.addEventListener('change', () => {
|
||
(ctrl as { value: string }).value = select.value;
|
||
if (conditionalVisibility[key]) {
|
||
applyConditionalVisibility(key, select.value);
|
||
}
|
||
});
|
||
wrapper.appendChild(select);
|
||
} else if (booleanControls.has(key)) {
|
||
const toggle = document.createElement('button');
|
||
toggle.type = 'button';
|
||
const isOn = currentValue === 'true';
|
||
toggle.className = `relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${isOn ? 'bg-indigo-500' : 'bg-gray-600'}`;
|
||
const dot = document.createElement('span');
|
||
dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${isOn ? 'translate-x-5' : 'translate-x-0'}`;
|
||
toggle.appendChild(dot);
|
||
toggle.addEventListener('click', () => {
|
||
const newVal =
|
||
(ctrl as { value: string }).value === 'true' ? 'false' : 'true';
|
||
(ctrl as { value: string }).value = newVal;
|
||
const on = newVal === 'true';
|
||
toggle.className = `relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${on ? 'bg-indigo-500' : 'bg-gray-600'}`;
|
||
dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`;
|
||
});
|
||
wrapper.appendChild(toggle);
|
||
} else if (colorControls.has(key)) {
|
||
const colorRow = document.createElement('div');
|
||
colorRow.className = 'flex items-center gap-2';
|
||
const colorInput = document.createElement('input');
|
||
colorInput.type = 'color';
|
||
colorInput.value = currentValue || '#000000';
|
||
colorInput.className = 'w-8 h-8 rounded bg-transparent cursor-pointer';
|
||
const hexInput = document.createElement('input');
|
||
hexInput.type = 'text';
|
||
hexInput.value = currentValue || '#000000';
|
||
hexInput.className = inputClass + ' flex-1';
|
||
colorInput.addEventListener('input', () => {
|
||
hexInput.value = colorInput.value;
|
||
(ctrl as { value: string }).value = colorInput.value;
|
||
});
|
||
hexInput.addEventListener('input', () => {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(hexInput.value)) {
|
||
colorInput.value = hexInput.value;
|
||
}
|
||
(ctrl as { value: string }).value = hexInput.value;
|
||
});
|
||
colorRow.appendChild(colorInput);
|
||
colorRow.appendChild(hexInput);
|
||
wrapper.appendChild(colorRow);
|
||
} else if (ctrl.type === 'number' || typeof ctrl.value === 'number') {
|
||
const input = document.createElement('input');
|
||
input.type = 'number';
|
||
input.className = inputClass;
|
||
input.value = currentValue;
|
||
input.addEventListener('input', () => {
|
||
const num = parseFloat(input.value);
|
||
if (!isNaN(num)) {
|
||
(ctrl as { value: number }).value = num;
|
||
}
|
||
});
|
||
wrapper.appendChild(input);
|
||
} else {
|
||
const input = document.createElement('input');
|
||
const isPasswordField = key === 'password' || key === 'ownerPassword';
|
||
input.type = isPasswordField ? 'password' : 'text';
|
||
input.className = inputClass;
|
||
input.value = currentValue;
|
||
input.addEventListener('input', () => {
|
||
(ctrl as { value: string }).value = input.value;
|
||
});
|
||
wrapper.appendChild(input);
|
||
}
|
||
|
||
if (controlHints[key]) {
|
||
const hint = document.createElement('p');
|
||
hint.className = 'text-[10px] text-gray-500 mt-1';
|
||
hint.textContent = controlHints[key];
|
||
wrapper.appendChild(hint);
|
||
}
|
||
|
||
if (advancedControls.has(key)) {
|
||
advancedWrappers.push(wrapper);
|
||
} else {
|
||
content.appendChild(wrapper);
|
||
}
|
||
}
|
||
|
||
function applyConditionalVisibility(
|
||
dropdownKey: string,
|
||
selectedValue: string
|
||
) {
|
||
const mapping = conditionalVisibility[dropdownKey];
|
||
if (!mapping) return;
|
||
const allControlled = new Set(Object.values(mapping).flat());
|
||
for (const controlKey of allControlled) {
|
||
const el = controlWrappers[controlKey];
|
||
if (el) el.style.display = 'none';
|
||
}
|
||
const visible = mapping[selectedValue] ?? [];
|
||
for (const controlKey of visible) {
|
||
const el = controlWrappers[controlKey];
|
||
if (el) el.style.display = '';
|
||
}
|
||
}
|
||
|
||
for (const [dropdownKey] of Object.entries(conditionalVisibility)) {
|
||
const ctrl = controlEntries.find(([k]) => k === dropdownKey)?.[1] as
|
||
| { value?: unknown }
|
||
| undefined;
|
||
if (ctrl) {
|
||
applyConditionalVisibility(dropdownKey, String(ctrl.value ?? ''));
|
||
}
|
||
}
|
||
|
||
if (hasAdvanced && advancedWrappers.length > 0) {
|
||
const details = document.createElement('details');
|
||
details.className =
|
||
'bg-gray-800/50 border border-gray-700 rounded-lg p-2 mt-1';
|
||
const summary = document.createElement('summary');
|
||
summary.className =
|
||
'text-xs font-medium text-gray-400 cursor-pointer select-none flex items-center justify-between';
|
||
const summaryText = document.createElement('span');
|
||
summaryText.textContent = 'Advanced Settings';
|
||
summary.appendChild(summaryText);
|
||
const chevron = document.createElement('i');
|
||
chevron.className =
|
||
'ph ph-caret-down text-xs text-gray-500 transition-transform duration-200';
|
||
summary.appendChild(chevron);
|
||
details.addEventListener('toggle', () => {
|
||
chevron.style.transform = details.open
|
||
? 'rotate(180deg)'
|
||
: 'rotate(0deg)';
|
||
});
|
||
details.appendChild(summary);
|
||
const advancedContent = document.createElement('div');
|
||
advancedContent.className = 'mt-2 space-y-3';
|
||
for (const w of advancedWrappers) {
|
||
advancedContent.appendChild(w);
|
||
}
|
||
details.appendChild(advancedContent);
|
||
content.appendChild(details);
|
||
}
|
||
}
|
||
|
||
function formatLabel(key: string): string {
|
||
return key
|
||
.replace(/([A-Z])/g, ' $1')
|
||
.replace(/^./, (c) => c.toUpperCase())
|
||
.trim();
|
||
}
|