Add translation controls

This commit is contained in:
2025-12-04 17:16:30 +01:00
parent cdb7ad64db
commit fe915b71f4
6 changed files with 282 additions and 12 deletions

View File

@@ -102,6 +102,21 @@ body {
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 {
list-style: none;
padding: 0;
@@ -128,6 +143,7 @@ body {
background: #111827;
border: 1px solid #1f2937;
cursor: pointer;
gap: 12px;
}
.asset-item.selected {
@@ -153,3 +169,49 @@ body {
.asset-item.hidden {
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;
}

View File

@@ -5,8 +5,22 @@ canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const assets = new Map();
const imageCache = new Map();
const renderStates = new Map();
let selectedAssetId = 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() {
const socket = new SockJS('/ws');
@@ -33,6 +47,7 @@ function handleEvent(event) {
if (event.type === 'DELETED') {
assets.delete(event.assetId);
imageCache.delete(event.assetId);
renderStates.delete(event.assetId);
if (selectedAssetId === event.assetId) {
selectedAssetId = null;
}
@@ -54,33 +69,68 @@ function draw() {
}
function drawAsset(asset) {
const renderState = smoothState(asset);
ctx.save();
ctx.translate(asset.x, asset.y);
ctx.rotate(asset.rotation * Math.PI / 180);
ctx.translate(renderState.x, renderState.y);
ctx.rotate(renderState.rotation * Math.PI / 180);
const image = ensureImage(asset);
if (image?.complete) {
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 {
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
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) {
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.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
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();
}
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) {
const cached = imageCache.get(asset.id);
if (cached && cached.src === asset.url) {
@@ -102,6 +152,7 @@ function renderAssetList() {
const empty = document.createElement('li');
empty.textContent = 'No assets yet. Upload to get started.';
list.appendChild(empty);
updateSelectedAssetControls();
return;
}
@@ -118,10 +169,15 @@ function renderAssetList() {
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');
meta.className = 'meta';
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');
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
meta.appendChild(name);
@@ -154,13 +210,76 @@ function renderAssetList() {
li.addEventListener('click', () => {
selectedAssetId = asset.id;
renderStates.set(asset.id, { ...asset });
drawAndList();
});
li.appendChild(preview);
li.appendChild(meta);
li.appendChild(actions);
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) {
@@ -178,6 +297,7 @@ function deleteAsset(asset) {
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
assets.delete(asset.id);
imageCache.delete(asset.id);
renderStates.delete(asset.id);
if (selectedAssetId === asset.id) {
selectedAssetId = null;
}
@@ -296,4 +416,5 @@ window.addEventListener('resize', () => {
draw();
});
startRenderLoop();
connect();

View File

@@ -4,6 +4,8 @@ canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const assets = new Map();
const imageCache = new Map();
const renderStates = new Map();
let animationFrameId = null;
function connect() {
const socket = new SockJS('/ws');
@@ -26,12 +28,14 @@ function handleEvent(event) {
if (event.type === 'DELETED') {
assets.delete(event.assetId);
imageCache.delete(event.assetId);
renderStates.delete(event.assetId);
} else if (event.payload && !event.payload.hidden) {
assets.set(event.payload.id, event.payload);
ensureImage(event.payload);
} else if (event.payload && event.payload.hidden) {
assets.delete(event.payload.id);
imageCache.delete(event.payload.id);
renderStates.delete(event.payload.id);
}
draw();
}
@@ -42,18 +46,42 @@ function draw() {
}
function drawAsset(asset) {
const renderState = smoothState(asset);
ctx.save();
ctx.translate(asset.x, asset.y);
ctx.rotate(asset.rotation * Math.PI / 180);
ctx.translate(renderState.x, renderState.y);
ctx.rotate(renderState.rotation * Math.PI / 180);
const image = ensureImage(asset);
if (image?.complete) {
ctx.drawImage(image, 0, 0, asset.width, asset.height);
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
}
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) {
const cached = imageCache.get(asset.id);
if (cached && cached.src === asset.url) {
@@ -67,10 +95,22 @@ function ensureImage(asset) {
return image;
}
function startRenderLoop() {
if (animationFrameId) {
return;
}
const tick = () => {
draw();
animationFrameId = requestAnimationFrame(tick);
};
animationFrameId = requestAnimationFrame(tick);
}
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
});
startRenderLoop();
connect();

View File

@@ -25,6 +25,32 @@
<input id="asset-file" type="file" accept="image/*" />
<button onclick="uploadAsset()">Upload</button>
<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"></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>
</section>
<section class="overlay">