refactor: update js - separate files, modules
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.mimocode/
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const COLORS = {
|
||||||
|
fieldBg: '#0a0e14',
|
||||||
|
accent: '#58a6ff',
|
||||||
|
widgetFill: 'rgba(88,166,255,0.12)',
|
||||||
|
widgetStroke: 'rgba(88,166,255,0.4)',
|
||||||
|
polygonFill: 'rgba(88,166,255,0.1)',
|
||||||
|
gridLine: 'rgba(255,255,255,0.06)'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
||||||
|
|
||||||
|
export const FIELD_PADDING = 140;
|
||||||
|
|
||||||
|
export const ROUND_DIGITS = 6;
|
||||||
|
export const round = v => parseFloat(v.toFixed(ROUND_DIGITS));
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { $ } from './dom.js';
|
||||||
|
import { loadBgFile, loadWidgetFile } from './file-loader.js';
|
||||||
|
|
||||||
|
function setupDropZone(el, onDrop) {
|
||||||
|
el.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
el.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('dragleave', (e) => {
|
||||||
|
if (!el.contains(e.relatedTarget)) {
|
||||||
|
el.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.body.classList.remove('file-dragging');
|
||||||
|
el.classList.remove('drag-over');
|
||||||
|
if (e.dataTransfer.files[0]) onDrop(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initDropZones(state) {
|
||||||
|
const lc = document.querySelector('.left-col');
|
||||||
|
|
||||||
|
document.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.body.classList.add('file-dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('drop', () => {
|
||||||
|
document.body.classList.remove('file-dragging');
|
||||||
|
lc.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
setupDropZone(lc, (file) => loadBgFile(file, state));
|
||||||
|
setupDropZone($('bgPanel'), (file) => loadBgFile(file, state));
|
||||||
|
setupDropZone($('widgetPanel'), (file) => loadWidgetFile(file, state));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { $ } from './dom.js';
|
||||||
|
|
||||||
|
let updateAllRef;
|
||||||
|
|
||||||
|
export function setUpdateAllRef(fn) {
|
||||||
|
updateAllRef = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBg(state) {
|
||||||
|
state.bgImage = null;
|
||||||
|
$('btnBgRemove').style.display = 'none';
|
||||||
|
updateAllRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWidgetImageLock(locked) {
|
||||||
|
$('inpWidgetW').disabled = locked;
|
||||||
|
$('inpWidgetH').disabled = locked;
|
||||||
|
$('btnWidgetRemove').style.display = locked ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWidgetFile(file, state) {
|
||||||
|
if (!file || !file.type.startsWith('image/')) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
state.widgetImage = reader.result;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
state.widgetW = img.naturalWidth;
|
||||||
|
state.widgetH = img.naturalHeight;
|
||||||
|
$('inpWidgetW').value = state.widgetW;
|
||||||
|
$('inpWidgetH').value = state.widgetH;
|
||||||
|
setWidgetImageLock(true);
|
||||||
|
updateAllRef();
|
||||||
|
};
|
||||||
|
img.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
+5
-4
@@ -1,3 +1,5 @@
|
|||||||
|
import { round } from './constants.js';
|
||||||
|
|
||||||
export function computeHomography(state) {
|
export function computeHomography(state) {
|
||||||
const w = state.widgetW, h = state.widgetH;
|
const w = state.widgetW, h = state.widgetH;
|
||||||
if (w <= 0 || h <= 0) return null;
|
if (w <= 0 || h <= 0) return null;
|
||||||
@@ -29,14 +31,13 @@ export function computeHomography(state) {
|
|||||||
|
|
||||||
export function toMatrix3dCSS(H) {
|
export function toMatrix3dCSS(H) {
|
||||||
if (!H) return '/* degenerate configuration */';
|
if (!H) return '/* degenerate configuration */';
|
||||||
const n = v => parseFloat(v.toFixed(8));
|
|
||||||
return [
|
return [
|
||||||
'transform-origin: 0 0;',
|
'transform-origin: 0 0;',
|
||||||
'transform: matrix3d(',
|
'transform: matrix3d(',
|
||||||
` ${n(H.h00)}, ${n(H.h10)}, 0, ${n(H.h20)},`,
|
` ${round(H.h00)}, ${round(H.h10)}, 0, ${round(H.h20)},`,
|
||||||
` ${n(H.h01)}, ${n(H.h11)}, 0, ${n(H.h21)},`,
|
` ${round(H.h01)}, ${round(H.h11)}, 0, ${round(H.h21)},`,
|
||||||
` 0, 0, 1, 0,`,
|
` 0, 0, 1, 0,`,
|
||||||
` ${n(H.h02)}, ${n(H.h12)}, 0, ${n(H.h22)}`,
|
` ${round(H.h02)}, ${round(H.h12)}, 0, ${round(H.h22)}`,
|
||||||
');'
|
');'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -1,6 +1,5 @@
|
|||||||
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
import { $ } from './dom.js';
|
||||||
|
import { LABELS } from './constants.js';
|
||||||
function $(id) { return document.getElementById(id); }
|
|
||||||
|
|
||||||
export function buildCornerInputs(state) {
|
export function buildCornerInputs(state) {
|
||||||
const container = $('cornerInputs');
|
const container = $('cornerInputs');
|
||||||
@@ -28,8 +27,14 @@ export function syncCornerInputs(state) {
|
|||||||
|
|
||||||
export function readCornerInputs(state) {
|
export function readCornerInputs(state) {
|
||||||
state.corners.forEach((c, i) => {
|
state.corners.forEach((c, i) => {
|
||||||
const x = parseInt($(`c${i}x`).value);
|
const xi = $(`c${i}x`);
|
||||||
const y = parseInt($(`c${i}y`).value);
|
const yi = $(`c${i}y`);
|
||||||
|
const x = parseInt(xi.value);
|
||||||
|
const y = parseInt(yi.value);
|
||||||
|
|
||||||
|
xi.classList.toggle('input-error', isNaN(x));
|
||||||
|
yi.classList.toggle('input-error', isNaN(y));
|
||||||
|
|
||||||
if (!isNaN(x)) c.x = x;
|
if (!isNaN(x)) c.x = x;
|
||||||
if (!isNaN(y)) c.y = y;
|
if (!isNaN(y)) c.y = y;
|
||||||
});
|
});
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { $ } from './dom.js';
|
||||||
|
|
||||||
|
let fieldEditing = false;
|
||||||
|
let fieldEditW, fieldEditH;
|
||||||
|
let keepAspect = true;
|
||||||
|
|
||||||
|
export function isFieldEditing() {
|
||||||
|
return fieldEditing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldEditW() {
|
||||||
|
return fieldEditW;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldEditH() {
|
||||||
|
return fieldEditH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFieldEditW(v) {
|
||||||
|
fieldEditW = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFieldEditH(v) {
|
||||||
|
fieldEditH = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKeepAspect() {
|
||||||
|
return keepAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleKeepAspect() {
|
||||||
|
keepAspect = !keepAspect;
|
||||||
|
updateChainIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChainIcon() {
|
||||||
|
const locked = $('chainLink').querySelectorAll('.chain-locked');
|
||||||
|
const unlocked = $('chainLink').querySelectorAll('.chain-unlocked');
|
||||||
|
locked.forEach(el => el.style.display = keepAspect ? '' : 'none');
|
||||||
|
unlocked.forEach(el => el.style.display = keepAspect ? 'none' : '');
|
||||||
|
$('chainLink').classList.toggle('unlocked', !keepAspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFieldEditing(editing, state) {
|
||||||
|
fieldEditing = editing;
|
||||||
|
const inpW = $('inpFieldW');
|
||||||
|
const inpH = $('inpFieldH');
|
||||||
|
const options = $('fieldEditOptions');
|
||||||
|
const btnEdit = $('btnEditField');
|
||||||
|
const chain = $('chainLink');
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
fieldEditW = state.fieldW;
|
||||||
|
fieldEditH = state.fieldH;
|
||||||
|
inpW.disabled = false;
|
||||||
|
inpH.disabled = false;
|
||||||
|
options.classList.add('visible');
|
||||||
|
btnEdit.style.display = 'none';
|
||||||
|
chain.classList.add('visible');
|
||||||
|
updateChainIcon();
|
||||||
|
} else {
|
||||||
|
inpW.value = state.fieldW;
|
||||||
|
inpW.disabled = true;
|
||||||
|
inpH.value = state.fieldH;
|
||||||
|
inpH.disabled = true;
|
||||||
|
options.classList.remove('visible');
|
||||||
|
btnEdit.style.display = '';
|
||||||
|
chain.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMode(mode) {
|
||||||
|
document.body.className = mode === 'preview' ? 'mode-preview' : 'mode-edit';
|
||||||
|
$('btnEdit').classList.toggle('active', mode === 'edit');
|
||||||
|
$('btnPreview').classList.toggle('active', mode === 'preview');
|
||||||
|
$('modeHint').textContent = mode === 'edit'
|
||||||
|
? 'Drag corners on the field'
|
||||||
|
: 'matrix3d() transform result';
|
||||||
|
}
|
||||||
+4
-5
@@ -1,6 +1,6 @@
|
|||||||
|
import { $ } from './dom.js';
|
||||||
import { computeHomography } from './homography.js';
|
import { computeHomography } from './homography.js';
|
||||||
|
import { round, FIELD_PADDING } from './constants.js';
|
||||||
function $(id) { return document.getElementById(id); }
|
|
||||||
|
|
||||||
function applyBgToField(field, state) {
|
function applyBgToField(field, state) {
|
||||||
if (state.bgImage) {
|
if (state.bgImage) {
|
||||||
@@ -33,7 +33,7 @@ export function updateLivePreview(state) {
|
|||||||
const cw = container.clientWidth;
|
const cw = container.clientWidth;
|
||||||
if (cw <= 0) return;
|
if (cw <= 0) return;
|
||||||
|
|
||||||
const maxH = window.innerHeight - 140;
|
const maxH = window.innerHeight - FIELD_PADDING;
|
||||||
const scaleW = cw / state.fieldW;
|
const scaleW = cw / state.fieldW;
|
||||||
const scaleH = maxH / state.fieldH;
|
const scaleH = maxH / state.fieldH;
|
||||||
const scale = Math.min(scaleW, scaleH);
|
const scale = Math.min(scaleW, scaleH);
|
||||||
@@ -54,8 +54,7 @@ export function updateLivePreview(state) {
|
|||||||
|
|
||||||
const H = computeHomography(state);
|
const H = computeHomography(state);
|
||||||
if (H) {
|
if (H) {
|
||||||
const n = v => parseFloat(v.toFixed(6));
|
widget.style.transform = `matrix3d(${round(H.h00)},${round(H.h10)},0,${round(H.h20)},${round(H.h01)},${round(H.h11)},0,${round(H.h21)},0,0,1,0,${round(H.h02)},${round(H.h12)},0,${round(H.h22)})`;
|
||||||
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';
|
widget.style.opacity = '1';
|
||||||
} else {
|
} else {
|
||||||
widget.style.transform = 'none';
|
widget.style.transform = 'none';
|
||||||
|
|||||||
+7
-23
@@ -12,29 +12,13 @@ export const defaultState = {
|
|||||||
widgetImage: null
|
widgetImage: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export const state = {
|
function deepClone(obj) {
|
||||||
fieldW: 1920, fieldH: 1080,
|
return JSON.parse(JSON.stringify(obj));
|
||||||
widgetW: 400, widgetH: 300,
|
}
|
||||||
corners: [
|
|
||||||
{ x: 300, y: 200 },
|
export const state = deepClone(defaultState);
|
||||||
{ x: 1620, y: 150 },
|
|
||||||
{ x: 1580, y: 920 },
|
|
||||||
{ x: 340, y: 950 }
|
|
||||||
],
|
|
||||||
bgImage: null,
|
|
||||||
objectFit: 'contain',
|
|
||||||
widgetImage: null
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resetState() {
|
export function resetState() {
|
||||||
state.fieldW = defaultState.fieldW;
|
const clone = deepClone(defaultState);
|
||||||
state.fieldH = defaultState.fieldH;
|
Object.assign(state, clone);
|
||||||
state.widgetW = defaultState.widgetW;
|
|
||||||
state.widgetH = defaultState.widgetH;
|
|
||||||
state.bgImage = null;
|
|
||||||
state.objectFit = defaultState.objectFit;
|
|
||||||
state.widgetImage = null;
|
|
||||||
defaultState.corners.forEach((c, i) => {
|
|
||||||
state.corners[i] = { x: c.x, y: c.y };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-14
@@ -1,4 +1,5 @@
|
|||||||
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
import { COLORS, LABELS } from './constants.js';
|
||||||
|
|
||||||
const ns = 'http://www.w3.org/2000/svg';
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
const OBJECT_FIT_MAP = {
|
const OBJECT_FIT_MAP = {
|
||||||
@@ -22,15 +23,12 @@ function setAttrs(el, attrs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSVG(svg, state) {
|
function renderBackground(svg, state) {
|
||||||
svg.setAttribute('viewBox', `0 0 ${state.fieldW} ${state.fieldH}`);
|
|
||||||
svg.innerHTML = '';
|
|
||||||
|
|
||||||
const rect = document.createElementNS(ns, 'rect');
|
const rect = document.createElementNS(ns, 'rect');
|
||||||
setAttrs(rect, {
|
setAttrs(rect, {
|
||||||
width: state.fieldW,
|
width: state.fieldW,
|
||||||
height: state.fieldH,
|
height: state.fieldH,
|
||||||
fill: '#0a0e14'
|
fill: COLORS.fieldBg
|
||||||
});
|
});
|
||||||
svg.appendChild(rect);
|
svg.appendChild(rect);
|
||||||
|
|
||||||
@@ -44,21 +42,28 @@ export function renderSVG(svg, state) {
|
|||||||
});
|
});
|
||||||
svg.appendChild(img);
|
svg.appendChild(img);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(svg, state) {
|
||||||
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) {
|
||||||
const line = document.createElementNS(ns, 'line');
|
const line = document.createElementNS(ns, 'line');
|
||||||
setAttrs(line, { x1: x, y1: 0, x2: x, y2: state.fieldH, stroke: 'rgba(255,255,255,0.06)', 'stroke-width': 1 });
|
setAttrs(line, { x1: x, y1: 0, x2: x, y2: state.fieldH, stroke: COLORS.gridLine, 'stroke-width': 1 });
|
||||||
gridG.appendChild(line);
|
gridG.appendChild(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let y = step; y < state.fieldH; y += step) {
|
for (let y = step; y < state.fieldH; y += step) {
|
||||||
const line = document.createElementNS(ns, 'line');
|
const line = document.createElementNS(ns, 'line');
|
||||||
setAttrs(line, { x1: 0, y1: y, x2: state.fieldW, y2: y, stroke: 'rgba(255,255,255,0.06)', 'stroke-width': 1 });
|
setAttrs(line, { x1: 0, y1: y, x2: state.fieldW, y2: y, stroke: COLORS.gridLine, 'stroke-width': 1 });
|
||||||
gridG.appendChild(line);
|
gridG.appendChild(line);
|
||||||
}
|
}
|
||||||
svg.appendChild(gridG);
|
|
||||||
|
|
||||||
|
svg.appendChild(gridG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWidgetRect(svg, state) {
|
||||||
const cx = state.corners.reduce((s, c) => s + c.x, 0) / 4;
|
const cx = state.corners.reduce((s, c) => s + c.x, 0) / 4;
|
||||||
const cy = state.corners.reduce((s, c) => s + c.y, 0) / 4;
|
const cy = state.corners.reduce((s, c) => s + c.y, 0) / 4;
|
||||||
|
|
||||||
@@ -68,31 +73,35 @@ export function renderSVG(svg, state) {
|
|||||||
y: cy - state.widgetH / 2,
|
y: cy - state.widgetH / 2,
|
||||||
width: state.widgetW,
|
width: state.widgetW,
|
||||||
height: state.widgetH,
|
height: state.widgetH,
|
||||||
fill: 'rgba(88,166,255,0.12)',
|
fill: COLORS.widgetFill,
|
||||||
stroke: 'rgba(88,166,255,0.4)',
|
stroke: COLORS.widgetStroke,
|
||||||
'stroke-width': 2,
|
'stroke-width': 2,
|
||||||
'stroke-dasharray': '12,8'
|
'stroke-dasharray': '12,8'
|
||||||
});
|
});
|
||||||
svg.appendChild(widgetRect);
|
svg.appendChild(widgetRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPolygon(svg, state) {
|
||||||
const poly = document.createElementNS(ns, 'polygon');
|
const poly = document.createElementNS(ns, 'polygon');
|
||||||
const pts = state.corners.map(c => `${c.x},${c.y}`).join(' ');
|
const pts = state.corners.map(c => `${c.x},${c.y}`).join(' ');
|
||||||
setAttrs(poly, {
|
setAttrs(poly, {
|
||||||
points: pts,
|
points: pts,
|
||||||
fill: 'rgba(88,166,255,0.1)',
|
fill: COLORS.polygonFill,
|
||||||
stroke: '#58a6ff',
|
stroke: COLORS.accent,
|
||||||
'stroke-width': 3,
|
'stroke-width': 3,
|
||||||
'stroke-linejoin': 'round'
|
'stroke-linejoin': 'round'
|
||||||
});
|
});
|
||||||
svg.appendChild(poly);
|
svg.appendChild(poly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCorners(svg, state) {
|
||||||
state.corners.forEach((c, i) => {
|
state.corners.forEach((c, i) => {
|
||||||
const g = document.createElementNS(ns, 'g');
|
const g = document.createElementNS(ns, 'g');
|
||||||
|
|
||||||
const circle = document.createElementNS(ns, 'circle');
|
const circle = document.createElementNS(ns, 'circle');
|
||||||
setAttrs(circle, {
|
setAttrs(circle, {
|
||||||
cx: c.x, cy: c.y, r: 10,
|
cx: c.x, cy: c.y, r: 10,
|
||||||
fill: '#58a6ff',
|
fill: COLORS.accent,
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff',
|
||||||
'stroke-width': 2.5,
|
'stroke-width': 2.5,
|
||||||
class: 'corner-circle'
|
class: 'corner-circle'
|
||||||
@@ -116,3 +125,14 @@ export function renderSVG(svg, state) {
|
|||||||
svg.appendChild(g);
|
svg.appendChild(g);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderSVG(svg, state) {
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${state.fieldW} ${state.fieldH}`);
|
||||||
|
svg.innerHTML = '';
|
||||||
|
|
||||||
|
renderBackground(svg, state);
|
||||||
|
renderGrid(svg, state);
|
||||||
|
renderWidgetRect(svg, state);
|
||||||
|
renderPolygon(svg, state);
|
||||||
|
renderCorners(svg, state);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,77 +1,11 @@
|
|||||||
|
import { $ } from './dom.js';
|
||||||
import { resetState } from './state.js';
|
import { resetState } from './state.js';
|
||||||
import { computeHomography, toMatrix3dCSS } from './homography.js';
|
import { computeHomography, toMatrix3dCSS } from './homography.js';
|
||||||
import { buildCornerInputs, syncCornerInputs, readCornerInputs, syncFieldInputs, syncWidgetInputs } from './inputs.js';
|
import { buildCornerInputs, syncCornerInputs, readCornerInputs, syncFieldInputs, syncWidgetInputs } from './inputs.js';
|
||||||
import { updateLivePreview } from './preview.js';
|
import { updateLivePreview } from './preview.js';
|
||||||
|
import { setFieldEditing, setMode, toggleKeepAspect, isFieldEditing, getFieldEditW, getFieldEditH, setFieldEditW, setFieldEditH, isKeepAspect } from './mode.js';
|
||||||
function $(id) { return document.getElementById(id); }
|
import { setUpdateAllRef, loadBgFile, clearBg, loadWidgetFile, setWidgetImageLock } from './file-loader.js';
|
||||||
|
import { initDropZones } from './drop-zone.js';
|
||||||
let fieldEditing = false;
|
|
||||||
let fieldEditW, fieldEditH;
|
|
||||||
let keepAspect = true;
|
|
||||||
|
|
||||||
function updateChainIcon() {
|
|
||||||
const locked = $('chainLink').querySelectorAll('.chain-locked');
|
|
||||||
const unlocked = $('chainLink').querySelectorAll('.chain-unlocked');
|
|
||||||
locked.forEach(el => el.style.display = keepAspect ? '' : 'none');
|
|
||||||
unlocked.forEach(el => el.style.display = keepAspect ? 'none' : '');
|
|
||||||
$('chainLink').classList.toggle('unlocked', !keepAspect);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFieldEditing(editing, state) {
|
|
||||||
fieldEditing = editing;
|
|
||||||
const inpW = $('inpFieldW');
|
|
||||||
const inpH = $('inpFieldH');
|
|
||||||
const options = $('fieldEditOptions');
|
|
||||||
const btnEdit = $('btnEditField');
|
|
||||||
const chain = $('chainLink');
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
fieldEditW = state.fieldW;
|
|
||||||
fieldEditH = state.fieldH;
|
|
||||||
inpW.disabled = false;
|
|
||||||
inpH.disabled = false;
|
|
||||||
options.classList.add('visible');
|
|
||||||
btnEdit.style.display = 'none';
|
|
||||||
chain.classList.add('visible');
|
|
||||||
updateChainIcon();
|
|
||||||
} else {
|
|
||||||
inpW.value = state.fieldW;
|
|
||||||
inpW.disabled = true;
|
|
||||||
inpH.value = state.fieldH;
|
|
||||||
inpH.disabled = true;
|
|
||||||
options.classList.remove('visible');
|
|
||||||
btnEdit.style.display = '';
|
|
||||||
chain.classList.remove('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMode(mode) {
|
|
||||||
document.body.className = mode === 'preview' ? 'mode-preview' : 'mode-edit';
|
|
||||||
$('btnEdit').classList.toggle('active', mode === 'edit');
|
|
||||||
$('btnPreview').classList.toggle('active', mode === 'preview');
|
|
||||||
$('modeHint').textContent = mode === 'edit'
|
|
||||||
? 'Drag corners on the field'
|
|
||||||
: '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);
|
||||||
@@ -79,11 +13,10 @@ export function updateCSSOutput(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initUI(state, updateAll) {
|
export function initUI(state, updateAll) {
|
||||||
updateAllRef = updateAll;
|
setUpdateAllRef(updateAll);
|
||||||
|
|
||||||
$('chainLink').addEventListener('click', () => {
|
$('chainLink').addEventListener('click', () => {
|
||||||
keepAspect = !keepAspect;
|
toggleKeepAspect();
|
||||||
updateChainIcon();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('btnEditField').addEventListener('click', () => {
|
$('btnEditField').addEventListener('click', () => {
|
||||||
@@ -96,25 +29,27 @@ export function initUI(state, updateAll) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('inpFieldW').addEventListener('input', () => {
|
$('inpFieldW').addEventListener('input', () => {
|
||||||
if (!fieldEditing) return;
|
if (!isFieldEditing()) return;
|
||||||
const newW = parseInt($('inpFieldW').value);
|
const newW = parseInt($('inpFieldW').value);
|
||||||
if (newW <= 0) return;
|
if (newW <= 0) return;
|
||||||
if (keepAspect) {
|
if (isKeepAspect()) {
|
||||||
fieldEditH = Math.round(newW * state.fieldH / state.fieldW);
|
const newH = Math.round(newW * state.fieldH / state.fieldW);
|
||||||
$('inpFieldH').value = fieldEditH;
|
$('inpFieldH').value = newH;
|
||||||
|
setFieldEditH(newH);
|
||||||
}
|
}
|
||||||
fieldEditW = newW;
|
setFieldEditW(newW);
|
||||||
});
|
});
|
||||||
|
|
||||||
$('inpFieldH').addEventListener('input', () => {
|
$('inpFieldH').addEventListener('input', () => {
|
||||||
if (!fieldEditing) return;
|
if (!isFieldEditing()) return;
|
||||||
const newH = parseInt($('inpFieldH').value);
|
const newH = parseInt($('inpFieldH').value);
|
||||||
if (newH <= 0) return;
|
if (newH <= 0) return;
|
||||||
if (keepAspect) {
|
if (isKeepAspect()) {
|
||||||
fieldEditW = Math.round(newH * state.fieldW / state.fieldH);
|
const newW = Math.round(newH * state.fieldW / state.fieldH);
|
||||||
$('inpFieldW').value = fieldEditW;
|
$('inpFieldW').value = newW;
|
||||||
|
setFieldEditW(newW);
|
||||||
}
|
}
|
||||||
fieldEditH = newH;
|
setFieldEditH(newH);
|
||||||
});
|
});
|
||||||
|
|
||||||
$('btnSaveField').addEventListener('click', () => {
|
$('btnSaveField').addEventListener('click', () => {
|
||||||
@@ -207,35 +142,10 @@ export function initUI(state, updateAll) {
|
|||||||
updateAll();
|
updateAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setWidgetImageLock(locked) {
|
|
||||||
$('inpWidgetW').disabled = locked;
|
|
||||||
$('inpWidgetH').disabled = locked;
|
|
||||||
$('btnWidgetRemove').style.display = locked ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadWidgetFile(file) {
|
|
||||||
if (!file || !file.type.startsWith('image/')) return;
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
state.widgetImage = reader.result;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
state.widgetW = img.naturalWidth;
|
|
||||||
state.widgetH = img.naturalHeight;
|
|
||||||
$('inpWidgetW').value = state.widgetW;
|
|
||||||
$('inpWidgetH').value = state.widgetH;
|
|
||||||
setWidgetImageLock(true);
|
|
||||||
updateAll();
|
|
||||||
};
|
|
||||||
img.src = reader.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
$('btnWidgetLoad').addEventListener('click', () => $('widgetFileInput').click());
|
$('btnWidgetLoad').addEventListener('click', () => $('widgetFileInput').click());
|
||||||
|
|
||||||
$('widgetFileInput').addEventListener('change', (e) => {
|
$('widgetFileInput').addEventListener('change', (e) => {
|
||||||
if (e.target.files[0]) loadWidgetFile(e.target.files[0]);
|
if (e.target.files[0]) loadWidgetFile(e.target.files[0], state);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +161,7 @@ export function initUI(state, updateAll) {
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith('image/')) {
|
if (item.type.startsWith('image/')) {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) loadWidgetFile(file);
|
if (file) loadWidgetFile(file, state);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,67 +182,5 @@ export function initUI(state, updateAll) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const lc = document.querySelector('.left-col');
|
initDropZones(state);
|
||||||
|
|
||||||
document.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.body.classList.add('file-dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('drop', () => {
|
|
||||||
document.body.classList.remove('file-dragging');
|
|
||||||
lc.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
lc.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
lc.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
lc.addEventListener('dragleave', (e) => {
|
|
||||||
if (!lc.contains(e.relatedTarget)) {
|
|
||||||
lc.classList.remove('drag-over');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
lc.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.body.classList.remove('file-dragging');
|
|
||||||
lc.classList.remove('drag-over');
|
|
||||||
if (e.dataTransfer.files[0]) loadBgFile(e.dataTransfer.files[0], state);
|
|
||||||
});
|
|
||||||
|
|
||||||
const bp = $('bgPanel');
|
|
||||||
bp.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
bp.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
bp.addEventListener('dragleave', (e) => {
|
|
||||||
if (!bp.contains(e.relatedTarget)) {
|
|
||||||
bp.classList.remove('drag-over');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
bp.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.body.classList.remove('file-dragging');
|
|
||||||
bp.classList.remove('drag-over');
|
|
||||||
if (e.dataTransfer.files[0]) loadBgFile(e.dataTransfer.files[0], state);
|
|
||||||
});
|
|
||||||
|
|
||||||
const wp = $('widgetPanel');
|
|
||||||
wp.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
wp.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
wp.addEventListener('dragleave', (e) => {
|
|
||||||
if (!wp.contains(e.relatedTarget)) {
|
|
||||||
wp.classList.remove('drag-over');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
wp.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.body.classList.remove('file-dragging');
|
|
||||||
wp.classList.remove('drag-over');
|
|
||||||
if (e.dataTransfer.files[0]) loadWidgetFile(e.dataTransfer.files[0]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ main {
|
|||||||
.corner-input-row input:focus {
|
.corner-input-row input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.corner-input-row input.input-error {
|
||||||
|
border-color: #f85149;
|
||||||
|
background: rgba(248, 81, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
#cssOutput {
|
#cssOutput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user