feat: initial release
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CSS Matrix3D Perspective Calculator</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>CSS Matrix3D Perspective Calculator</h1>
|
||||||
|
<p>Задайте 4 точки на поле и размер виджета — получите CSS matrix3d() для перспективного отображения</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="left-col">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Preview — перетаскивайте углы</h2>
|
||||||
|
<svg id="svg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Live Preview</h2>
|
||||||
|
<div id="fieldContainer">
|
||||||
|
<div id="field">
|
||||||
|
<div id="widget">
|
||||||
|
<span>WIDGET</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-col">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Основное поле</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<label><span>W</span><input id="inpFieldW" type="number" value="1920" min="1"></label>
|
||||||
|
<label><span>H</span><input id="inpFieldH" type="number" value="1080" min="1"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Виджет</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<label><span>W</span><input id="inpWidgetW" type="number" value="400" min="1"></label>
|
||||||
|
<label><span>H</span><input id="inpWidgetH" type="number" value="300" min="1"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Углы четырёхугольника</h2>
|
||||||
|
<div class="corner-inputs" id="cornerInputs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>CSS Output</h2>
|
||||||
|
<textarea id="cssOutput" readonly></textarea>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-copy" id="copyBtn">Копировать CSS</button>
|
||||||
|
<button class="btn btn-reset" id="resetBtn">Сброс</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
(() => {
|
||||||
|
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
fieldW: 1920, fieldH: 1080,
|
||||||
|
widgetW: 400, widgetH: 300,
|
||||||
|
corners: [
|
||||||
|
{ x: 300, y: 200 },
|
||||||
|
{ x: 1620, y: 150 },
|
||||||
|
{ x: 1580, y: 920 },
|
||||||
|
{ x: 340, y: 950 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
fieldW: 1920, fieldH: 1080,
|
||||||
|
widgetW: 400, widgetH: 300,
|
||||||
|
corners: [
|
||||||
|
{ x: 300, y: 200 },
|
||||||
|
{ x: 1620, y: 150 },
|
||||||
|
{ x: 1580, y: 920 },
|
||||||
|
{ x: 340, y: 950 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const svg = $('svg');
|
||||||
|
|
||||||
|
// ===== Homography math =====
|
||||||
|
function computeHomography() {
|
||||||
|
const w = state.widgetW, h = state.widgetH;
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
const [p0, p1, p2, p3] = state.corners;
|
||||||
|
|
||||||
|
const a11 = p2.x - p1.x, a12 = p2.x - p3.x;
|
||||||
|
const b1 = p1.x + p3.x - p0.x - p2.x;
|
||||||
|
const a21 = p2.y - p1.y, a22 = p2.y - p3.y;
|
||||||
|
const b2 = p1.y + p3.y - p0.y - p2.y;
|
||||||
|
|
||||||
|
const D = a11 * a22 - a12 * a21;
|
||||||
|
if (Math.abs(D) < 1e-10) return null;
|
||||||
|
|
||||||
|
const A = (b1 * a22 - b2 * a12) / D;
|
||||||
|
const B = (a11 * b2 - a21 * b1) / D;
|
||||||
|
|
||||||
|
return {
|
||||||
|
h00: (p1.x * (A + 1) - p0.x) / w,
|
||||||
|
h10: (p1.y * (A + 1) - p0.y) / w,
|
||||||
|
h01: (p3.x * (B + 1) - p0.x) / h,
|
||||||
|
h11: (p3.y * (B + 1) - p0.y) / h,
|
||||||
|
h02: p0.x,
|
||||||
|
h12: p0.y,
|
||||||
|
h20: A / w,
|
||||||
|
h21: B / h,
|
||||||
|
h22: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMatrix3dCSS(H) {
|
||||||
|
if (!H) return '/* вырожденная конфигурация */';
|
||||||
|
const n = v => parseFloat(v.toFixed(8));
|
||||||
|
return [
|
||||||
|
'transform-origin: 0 0;',
|
||||||
|
'transform: matrix3d(',
|
||||||
|
` ${n(H.h00)}, ${n(H.h10)}, 0, ${n(H.h20)},`,
|
||||||
|
` ${n(H.h01)}, ${n(H.h11)}, 0, ${n(H.h21)},`,
|
||||||
|
` 0, 0, 1, 0,`,
|
||||||
|
` ${n(H.h02)}, ${n(H.h12)}, 0, ${n(H.h22)}`,
|
||||||
|
');'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SVG rendering =====
|
||||||
|
function gridInterval(maxDim) {
|
||||||
|
if (maxDim <= 500) return 50;
|
||||||
|
if (maxDim <= 1000) return 100;
|
||||||
|
if (maxDim <= 2000) return 200;
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSVG() {
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${state.fieldW} ${state.fieldH}`);
|
||||||
|
svg.innerHTML = '';
|
||||||
|
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
const rect = document.createElementNS(ns, 'rect');
|
||||||
|
rect.setAttribute('width', state.fieldW);
|
||||||
|
rect.setAttribute('height', state.fieldH);
|
||||||
|
rect.setAttribute('fill', '#0a0e14');
|
||||||
|
svg.appendChild(rect);
|
||||||
|
|
||||||
|
const step = gridInterval(Math.max(state.fieldW, state.fieldH));
|
||||||
|
const gridG = document.createElementNS(ns, 'g');
|
||||||
|
for (let x = step; x < state.fieldW; x += step) {
|
||||||
|
const line = document.createElementNS(ns, 'line');
|
||||||
|
line.setAttribute('x1', x); line.setAttribute('y1', 0);
|
||||||
|
line.setAttribute('x2', x); line.setAttribute('y2', state.fieldH);
|
||||||
|
line.setAttribute('stroke', 'rgba(255,255,255,0.06)');
|
||||||
|
line.setAttribute('stroke-width', 1);
|
||||||
|
gridG.appendChild(line);
|
||||||
|
}
|
||||||
|
for (let y = step; y < state.fieldH; y += step) {
|
||||||
|
const line = document.createElementNS(ns, 'line');
|
||||||
|
line.setAttribute('x1', 0); line.setAttribute('y1', y);
|
||||||
|
line.setAttribute('x2', state.fieldW); line.setAttribute('y2', y);
|
||||||
|
line.setAttribute('stroke', 'rgba(255,255,255,0.06)');
|
||||||
|
line.setAttribute('stroke-width', 1);
|
||||||
|
gridG.appendChild(line);
|
||||||
|
}
|
||||||
|
svg.appendChild(gridG);
|
||||||
|
|
||||||
|
const widgetRect = document.createElementNS(ns, 'rect');
|
||||||
|
widgetRect.setAttribute('x', 0);
|
||||||
|
widgetRect.setAttribute('y', 0);
|
||||||
|
widgetRect.setAttribute('width', state.widgetW);
|
||||||
|
widgetRect.setAttribute('height', state.widgetH);
|
||||||
|
widgetRect.setAttribute('fill', 'none');
|
||||||
|
widgetRect.setAttribute('stroke', 'rgba(88,166,255,0.2)');
|
||||||
|
widgetRect.setAttribute('stroke-width', 2);
|
||||||
|
widgetRect.setAttribute('stroke-dasharray', '12,8');
|
||||||
|
svg.appendChild(widgetRect);
|
||||||
|
|
||||||
|
const poly = document.createElementNS(ns, 'polygon');
|
||||||
|
const pts = state.corners.map(c => `${c.x},${c.y}`).join(' ');
|
||||||
|
poly.setAttribute('points', pts);
|
||||||
|
poly.setAttribute('fill', 'rgba(88,166,255,0.1)');
|
||||||
|
poly.setAttribute('stroke', '#58a6ff');
|
||||||
|
poly.setAttribute('stroke-width', 3);
|
||||||
|
poly.setAttribute('stroke-linejoin', 'round');
|
||||||
|
svg.appendChild(poly);
|
||||||
|
|
||||||
|
state.corners.forEach((c, i) => {
|
||||||
|
const g = document.createElementNS(ns, 'g');
|
||||||
|
|
||||||
|
const circle = document.createElementNS(ns, 'circle');
|
||||||
|
circle.setAttribute('cx', c.x);
|
||||||
|
circle.setAttribute('cy', c.y);
|
||||||
|
circle.setAttribute('r', 10);
|
||||||
|
circle.setAttribute('fill', '#58a6ff');
|
||||||
|
circle.setAttribute('stroke', '#ffffff');
|
||||||
|
circle.setAttribute('stroke-width', 2.5);
|
||||||
|
circle.setAttribute('class', 'corner-circle');
|
||||||
|
circle.dataset.index = i;
|
||||||
|
g.appendChild(circle);
|
||||||
|
|
||||||
|
const label = document.createElementNS(ns, 'text');
|
||||||
|
const lx = c.x + (i === 0 || i === 3 ? -30 : 20);
|
||||||
|
const ly = c.y + (i < 2 ? -18 : 28);
|
||||||
|
label.setAttribute('x', lx);
|
||||||
|
label.setAttribute('y', ly);
|
||||||
|
label.setAttribute('class', 'corner-label');
|
||||||
|
label.setAttribute('text-anchor', 'middle');
|
||||||
|
label.textContent = LABELS[i];
|
||||||
|
g.appendChild(label);
|
||||||
|
|
||||||
|
const coord = document.createElementNS(ns, 'text');
|
||||||
|
coord.setAttribute('x', lx);
|
||||||
|
coord.setAttribute('y', ly + 18);
|
||||||
|
coord.setAttribute('class', 'corner-coord');
|
||||||
|
coord.setAttribute('text-anchor', 'middle');
|
||||||
|
coord.textContent = `(${c.x}, ${c.y})`;
|
||||||
|
g.appendChild(coord);
|
||||||
|
|
||||||
|
svg.appendChild(g);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Drag handling =====
|
||||||
|
let dragIndex = -1;
|
||||||
|
|
||||||
|
function svgPoint(clientX, clientY) {
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = clientX;
|
||||||
|
pt.y = clientY;
|
||||||
|
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
const target = e.target.closest('.corner-circle');
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragIndex = parseInt(target.dataset.index);
|
||||||
|
target.classList.add('dragging');
|
||||||
|
document.addEventListener('mousemove', onPointerMove);
|
||||||
|
document.addEventListener('mouseup', onPointerUp);
|
||||||
|
document.addEventListener('touchmove', onPointerMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (dragIndex < 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
const pt = svgPoint(cx, cy);
|
||||||
|
state.corners[dragIndex].x = Math.round(Math.max(0, Math.min(state.fieldW, pt.x)));
|
||||||
|
state.corners[dragIndex].y = Math.round(Math.max(0, Math.min(state.fieldH, pt.y)));
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragIndex = -1;
|
||||||
|
document.querySelectorAll('.corner-circle.dragging').forEach(el => el.classList.remove('dragging'));
|
||||||
|
document.removeEventListener('mousemove', onPointerMove);
|
||||||
|
document.removeEventListener('mouseup', onPointerUp);
|
||||||
|
document.removeEventListener('touchmove', onPointerMove);
|
||||||
|
document.removeEventListener('touchend', onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.addEventListener('mousedown', onPointerDown);
|
||||||
|
svg.addEventListener('touchstart', onPointerDown, { passive: false });
|
||||||
|
|
||||||
|
// ===== Live preview =====
|
||||||
|
function updateLivePreview() {
|
||||||
|
const container = $('fieldContainer');
|
||||||
|
const cw = container.clientWidth;
|
||||||
|
if (cw <= 0) return;
|
||||||
|
|
||||||
|
const scale = cw / state.fieldW;
|
||||||
|
const field = $('field');
|
||||||
|
field.style.width = state.fieldW + 'px';
|
||||||
|
field.style.height = state.fieldH + 'px';
|
||||||
|
field.style.transform = `scale(${scale})`;
|
||||||
|
container.style.height = (state.fieldH * scale) + 'px';
|
||||||
|
|
||||||
|
const widget = $('widget');
|
||||||
|
widget.style.width = state.widgetW + 'px';
|
||||||
|
widget.style.height = state.widgetH + 'px';
|
||||||
|
|
||||||
|
const H = computeHomography();
|
||||||
|
if (H) {
|
||||||
|
const n = v => parseFloat(v.toFixed(6));
|
||||||
|
widget.style.transform = `matrix3d(${n(H.h00)},${n(H.h10)},0,${n(H.h20)},${n(H.h01)},${n(H.h11)},0,${n(H.h21)},0,0,1,0,${n(H.h02)},${n(H.h12)},0,${n(H.h22)})`;
|
||||||
|
widget.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
widget.style.transform = 'none';
|
||||||
|
widget.style.opacity = '0.3';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Corner inputs =====
|
||||||
|
function buildCornerInputs() {
|
||||||
|
const container = $('cornerInputs');
|
||||||
|
container.innerHTML = '';
|
||||||
|
LABELS.forEach((label, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'corner-input-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="label">${label}</span>
|
||||||
|
<label><span>x</span><input id="c${i}x" type="number" value="${state.corners[i].x}"></label>
|
||||||
|
<label><span>y</span><input id="c${i}y" type="number" value="${state.corners[i].y}"></label>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCornerInputs() {
|
||||||
|
state.corners.forEach((c, i) => {
|
||||||
|
const xi = $(`c${i}x`);
|
||||||
|
const yi = $(`c${i}y`);
|
||||||
|
if (document.activeElement !== xi) xi.value = c.x;
|
||||||
|
if (document.activeElement !== yi) yi.value = c.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCornerInputs() {
|
||||||
|
state.corners.forEach((c, i) => {
|
||||||
|
const x = parseInt($(`c${i}x`).value);
|
||||||
|
const y = parseInt($(`c${i}y`).value);
|
||||||
|
if (!isNaN(x)) c.x = x;
|
||||||
|
if (!isNaN(y)) c.y = y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Update all =====
|
||||||
|
function updateAll() {
|
||||||
|
renderSVG();
|
||||||
|
updateLivePreview();
|
||||||
|
syncCornerInputs();
|
||||||
|
updateCSSOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCSSOutput() {
|
||||||
|
const H = computeHomography();
|
||||||
|
$('cssOutput').value = toMatrix3dCSS(H);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Input events =====
|
||||||
|
$('inpFieldW').addEventListener('input', () => {
|
||||||
|
const v = parseInt($('inpFieldW').value);
|
||||||
|
if (v > 0) { state.fieldW = v; updateAll(); }
|
||||||
|
});
|
||||||
|
$('inpFieldH').addEventListener('input', () => {
|
||||||
|
const v = parseInt($('inpFieldH').value);
|
||||||
|
if (v > 0) { state.fieldH = v; updateAll(); }
|
||||||
|
});
|
||||||
|
$('inpWidgetW').addEventListener('input', () => {
|
||||||
|
const v = parseInt($('inpWidgetW').value);
|
||||||
|
if (v > 0) { state.widgetW = v; updateAll(); }
|
||||||
|
});
|
||||||
|
$('inpWidgetH').addEventListener('input', () => {
|
||||||
|
const v = parseInt($('inpWidgetH').value);
|
||||||
|
if (v > 0) { state.widgetH = v; updateAll(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('input', (e) => {
|
||||||
|
if (e.target.id && /^c\d[xy]$/.test(e.target.id)) {
|
||||||
|
readCornerInputs();
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Copy =====
|
||||||
|
$('copyBtn').addEventListener('click', () => {
|
||||||
|
const text = $('cssOutput').value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const btn = $('copyBtn');
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'Скопировано!';
|
||||||
|
setTimeout(() => btn.textContent = orig, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Reset =====
|
||||||
|
$('resetBtn').addEventListener('click', () => {
|
||||||
|
state.fieldW = defaultState.fieldW;
|
||||||
|
state.fieldH = defaultState.fieldH;
|
||||||
|
state.widgetW = defaultState.widgetW;
|
||||||
|
state.widgetH = defaultState.widgetH;
|
||||||
|
defaultState.corners.forEach((c, i) => {
|
||||||
|
state.corners[i] = { x: c.x, y: c.y };
|
||||||
|
});
|
||||||
|
$('inpFieldW').value = state.fieldW;
|
||||||
|
$('inpFieldH').value = state.fieldH;
|
||||||
|
$('inpWidgetW').value = state.widgetW;
|
||||||
|
$('inpWidgetH').value = state.widgetH;
|
||||||
|
buildCornerInputs();
|
||||||
|
updateAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Resize =====
|
||||||
|
window.addEventListener('resize', updateLivePreview);
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
buildCornerInputs();
|
||||||
|
updateAll();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--panel: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--heading: #e6edf3;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-dim: rgba(88, 166, 255, 0.12);
|
||||||
|
--input-bg: #0d1117;
|
||||||
|
--btn-bg: #238636;
|
||||||
|
--btn-hover: #2ea043;
|
||||||
|
--widget-bg: #1a1a3e;
|
||||||
|
--widget-grid: rgba(88, 166, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--heading);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
header p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#svg {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0a0e14;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-circle {
|
||||||
|
cursor: grab;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(88, 166, 255, 0.5));
|
||||||
|
transition: r 0.1s;
|
||||||
|
}
|
||||||
|
.corner-circle:hover {
|
||||||
|
r: 14;
|
||||||
|
}
|
||||||
|
.corner-circle.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
r: 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-label {
|
||||||
|
font-size: 22px;
|
||||||
|
fill: var(--text);
|
||||||
|
font-family: monospace;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-coord {
|
||||||
|
font-size: 18px;
|
||||||
|
fill: #8b949e;
|
||||||
|
font-family: monospace;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.field-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8b949e;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.field-row label span {
|
||||||
|
min-width: 14px;
|
||||||
|
}
|
||||||
|
.field-row input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.field-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.corner-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.corner-input-row .label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
min-width: 28px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.corner-input-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8b949e;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.corner-input-row label span {
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
.corner-input-row input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 6px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.corner-input-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cssOutput {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: "Cascadia Code", "Fira Code", monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--btn-hover);
|
||||||
|
}
|
||||||
|
.btn-reset {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #484f58;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fieldContainer {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #080b10;
|
||||||
|
border: 2px solid #1a1f2a;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#field {
|
||||||
|
position: relative;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
background: #0c1018;
|
||||||
|
}
|
||||||
|
|
||||||
|
#widget {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
background-color: var(--widget-bg);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, var(--widget-grid) 25%, transparent 25%, transparent 75%, var(--widget-grid) 75%),
|
||||||
|
linear-gradient(45deg, var(--widget-grid) 25%, transparent 25%, transparent 75%, var(--widget-grid) 75%);
|
||||||
|
background-size: 30px 30px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
15px 15px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#widget span {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #f85149;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user