From 3b49c9f3c2abef5373739296e886323558e438aa Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 24 Jun 2026 06:48:13 +0500 Subject: [PATCH] feat: load background file --- .../plans/1782265108554-gentle-rocket.md | 92 +++++++++++++++++++ index.html | 16 ++++ js/preview.js | 12 +++ js/state.js | 10 +- js/svg-renderer.js | 19 ++++ js/ui.js | 65 +++++++++++++ style.css | 31 ++++++- 7 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 .mimocode/plans/1782265108554-gentle-rocket.md diff --git a/.mimocode/plans/1782265108554-gentle-rocket.md b/.mimocode/plans/1782265108554-gentle-rocket.md new file mode 100644 index 0000000..27d5d3f --- /dev/null +++ b/.mimocode/plans/1782265108554-gentle-rocket.md @@ -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 +
+

Background

+
+ + + + +
+
+``` + +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 `` 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 diff --git a/index.html b/index.html index 4659107..55690f6 100644 --- a/index.html +++ b/index.html @@ -77,6 +77,22 @@ +
+

Background

+
+ + + + +
+
+

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;