110 lines
5.3 KiB
JavaScript
110 lines
5.3 KiB
JavaScript
/**
|
||
* Static markup for the Transform popup that floats over the canvas
|
||
* when the user activates the Resize/Transform tool.
|
||
*
|
||
* Pure DOM — no module state, no event listeners. The caller wires all
|
||
* IDs via document.getElementById / pop.querySelector.
|
||
*
|
||
* @returns {string}
|
||
*/
|
||
export function transformPopupHTML() {
|
||
return `
|
||
<div class="ge-adj-head ge-transform-popup-head" data-transform-drag>
|
||
<span class="ge-adj-icon">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 7 7 3 11 7"/><line x1="7" y1="3" x2="7" y2="21"/><polyline points="21 17 17 21 13 17"/><line x1="17" y1="21" x2="17" y2="3"/></svg>
|
||
</span>
|
||
<span class="ge-adj-title">Transform</span>
|
||
<button type="button" id="ge-transform-aspect" class="ge-transform-aspect-btn" title="Lock aspect ratio" aria-pressed="true">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
</button>
|
||
<span class="ge-head-btns">
|
||
<button class="ge-adj-min" type="button" title="Minimise" id="ge-transform-min">−</button>
|
||
<button class="ge-adj-close" type="button" title="Cancel" id="ge-transform-cancel">×</button>
|
||
</span>
|
||
</div>
|
||
<div class="ge-transform-popup-body">
|
||
<div class="ge-transform-field">
|
||
<label>W</label>
|
||
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
|
||
<span class="ge-transform-spin" data-spin-for="ge-transform-w">
|
||
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease width">−</button>
|
||
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase width">+</button>
|
||
</span>
|
||
</div>
|
||
<div class="ge-transform-field">
|
||
<label>H</label>
|
||
<input type="number" class="ge-transform-popup-input" id="ge-transform-h" step="1" />
|
||
<span class="ge-transform-spin" data-spin-for="ge-transform-h">
|
||
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease height">−</button>
|
||
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase height">+</button>
|
||
</span>
|
||
</div>
|
||
<div class="ge-row-break"></div>
|
||
<div class="ge-transform-field">
|
||
<label>↻</label>
|
||
<input type="number" class="ge-transform-popup-input ge-transform-popup-input-rot" id="ge-transform-rot" step="1" value="0" />
|
||
<span class="ge-transform-spin" data-spin-for="ge-transform-rot">
|
||
<button type="button" data-spin="down" tabindex="-1" aria-label="Rotate -1°">−</button>
|
||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||
</span>
|
||
</div>
|
||
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
|
||
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
|
||
</div>
|
||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||
`;
|
||
}
|
||
|
||
|
||
/**
|
||
* Wire a `<span class="ge-transform-spin">…<button data-spin="up|down"/>…</span>`
|
||
* group with tap-to-tick + hold-to-repeat. After 1.5 s the repeat
|
||
* accelerates from 70ms→30ms intervals so users can rapidly scrub a
|
||
* numeric field without mashing the button.
|
||
*
|
||
* On each tick, the helper looks up the target `<input>` by the
|
||
* spin-group's `data-spin-for` attribute and dispatches an `input`
|
||
* event so the rest of the popup's wiring picks up the change.
|
||
*
|
||
* @param {HTMLElement} root Element that owns one or more spin groups
|
||
* (e.g. the transform popup).
|
||
*/
|
||
export function attachSpinRepeat(root) {
|
||
root.querySelectorAll('.ge-transform-spin button').forEach(btn => {
|
||
const tick = (shift) => {
|
||
const targetId = btn.parentElement?.dataset?.spinFor;
|
||
if (!targetId) return;
|
||
const input = root.querySelector('#' + CSS.escape(targetId));
|
||
if (!input || input.readOnly) return;
|
||
const step = shift ? 10 : 1;
|
||
const cur = parseInt(input.value, 10) || 0;
|
||
const next = btn.dataset.spin === 'up' ? cur + step : cur - step;
|
||
input.value = String(next);
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
};
|
||
let holdTimeout = null, repeatInterval = null, started = 0;
|
||
btn.addEventListener('pointerdown', (e) => {
|
||
e.preventDefault();
|
||
tick(e.shiftKey);
|
||
started = Date.now();
|
||
holdTimeout = setTimeout(() => {
|
||
repeatInterval = setInterval(() => {
|
||
tick(false);
|
||
if (Date.now() - started > 1500 && repeatInterval) {
|
||
clearInterval(repeatInterval);
|
||
repeatInterval = setInterval(() => tick(false), 30);
|
||
}
|
||
}, 70);
|
||
}, 350);
|
||
});
|
||
const endHold = () => {
|
||
if (holdTimeout) clearTimeout(holdTimeout);
|
||
if (repeatInterval) clearInterval(repeatInterval);
|
||
holdTimeout = null; repeatInterval = null;
|
||
};
|
||
btn.addEventListener('pointerup', endHold);
|
||
btn.addEventListener('pointerleave', endHold);
|
||
btn.addEventListener('pointercancel', endHold);
|
||
});
|
||
}
|