feat: load background file
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
# Plan: Background Image Upload for CSS Matrix Calculator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Add ability to load a background image onto the main `#field` element for visual reference when positioning corners. The image is stored as a data URL in memory (no server). Three upload methods: Ctrl+V paste, file picker, drag & drop onto the field. Adjustable via `object-fit` options.
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### 1. `js/state.js` — Add background state
|
||||||
|
- Add `bgImage: null` (data URL string) and `objectFit: 'contain'` to `defaultState` and `state`
|
||||||
|
- Reset these in `resetState()`
|
||||||
|
|
||||||
|
### 2. `index.html` — New "Background" panel + hidden file input
|
||||||
|
Add a new panel in `right-col` (between Field and Widget panels, after line 78):
|
||||||
|
```html
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Background</h2>
|
||||||
|
<div class="bg-controls">
|
||||||
|
<input type="file" id="bgFileInput" accept="image/*" hidden />
|
||||||
|
<button class="btn btn-edit-field" id="btnBgLoad">Load Image</button>
|
||||||
|
<button class="btn btn-cancel-field" id="btnBgRemove" style="display:none">Remove</button>
|
||||||
|
<select id="bgObjectFit">
|
||||||
|
<option value="contain">contain</option>
|
||||||
|
<option value="cover">cover</option>
|
||||||
|
<option value="fill">fill</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
<option value="scale-down">scale-down</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add drag & drop hint overlay on `#fieldContainer` (drop zone indicator).
|
||||||
|
|
||||||
|
### 3. `style.css` — New styles
|
||||||
|
- `.bg-controls` — flex row layout for controls
|
||||||
|
- `#bgObjectFit` — styled select matching existing dark theme
|
||||||
|
- `#fieldContainer.drag-over` — dashed border highlight for drop feedback
|
||||||
|
- `#field` — add `background-size`, `background-position`, `background-repeat` properties
|
||||||
|
- `.bg-hint` — overlay text on field when no image loaded ("Drop image or Ctrl+V")
|
||||||
|
|
||||||
|
### 4. `js/ui.js` — Event handlers
|
||||||
|
- `btnBgLoad` click → trigger hidden file input
|
||||||
|
- `bgFileInput` change → read file as data URL via `FileReader` → `state.bgImage = dataUrl`
|
||||||
|
- `document` paste event → detect image in `clipboardData.files` → read as data URL
|
||||||
|
- `#fieldContainer` dragover/drop → read dropped file as data URL
|
||||||
|
- `btnBgRemove` click → clear `state.bgImage`
|
||||||
|
- `bgObjectFit` change → `state.objectFit = value`
|
||||||
|
- Toggle remove button visibility based on whether image is loaded
|
||||||
|
- On state change: call `updateAll()`
|
||||||
|
|
||||||
|
### 5. `js/preview.js` — Apply background to field
|
||||||
|
- If `state.bgImage`: set `field.style.backgroundImage = url(...)`, `field.style.backgroundSize`, `field.style.backgroundPosition`, `field.style.backgroundRepeat` based on objectFit
|
||||||
|
- If no image: clear background properties
|
||||||
|
|
||||||
|
### 6. `js/svg-renderer.js` — Show background in SVG editor
|
||||||
|
- If `state.bgImage`: add an `<image>` element as first child of SVG (behind grid) with `href`, `width`, `height`, and `preserveAspectRatio` matching the objectFit mapping
|
||||||
|
|
||||||
|
### 7. `js/main.js` — No changes needed (updateAll already propagates)
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Image loading flow
|
||||||
|
1. User triggers upload (paste/file/drop) → get `File` object
|
||||||
|
2. `FileReader.readAsDataURL(file)` → get data URL string
|
||||||
|
3. Store in `state.bgImage`
|
||||||
|
4. Call `updateAll()` to propagate
|
||||||
|
|
||||||
|
### object-fit mapping to SVG preserveAspectRatio
|
||||||
|
| objectFit | SVG preserveAspectRatio |
|
||||||
|
|---------------|------------------------|
|
||||||
|
| contain | xMidYMid meet |
|
||||||
|
| cover | xMidYMid slice |
|
||||||
|
| fill | none |
|
||||||
|
| none | xMinYMin meet |
|
||||||
|
| scale-down | xMidYMid meet |
|
||||||
|
|
||||||
|
### Keyboard shortcut
|
||||||
|
- Ctrl+V paste listener on `document` — check for image in clipboard items
|
||||||
|
- Only active when no input/textarea is focused (to avoid conflicts)
|
||||||
|
|
||||||
|
### Drag & drop
|
||||||
|
- Listen on `#fieldContainer` for `dragover` (prevent default, show visual hint) and `drop` (read file)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. Open `index.html` in browser
|
||||||
|
2. **File picker**: Click "Load Image" → select image → verify it appears as background on the field in both edit (SVG) and preview modes
|
||||||
|
3. **Ctrl+V**: Copy an image to clipboard → press Ctrl+V → verify it loads
|
||||||
|
4. **Drag & drop**: Drag an image file onto the field area → verify it loads
|
||||||
|
5. **object-fit**: Change dropdown → verify background resizes correctly (contain fits, cover fills, etc.)
|
||||||
|
6. **Remove**: Click "Remove" → verify background clears
|
||||||
|
7. **Reset**: Click "Reset" → verify background clears along with other state
|
||||||
|
8. **Corner drag**: Load background → drag corners → verify background stays in place, corners move independently
|
||||||
+16
@@ -77,6 +77,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Background</h2>
|
||||||
|
<div class="bg-controls">
|
||||||
|
<input type="file" id="bgFileInput" accept="image/*" hidden />
|
||||||
|
<button class="btn btn-edit-field" id="btnBgLoad">Load Image</button>
|
||||||
|
<button class="btn btn-cancel-field" id="btnBgRemove" style="display:none">Remove</button>
|
||||||
|
<select id="bgObjectFit">
|
||||||
|
<option value="contain">contain</option>
|
||||||
|
<option value="cover">cover</option>
|
||||||
|
<option value="fill">fill</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
<option value="scale-down">scale-down</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Widget</h2>
|
<h2>Widget</h2>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { computeHomography } from './homography.js';
|
|||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
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) {
|
export function updateLivePreview(state) {
|
||||||
const container = $('fieldContainer');
|
const container = $('fieldContainer');
|
||||||
const cw = container.clientWidth;
|
const cw = container.clientWidth;
|
||||||
@@ -18,6 +28,8 @@ export function updateLivePreview(state) {
|
|||||||
field.style.transform = `scale(${scale})`;
|
field.style.transform = `scale(${scale})`;
|
||||||
container.style.height = (state.fieldH * scale) + 'px';
|
container.style.height = (state.fieldH * scale) + 'px';
|
||||||
|
|
||||||
|
applyBgToField(field, state);
|
||||||
|
|
||||||
const widget = $('widget');
|
const widget = $('widget');
|
||||||
widget.style.width = state.widgetW + 'px';
|
widget.style.width = state.widgetW + 'px';
|
||||||
widget.style.height = state.widgetH + 'px';
|
widget.style.height = state.widgetH + 'px';
|
||||||
|
|||||||
+8
-2
@@ -6,7 +6,9 @@ export const defaultState = {
|
|||||||
{ x: 1620, y: 150 },
|
{ x: 1620, y: 150 },
|
||||||
{ x: 1580, y: 920 },
|
{ x: 1580, y: 920 },
|
||||||
{ x: 340, y: 950 }
|
{ x: 340, y: 950 }
|
||||||
]
|
],
|
||||||
|
bgImage: null,
|
||||||
|
objectFit: 'contain'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -17,7 +19,9 @@ export const state = {
|
|||||||
{ x: 1620, y: 150 },
|
{ x: 1620, y: 150 },
|
||||||
{ x: 1580, y: 920 },
|
{ x: 1580, y: 920 },
|
||||||
{ x: 340, y: 950 }
|
{ x: 340, y: 950 }
|
||||||
]
|
],
|
||||||
|
bgImage: null,
|
||||||
|
objectFit: 'contain'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resetState() {
|
export function resetState() {
|
||||||
@@ -25,6 +29,8 @@ export function resetState() {
|
|||||||
state.fieldH = defaultState.fieldH;
|
state.fieldH = defaultState.fieldH;
|
||||||
state.widgetW = defaultState.widgetW;
|
state.widgetW = defaultState.widgetW;
|
||||||
state.widgetH = defaultState.widgetH;
|
state.widgetH = defaultState.widgetH;
|
||||||
|
state.bgImage = null;
|
||||||
|
state.objectFit = defaultState.objectFit;
|
||||||
defaultState.corners.forEach((c, i) => {
|
defaultState.corners.forEach((c, i) => {
|
||||||
state.corners[i] = { x: c.x, y: c.y };
|
state.corners[i] = { x: c.x, y: c.y };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
||||||
const ns = 'http://www.w3.org/2000/svg';
|
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) {
|
function gridInterval(maxDim) {
|
||||||
if (maxDim <= 500) return 50;
|
if (maxDim <= 500) return 50;
|
||||||
if (maxDim <= 1000) return 100;
|
if (maxDim <= 1000) return 100;
|
||||||
@@ -26,6 +34,17 @@ export function renderSVG(svg, state) {
|
|||||||
});
|
});
|
||||||
svg.appendChild(rect);
|
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 step = gridInterval(Math.max(state.fieldW, state.fieldH));
|
||||||
const gridG = document.createElementNS(ns, 'g');
|
const gridG = document.createElementNS(ns, 'g');
|
||||||
for (let x = step; x < state.fieldW; x += step) {
|
for (let x = step; x < state.fieldW; x += step) {
|
||||||
|
|||||||
@@ -54,12 +54,33 @@ function setMode(mode) {
|
|||||||
: 'matrix3d() transform result';
|
: '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) {
|
export function updateCSSOutput(state) {
|
||||||
const H = computeHomography(state);
|
const H = computeHomography(state);
|
||||||
$('cssOutput').value = toMatrix3dCSS(H);
|
$('cssOutput').value = toMatrix3dCSS(H);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initUI(state, updateAll) {
|
export function initUI(state, updateAll) {
|
||||||
|
updateAllRef = updateAll;
|
||||||
|
|
||||||
$('chainLink').addEventListener('click', () => {
|
$('chainLink').addEventListener('click', () => {
|
||||||
keepAspect = !keepAspect;
|
keepAspect = !keepAspect;
|
||||||
updateChainIcon();
|
updateChainIcon();
|
||||||
@@ -150,6 +171,8 @@ export function initUI(state, updateAll) {
|
|||||||
syncFieldInputs(state, state.fieldW, state.fieldH);
|
syncFieldInputs(state, state.fieldW, state.fieldH);
|
||||||
syncWidgetInputs(state);
|
syncWidgetInputs(state);
|
||||||
buildCornerInputs(state);
|
buildCornerInputs(state);
|
||||||
|
$('btnBgRemove').style.display = 'none';
|
||||||
|
$('bgObjectFit').value = state.objectFit;
|
||||||
updateAll();
|
updateAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,4 +187,46 @@ export function initUI(state, updateAll) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', () => updateLivePreview(state));
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,12 +440,20 @@ main {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.left-col.drag-over {
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
#field {
|
#field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
background: #0c1018;
|
background-color: #0c1018;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#widget {
|
#widget {
|
||||||
@@ -507,6 +515,27 @@ body.mode-preview .left-col {
|
|||||||
width: 100%;
|
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) {
|
@media (max-width: 900px) {
|
||||||
main {
|
main {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user