/**
* Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro
* recorder/player. Every step maps to a known editor command by CSS
* selector; recording captures clicks on those selectors, and play
* replays them in order. Named actions persist to localStorage.
*
* The module owns no editor state: it only dispatches clicks on the
* selectors it is given.
*/
export const ACTION_COMMANDS = Object.freeze([
['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'],
['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'],
['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'],
['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'],
['fill', 'Fill Foreground', '[data-image-action="fill"]'],
['merge-down', 'Merge Down', '#ge-merge-down'],
['flatten', 'Flatten', '#ge-flatten'],
['select-all', 'Select All', '[data-selection-action="all"]'],
['deselect', 'Deselect', '[data-selection-action="deselect"]'],
['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'],
['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'],
['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'],
['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'],
['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'],
['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'],
['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'],
['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'],
['rembg', 'Remove Background', '[data-tool-option="rembg"]'],
['inpaint', 'Inpaint', '[data-tool="inpaint"]'],
]);
const STORAGE_KEY = 'ge-actions';
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function commandById(id) {
return ACTION_COMMANDS.find(command => command[0] === id) || null;
}
export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) {
let steps = [];
let recording = false;
let saved = loadSaved();
const body = el('div', 'ge-panel-body ge-actions-body');
const nameRow = el('div', 'ge-actions-name-row');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Action name';
nameInput.className = 'ge-actions-name';
const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save');
saveButton.type = 'button';
nameRow.append(nameInput, saveButton);
const addRow = el('div', 'ge-actions-add-row');
const addSelect = document.createElement('select');
addSelect.className = 'ge-actions-command';
ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id)));
const addButton = el('button', 'ge-btn ge-btn-sm', 'Add');
addButton.type = 'button';
addRow.append(addSelect, addButton);
const controls = el('div', 'ge-actions-controls');
const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record');
recordButton.type = 'button';
const playButton = el('button', 'ge-btn ge-btn-sm', 'Play');
playButton.type = 'button';
const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear');
clearButton.type = 'button';
controls.append(recordButton, playButton, clearButton);
const batchRow = el('div', 'ge-actions-batch-row');
const batchSelect = document.createElement('select');
batchSelect.className = 'ge-actions-batch-select';
const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch');
batchButton.type = 'button';
batchButton.title = 'Apply the selected action to every layer';
const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs');
batchDocsButton.type = 'button';
batchDocsButton.title = 'Apply the selected action to every open document';
batchRow.append(batchSelect, batchButton, batchDocsButton);
const list = el('div', 'ge-actions-list');
const savedList = el('div', 'ge-actions-saved');
body.append(nameRow, addRow, controls, batchRow, list, savedList);
function renderList() {
list.innerHTML = '';
if (!steps.length) {
list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.'));
return;
}
steps.forEach((step, index) => {
const command = commandById(step);
const row = el('div', 'ge-actions-step', `${index + 1}${command ? command[1] : step}`);
const remove = el('button', 'ge-action-remove', '✕');
remove.type = 'button';
remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); });
row.appendChild(remove);
list.appendChild(row);
});
}
function renderSaved() {
savedList.innerHTML = '';
syncBatchSelect();
const names = Object.keys(saved);
if (!names.length) return;
savedList.appendChild(el('div', 'ge-panel-title', 'SAVED'));
names.forEach(name => {
const row = el('div', 'ge-actions-saved-row');
const loadButton = el('button', 'ge-btn ge-btn-sm', name);
loadButton.type = 'button';
loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); });
const deleteButton = el('button', 'ge-action-remove', '✕');
deleteButton.type = 'button';
deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); });
row.append(loadButton, deleteButton);
savedList.appendChild(row);
});
}
function persist() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ }
}
function syncBatchSelect() {
const names = Object.keys(saved);
const previous = batchSelect.value;
batchSelect.innerHTML = '';
if (!names.length) {
batchSelect.appendChild(new Option('No saved actions', ''));
batchButton.disabled = true;
batchDocsButton.disabled = true;
return;
}
batchButton.disabled = false;
batchDocsButton.disabled = false;
names.forEach(name => batchSelect.appendChild(new Option(name, name)));
if (names.includes(previous)) batchSelect.value = previous;
}
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch { return {}; }
}
function runStep(step) {
const command = commandById(step);
if (!command) return;
document.querySelector(command[2])?.click();
}
function play() {
steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60));
showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`);
}
async function runOnAllLayers() {
const name = batchSelect.value;
const action = name && saved[name] ? saved[name].slice() : null;
if (!action || !action.length) { showToast('Select a saved action to batch'); return; }
if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; }
const layers = getLayers() || [];
if (layers.length < 1) { showToast('No layers to batch'); return; }
showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`);
for (const layer of layers) {
selectLayer(layer.id);
for (const step of action) {
runStep(step);
await new Promise(resolve => setTimeout(resolve, 80));
}
}
showToast('Batch complete');
}
function onDocumentClick(event) {
if (!recording) return;
const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2]));
if (match && !steps.includes(match[0])) {
steps.push(match[0]);
renderList();
}
}
function toggleRecord() {
recording = !recording;
recordButton.classList.toggle('active', recording);
recordButton.textContent = recording ? 'Stop' : 'Record';
showToast(recording ? 'Recording actions…' : 'Recording stopped');
}
addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); });
recordButton.addEventListener('click', toggleRecord);
playButton.addEventListener('click', play);
batchButton.addEventListener('click', () => { runOnAllLayers(); });
batchDocsButton.addEventListener('click', async () => {
const name = batchSelect.value;
if (!name) { showToast('Select a saved action to batch'); return; }
try {
const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name });
const results = (response && response.result) || [];
const ok = results.filter((entry) => entry.ok).length;
showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`);
} catch (error) {
showToast('Batch docs failed: ' + (error?.message || error));
}
});
clearButton.addEventListener('click', () => { steps = []; renderList(); });
saveButton.addEventListener('click', () => {
const name = (nameInput.value || '').trim();
if (!name) return;
saved[name] = steps.slice();
persist();
renderSaved();
showToast(`Saved action "${name}"`);
});
document.addEventListener('click', onDocumentClick, true);
const element = el('div', 'ge-panel');
element.id = 'ge-panel-actions';
element.appendChild(el('div', 'ge-panel-title', 'ACTIONS'));
element.appendChild(body);
renderList();
renderSaved();
return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } };
}