/** * 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 = `${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; }, }; }