- Create core/icons.js with type-resolution getters and icon constants - Replace inline emoji literals across 11 feature files with imports - Remove duplicate icon maps (getEngineIcon, _vsTypeIcons, typeIcons, etc.) - Fix dashboard auto-start row missing metrics placeholder div Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
831 lines
32 KiB
JavaScript
831 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';
|
|
|
|
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 `
|
|
<div class="template-card" data-pattern-template-id="${pt.id}">
|
|
<button class="card-remove-btn" onclick="deletePatternTemplate('${pt.id}')" title="${t('common.delete')}">✕</button>
|
|
<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>
|
|
<div class="template-card-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>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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').textContent = 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').textContent = 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').textContent = 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('Failed to open pattern template editor', '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 fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
|
if (!resp.ok) throw new Error('Failed to load pattern template');
|
|
const tmpl = await resp.json();
|
|
showPatternTemplateEditor(null, tmpl);
|
|
} catch (error) {
|
|
console.error('Failed to clone pattern template:', error);
|
|
showToast('Failed to clone pattern template', '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(`Failed to delete: ${error.detail}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast('Failed to delete pattern template', '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')}">✕</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('Failed to capture background', 'error');
|
|
}
|
|
}
|