/** * 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'; 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 ? `
${escapeHtml(pt.description)}
` : ''; return `
${ICON_PATTERN_TEMPLATE} ${escapeHtml(pt.name)}
${desc}
▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
`; } 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 = `${getPictureSourceIcon(s.stream_type)} ${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.status === 409) { showToast(t('pattern.delete.referenced'), 'error'); return; } if (response.ok) { showToast(t('pattern.deleted'), 'success'); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } else { const error = await response.json(); showToast(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 = `
${t('pattern.rect.empty')}
`; return; } container.innerHTML = patternEditorRects.map((rect, i) => `
`).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'); } }