mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add translation handles
This commit is contained in:
@@ -10,9 +10,11 @@ const assets = new Map();
|
|||||||
const imageCache = new Map();
|
const imageCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
let selectedAssetId = null;
|
let selectedAssetId = null;
|
||||||
let dragState = null;
|
let interactionState = null;
|
||||||
let animationFrameId = null;
|
let animationFrameId = null;
|
||||||
let lastSizeInputChanged = null;
|
let lastSizeInputChanged = null;
|
||||||
|
const HANDLE_SIZE = 10;
|
||||||
|
const ROTATE_HANDLE_OFFSET = 32;
|
||||||
|
|
||||||
const controlsPanel = document.getElementById('asset-controls');
|
const controlsPanel = document.getElementById('asset-controls');
|
||||||
const widthInput = document.getElementById('asset-width');
|
const widthInput = document.getElementById('asset-width');
|
||||||
@@ -132,12 +134,15 @@ function drawAsset(asset) {
|
|||||||
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(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
|
if (asset.id === selectedAssetId) {
|
||||||
|
drawSelectionOverlay(renderState);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function smoothState(asset) {
|
function smoothState(asset) {
|
||||||
const previous = renderStates.get(asset.id) || { ...asset };
|
const previous = renderStates.get(asset.id) || { ...asset };
|
||||||
const factor = dragState && dragState.assetId === asset.id ? 0.5 : 0.18;
|
const factor = interactionState && interactionState.assetId === asset.id ? 0.5 : 0.18;
|
||||||
const next = {
|
const next = {
|
||||||
x: lerp(previous.x, asset.x, factor),
|
x: lerp(previous.x, asset.x, factor),
|
||||||
y: lerp(previous.y, asset.y, factor),
|
y: lerp(previous.y, asset.y, factor),
|
||||||
@@ -158,6 +163,194 @@ function lerp(a, b, t) {
|
|||||||
return a + (b - a) * t;
|
return a + (b - a) * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawSelectionOverlay(asset) {
|
||||||
|
const halfWidth = asset.width / 2;
|
||||||
|
const halfHeight = asset.height / 2;
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([6, 4]);
|
||||||
|
ctx.strokeStyle = 'rgba(124, 58, 237, 0.9)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height);
|
||||||
|
|
||||||
|
const handles = getHandlePositions(asset);
|
||||||
|
handles.forEach((handle) => {
|
||||||
|
drawHandle(handle.x - halfWidth, handle.y - halfHeight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHandle(x, y, isRotation) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = isRotation ? 'rgba(96, 165, 250, 0.9)' : 'rgba(124, 58, 237, 0.9)';
|
||||||
|
ctx.strokeStyle = '#0f172a';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
if (isRotation) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
|
ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHandlePositions(asset) {
|
||||||
|
return [
|
||||||
|
{ x: 0, y: 0, type: 'nw' },
|
||||||
|
{ x: asset.width / 2, y: 0, type: 'n' },
|
||||||
|
{ x: asset.width, y: 0, type: 'ne' },
|
||||||
|
{ x: asset.width, y: asset.height / 2, type: 'e' },
|
||||||
|
{ x: asset.width, y: asset.height, type: 'se' },
|
||||||
|
{ x: asset.width / 2, y: asset.height, type: 's' },
|
||||||
|
{ x: 0, y: asset.height, type: 'sw' },
|
||||||
|
{ x: 0, y: asset.height / 2, type: 'w' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePoint(x, y, degrees) {
|
||||||
|
const radians = degrees * Math.PI / 180;
|
||||||
|
const cos = Math.cos(radians);
|
||||||
|
const sin = Math.sin(radians);
|
||||||
|
return {
|
||||||
|
x: x * cos - y * sin,
|
||||||
|
y: x * sin + y * cos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerToLocal(asset, point) {
|
||||||
|
const centerX = asset.x + asset.width / 2;
|
||||||
|
const centerY = asset.y + asset.height / 2;
|
||||||
|
const dx = point.x - centerX;
|
||||||
|
const dy = point.y - centerY;
|
||||||
|
const rotated = rotatePoint(dx, dy, -asset.rotation);
|
||||||
|
return {
|
||||||
|
x: rotated.x + asset.width / 2,
|
||||||
|
y: rotated.y + asset.height / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleFromCenter(asset, point) {
|
||||||
|
const centerX = asset.x + asset.width / 2;
|
||||||
|
const centerY = asset.y + asset.height / 2;
|
||||||
|
return Math.atan2(point.y - centerY, point.x - centerX) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitHandle(asset, point) {
|
||||||
|
const local = pointerToLocal(asset, point);
|
||||||
|
const tolerance = HANDLE_SIZE * 1.2;
|
||||||
|
const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET);
|
||||||
|
if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) {
|
||||||
|
return 'rotate';
|
||||||
|
}
|
||||||
|
for (const handle of getHandlePositions(asset)) {
|
||||||
|
if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) {
|
||||||
|
return handle.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursorForHandle(handle) {
|
||||||
|
switch (handle) {
|
||||||
|
case 'nw':
|
||||||
|
case 'se':
|
||||||
|
return 'nwse-resize';
|
||||||
|
case 'ne':
|
||||||
|
case 'sw':
|
||||||
|
return 'nesw-resize';
|
||||||
|
case 'n':
|
||||||
|
case 's':
|
||||||
|
return 'ns-resize';
|
||||||
|
case 'e':
|
||||||
|
case 'w':
|
||||||
|
return 'ew-resize';
|
||||||
|
case 'rotate':
|
||||||
|
return 'grab';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeFromHandle(state, point) {
|
||||||
|
const asset = assets.get(state.assetId);
|
||||||
|
if (!asset) return;
|
||||||
|
const basis = state.original;
|
||||||
|
const local = pointerToLocal(basis, point);
|
||||||
|
const handle = state.handle;
|
||||||
|
const minSize = 10;
|
||||||
|
|
||||||
|
let nextWidth = basis.width;
|
||||||
|
let nextHeight = basis.height;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (handle.includes('e')) {
|
||||||
|
nextWidth = basis.width + (local.x - state.startLocal.x);
|
||||||
|
}
|
||||||
|
if (handle.includes('s')) {
|
||||||
|
nextHeight = basis.height + (local.y - state.startLocal.y);
|
||||||
|
}
|
||||||
|
if (handle.includes('w')) {
|
||||||
|
nextWidth = basis.width - (local.x - state.startLocal.x);
|
||||||
|
}
|
||||||
|
if (handle.includes('n')) {
|
||||||
|
nextHeight = basis.height - (local.y - state.startLocal.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = isAspectLocked(asset.id) ? (getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1)) : null;
|
||||||
|
if (ratio) {
|
||||||
|
const widthChanged = handle.includes('e') || handle.includes('w');
|
||||||
|
const heightChanged = handle.includes('n') || handle.includes('s');
|
||||||
|
if (widthChanged && !heightChanged) {
|
||||||
|
nextHeight = nextWidth / ratio;
|
||||||
|
} else if (!widthChanged && heightChanged) {
|
||||||
|
nextWidth = nextHeight * ratio;
|
||||||
|
} else {
|
||||||
|
if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) {
|
||||||
|
nextHeight = nextWidth / ratio;
|
||||||
|
} else {
|
||||||
|
nextWidth = nextHeight * ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWidth = Math.max(minSize, nextWidth);
|
||||||
|
nextHeight = Math.max(minSize, nextHeight);
|
||||||
|
|
||||||
|
if (handle.includes('w')) {
|
||||||
|
offsetX = basis.width - nextWidth;
|
||||||
|
}
|
||||||
|
if (handle.includes('n')) {
|
||||||
|
offsetY = basis.height - nextHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift = rotatePoint(offsetX, offsetY, basis.rotation);
|
||||||
|
asset.x = basis.x + shift.x;
|
||||||
|
asset.y = basis.y + shift.y;
|
||||||
|
asset.width = nextWidth;
|
||||||
|
asset.height = nextHeight;
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHoverCursor(point) {
|
||||||
|
const asset = getSelectedAsset();
|
||||||
|
if (asset) {
|
||||||
|
const handle = hitHandle(asset, point);
|
||||||
|
if (handle) {
|
||||||
|
canvas.style.cursor = cursorForHandle(handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hit = findAssetAtPoint(point.x, point.y);
|
||||||
|
canvas.style.cursor = hit ? 'move' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
function startRenderLoop() {
|
function startRenderLoop() {
|
||||||
if (animationFrameId) {
|
if (animationFrameId) {
|
||||||
return;
|
return;
|
||||||
@@ -463,49 +656,93 @@ function persistTransform(asset) {
|
|||||||
|
|
||||||
canvas.addEventListener('mousedown', (event) => {
|
canvas.addEventListener('mousedown', (event) => {
|
||||||
const point = getCanvasPoint(event);
|
const point = getCanvasPoint(event);
|
||||||
|
const current = getSelectedAsset();
|
||||||
|
const handle = current ? hitHandle(current, point) : null;
|
||||||
|
if (current && handle) {
|
||||||
|
interactionState = handle === 'rotate'
|
||||||
|
? {
|
||||||
|
mode: 'rotate',
|
||||||
|
assetId: current.id,
|
||||||
|
startAngle: angleFromCenter(current, point),
|
||||||
|
startRotation: current.rotation || 0
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: 'resize',
|
||||||
|
assetId: current.id,
|
||||||
|
handle,
|
||||||
|
startLocal: pointerToLocal(current, point),
|
||||||
|
original: { ...current }
|
||||||
|
};
|
||||||
|
canvas.style.cursor = cursorForHandle(handle);
|
||||||
|
drawAndList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hit = findAssetAtPoint(point.x, point.y);
|
const hit = findAssetAtPoint(point.x, point.y);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
selectedAssetId = hit.id;
|
selectedAssetId = hit.id;
|
||||||
dragState = {
|
renderStates.set(hit.id, { ...hit });
|
||||||
|
interactionState = {
|
||||||
|
mode: 'move',
|
||||||
assetId: hit.id,
|
assetId: hit.id,
|
||||||
offsetX: point.x - hit.x,
|
offsetX: point.x - hit.x,
|
||||||
offsetY: point.y - hit.y
|
offsetY: point.y - hit.y
|
||||||
};
|
};
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
} else {
|
} else {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
|
interactionState = null;
|
||||||
|
canvas.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
drawAndList();
|
drawAndList();
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mousemove', (event) => {
|
canvas.addEventListener('mousemove', (event) => {
|
||||||
if (!dragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const asset = assets.get(dragState.assetId);
|
|
||||||
if (!asset) {
|
|
||||||
dragState = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const point = getCanvasPoint(event);
|
const point = getCanvasPoint(event);
|
||||||
asset.x = point.x - dragState.offsetX;
|
if (!interactionState) {
|
||||||
asset.y = point.y - dragState.offsetY;
|
updateHoverCursor(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asset = assets.get(interactionState.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
interactionState = null;
|
||||||
|
updateHoverCursor(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactionState.mode === 'move') {
|
||||||
|
asset.x = point.x - interactionState.offsetX;
|
||||||
|
asset.y = point.y - interactionState.offsetY;
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
draw();
|
draw();
|
||||||
|
} else if (interactionState.mode === 'resize') {
|
||||||
|
resizeFromHandle(interactionState, point);
|
||||||
|
canvas.style.cursor = cursorForHandle(interactionState.handle);
|
||||||
|
} else if (interactionState.mode === 'rotate') {
|
||||||
|
const angle = angleFromCenter(asset, point);
|
||||||
|
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
||||||
|
renderStates.set(asset.id, { ...asset });
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
draw();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function endDrag() {
|
function endInteraction() {
|
||||||
if (!dragState) {
|
if (!interactionState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const asset = assets.get(dragState.assetId);
|
const asset = assets.get(interactionState.assetId);
|
||||||
dragState = null;
|
interactionState = null;
|
||||||
|
canvas.style.cursor = 'default';
|
||||||
drawAndList();
|
drawAndList();
|
||||||
if (asset) {
|
if (asset) {
|
||||||
persistTransform(asset);
|
persistTransform(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', endDrag);
|
canvas.addEventListener('mouseup', endInteraction);
|
||||||
canvas.addEventListener('mouseleave', endDrag);
|
canvas.addEventListener('mouseleave', endInteraction);
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ function renderAdmins(list) {
|
|||||||
identity.appendChild(avatar);
|
identity.appendChild(avatar);
|
||||||
identity.appendChild(details);
|
identity.appendChild(details);
|
||||||
li.appendChild(identity);
|
li.appendChild(identity);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'actions';
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'secondary';
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.addEventListener('click', () => removeAdmin(admin.login));
|
||||||
|
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
li.appendChild(actions);
|
||||||
adminList.appendChild(li);
|
adminList.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,6 +62,13 @@ function fetchAdmins() {
|
|||||||
.catch(() => renderAdmins([]));
|
.catch(() => renderAdmins([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeAdmin(username) {
|
||||||
|
if (!username) return;
|
||||||
|
fetch(`/api/channels/${broadcaster}/admins/${encodeURIComponent(username)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(fetchAdmins);
|
||||||
|
}
|
||||||
|
|
||||||
function addAdmin() {
|
function addAdmin() {
|
||||||
const input = document.getElementById('new-admin');
|
const input = document.getElementById('new-admin');
|
||||||
const username = input.value.trim();
|
const username = input.value.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user