mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add translation controls
This commit is contained in:
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.PrePersist;
|
import jakarta.persistence.PrePersist;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -19,6 +20,9 @@ public class Asset {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String broadcaster;
|
private String broadcaster;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String url;
|
private String url;
|
||||||
private double x;
|
private double x;
|
||||||
@@ -32,9 +36,10 @@ public class Asset {
|
|||||||
public Asset() {
|
public Asset() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Asset(String broadcaster, String url, double width, double height) {
|
public Asset(String broadcaster, String name, String url, double width, double height) {
|
||||||
this.id = UUID.randomUUID().toString();
|
this.id = UUID.randomUUID().toString();
|
||||||
this.broadcaster = normalize(broadcaster);
|
this.broadcaster = normalize(broadcaster);
|
||||||
|
this.name = name;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
@@ -46,6 +51,7 @@ public class Asset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
|
@PreUpdate
|
||||||
public void prepare() {
|
public void prepare() {
|
||||||
if (this.id == null) {
|
if (this.id == null) {
|
||||||
this.id = UUID.randomUUID().toString();
|
this.id = UUID.randomUUID().toString();
|
||||||
@@ -54,6 +60,9 @@ public class Asset {
|
|||||||
this.createdAt = Instant.now();
|
this.createdAt = Instant.now();
|
||||||
}
|
}
|
||||||
this.broadcaster = normalize(broadcaster);
|
this.broadcaster = normalize(broadcaster);
|
||||||
|
if (this.name == null || this.name.isBlank()) {
|
||||||
|
this.name = this.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getId() {
|
public String getId() {
|
||||||
@@ -68,6 +77,14 @@ public class Asset {
|
|||||||
this.broadcaster = normalize(broadcaster);
|
this.broadcaster = normalize(broadcaster);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
public String getUrl() {
|
public String getUrl() {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,9 +75,13 @@ public class ChannelDirectoryService {
|
|||||||
if (image == null) {
|
if (image == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
String name = Optional.ofNullable(file.getOriginalFilename())
|
||||||
|
.map(filename -> filename.replaceAll("^.*[/\\\\]", ""))
|
||||||
|
.filter(s -> !s.isBlank())
|
||||||
|
.orElse("Asset " + System.currentTimeMillis());
|
||||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
||||||
String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes);
|
String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes);
|
||||||
Asset asset = new Asset(channel.getBroadcaster(), dataUrl, image.getWidth(), image.getHeight());
|
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, image.getWidth(), image.getHeight());
|
||||||
assetRepository.save(asset);
|
assetRepository.save(asset);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||||
return Optional.of(asset);
|
return Optional.of(asset);
|
||||||
|
|||||||
@@ -102,6 +102,21 @@ body {
|
|||||||
border: 1px solid #1f2937;
|
border: 1px solid #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.panel ul {
|
.panel ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -128,6 +143,7 @@ body {
|
|||||||
background: #111827;
|
background: #111827;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #1f2937;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-item.selected {
|
.asset-item.selected {
|
||||||
@@ -153,3 +169,49 @@ body {
|
|||||||
.asset-item.hidden {
|
.asset-item.hidden {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-preview {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid input[type="number"],
|
||||||
|
.control-grid input[type="range"] {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-value {
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,22 @@ canvas.width = canvas.offsetWidth;
|
|||||||
canvas.height = canvas.offsetHeight;
|
canvas.height = canvas.offsetHeight;
|
||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const imageCache = new Map();
|
const imageCache = new Map();
|
||||||
|
const renderStates = new Map();
|
||||||
let selectedAssetId = null;
|
let selectedAssetId = null;
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
|
let animationFrameId = null;
|
||||||
|
|
||||||
|
const controlsPanel = document.getElementById('asset-controls');
|
||||||
|
const widthInput = document.getElementById('asset-width');
|
||||||
|
const heightInput = document.getElementById('asset-height');
|
||||||
|
const rotationInput = document.getElementById('asset-rotation');
|
||||||
|
const rotationDisplay = document.getElementById('rotation-display');
|
||||||
|
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||||
|
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||||
|
|
||||||
|
if (rotationInput) {
|
||||||
|
rotationInput.addEventListener('input', updateRotationDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const socket = new SockJS('/ws');
|
const socket = new SockJS('/ws');
|
||||||
@@ -33,6 +47,7 @@ function handleEvent(event) {
|
|||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(event.assetId);
|
||||||
imageCache.delete(event.assetId);
|
imageCache.delete(event.assetId);
|
||||||
|
renderStates.delete(event.assetId);
|
||||||
if (selectedAssetId === event.assetId) {
|
if (selectedAssetId === event.assetId) {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
}
|
}
|
||||||
@@ -54,33 +69,68 @@ function draw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawAsset(asset) {
|
function drawAsset(asset) {
|
||||||
|
const renderState = smoothState(asset);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(asset.x, asset.y);
|
ctx.translate(renderState.x, renderState.y);
|
||||||
ctx.rotate(asset.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const image = ensureImage(asset);
|
const image = ensureImage(asset);
|
||||||
if (image?.complete) {
|
if (image?.complete) {
|
||||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||||
ctx.drawImage(image, 0, 0, asset.width, asset.height);
|
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||||
} else {
|
} else {
|
||||||
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
||||||
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
||||||
ctx.fillRect(0, 0, asset.width, asset.height);
|
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.hidden) {
|
if (asset.hidden) {
|
||||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
|
||||||
ctx.fillRect(0, 0, asset.width, asset.height);
|
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)';
|
ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)';
|
||||||
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
||||||
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
||||||
ctx.strokeRect(0, 0, asset.width, asset.height);
|
ctx.strokeRect(0, 0, renderState.width, renderState.height);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function smoothState(asset) {
|
||||||
|
const previous = renderStates.get(asset.id) || { ...asset };
|
||||||
|
const factor = dragState && dragState.assetId === asset.id ? 0.5 : 0.18;
|
||||||
|
const next = {
|
||||||
|
x: lerp(previous.x, asset.x, factor),
|
||||||
|
y: lerp(previous.y, asset.y, factor),
|
||||||
|
width: lerp(previous.width, asset.width, factor),
|
||||||
|
height: lerp(previous.height, asset.height, factor),
|
||||||
|
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
|
||||||
|
};
|
||||||
|
renderStates.set(asset.id, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothAngle(current, target, factor) {
|
||||||
|
let delta = ((target - current + 180) % 360) - 180;
|
||||||
|
return current + delta * factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (animationFrameId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
draw();
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureImage(asset) {
|
function ensureImage(asset) {
|
||||||
const cached = imageCache.get(asset.id);
|
const cached = imageCache.get(asset.id);
|
||||||
if (cached && cached.src === asset.url) {
|
if (cached && cached.src === asset.url) {
|
||||||
@@ -102,6 +152,7 @@ function renderAssetList() {
|
|||||||
const empty = document.createElement('li');
|
const empty = document.createElement('li');
|
||||||
empty.textContent = 'No assets yet. Upload to get started.';
|
empty.textContent = 'No assets yet. Upload to get started.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
|
updateSelectedAssetControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +169,15 @@ function renderAssetList() {
|
|||||||
li.classList.add('hidden');
|
li.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preview = document.createElement('img');
|
||||||
|
preview.className = 'asset-preview';
|
||||||
|
preview.src = asset.url;
|
||||||
|
preview.alt = asset.name || 'Asset preview';
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'meta';
|
meta.className = 'meta';
|
||||||
const name = document.createElement('strong');
|
const name = document.createElement('strong');
|
||||||
name.textContent = `Asset ${asset.id.slice(0, 6)}`;
|
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||||
const details = document.createElement('small');
|
const details = document.createElement('small');
|
||||||
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||||
meta.appendChild(name);
|
meta.appendChild(name);
|
||||||
@@ -154,13 +210,76 @@ function renderAssetList() {
|
|||||||
|
|
||||||
li.addEventListener('click', () => {
|
li.addEventListener('click', () => {
|
||||||
selectedAssetId = asset.id;
|
selectedAssetId = asset.id;
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
drawAndList();
|
drawAndList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
li.appendChild(preview);
|
||||||
li.appendChild(meta);
|
li.appendChild(meta);
|
||||||
li.appendChild(actions);
|
li.appendChild(actions);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateSelectedAssetControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedAsset() {
|
||||||
|
return selectedAssetId ? assets.get(selectedAssetId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedAssetControls() {
|
||||||
|
if (!controlsPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) {
|
||||||
|
controlsPanel.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsPanel.classList.remove('hidden');
|
||||||
|
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||||
|
selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||||
|
|
||||||
|
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||||
|
if (heightInput) heightInput.value = Math.round(asset.height);
|
||||||
|
if (rotationInput) {
|
||||||
|
rotationInput.value = Math.round(asset.rotation);
|
||||||
|
updateRotationDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransformFromInputs() {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const nextWidth = parseFloat(widthInput?.value) || asset.width;
|
||||||
|
const nextHeight = parseFloat(heightInput?.value) || asset.height;
|
||||||
|
const nextRotation = parseFloat(rotationInput?.value) || 0;
|
||||||
|
|
||||||
|
asset.width = Math.max(10, nextWidth);
|
||||||
|
asset.height = Math.max(10, nextHeight);
|
||||||
|
asset.rotation = nextRotation;
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
|
persistTransform(asset);
|
||||||
|
drawAndList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeRotation(delta) {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (!asset) return;
|
||||||
|
const next = (asset.rotation || 0) + delta;
|
||||||
|
if (rotationInput) rotationInput.value = next;
|
||||||
|
asset.rotation = next;
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
|
updateRotationDisplay();
|
||||||
|
persistTransform(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRotationDisplay() {
|
||||||
|
if (rotationDisplay && rotationInput) {
|
||||||
|
const value = Math.round(parseFloat(rotationInput.value || '0'));
|
||||||
|
rotationDisplay.textContent = `${value}°`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVisibility(asset, hidden) {
|
function updateVisibility(asset, hidden) {
|
||||||
@@ -178,6 +297,7 @@ function deleteAsset(asset) {
|
|||||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
|
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
|
||||||
assets.delete(asset.id);
|
assets.delete(asset.id);
|
||||||
imageCache.delete(asset.id);
|
imageCache.delete(asset.id);
|
||||||
|
renderStates.delete(asset.id);
|
||||||
if (selectedAssetId === asset.id) {
|
if (selectedAssetId === asset.id) {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
}
|
}
|
||||||
@@ -296,4 +416,5 @@ window.addEventListener('resize', () => {
|
|||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startRenderLoop();
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ canvas.width = window.innerWidth;
|
|||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight;
|
||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const imageCache = new Map();
|
const imageCache = new Map();
|
||||||
|
const renderStates = new Map();
|
||||||
|
let animationFrameId = null;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const socket = new SockJS('/ws');
|
const socket = new SockJS('/ws');
|
||||||
@@ -26,12 +28,14 @@ function handleEvent(event) {
|
|||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(event.assetId);
|
||||||
imageCache.delete(event.assetId);
|
imageCache.delete(event.assetId);
|
||||||
|
renderStates.delete(event.assetId);
|
||||||
} else if (event.payload && !event.payload.hidden) {
|
} else if (event.payload && !event.payload.hidden) {
|
||||||
assets.set(event.payload.id, event.payload);
|
assets.set(event.payload.id, event.payload);
|
||||||
ensureImage(event.payload);
|
ensureImage(event.payload);
|
||||||
} else if (event.payload && event.payload.hidden) {
|
} else if (event.payload && event.payload.hidden) {
|
||||||
assets.delete(event.payload.id);
|
assets.delete(event.payload.id);
|
||||||
imageCache.delete(event.payload.id);
|
imageCache.delete(event.payload.id);
|
||||||
|
renderStates.delete(event.payload.id);
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -42,18 +46,42 @@ function draw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawAsset(asset) {
|
function drawAsset(asset) {
|
||||||
|
const renderState = smoothState(asset);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(asset.x, asset.y);
|
ctx.translate(renderState.x, renderState.y);
|
||||||
ctx.rotate(asset.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const image = ensureImage(asset);
|
const image = ensureImage(asset);
|
||||||
if (image?.complete) {
|
if (image?.complete) {
|
||||||
ctx.drawImage(image, 0, 0, asset.width, asset.height);
|
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function smoothState(asset) {
|
||||||
|
const previous = renderStates.get(asset.id) || { ...asset };
|
||||||
|
const factor = 0.15;
|
||||||
|
const next = {
|
||||||
|
x: lerp(previous.x, asset.x, factor),
|
||||||
|
y: lerp(previous.y, asset.y, factor),
|
||||||
|
width: lerp(previous.width, asset.width, factor),
|
||||||
|
height: lerp(previous.height, asset.height, factor),
|
||||||
|
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
|
||||||
|
};
|
||||||
|
renderStates.set(asset.id, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothAngle(current, target, factor) {
|
||||||
|
const delta = ((target - current + 180) % 360) - 180;
|
||||||
|
return current + delta * factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureImage(asset) {
|
function ensureImage(asset) {
|
||||||
const cached = imageCache.get(asset.id);
|
const cached = imageCache.get(asset.id);
|
||||||
if (cached && cached.src === asset.url) {
|
if (cached && cached.src === asset.url) {
|
||||||
@@ -67,10 +95,22 @@ function ensureImage(asset) {
|
|||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (animationFrameId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
draw();
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth;
|
||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight;
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startRenderLoop();
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -25,6 +25,32 @@
|
|||||||
<input id="asset-file" type="file" accept="image/*" />
|
<input id="asset-file" type="file" accept="image/*" />
|
||||||
<button onclick="uploadAsset()">Upload</button>
|
<button onclick="uploadAsset()">Upload</button>
|
||||||
<ul id="asset-list" class="asset-list"></ul>
|
<ul id="asset-list" class="asset-list"></ul>
|
||||||
|
<div id="asset-controls" class="panel hidden">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4 id="selected-asset-name">Selected asset</h4>
|
||||||
|
<small id="selected-asset-meta"></small>
|
||||||
|
</div>
|
||||||
|
<div class="control-grid">
|
||||||
|
<label>
|
||||||
|
Width
|
||||||
|
<input id="asset-width" type="number" min="10" step="5" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Height
|
||||||
|
<input id="asset-height" type="number" min="10" step="5" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rotation
|
||||||
|
<input id="asset-rotation" type="range" min="-180" max="180" step="1" />
|
||||||
|
<div class="range-value" id="rotation-display">0°</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate -5°</button>
|
||||||
|
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate +5°</button>
|
||||||
|
<button type="button" onclick="applyTransformFromInputs()">Apply size & rotation</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="overlay">
|
<section class="overlay">
|
||||||
|
|||||||
Reference in New Issue
Block a user