Compare commits
10
Commits
56f01d412e
...
c36b19ea03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36b19ea03 | ||
|
|
eaea365308 | ||
|
|
1f9ac90ce2 | ||
|
|
99ebaceda3 | ||
|
|
5796a01a85 | ||
|
|
3b49c9f3c2 | ||
|
|
680dad30bf | ||
|
|
9fec5884f8 | ||
|
|
ab84d7edfa | ||
|
|
0b05981b49 |
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.mimocode/
|
||||||
@@ -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
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
+23
-1
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
+37
-4
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user