1/** 2 * Layer panel renderer â rebuilds the right-side layer list from 3 * `state.layers` every time it's called. The full row tree per layer: 4 * 5 * parent row 6 * [drag handle] [eye] [name] [opacity slider] [FX] [dup] [mask] [merge-down] [Ã] 7 * adjustment sub-rows (FX entries) 8 * [eye] [name+icon] [opacity slider] [merge] [Ã] 9 * mask sub-rows 10 * [eye] [name] [merge-up?] [Ã] 11 * 12 * Reads/writes shared `state` directly (layers, activeLayerId, 13 * layerOffsets, imgWidth, imgHeight, lassoPoints/lassoActive, 14 * wandMask, maskCanvas/maskCtx, nextLayerId). Function deps are 15 * orchestration callbacks still living in galleryEditor.js. 16 * 17 * Returns `{ render }` so the recursive self-call works via closure 18 * over `render` rather than module-state lookup. 19 * 20 * @param {{ 21 * composite: () => void, 22 * saveState: (label?: string) => void, 23 * showLayerThumb: (rowEl: HTMLElement, layer: object) => void, 24 * hideLayerThumb: () => void, 25 * loadLayerAlphaAsSelection: (layer: object) => void, 26 * openFxPopup: (layer: object, anchor: HTMLElement) => void, 27 * editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void, 28 * createLayer: (name: string, w: number, h: number) => object, 29 * lassoToMask: () => void, 30 * wandToMask: () => void, 31 * getActiveMaskLayer: () => object | null, 32 * syncFxPanelToActiveLayerIfPresent: () => void, 33 * dragSortModule: object | null, 34 * uiModule: object | null, 35 * }} deps 36 */ 37import { state } from './state.js'; 38import { 39 layerHasAdjustments, 40 isLayerEmpty, 41 isMaskCanvasEmpty, 42 adjLayerLabel, 43 ADJ_ICONS, 44} from './layer-helpers.js'; 45import { applyAdjustment } from './fx/pixel-pass.js'; 46import { mergeLayerDownAtIndex } from './wire-merge-buttons.js'; 47 48const EYE_OPEN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; 49const EYE_OFF = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>'; 50const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; 51const EYE_OFF_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>'; 52 53export function createLayerPanelRenderer(deps) { 54 const { 55 composite, saveState, showLayerThumb, hideLayerThumb, 56 loadLayerAlphaAsSelection, openFxPopup, editAdjLayer, 57 createLayer, lassoToMask, wandToMask, getActiveMaskLayer, 58 syncFxPanelToActiveLayerIfPresent, 59 dragSortModule, uiModule, 60 } = deps; 61 62 function shouldIgnoreLayerTap() { 63 return Date.now() < (window.__geSuppressLayerTapUntil || 0); 64 } 65 66 function render() { 67 // FX panel mirrors the active layer's adjustments â re-sync on 68 // every layer event (activation, add, delete, etc). 69 try { syncFxPanelToActiveLayerIfPresent(); } catch {} 70 const list = document.getElementById('ge-layers-list'); 71 if (!list) return; 72 // Mobile bottom-sheet peek height â header + N rows, capped so a 73 // 20-layer document doesn't get a peek that eats the canvas. 74 const panel = document.querySelector('.ge-right-panel'); 75 if (panel) { 76 requestAnimationFrame(() => { 77 const header = panel.querySelector('.ge-layers-header');
78 const firstRow = list.querySelector('.ge-layer-item'); 79 const headerH = header ? header.offsetHeight : 52; 80 const rowH = firstRow ? firstRow.offsetHeight : 36; 81 const allRows = list.querySelectorAll('.ge-layer-item').length; 82 const MAX_ROWS = 2; 83 const rows = Math.min(allRows, MAX_ROWS); 84 panel.style.setProperty('--peek-height', `${headerH + rows * rowH + 6}px`); 85 }); 86 } 87 list.innerHTML = ''; 88 89 // Render in reverse order (top layer first). 90 for (let i = state.layers.length - 1; i >= 0; i--) { 91 const layer = state.layers[i]; 92 const item = document.createElement('div'); 93 // Parent row is highlighted ONLY when it's actually the paint 94 // target â activated AND no mask sub-layer is currently active. 95 const parentIsPaintTarget = layer.id === state.activeLayerId && 96 !(layer.masks && layer.activeMaskId && layer.masks.some(m => m.id === layer.activeMaskId)); 97 item.className = 'ge-layer-item' + 98 (parentIsPaintTarget ? ' active' : '') + 99 (layer.id === state.activeLayerId && !parentIsPaintTarget ? ' active-parent' : ''); 100 item.dataset.layerId = layer.id; 101 // Hover thumbnail. 102 item.addEventListener('mouseenter', () => showLayerThumb(item, layer)); 103 item.addEventListener('mouseleave', () => hideLayerThumb()); 104 item.addEventListener('click', (e) => { 105 if (shouldIgnoreLayerTap()) { 106 e.preventDefault(); 107 e.stopPropagation(); 108 return; 109 } 110 // Shift+click â load layer transparency as wand selection. 111 if (e.shiftKey) { 112 e.preventDefault(); 113 loadLayerAlphaAsSelection(layer); 114 return; 115 } 116 if (state.activeLayerId === layer.id) return; 117 state.activeLayerId = layer.id; 118 // Toggle the active class inline (avoid full re-render so the 119 // dblclick listener on the name element stays alive between 120 // clicks â a re-render destroys the element after the first 121 // click and the second lands on a different node). 122 document.querySelectorAll('.ge-layers-list .ge-layer-item').forEach(el => { 123 el.classList.toggle('active', el.dataset.layerId === state.activeLayerId); 124 }); 125 }); 126 127 // Drag handle â grip dots; dragSortModule.enable() below scopes 128 // drag-init to this handle so row body clicks still activate. 129 const handle = document.createElement('span'); 130 handle.className = 'ge-layer-drag'; 131 handle.title = 'Drag to reorder'; 132 handle.innerHTML = '<svg width="8" height="14" viewBox="0 0 8 14" fill="currentColor"><circle cx="2" cy="2" r="1"/><circle cx="6" cy="2" r="1"/><circle cx="2" cy="7" r="1"/><circle cx="6" cy="7" r="1"/><circle cx="2" cy="12" r="1"/><circle cx="6" cy="12" r="1"/></svg>'; 133 item.appendChild(handle); 134 135 const visBtn = document.createElement('button'); 136 visBtn.className = 'ge-layer-vis' + (layer.visible ? ' visible' : ''); 137 visBtn.innerHTML = layer.visible ? EYE_OPEN : EYE_OFF; 138 visBtn.title = layer.visible ? 'Hide layer' : 'Show layer'; 139 visBtn.addEventListener('click', (e) => { 140 e.stopPropagation(); 141 layer.visible = !layer.visible; 142 composite(); 143 render(); 144 }); 145 146 const nameEl = document.createElement('span'); 147 nameEl.className = 'ge-layer-name'; 148 nameEl.textContent = layer.name + (isLayerEmpty(layer) ? ' (empty)' : ''); 149 nameEl.addEventListener('dblclick', () => { 150 const input = document.createElement('input'); 151 input.type = 'text'; 152 input.value = layer.name; 153 input.className = 'ge-layer-name-input'; 154 nameEl.replaceWith(input); 155 input.focus(); 156 const save = () => { layer.name = input.value || layer.name; render(); }; 157 input.addEventListener('blur', save); 158 input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') save(); }); 159 }); 160 161 const opSlider = document.createElement('input'); 162 opSlider.type = 'range'; 163 opSlider.min = '0'; 164 opSlider.max = '100';
165 opSlider.value = String(Math.round(layer.opacity * 100)); 166 opSlider.className = 'ge-layer-opacity'; 167 opSlider.title = 'Opacity'; 168 opSlider.addEventListener('input', (e) => { 169 e.stopPropagation(); 170 layer.opacity = parseInt(e.target.value) / 100; 171 composite(); 172 }); 173 // Browser :active drops the moment the cursor leaves the slider 174 // hit-area in some browsers; a JS-managed `dragging` class 175 // survives the OS pointer-capture so the slider stays expanded 176 // for the whole drag. 177 opSlider.addEventListener('pointerdown', () => { 178 opSlider.classList.add('dragging'); 179 const onUp = () => { 180 opSlider.classList.remove('dragging'); 181 window.removeEventListener('pointerup', onUp); 182 }; 183 window.addEventListener('pointerup', onUp); 184 }); 185 186 const controls = document.createElement('div'); 187 controls.className = 'ge-layer-controls'; 188 189 // FX (adjustments) â opens a floating popup bound to this layer. 190 const fxBtn = document.createElement('button'); 191 fxBtn.className = 'ge-layer-btn ge-layer-fx-btn' + (layerHasAdjustments(layer) ? ' active' : ''); 192 fxBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor"/></svg>'; 193 fxBtn.title = 'Adjust layer (Brightness, Contrast, Saturation, Hue, Levels, Color Balance)'; 194 fxBtn.style.touchAction = 'manipulation'; 195 let lastFxPointerOpenAt = 0; 196 let fxOpenTimer = null; 197 const openLayerFx = (e, delay = 0) => { 198 e.preventDefault?.(); 199 e.stopPropagation(); 200 window.__geSuppressLayerTapUntil = 0; 201 if (fxOpenTimer) clearTimeout(fxOpenTimer); 202 fxOpenTimer = setTimeout(() => { 203 fxOpenTimer = null; 204 openFxPopup(layer, fxBtn); 205 }, delay); 206 }; 207 fxBtn.addEventListener('pointerdown', (e) => { 208 e.stopPropagation(); 209 }); 210 fxBtn.addEventListener('pointerup', (e) => { 211 lastFxPointerOpenAt = Date.now(); 212 const delay = e.pointerType === 'touch' || e.pointerType === 'pen' ? 120 : 0; 213 openLayerFx(e, delay); 214 }); 215 fxBtn.addEventListener('click', (e) => { 216 if (Date.now() - lastFxPointerOpenAt < 500) { 217 e.preventDefault(); 218 e.stopPropagation(); 219 return; 220 } 221 openLayerFx(e); 222 }); 223 controls.appendChild(fxBtn); 224 225 // Duplicate â clones pixels + offset + opacity + masks + adjLayers 226 // + visibility; inserts above the original; new copy becomes 227 // active. 228 const dupBtn = document.createElement('button'); 229 dupBtn.className = 'ge-layer-btn'; 230 dupBtn.title = 'Duplicate layer'; 231 dupBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; 232 dupBtn.addEventListener('click', (e) => { 233 e.stopPropagation(); 234 saveState(`Duplicate "${layer.name}"`); 235 const copy = createLayer(layer.name + ' copy', layer.canvas.width, layer.canvas.height); 236 copy.ctx.drawImage(layer.canvas, 0, 0); 237 copy.opacity = layer.opacity; 238 copy.visible = layer.visible; 239 const srcOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; 240 state.layerOffsets.set(copy.id, { x: srcOff.x, y: srcOff.y }); 241 if (Array.isArray(layer.masks) && layer.masks.length) { 242 copy.masks = layer.masks.map(m => { 243 const c = document.createElement('canvas'); 244 c.width = m.canvas.width; c.height = m.canvas.height; 245 c.getContext('2d').drawImage(m.canvas, 0, 0); 246 return { 247 id: 'mask-' + (state.nextLayerId++), 248 name: m.name, 249 canvas: c, 250 ctx: c.getContext('2d'), 251 visible: m.visible !== false, 252 }; 253 }); 254 } 255 if (Array.isArray(layer.adjLayers) && layer.adjLayers.length) { 256 copy.adjLayers = layer.adjLayers.map(a => ({ 257 id: 'adj-' + Math.random().toString(36).slice(2, 9), 258 type: a.type, 259 name: a.name, 260 visible: a.visible !== false, 261 opacity: a.opacity != null ? a.opacity : 1, 262 params: JSON.parse(JSON.stringify(a.params || {})), 263 })); 264 } 265 const idx = state.layers.findIndex(l => l.id === layer.id); 266 if (idx >= 0) state.layers.splice(idx + 1, 0, copy); 267 else state.layers.push(copy); 268 state.activeLayerId = copy.id; 269 composite(); 270 render(); 271 if (uiModule) uiModule.showToast('Layer duplicated'); 272 }); 273 controls.appendChild(dupBtn); 274 275 // Add-mask â if a lasso/wand selection is active, bake it into a 276 // mask sub-layer on this layer; otherwise create an empty mask 277 // for the user to paint with the Brush tool. 278 const hasLassoSelInitial = state.lassoPoints.length >= 3 && !state.lassoActive; 279 const hasWandSelInitial = !!state.wandMask; 280 const maskBtn = document.createElement('button');
281 maskBtn.className = 'ge-layer-btn ge-layer-mask-btn' + 282 ((hasLassoSelInitial || hasWandSelInitial) ? ' from-selection' : ''); 283 maskBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>'; 284 maskBtn.title = (hasLassoSelInitial || hasWandSelInitial) 285 ? 'Make mask from current selection' 286 : 'Add empty mask (paint with Brush)'; 287 maskBtn.addEventListener('click', (e) => { 288 e.stopPropagation(); 289 // Activate this layer first so the new mask attaches here. 290 state.activeLayerId = layer.id; 291 // Re-check selection state AT CLICK TIME â captured vars may 292 // be stale if a selection was drawn after the panel paint. 293 const hasLassoSel = state.lassoPoints.length >= 3 && !state.lassoActive; 294 const hasWandSel = !!state.wandMask; 295 if (hasLassoSel) { 296 saveState(`Mask from lasso on "${layer.name}"`); 297 // Force a fresh mask sub-layer for this conversion so each 298 // selection becomes its own mask instead of merging into the 299 // previously active one. 300 layer.activeMaskId = null; 301 lassoToMask(); 302 } else if (hasWandSel) { 303 saveState(`Mask from wand on "${layer.name}"`); 304 layer.activeMaskId = null; 305 wandToMask(); 306 } else { 307 saveState(`Add mask to "${layer.name}"`); 308 const c = document.createElement('canvas'); 309 c.width = state.imgWidth; 310 c.height = state.imgHeight; 311 if (!layer.masks) layer.masks = []; 312 const mask = { 313 id: 'mask-' + (state.nextLayerId++), 314 name: 'Mask ' + (layer.masks.length + 1), 315 canvas: c, 316 ctx: c.getContext('2d'), 317 visible: true, 318 }; 319 layer.masks.push(mask); 320 layer.activeMaskId = mask.id; 321 state.maskCanvas = mask.canvas; 322 state.maskCtx = mask.ctx; 323 composite(); 324 render(); 325 } 326 }); 327 controls.appendChild(maskBtn); 328 329 // Per-row Merge Down â bakes this layer into the one beneath. 330 // Hidden on the bottom layer in the visual stack (idx 0 forward). 331 if (i > 0) { 332 const mergeDownBtn = document.createElement('button'); 333 mergeDownBtn.className = 'ge-layer-btn'; 334 mergeDownBtn.title = 'Merge down into layer below'; 335 mergeDownBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>'; 336 mergeDownBtn.addEventListener('click', (e) => { 337 e.stopPropagation(); 338 saveState(`Merge "${layer.name}" down`); 339 mergeLayerDownAtIndex(i); 340 composite(); 341 render(); 342 uiModule.showToast('Layer merged down'); 343 }); 344 controls.appendChild(mergeDownBtn); 345 } 346 347 // Delete â shown for every layer except when this is the last 348 // remaining one. Base photo is deletable too; Ctrl+Z brings it 349 // back from history. Extra confirm for the base layer. 350 if (state.layers.length > 1) { 351 const delBtn = document.createElement('button'); 352 delBtn.className = 'ge-layer-btn danger'; 353 delBtn.textContent = 'Ã'; 354 delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer'; 355 delBtn.addEventListener('click', async (e) => { 356 e.stopPropagation(); 357 if (layer.isBase && uiModule?.styledConfirm) { 358 const ok = await uiModule.styledConfirm( 359 'Delete the original photo layer? Ctrl+Z brings it back.', 360 { confirmText: 'Delete', cancelText: 'Cancel', danger: true } 361 ); 362 if (!ok) return; 363 } 364 // Snapshot BEFORE removing so Ctrl+Z can bring it back. 365 saveState(`Delete layer "${layer.name}"`); 366 state.layers.splice(i, 1); 367 state.layerOffsets.delete(layer.id); 368 if (state.activeLayerId === layer.id) { 369 state.activeLayerId = state.layers[Math.min(i, state.layers.length - 1)].id; 370 } 371 composite(); 372 render(); 373 }); 374 controls.appendChild(delBtn); 375 } 376 377 item.appendChild(visBtn); 378 item.appendChild(nameEl); 379 item.appendChild(opSlider); 380 item.appendChild(controls); 381 382 item.addEventListener('click', () => { 383 if (shouldIgnoreLayerTap()) return; 384 state.activeLayerId = layer.id; 385 // Clicking the PARENT row makes layer pixels the paint target 386 // (mask is no longer the target). Mask sub-rows stay in the 387 // panel; clicking one re-targets it. 388 layer.activeMaskId = null; 389 state.maskCanvas = null; 390 state.maskCtx = null; 391 render(); 392 composite(); 393 }); 394 395 list.appendChild(item); 396 397 // Adjustment sub-layer rows, indented under the parent. 398 if (layer.adjLayers && layer.adjLayers.length) { 399 for (const adj of layer.adjLayers) { 400 const sub = document.createElement('div');
401 sub.className = 'ge-layer-item ge-adj-sub-item'; 402 sub.dataset.adjId = adj.id; 403 const sVis = document.createElement('button'); 404 sVis.className = 'ge-layer-vis' + (adj.visible ? ' visible' : ''); 405 sVis.innerHTML = adj.visible ? EYE_OPEN_SM : EYE_OFF_SM; 406 sVis.title = adj.visible ? 'Hide adjustment' : 'Show adjustment'; 407 sVis.addEventListener('click', (e) => { 408 e.stopPropagation(); 409 adj.visible = !adj.visible; 410 layer._adjFinalKey = null; 411 composite(); 412 render(); 413 }); 414 const sName = document.createElement('span'); 415 sName.className = 'ge-layer-name ge-adj-sub-name'; 416 sName.innerHTML = `<span class="ge-adj-sub-icon">${ADJ_ICONS[adj.type] || ''}</span><span>${(adj.name || adjLayerLabel(adj.type)).replace(/[<>&]/g,'')}</span>`; 417 const sOp = document.createElement('input'); 418 sOp.type = 'range'; 419 sOp.min = '0'; sOp.max = '100'; 420 sOp.value = Math.round(adj.opacity * 100); 421 sOp.className = 'ge-layer-opacity'; 422 sOp.title = 'Adjustment opacity'; 423 sOp.addEventListener('input', () => { 424 adj.opacity = parseInt(sOp.value, 10) / 100; 425 layer._adjFinalKey = null; 426 composite(); 427 }); 428 const sControls = document.createElement('div'); 429 sControls.className = 'ge-layer-controls'; 430 const mergeBtn = document.createElement('button'); 431 mergeBtn.className = 'ge-layer-btn'; 432 mergeBtn.title = 'Merge into layer (bake)'; 433 mergeBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>'; 434 mergeBtn.addEventListener('click', (e) => { 435 e.stopPropagation(); 436 // Bake just this adjustment into layer.canvas, then drop it. 437 saveState(`Merge ${adjLayerLabel(adj.type)}`); 438 const baked = applyAdjustment(layer.canvas, adj); 439 layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height); 440 layer.ctx.drawImage(baked, 0, 0); 441 layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id); 442 layer._adjFinalKey = null; 443 composite(); 444 render(); 445 }); 446 sControls.appendChild(mergeBtn); 447 const delBtn = document.createElement('button'); 448 delBtn.className = 'ge-layer-btn danger'; 449 delBtn.textContent = 'Ã'; 450 delBtn.title = 'Delete adjustment'; 451 delBtn.addEventListener('click', (e) => { 452 e.stopPropagation(); 453 saveState(`Delete ${adjLayerLabel(adj.type)}`); 454 layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id); 455 layer._adjFinalKey = null; 456 composite(); 457 render(); 458 }); 459 sControls.appendChild(delBtn); 460 461 sub.appendChild(sVis); 462 sub.appendChild(sName); 463 sub.appendChild(sOp); 464 sub.appendChild(sControls); 465 // Single-click on the sub-row (outside the inline controls) 466 // reopens the adj popup with this sub-layer's params staged. 467 sub.addEventListener('click', (e) => { 468 if (shouldIgnoreLayerTap()) { 469 e.preventDefault(); 470 e.stopPropagation(); 471 return; 472 } 473 if (e.target.closest('.ge-layer-vis, .ge-layer-opacity, .ge-layer-btn')) return; 474 if (!e.target.closest('.ge-adj-sub-name')) return; 475 e.stopPropagation(); 476 editAdjLayer(layer, adj, sub); 477 }); 478 list.appendChild(sub); 479 } 480 } 481 482 // Mask sub-layer rows. 483 if (layer.masks && layer.masks.length) { 484 for (let mi = 0; mi < layer.masks.length; mi++) { 485 const mk = layer.masks[mi]; 486 const sub = document.createElement('div'); 487 sub.className = 'ge-layer-item ge-adj-sub-item ge-mask-sub-item' + 488 (layer.activeMaskId === mk.id ? ' active' : ''); 489 sub.dataset.maskId = mk.id; 490 const sVis = document.createElement('button');
491 sVis.className = 'ge-layer-vis' + (mk.visible ? ' visible' : ''); 492 sVis.innerHTML = mk.visible ? EYE_OPEN_SM : EYE_OFF_SM; 493 sVis.title = mk.visible ? 'Hide mask' : 'Show mask'; 494 sVis.addEventListener('click', (e) => { 495 e.stopPropagation(); 496 mk.visible = !mk.visible; 497 composite(); 498 render(); 499 }); 500 const sName = document.createElement('span'); 501 sName.className = 'ge-layer-name ge-adj-sub-name'; 502 const maskIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>'; 503 const mkName = String(mk.name || 'Mask').replace(/[<>&]/g, ''); 504 const mkEmpty = isMaskCanvasEmpty(mk.canvas) ? ' <span style="opacity:0.55;">(empty)</span>' : ''; 505 sName.innerHTML = `<span class="ge-adj-sub-icon">${maskIcon}</span><span>${mkName}${mkEmpty}</span>`; 506 const sControls = document.createElement('div'); 507 sControls.className = 'ge-layer-controls'; 508 // Merge-up â combine this mask into the one above (lower mi). 509 if (mi > 0) { 510 const mergeBtn = document.createElement('button'); 511 mergeBtn.className = 'ge-layer-btn'; 512 mergeBtn.title = 'Merge into mask above'; 513 mergeBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="6 11 12 5 18 11"/></svg>'; 514 mergeBtn.addEventListener('click', (e) => { 515 e.stopPropagation(); 516 const above = layer.masks[mi - 1]; 517 if (!above) return; 518 saveState(`Merge mask "${mk.name}" into "${above.name}"`); 519 // Union of alpha â `source-over` already does max for 520 // fully opaque white masks; this also handles partial alpha. 521 above.ctx.save(); 522 above.ctx.globalCompositeOperation = 'source-over'; 523 above.ctx.drawImage(mk.canvas, 0, 0); 524 above.ctx.restore(); 525 layer.masks = layer.masks.filter(x => x.id !== mk.id); 526 if (layer.activeMaskId === mk.id) layer.activeMaskId = above.id; 527 const a = getActiveMaskLayer(); 528 if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; } 529 else { state.maskCanvas = null; state.maskCtx = null; } 530 composite(); 531 render(); 532 }); 533 sControls.appendChild(mergeBtn); 534 } 535 const delBtn = document.createElement('button'); 536 delBtn.className = 'ge-layer-btn danger'; 537 delBtn.textContent = 'Ã'; 538 delBtn.title = 'Delete mask'; 539 delBtn.addEventListener('click', (e) => { 540 e.stopPropagation(); 541 saveState(`Delete mask "${mk.name}"`); 542 layer.masks = layer.masks.filter(x => x.id !== mk.id); 543 if (layer.activeMaskId === mk.id) { 544 layer.activeMaskId = layer.masks[layer.masks.length - 1]?.id || null; 545 } 546 // Sync global mask plumbing. 547 const a = getActiveMaskLayer(); 548 if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; } 549 else { state.maskCanvas = null; state.maskCtx = null; } 550 composite(); 551 render(); 552 }); 553 sControls.appendChild(delBtn); 554 sub.appendChild(sVis); 555 sub.appendChild(sName); 556 sub.appendChild(sControls); 557 sub.addEventListener('click', (e) => { 558 if (e.target.closest('.ge-layer-vis, .ge-layer-btn')) return; 559 e.stopPropagation(); 560 // Activate this mask: paint/inpaint/generate target. 561 layer.activeMaskId = mk.id; 562 state.activeLayerId = layer.id; 563 state.maskCanvas = mk.canvas; 564 state.maskCtx = mk.ctx; 565 render(); 566 composite(); 567 }); 568 list.appendChild(sub); 569 } 570 } 571 } 572 573 // Wire the shared dragSort module â limit drag-init to the grip 574 // handle so row body clicks still activate. Called every render 575 // because `enable()` cleans up the previous instance keyed on 576 // instanceKey. 577 if (dragSortModule) {
578 dragSortModule.enable('ge-layers-list', '.ge-layer-item', { 579 instanceKey: 'ge-layers', 580 handleSelector: '.ge-layer-drag', 581 onReorder: (orderedItems) => { 582 // DOM is topâbottom = reverse of array order, so the new 583 // array is the reverse of the DOM order. 584 const byId = new Map(state.layers.map(l => [l.id, l])); 585 const newLayers = orderedItems 586 .map(el => byId.get(el.dataset.layerId)) 587 .filter(Boolean) 588 .reverse(); 589 if (newLayers.length === state.layers.length) { 590 state.layers = newLayers; 591 saveState(); 592 composite(); 593 } 594 }, 595 }); 596 } 597 } 598 599 return { render }; 600}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.