mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(media): add Image/Audio/Video Tools dialogs wired to new operation backends
Adds Tools > Image/Audio/Video Tools... menu items and a single dynamic renderer dialog (src/renderer/media-operations-dialog.js) that lets the user pick a media-kind-scoped operation, fill in its operation-specific fields, and invoke process-image-operation/process-audio-operation/ process-video-operation (Tasks 9-11's backends). File selection reuses the existing <input type="file"> + file.path convention; the one folder picker need (video frame extraction) reuses the existing generic select-folder/folder-selected IPC channels, so no new IPC handler was required. Also removes the three dead electronAPI.image/audio/video convenience blocks from preload.js (stale pre-Task-9-11 channel names, unused everywhere).
This commit is contained in:
+15
@@ -1457,6 +1457,21 @@ function createMenu() {
|
||||
label: 'Document Compare',
|
||||
click: () => mainWindow.webContents.send('show-document-compare'),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Image Tools...',
|
||||
click: () => mainWindow.webContents.send('show-image-converter'),
|
||||
},
|
||||
{
|
||||
label: 'Audio Tools...',
|
||||
click: () => mainWindow.webContents.send('show-audio-converter'),
|
||||
},
|
||||
{
|
||||
label: 'Video Tools...',
|
||||
click: () => mainWindow.webContents.send('show-video-converter'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -406,34 +406,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
|
||||
},
|
||||
|
||||
// Image Converter Operations
|
||||
image: {
|
||||
convert: (data) => ipcRenderer.send('image-convert', data),
|
||||
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
||||
resize: (data) => ipcRenderer.send('image-resize', data),
|
||||
compress: (data) => ipcRenderer.send('image-compress', data),
|
||||
rotate: (data) => ipcRenderer.send('image-rotate', data),
|
||||
},
|
||||
|
||||
// Audio Converter Operations
|
||||
audio: {
|
||||
convert: (data) => ipcRenderer.send('audio-convert', data),
|
||||
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
||||
extract: (data) => ipcRenderer.send('audio-extract', data),
|
||||
trim: (data) => ipcRenderer.send('audio-trim', data),
|
||||
merge: (data) => ipcRenderer.send('audio-merge', data),
|
||||
},
|
||||
|
||||
// Video Converter Operations
|
||||
video: {
|
||||
convert: (data) => ipcRenderer.send('video-convert', data),
|
||||
batchConvert: (data) => ipcRenderer.send('video-batch-convert', data),
|
||||
compress: (data) => ipcRenderer.send('video-compress', data),
|
||||
trim: (data) => ipcRenderer.send('video-trim', data),
|
||||
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
||||
toGif: (data) => ipcRenderer.send('video-gif', data),
|
||||
},
|
||||
|
||||
// Generator Windows
|
||||
generators: {
|
||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||
|
||||
@@ -11,6 +11,7 @@ const DOMPurify = createDOMPurify(window);
|
||||
const hljs = require('highlight.js');
|
||||
const { createEditor } = require('./editor/codemirror-setup');
|
||||
const { undo, redo } = require('@codemirror/commands');
|
||||
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
||||
|
||||
/**
|
||||
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
|
||||
@@ -3723,6 +3724,17 @@ ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => {
|
||||
currentPDFOperation = operation;
|
||||
showPDFEditorDialog(operation, openedFilePath);
|
||||
});
|
||||
|
||||
// Show Media Operations Dialogs (Tools > Image/Audio/Video Tools...)
|
||||
ipcRenderer.on('show-image-converter', () => {
|
||||
showMediaOperationsDialog('image');
|
||||
});
|
||||
ipcRenderer.on('show-audio-converter', () => {
|
||||
showMediaOperationsDialog('audio');
|
||||
});
|
||||
ipcRenderer.on('show-video-converter', () => {
|
||||
showMediaOperationsDialog('video');
|
||||
});
|
||||
function showPDFEditorDialog(operation, openedFilePath = null) {
|
||||
const title = document.getElementById('pdf-editor-title');
|
||||
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Media Operations Dialog
|
||||
*
|
||||
* Single dialog for image/audio/video operation-specific tasks (resize, compress,
|
||||
* rotate, trim, extract, merge, frames, gif, ...). Mirrors the construction pattern
|
||||
* used by the PDF Editor dialog in renderer.js (a modal built from `.modal` /
|
||||
* `.modal-content` / `.modal-header` / `.modal-body` / `.modal-footer` markup, driven
|
||||
* by ModalManager, with a readonly text input + "Browse" button for file/folder
|
||||
* selection, and a status line reusing the `info-message` / `success-message` /
|
||||
* `warning-message` classes) — except the operation-specific fields are generated
|
||||
* dynamically instead of being hand-authored per-operation in index.html, since the
|
||||
* three media kinds together cover 13 distinct operations.
|
||||
*
|
||||
* File selection reuses the app's existing convention: a plain `<input type="file">`
|
||||
* whose `.path` is read directly (nodeIntegration is enabled for this renderer), the
|
||||
* same approach already used throughout the PDF Editor and Universal Converter
|
||||
* dialogs. No new IPC channel is needed for single-file or save-file pickers. Output
|
||||
* *folder* selection (used by the video "Extract Frames" operation) reuses the
|
||||
* existing generic `select-folder` / `folder-selected` IPC channels already wired up
|
||||
* in main.js for the batch converter — filtered here by a unique `type` string so this
|
||||
* dialog only reacts to its own request.
|
||||
*
|
||||
* @module media-operations-dialog
|
||||
*/
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
const IMAGE_ACCEPT = '.jpg,.jpeg,.png,.webp,.avif,.tiff,.tif,.gif';
|
||||
const AUDIO_ACCEPT = '.mp3,.wav,.ogg,.flac,.aac,.m4a,.wma';
|
||||
const VIDEO_ACCEPT = '.mp4,.mov,.avi,.mkv,.webm,.flv,.wmv';
|
||||
|
||||
// Field names below are chosen to exactly match the `data` shape each backend
|
||||
// operation destructures — see src/main/ImageOperations.js, AudioOperations.js,
|
||||
// VideoOperations.js.
|
||||
const MEDIA_KIND_CONFIG = {
|
||||
image: {
|
||||
title: 'Image Tools',
|
||||
channel: 'process-image-operation',
|
||||
operations: {
|
||||
convert: {
|
||||
label: 'Convert Format',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Image', type: 'file', accept: IMAGE_ACCEPT },
|
||||
{
|
||||
name: 'format',
|
||||
label: 'Output Format',
|
||||
type: 'select',
|
||||
options: ['jpeg', 'png', 'webp', 'avif', 'tiff', 'gif'],
|
||||
default: 'png',
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
resize: {
|
||||
label: 'Resize',
|
||||
help: 'Provide at least one of Width or Height; the other scales proportionally.',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Image', type: 'file', accept: IMAGE_ACCEPT },
|
||||
{ name: 'width', label: 'Width (px)', type: 'number', min: 1, optional: true },
|
||||
{ name: 'height', label: 'Height (px)', type: 'number', min: 1, optional: true },
|
||||
{
|
||||
name: 'fit',
|
||||
label: 'Fit Mode',
|
||||
type: 'select',
|
||||
options: ['cover', 'contain', 'fill', 'inside', 'outside'],
|
||||
default: 'inside',
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
compress: {
|
||||
label: 'Compress',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Image', type: 'file', accept: IMAGE_ACCEPT },
|
||||
{
|
||||
name: 'quality',
|
||||
label: 'Quality (1-100)',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
default: 80,
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
rotate: {
|
||||
label: 'Rotate',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Image', type: 'file', accept: IMAGE_ACCEPT },
|
||||
{ name: 'angle', label: 'Angle (degrees)', type: 'number', default: 90, step: 1 },
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
title: 'Audio Tools',
|
||||
channel: 'process-audio-operation',
|
||||
operations: {
|
||||
convert: {
|
||||
label: 'Convert Format',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Audio', type: 'file', accept: AUDIO_ACCEPT },
|
||||
{
|
||||
name: 'format',
|
||||
label: 'Output Format',
|
||||
type: 'select',
|
||||
options: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'],
|
||||
default: 'mp3',
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
trim: {
|
||||
label: 'Trim',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Audio', type: 'file', accept: AUDIO_ACCEPT },
|
||||
{
|
||||
name: 'startTime',
|
||||
label: 'Start Time (seconds)',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
default: 0,
|
||||
},
|
||||
{ name: 'duration', label: 'Duration (seconds)', type: 'number', min: 0, default: 10 },
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
extract: {
|
||||
label: 'Extract Audio Track',
|
||||
help: 'Extracts the audio track from a video or audio file.',
|
||||
fields: [
|
||||
{
|
||||
name: 'inputPath',
|
||||
label: 'Input File (video or audio)',
|
||||
type: 'file',
|
||||
accept: `${VIDEO_ACCEPT},${AUDIO_ACCEPT}`,
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
merge: {
|
||||
label: 'Merge',
|
||||
help: 'Select at least 2 audio files to merge, in order.',
|
||||
fields: [
|
||||
{
|
||||
name: 'inputPaths',
|
||||
label: 'Input Audio Files',
|
||||
type: 'files',
|
||||
accept: AUDIO_ACCEPT,
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
video: {
|
||||
title: 'Video Tools',
|
||||
channel: 'process-video-operation',
|
||||
operations: {
|
||||
convert: {
|
||||
label: 'Convert Format',
|
||||
help: 'Output format is inferred from the output file extension (e.g. .mp4, .webm).',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Video', type: 'file', accept: VIDEO_ACCEPT },
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
compress: {
|
||||
label: 'Compress',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Video', type: 'file', accept: VIDEO_ACCEPT },
|
||||
{
|
||||
name: 'crf',
|
||||
label: 'CRF (0-51, lower = higher quality)',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 51,
|
||||
default: 28,
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
trim: {
|
||||
label: 'Trim',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Video', type: 'file', accept: VIDEO_ACCEPT },
|
||||
{
|
||||
name: 'startTime',
|
||||
label: 'Start Time (seconds)',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
default: 0,
|
||||
},
|
||||
{ name: 'duration', label: 'Duration (seconds)', type: 'number', min: 0, default: 10 },
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
frames: {
|
||||
label: 'Extract Frames',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Video', type: 'file', accept: VIDEO_ACCEPT },
|
||||
{
|
||||
name: 'fps',
|
||||
label: 'Frames per Second',
|
||||
type: 'number',
|
||||
min: 0.1,
|
||||
step: 0.1,
|
||||
default: 1,
|
||||
},
|
||||
{ name: 'outputDir', label: 'Output Folder', type: 'folder' },
|
||||
],
|
||||
},
|
||||
gif: {
|
||||
label: 'Convert to GIF',
|
||||
fields: [
|
||||
{ name: 'inputPath', label: 'Input Video', type: 'file', accept: VIDEO_ACCEPT },
|
||||
{ name: 'fps', label: 'Frames per Second', type: 'number', min: 1, default: 10 },
|
||||
{
|
||||
name: 'width',
|
||||
label: 'Width (px, height auto-scales)',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
default: 480,
|
||||
},
|
||||
{ name: 'outputPath', label: 'Output File', type: 'save' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FOLDER_PICK_TYPE = 'media-operations-output-dir';
|
||||
|
||||
let modalEl = null;
|
||||
let modalManager = null;
|
||||
let els = null;
|
||||
let currentKind = null;
|
||||
let mergeFilePaths = [];
|
||||
|
||||
function fieldElId(name) {
|
||||
return `media-field-${name}`;
|
||||
}
|
||||
|
||||
function buildDialogDom() {
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.id = 'media-operations-dialog';
|
||||
modalEl.className = 'modal hidden';
|
||||
modalEl.setAttribute('role', 'dialog');
|
||||
modalEl.setAttribute('aria-modal', 'true');
|
||||
modalEl.setAttribute('aria-labelledby', 'media-operations-title');
|
||||
modalEl.innerHTML = `
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3 id="media-operations-title">Media Tools</h3>
|
||||
<button class="modal-close" id="media-operations-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="export-section">
|
||||
<label for="media-operation-select">Operation:</label>
|
||||
<select id="media-operation-select"></select>
|
||||
</div>
|
||||
<small id="media-operation-help" class="hidden"></small>
|
||||
<div id="media-operation-fields"></div>
|
||||
<div id="media-status-message" class="info-message hidden" aria-live="polite"></div>
|
||||
<div id="media-progress" class="batch-progress hidden">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="media-progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
<span id="media-progress-text">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="media-operations-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||
<button id="media-operations-process" class="btn btn-primary">Process</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modalEl);
|
||||
|
||||
els = {
|
||||
title: modalEl.querySelector('#media-operations-title'),
|
||||
operationSelect: modalEl.querySelector('#media-operation-select'),
|
||||
help: modalEl.querySelector('#media-operation-help'),
|
||||
fieldsContainer: modalEl.querySelector('#media-operation-fields'),
|
||||
status: modalEl.querySelector('#media-status-message'),
|
||||
progress: modalEl.querySelector('#media-progress'),
|
||||
progressFill: modalEl.querySelector('#media-progress-fill'),
|
||||
progressText: modalEl.querySelector('#media-progress-text'),
|
||||
processBtn: modalEl.querySelector('#media-operations-process'),
|
||||
cancelBtn: modalEl.querySelector('#media-operations-cancel'),
|
||||
};
|
||||
|
||||
els.operationSelect.addEventListener('change', renderFields);
|
||||
els.processBtn.addEventListener('click', handleProcess);
|
||||
els.cancelBtn.addEventListener('click', hideDialog);
|
||||
|
||||
modalManager = new window.ModalManager(modalEl);
|
||||
|
||||
// Generic output-folder picker reply (shared with the batch converter's
|
||||
// input/output folder pickers) — filter by our own `type` so we only react
|
||||
// to requests this dialog made.
|
||||
ipcRenderer.on('folder-selected', (event, { type, path: folderPath }) => {
|
||||
if (type !== FOLDER_PICK_TYPE || !folderPath) return;
|
||||
const input = document.getElementById(fieldElId('outputDir'));
|
||||
if (input) input.value = folderPath;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
if (!modalEl) {
|
||||
buildDialogDom();
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
if (!els.status) return;
|
||||
els.status.textContent = '';
|
||||
els.status.classList.remove('info-message', 'warning-message', 'success-message');
|
||||
els.status.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showStatus(message, type = 'info') {
|
||||
if (!els.status) return;
|
||||
els.status.textContent = message;
|
||||
els.status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message');
|
||||
els.status.classList.add(`${type}-message`);
|
||||
}
|
||||
|
||||
function showProgress() {
|
||||
els.progress.classList.remove('hidden');
|
||||
els.progressText.textContent = 'Processing...';
|
||||
els.progressFill.style.width = '50%';
|
||||
els.processBtn.disabled = true;
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
els.progress.classList.add('hidden');
|
||||
els.progressFill.style.width = '0%';
|
||||
els.processBtn.disabled = false;
|
||||
}
|
||||
|
||||
function createFolderInputGroup(field, { onBrowse }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'export-section';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', fieldElId(field.name));
|
||||
label.textContent = `${field.label}:`;
|
||||
wrapper.appendChild(label);
|
||||
|
||||
const group = document.createElement('div');
|
||||
group.className = 'folder-input-group';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = fieldElId(field.name);
|
||||
input.placeholder = field.placeholder || 'Choose...';
|
||||
input.readOnly = true;
|
||||
group.appendChild(input);
|
||||
|
||||
const browseBtn = document.createElement('button');
|
||||
browseBtn.type = 'button';
|
||||
browseBtn.textContent = field.type === 'folder' ? 'Browse Folder' : 'Browse';
|
||||
browseBtn.addEventListener('click', () => onBrowse(input));
|
||||
group.appendChild(browseBtn);
|
||||
|
||||
wrapper.appendChild(group);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderFileField(field) {
|
||||
return createFolderInputGroup(field, {
|
||||
onBrowse: (input) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = field.accept || '*';
|
||||
fileInput.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) input.value = file.path;
|
||||
};
|
||||
fileInput.click();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderSaveField(field) {
|
||||
return createFolderInputGroup(field, {
|
||||
onBrowse: (input) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.nwsaveas = true;
|
||||
fileInput.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) input.value = file.path;
|
||||
};
|
||||
fileInput.click();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderFolderField(field) {
|
||||
return createFolderInputGroup(field, {
|
||||
onBrowse: () => {
|
||||
ipcRenderer.send('select-folder', FOLDER_PICK_TYPE);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderNumberField(field) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'export-section';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', fieldElId(field.name));
|
||||
label.textContent = `${field.label}:`;
|
||||
wrapper.appendChild(label);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.id = fieldElId(field.name);
|
||||
if (field.min !== undefined) input.min = field.min;
|
||||
if (field.max !== undefined) input.max = field.max;
|
||||
if (field.step !== undefined) input.step = field.step;
|
||||
if (field.default !== undefined) input.value = field.default;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderSelectField(field) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'export-section';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', fieldElId(field.name));
|
||||
label.textContent = `${field.label}:`;
|
||||
wrapper.appendChild(label);
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.id = fieldElId(field.name);
|
||||
field.options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
if (opt === field.default) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
wrapper.appendChild(select);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function updateMergeFilesList(listContainer) {
|
||||
listContainer.innerHTML = '';
|
||||
mergeFilePaths.forEach((filePath, index) => {
|
||||
const fileEntry = document.createElement('div');
|
||||
fileEntry.className = 'file-entry';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = filePath.split(/[\\/]/).pop();
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'remove-file';
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
mergeFilePaths.splice(index, 1);
|
||||
updateMergeFilesList(listContainer);
|
||||
});
|
||||
fileEntry.appendChild(name);
|
||||
fileEntry.appendChild(removeBtn);
|
||||
listContainer.appendChild(fileEntry);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFilesField(field) {
|
||||
mergeFilePaths = [];
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'export-section';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${field.label}:`;
|
||||
wrapper.appendChild(label);
|
||||
|
||||
const listContainer = document.createElement('div');
|
||||
listContainer.className = 'file-list';
|
||||
listContainer.id = fieldElId(field.name);
|
||||
wrapper.appendChild(listContainer);
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.textContent = '+ Add File';
|
||||
addBtn.addEventListener('click', () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = field.accept || '*';
|
||||
fileInput.multiple = true;
|
||||
fileInput.onchange = (e) => {
|
||||
Array.from(e.target.files).forEach((file) => {
|
||||
if (!mergeFilePaths.includes(file.path)) {
|
||||
mergeFilePaths.push(file.path);
|
||||
}
|
||||
});
|
||||
updateMergeFilesList(listContainer);
|
||||
};
|
||||
fileInput.click();
|
||||
});
|
||||
wrapper.appendChild(addBtn);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderFields() {
|
||||
const kindConfig = MEDIA_KIND_CONFIG[currentKind];
|
||||
const opKey = els.operationSelect.value;
|
||||
const opConfig = kindConfig.operations[opKey];
|
||||
|
||||
els.fieldsContainer.innerHTML = '';
|
||||
|
||||
if (opConfig.help) {
|
||||
els.help.textContent = opConfig.help;
|
||||
els.help.classList.remove('hidden');
|
||||
} else {
|
||||
els.help.textContent = '';
|
||||
els.help.classList.add('hidden');
|
||||
}
|
||||
|
||||
opConfig.fields.forEach((field) => {
|
||||
let fieldEl;
|
||||
switch (field.type) {
|
||||
case 'file':
|
||||
fieldEl = renderFileField(field);
|
||||
break;
|
||||
case 'save':
|
||||
fieldEl = renderSaveField(field);
|
||||
break;
|
||||
case 'folder':
|
||||
fieldEl = renderFolderField(field);
|
||||
break;
|
||||
case 'number':
|
||||
fieldEl = renderNumberField(field);
|
||||
break;
|
||||
case 'select':
|
||||
fieldEl = renderSelectField(field);
|
||||
break;
|
||||
case 'files':
|
||||
fieldEl = renderFilesField(field);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
els.fieldsContainer.appendChild(fieldEl);
|
||||
});
|
||||
}
|
||||
|
||||
function collectOperationData(opConfig) {
|
||||
const data = {};
|
||||
|
||||
for (const field of opConfig.fields) {
|
||||
if (field.type === 'files') {
|
||||
data[field.name] = [...mergeFilePaths];
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = document.getElementById(fieldElId(field.name));
|
||||
if (!input) continue;
|
||||
|
||||
if (field.type === 'number') {
|
||||
const raw = String(input.value).trim();
|
||||
if (raw === '') {
|
||||
if (field.optional) {
|
||||
data[field.name] = null;
|
||||
continue;
|
||||
}
|
||||
return { error: `${field.label} is required.` };
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return { error: `${field.label} must be a valid number.` };
|
||||
}
|
||||
data[field.name] = num;
|
||||
} else {
|
||||
data[field.name] = input.value;
|
||||
}
|
||||
}
|
||||
|
||||
if ('inputPath' in data && !data.inputPath) {
|
||||
return { error: 'Select an input file.' };
|
||||
}
|
||||
if ('inputPaths' in data && (!data.inputPaths || data.inputPaths.length < 2)) {
|
||||
return { error: 'Select at least 2 input files.' };
|
||||
}
|
||||
if ('outputPath' in data && !data.outputPath) {
|
||||
return { error: 'Select an output file.' };
|
||||
}
|
||||
if ('outputDir' in data && !data.outputDir) {
|
||||
return { error: 'Select an output folder.' };
|
||||
}
|
||||
if ('width' in data && 'height' in data && data.width === null && data.height === null) {
|
||||
return { error: 'Provide at least one of Width or Height.' };
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function handleProcess() {
|
||||
const kindConfig = MEDIA_KIND_CONFIG[currentKind];
|
||||
const opKey = els.operationSelect.value;
|
||||
const opConfig = kindConfig.operations[opKey];
|
||||
|
||||
const { data, error } = collectOperationData(opConfig);
|
||||
if (error) {
|
||||
showStatus(error, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
clearStatus();
|
||||
showProgress();
|
||||
|
||||
try {
|
||||
const result = await ipcRenderer.invoke(kindConfig.channel, { operation: opKey, data });
|
||||
hideProgress();
|
||||
if (result && result.success) {
|
||||
showStatus(
|
||||
`Success: ${result.outputPath || result.outputDir || 'Operation completed.'}`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showStatus(`Error: ${(result && result.error) || 'Operation failed.'}`, 'warning');
|
||||
}
|
||||
} catch (err) {
|
||||
hideProgress();
|
||||
showStatus(`Error: ${err.message}`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function hideDialog() {
|
||||
if (modalManager) modalManager.close();
|
||||
clearStatus();
|
||||
hideProgress();
|
||||
}
|
||||
|
||||
function showMediaOperationsDialog(kind) {
|
||||
if (!MEDIA_KIND_CONFIG[kind]) {
|
||||
throw new Error(`Unknown media kind: ${kind}`);
|
||||
}
|
||||
|
||||
ensureDialog();
|
||||
currentKind = kind;
|
||||
|
||||
const kindConfig = MEDIA_KIND_CONFIG[kind];
|
||||
els.title.textContent = kindConfig.title;
|
||||
|
||||
els.operationSelect.innerHTML = '';
|
||||
Object.entries(kindConfig.operations).forEach(([key, op]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = op.label;
|
||||
els.operationSelect.appendChild(option);
|
||||
});
|
||||
|
||||
clearStatus();
|
||||
hideProgress();
|
||||
renderFields();
|
||||
modalManager.open();
|
||||
}
|
||||
|
||||
module.exports = { showMediaOperationsDialog };
|
||||
Reference in New Issue
Block a user