Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/pattern-templates.js
alexei.dolgolyov 493d96d604 Show backend error details in toast notifications
Use error.detail from API responses instead of generic i18n messages
so users see specific reasons for failures (e.g. "Device is referenced
by target(s): ...").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:11:24 +03:00

830 lines
32 KiB
JavaScript

/**
* Pattern templates — cards, visual canvas editor, rect list, drag handlers.
*/
import {
patternEditorRects, setPatternEditorRects,
patternEditorSelectedIdx, setPatternEditorSelectedIdx,
patternEditorBgImage, setPatternEditorBgImage,
patternCanvasDragMode, setPatternCanvasDragMode,
patternCanvasDragStart, setPatternCanvasDragStart,
patternCanvasDragOrigRect, setPatternCanvasDragOrigRect,
patternEditorHoveredIdx, setPatternEditorHoveredIdx,
patternEditorHoverHit, setPatternEditorHoverHit,
PATTERN_RECT_COLORS,
PATTERN_RECT_BORDERS,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
class PatternTemplateModal extends Modal {
constructor() {
super('pattern-template-modal');
}
snapshotValues() {
return {
name: document.getElementById('pattern-template-name').value,
description: document.getElementById('pattern-template-description').value,
rectangles: JSON.stringify(patternEditorRects),
};
}
onForceClose() {
setPatternEditorRects([]);
setPatternEditorSelectedIdx(-1);
setPatternEditorBgImage(null);
// Clean up ResizeObserver to prevent leaks
const canvas = document.getElementById('pattern-canvas');
if (canvas?._patternResizeObserver) {
canvas._patternResizeObserver.disconnect();
canvas._patternResizeObserver = null;
}
if (canvas) canvas._patternEventsAttached = false;
}
}
const patternModal = new PatternTemplateModal();
export function createPatternTemplateCard(pt) {
const rectCount = (pt.rectangles || []).length;
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
return wrapCard({
type: 'template-card',
dataAttr: 'data-pattern-template-id',
id: pt.id,
removeOnclick: `deletePatternTemplate('${pt.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<span class="template-name">${ICON_PATTERN_TEMPLATE} ${escapeHtml(pt.name)}</span>
</div>
${desc}
<div class="stream-card-props">
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
export async function showPatternTemplateEditor(templateId = null, cloneData = null) {
try {
// Load sources for background capture
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const bgSelect = document.getElementById('pattern-bg-source');
bgSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
bgSelect.appendChild(opt);
});
setPatternEditorBgImage(null);
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
if (templateId) {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load pattern template');
const tmpl = await resp.json();
document.getElementById('pattern-template-id').value = tmpl.id;
document.getElementById('pattern-template-name').value = tmpl.name;
document.getElementById('pattern-template-description').value = tmpl.description || '';
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
} else if (cloneData) {
document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pattern-template-description').value = cloneData.description || '';
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
} else {
document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = '';
document.getElementById('pattern-template-description').value = '';
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects([]);
}
patternModal.snapshot();
renderPatternRectList();
renderPatternCanvas();
_attachPatternCanvasEvents();
patternModal.open();
document.getElementById('pattern-template-error').style.display = 'none';
setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
} catch (error) {
console.error('Failed to open pattern template editor:', error);
showToast(t('pattern.error.editor_open_failed'), 'error');
}
}
export function isPatternEditorDirty() {
return patternModal.isDirty();
}
export async function closePatternTemplateModal() {
await patternModal.close();
}
export function forceClosePatternTemplateModal() {
patternModal.forceClose();
}
export async function savePatternTemplate() {
const templateId = document.getElementById('pattern-template-id').value;
const name = document.getElementById('pattern-template-name').value.trim();
const description = document.getElementById('pattern-template-description').value.trim();
if (!name) {
patternModal.showError(t('pattern.error.required'));
return;
}
const payload = {
name,
rectangles: patternEditorRects.map(r => ({
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
})),
description: description || null,
};
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'PUT', body: JSON.stringify(payload),
});
} else {
response = await fetchWithAuth('/pattern-templates', {
method: 'POST', body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
patternModal.forceClose();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
console.error('Error saving pattern template:', error);
patternModal.showError(error.message);
}
}
export async function clonePatternTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/pattern-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load pattern template');
const tmpl = await resp.json();
showPatternTemplateEditor(null, tmpl);
} catch (error) {
if (error.isAuth) return;
showToast(t('pattern.error.clone_failed'), 'error');
}
}
export async function deletePatternTemplate(templateId) {
const confirmed = await showConfirm(t('pattern.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('pattern.deleted'), 'success');
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {
const error = await response.json();
showToast(error.detail || t('pattern.error.delete_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('pattern.error.delete_failed'), 'error');
}
}
// ----- Pattern rect list (precise coordinate inputs) -----
export function renderPatternRectList() {
const container = document.getElementById('pattern-rect-list');
if (!container) return;
if (patternEditorRects.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('pattern.rect.empty')}</div>`;
return;
}
container.innerHTML = patternEditorRects.map((rect, i) => `
<div class="pattern-rect-row${i === patternEditorSelectedIdx ? ' selected' : ''}" onclick="selectPatternRect(${i})">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('pattern.rect.name')}" onchange="updatePatternRect(${i}, 'name', this.value)" onclick="event.stopPropagation()">
<input type="number" value="${rect.x.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'x', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">&#x2715;</button>
</div>
`).join('');
}
export function selectPatternRect(index) {
setPatternEditorSelectedIdx(patternEditorSelectedIdx === index ? -1 : index);
renderPatternRectList();
renderPatternCanvas();
}
export function updatePatternRect(index, field, value) {
if (index < 0 || index >= patternEditorRects.length) return;
patternEditorRects[index][field] = value;
// Clamp coordinates
if (field !== 'name') {
const r = patternEditorRects[index];
r.x = Math.max(0, Math.min(1 - r.width, r.x));
r.y = Math.max(0, Math.min(1 - r.height, r.y));
r.width = Math.max(0.01, Math.min(1, r.width));
r.height = Math.max(0.01, Math.min(1, r.height));
}
renderPatternCanvas();
}
export function addPatternRect() {
const name = `Zone ${patternEditorRects.length + 1}`;
// Inherit size from selected rect, or default to 30%
let w = 0.3, h = 0.3;
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
const sel = patternEditorRects[patternEditorSelectedIdx];
w = sel.width;
h = sel.height;
}
const x = Math.min(0.5 - w / 2, 1 - w);
const y = Math.min(0.5 - h / 2, 1 - h);
patternEditorRects.push({ name, x: Math.max(0, x), y: Math.max(0, y), width: w, height: h });
setPatternEditorSelectedIdx(patternEditorRects.length - 1);
renderPatternRectList();
renderPatternCanvas();
}
export function deleteSelectedPatternRect() {
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
patternEditorRects.splice(patternEditorSelectedIdx, 1);
setPatternEditorSelectedIdx(-1);
renderPatternRectList();
renderPatternCanvas();
}
export function removePatternRect(index) {
patternEditorRects.splice(index, 1);
if (patternEditorSelectedIdx === index) setPatternEditorSelectedIdx(-1);
else if (patternEditorSelectedIdx > index) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
renderPatternRectList();
renderPatternCanvas();
}
// ----- Pattern Canvas Visual Editor -----
export function renderPatternCanvas() {
const canvas = document.getElementById('pattern-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
// Clear
ctx.clearRect(0, 0, w, h);
// Draw background image or grid
if (patternEditorBgImage) {
ctx.drawImage(patternEditorBgImage, 0, 0, w, h);
} else {
// Draw subtle grid
ctx.fillStyle = 'rgba(128,128,128,0.05)';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(128,128,128,0.15)';
ctx.lineWidth = 1;
const dpr = window.devicePixelRatio || 1;
const gridStep = 80 * dpr;
const colsCount = Math.max(2, Math.round(w / gridStep));
const rowsCount = Math.max(2, Math.round(h / gridStep));
for (let gx = 0; gx <= colsCount; gx++) {
const x = Math.round(gx * w / colsCount) + 0.5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let gy = 0; gy <= rowsCount; gy++) {
const y = Math.round(gy * h / rowsCount) + 0.5;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
}
// Draw rectangles
const dpr = window.devicePixelRatio || 1;
patternEditorRects.forEach((rect, i) => {
const rx = rect.x * w;
const ry = rect.y * h;
const rw = rect.width * w;
const rh = rect.height * h;
const colorIdx = i % PATTERN_RECT_COLORS.length;
const isSelected = (i === patternEditorSelectedIdx);
const isHovered = (i === patternEditorHoveredIdx) && !patternCanvasDragMode;
const isDragging = (i === patternEditorSelectedIdx) && !!patternCanvasDragMode;
// Fill
ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx];
ctx.fillRect(rx, ry, rw, rh);
if (isHovered || isDragging) {
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(rx, ry, rw, rh);
}
// Border
ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx];
ctx.lineWidth = isSelected ? 3 : isHovered ? 2.5 : 1.5;
ctx.strokeRect(rx, ry, rw, rh);
// Edge highlight
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
}
if (edgeDir) {
ctx.save();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3 * dpr;
ctx.shadowColor = 'rgba(76,175,80,0.7)';
ctx.shadowBlur = 6 * dpr;
ctx.beginPath();
if (edgeDir.includes('n')) { ctx.moveTo(rx, ry); ctx.lineTo(rx + rw, ry); }
if (edgeDir.includes('s')) { ctx.moveTo(rx, ry + rh); ctx.lineTo(rx + rw, ry + rh); }
if (edgeDir.includes('w')) { ctx.moveTo(rx, ry); ctx.lineTo(rx, ry + rh); }
if (edgeDir.includes('e')) { ctx.moveTo(rx + rw, ry); ctx.lineTo(rx + rw, ry + rh); }
ctx.stroke();
ctx.restore();
}
// Name label
ctx.fillStyle = '#fff';
ctx.font = `${12 * dpr}px sans-serif`;
ctx.shadowColor = 'rgba(0,0,0,0.7)';
ctx.shadowBlur = 3;
ctx.fillText(rect.name, rx + 4 * dpr, ry + 14 * dpr);
ctx.shadowBlur = 0;
// Delete button on hovered or selected rects (not during drag)
if ((isHovered || isSelected) && !patternCanvasDragMode) {
const btnR = 9 * dpr;
const btnCx = rx + rw - btnR - 2 * dpr;
const btnCy = ry + btnR + 2 * dpr;
ctx.beginPath();
ctx.arc(btnCx, btnCy, btnR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
const cross = btnR * 0.5;
ctx.beginPath();
ctx.moveTo(btnCx - cross, btnCy - cross);
ctx.lineTo(btnCx + cross, btnCy + cross);
ctx.moveTo(btnCx + cross, btnCy - cross);
ctx.lineTo(btnCx - cross, btnCy + cross);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 * dpr;
ctx.stroke();
}
});
// Draw "add rectangle" placement buttons (4 corners + center) when not dragging
if (!patternCanvasDragMode) {
const abR = 12 * dpr;
const abMargin = 18 * dpr;
const addBtnPositions = [
{ cx: abMargin, cy: abMargin },
{ cx: w - abMargin, cy: abMargin },
{ cx: w / 2, cy: h / 2 },
{ cx: abMargin, cy: h - abMargin },
{ cx: w - abMargin, cy: h - abMargin },
];
addBtnPositions.forEach(pos => {
ctx.beginPath();
ctx.arc(pos.cx, pos.cy, abR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
const pl = abR * 0.5;
ctx.beginPath();
ctx.moveTo(pos.cx - pl, pos.cy);
ctx.lineTo(pos.cx + pl, pos.cy);
ctx.moveTo(pos.cx, pos.cy - pl);
ctx.lineTo(pos.cx, pos.cy + pl);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1.5 * dpr;
ctx.stroke();
});
}
}
// Placement button positions (relative 0-1 coords for where the new rect is anchored)
const _ADD_BTN_ANCHORS = [
{ ax: 0, ay: 0 },
{ ax: 1, ay: 0 },
{ ax: 0.5, ay: 0.5 },
{ ax: 0, ay: 1 },
{ ax: 1, ay: 1 },
];
function _hitTestAddButtons(mx, my, w, h) {
const dpr = window.devicePixelRatio || 1;
const abR = 12 * dpr;
const abMargin = 18 * dpr;
const positions = [
{ cx: abMargin, cy: abMargin },
{ cx: w - abMargin, cy: abMargin },
{ cx: w / 2, cy: h / 2 },
{ cx: abMargin, cy: h - abMargin },
{ cx: w - abMargin, cy: h - abMargin },
];
for (let i = 0; i < positions.length; i++) {
const dx = mx - positions[i].cx, dy = my - positions[i].cy;
if (dx * dx + dy * dy <= (abR + 3 * dpr) * (abR + 3 * dpr)) return i;
}
return -1;
}
function _addRectAtAnchor(anchorIdx) {
const anchor = _ADD_BTN_ANCHORS[anchorIdx];
const name = `Zone ${patternEditorRects.length + 1}`;
let rw = 0.3, rh = 0.3;
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
const sel = patternEditorRects[patternEditorSelectedIdx];
rw = sel.width;
rh = sel.height;
}
let rx = anchor.ax - rw * anchor.ax;
let ry = anchor.ay - rh * anchor.ay;
rx = Math.max(0, Math.min(1 - rw, rx));
ry = Math.max(0, Math.min(1 - rh, ry));
patternEditorRects.push({ name, x: rx, y: ry, width: rw, height: rh });
setPatternEditorSelectedIdx(patternEditorRects.length - 1);
renderPatternRectList();
renderPatternCanvas();
}
// Hit-test a point against a rect's edges/corners.
const _EDGE_THRESHOLD = 8;
function _hitTestRect(mx, my, r, w, h) {
const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
const dpr = window.devicePixelRatio || 1;
const thr = _EDGE_THRESHOLD * dpr;
const nearLeft = Math.abs(mx - rx) <= thr;
const nearRight = Math.abs(mx - (rx + rw)) <= thr;
const nearTop = Math.abs(my - ry) <= thr;
const nearBottom = Math.abs(my - (ry + rh)) <= thr;
const inHRange = mx >= rx - thr && mx <= rx + rw + thr;
const inVRange = my >= ry - thr && my <= ry + rh + thr;
if (nearTop && nearLeft && inHRange && inVRange) return 'nw';
if (nearTop && nearRight && inHRange && inVRange) return 'ne';
if (nearBottom && nearLeft && inHRange && inVRange) return 'sw';
if (nearBottom && nearRight && inHRange && inVRange) return 'se';
if (nearTop && inHRange) return 'n';
if (nearBottom && inHRange) return 's';
if (nearLeft && inVRange) return 'w';
if (nearRight && inVRange) return 'e';
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) return 'move';
return null;
}
const _DIR_CURSORS = {
'nw': 'nwse-resize', 'se': 'nwse-resize',
'ne': 'nesw-resize', 'sw': 'nesw-resize',
'n': 'ns-resize', 's': 'ns-resize',
'e': 'ew-resize', 'w': 'ew-resize',
'move': 'grab',
};
function _hitTestDeleteButton(mx, my, rect, w, h) {
const dpr = window.devicePixelRatio || 1;
const btnR = 9 * dpr;
const rx = rect.x * w, ry = rect.y * h, rw = rect.width * w;
const btnCx = rx + rw - btnR - 2 * dpr;
const btnCy = ry + btnR + 2 * dpr;
const dx = mx - btnCx, dy = my - btnCy;
return (dx * dx + dy * dy) <= (btnR + 2 * dpr) * (btnR + 2 * dpr);
}
function _patternCanvasDragMove(e) {
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const canvasRect = canvas.getBoundingClientRect();
const scaleX = w / canvasRect.width;
const scaleY = h / canvasRect.height;
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
const dx = (mx - patternCanvasDragStart.mx) / w;
const dy = (my - patternCanvasDragStart.my) / h;
const orig = patternCanvasDragOrigRect;
const r = patternEditorRects[patternEditorSelectedIdx];
if (patternCanvasDragMode === 'move') {
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
} else if (patternCanvasDragMode.startsWith('resize-')) {
const dir = patternCanvasDragMode.replace('resize-', '');
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
if (dir.includes('e')) { nw = orig.width + dx; }
if (dir.includes('n')) { ny = orig.y + dy; nh = orig.height - dy; }
if (dir.includes('s')) { nh = orig.height + dy; }
if (nw < 0.02) { nw = 0.02; if (dir.includes('w')) nx = orig.x + orig.width - 0.02; }
if (nh < 0.02) { nh = 0.02; if (dir.includes('n')) ny = orig.y + orig.height - 0.02; }
nx = Math.max(0, Math.min(1 - nw, nx));
ny = Math.max(0, Math.min(1 - nh, ny));
nw = Math.min(1, nw);
nh = Math.min(1, nh);
r.x = nx; r.y = ny; r.width = nw; r.height = nh;
}
renderPatternCanvas();
}
function _patternCanvasDragEnd(e) {
window.removeEventListener('mousemove', _patternCanvasDragMove);
window.removeEventListener('mouseup', _patternCanvasDragEnd);
setPatternCanvasDragMode(null);
setPatternCanvasDragStart(null);
setPatternCanvasDragOrigRect(null);
// Recalculate hover at current mouse position
const canvas = document.getElementById('pattern-canvas');
if (canvas) {
const w = canvas.width;
const h = canvas.height;
const canvasRect = canvas.getBoundingClientRect();
const scaleX = w / canvasRect.width;
const scaleY = h / canvasRect.height;
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
if (hit) {
cursor = _DIR_CURSORS[hit] || 'default';
newHoverIdx = i;
newHoverHit = hit;
break;
}
}
}
canvas.style.cursor = cursor;
setPatternEditorHoveredIdx(newHoverIdx);
setPatternEditorHoverHit(newHoverHit);
}
renderPatternRectList();
renderPatternCanvas();
}
function _attachPatternCanvasEvents() {
const canvas = document.getElementById('pattern-canvas');
if (!canvas || canvas._patternEventsAttached) return;
canvas._patternEventsAttached = true;
canvas.addEventListener('mousedown', _patternCanvasMouseDown);
canvas.addEventListener('mousemove', _patternCanvasMouseMove);
canvas.addEventListener('mouseleave', _patternCanvasMouseLeave);
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
_patternCanvasMouseDown(_touchToMouseEvent(canvas, touch, 'mousedown'));
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
if (patternCanvasDragMode) {
_patternCanvasDragMove({ clientX: touch.clientX, clientY: touch.clientY });
} else {
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
}
}, { passive: false });
canvas.addEventListener('touchend', () => {
if (patternCanvasDragMode) {
window.removeEventListener('mousemove', _patternCanvasDragMove);
window.removeEventListener('mouseup', _patternCanvasDragEnd);
setPatternCanvasDragMode(null);
setPatternCanvasDragStart(null);
setPatternCanvasDragOrigRect(null);
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(null);
renderPatternRectList();
renderPatternCanvas();
}
});
// Resize observer
const container = canvas.parentElement;
if (container && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
renderPatternCanvas();
});
ro.observe(container);
canvas._patternResizeObserver = ro;
}
}
function _touchToMouseEvent(canvas, touch, type) {
const rect = canvas.getBoundingClientRect();
return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} };
}
function _patternCanvasMouseDown(e) {
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
// Check delete button on hovered or selected rects first
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
if (idx >= 0 && idx < patternEditorRects.length) {
if (_hitTestDeleteButton(mx, my, patternEditorRects[idx], w, h)) {
patternEditorRects.splice(idx, 1);
if (patternEditorSelectedIdx === idx) setPatternEditorSelectedIdx(-1);
else if (patternEditorSelectedIdx > idx) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(null);
renderPatternRectList();
renderPatternCanvas();
return;
}
}
}
// Test all rects; selected rect takes priority so it stays interactive
// even when overlapping with others.
const selIdx = patternEditorSelectedIdx;
const testOrder = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) testOrder.push(i);
}
for (const i of testOrder) {
const r = patternEditorRects[i];
const hit = _hitTestRect(mx, my, r, w, h);
if (!hit) continue;
setPatternEditorSelectedIdx(i);
setPatternCanvasDragStart({ mx, my });
setPatternCanvasDragOrigRect({ ...r });
if (hit === 'move') {
setPatternCanvasDragMode('move');
canvas.style.cursor = 'grabbing';
} else {
setPatternCanvasDragMode(`resize-${hit}`);
canvas.style.cursor = _DIR_CURSORS[hit] || 'default';
}
// Capture mouse at window level for drag
window.addEventListener('mousemove', _patternCanvasDragMove);
window.addEventListener('mouseup', _patternCanvasDragEnd);
e.preventDefault();
renderPatternRectList();
renderPatternCanvas();
return;
}
// Check placement "+" buttons (corners + center)
const addIdx = _hitTestAddButtons(mx, my, w, h);
if (addIdx >= 0) {
_addRectAtAnchor(addIdx);
return;
}
// Click on empty space — deselect
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
canvas.style.cursor = 'default';
renderPatternRectList();
renderPatternCanvas();
}
function _patternCanvasMouseMove(e) {
if (patternCanvasDragMode) return;
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
// Selected rect takes priority for hover so edges stay reachable under overlaps
const selIdx = patternEditorSelectedIdx;
const hoverOrder = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) hoverOrder.push(i);
}
for (const i of hoverOrder) {
const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
if (hit) {
cursor = _DIR_CURSORS[hit] || 'default';
newHoverIdx = i;
newHoverHit = hit;
break;
}
}
canvas.style.cursor = cursor;
if (newHoverIdx !== patternEditorHoveredIdx || newHoverHit !== patternEditorHoverHit) {
setPatternEditorHoveredIdx(newHoverIdx);
setPatternEditorHoverHit(newHoverHit);
renderPatternCanvas();
}
}
function _patternCanvasMouseLeave() {
if (patternCanvasDragMode) return;
if (patternEditorHoveredIdx !== -1) {
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(null);
renderPatternCanvas();
}
}
export async function capturePatternBackground() {
const sourceId = document.getElementById('pattern-bg-source').value;
if (!sourceId) {
showToast(t('pattern.source_for_bg.none'), 'error');
return;
}
try {
const resp = await fetch(`${API_BASE}/picture-sources/${sourceId}/test`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ capture_duration: 0 }),
});
if (!resp.ok) throw new Error('Failed to capture');
const data = await resp.json();
if (data.full_capture && data.full_capture.full_image) {
const img = new Image();
img.onload = () => {
setPatternEditorBgImage(img);
renderPatternCanvas();
};
img.src = data.full_capture.full_image;
}
} catch (error) {
console.error('Failed to capture background:', error);
showToast(t('pattern.error.capture_bg_failed'), 'error');
}
}