PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/editor/tools/transform-session.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:01:12 UTC 15,508 bytes, 381 lines download raw bytes

1/**
2 * Transform-tool session lifecycle + floating popup wiring.
3 *
4 *   _startTransform        snapshot the active layer + open popup
5 *   _openTransformPopup    build the W/H/rotation popup, wire inputs
6 *   _wireTransformDrag     header drag, mobile + desktop position handling
7 *   _reapplyTransform      live preview re-render from the snapshot
8 *   _confirmTransform      commit + clear session state
9 *   _cancelTransform       restore via undo() + clear session state
10 *
11 * Handle-drag interactions on the CANVAS (corner / rotation grip) live
12 * in `editor/tools/transform-drag.js` — those mutate the same staged
13 * `state.transformPending*` fields that the popup inputs do, so both
14 * surfaces stay in sync via `_reapplyTransform()`.
15 *
16 * @param {{
17 *   activeLayer:           () => object | null,
18 *   saveState:             (label?: string) => void,
19 *   composite:             () => void,
20 *   fitZoom:               () => void,
21 *   drawTransformHandles:  () => void,
22 *   showCanvasLoading:     (label: string) => void,
23 *   hideCanvasLoading:     () => void,
24 *   undo:                  () => void,
25 *   uiModule:              object | null,
26 * }} deps
27 *
28 * @returns {{
29 *   startTransform, openTransformPopup, closeTransformPopup,
30 *   reapplyTransform, confirmTransform, cancelTransform,
31 * }}
32 */
33import { state } from '../state.js';
34import {
35  transformPopupHTML,
36  attachSpinRepeat,
37} from '../build/transform-popup.js';
38
39export function createTransformSession({
40  activeLayer, saveState, composite, fitZoom, drawTransformHandles,
41  showCanvasLoading, hideCanvasLoading, undo, uiModule,
42}) {
43  function startTransform() {
44    const layer = activeLayer();
45    if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
46    if (state.transformActive) { cancelTransform(); return; } // toggle off
47    state.transformActive = true;
48    state.transformLayer = layer;
49    state.transformOrigW = layer.canvas.width;
50    state.transformOrigH = layer.canvas.height;
51    state.transformPendingW = state.transformOrigW;
52    state.transformPendingH = state.transformOrigH;
53    state.transformPendingRot = 0;
54    state.transformPendingFlipH = false;
55    state.transformPendingFlipV = false;
56    // Snapshot the layer so live preview can re-derive from the
57    // original pixels on every keystroke instead of stacking
58    // destructive edits.
59    state.transformOrigCanvas = document.createElement('canvas');
60    state.transformOrigCanvas.width = state.transformOrigW;
61    state.transformOrigCanvas.height = state.transformOrigH;
62    state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
63    state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
64    saveState();
65    // Fit canvas to viewport so the corner handles are visible —
66    // without this, a layer larger than the viewport leaves the grab
67    // markers off-screen.
68    try { fitZoom(); } catch {}
69    composite();
70    drawTransformHandles();
71    openTransformPopup();
72  }
73
74  function closeTransformPopup() {
75    if (state.transformPopup) {
76      try { state.transformPopup.remove(); } catch {}
77      state.transformPopup = null;
78    }
79  }
80
81  // Floating Transform popup — horizontal layout, draggable via its
82  // header, anchored over the right panel (layers area) by default
83  // so it doesn't cover the canvas. Lets the user type exact W/H/Rot
84  // and flip via negative values.
85  function openTransformPopup() {
86    closeTransformPopup();
87    if (!state.container) return;
88    const pop = document.createElement('div');
89    pop.className = 'ge-transform-popup';
90    pop.innerHTML = transformPopupHTML();
91    state.container.appendChild(pop);
92    state.transformPopup = pop;
93    wireTransformDrag(pop);
94    const wInput = pop.querySelector('#ge-transform-w');
95    const hInput = pop.querySelector('#ge-transform-h');
96    const rotInput = pop.querySelector('#ge-transform-rot');
97    const aspectBtn = pop.querySelector('#ge-transform-aspect');
98    wInput.value = String(state.transformOrigW);
99    hInput.value = String(state.transformOrigH);
100    rotInput.value = '0';
101    aspectBtn.classList.toggle('active', state.transformAspectLock);
102    aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
103
104    // Aspect-lock follower model: while the lock is engaged, ONE
105    // field is the "driver" and the other is read-only + dimmed.
106    // Driver = whichever field the user last typed in. Toggling the
107    // chain releases the follower.
108    let driver = null;
109    const applyAspectVisuals = () => {
110      if (!state.transformAspectLock || !driver) {
111        wInput.readOnly = false;
112        hInput.readOnly = false;
113        wInput.classList.remove('ge-transform-input-locked');
114        hInput.classList.remove('ge-transform-input-locked');
115        return;
116      }
117      const followerW = driver === 'h';
118      const followerH = driver === 'w';
119      wInput.readOnly = followerW;
120      hInput.readOnly = followerH;
121      wInput.classList.toggle('ge-transform-input-locked', followerW);
122      hInput.classList.toggle('ge-transform-input-locked', followerH);
123    };
124    const refresh = () => {
125      let w = parseInt(wInput.value, 10);
126      let h = parseInt(hInput.value, 10);
127      const rot = parseInt(rotInput.value, 10) || 0;
128      state.transformPendingFlipH = w < 0;
129      state.transformPendingFlipV = h < 0;
130      w = Math.abs(w || state.transformOrigW);
131      h = Math.abs(h || state.transformOrigH);
132      state.transformPendingW = Math.max(1, w);
133      state.transformPendingH = Math.max(1, h);
134      state.transformPendingRot = rot;
135      reapplyTransform();
136    };
137    wInput.addEventListener('input', () => {
138      if (state.transformAspectLock) {
139        driver = 'w';
140        const w = parseInt(wInput.value, 10);
141        if (!Number.isNaN(w) && state.transformOrigW > 0) {
142          const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1;
143          const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign;
144          hInput.value = String(newH);
145        }
146        applyAspectVisuals();
147      }
148      refresh();
149    });
150    hInput.addEventListener('input', () => {
151      if (state.transformAspectLock) {
152        driver = 'h';
153        const h = parseInt(hInput.value, 10);
154        if (!Number.isNaN(h) && state.transformOrigH > 0) {
155          const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1;
156          const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign;
157          wInput.value = String(newW);
158        }
159        applyAspectVisuals();
160      }
161      refresh();
162    });
163    rotInput.addEventListener('input', refresh);
164    aspectBtn.addEventListener('click', () => {
165      state.transformAspectLock = !state.transformAspectLock;
166      aspectBtn.classList.toggle('active', state.transformAspectLock);
167      aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
168      // Reset follower the moment the user breaks the lock so both
169      // fields go editable; re-engaging means "next type sets the driver".
170      driver = null;
171      applyAspectVisuals();
172    });
173    pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform());
174    pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform());
175    pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform());
176    // Minimise — collapses the body so only the header is visible.
177    pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => {
178      e.stopPropagation();
179      pop.classList.toggle('ge-transform-popup-minimised');
180    });
181    // Quick actions: flip W/H via sign so the reapply pipeline picks
182    // up the new orientation. Rotate-90 nudges rotation ±90°.
183    pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => {
184      const wIn = pop.querySelector('#ge-transform-w');
185      const cur = parseInt(wIn.value, 10) || state.transformOrigW;
186      wIn.value = String(-cur);
187      wIn.dispatchEvent(new Event('input', { bubbles: true }));
188    });
189    pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => {
190      const hIn = pop.querySelector('#ge-transform-h');
191      const cur = parseInt(hIn.value, 10) || state.transformOrigH;
192      hIn.value = String(-cur);
193      hIn.dispatchEvent(new Event('input', { bubbles: true }));
194    });
195    pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => {
196      const rIn = pop.querySelector('#ge-transform-rot');
197      const cur = parseInt(rIn.value, 10) || 0;
198      const delta = e.shiftKey ? -90 : 90;
199      let next = cur + delta;
200      while (next > 180) next -= 360;
201      while (next <= -180) next += 360;
202      rIn.value = String(next);
203      // Big images: rotation pass blocks UI ~0.5–2 s. Show a spinner
204      // so the user sees something happen. rAF defers the heavy work
205      // past the current frame so the overlay paints first.
206      showCanvasLoading('Rotating…');
207      requestAnimationFrame(() => {
208        try { rIn.dispatchEvent(new Event('input', { bubbles: true })); }
209        finally { hideCanvasLoading(); }
210      });
211    });
212    attachSpinRepeat(pop);
213  }
214
215  // Header-drag for the Transform popup. Default position: over the
216  // right panel (layers area). Mobile pins via stylesheet so we use
217  // setProperty 'important' to override during drag.
218  function wireTransformDrag(pop) {
219    const isMobile = window.matchMedia('(max-width: 820px)').matches;
220    const defaultRight = 20;
221    const defaultTop = 60;
222    if (isMobile) {
223      pop.style.setProperty('position', 'fixed', 'important');
224    } else {
225      pop.style.position = 'absolute';
226      pop.style.right = defaultRight + 'px';
227      pop.style.top = defaultTop + 'px';
228      pop.style.left = 'auto';
229    }
230    const dragSource = pop.querySelector('[data-transform-drag]') || pop;
231    let dragging = false;
232    let startX = 0, startY = 0, originLeft = 0, originTop = 0;
233    const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]';
234
235    const setPos = (x, y) => {
236      if (isMobile) {
237        pop.style.setProperty('left', x + 'px', 'important');
238        pop.style.setProperty('top', y + 'px', 'important');
239        pop.style.setProperty('right', 'auto', 'important');
240        pop.style.setProperty('bottom', 'auto', 'important');
241        pop.style.setProperty('width', 'auto', 'important');
242        pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
243      } else {
244        pop.style.left = x + 'px';
245        pop.style.top = y + 'px';
246        pop.style.right = 'auto';
247      }
248    };
249
250    const beginDrag = (clientX, clientY) => {
251      dragging = true;
252      const rect = pop.getBoundingClientRect();
253      if (isMobile) {
254        originLeft = rect.left;
255        originTop = rect.top;
256      } else {
257        const parentRect = state.container.getBoundingClientRect();
258        originLeft = rect.left - parentRect.left;
259        originTop = rect.top - parentRect.top;
260      }
261      startX = clientX;
262      startY = clientY;
263      setPos(originLeft, originTop);
264      pop.classList.add('ge-transform-popup-dragging');
265      document.body.style.userSelect = 'none';
266    };
267
268    const moveDrag = (clientX, clientY) => {
269      if (!dragging) return;
270      const dx = clientX - startX;
271      const dy = clientY - startY;
272      let nx = originLeft + dx;
273      let ny = originTop + dy;
274      if (isMobile) {
275        const rect = pop.getBoundingClientRect();
276        nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx));
277        ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny));
278      }
279      setPos(nx, ny);
280    };
281
282    const endDrag = () => {
283      if (!dragging) return;
284      dragging = false;
285      document.body.style.userSelect = '';
286      pop.classList.remove('ge-transform-popup-dragging');
287    };
288
289    dragSource.addEventListener('mousedown', (e) => {
290      if (e.target.closest(NON_DRAG)) return;
291      e.preventDefault();
292      beginDrag(e.clientX, e.clientY);
293    });
294    document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
295    document.addEventListener('mouseup', endDrag);
296
297    dragSource.addEventListener('touchstart', (e) => {
298      if (e.target.closest(NON_DRAG)) return;
299      if (!e.touches || e.touches.length !== 1) return;
300      e.preventDefault();
301      beginDrag(e.touches[0].clientX, e.touches[0].clientY);
302    }, { passive: false });
303    document.addEventListener('touchmove', (e) => {
304      if (!dragging) return;
305      if (!e.touches || e.touches.length !== 1) return;
306      e.preventDefault();
307      moveDrag(e.touches[0].clientX, e.touches[0].clientY);
308    }, { passive: false });
309    document.addEventListener('touchend', endDrag);
310    document.addEventListener('touchcancel', endDrag);
311  }
312
313  // Re-derive the active layer's pixels from the original snapshot
314  // with the popup's current W/H/flip/rotation applied. Cheap —
315  // paints into an off-screen canvas of the final size.
316  function reapplyTransform() {
317    const layer = state.transformLayer;
318    if (!layer || !state.transformOrigCanvas) return;
319    const w = state.transformPendingW;
320    const h = state.transformPendingH;
321    const rotDeg = state.transformPendingRot;
322    const rotRad = (rotDeg * Math.PI) / 180;
323    const cos = Math.abs(Math.cos(rotRad));
324    const sin = Math.abs(Math.sin(rotRad));
325    // Bounding box of the rotated W×H — canvas grows so corners
326    // don't clip.
327    const finalW = Math.max(1, Math.round(w * cos + h * sin));
328    const finalH = Math.max(1, Math.round(w * sin + h * cos));
329    const tmp = document.createElement('canvas');
330    tmp.width = finalW; tmp.height = finalH;
331    const tCtx = tmp.getContext('2d');
332    tCtx.imageSmoothingEnabled = true;
333    tCtx.imageSmoothingQuality = 'high';
334    tCtx.save();
335    tCtx.translate(finalW / 2, finalH / 2);
336    if (rotDeg) tCtx.rotate(rotRad);
337    tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
338    tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
339    tCtx.restore();
340    layer.canvas.width = finalW;
341    layer.canvas.height = finalH;
342    layer.ctx.clearRect(0, 0, finalW, finalH);
343    layer.ctx.drawImage(tmp, 0, 0);
344    // Recenter the layer so the rotation pivot stays put visually.
345    const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
346    const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
347    state.layerOffsets.set(layer.id, {
348      x: Math.round(origCenterX - finalW / 2),
349      y: Math.round(origCenterY - finalH / 2),
350    });
351    composite();
352    drawTransformHandles();
353  }
354
355  function confirmTransform() {
356    closeTransformPopup();
357    state.transformOrigCanvas = null;
358    state.transformOrigOffset = null;
359    state.transformActive = false;
360    state.transformLayer = null;
361    state.transformHandle = null;
362    composite();
363    uiModule.showToast('Transform applied');
364  }
365
366  function cancelTransform() {
367    closeTransformPopup();
368    state.transformOrigCanvas = null;
369    state.transformOrigOffset = null;
370    if (state.transformLayer) undo(); // restore saved state
371    state.transformActive = false;
372    state.transformLayer = null;
373    state.transformHandle = null;
374    composite();
375  }
376
377  return {
378    startTransform, openTransformPopup, closeTransformPopup,
379    reapplyTransform, confirmTransform, cancelTransform,
380  };
381}

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.