PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/editor/layer-panel.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:01:05 UTC 28,514 bytes, 600 lines download raw bytes

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.