/**
* Layer panel renderer — rebuilds the right-side layer list from
* `state.layers` every time it's called. The full row tree per layer:
*
* parent row
* [drag handle] [eye] [name] [opacity slider] [FX] [dup] [mask] [merge-down] [×]
* adjustment sub-rows (FX entries)
* [eye] [name+icon] [opacity slider] [merge] [×]
* mask sub-rows
* [eye] [name] [merge-up?] [×]
*
* Reads/writes shared `state` directly (layers, activeLayerId,
* layerOffsets, imgWidth, imgHeight, lassoPoints/lassoActive,
* wandMask, maskCanvas/maskCtx, nextLayerId). Function deps are
* orchestration callbacks still living in galleryEditor.js.
*
* Returns `{ render }` so the recursive self-call works via closure
* over `render` rather than module-state lookup.
*
* @param {{
* composite: () => void,
* saveState: (label?: string) => void,
* showLayerThumb: (rowEl: HTMLElement, layer: object) => void,
* hideLayerThumb: () => void,
* loadLayerAlphaAsSelection: (layer: object) => void,
* loadMaskAsSelection?: (layer: object, mask: object) => void,
* getDocumentSelection?: () => HTMLCanvasElement | null,
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
* createLayer: (name: string, w: number, h: number) => object,
* renderLayer?: (layer: object) => HTMLCanvasElement,
* replacePlacedLayer?: (layer: object) => void,
* rasterizePlacedLayer?: (layer: object) => void,
* lassoToMask: () => void,
* wandToMask: () => void,
* getActiveMaskLayer: () => object | null,
* syncFxPanelToActiveLayerIfPresent: () => void,
* dragSortModule: object | null,
* uiModule: object | null,
* }} deps
*/
import { state } from './state.js';
import { rasterizeTextLayer } from './text-layer.js';
import { rasterizeShapeLayer } from './shape-layer.js';
import {
clonePlacedData,
createPlacedData,
rasterizePlacedLayer as rasterizePlacedPixels,
renderPlacedLayer,
} from './placed-layer.js';
import {
layerHasAdjustments,
isLayerEmpty,
isMaskCanvasEmpty,
adjLayerLabel,
ADJ_ICONS,
} from './layer-helpers.js';
import { applyAdjustment } from './fx/pixel-pass.js';
import { normalizeEffect, effectLabel } from './effects.js';
import { mergeLayerDownAtIndex } from './wire-merge-buttons.js';
import {
normalizeLayerSelection,
selectedLayers,
selectAllLayers,
selectLayerRange,
selectOnlyLayer,
toggleLayerSelection,
} from './layer-selection.js';
import {
allLayerIdsInGroup,
createGroupFromSelection,
groupAncestors,
groupDepth,
groupSiblingUnits,
groupsForLayer,
isLayerPixelLocked,
layerHasAnyLock,
normalizeLayerGroups,
normalizeLayerLocks,
reorderGroupAmongSiblings,
ungroupLayers,
} from './layer-groups.js';
import {
canToggleLayerClipping,
normalizeLayerClipping,
} from './layer-clipping.js';
import {
alignSelectedLayers,
distributeSelectedLayers,
} from './layer-geometry.js';
import { invertMaskInPlace, normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js';
const EYE_OPEN = '';
const EYE_OFF = '';
const EYE_OPEN_SM = '';
const EYE_OFF_SM = '';
export function createLayerPanelRenderer(deps) {
const {
composite, saveState, showLayerThumb, hideLayerThumb,
loadLayerAlphaAsSelection, loadMaskAsSelection, getDocumentSelection,
openFxPopup, editAdjLayer, editRetainedEffect, addEffectMask, rasterizeEffects,
createLayer, renderLayer, replacePlacedLayer, rasterizePlacedLayer,
lassoToMask, wandToMask, getActiveMaskLayer,
syncFxPanelToActiveLayerIfPresent, onSelectLayer,
dragSortModule, uiModule,
} = deps;
function shouldIgnoreLayerTap() {
return Date.now() < (window.__geSuppressLayerTapUntil || 0);
}
function rowTargets(layer) {
const selected = selectedLayers(state);
return selected.length > 1 && selected.some(item => item.id === layer.id)
? selected
: [layer];
}
function createInlineThumbnail(source, title, extraClass = '') {
const thumb = document.createElement('canvas');
thumb.className = `ge-layer-inline-thumb${extraClass ? ` ${extraClass}` : ''}`;
thumb.width = 68;
thumb.height = 52;
thumb.title = title;
thumb.setAttribute('role', 'img');
thumb.setAttribute('aria-label', title);
const ctx = thumb.getContext('2d');
if (!ctx || !source?.width || !source?.height) return thumb;
const tile = 8;
for (let y = 0; y < thumb.height; y += tile) {
for (let x = 0; x < thumb.width; x += tile) {
ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#464646' : '#303030';
ctx.fillRect(x, y, tile, tile);
}
}
const scale = Math.min(thumb.width / source.width, thumb.height / source.height);
const width = source.width * scale;
const height = source.height * scale;
ctx.drawImage(source, (thumb.width - width) / 2, (thumb.height - height) / 2, width, height);
return thumb;
}
function createMaskThumbnail(mask) {
return createInlineThumbnail(mask.canvas, `${mask.name || 'Mask'} preview`, 'ge-mask-inline-thumb');
}
function applyLayerMask(layer, mask) {
if (!layer?.canvas || !mask?.canvas || mask.mode !== 'layer') return;
saveState(`Apply mask "${mask.name || 'Layer Mask'}"`);
const baked = document.createElement('canvas');
baked.width = layer.canvas.width;
baked.height = layer.canvas.height;
const ctx = baked.getContext('2d');
if (!ctx) return;
ctx.drawImage(layer.canvas, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.globalAlpha = normalizeMaskDensity(mask.density);
const feather = normalizeMaskFeather(mask.feather);
ctx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
if (mask.space === 'document') {
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.drawImage(mask.canvas, -(Number(offset.x) || 0), -(Number(offset.y) || 0));
} else {
ctx.drawImage(mask.canvas, Number(mask.offset?.x) || 0, Number(mask.offset?.y) || 0);
}
ctx.globalAlpha = 1;
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
layer.ctx.drawImage(baked, 0, 0);
layer.masks = (layer.masks || []).filter(item => item.id !== mask.id);
if (layer.activeMaskId === mask.id) layer.activeMaskId = null;
state.maskCanvas = null;
state.maskCtx = null;
state.maskInspectMode = false;
composite();
render();
}
function createMaskPropertiesControl(mask, label, onApply = null) {
const details = document.createElement('details');
details.className = 'ge-mask-properties';
details.addEventListener('click', event => event.stopPropagation());
const summary = document.createElement('summary');
summary.title = `${label} properties`;
summary.setAttribute('aria-label', summary.title);
summary.innerHTML = '';
const panel = document.createElement('div');
panel.className = 'ge-mask-properties-panel';
details.addEventListener('toggle', () => {
if (!details.open) return;
const rect = summary.getBoundingClientRect();
const width = 190;
panel.style.left = `${Math.max(6, Math.min(window.innerWidth - width - 6, rect.right - width))}px`;
panel.style.top = `${Math.min(window.innerHeight - 150, rect.bottom + 4)}px`;
});
const makeRange = (className, title, min, max, step, value, update, historyLabel) => {
const row = document.createElement('label');
row.className = 'ge-mask-property-row';
const text = document.createElement('span');
text.textContent = title;
const output = document.createElement('span');
const input = document.createElement('input');
input.type = 'range';
input.className = className;
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
input.title = title;
input.setAttribute('aria-label', title);
output.textContent = className === 'ge-mask-density' ? `${value}%` : `${value}px`;
let initial = input.value;
let saved = false;
input.addEventListener('input', event => {
event.stopPropagation();
if (!saved && input.value !== initial) {
saveState(historyLabel);
saved = true;
}
const numeric = Number(input.value);
output.textContent = className === 'ge-mask-density' ? `${numeric}%` : `${numeric}px`;
update(numeric);
composite();
});
input.addEventListener('change', event => {
event.stopPropagation();
initial = input.value;
saved = false;
});
row.append(text, input, output);
return row;
};
panel.appendChild(makeRange(
'ge-mask-density', 'Density', 0, 100, 1,
Math.round(normalizeMaskDensity(mask.density) * 100),
value => { mask.density = value / 100; },
`Adjust ${label} density`,
));
panel.appendChild(makeRange(
'ge-mask-feather', 'Feather', 0, 200, 1,
Math.round(normalizeMaskFeather(mask.feather)),
value => { mask.feather = value; },
`Adjust ${label} feather`,
));
if (onApply) {
const apply = document.createElement('button');
apply.type = 'button';
apply.className = 'ge-mask-apply-btn';
apply.textContent = 'Apply mask';
apply.title = 'Bake this mask into the layer and remove the mask';
apply.addEventListener('click', event => {
event.stopPropagation();
onApply();
});
panel.appendChild(apply);
}
details.append(summary, panel);
return details;
}
function createMaskInspectionControl(mask, owner, isGroup = false) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ge-layer-btn ge-mask-inspect-btn';
button.title = 'Inspect mask';
button.setAttribute('aria-label', button.title);
const active = state.maskInspectMode && (isGroup
? owner.activeMaskId === mask.id
: owner.activeMaskId === mask.id);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
if (active) button.classList.add('active');
button.innerHTML = '';
button.addEventListener('click', event => {
event.stopPropagation();
if (isGroup) {
state.activeGroupId = owner.id;
owner.activeMaskId = mask.id;
state.selectedLayerIds = allLayerIdsInGroup(state, owner);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1] || state.activeLayerId;
state.selectionAnchorId = state.activeLayerId;
} else {
state.activeGroupId = null;
owner.activeMaskId = mask.id;
selectOnlyLayer(state, owner.id);
}
state.maskCanvas = mask.canvas;
state.maskCtx = mask.ctx;
state.maskInspectMode = !state.maskInspectMode;
composite();
render();
});
return button;
}
function closeLayerLockMenu() {
const menu = document.getElementById('ge-layer-lock-menu');
if (!menu) return;
if (menu._awayHandler) document.removeEventListener('pointerdown', menu._awayHandler, true);
if (menu._escHandler) document.removeEventListener('keydown', menu._escHandler, true);
menu.remove();
}
function openLayerLockMenu(layer, anchor) {
closeLayerLockMenu();
const targets = rowTargets(layer);
const menu = document.createElement('div');
menu.id = 'ge-layer-lock-menu';
menu.className = 'ge-layer-lock-menu ge-frosted';
const options = [
{ key: 'all', label: 'Lock all' },
{ key: 'pixels', label: 'Image pixels' },
{ key: 'transparency', label: 'Transparent pixels' },
{ key: 'position', label: 'Position' },
];
const checked = key => targets.every(item => key === 'all'
? !!item.locked
: normalizeLayerLocks(item.locks)[key]);
for (const option of options) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ge-layer-lock-option';
button.dataset.lockType = option.key;
button.setAttribute('role', 'menuitemcheckbox');
button.setAttribute('aria-checked', String(checked(option.key)));
button.innerHTML = `${checked(option.key) ? '✓' : ''}${option.label}`;
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const next = !checked(option.key);
const scope = targets.length > 1 ? `${targets.length} layers` : `"${layer.name}"`;
saveState(`${next ? 'Lock' : 'Unlock'} ${option.label.toLowerCase()} on ${scope}`);
for (const item of targets) {
if (option.key === 'all') item.locked = next;
else {
item.locks = normalizeLayerLocks(item.locks);
item.locks[option.key] = next;
}
}
closeLayerLockMenu();
render();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = anchor.getBoundingClientRect();
const mobile = window.matchMedia('(max-width: 700px)').matches;
if (mobile) {
menu.style.left = '8px';
menu.style.right = '8px';
menu.style.bottom = '8px';
} else {
const width = menu.offsetWidth || 176;
const height = menu.offsetHeight || 132;
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - width - 8, rect.right - width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - height - 8, rect.bottom + 4))}px`;
}
menu._awayHandler = event => {
if (!menu.contains(event.target) && event.target !== anchor) closeLayerLockMenu();
};
menu._escHandler = event => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
closeLayerLockMenu();
anchor.focus();
};
setTimeout(() => document.addEventListener('pointerdown', menu._awayHandler, true), 0);
document.addEventListener('keydown', menu._escHandler, true);
menu.querySelector('button')?.focus();
}
function wireGroupDrag(handle, row, group) {
handle.addEventListener('pointerdown', event => {
if (event.button !== 0 || group.locked) return;
const units = groupSiblingUnits(state, group.parentId || null);
if (units.length < 2) return;
const list = row.parentElement;
const rowForUnit = unit => Array.from(list.children).find(element =>
unit.type === 'group'
? element.dataset.groupId === unit.id
: element.dataset.layerId === unit.id
);
const visibleOthers = [...units].reverse()
.filter(unit => !(unit.type === 'group' && unit.id === group.id))
.map(unit => ({ unit, row: rowForUnit(unit) }))
.filter(item => item.row);
if (!visibleOthers.length) return;
event.preventDefault();
event.stopPropagation();
const startY = event.clientY;
let dragging = false;
let targetIndex = units.findIndex(unit => unit.type === 'group' && unit.id === group.id);
const line = document.createElement('div');
line.className = 'ge-group-drop-line';
const move = moveEvent => {
if (!dragging && Math.abs(moveEvent.clientY - startY) < 4) return;
if (!dragging) {
dragging = true;
row.classList.add('group-dragging');
if (getComputedStyle(list).position === 'static') list.style.position = 'relative';
list.appendChild(line);
}
moveEvent.preventDefault();
const visualIndex = visibleOthers.filter(item => {
const rect = item.row.getBoundingClientRect();
return moveEvent.clientY > rect.top + rect.height / 2;
}).length;
targetIndex = units.length - 1 - visualIndex;
const listRect = list.getBoundingClientRect();
const anchor = visibleOthers[Math.min(visualIndex, visibleOthers.length - 1)];
const anchorRect = anchor.row.getBoundingClientRect();
const top = visualIndex < visibleOthers.length ? anchorRect.top : anchorRect.bottom;
line.style.top = `${top - listRect.top + list.scrollTop}px`;
};
const end = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', end);
document.removeEventListener('pointercancel', end);
row.classList.remove('group-dragging');
line.remove();
window.__geSuppressLayerTapUntil = Date.now() + 250;
if (!dragging) return;
const currentIndex = units.findIndex(unit => unit.type === 'group' && unit.id === group.id);
if (targetIndex === currentIndex) return;
saveState(`Reorder group "${group.name}"`);
const result = reorderGroupAmongSiblings(state, group.id, targetIndex);
if (!result?.changed) return;
normalizeLayerClipping(state);
composite();
render();
};
document.addEventListener('pointermove', move, { passive: false });
document.addEventListener('pointerup', end);
document.addEventListener('pointercancel', end);
});
}
async function deleteLayers(layers) {
const targets = layers.filter(layer => state.layers.includes(layer));
if (!targets.length || targets.length >= state.layers.length) return false;
if (targets.some(layer => layer.isBase) && uiModule?.styledConfirm) {
const ok = await uiModule.styledConfirm(
`Delete ${targets.length === 1 ? 'the original photo layer' : `${targets.length} selected layers, including the original`}? Ctrl+Z brings ${targets.length === 1 ? 'it' : 'them'} back.`,
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true },
);
if (!ok) return false;
}
saveState(targets.length === 1
? `Delete layer "${targets[0].name}"`
: `Delete ${targets.length} layers`);
const ids = new Set(targets.map(layer => layer.id));
state.layers = state.layers.filter(layer => !ids.has(layer.id));
for (const id of ids) state.layerOffsets.delete(id);
normalizeLayerClipping(state);
const fallback = state.layers[state.layers.length - 1];
selectOnlyLayer(state, fallback.id);
state.maskCanvas = null;
state.maskCtx = null;
composite();
render();
return true;
}
function syncSelectionUi() {
const selectedIds = new Set(normalizeLayerSelection(state));
const selected = selectedLayers(state);
document.querySelectorAll('.ge-layers-list .ge-layer-item[data-layer-id]').forEach(row => {
const id = row.dataset.layerId;
const layer = state.layers.find(item => item.id === id);
const active = id === state.activeLayerId;
const maskActive = !!(active && layer?.activeMaskId && layer.masks?.some(mask => mask.id === layer.activeMaskId));
row.classList.toggle('active', active && !maskActive);
row.classList.toggle('active-parent', active && maskActive);
row.classList.toggle('selected', selectedIds.has(id));
});
const bar = document.getElementById('ge-layer-selection-bar');
const count = document.getElementById('ge-layer-selection-count');
if (bar) bar.hidden = selected.length < 2;
if (count) count.textContent = `${selected.length} layers`;
const deleteButton = document.getElementById('ge-selected-delete');
if (deleteButton) deleteButton.disabled = selected.length >= state.layers.length;
const groupButton = document.getElementById('ge-group-selected');
if (groupButton) groupButton.disabled = selected.length < 2;
}
function closeAlignMenu() {
const menu = document.getElementById('ge-layer-align-menu');
if (!menu) return;
menu._awayHandler && document.removeEventListener('pointerdown', menu._awayHandler, true);
menu._escHandler && document.removeEventListener('keydown', menu._escHandler, true);
menu.remove();
}
function openAlignMenu(anchor) {
closeAlignMenu();
const menu = document.createElement('div');
menu.id = 'ge-layer-align-menu';
menu.className = 'ge-layer-lock-menu ge-layer-align-menu ge-frosted';
const options = [
['left', 'Align left'], ['center', 'Align center'], ['right', 'Align right'],
['top', 'Align top'], ['middle', 'Align middle'], ['bottom', 'Align bottom'],
['horizontal', 'Distribute horizontally'], ['vertical', 'Distribute vertically'],
];
for (const [action, label] of options) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ge-layer-lock-option';
button.textContent = label;
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const selected = selectedLayers(state);
saveState(`${label} (${selected.length} layers)`);
const changed = action === 'horizontal' || action === 'vertical'
? distributeSelectedLayers(state, selected, action)
: alignSelectedLayers(state, selected, action);
if (changed) {
composite();
render();
}
closeAlignMenu();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = anchor.getBoundingClientRect();
const mobile = window.matchMedia('(max-width: 700px)').matches;
if (mobile) {
menu.style.left = '8px';
menu.style.right = '8px';
menu.style.bottom = '8px';
} else {
const width = menu.offsetWidth || 210;
const height = menu.offsetHeight || 220;
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - width - 8, rect.right - width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - height - 8, rect.bottom + 4))}px`;
}
menu._awayHandler = event => {
if (!menu.contains(event.target) && event.target !== anchor) closeAlignMenu();
};
menu._escHandler = event => {
if (event.key !== 'Escape') return;
event.preventDefault();
closeAlignMenu();
anchor.focus();
};
setTimeout(() => document.addEventListener('pointerdown', menu._awayHandler, true), 0);
document.addEventListener('keydown', menu._escHandler, true);
menu.querySelector('button')?.focus();
}
function wireSelectionActions() {
const selectAll = document.getElementById('ge-select-all-layers');
if (selectAll && !selectAll.dataset.wired) {
selectAll.dataset.wired = 'true';
selectAll.addEventListener('click', () => {
selectAllLayers(state);
render();
});
}
const groupSelected = document.getElementById('ge-group-selected');
if (groupSelected && !groupSelected.dataset.wired) {
groupSelected.dataset.wired = 'true';
groupSelected.addEventListener('click', () => {
const selected = selectedLayers(state);
if (selected.length < 2) return;
saveState(`Group ${selected.length} layers`);
const group = createGroupFromSelection(state);
if (!group) return;
normalizeLayerClipping(state);
composite();
render();
uiModule?.showToast?.(`${group.name} created`);
});
}
const visibility = document.getElementById('ge-selected-visibility');
if (visibility && !visibility.dataset.wired) {
visibility.dataset.wired = 'true';
visibility.addEventListener('click', () => {
const selected = selectedLayers(state);
if (selected.length < 2) return;
const visible = !selected.every(layer => layer.visible !== false);
saveState(`${visible ? 'Show' : 'Hide'} ${selected.length} layers`);
for (const layer of selected) layer.visible = visible;
composite();
render();
});
}
const lock = document.getElementById('ge-selected-lock');
if (lock && !lock.dataset.wired) {
lock.dataset.wired = 'true';
lock.addEventListener('click', () => {
const selected = selectedLayers(state);
if (selected.length < 2) return;
const locked = !selected.every(layer => layer.locked);
saveState(`${locked ? 'Lock' : 'Unlock'} ${selected.length} layers`);
for (const layer of selected) layer.locked = locked;
render();
});
}
const align = document.getElementById('ge-selected-align');
if (align && !align.dataset.wired) {
align.dataset.wired = 'true';
align.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
openAlignMenu(align);
});
}
const remove = document.getElementById('ge-selected-delete');
if (remove && !remove.dataset.wired) {
remove.dataset.wired = 'true';
remove.addEventListener('click', () => deleteLayers(selectedLayers(state)));
}
}
function appendGroupRow(list, group, depth = 0) {
const row = document.createElement('div');
row.className = 'ge-layer-group-row' + (state.activeGroupId === group.id ? ' active' : '');
row.dataset.groupId = group.id;
row.style.setProperty('--group-depth', String(depth));
row.tabIndex = 0;
row.setAttribute('aria-label', `${group.name} group`);
row.setAttribute('aria-pressed', state.activeGroupId === group.id ? 'true' : 'false');
const handle = document.createElement('span');
handle.className = 'ge-layer-drag ge-layer-group-drag';
handle.title = group.locked ? 'Unlock group to reorder' : 'Drag group to reorder';
handle.setAttribute('aria-label', handle.title);
handle.innerHTML = '';
wireGroupDrag(handle, row, group);
const collapse = document.createElement('button');
collapse.className = 'ge-layer-group-toggle' + (group.collapsed ? '' : ' expanded');
collapse.title = group.collapsed ? 'Expand group' : 'Collapse group';
collapse.setAttribute('aria-label', collapse.title);
collapse.innerHTML = '';
collapse.addEventListener('click', event => {
event.stopPropagation();
saveState(`${group.collapsed ? 'Expand' : 'Collapse'} "${group.name}"`);
group.collapsed = !group.collapsed;
render();
});
const visibility = document.createElement('button');
visibility.className = 'ge-layer-vis' + (group.visible ? ' visible' : '');
visibility.innerHTML = group.visible ? EYE_OPEN : EYE_OFF;
visibility.title = group.visible ? 'Hide group' : 'Show group';
visibility.addEventListener('click', event => {
event.stopPropagation();
saveState(`${group.visible ? 'Hide' : 'Show'} group "${group.name}"`);
group.visible = !group.visible;
composite();
render();
});
const groupLayers = allLayerIdsInGroup(state, group)
.map(id => state.layers.find(layer => layer.id === id))
.filter(layer => layer && layer.visible !== false);
const groupPreview = state.groupCompositeCanvases?.get?.(group.id)
|| groupLayers.slice().reverse()
.map(layer => renderLayer?.(layer) || layer.canvas)
.find(canvas => canvas?.width && canvas?.height);
const groupThumb = createInlineThumbnail(groupPreview, `${group.name} preview`, 'ge-group-inline-thumb');
const name = document.createElement('span');
name.className = 'ge-layer-group-name';
name.innerHTML = '';
name.lastElementChild.textContent = group.name;
// Keep the row-selection click from re-rendering the name span between
// the two clicks that make up a double-click rename gesture.
name.addEventListener('click', event => event.stopPropagation());
name.addEventListener('dblclick', event => {
event.stopPropagation();
const input = document.createElement('input');
input.className = 'ge-layer-name-input';
input.value = group.name;
name.replaceWith(input);
input.focus();
const finish = () => {
const value = input.value.trim();
if (value && value !== group.name) {
saveState(`Rename group "${group.name}"`);
group.name = value;
}
render();
};
input.addEventListener('blur', finish, { once: true });
input.addEventListener('keydown', event => { if (event.key === 'Enter') input.blur(); });
});
const opacity = document.createElement('input');
opacity.type = 'range';
opacity.min = '0';
opacity.max = '100';
opacity.value = String(Math.round(group.opacity * 100));
opacity.className = 'ge-layer-opacity';
opacity.title = 'Group opacity';
opacity.addEventListener('pointerdown', event => {
event.stopPropagation();
state.selectedLayerIds = allLayerIdsInGroup(state, group);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1] || null;
state.selectionAnchorId = state.activeLayerId;
state.activeGroupId = group.id;
});
let opacitySaved = false;
opacity.addEventListener('input', event => {
event.stopPropagation();
const nextOpacity = Number(opacity.value) / 100;
if (Math.abs(group.opacity - nextOpacity) < 0.0001) return;
if (!opacitySaved) {
saveState(`Change opacity of group "${group.name}"`);
opacitySaved = true;
}
group.opacity = nextOpacity;
composite();
});
opacity.addEventListener('change', () => { opacitySaved = false; });
const controls = document.createElement('div');
controls.className = 'ge-layer-controls';
const effectsButton = document.createElement('button');
effectsButton.className = 'ge-layer-btn';
effectsButton.type = 'button';
effectsButton.textContent = 'FX';
effectsButton.title = group.effects?.length ? `Edit group effects (${group.effects.length}) in Filter menu` : 'Add group effect in Filter menu';
effectsButton.addEventListener('click', event => {
event.stopPropagation();
state.activeGroupId = group.id;
state.selectedLayerIds = allLayerIdsInGroup(state, group);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1] || null;
document.getElementById('ge-filter-menu-btn')?.click();
});
const maskButton = document.createElement('button');
maskButton.className = 'ge-layer-btn ge-layer-mask-btn ge-group-mask-btn';
maskButton.title = 'Add group mask (white reveals; erase hides)';
maskButton.innerHTML = '';
maskButton.addEventListener('click', event => {
event.stopPropagation();
saveState(`Add group mask to "${group.name}"`);
const canvas = document.createElement('canvas');
canvas.width = state.imgWidth;
canvas.height = state.imgHeight;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (!Array.isArray(group.masks)) group.masks = [];
const mask = {
id: `mask-${state.nextLayerId++}`,
name: group.masks.length ? `Group Mask ${group.masks.length + 1}` : 'Group Mask',
visible: true,
mode: 'group',
space: 'document',
canvas,
ctx,
};
group.masks.push(mask);
group.activeMaskId = mask.id;
state.activeGroupId = group.id;
state.selectedLayerIds = allLayerIdsInGroup(state, group);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1];
state.maskCanvas = canvas;
state.maskCtx = ctx;
composite();
render();
uiModule?.showToast?.('Group mask added — use Eraser to hide, Brush to reveal');
});
const lock = document.createElement('button');
lock.className = 'ge-layer-btn ge-layer-lock-btn' + (group.locked ? ' active' : '');
lock.title = group.locked ? 'Unlock group' : 'Lock group';
lock.innerHTML = '';
lock.addEventListener('click', event => {
event.stopPropagation();
saveState(`${group.locked ? 'Unlock' : 'Lock'} group "${group.name}"`);
group.locked = !group.locked;
render();
});
const ungroup = document.createElement('button');
ungroup.className = 'ge-layer-btn';
ungroup.title = group.masks?.length ? 'Delete group masks before ungrouping' : 'Ungroup layers';
ungroup.disabled = !!group.masks?.length;
ungroup.innerHTML = '';
ungroup.addEventListener('click', event => {
event.stopPropagation();
saveState(`Ungroup "${group.name}"`);
ungroupLayers(state, group.id);
normalizeLayerClipping(state);
composite();
render();
});
const opacityRow = document.createElement('div');
opacityRow.className = 'ge-layer-opacity-row';
opacityRow.append(opacity);
controls.append(effectsButton, maskButton, ungroup);
row.append(handle, collapse, visibility, groupThumb, name, lock, opacityRow, controls);
row.addEventListener('click', event => {
if (event.target.closest('button,input')) return;
state.selectedLayerIds = allLayerIdsInGroup(state, group);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1];
state.selectionAnchorId = state.activeLayerId;
state.activeGroupId = group.id;
group.activeMaskId = null;
state.maskCanvas = null;
state.maskCtx = null;
document.querySelectorAll('.ge-layer-group-row').forEach(item => {
item.classList.toggle('active', item.dataset.groupId === group.id);
});
syncSelectionUi();
render();
onSelectLayer?.(state.layers.find(layer => layer.id === state.activeLayerId));
});
row.addEventListener('keydown', event => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
row.click();
});
list.appendChild(row);
// Group effects sit below the group row and above its masks. They use
// the same retained-effect controls as layer effects, except that
// rasterization and layer-local masks do not apply to a group.
for (let effectIndex = 0; effectIndex < (group.effects || []).length; effectIndex += 1) {
const effect = normalizeEffect(group.effects[effectIndex]);
group.effects[effectIndex] = effect;
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item ge-effect-sub-item ge-group-effect-sub-item';
sub.dataset.effectId = effect.id;
sub.dataset.groupEffectId = group.id;
const visibility = document.createElement('button');
visibility.className = 'ge-layer-vis' + (effect.visible ? ' visible' : '');
visibility.innerHTML = effect.visible ? EYE_OPEN_SM : EYE_OFF_SM;
visibility.title = effect.visible ? 'Hide group effect' : 'Show group effect';
visibility.addEventListener('click', event => {
event.stopPropagation();
saveState(`${effect.visible ? 'Hide' : 'Show'} group ${effectLabel(effect.type)}`);
effect.visible = !effect.visible;
composite();
render();
});
const effectName = document.createElement('span');
effectName.className = 'ge-layer-name ge-adj-sub-name';
effectName.innerHTML = '◌';
effectName.lastElementChild.textContent = effectLabel(effect.type);
effectName.title = 'Edit group effect';
effectName.addEventListener('click', event => {
event.stopPropagation();
editRetainedEffect?.(group, effect, effectName);
});
const effectOpacity = document.createElement('input');
effectOpacity.type = 'range';
effectOpacity.min = '0';
effectOpacity.max = '100';
effectOpacity.value = String(Math.round(effect.opacity * 100));
effectOpacity.className = 'ge-layer-opacity';
effectOpacity.title = 'Group effect opacity';
let effectOpacitySaved = false;
effectOpacity.addEventListener('input', event => {
event.stopPropagation();
const next = Number(effectOpacity.value) / 100;
if (Math.abs(effect.opacity - next) < 0.0001) return;
if (!effectOpacitySaved) {
saveState(`Change group ${effectLabel(effect.type)} opacity`);
effectOpacitySaved = true;
}
effect.opacity = next;
composite();
});
effectOpacity.addEventListener('change', () => { effectOpacitySaved = false; });
const effectControls = document.createElement('div');
effectControls.className = 'ge-layer-controls';
for (const [direction, symbol, title] of [[-1, '↑', 'Move group effect up'], [1, '↓', 'Move group effect down']]) {
const move = document.createElement('button');
move.className = 'ge-layer-btn';
move.type = 'button';
move.textContent = symbol;
move.title = title;
move.disabled = effectIndex + direction < 0 || effectIndex + direction >= group.effects.length;
move.addEventListener('click', event => {
event.stopPropagation();
const nextIndex = effectIndex + direction;
if (nextIndex < 0 || nextIndex >= group.effects.length) return;
saveState(`${title} ${effectLabel(effect.type)}`);
[group.effects[effectIndex], group.effects[nextIndex]] = [group.effects[nextIndex], group.effects[effectIndex]];
composite();
render();
});
effectControls.appendChild(move);
}
const remove = document.createElement('button');
remove.className = 'ge-layer-btn danger';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Delete group effect';
remove.addEventListener('click', event => {
event.stopPropagation();
saveState(`Delete group ${effectLabel(effect.type)}`);
group.effects.splice(effectIndex, 1);
composite();
render();
});
effectControls.appendChild(remove);
sub.append(visibility, effectName, effectOpacity, effectControls);
list.appendChild(sub);
}
for (const mask of group.masks || []) {
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item ge-mask-sub-item ge-group-mask-sub-item' +
(state.activeGroupId === group.id && group.activeMaskId === mask.id ? ' active' : '');
sub.dataset.groupMaskId = mask.id;
sub.style.setProperty('--group-mask-depth', String(depth + 1));
const visibility = document.createElement('button');
visibility.className = 'ge-layer-vis' + (mask.visible !== false ? ' visible' : '');
visibility.innerHTML = mask.visible !== false ? EYE_OPEN_SM : EYE_OFF_SM;
visibility.title = mask.visible !== false ? 'Disable group mask' : 'Enable group mask';
visibility.addEventListener('click', event => {
event.stopPropagation();
saveState(`${mask.visible !== false ? 'Disable' : 'Enable'} group mask "${mask.name}"`);
mask.visible = mask.visible === false;
composite();
render();
});
const name = document.createElement('span');
name.className = 'ge-layer-name ge-adj-sub-name';
name.innerHTML = '';
name.lastElementChild.textContent = mask.name || 'Group Mask';
const maskControls = document.createElement('div');
maskControls.className = 'ge-layer-controls';
const invertGroupMask = document.createElement('button');
invertGroupMask.className = 'ge-layer-btn';
invertGroupMask.title = 'Invert group mask';
invertGroupMask.setAttribute('aria-label', invertGroupMask.title);
invertGroupMask.innerHTML = '';
invertGroupMask.addEventListener('click', event => {
event.stopPropagation();
saveState(`Invert group mask "${mask.name}"`);
invertMaskInPlace(mask.canvas);
if (state.activeGroupId === group.id && group.activeMaskId === mask.id) {
state.maskCanvas = mask.canvas;
state.maskCtx = mask.ctx;
}
composite();
render();
});
maskControls.appendChild(createMaskInspectionControl(mask, group, true));
maskControls.appendChild(createMaskPropertiesControl(mask, 'Group mask'));
maskControls.appendChild(invertGroupMask);
const remove = document.createElement('button');
remove.className = 'ge-layer-btn danger';
remove.title = 'Delete group mask';
remove.textContent = '×';
remove.addEventListener('click', event => {
event.stopPropagation();
saveState(`Delete group mask "${mask.name}"`);
group.masks = group.masks.filter(item => item.id !== mask.id);
if (group.activeMaskId === mask.id) group.activeMaskId = null;
state.maskCanvas = null;
state.maskCtx = null;
composite();
render();
});
maskControls.append(remove);
sub.append(visibility, createMaskThumbnail(mask), name, maskControls);
sub.addEventListener('click', event => {
if (event.target.closest('button')) return;
event.stopPropagation();
group.activeMaskId = mask.id;
state.activeGroupId = group.id;
state.selectedLayerIds = allLayerIdsInGroup(state, group);
state.activeLayerId = state.selectedLayerIds[state.selectedLayerIds.length - 1];
state.selectionAnchorId = state.activeLayerId;
state.maskCanvas = mask.canvas;
state.maskCtx = mask.ctx;
render();
composite();
});
list.appendChild(sub);
}
}
function render() {
// FX panel mirrors the active layer's adjustments — re-sync on
// every layer event (activation, add, delete, etc).
try { syncFxPanelToActiveLayerIfPresent(); } catch {}
const list = document.getElementById('ge-layers-list');
if (!list) return;
const sharedTools = document.getElementById('ge-layer-tools');
if (sharedTools) {
// Controls are rebuilt with each row, so clear the previous active
// row's cluster before the list is rendered again.
sharedTools.innerHTML = '';
sharedTools.hidden = true;
}
const blendSelect = document.getElementById('ge-layer-blend');
normalizeLayerSelection(state);
wireSelectionActions();
const selectedLayer = state.layers.find(layer => layer.id === state.activeLayerId) || null;
const selectedGroup = normalizeLayerGroups(state).find(group => group.id === state.activeGroupId) || null;
normalizeLayerClipping(state);
if (blendSelect) {
blendSelect.disabled = !selectedLayer && !selectedGroup;
blendSelect.value = selectedGroup?.blendMode || selectedLayer?.blendMode || 'source-over';
if (!blendSelect.dataset.wired) {
blendSelect.dataset.wired = 'true';
blendSelect.addEventListener('change', () => {
const activeGroup = normalizeLayerGroups(state).find(group => group.id === state.activeGroupId);
if (activeGroup) {
if (activeGroup.blendMode === blendSelect.value) return;
saveState(`Change blend mode of group "${activeGroup.name}"`);
activeGroup.blendMode = blendSelect.value;
composite();
return;
}
const active = state.layers.find(layer => layer.id === state.activeLayerId);
if (!active) return;
const targets = rowTargets(active);
if (targets.every(layer => layer.blendMode === blendSelect.value)) return;
saveState(targets.length > 1 ? `Change blend mode of ${targets.length} layers` : `Change blend mode of "${active.name}"`);
for (const layer of targets) layer.blendMode = blendSelect.value;
composite();
});
}
}
// Mobile bottom-sheet peek height — header + N rows, capped so a
// 20-layer document doesn't get a peek that eats the canvas.
const panel = document.querySelector('.ge-right-panel');
if (panel) {
requestAnimationFrame(() => {
const header = panel.querySelector('.ge-layers-header');
const firstRow = list.querySelector('.ge-layer-item');
const headerH = header ? header.offsetHeight : 52;
const rowH = firstRow ? firstRow.offsetHeight : 36;
const allRows = list.querySelectorAll('.ge-layer-item').length;
const MAX_ROWS = 2;
const rows = Math.min(allRows, MAX_ROWS);
panel.style.setProperty('--peek-height', `${headerH + rows * rowH + 6}px`);
});
}
list.innerHTML = '';
const groups = normalizeLayerGroups(state);
const groupByLayer = new Map();
const topIndexByGroup = new Map();
for (const group of groups) {
for (const id of group.layerIds) groupByLayer.set(id, group);
for (const id of allLayerIdsInGroup(state, group)) {
const index = state.layers.findIndex(layer => layer.id === id);
if (index >= 0) topIndexByGroup.set(group.id, Math.max(topIndexByGroup.get(group.id) ?? -1, index));
}
}
const groupsAtTopIndex = new Map();
for (const group of groups) {
const index = topIndexByGroup.get(group.id);
if (!Number.isInteger(index)) continue;
if (!groupsAtTopIndex.has(index)) groupsAtTopIndex.set(index, []);
groupsAtTopIndex.get(index).push(group);
}
// Render in reverse order (top layer first).
for (let i = state.layers.length - 1; i >= 0; i--) {
const layer = state.layers[i];
for (const group of (groupsAtTopIndex.get(i) || []).sort((a, b) => groupDepth(state, a) - groupDepth(state, b))) {
if (groupAncestors(state, group).some(parent => parent.collapsed)) continue;
appendGroupRow(list, group, groupDepth(state, group));
}
const lineage = groupsForLayer(state, layer.id);
const group = lineage[0] || null;
if (lineage.some(item => item.collapsed)) continue;
const item = document.createElement('div');
// Parent row is highlighted ONLY when it's actually the paint
// target — activated AND no mask sub-layer is currently active.
const parentIsPaintTarget = layer.id === state.activeLayerId &&
!(layer.masks && layer.activeMaskId && layer.masks.some(m => m.id === layer.activeMaskId));
item.className = 'ge-layer-item' +
(parentIsPaintTarget ? ' active' : '') +
(layer.id === state.activeLayerId && !parentIsPaintTarget ? ' active-parent' : '') +
(state.selectedLayerIds.includes(layer.id) ? ' selected' : '') +
(group ? ' grouped' : '') +
(layer.clipped ? ' clipped' : '') +
(layer.kind === 'adjustment' ? ' ge-adjustment-layer-item' : '');
item.dataset.layerId = layer.id;
item.tabIndex = 0;
item.setAttribute('aria-label', `${layer.name} layer`);
item.setAttribute('aria-pressed', state.selectedLayerIds.includes(layer.id) ? 'true' : 'false');
if (group) item.style.setProperty('--layer-group-depth', String(lineage.length));
// Hover thumbnail.
item.addEventListener('mouseenter', () => {
if (layer.kind !== 'adjustment') showLayerThumb(item, layer);
});
item.addEventListener('mouseleave', () => hideLayerThumb());
item.addEventListener('click', (e) => {
if (shouldIgnoreLayerTap()) {
e.preventDefault();
e.stopPropagation();
return;
}
// Alt+click keeps the legacy transparency-to-selection shortcut.
if (e.altKey && layer.kind !== 'adjustment') {
e.preventDefault();
loadLayerAlphaAsSelection(layer);
return;
}
if (e.shiftKey) selectLayerRange(state, layer.id);
else if (e.ctrlKey || e.metaKey) toggleLayerSelection(state, layer.id);
else selectOnlyLayer(state, layer.id);
const active = state.layers.find(item => item.id === state.activeLayerId) || layer;
active.activeMaskId = null;
state.maskInspectMode = false;
state.maskCanvas = null;
state.maskCtx = null;
syncSelectionUi();
onSelectLayer?.(active);
composite();
// Preserve the name element across the first click of a double-click
// so inline renaming can receive the browser's dblclick event.
if (!e.target.closest('.ge-layer-name')) render();
});
item.addEventListener('keydown', (e) => {
if (e.target !== item || (e.key !== 'Enter' && e.key !== ' ')) return;
e.preventDefault();
item.click();
});
// Drag handle — grip dots; dragSortModule.enable() below scopes
// drag-init to this handle so row body clicks still activate.
const handle = document.createElement('span');
handle.className = 'ge-layer-drag';
handle.title = 'Drag to reorder';
handle.innerHTML = '';
item.appendChild(handle);
const visBtn = document.createElement('button');
visBtn.className = 'ge-layer-vis' + (layer.visible ? ' visible' : '');
visBtn.innerHTML = layer.visible ? EYE_OPEN : EYE_OFF;
visBtn.title = layer.visible ? 'Hide layer' : 'Show layer';
visBtn.addEventListener('click', (e) => {
e.stopPropagation();
const targets = rowTargets(layer);
const visible = !targets.every(item => item.visible !== false);
saveState(targets.length > 1 ? `${visible ? 'Show' : 'Hide'} ${targets.length} layers` : `${layer.visible ? 'Hide' : 'Show'} layer "${layer.name}"`);
for (const item of targets) item.visible = visible;
composite();
render();
});
// Retained text/shape layers keep their editable metadata separately
// from the raster canvas. Use the normal renderer when available so
// their layer previews match what the document actually displays.
const previewCanvas = renderLayer?.(layer) || layer.canvas;
const thumb = createInlineThumbnail(previewCanvas, `${layer.name} preview`);
item.appendChild(thumb);
const nameEl = document.createElement('span');
nameEl.className = 'ge-layer-name';
if (layer.kind === 'placed') {
const marker = document.createElement('span');
marker.className = 'ge-layer-placed-marker';
marker.title = 'Placed image';
marker.innerHTML = '';
nameEl.appendChild(marker);
}
if (layer.kind === 'text' || layer.kind === 'shape') {
const marker = document.createElement('span');
marker.className = 'ge-layer-placed-marker';
marker.title = layer.kind === 'text' ? 'Editable text' : 'Editable shape';
marker.textContent = layer.kind === 'text' ? 'T' : '◇';
nameEl.appendChild(marker);
}
if (layer.kind === 'adjustment') {
const marker = document.createElement('span');
marker.className = 'ge-layer-placed-marker ge-layer-adjustment-marker';
marker.title = 'Adjustment layer';
marker.innerHTML = ADJ_ICONS[layer.adjustment?.type] || ADJ_ICONS.levels || '';
nameEl.appendChild(marker);
}
if (layer.clipped) {
const marker = document.createElement('span');
marker.className = 'ge-layer-clipped-marker';
marker.title = 'Clipped to layer below';
marker.innerHTML = '';
nameEl.appendChild(marker);
}
nameEl.appendChild(document.createTextNode(layer.name + (layer.kind !== 'adjustment' && isLayerEmpty(layer) ? ' (empty)' : '')));
// Keep the name node stable across the first click of a double-click
// so inline renaming can receive the browser's dblclick event.
nameEl.addEventListener('click', event => event.stopPropagation());
nameEl.addEventListener('dblclick', event => {
event.preventDefault();
event.stopPropagation();
const input = document.createElement('input');
input.type = 'text';
input.value = layer.name;
input.className = 'ge-layer-name-input';
nameEl.replaceWith(input);
input.focus();
let finished = false;
let cancelRename = false;
const finish = (commit) => {
if (finished) return;
finished = true;
if (!commit) {
input.replaceWith(nameEl);
return;
}
const nextName = input.value.trim() || layer.name;
if (nextName !== layer.name) {
saveState(`Rename layer "${layer.name}"`);
layer.name = nextName;
}
render();
};
input.addEventListener('blur', () => finish(!cancelRename && input.dataset.cancelRename !== 'true'));
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); finish(true); }
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
cancelRename = true;
finish(false);
}
}, true);
});
const opSlider = document.createElement('input');
opSlider.type = 'range';
opSlider.min = '0';
opSlider.max = '100';
opSlider.value = String(Math.round(layer.opacity * 100));
opSlider.className = 'ge-layer-opacity';
opSlider.title = 'Opacity';
let opacityHistorySaved = false;
opSlider.addEventListener('input', (e) => {
e.stopPropagation();
const targets = rowTargets(layer);
const nextOpacity = parseInt(e.target.value, 10) / 100;
if (targets.every(item => Math.abs(item.opacity - nextOpacity) < 0.0001)) return;
if (!opacityHistorySaved) {
saveState(targets.length > 1 ? `Change opacity of ${targets.length} layers` : `Change opacity of "${layer.name}"`);
opacityHistorySaved = true;
}
for (const item of targets) item.opacity = nextOpacity;
composite();
});
opSlider.addEventListener('change', () => { opacityHistorySaved = false; });
// Browser :active drops the moment the cursor leaves the slider
// hit-area in some browsers; a JS-managed `dragging` class
// survives the OS pointer-capture so the slider stays expanded
// for the whole drag.
opSlider.addEventListener('pointerdown', () => {
opSlider.classList.add('dragging');
const onUp = () => {
opSlider.classList.remove('dragging');
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointerup', onUp);
});
const controls = document.createElement('div');
controls.className = 'ge-layer-controls';
const lockBtn = document.createElement('button');
lockBtn.className = 'ge-layer-btn ge-layer-lock-btn' + (layerHasAnyLock(layer) ? ' active' : '');
lockBtn.title = 'Layer locks';
lockBtn.setAttribute('aria-label', `Layer locks for ${layer.name}`);
lockBtn.setAttribute('aria-haspopup', 'menu');
lockBtn.innerHTML = layerHasAnyLock(layer)
? ''
: '';
lockBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openLayerLockMenu(layer, lockBtn);
});
const clipBtn = document.createElement('button');
const clipAllowed = canToggleLayerClipping(state, layer.id);
clipBtn.className = 'ge-layer-btn ge-layer-clip-btn' + (layer.clipped ? ' active' : '');
clipBtn.title = layer.clipped
? 'Release clipping mask'
: (clipAllowed ? 'Create clipping mask from layer below' : 'Clipping requires a layer below in the same group');
clipBtn.setAttribute('aria-label', clipBtn.title);
clipBtn.disabled = !clipAllowed;
clipBtn.innerHTML = '';
clipBtn.addEventListener('click', event => {
event.stopPropagation();
if (!canToggleLayerClipping(state, layer.id)) return;
saveState(`${layer.clipped ? 'Release' : 'Create'} clipping mask for "${layer.name}"`);
layer.clipped = !layer.clipped;
normalizeLayerClipping(state);
composite();
render();
});
controls.appendChild(clipBtn);
// FX (adjustments) — opens a floating popup bound to this layer.
const fxBtn = document.createElement('button');
fxBtn.className = 'ge-layer-btn ge-layer-fx-btn' + (layerHasAdjustments(layer) ? ' active' : '');
fxBtn.innerHTML = '';
fxBtn.title = 'Adjust layer (Brightness, Contrast, Saturation, Hue, Levels, Color Balance)';
fxBtn.style.touchAction = 'manipulation';
let lastFxPointerOpenAt = 0;
let fxOpenTimer = null;
const openLayerFx = (e, delay = 0) => {
e.preventDefault?.();
e.stopPropagation();
window.__geSuppressLayerTapUntil = 0;
if (fxOpenTimer) clearTimeout(fxOpenTimer);
fxOpenTimer = setTimeout(() => {
fxOpenTimer = null;
openFxPopup(layer, fxBtn);
}, delay);
};
fxBtn.addEventListener('pointerdown', (e) => {
e.stopPropagation();
});
fxBtn.addEventListener('pointerup', (e) => {
lastFxPointerOpenAt = Date.now();
const delay = e.pointerType === 'touch' || e.pointerType === 'pen' ? 120 : 0;
openLayerFx(e, delay);
});
fxBtn.addEventListener('click', (e) => {
if (Date.now() - lastFxPointerOpenAt < 500) {
e.preventDefault();
e.stopPropagation();
return;
}
openLayerFx(e);
});
controls.appendChild(fxBtn);
// Duplicate — clones pixels + offset + opacity + masks + adjLayers
// + visibility; inserts above the original; new copy becomes
// active.
const dupBtn = document.createElement('button');
dupBtn.className = 'ge-layer-btn';
dupBtn.title = 'Duplicate layer';
dupBtn.dataset.layerId = layer.id;
dupBtn.innerHTML = '';
dupBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Duplicate "${layer.name}"`);
const copy = createLayer(layer.name + ' copy', layer.canvas.width, layer.canvas.height);
copy.ctx.drawImage(layer.canvas, 0, 0);
copy.opacity = layer.opacity;
copy.visible = layer.visible;
copy.locked = layer.locked;
copy.locks = normalizeLayerLocks(layer.locks);
copy.clipped = !!layer.clipped;
copy.blendMode = layer.blendMode || 'source-over';
copy.kind = layer.kind || 'raster';
copy.text = layer.text ? JSON.parse(JSON.stringify(layer.text)) : null;
copy.shape = layer.shape ? JSON.parse(JSON.stringify(layer.shape)) : null;
copy.adjustment = layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null;
// Give duplicated placed layers their own immutable source surface.
// This keeps future source replacement/editing isolated between the
// original and its copy instead of relying on reference sharing.
copy.placed = layer.kind === 'placed' ? clonePlacedData(layer.placed, { copySource: true }) : null;
copy.adjustments = JSON.parse(JSON.stringify(layer.adjustments || {}));
copy.effects = (layer.effects || []).map(effect => ({
...normalizeEffect(effect),
id: 'effect-' + Math.random().toString(36).slice(2, 9),
params: JSON.parse(JSON.stringify(effect.params || {})),
}));
const srcOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.layerOffsets.set(copy.id, { x: srcOff.x, y: srcOff.y });
if (Array.isArray(layer.masks) && layer.masks.length) {
copy.masks = layer.masks.map(m => {
const c = document.createElement('canvas');
c.width = m.canvas.width; c.height = m.canvas.height;
c.getContext('2d').drawImage(m.canvas, 0, 0);
return {
id: 'mask-' + (state.nextLayerId++),
name: m.name,
canvas: c,
ctx: c.getContext('2d'),
visible: m.visible !== false,
mode: m.mode || 'selection',
space: m.space || (m.mode === 'layer' ? 'layer' : 'document'),
linked: m.mode === 'layer' ? m.linked !== false : true,
offset: { x: Number(m.offset?.x) || 0, y: Number(m.offset?.y) || 0 },
};
});
const activeMaskIndex = layer.masks.findIndex(m => m.id === layer.activeMaskId);
copy.activeMaskId = activeMaskIndex >= 0 ? copy.masks[activeMaskIndex]?.id || null : null;
}
if (Array.isArray(layer.adjLayers) && layer.adjLayers.length) {
copy.adjLayers = layer.adjLayers.map(a => ({
id: 'adj-' + Math.random().toString(36).slice(2, 9),
type: a.type,
name: a.name,
visible: a.visible !== false,
opacity: a.opacity != null ? a.opacity : 1,
params: JSON.parse(JSON.stringify(a.params || {})),
}));
}
const idx = state.layers.findIndex(l => l.id === layer.id);
if (idx >= 0) state.layers.splice(idx + 1, 0, copy);
else state.layers.push(copy);
state.activeLayerId = copy.id;
composite();
render();
if (uiModule) uiModule.showToast('Layer duplicated');
});
controls.appendChild(dupBtn);
if (layer.kind === 'raster') {
const convertBtn = document.createElement('button');
convertBtn.className = 'ge-layer-btn';
convertBtn.title = 'Convert to editable source';
convertBtn.setAttribute('aria-label', 'Convert to editable source');
convertBtn.innerHTML = '';
convertBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!layer.canvas?.width || !layer.canvas?.height) return;
saveState(`Convert "${layer.name}" to editable source`);
layer.placed = createPlacedData(layer.canvas, [1, 0, 0, 1, 0, 0], layer.name || 'Raster layer');
layer.kind = 'placed';
renderPlacedLayer(layer);
composite();
render();
uiModule?.showToast('Layer converted to editable source');
});
controls.appendChild(convertBtn);
}
if (layer.kind === 'placed') {
const replaceBtn = document.createElement('button');
replaceBtn.className = 'ge-layer-btn';
replaceBtn.title = 'Replace placed image';
replaceBtn.setAttribute('aria-label', 'Replace placed image');
replaceBtn.innerHTML = '';
replaceBtn.addEventListener('click', (e) => {
e.stopPropagation();
replacePlacedLayer?.(layer);
});
controls.appendChild(replaceBtn);
const rasterizeBtn = document.createElement('button');
rasterizeBtn.className = 'ge-layer-btn';
rasterizeBtn.title = 'Rasterize placed layer';
rasterizeBtn.setAttribute('aria-label', 'Rasterize placed layer');
rasterizeBtn.innerHTML = '';
rasterizeBtn.addEventListener('click', (e) => {
e.stopPropagation();
rasterizePlacedLayer?.(layer);
});
controls.appendChild(rasterizeBtn);
}
// Add-mask — if a lasso/wand selection is active, bake it into a
// mask sub-layer on this layer; otherwise create an empty mask
// for the user to paint with the Brush tool.
const hasLassoSelInitial = state.lassoPoints.length >= 3 && !state.lassoActive;
const hasWandSelInitial = !!state.wandMask;
const maskBtn = document.createElement('button');
maskBtn.className = 'ge-layer-btn ge-layer-mask-btn' +
((hasLassoSelInitial || hasWandSelInitial) ? ' from-selection' : '');
maskBtn.innerHTML = '';
maskBtn.title = (hasLassoSelInitial || hasWandSelInitial)
? 'Make mask from current selection'
: 'Add empty mask (paint with Brush)';
maskBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Activate this layer first so the new mask attaches here.
state.activeLayerId = layer.id;
// Re-check selection state AT CLICK TIME — captured vars may
// be stale if a selection was drawn after the panel paint.
const hasLassoSel = state.lassoPoints.length >= 3 && !state.lassoActive;
const hasWandSel = !!state.wandMask;
if (hasLassoSel) {
saveState(`Mask from lasso on "${layer.name}"`);
// Force a fresh mask sub-layer for this conversion so each
// selection becomes its own mask instead of merging into the
// previously active one.
layer.activeMaskId = null;
lassoToMask();
} else if (hasWandSel) {
saveState(`Mask from wand on "${layer.name}"`);
layer.activeMaskId = null;
wandToMask();
} else {
saveState(`Add mask to "${layer.name}"`);
const c = document.createElement('canvas');
c.width = state.imgWidth;
c.height = state.imgHeight;
if (!layer.masks) layer.masks = [];
const mask = {
id: 'mask-' + (state.nextLayerId++),
name: 'Mask ' + (layer.masks.length + 1),
canvas: c,
ctx: c.getContext('2d'),
visible: true,
mode: 'selection',
space: 'document',
};
layer.masks.push(mask);
layer.activeMaskId = mask.id;
state.maskCanvas = mask.canvas;
state.maskCtx = mask.ctx;
composite();
render();
}
});
controls.appendChild(maskBtn);
const layerMaskBtn = document.createElement('button');
layerMaskBtn.className = 'ge-layer-btn ge-layer-mask-btn ge-true-mask-btn';
layerMaskBtn.title = 'Add layer mask (white reveals; erase hides)';
layerMaskBtn.innerHTML = '';
layerMaskBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Add layer mask to "${layer.name}"`);
const c = document.createElement('canvas');
c.width = layer.canvas.width;
c.height = layer.canvas.height;
const ctx = c.getContext('2d');
const selection = getDocumentSelection?.();
if (selection) {
const layerOffset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.drawImage(selection, -layerOffset.x, -layerOffset.y);
} else {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, c.width, c.height);
}
if (!layer.masks) layer.masks = [];
const mask = {
id: 'mask-' + (state.nextLayerId++),
name: 'Layer Mask',
canvas: c,
ctx,
visible: true,
mode: 'layer',
space: 'layer',
linked: true,
offset: { x: 0, y: 0 },
};
layer.masks.push(mask);
layer.activeMaskId = mask.id;
state.activeLayerId = layer.id;
state.maskCanvas = c;
state.maskCtx = ctx;
composite();
render();
uiModule.showToast(selection ? 'Layer mask created from selection' : 'Layer mask added — use Eraser to hide, Brush to reveal');
});
controls.appendChild(layerMaskBtn);
// Per-row Merge Down — bakes this layer into the one beneath.
// Hidden on the bottom layer in the visual stack (idx 0 forward).
const lowerLayer = state.layers[i - 1] || null;
const sameMergeScope = lowerLayer && (groupByLayer.get(lowerLayer.id)?.id || null) === (group?.id || null);
if (i > 0 && layer.kind !== 'adjustment' && lowerLayer.kind !== 'adjustment' && !lowerLayer.clipped && sameMergeScope) {
const mergeDownBtn = document.createElement('button');
mergeDownBtn.className = 'ge-layer-btn';
mergeDownBtn.title = 'Merge down into layer below';
mergeDownBtn.innerHTML = '';
mergeDownBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Merge "${layer.name}" down`);
const merged = mergeLayerDownAtIndex(i, renderLayer);
if (!merged) return;
composite();
render();
uiModule.showToast('Layer merged down');
});
controls.appendChild(mergeDownBtn);
}
// Delete — shown for every layer except when this is the last
// remaining one. Base photo is deletable too; Ctrl+Z brings it
// back from history. Extra confirm for the base layer.
if (state.layers.length > 1) {
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteLayers(rowTargets(layer));
});
controls.appendChild(delBtn);
}
const opacityRow = document.createElement('div');
opacityRow.className = 'ge-layer-opacity-row';
opacityRow.append(opSlider);
item.appendChild(visBtn);
item.appendChild(nameEl);
item.appendChild(lockBtn);
item.appendChild(opacityRow);
item.appendChild(controls);
list.appendChild(item);
// Adjustment sub-layer rows, indented under the parent.
if (layer.adjLayers && layer.adjLayers.length) {
for (const adj of layer.adjLayers) {
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item';
sub.dataset.adjId = adj.id;
sub.tabIndex = 0;
sub.setAttribute('aria-label', `${adj.name || adjLayerLabel(adj.type)} adjustment`);
const sVis = document.createElement('button');
sVis.className = 'ge-layer-vis' + (adj.visible ? ' visible' : '');
sVis.innerHTML = adj.visible ? EYE_OPEN_SM : EYE_OFF_SM;
sVis.title = adj.visible ? 'Hide adjustment' : 'Show adjustment';
sVis.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`${adj.visible ? 'Hide' : 'Show'} ${adjLayerLabel(adj.type)}`);
adj.visible = !adj.visible;
layer._adjFinalKey = null;
composite();
render();
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `${ADJ_ICONS[adj.type] || ''}${(adj.name || adjLayerLabel(adj.type)).replace(/[<>&]/g,'')}`;
const sOp = document.createElement('input');
sOp.type = 'range';
sOp.min = '0'; sOp.max = '100';
sOp.value = Math.round(adj.opacity * 100);
sOp.className = 'ge-layer-opacity';
sOp.title = 'Adjustment opacity';
let adjOpacityHistorySaved = false;
sOp.addEventListener('input', () => {
const nextOpacity = parseInt(sOp.value, 10) / 100;
if (Math.abs(adj.opacity - nextOpacity) < 0.0001) return;
if (!adjOpacityHistorySaved) {
saveState(`Change ${adjLayerLabel(adj.type)} opacity`);
adjOpacityHistorySaved = true;
}
adj.opacity = nextOpacity;
layer._adjFinalKey = null;
composite();
});
sOp.addEventListener('change', () => { adjOpacityHistorySaved = false; });
const sControls = document.createElement('div');
sControls.className = 'ge-layer-controls';
const mergeBtn = document.createElement('button');
mergeBtn.className = 'ge-layer-btn';
mergeBtn.title = isLayerPixelLocked(state, layer)
? 'Unlock image pixels before baking this adjustment'
: 'Merge into layer (bake)';
mergeBtn.disabled = isLayerPixelLocked(state, layer);
mergeBtn.innerHTML = '';
mergeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (isLayerPixelLocked(state, layer)) return;
// Bake just this adjustment into layer.canvas, then drop it.
saveState(`Merge ${adjLayerLabel(adj.type)}`);
rasterizePlacedPixels(layer);
const baked = applyAdjustment(layer.canvas, adj);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
layer.ctx.drawImage(baked, 0, 0);
rasterizeTextLayer(layer);
rasterizeShapeLayer(layer);
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
layer._adjFinalKey = null;
composite();
render();
});
sControls.appendChild(mergeBtn);
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = 'Delete adjustment';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Delete ${adjLayerLabel(adj.type)}`);
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
layer._adjFinalKey = null;
composite();
render();
});
sControls.appendChild(delBtn);
sub.appendChild(sVis);
sub.appendChild(sName);
sub.appendChild(sOp);
sub.appendChild(sControls);
// Single-click on the sub-row (outside the inline controls)
// reopens the adj popup with this sub-layer's params staged.
sub.addEventListener('click', (e) => {
if (shouldIgnoreLayerTap()) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.target.closest('.ge-layer-vis, .ge-layer-opacity, .ge-layer-btn')) return;
if (!e.target.closest('.ge-adj-sub-name')) return;
e.stopPropagation();
editAdjLayer(layer, adj, sub);
});
sub.addEventListener('keydown', event => {
if (event.target !== sub || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
editAdjLayer(layer, adj, sub);
});
list.appendChild(sub);
}
}
// Retained effect rows. Effects are ordered bottom-to-top in the
// array; the arrow buttons change that order without rasterizing.
if (layer.effects && layer.effects.length) {
for (let effectIndex = 0; effectIndex < layer.effects.length; effectIndex += 1) {
const effect = normalizeEffect(layer.effects[effectIndex]);
layer.effects[effectIndex] = effect;
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item ge-effect-sub-item';
sub.dataset.effectId = effect.id;
sub.tabIndex = 0;
sub.setAttribute('aria-label', `${effectLabel(effect.type)} effect`);
const sVis = document.createElement('button');
sVis.className = 'ge-layer-vis' + (effect.visible ? ' visible' : '');
sVis.innerHTML = effect.visible ? EYE_OPEN_SM : EYE_OFF_SM;
sVis.title = effect.visible ? 'Hide effect' : 'Show effect';
sVis.addEventListener('click', event => {
event.stopPropagation();
saveState(`${effect.visible ? 'Hide' : 'Show'} ${effectLabel(effect.type)}`);
effect.visible = !effect.visible;
composite();
render();
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `◌${effectLabel(effect.type)}`;
sName.title = 'Edit effect';
sName.addEventListener('click', event => {
event.stopPropagation();
editRetainedEffect?.(layer, effect, sName);
});
sub.addEventListener('keydown', event => {
if (event.target !== sub || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
editRetainedEffect?.(layer, effect, sub);
});
const sOp = document.createElement('input');
sOp.type = 'range'; sOp.min = '0'; sOp.max = '100';
sOp.value = String(Math.round(effect.opacity * 100));
sOp.className = 'ge-layer-opacity';
sOp.title = 'Effect opacity';
let effectOpacitySaved = false;
sOp.addEventListener('input', event => {
event.stopPropagation();
const next = Number(sOp.value) / 100;
if (Math.abs(effect.opacity - next) < 0.0001) return;
if (!effectOpacitySaved) {
saveState(`Change ${effectLabel(effect.type)} opacity`);
effectOpacitySaved = true;
}
effect.opacity = next;
composite();
});
sOp.addEventListener('change', () => { effectOpacitySaved = false; });
const sControls = document.createElement('div');
sControls.className = 'ge-layer-controls';
const maskBtn = document.createElement('button');
maskBtn.className = 'ge-layer-btn';
maskBtn.type = 'button';
maskBtn.textContent = effect.mask ? '◉' : '○';
maskBtn.title = effect.mask
? (effect.mask.visible === false ? 'Show effect mask' : 'Hide effect mask')
: 'Add effect mask from selection';
maskBtn.addEventListener('click', event => {
event.stopPropagation();
if (!effect.mask) {
addEffectMask?.(layer, effect);
return;
}
saveState(`${effect.mask.visible === false ? 'Show' : 'Hide'} ${effectLabel(effect.type)} mask`);
effect.mask.visible = effect.mask.visible === false;
composite();
render();
});
sControls.appendChild(maskBtn);
if (effect.mask) {
const removeMask = document.createElement('button');
removeMask.className = 'ge-layer-btn';
removeMask.type = 'button';
removeMask.textContent = '×';
removeMask.title = 'Remove effect mask';
removeMask.addEventListener('click', event => {
event.stopPropagation();
saveState(`Remove ${effectLabel(effect.type)} mask`);
effect.mask = null;
composite();
render();
});
sControls.appendChild(removeMask);
}
const bake = document.createElement('button');
bake.className = 'ge-layer-btn';
bake.type = 'button';
bake.textContent = '◆';
bake.title = 'Rasterize effects';
bake.addEventListener('click', event => {
event.stopPropagation();
rasterizeEffects?.(layer);
});
sControls.appendChild(bake);
for (const [direction, symbol, title] of [[-1, '↑', 'Move effect up'], [1, '↓', 'Move effect down']]) {
const move = document.createElement('button');
move.className = 'ge-layer-btn';
move.type = 'button';
move.textContent = symbol;
move.title = title;
move.disabled = effectIndex + direction < 0 || effectIndex + direction >= layer.effects.length;
move.addEventListener('click', event => {
event.stopPropagation();
const nextIndex = effectIndex + direction;
if (nextIndex < 0 || nextIndex >= layer.effects.length) return;
saveState(`${title} ${effectLabel(effect.type)}`);
[layer.effects[effectIndex], layer.effects[nextIndex]] = [layer.effects[nextIndex], layer.effects[effectIndex]];
composite();
render();
});
sControls.appendChild(move);
}
const remove = document.createElement('button');
remove.className = 'ge-layer-btn danger';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Delete effect';
remove.addEventListener('click', event => {
event.stopPropagation();
saveState(`Delete ${effectLabel(effect.type)}`);
layer.effects.splice(effectIndex, 1);
composite();
render();
});
sControls.appendChild(remove);
sub.append(sVis, sName, sOp, sControls);
list.appendChild(sub);
}
}
// Mask sub-layer rows.
if (layer.masks && layer.masks.length) {
for (let mi = 0; mi < layer.masks.length; mi++) {
const mk = layer.masks[mi];
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item ge-mask-sub-item' +
(layer.activeMaskId === mk.id ? ' active' : '');
sub.dataset.maskId = mk.id;
sub.tabIndex = 0;
sub.setAttribute('aria-label', `${mk.name || (mk.mode === 'layer' ? 'Layer Mask' : 'AI Mask')} mask`);
const sVis = document.createElement('button');
sVis.className = 'ge-layer-vis' + (mk.visible ? ' visible' : '');
sVis.innerHTML = mk.visible ? EYE_OPEN_SM : EYE_OFF_SM;
sVis.title = mk.visible ? 'Hide mask' : 'Show mask';
sVis.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`${mk.visible ? 'Hide' : 'Show'} mask "${mk.name || 'Mask'}"`);
mk.visible = !mk.visible;
composite();
render();
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
const maskIcon = '';
const mkName = String(mk.name || (mk.mode === 'layer' ? 'Layer Mask' : 'AI Mask')).replace(/[<>&]/g, '');
const mkEmpty = isMaskCanvasEmpty(mk.canvas) ? ' (empty)' : '';
sName.innerHTML = `${maskIcon}${mkName}${mkEmpty}`;
const sControls = document.createElement('div');
sControls.className = 'ge-layer-controls';
const invertMask = document.createElement('button');
invertMask.className = 'ge-layer-btn';
invertMask.title = 'Invert mask';
invertMask.setAttribute('aria-label', invertMask.title);
invertMask.innerHTML = '';
invertMask.addEventListener('click', event => {
event.stopPropagation();
saveState(`Invert mask "${mk.name || 'Mask'}"`);
invertMaskInPlace(mk.canvas);
if (layer.activeMaskId === mk.id) {
state.maskCanvas = mk.canvas;
state.maskCtx = mk.ctx;
}
composite();
render();
});
sControls.appendChild(createMaskInspectionControl(mk, layer));
sControls.appendChild(createMaskPropertiesControl(
mk,
mk.mode === 'layer' ? 'Layer mask' : 'Mask',
mk.mode === 'layer' ? () => applyLayerMask(layer, mk) : null,
));
sControls.appendChild(invertMask);
if (mk.mode === 'layer') {
const linked = mk.linked !== false;
const linkBtn = document.createElement('button');
linkBtn.className = 'ge-layer-btn ge-mask-link-btn' + (linked ? ' active' : '');
linkBtn.title = linked
? 'Unlink mask from layer position'
: 'Link mask to layer position';
linkBtn.setAttribute('aria-label', linkBtn.title);
linkBtn.setAttribute('aria-pressed', linked ? 'true' : 'false');
linkBtn.innerHTML = linked
? ''
: '';
linkBtn.addEventListener('click', event => {
event.stopPropagation();
saveState(`${linked ? 'Unlink' : 'Link'} mask "${mk.name || 'Layer Mask'}"`);
mk.linked = !linked;
if (!mk.offset || typeof mk.offset !== 'object') mk.offset = { x: 0, y: 0 };
composite();
render();
});
sControls.appendChild(linkBtn);
}
const selectBtn = document.createElement('button');
selectBtn.className = 'ge-layer-btn';
selectBtn.title = 'Load mask as selection';
selectBtn.setAttribute('aria-label', 'Load mask as selection');
selectBtn.innerHTML = '';
selectBtn.addEventListener('click', event => {
event.stopPropagation();
loadMaskAsSelection?.(layer, mk);
});
sControls.appendChild(selectBtn);
// Merge-up — combine this mask into the one above (lower mi).
if (mi > 0) {
const mergeBtn = document.createElement('button');
mergeBtn.className = 'ge-layer-btn';
mergeBtn.title = 'Merge into mask above';
mergeBtn.innerHTML = '';
mergeBtn.addEventListener('click', (e) => {
e.stopPropagation();
const above = layer.masks[mi - 1];
if (!above) return;
saveState(`Merge mask "${mk.name}" into "${above.name}"`);
// Union of alpha — `source-over` already does max for
// fully opaque white masks; this also handles partial alpha.
above.ctx.save();
above.ctx.globalCompositeOperation = 'source-over';
const aboveOffset = above.offset || { x: 0, y: 0 };
const maskOffset = mk.offset || { x: 0, y: 0 };
above.ctx.drawImage(
mk.canvas,
(Number(maskOffset.x) || 0) - (Number(aboveOffset.x) || 0),
(Number(maskOffset.y) || 0) - (Number(aboveOffset.y) || 0),
);
above.ctx.restore();
layer.masks = layer.masks.filter(x => x.id !== mk.id);
if (layer.activeMaskId === mk.id) layer.activeMaskId = above.id;
const a = getActiveMaskLayer();
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
else { state.maskCanvas = null; state.maskCtx = null; }
composite();
render();
});
sControls.appendChild(mergeBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = 'Delete mask';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Delete mask "${mk.name}"`);
layer.masks = layer.masks.filter(x => x.id !== mk.id);
if (layer.activeMaskId === mk.id) {
layer.activeMaskId = layer.masks[layer.masks.length - 1]?.id || null;
}
// Sync global mask plumbing.
const a = getActiveMaskLayer();
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
else { state.maskCanvas = null; state.maskCtx = null; }
composite();
render();
});
sControls.appendChild(delBtn);
sub.appendChild(sVis);
sub.appendChild(createMaskThumbnail(mk));
sub.appendChild(sName);
sub.appendChild(sControls);
const activateMask = () => {
layer.activeMaskId = mk.id;
selectOnlyLayer(state, layer.id);
state.maskCanvas = mk.canvas;
state.maskCtx = mk.ctx;
render();
composite();
};
sub.addEventListener('click', (e) => {
if (e.target.closest('.ge-layer-vis, .ge-layer-btn')) return;
e.stopPropagation();
// Activate this mask: paint/inpaint/generate target.
activateMask();
});
sub.addEventListener('keydown', event => {
if (event.target !== sub || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
activateMask();
});
list.appendChild(sub);
}
}
}
// Keep one action strip for the active layer/group. The buttons retain
// their row-specific closures, but the UI no longer repeats them on
// every layer row.
if (sharedTools) {
const activeRow = selectedGroup
? [...list.children].find(row => row.dataset.groupId === selectedGroup.id)
: selectedLayer
? [...list.children].find(row => row.dataset.layerId === selectedLayer.id)
: null;
const controls = activeRow?.querySelector(':scope > .ge-layer-controls');
if (controls) {
sharedTools.appendChild(controls);
sharedTools.hidden = false;
}
}
// Wire the shared dragSort module — limit drag-init to the grip
// handle so row body clicks still activate. Called every render
// because `enable()` cleans up the previous instance keyed on
// instanceKey.
if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers',
handleSelector: '.ge-layer-drag',
onReorder: (orderedItems) => {
// DOM is top→bottom = reverse of array order, so the new
// array is the reverse of the DOM order.
const byId = new Map(state.layers.map(l => [l.id, l]));
const newLayers = orderedItems
.map(el => byId.get(el.dataset.layerId))
.filter(Boolean)
.reverse();
if (newLayers.length === state.layers.length) {
const positions = new Map(newLayers.map((layer, index) => [layer.id, index]));
const groupsStayContiguous = normalizeLayerGroups(state).every(group => {
const indexes = allLayerIdsInGroup(state, group).map(id => positions.get(id)).filter(Number.isInteger).sort((a, b) => a - b);
return indexes.length < 2 || indexes[indexes.length - 1] - indexes[0] + 1 === indexes.length;
});
if (!groupsStayContiguous) {
uiModule?.showToast?.('Ungroup layers before moving them outside their group');
render();
return;
}
saveState('Reorder layers');
state.layers = newLayers;
normalizeLayerClipping(state);
composite();
}
},
});
}
syncSelectionUi();
}
// Reuse the guarded deletion path from the selection toolbar for keyboard
// shortcuts so undo, confirmations, and layer normalization stay aligned.
return {
render,
deleteSelectedLayers: () => deleteLayers(selectedLayers(state)),
duplicateActiveLayer: () => {
const button = [...document.querySelectorAll('button[title="Duplicate layer"]')]
.find(item => item.dataset.layerId === state.activeLayerId);
if (!button || button.disabled) return false;
button.click();
return true;
},
};
}