mirror of
https://github.com/alesimula/Murine-launcher.git
synced 2026-10-28 09:43:42 +00:00
WEBSITE - WIP - icon shapes - touch / interaction fixes
This commit is contained in:
1 parent
18f170b63e
commit
d55084452b
2 files changed
+79
-14
No files matched your search
@@ -20,19 +20,26 @@
|
||||
|
||||
// ---------- geometry ----------
|
||||
|
||||
// How far each corner's rounding actually reaches. Two corners sharing an edge split it in proportion
|
||||
// to their sizes, so they never overlap.
|
||||
function cornerSizes(pts) {
|
||||
const n = pts.length, cap = (p, q, len) => (p.r + q.r <= len ? p.r : (len * p.r) / (p.r + q.r));
|
||||
return pts.map((p, i) => {
|
||||
const a = pts[(i + n - 1) % n], b = pts[(i + 1) % n], la = dist(a, p), lb = dist(p, b);
|
||||
return la < 1e-6 || lb < 1e-6 ? 0 : Math.min(p.r, cap(p, a, la), cap(p, b, lb));
|
||||
});
|
||||
}
|
||||
|
||||
function buildPath(pts) {
|
||||
const n = pts.length, out = [];
|
||||
const n = pts.length, out = [], sizes = cornerSizes(pts);
|
||||
let last = null;
|
||||
const xy = (p) => fmt(p.x) + ' ' + fmt(p.y);
|
||||
// Skip near-zero lines: they become degenerate cubics in the launcher's polygon.
|
||||
const line = (p) => { if (!last || dist(last, p) > 0.05) out.push('L ' + xy(p)); last = p; };
|
||||
// Two corners sharing an edge split it in proportion to their sizes, so they never overlap.
|
||||
const cap = (p, q, len) => (p.r + q.r <= len ? p.r : (len * p.r) / (p.r + q.r));
|
||||
|
||||
pts.forEach((p, i) => {
|
||||
const a = pts[(i + n - 1) % n], b = pts[(i + 1) % n];
|
||||
const la = dist(a, p), lb = dist(p, b);
|
||||
const d = la < 1e-6 || lb < 1e-6 ? 0 : Math.min(p.r, cap(p, a, la), cap(p, b, lb));
|
||||
const la = dist(a, p), lb = dist(p, b), d = sizes[i];
|
||||
if (d < 0.01) return line(p);
|
||||
|
||||
const pin = lerp(p, a, d / la), pout = lerp(p, b, d / lb);
|
||||
@@ -161,6 +168,19 @@
|
||||
const snap = (v) => clamp(grid.snap ? Math.round(v / grid.step) * grid.step : +v.toFixed(2), view.lo, view.hi);
|
||||
const checkpoint = () => { undo.push(JSON.stringify(snapshot())); if (undo.length > 100) undo.shift(); };
|
||||
const load = (p) => { checkpoint(); pts = p; fixed = null; sel = -1; render(); };
|
||||
const withSize = (i, r) => pts.map((p, k) => (k === i ? { ...p, r } : p));
|
||||
const neighbours = (i) => [(i + pts.length - 1) % pts.length, (i + 1) % pts.length];
|
||||
// Largest size corner i can take before it starts shrinking a neighbour's rounding. Neighbours only
|
||||
// ever shrink as it grows, so a binary search finds the point.
|
||||
function squeezeStart(i, max) {
|
||||
const nb = neighbours(i);
|
||||
const sum = (r) => { const d = cornerSizes(withSize(i, r)); return d[nb[0]] + d[nb[1]]; };
|
||||
const base = sum(0);
|
||||
if (sum(max) >= base - 0.01) return max;
|
||||
let lo = 0, hi = max;
|
||||
for (let k = 0; k < 20; k++) { const m = (lo + hi) / 2; if (sum(m) < base - 0.01) hi = m; else lo = m; }
|
||||
return lo;
|
||||
}
|
||||
const loadFixed = (name) => { checkpoint(); fixed = name; sel = -1; render(); };
|
||||
|
||||
// ---------- rendering ----------
|
||||
@@ -197,6 +217,12 @@
|
||||
const d = fixed ? FIXED[fixed] : buildPath(pts), bad = !fixed && selfIntersects(pts);
|
||||
$('se-shape').setAttribute('d', d);
|
||||
$('se-outline').setAttribute('points', fixed ? '' : pts.map((p) => p.x + ',' + p.y).join(' '));
|
||||
// Neighbours whose rounding the selected corner is currently eating into.
|
||||
const squeezed = new Set();
|
||||
if (!fixed && sel >= 0) {
|
||||
const now = cornerSizes(pts), alone = cornerSizes(withSize(sel, 0));
|
||||
for (const j of neighbours(sel)) if (now[j] < alone[j] - 0.01) squeezed.add(j);
|
||||
}
|
||||
|
||||
const handles = $('se-handles'), adders = $('se-adders');
|
||||
const focused = document.activeElement?.dataset?.i !== undefined;
|
||||
@@ -206,6 +232,7 @@
|
||||
const q = pts[(i + 1) % pts.length];
|
||||
if (!drag && dist(p, q) > 10) {
|
||||
const m = lerp(p, q, 0.5), g = el('g', { 'data-add': i, class: 'se-add' });
|
||||
g.append(el('circle', { cx: m.x, cy: m.y, r: 5, class: 'se-hit' })); // finger-sized target
|
||||
g.append(el('circle', { cx: m.x, cy: m.y, r: 2.6 }));
|
||||
g.append(el('path', { d: `M${m.x - 1.4} ${m.y}h2.8M${m.x} ${m.y - 1.4}v2.8` }));
|
||||
const t = el('title', {});
|
||||
@@ -215,7 +242,7 @@
|
||||
}
|
||||
const h = el('g', {
|
||||
'data-i': i, tabindex: 0, role: 'button', 'aria-label': `Point ${i + 1}`,
|
||||
class: 'se-handle' + (i === sel ? ' selected' : ''),
|
||||
class: 'se-handle' + (i === sel ? ' selected' : '') + (squeezed.has(i) ? ' squeezed' : ''),
|
||||
});
|
||||
h.append(el('circle', { cx: p.x, cy: p.y, r: 6, class: 'se-hit' }));
|
||||
h.append(el('circle', { cx: p.x, cy: p.y, r: 2.4 }));
|
||||
@@ -232,6 +259,16 @@
|
||||
const maxSize = sel >= 0 ? reach(sel) : Math.max(...pts.map((_, i) => reach(i) / 2));
|
||||
$('se-size').max = Math.max(0.1, Math.ceil(maxSize * 10) / 10); // set before value, which the browser clamps to it
|
||||
$('se-size').value = cur.r; $('se-size-v').textContent = fmt(Math.min(cur.r, +$('se-size').max));
|
||||
const max = +$('se-size').max, zone = sel >= 0 && !fixed ? squeezeStart(sel, max) : max, hasZone = zone < max - 0.05;
|
||||
$('se-size-track').classList.toggle('has-zone', hasZone);
|
||||
$('se-size-track').style.setProperty('--squeeze', zone / max);
|
||||
// Only the text changes while the slider is dragged (the zone depends on the neighbours, not on this
|
||||
// corner's size): showing or hiding an element under the thumb makes Chrome drop the drag.
|
||||
const warn = $('se-size-warn');
|
||||
warn.hidden = !hasZone;
|
||||
warn.classList.toggle('active', squeezed.size > 0);
|
||||
warn.textContent = squeezed.size ? 'Shrinking the highlighted neighbouring corners'
|
||||
: 'Past the red mark it also shrinks the neighbouring corners';
|
||||
$('se-smooth').value = Math.round(cur.s * 100); $('se-smooth-v').textContent = Math.round(cur.s * 100) + '%';
|
||||
$('se-apply-all').disabled = sel < 0;
|
||||
$('se-x').disabled = $('se-y').disabled = sel < 0;
|
||||
@@ -255,9 +292,22 @@
|
||||
|
||||
const toSvg = (e) => new DOMPoint(e.clientX, e.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
||||
|
||||
// Touch: only a point or a "+" captures the finger; anywhere else on the canvas the page scrolls and
|
||||
// pinch-zooms as usual. A touch that starts on a point or "+" cancels scrolling in touchstart.
|
||||
let tapOnBackground = false;
|
||||
svg.addEventListener('touchstart', (e) => {
|
||||
if (!fixed && e.target.closest('[data-i], [data-add]')) e.preventDefault();
|
||||
}, { passive: false });
|
||||
// A tap (not a scroll, which ends in pointercancel instead of click) on the background deselects.
|
||||
svg.addEventListener('click', () => {
|
||||
if (tapOnBackground && sel >= 0) { sel = -1; render(); }
|
||||
tapOnBackground = false;
|
||||
});
|
||||
|
||||
svg.addEventListener('pointerdown', (e) => {
|
||||
if (fixed) return;
|
||||
const h = e.target.closest('[data-i]'), add = e.target.closest('[data-add]');
|
||||
tapOnBackground = !h && !add;
|
||||
if (add) {
|
||||
checkpoint();
|
||||
const i = +add.dataset.add, a = pts[i], b = pts[(i + 1) % pts.length], m = lerp(a, b, 0.5);
|
||||
@@ -266,14 +316,13 @@
|
||||
} else if (h) {
|
||||
checkpoint();
|
||||
sel = +h.dataset.i;
|
||||
} else {
|
||||
sel = -1;
|
||||
return render();
|
||||
}
|
||||
} else return;
|
||||
drag = true;
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
render();
|
||||
// Next frame: rendering now would replace the touched element before its touchstart fires, and a
|
||||
// detached element's touchstart never reaches the listener above that stops the page scrolling.
|
||||
requestAnimationFrame(render);
|
||||
});
|
||||
svg.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
@@ -425,6 +474,9 @@
|
||||
render();
|
||||
}
|
||||
addEventListener('hashchange', openEditLink);
|
||||
// Fingers can't hit 5-unit cells (about 13px on a phone-sized canvas), so touch screens start at 10.
|
||||
// Pixel density doesn't matter here: sizes are in CSS pixels, which already account for it.
|
||||
if (matchMedia('(pointer: coarse)').matches) $('se-step').value = grid.step = 10;
|
||||
drawGrid();
|
||||
render();
|
||||
openEditLink();
|
||||
|
||||
+16
-3
@@ -19,7 +19,7 @@ noindex: true # and out of search engines
|
||||
|
||||
.se-editor { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; align-items: start; }
|
||||
@media (max-width: 760px) { .se-editor { grid-template-columns: 1fr; } }
|
||||
#se-canvas { width: 100%; aspect-ratio: 1; display: block; touch-action: none; user-select: none;
|
||||
#se-canvas { width: 100%; aspect-ratio: 1; display: block; touch-action: manipulation; user-select: none;
|
||||
background: var(--md-surface-container-lowest); border: 1px solid var(--md-outline-variant);
|
||||
border-radius: var(--md-shape-lg); outline: none; }
|
||||
#se-canvas:focus-visible { box-shadow: 0 0 0 2px var(--md-primary); }
|
||||
@@ -34,7 +34,8 @@ noindex: true # and out of search engines
|
||||
.se-hit { fill: transparent; }
|
||||
.se-add { cursor: copy; opacity: .55; }
|
||||
.se-add:hover { opacity: 1; }
|
||||
.se-add circle { fill: var(--md-surface-container-lowest); stroke: var(--md-outline); stroke-width: .35; }
|
||||
.se-handle, .se-add { touch-action: none; }
|
||||
.se-add circle:not(.se-hit) { fill: var(--md-surface-container-lowest); stroke: var(--md-outline); stroke-width: .35; }
|
||||
.se-add path { stroke: var(--md-on-surface-variant); stroke-width: .45; }
|
||||
|
||||
.se-panel fieldset { border: 1px solid var(--md-outline-variant); border-radius: var(--md-shape-md);
|
||||
@@ -43,6 +44,17 @@ noindex: true # and out of search engines
|
||||
.se-panel label { display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
font-size: .88rem; margin: 4px 0; }
|
||||
.se-panel input[type=range] { width: 100%; accent-color: var(--md-primary); }
|
||||
/* Tinted end of the size slider: past it, the selected corner shrinks its neighbours' rounding.
|
||||
8px is about half the native thumb, so the tint starts under the thumb's centre. */
|
||||
/* Native range inputs ignore their own background, so the tint sits on a wrapper behind the input. */
|
||||
.se-size-track { display: flex; }
|
||||
.se-size-track.has-zone { background: linear-gradient(to right, transparent calc(8px + (100% - 16px) * var(--squeeze)),
|
||||
color-mix(in srgb, var(--md-tertiary) 55%, transparent) 0) 0 100% / 100% 4px no-repeat; }
|
||||
.se-size-track input { margin-bottom: 6px; }
|
||||
.se-panel .se-size-warn { color: var(--md-on-surface-variant); font-size: .78rem; line-height: 1.4; min-height: 2.8em; margin: 0 0 4px; }
|
||||
.se-panel .se-size-warn.active { color: var(--md-tertiary); }
|
||||
.se-handle.squeezed circle:not(.se-hit) { stroke: var(--md-tertiary);
|
||||
fill: color-mix(in srgb, var(--md-tertiary) 35%, var(--md-surface-container-lowest)); }
|
||||
.se-panel input[type=number], .se-panel select { width: 5.5em; font: inherit; padding: 2px 6px;
|
||||
color: var(--md-on-surface); background: var(--md-surface-container); border: 1px solid var(--md-outline-variant);
|
||||
border-radius: var(--md-shape-xs); }
|
||||
@@ -101,7 +113,8 @@ noindex: true # and out of search engines
|
||||
<fieldset id="se-corner-set">
|
||||
<legend id="se-corner-legend">All corners</legend>
|
||||
<label for="se-size">Size <span id="se-size-v"></span></label>
|
||||
<input id="se-size" type="range" min="0" max="50" step="0.1">
|
||||
<div id="se-size-track" class="se-size-track"><input id="se-size" type="range" min="0" max="50" step="0.1"></div>
|
||||
<p id="se-size-warn" class="se-size-warn" hidden></p>
|
||||
<label for="se-smooth">Smoothing <span id="se-smooth-v"></span></label>
|
||||
<input id="se-smooth" type="range" min="0" max="100" step="1">
|
||||
<div class="se-scale"><span>Cut</span><span>Round</span><span>Squircle</span></div>
|
||||
|
||||
Reference in new issue
Block a user