Widget
diff --git a/js/preview.js b/js/preview.js
index 8b33d36..8ed4b78 100644
--- a/js/preview.js
+++ b/js/preview.js
@@ -2,6 +2,16 @@ import { computeHomography } from './homography.js';
function $(id) { return document.getElementById(id); }
+function applyBgToField(field, state) {
+ if (state.bgImage) {
+ field.style.backgroundImage = `url(${state.bgImage})`;
+ field.style.backgroundSize = state.objectFit === 'fill' ? '100% 100%' : state.objectFit;
+ } else {
+ field.style.backgroundImage = 'none';
+ field.style.backgroundSize = '';
+ }
+}
+
export function updateLivePreview(state) {
const container = $('fieldContainer');
const cw = container.clientWidth;
@@ -18,6 +28,8 @@ export function updateLivePreview(state) {
field.style.transform = `scale(${scale})`;
container.style.height = (state.fieldH * scale) + 'px';
+ applyBgToField(field, state);
+
const widget = $('widget');
widget.style.width = state.widgetW + 'px';
widget.style.height = state.widgetH + 'px';
diff --git a/js/state.js b/js/state.js
index b384363..9291717 100644
--- a/js/state.js
+++ b/js/state.js
@@ -6,7 +6,9 @@ export const defaultState = {
{ x: 1620, y: 150 },
{ x: 1580, y: 920 },
{ x: 340, y: 950 }
- ]
+ ],
+ bgImage: null,
+ objectFit: 'contain'
};
export const state = {
@@ -17,7 +19,9 @@ export const state = {
{ x: 1620, y: 150 },
{ x: 1580, y: 920 },
{ x: 340, y: 950 }
- ]
+ ],
+ bgImage: null,
+ objectFit: 'contain'
};
export function resetState() {
@@ -25,6 +29,8 @@ export function resetState() {
state.fieldH = defaultState.fieldH;
state.widgetW = defaultState.widgetW;
state.widgetH = defaultState.widgetH;
+ state.bgImage = null;
+ state.objectFit = defaultState.objectFit;
defaultState.corners.forEach((c, i) => {
state.corners[i] = { x: c.x, y: c.y };
});
diff --git a/js/svg-renderer.js b/js/svg-renderer.js
index 9fb12d9..8ed7028 100644
--- a/js/svg-renderer.js
+++ b/js/svg-renderer.js
@@ -1,6 +1,14 @@
const LABELS = ['TL', 'TR', 'BR', 'BL'];
const ns = 'http://www.w3.org/2000/svg';
+const OBJECT_FIT_MAP = {
+ contain: 'xMidYMid meet',
+ cover: 'xMidYMid slice',
+ fill: 'none',
+ none: 'xMinYMin meet',
+ 'scale-down': 'xMidYMid meet'
+};
+
function gridInterval(maxDim) {
if (maxDim <= 500) return 50;
if (maxDim <= 1000) return 100;
@@ -26,6 +34,17 @@ export function renderSVG(svg, state) {
});
svg.appendChild(rect);
+ if (state.bgImage) {
+ const img = document.createElementNS(ns, 'image');
+ setAttrs(img, {
+ href: state.bgImage,
+ width: state.fieldW,
+ height: state.fieldH,
+ preserveAspectRatio: OBJECT_FIT_MAP[state.objectFit] || 'xMidYMid meet'
+ });
+ svg.appendChild(img);
+ }
+
const step = gridInterval(Math.max(state.fieldW, state.fieldH));
const gridG = document.createElementNS(ns, 'g');
for (let x = step; x < state.fieldW; x += step) {
diff --git a/js/ui.js b/js/ui.js
index 1baeee3..897de1c 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -54,12 +54,33 @@ function setMode(mode) {
: 'matrix3d() transform result';
}
+let updateAllRef;
+
+function loadBgFile(file, state) {
+ if (!file || !file.type.startsWith('image/')) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ state.bgImage = reader.result;
+ $('btnBgRemove').style.display = '';
+ updateAllRef();
+ };
+ reader.readAsDataURL(file);
+}
+
+function clearBg(state) {
+ state.bgImage = null;
+ $('btnBgRemove').style.display = 'none';
+ updateAllRef();
+}
+
export function updateCSSOutput(state) {
const H = computeHomography(state);
$('cssOutput').value = toMatrix3dCSS(H);
}
export function initUI(state, updateAll) {
+ updateAllRef = updateAll;
+
$('chainLink').addEventListener('click', () => {
keepAspect = !keepAspect;
updateChainIcon();
@@ -150,6 +171,8 @@ export function initUI(state, updateAll) {
syncFieldInputs(state, state.fieldW, state.fieldH);
syncWidgetInputs(state);
buildCornerInputs(state);
+ $('btnBgRemove').style.display = 'none';
+ $('bgObjectFit').value = state.objectFit;
updateAll();
});
@@ -164,4 +187,46 @@ export function initUI(state, updateAll) {
});
window.addEventListener('resize', () => updateLivePreview(state));
+
+ $('btnBgLoad').addEventListener('click', () => $('bgFileInput').click());
+
+ $('bgFileInput').addEventListener('change', (e) => {
+ if (e.target.files[0]) loadBgFile(e.target.files[0], state);
+ e.target.value = '';
+ });
+
+ $('btnBgRemove').addEventListener('click', () => clearBg(state));
+
+ $('bgObjectFit').addEventListener('change', (e) => {
+ state.objectFit = e.target.value;
+ updateAll();
+ });
+
+ document.addEventListener('paste', (e) => {
+ const tag = document.activeElement?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) loadBgFile(file, state);
+ break;
+ }
+ }
+ });
+
+ const lc = document.querySelector('.left-col');
+ lc.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ lc.classList.add('drag-over');
+ });
+ lc.addEventListener('dragleave', () => {
+ lc.classList.remove('drag-over');
+ });
+ lc.addEventListener('drop', (e) => {
+ e.preventDefault();
+ lc.classList.remove('drag-over');
+ if (e.dataTransfer.files[0]) loadBgFile(e.dataTransfer.files[0], state);
+ });
}
diff --git a/style.css b/style.css
index ff59016..376e05a 100644
--- a/style.css
+++ b/style.css
@@ -440,12 +440,20 @@ main {
position: relative;
}
+.left-col.drag-over {
+ outline: 2px dashed var(--accent);
+ outline-offset: -2px;
+ border-radius: 8px;
+}
+
#field {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
- background: #0c1018;
+ background-color: #0c1018;
+ background-repeat: no-repeat;
+ background-position: center;
}
#widget {
@@ -507,6 +515,27 @@ body.mode-preview .left-col {
width: 100%;
}
+.bg-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+#bgObjectFit {
+ padding: 4px 8px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--heading);
+ font-size: 13px;
+ font-family: monospace;
+ outline: none;
+ cursor: pointer;
+}
+#bgObjectFit:focus {
+ border-color: var(--accent);
+}
+
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;