Files
odysseus/static/js/editor/layer-clipping.js
T

145 lines
5.9 KiB
JavaScript

/** Alpha clipping for flat layer stacks and isolated groups. */
function scopeByLayer(editorState) {
const scopes = new Map();
for (const group of editorState.layerGroups || []) {
for (const id of group.layerIds || []) scopes.set(id, group.id);
}
return scopes;
}
export function clippingBaseForLayer(editorState, layerId) {
const layers = editorState.layers || [];
const index = layers.findIndex(layer => layer.id === layerId);
if (index < 0 || !layers[index]?.clipped || index === 0) return null;
const scopes = scopeByLayer(editorState);
const scope = scopes.get(layerId) || null;
if ((scopes.get(layers[index - 1].id) || null) !== scope) return null;
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const candidate = layers[cursor];
if ((scopes.get(candidate.id) || null) !== scope) return null;
if (candidate.kind === 'adjustment') return null;
if (!candidate.clipped) return candidate;
}
return null;
}
export function canToggleLayerClipping(editorState, layerId) {
const layer = (editorState.layers || []).find(item => item.id === layerId);
if (!layer) return false;
if (layer.clipped) return true;
layer.clipped = true;
const canClip = !!clippingBaseForLayer(editorState, layerId);
layer.clipped = false;
return canClip;
}
/** Clear clipping flags made invalid by delete, grouping, or reorder. */
export function normalizeLayerClipping(editorState) {
const cleared = [];
for (const layer of editorState.layers || []) {
layer.clipped = !!layer.clipped;
if (layer.clipped && !clippingBaseForLayer(editorState, layer.id)) {
layer.clipped = false;
cleared.push(layer.id);
}
}
return cleared;
}
function ensureScratchCanvas(editorState) {
const canvas = editorState.clippingCompositeCanvas || document.createElement('canvas');
editorState.clippingCompositeCanvas = canvas;
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
return canvas;
}
/** Draw one contiguous stack scope, bottom to top. */
export function drawLayerStack(target, editorState, layers, renderLayer, renderAdjustment = null) {
let base = null;
const drawNormal = layer => {
if (!layer.visible) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(renderLayer(layer), offset.x, offset.y);
};
for (const layer of layers) {
if (layer.kind === 'adjustment' && renderAdjustment) {
renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(layer), layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}
/** Async counterpart used by the worker-backed live effect compositor. */
export async function drawLayerStackAsync(target, editorState, layers, renderLayer, renderAdjustment = null, shouldContinue = () => true) {
let base = null;
const drawNormal = async layer => {
if (!layer.visible || !shouldContinue()) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
const rendered = await renderLayer(layer);
if (!shouldContinue() || !rendered) return;
target.drawImage(rendered, offset.x, offset.y);
};
for (const layer of layers) {
if (!shouldContinue()) return;
if (layer.kind === 'adjustment' && renderAdjustment) {
await renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
await drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const renderedLayer = await renderLayer(layer);
const renderedBase = await renderLayer(base);
if (!shouldContinue() || !renderedLayer || !renderedBase) return;
scratchCtx.drawImage(renderedLayer, layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}