/** Parent-linked layer groups with recursively isolated compositing. */ import { drawLayerStack, drawLayerStackAsync } from './layer-clipping.js'; import { renderEffects, renderEffectsAsync } from './effects.js'; function groupMap(editorState) { return new Map((editorState.layerGroups || []).map(group => [group.id, group])); } function breakParentCycles(groups) { const byId = new Map(groups.map(group => [group.id, group])); for (const group of groups) { const visited = new Set([group.id]); let cursor = group; while (cursor.parentId) { if (visited.has(cursor.parentId)) { group.parentId = null; break; } visited.add(cursor.parentId); cursor = byId.get(cursor.parentId); if (!cursor) break; } } } export function normalizeLayerGroups(editorState) { const validLayerIds = new Set((editorState.layers || []).map(layer => layer.id)); const claimedLayers = new Set(); const usedGroupIds = new Set(); const normalized = []; for (const source of editorState.layerGroups || []) { if (!source || typeof source !== 'object') continue; let id = String(source.id || `group-${normalized.length + 1}`); while (usedGroupIds.has(id)) id = `${id}-${normalized.length + 1}`; usedGroupIds.add(id); const layerIds = []; for (const layerId of source.layerIds || []) { if (validLayerIds.has(layerId) && !claimedLayers.has(layerId)) { claimedLayers.add(layerId); layerIds.push(layerId); } } source.id = id; source.name = String(source.name || `Group ${normalized.length + 1}`); source.layerIds = layerIds; source.parentId = source.parentId == null ? null : String(source.parentId); source.visible = source.visible !== false; source.opacity = Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1; source.blendMode = source.blendMode || 'source-over'; source.locked = !!source.locked; source.collapsed = !!source.collapsed; source.masks = Array.isArray(source.masks) ? source.masks.filter(mask => mask && typeof mask === 'object') : []; source.effects = Array.isArray(source.effects) ? source.effects : []; source.activeMaskId = source.masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null; normalized.push(source); } const validGroupIds = new Set(normalized.map(group => group.id)); for (const group of normalized) { if (group.parentId === group.id || !validGroupIds.has(group.parentId)) group.parentId = null; } breakParentCycles(normalized); // Parent-only groups are valid. Remove only empty leaves, then repeat because // their removal can make an ancestor empty as well. let retained = normalized; while (true) { const parentIds = new Set(retained.map(group => group.parentId).filter(Boolean)); const next = retained.filter(group => group.layerIds.length || parentIds.has(group.id)); if (next.length === retained.length) break; const nextIds = new Set(next.map(group => group.id)); for (const group of next) { if (group.parentId && !nextIds.has(group.parentId)) group.parentId = null; } retained = next; } editorState.layerGroups = retained; if (!retained.some(group => group.id === editorState.activeGroupId)) editorState.activeGroupId = null; return retained; } export function groupForLayer(editorState, layerId) { return normalizeLayerGroups(editorState).find(group => group.layerIds.includes(layerId)) || null; } export function groupAncestors(editorState, groupOrId, { includeSelf = false } = {}) { const groups = normalizeLayerGroups(editorState); const byId = new Map(groups.map(group => [group.id, group])); const group = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId; const result = []; let cursor = includeSelf ? group : byId.get(group?.parentId); while (cursor && !result.some(item => item.id === cursor.id)) { result.push(cursor); cursor = byId.get(cursor.parentId); } return result; } export function groupDepth(editorState, groupOrId) { return groupAncestors(editorState, groupOrId).length; } export function groupsForLayer(editorState, layerId) { const direct = groupForLayer(editorState, layerId); return direct ? [direct, ...groupAncestors(editorState, direct)] : []; } export function allLayerIdsInGroup(editorState, groupOrId) { const groups = normalizeLayerGroups(editorState); const byId = new Map(groups.map(group => [group.id, group])); const root = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId; if (!root) return []; const children = new Map(); for (const group of groups) { if (!group.parentId) continue; if (!children.has(group.parentId)) children.set(group.parentId, []); children.get(group.parentId).push(group); } const ids = new Set(); const visit = group => { for (const id of group.layerIds) ids.add(id); for (const child of children.get(group.id) || []) visit(child); }; visit(root); return (editorState.layers || []).filter(layer => ids.has(layer.id)).map(layer => layer.id); } export function isLayerEffectivelyLocked(editorState, layer) { if (!layer) return true; return !!layer.locked || groupsForLayer(editorState, layer.id).some(group => group.locked); } export function normalizeLayerLocks(value) { const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; return { pixels: !!source.pixels, transparency: !!source.transparency, position: !!source.position, }; } export function layerHasAnyLock(layer) { const locks = normalizeLayerLocks(layer?.locks); return !!layer?.locked || locks.pixels || locks.transparency || locks.position; } export function isLayerPixelLocked(editorState, layer) { return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).pixels; } export function isLayerTransparencyLocked(editorState, layer) { return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).transparency; } export function isLayerPositionLocked(editorState, layer) { return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).position; } export function createGroupFromSelection(editorState, name = null) { const selected = new Set(editorState.selectedLayerIds || []); const indexed = (editorState.layers || []) .map((layer, index) => ({ layer, index })) .filter(item => selected.has(item.layer.id)); if (indexed.length < 2) return null; const groups = normalizeLayerGroups(editorState); const descendantIds = new Map(groups.map(group => [group.id, allLayerIdsInGroup(editorState, group)])); const fullySelected = new Set(groups .filter(group => descendantIds.get(group.id).length && descendantIds.get(group.id).every(id => selected.has(id))) .map(group => group.id)); const selectedGroups = groups.filter(group => fullySelected.has(group.id) && !groupAncestors(editorState, group).some(parent => fullySelected.has(parent.id)) ); const covered = new Set(selectedGroups.flatMap(group => descendantIds.get(group.id))); const directLayerIds = indexed.map(item => item.layer.id).filter(id => !covered.has(id)); const componentParents = [ ...selectedGroups.map(group => group.parentId || null), ...directLayerIds.map(id => groupForLayer(editorState, id)?.id || null), ]; const commonParentId = componentParents.length && componentParents.every(id => id === componentParents[0]) ? componentParents[0] : null; const directSet = new Set(directLayerIds); for (const group of groups) group.layerIds = group.layerIds.filter(id => !directSet.has(id)); const selectedIds = new Set(indexed.map(item => item.layer.id)); const topIndex = indexed[indexed.length - 1].index; const remaining = editorState.layers.filter(layer => !selectedIds.has(layer.id)); const insertion = editorState.layers .slice(0, topIndex + 1) .filter(layer => !selectedIds.has(layer.id)).length; const members = indexed.map(item => item.layer); remaining.splice(insertion, 0, ...members); editorState.layers = remaining; const id = `group-${editorState.nextLayerId++}`; const group = { id, name: name || `Group ${groups.length + 1}`, layerIds: directLayerIds, parentId: commonParentId, visible: true, opacity: 1, blendMode: 'source-over', locked: false, collapsed: false, masks: [], activeMaskId: null, effects: [], }; for (const child of selectedGroups) child.parentId = id; editorState.layerGroups.push(group); normalizeLayerGroups(editorState); editorState.activeGroupId = id; editorState.selectedLayerIds = allLayerIdsInGroup(editorState, id); editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1]; editorState.selectionAnchorId = editorState.activeLayerId; return groupMap(editorState).get(id) || group; } export function ungroupLayers(editorState, groupId) { const group = normalizeLayerGroups(editorState).find(item => item.id === groupId); if (!group || group.masks?.length) return null; const selectedIds = allLayerIdsInGroup(editorState, group); const parent = group.parentId ? groupMap(editorState).get(group.parentId) : null; if (parent) { const direct = new Set([...parent.layerIds, ...group.layerIds]); parent.layerIds = (editorState.layers || []).filter(layer => direct.has(layer.id)).map(layer => layer.id); } for (const child of editorState.layerGroups) { if (child.parentId === group.id) child.parentId = group.parentId || null; } editorState.layerGroups = editorState.layerGroups.filter(item => item.id !== groupId); if (editorState.activeGroupId === groupId) editorState.activeGroupId = group.parentId || null; editorState.selectedLayerIds = selectedIds; normalizeLayerGroups(editorState); return group; } export function groupSiblingUnits(editorState, parentId = null) { const groups = normalizeLayerGroups(editorState); const parent = parentId ? groups.find(group => group.id === parentId) : null; if (parentId && !parent) return []; const layerOrder = new Map((editorState.layers || []).map((layer, index) => [layer.id, index])); const units = []; const directIds = parent ? parent.layerIds : (editorState.layers || []) .filter(layer => !groups.some(group => group.layerIds.includes(layer.id))) .map(layer => layer.id); for (const layerId of directIds) { if (layerOrder.has(layerId)) units.push({ type: 'layer', id: layerId, layerIds: [layerId] }); } for (const group of groups.filter(item => (item.parentId || null) === (parentId || null))) { const layerIds = allLayerIdsInGroup(editorState, group); if (layerIds.length) units.push({ type: 'group', id: group.id, layerIds }); } return units.sort((a, b) => { const aIndex = Math.min(...a.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger)); const bIndex = Math.min(...b.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger)); return aIndex - bIndex; }); } /** Move a complete group subtree among siblings without changing its parent. */ export function reorderGroupAmongSiblings(editorState, groupId, targetIndex) { const group = normalizeLayerGroups(editorState).find(item => item.id === groupId); if (!group) return null; const parentId = group.parentId || null; const units = groupSiblingUnits(editorState, parentId); const currentIndex = units.findIndex(unit => unit.type === 'group' && unit.id === groupId); if (currentIndex < 0) return null; const nextIndex = Math.max(0, Math.min(units.length - 1, Math.round(Number(targetIndex)))); if (nextIndex === currentIndex) return { currentIndex, targetIndex: nextIndex, changed: false }; const [moving] = units.splice(currentIndex, 1); units.splice(nextIndex, 0, moving); const scopeIds = new Set(units.flatMap(unit => unit.layerIds)); const byId = new Map((editorState.layers || []).map(layer => [layer.id, layer])); const reordered = units.flatMap(unit => unit.layerIds).map(id => byId.get(id)).filter(Boolean); let cursor = 0; editorState.layers = (editorState.layers || []).map(layer => scopeIds.has(layer.id) ? reordered[cursor++] : layer ); return { currentIndex, targetIndex: nextIndex, changed: true, parentId }; } function groupCanvas(editorState, groupId) { if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map(); let canvas = editorState.groupCompositeCanvases.get(groupId); if (!canvas) { canvas = document.createElement('canvas'); editorState.groupCompositeCanvases.set(groupId, canvas); } if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth; if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight; return canvas; } export function drawGroupedLayers(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) { ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight); if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map(); // A hidden or removed group must not keep advertising its previous pixels to // the layer panel while the next document composite is being built. editorState.groupCompositeCanvases.clear(); const groups = normalizeLayerGroups(editorState); const byId = new Map(groups.map(group => [group.id, group])); const directGroupByLayer = new Map(); const children = new Map(); for (const group of groups) { for (const id of group.layerIds) directGroupByLayer.set(id, group.id); const parentId = group.parentId || null; if (!children.has(parentId)) children.set(parentId, []); children.get(parentId).push(group); } const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index])); const boundsMemo = new Map(); const groupBounds = group => { if (boundsMemo.has(group.id)) return boundsMemo.get(group.id); const values = [ ...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger), ...(children.get(group.id) || []).flatMap(child => groupBounds(child)), ]; const result = values.length ? [Math.min(...values), Math.max(...values)] : []; boundsMemo.set(group.id, result); return result; }; const renderScope = (target, parentId = null) => { const nodes = []; for (const layer of editorState.layers || []) { if ((directGroupByLayer.get(layer.id) || null) === parentId) { nodes.push({ type: 'layer', index: indexes.get(layer.id), layer }); } } for (const group of children.get(parentId) || []) { const bounds = groupBounds(group); if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group }); } nodes.sort((a, b) => a.index - b.index); let layerRun = []; const flushLayers = () => { if (!layerRun.length) return; drawLayerStack(target, editorState, layerRun, renderLayer, renderAdjustment); layerRun = []; }; for (const node of nodes) { if (!shouldContinue()) return; if (node.type === 'layer') { layerRun.push(node.layer); continue; } flushLayers(); const group = byId.get(node.group.id); if (!group?.visible) continue; const canvas = groupCanvas(editorState, group.id); const groupCtx = canvas.getContext('2d'); groupCtx.clearRect(0, 0, canvas.width, canvas.height); renderScope(groupCtx, group.id); const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas); if (masks.length) { groupCtx.globalAlpha = 1; groupCtx.globalCompositeOperation = 'destination-in'; for (const mask of masks) { groupCtx.globalAlpha = Number.isFinite(Number(mask.density)) ? Math.max(0, Math.min(1, Number(mask.density))) : 1; const feather = Number.isFinite(Number(mask.feather)) ? Math.max(0, Math.min(200, Number(mask.feather))) : 0; groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none'; groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height); } } groupCtx.globalAlpha = 1; groupCtx.filter = 'none'; groupCtx.globalCompositeOperation = 'source-over'; const groupOutput = renderGroup ? renderGroup(canvas, group, shouldContinue) : (group.effects?.length ? renderEffects(canvas, group.effects, shouldContinue) : canvas); if (!shouldContinue()) return; if (groupOutput?.width && groupOutput?.height) { // Keep the exact final group output for the layer-panel thumbnail. A // raw member fallback loses masks, blending, and group effects. editorState.groupCompositeCanvases.set(group.id, groupOutput); } target.globalAlpha = group.opacity; target.globalCompositeOperation = group.blendMode || 'source-over'; target.drawImage(groupOutput, 0, 0); } flushLayers(); target.globalAlpha = 1; target.globalCompositeOperation = 'source-over'; }; renderScope(ctx); const validIds = new Set(groups.map(group => group.id)); for (const id of editorState.groupCompositeCanvases?.keys?.() || []) { if (!validIds.has(id)) editorState.groupCompositeCanvases.delete(id); } } /** * Async group compositor. It deliberately uses fresh group surfaces so an * older generation cannot overwrite the cached surface used by a newer one. */ export async function drawGroupedLayersAsync(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) { ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight); if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map(); editorState.groupCompositeCanvases.clear(); const groups = normalizeLayerGroups(editorState); const byId = new Map(groups.map(group => [group.id, group])); const directGroupByLayer = new Map(); const children = new Map(); for (const group of groups) { for (const id of group.layerIds) directGroupByLayer.set(id, group.id); const parentId = group.parentId || null; if (!children.has(parentId)) children.set(parentId, []); children.get(parentId).push(group); } const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index])); const boundsMemo = new Map(); const groupBounds = group => { if (boundsMemo.has(group.id)) return boundsMemo.get(group.id); const values = [ ...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger), ...(children.get(group.id) || []).flatMap(child => groupBounds(child)), ]; const result = values.length ? [Math.min(...values), Math.max(...values)] : []; boundsMemo.set(group.id, result); return result; }; const renderScope = async (target, parentId = null) => { const nodes = []; for (const layer of editorState.layers || []) { if ((directGroupByLayer.get(layer.id) || null) === parentId) { nodes.push({ type: 'layer', index: indexes.get(layer.id), layer }); } } for (const group of children.get(parentId) || []) { const bounds = groupBounds(group); if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group }); } nodes.sort((a, b) => a.index - b.index); let layerRun = []; const flushLayers = async () => { if (!layerRun.length || !shouldContinue()) return; const run = layerRun; layerRun = []; await drawLayerStackAsync(target, editorState, run, renderLayer, renderAdjustment, shouldContinue); }; for (const node of nodes) { if (!shouldContinue()) return; if (node.type === 'layer') { layerRun.push(node.layer); continue; } await flushLayers(); if (!shouldContinue()) return; const group = byId.get(node.group.id); if (!group?.visible) continue; const canvas = document.createElement('canvas'); canvas.width = editorState.imgWidth; canvas.height = editorState.imgHeight; const groupCtx = canvas.getContext('2d'); await renderScope(groupCtx, group.id); if (!shouldContinue()) return; const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas); if (masks.length) { groupCtx.globalAlpha = 1; groupCtx.globalCompositeOperation = 'destination-in'; for (const mask of masks) { groupCtx.globalAlpha = Number.isFinite(Number(mask.density)) ? Math.max(0, Math.min(1, Number(mask.density))) : 1; const feather = Number.isFinite(Number(mask.feather)) ? Math.max(0, Math.min(200, Number(mask.feather))) : 0; groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none'; groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height); } } groupCtx.globalAlpha = 1; groupCtx.filter = 'none'; groupCtx.globalCompositeOperation = 'source-over'; const groupOutput = renderGroup ? await renderGroup(canvas, group, shouldContinue) : (group.effects?.length ? await renderEffectsAsync(canvas, group.effects, shouldContinue) : canvas); if (!shouldContinue() || !groupOutput) return; if (groupOutput.width && groupOutput.height) { editorState.groupCompositeCanvases.set(group.id, groupOutput); } target.globalAlpha = group.opacity; target.globalCompositeOperation = group.blendMode || 'source-over'; target.drawImage(groupOutput, 0, 0); } await flushLayers(); target.globalAlpha = 1; target.globalCompositeOperation = 'source-over'; }; await renderScope(ctx); }