Compare commits

..
10 Commits
Author SHA1 Message Date
Ku6epXBOCTuK c36b19ea03 docs: add workflow description
Deploy to GitHub Pages / deploy (push) Canceled after 0s
2026-06-30 20:32:13 +05:00
Ku6epXBOCTuK eaea365308 docs: update readme 2026-06-24 08:16:36 +05:00
Ku6epXBOCTuK 1f9ac90ce2 refactor: update js - separate files, modules 2026-06-24 08:11:22 +05:00
Ku6epXBOCTuK 99ebaceda3 feat: load widget image flow 2026-06-24 07:59:52 +05:00
Ku6epXBOCTuK 5796a01a85 fix: update load bg file flow 2026-06-24 07:01:53 +05:00
Ku6epXBOCTuK 3b49c9f3c2 feat: load background file 2026-06-24 06:48:13 +05:00
Ku6epXBOCTuK 680dad30bf docs: update link 2026-06-24 01:34:15 +05:00
Ku6epXBOCTuK 9fec5884f8 fix: show widget in center of figure 2026-06-23 22:54:20 +05:00
Ku6epXBOCTuK ab84d7edfa ci: add github pages deploy 2026-06-23 22:54:20 +05:00
Ku6epXBOCTuK 0b05981b49 fix: resolve preview size issue 2026-06-23 22:54:20 +05:00
20 changed files with 740 additions and 116 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: .
- id: deployment
uses: actions/deploy-pages@v4
+1
View File
@@ -0,0 +1 @@
.mimocode/
+67 -4
View File
@@ -1,18 +1,48 @@
# CSS Matrix3D Perspective Calculator # CSS Matrix3D Perspective Calculator
[Русская версия](README_ru.md)
A browser-based tool for generating CSS `matrix3d()` transforms with perspective projection. Define 4 corners on a field and a widget size — get ready-to-use CSS code. A browser-based tool for generating CSS `matrix3d()` transforms with perspective projection. Define 4 corners on a field and a widget size — get ready-to-use CSS code.
**Keywords:** CSS matrix3d, perspective transform, 3D CSS, homography, quadrilateral mapping, CSS transform generator, perspective projection, CSS 3D effects **Keywords:** CSS matrix3d, perspective transform, 3D CSS, homography, quadrilateral mapping, CSS transform generator, perspective projection, CSS 3D effects, background image, perspective mockup, CSS perspective tool
## Usage ## Usage
Open `index.html` in a browser (no build step required). [Open in browser](https://ku6epxboctuk.is-a.dev/css-matrix-calc/) — no build step required.
- **Edit** — drag corners on the SVG field, adjust field and widget sizes - **Edit** — drag corners on the SVG field, adjust field and widget sizes
- **Preview** — see the live matrix3d() transform result - **Preview** — see the live matrix3d() transform result
Copy the CSS from the "CSS Output" section and paste into your project. Copy the CSS from the "CSS Output" section and paste into your project.
## Step-by-Step Example
Place a widget onto a screen in a background image using perspective transform.
### 1. Upload background image
Load a background photo onto the field. This will be your visual reference for positioning corners.
<img src="images/background.jpg" width="600">
### 2. Upload widget image
Load the widget (e.g. a UI panel or screenshot) that you want to place on the screen.
<img src="images/widget.png" width="400">
### 3. Align corners to the target area
Drag the 4 corners on the SVG field to match the edges of the screen (or any quadrilateral surface) in the background image. The preview updates in real time.
### 4. Get the result
The widget is rendered with the correct perspective, matching the screen angle in the background.
<img src="images/result.jpg" width="600">
Copy the generated `matrix3d()` CSS and paste it into your project.
## Features ## Features
- Pure math — homography-based matrix3d() calculation - Pure math — homography-based matrix3d() calculation
@@ -20,8 +50,40 @@ Copy the CSS from the "CSS Output" section and paste into your project.
- Drag-and-drop corner positioning - Drag-and-drop corner positioning
- Editable field dimensions with aspect ratio lock - Editable field dimensions with aspect ratio lock
- Rescale coordinates option when resizing the field - Rescale coordinates option when resizing the field
- **Background image upload** — load an image onto the field for visual reference
- **Widget image upload** — load an image into the widget with auto-sizing
- Three upload methods: file picker, Ctrl+V paste, drag & drop
- **Object-fit options** — contain, cover, fill, none, scale-down
- Zero dependencies, runs in any modern browser - Zero dependencies, runs in any modern browser
## Background Image
Load a background image onto the field to use as a reference when positioning corners. Useful for matching perspective of existing screenshots or photos.
**How to upload:**
- Click "Load Image" and select a file
- Press Ctrl+V to paste from clipboard
- Drag & drop an image file onto the left column or the Background panel
**Adjust fit:**
- Use the object-fit dropdown to control how the image scales: contain, cover, fill, none, or scale-down
## Widget Image
Load an image directly into the widget. When an image is loaded, the widget dimensions (W/H) automatically match the image size and become locked.
**How to upload:**
- Click "Load Image" in the Widget panel
- Click the paste input field and press Ctrl+V
- Drag & drop an image file onto the Widget panel
**To remove:**
- Click "Remove" to clear the widget image and unlock dimensions
## Use Cases ## Use Cases
- **Presentations & landing pages** — render a widget or screenshot on a monitor at any angle - **Presentations & landing pages** — render a widget or screenshot on a monitor at any angle
@@ -30,6 +92,7 @@ Copy the CSS from the "CSS Output" section and paste into your project.
- **App previews** — place mobile UI on a device background - **App previews** — place mobile UI on a device background
- **Scroll-based effects** — animate matrix3d for 3D effects without WebGL - **Scroll-based effects** — animate matrix3d for 3D effects without WebGL
- **Mockups & prototypes** — visually place interfaces in context (on a wall, laptop, banner) - **Mockups & prototypes** — visually place interfaces in context (on a wall, laptop, banner)
- **Perspective matching** — load a photo of a screen, align corners, get the exact CSS transform
## Project Structure ## Project Structure
@@ -44,12 +107,12 @@ Copy the CSS from the "CSS Output" section and paste into your project.
├── drag.js — drag handling ├── drag.js — drag handling
├── inputs.js — input fields ├── inputs.js — input fields
├── preview.js — live preview ├── preview.js — live preview
└── ui.js — buttons and modes └── ui.js — buttons, modes, image upload
``` ```
## How It Works ## How It Works
The tool uses [homography](https://en.wikipedia.org/wiki/Homography_(computer_vision)) to compute a 3D perspective transform from 4 corresponding points. The resulting `matrix3d()` CSS property maps a rectangular widget onto an arbitrary quadrilateral defined by the user. The tool uses [homography](<https://en.wikipedia.org/wiki/Homography_(computer_vision)>) to compute a 3D perspective transform from 4 corresponding points. The resulting `matrix3d()` CSS property maps a rectangular widget onto an arbitrary quadrilateral defined by the user.
## License ## License
+119
View File
@@ -0,0 +1,119 @@
# CSS Matrix3D — Калькулятор перспективы
[English version](README.md)
Браузерный инструмент для генерации CSS-трансформаций `matrix3d()` с перспективной проекцией. Укажите 4 угла на поле и размер виджета — получите готовый CSS-код.
**Ключевые слова:** CSS matrix3d, перспективная трансформация, 3D CSS, гомография, отображение четырёхугольника, генератор CSS-трансформаций, перспективная проекция, CSS 3D-эффекты, фоновое изображение, перспективный макет, инструмент перспективы CSS
## Использование
[Открыть в браузере](https://ku6epxboctuk.is-a.dev/css-matrix-calc/) — не требует сборки.
- **Редактирование** — перетаскивайте углы на SVG-поле, настраивайте размеры поля и виджета
- **Предпросмотр** — результат трансформации matrix3d() обновляется в реальном времени
Скопируйте CSS из раздела «CSS Output» и вставьте в свой проект.
## Пошаговый пример
Размещение виджета на экране в фоновом изображении с помощью перспективной трансформации.
### 1. Загрузите фоновое изображение
Загрузите фотографию фона на поле. Она послужит визуальным ориентиром для позиционирования углов.
<img src="images/background.jpg" width="600">
### 2. Загрузите изображение виджета
Загрузите виджет (например, панель интерфейса или скриншот), который хотите разместить на экране.
<img src="images/widget.png" width="400">
### 3. Сопоставьте углы с целевой областью
Перетащите 4 угла на SVG-поле так, чтобы они совпали с краями экрана (или любой другой четырёхугольной поверхности) на фоновом изображении. Предпросмотр обновляется в реальном времени.
### 4. Получите результат
Виджет отрисовывается с правильной перспективой, соответствующей углу наклона экрана на фоне.
<img src="images/result.jpg" width="600">
Скопируйте сгенерированный CSS с `matrix3d()` и вставьте в свой проект.
## Возможности
- Чистая математика — расчёт matrix3d() на основе гомографии
- Предпросмотр в реальном времени
- Перетаскивание углов мышью
- Редактируемые размеры поля с блокировкой пропорций
- Масштабирование координат при изменении размера поля
- **Загрузка фонового изображения** — загрузка изображения на поле для визуальной ориентации
- **Загрузка изображения виджета** — загрузка изображения в виджет с автоматическим подбором размера
- Три способа загрузки: выбор файла, вставка через Ctrl+V, drag & drop
- **Настройка object-fit** — contain, cover, fill, none, scale-down
- Нулевые зависимости, работает в любом современном браузере
## Фоновое изображение
Загрузите фоновое изображение на поле, чтобы использовать его как ориентир при позиционировании углов. Полезно для точного совпадения перспективы существующих скриншотов или фотографий.
**Как загрузить:**
- Нажмите «Загрузить изображение» и выберите файл
- Нажмите Ctrl+V, чтобы вставить из буфера обмена
- Перетащите файл изображения на левую колонку или панель «Фон»
**Настройка отображения:**
- Используйте выпадающий список object-fit для управления масштабированием изображения: contain, cover, fill, none или scale-down
## Изображение виджета
Загрузите изображение непосредственно в виджет. При загрузке изображения размеры виджета (Ш/В) автоматически подстраиваются под размер изображения и блокируются.
**Как загрузить:**
- Нажмите «Загрузить изображение» на панели виджета
- Нажмите на поле вставки и нажмите Ctrl+V
- Перетащите файл изображения на панель виджета
**Удаление:**
- Нажмите «Удалить», чтобы очистить изображение виджета и разблокировать размеры
## Применение
- **Презентации и лендинги** — отрисовка виджета или скриншота на мониторе под любым углом
- **Карусели и галереи** — демонстрация карточек товаров с перспективной деформацией
- **Интерактивные инфографики** — наложение данных на карты или диаграммы с перспективой
- **Превью приложений** — размещение мобильного интерфейса на фоне устройства
- **Эффекты при скролле** — анимация matrix3d для 3D-эффектов без WebGL
- **Макеты и прототипы** — визуальное размещение интерфейсов в контексте (на стене, ноутбуке, баннере)
- **Совпадение перспективы** — загрузите фото экрана, совместите углы, получите точную CSS-трансформацию
## Структура проекта
```text
├── index.html
├── style.css
└── js/
├── main.js — точка входа
├── state.js — управление состоянием
├── homography.js — математика гомографии
├── svg-renderer.js — SVG-рендеринг
├── drag.js — обработка перетаскивания
├── inputs.js — поля ввода
├── preview.js — предпросмотр
└── ui.js — кнопки, режимы, загрузка изображений
```
## Как это работает
Инструмент использует [гомографию](<https://en.wikipedia.org/wiki/Homography_(computer_vision)>) для вычисления 3D-перспективной трансформации по 4 соответствующим точкам. Результирующее CSS-свойство `matrix3d()` отображает прямоугольный виджет на произвольный четырёхугольник, заданный пользователем.
## Лицензия
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+23 -1
View File
@@ -77,12 +77,34 @@
</div> </div>
</div> </div>
<div class="panel"> <div class="panel" id="bgPanel">
<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" id="widgetPanel">
<h2>Widget</h2> <h2>Widget</h2>
<div class="field-row"> <div class="field-row">
<label><span>W</span><input id="inpWidgetW" type="number" value="400" min="1" /></label> <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> <label><span>H</span><input id="inpWidgetH" type="number" value="300" min="1" /></label>
</div> </div>
<div class="widget-img-controls">
<input type="file" id="widgetFileInput" accept="image/*" hidden />
<button class="btn btn-edit-field" id="btnWidgetLoad">Load Image</button>
<button class="btn btn-cancel-field" id="btnWidgetRemove" style="display:none">Remove</button>
</div>
<input id="widgetPasteInput" type="text" class="widget-paste-input" placeholder="Ctrl+V to paste image" readonly />
</div> </div>
<div class="panel"> <div class="panel">
+15
View File
@@ -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));
+1
View File
@@ -0,0 +1 @@
export const $ = id => document.getElementById(id);
+40
View File
@@ -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));
}
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
}
+37 -4
View File
@@ -1,27 +1,60 @@
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) {
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 = '';
}
}
function applyWidgetImage(widget, state) {
if (state.widgetImage) {
widget.style.backgroundImage = `url(${state.widgetImage})`;
widget.style.backgroundSize = '100% 100%';
widget.style.backgroundPosition = 'center';
widget.style.backgroundRepeat = 'no-repeat';
widget.querySelector('span').style.display = 'none';
} else {
widget.style.backgroundImage = '';
widget.style.backgroundSize = '';
widget.style.backgroundPosition = '';
widget.style.backgroundRepeat = '';
widget.querySelector('span').style.display = '';
}
}
export function updateLivePreview(state) { export function updateLivePreview(state) {
const container = $('fieldContainer'); const container = $('fieldContainer');
const cw = container.clientWidth; const cw = container.clientWidth;
if (cw <= 0) return; if (cw <= 0) return;
const scale = cw / state.fieldW; const maxH = window.innerHeight - FIELD_PADDING;
const scaleW = cw / state.fieldW;
const scaleH = maxH / state.fieldH;
const scale = Math.min(scaleW, scaleH);
const field = $('field'); const field = $('field');
field.style.width = state.fieldW + 'px'; field.style.width = state.fieldW + 'px';
field.style.height = state.fieldH + 'px'; field.style.height = state.fieldH + 'px';
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';
applyWidgetImage(widget, 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';
+11 -18
View File
@@ -6,26 +6,19 @@ 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',
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 }
]
};
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;
defaultState.corners.forEach((c, i) => {
state.corners[i] = { x: c.x, y: c.y };
});
} }
+57 -14
View File
@@ -1,6 +1,15 @@
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 = {
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;
@@ -14,62 +23,85 @@ 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);
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);
}
}
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 cy = state.corners.reduce((s, c) => s + c.y, 0) / 4;
const widgetRect = document.createElementNS(ns, 'rect'); const widgetRect = document.createElementNS(ns, 'rect');
setAttrs(widgetRect, { setAttrs(widgetRect, {
x: 0, y: 0, x: cx - state.widgetW / 2,
y: cy - state.widgetH / 2,
width: state.widgetW, width: state.widgetW,
height: state.widgetH, height: state.widgetH,
fill: 'none', fill: COLORS.widgetFill,
stroke: 'rgba(88,166,255,0.2)', 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'
@@ -93,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);
}
+82 -63
View File
@@ -1,58 +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';
}
export function updateCSSOutput(state) { export function updateCSSOutput(state) {
const H = computeHomography(state); const H = computeHomography(state);
@@ -60,9 +13,10 @@ export function updateCSSOutput(state) {
} }
export function initUI(state, updateAll) { export function initUI(state, updateAll) {
setUpdateAllRef(updateAll);
$('chainLink').addEventListener('click', () => { $('chainLink').addEventListener('click', () => {
keepAspect = !keepAspect; toggleKeepAspect();
updateChainIcon();
}); });
$('btnEditField').addEventListener('click', () => { $('btnEditField').addEventListener('click', () => {
@@ -75,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', () => {
@@ -118,11 +74,13 @@ export function initUI(state, updateAll) {
}); });
$('inpWidgetW').addEventListener('input', () => { $('inpWidgetW').addEventListener('input', () => {
if (state.widgetImage) return;
const v = parseInt($('inpWidgetW').value); const v = parseInt($('inpWidgetW').value);
if (v > 0) { state.widgetW = v; updateAll(); } if (v > 0) { state.widgetW = v; updateAll(); }
}); });
$('inpWidgetH').addEventListener('input', () => { $('inpWidgetH').addEventListener('input', () => {
if (state.widgetImage) return;
const v = parseInt($('inpWidgetH').value); const v = parseInt($('inpWidgetH').value);
if (v > 0) { state.widgetH = v; updateAll(); } if (v > 0) { state.widgetH = v; updateAll(); }
}); });
@@ -150,6 +108,11 @@ 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;
$('inpWidgetW').disabled = false;
$('inpWidgetH').disabled = false;
$('btnWidgetRemove').style.display = 'none';
updateAll(); updateAll();
}); });
@@ -164,4 +127,60 @@ 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();
});
$('btnWidgetLoad').addEventListener('click', () => $('widgetFileInput').click());
$('widgetFileInput').addEventListener('change', (e) => {
if (e.target.files[0]) loadWidgetFile(e.target.files[0], state);
e.target.value = '';
});
$('btnWidgetRemove').addEventListener('click', () => {
state.widgetImage = null;
setWidgetImageLock(false);
updateAll();
});
$('widgetPasteInput').addEventListener('paste', (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) loadWidgetFile(file, state);
break;
}
}
});
document.addEventListener('paste', (e) => {
if (document.activeElement?.id === 'widgetPasteInput') return;
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;
}
}
});
initDropZones(state);
} }
+115 -3
View File
@@ -142,6 +142,10 @@ main {
touch-action: none; touch-action: none;
} }
#svg image {
transition: opacity 0.15s;
}
.corner-circle { .corner-circle {
cursor: grab; cursor: grab;
filter: drop-shadow(0 0 4px rgba(88, 166, 255, 0.5)); filter: drop-shadow(0 0 4px rgba(88, 166, 255, 0.5));
@@ -242,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%;
@@ -402,6 +410,11 @@ main {
cursor: not-allowed; cursor: not-allowed;
} }
.field-row input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.field-edit-options { .field-edit-options {
display: none; display: none;
margin-top: 10px; margin-top: 10px;
@@ -440,10 +453,64 @@ main {
position: relative; position: relative;
} }
.left-col.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -2px;
border-radius: 8px;
}
#field { #field {
position: relative; position: absolute;
top: 0;
left: 0;
transform-origin: 0 0; transform-origin: 0 0;
background: #0c1018; background-color: #0c1018;
background-repeat: no-repeat;
background-position: center;
transition: opacity 0.15s;
}
body.file-dragging #field {
opacity: 0.1;
}
body.file-dragging #svg image {
opacity: 0.1;
}
body.file-dragging .left-col {
outline: 3px dashed rgba(88, 166, 255, 0.9);
outline-offset: -3px;
border-radius: 8px;
}
body.file-dragging .left-col.drag-over {
outline: 5px dashed var(--accent);
outline-offset: -5px;
border-radius: 8px;
background: rgba(88, 166, 255, 0.15);
}
body.file-dragging #bgPanel {
outline: 2px dashed rgba(88, 166, 255, 0.7);
outline-offset: -2px;
}
body.file-dragging #bgPanel.drag-over {
outline: 3px dashed var(--accent);
outline-offset: -3px;
background: rgba(88, 166, 255, 0.08);
}
body.file-dragging #widgetPanel {
outline: 2px dashed rgba(88, 166, 255, 0.7);
outline-offset: -2px;
}
body.file-dragging #widgetPanel.drag-over {
outline: 3px dashed var(--accent);
outline-offset: -3px;
background: rgba(88, 166, 255, 0.08);
} }
#widget { #widget {
@@ -502,10 +569,55 @@ body.mode-preview .right-col {
} }
body.mode-preview .left-col { body.mode-preview .left-col {
max-width: 900px;
width: 100%; width: 100%;
} }
.bg-controls {
display: flex;
align-items: center;
gap: 8px;
}
.widget-img-controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.widget-paste-input {
width: 100%;
padding: 6px 8px;
margin-top: 8px;
background: var(--input-bg);
border: 1px dashed var(--border);
border-radius: 4px;
color: #8b949e;
font-size: 13px;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
}
.widget-paste-input:focus {
border-color: var(--accent);
border-style: solid;
}
#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;