Add Pattern Templates for Key Colors targets with visual canvas editor
Introduce Pattern Template entity as a reusable rectangle layout that Key Colors targets reference via pattern_template_id. This replaces inline rectangle storage with a shared template system. Backend: - New PatternTemplate data model, store (JSON persistence), CRUD API - KC targets now reference pattern_template_id instead of inline rectangles - ProcessorManager resolves pattern template at KC processing start - Picture source test endpoint supports capture_duration=0 for single frame - Delete protection: 409 when template is referenced by a KC target Frontend: - Pattern Templates section in Key Colors sub-tab with card UI - Visual canvas editor with drag-to-move, 8-point resize handles - Background capture from any picture source for visual alignment - Precise coordinate list synced bidirectionally with canvas - Resizable editor container, viewport-constrained modal - KC target editor uses pattern template dropdown instead of inline rects - Localization (en/ru) for all new UI elements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3961,11 +3961,12 @@ async function loadTargetsTab() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, and sources in parallel
|
||||
const [devicesResp, targetsResp, sourcesResp] = await Promise.all([
|
||||
// Fetch devices, targets, sources, and pattern templates in parallel
|
||||
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
]);
|
||||
|
||||
if (devicesResp.status === 401 || targetsResp.status === 401) {
|
||||
@@ -3985,6 +3986,14 @@ async function loadTargetsTab() {
|
||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
||||
}
|
||||
|
||||
let patternTemplates = [];
|
||||
let patternTemplateMap = {};
|
||||
if (patResp && patResp.ok) {
|
||||
const patData = await patResp.json();
|
||||
patternTemplates = patData.templates || [];
|
||||
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
||||
}
|
||||
|
||||
// Fetch state for each device
|
||||
const devicesWithState = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
@@ -4033,7 +4042,7 @@ async function loadTargetsTab() {
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
|
||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||
];
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
||||
@@ -4069,12 +4078,21 @@ async function loadTargetsTab() {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
||||
<div class="devices-grid">
|
||||
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
|
||||
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showKCEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
|
||||
<div class="templates-grid">
|
||||
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
||||
@@ -4244,7 +4262,7 @@ async function deleteTarget(targetId) {
|
||||
|
||||
// ===== KEY COLORS TARGET CARD =====
|
||||
|
||||
function createKCTargetCard(target, sourceMap) {
|
||||
function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
@@ -4253,7 +4271,9 @@ function createKCTargetCard(target, sourceMap) {
|
||||
|
||||
const source = sourceMap[target.picture_source_id];
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
const rectCount = (kcSettings.rectangles || []).length;
|
||||
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id];
|
||||
const patternName = patTmpl ? patTmpl.name : 'No pattern';
|
||||
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
|
||||
|
||||
// Render initial color swatches from pre-fetched REST data
|
||||
let swatchesHtml = '';
|
||||
@@ -4281,7 +4301,8 @@ function createKCTargetCard(target, sourceMap) {
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
|
||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
||||
@@ -4328,73 +4349,33 @@ function createKCTargetCard(target, sourceMap) {
|
||||
|
||||
// ===== KEY COLORS EDITOR =====
|
||||
|
||||
let kcEditorRectangles = [];
|
||||
let kcEditorInitialValues = {};
|
||||
let _kcNameManuallyEdited = false;
|
||||
|
||||
function _autoGenerateKCName() {
|
||||
if (_kcNameManuallyEdited) return;
|
||||
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
|
||||
if (document.getElementById('kc-editor-id').value) return;
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!sourceName) return;
|
||||
const rectCount = kcEditorRectangles.length;
|
||||
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
||||
const modeName = t(`kc.interpolation.${mode}`);
|
||||
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
|
||||
}
|
||||
|
||||
function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
|
||||
kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
|
||||
renderKCRectangles();
|
||||
_autoGenerateKCName();
|
||||
}
|
||||
|
||||
function removeKCRectangle(index) {
|
||||
kcEditorRectangles.splice(index, 1);
|
||||
renderKCRectangles();
|
||||
_autoGenerateKCName();
|
||||
}
|
||||
|
||||
function renderKCRectangles() {
|
||||
const container = document.getElementById('kc-rect-list');
|
||||
if (!container) return;
|
||||
|
||||
if (kcEditorRectangles.length === 0) {
|
||||
container.innerHTML = `<div class="kc-rect-empty">${t('kc.rect.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = `<div class="kc-rect-labels">
|
||||
<span>${t('kc.rect.name')}</span>
|
||||
<span>${t('kc.rect.x')}</span>
|
||||
<span>${t('kc.rect.y')}</span>
|
||||
<span>${t('kc.rect.width')}</span>
|
||||
<span>${t('kc.rect.height')}</span>
|
||||
<span></span>
|
||||
</div>`;
|
||||
|
||||
const rows = kcEditorRectangles.map((rect, i) => `
|
||||
<div class="kc-rect-row">
|
||||
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('kc.rect.name')}" onchange="kcEditorRectangles[${i}].name = this.value">
|
||||
<input type="number" value="${rect.x}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].x = parseFloat(this.value) || 0">
|
||||
<input type="number" value="${rect.y}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].y = parseFloat(this.value) || 0">
|
||||
<input type="number" value="${rect.width}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].width = parseFloat(this.value) || 0.01">
|
||||
<input type="number" value="${rect.height}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].height = parseFloat(this.value) || 0.01">
|
||||
<button type="button" class="kc-rect-remove-btn" onclick="removeKCRectangle(${i})" title="${t('kc.rect.remove')}">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = labels + rows;
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
const patName = patSelect.selectedOptions[0]?.textContent?.trim() || '';
|
||||
document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${modeName})`;
|
||||
}
|
||||
|
||||
async function showKCEditor(targetId = null) {
|
||||
try {
|
||||
// Load sources for dropdown
|
||||
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
|
||||
// Load sources and pattern templates in parallel
|
||||
const [sourcesResp, patResp] = await Promise.all([
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
]);
|
||||
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
||||
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
|
||||
|
||||
// Populate source select (no empty option — source is required for KC targets)
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
@@ -4406,8 +4387,18 @@ async function showKCEditor(targetId = null) {
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate pattern template select
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
patSelect.innerHTML = `<option value="">${t('kc.pattern_template.none')}</option>`;
|
||||
patTemplates.forEach(pt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pt.id;
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
opt.textContent = `📄 ${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
|
||||
patSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (targetId) {
|
||||
// Editing existing target
|
||||
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
@@ -4421,11 +4412,9 @@ async function showKCEditor(targetId = null) {
|
||||
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
|
||||
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
||||
patSelect.value = kcSettings.pattern_template_id || '';
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.edit');
|
||||
|
||||
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = '';
|
||||
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
||||
@@ -4434,20 +4423,16 @@ async function showKCEditor(targetId = null) {
|
||||
document.getElementById('kc-editor-interpolation').value = 'average';
|
||||
document.getElementById('kc-editor-smoothing').value = 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
|
||||
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.add');
|
||||
|
||||
kcEditorRectangles = [];
|
||||
}
|
||||
|
||||
renderKCRectangles();
|
||||
|
||||
// Auto-name: reset flag and wire listeners
|
||||
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
|
||||
// Auto-name
|
||||
_kcNameManuallyEdited = !!targetId;
|
||||
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
|
||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
||||
|
||||
// Trigger auto-name after dropdowns are populated (create mode only)
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId) _autoGenerateKCName();
|
||||
|
||||
kcEditorInitialValues = {
|
||||
@@ -4456,7 +4441,7 @@ async function showKCEditor(targetId = null) {
|
||||
fps: document.getElementById('kc-editor-fps').value,
|
||||
interpolation: document.getElementById('kc-editor-interpolation').value,
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
rectangles: JSON.stringify(kcEditorRectangles),
|
||||
patternTemplateId: patSelect.value,
|
||||
};
|
||||
|
||||
const modal = document.getElementById('kc-editor-modal');
|
||||
@@ -4479,7 +4464,7 @@ function isKCEditorDirty() {
|
||||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
|
||||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
|
||||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
|
||||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
|
||||
document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4496,7 +4481,6 @@ function forceCloseKCEditorModal() {
|
||||
document.getElementById('kc-editor-error').style.display = 'none';
|
||||
unlockBody();
|
||||
kcEditorInitialValues = {};
|
||||
kcEditorRectangles = [];
|
||||
}
|
||||
|
||||
async function saveKCEditor() {
|
||||
@@ -4506,6 +4490,7 @@ async function saveKCEditor() {
|
||||
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
|
||||
const interpolation = document.getElementById('kc-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
|
||||
const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
|
||||
const errorEl = document.getElementById('kc-editor-error');
|
||||
|
||||
if (!name) {
|
||||
@@ -4514,8 +4499,8 @@ async function saveKCEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (kcEditorRectangles.length === 0) {
|
||||
errorEl.textContent = t('kc.error.no_rectangles');
|
||||
if (!patternTemplateId) {
|
||||
errorEl.textContent = t('kc.error.no_pattern');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
@@ -4527,13 +4512,7 @@ async function saveKCEditor() {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing,
|
||||
rectangles: kcEditorRectangles.map(r => ({
|
||||
name: r.name,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
})),
|
||||
pattern_template_id: patternTemplateId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4663,3 +4642,545 @@ function updateKCColorSwatches(targetId, colors) {
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== PATTERN TEMPLATES =====
|
||||
|
||||
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">📄 ${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="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----- Pattern Template Editor state -----
|
||||
let patternEditorRects = [];
|
||||
let patternEditorSelectedIdx = -1;
|
||||
let patternEditorBgImage = null;
|
||||
let patternEditorInitialValues = {};
|
||||
let patternCanvasDragMode = null;
|
||||
let patternCanvasDragStart = null;
|
||||
let patternCanvasDragOrigRect = null;
|
||||
|
||||
const PATTERN_RECT_COLORS = [
|
||||
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
|
||||
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
|
||||
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
|
||||
];
|
||||
const PATTERN_RECT_BORDERS = [
|
||||
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||
];
|
||||
|
||||
async function showPatternTemplateEditor(templateId = 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;
|
||||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
bgSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
patternEditorBgImage = null;
|
||||
patternEditorSelectedIdx = -1;
|
||||
patternCanvasDragMode = 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-title').textContent = t('pattern.edit');
|
||||
patternEditorRects = (tmpl.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-title').textContent = t('pattern.add');
|
||||
patternEditorRects = [];
|
||||
}
|
||||
|
||||
patternEditorInitialValues = {
|
||||
name: document.getElementById('pattern-template-name').value,
|
||||
description: document.getElementById('pattern-template-description').value,
|
||||
rectangles: JSON.stringify(patternEditorRects),
|
||||
};
|
||||
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
_attachPatternCanvasEvents();
|
||||
|
||||
const modal = document.getElementById('pattern-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setupBackdropClose(modal, closePatternTemplateModal);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function isPatternEditorDirty() {
|
||||
return (
|
||||
document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name ||
|
||||
document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description ||
|
||||
JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles
|
||||
);
|
||||
}
|
||||
|
||||
async function closePatternTemplateModal() {
|
||||
if (isPatternEditorDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceClosePatternTemplateModal();
|
||||
}
|
||||
|
||||
function forceClosePatternTemplateModal() {
|
||||
document.getElementById('pattern-template-modal').style.display = 'none';
|
||||
document.getElementById('pattern-template-error').style.display = 'none';
|
||||
unlockBody();
|
||||
patternEditorRects = [];
|
||||
patternEditorSelectedIdx = -1;
|
||||
patternEditorBgImage = null;
|
||||
patternEditorInitialValues = {};
|
||||
}
|
||||
|
||||
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();
|
||||
const errorEl = document.getElementById('pattern-template-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('pattern.error.required');
|
||||
errorEl.style.display = 'block';
|
||||
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 fetch(`${API_BASE}/pattern-templates/${templateId}`, {
|
||||
method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${API_BASE}/pattern-templates`, {
|
||||
method: 'POST', headers: getHeaders(), body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
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');
|
||||
forceClosePatternTemplateModal();
|
||||
await loadTargets();
|
||||
} catch (error) {
|
||||
console.error('Error saving pattern template:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePatternTemplate(templateId) {
|
||||
const confirmed = await showConfirm(t('pattern.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
|
||||
method: 'DELETE', headers: getHeaders(),
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.status === 409) {
|
||||
showToast(t('pattern.delete.referenced'), 'error');
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
loadTargets();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to delete pattern template', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Pattern rect list (precise coordinate inputs) -----
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
function selectPatternRect(index) {
|
||||
patternEditorSelectedIdx = (patternEditorSelectedIdx === index) ? -1 : index;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function addPatternRect() {
|
||||
const name = `Zone ${patternEditorRects.length + 1}`;
|
||||
// Place new rect centered, 30% of canvas
|
||||
patternEditorRects.push({ name, x: 0.35, y: 0.35, width: 0.3, height: 0.3 });
|
||||
patternEditorSelectedIdx = patternEditorRects.length - 1;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function deleteSelectedPatternRect() {
|
||||
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
|
||||
patternEditorRects.splice(patternEditorSelectedIdx, 1);
|
||||
patternEditorSelectedIdx = -1;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function removePatternRect(index) {
|
||||
patternEditorRects.splice(index, 1);
|
||||
if (patternEditorSelectedIdx === index) patternEditorSelectedIdx = -1;
|
||||
else if (patternEditorSelectedIdx > index) patternEditorSelectedIdx--;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
// ----- Pattern Canvas Visual Editor -----
|
||||
|
||||
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 — spacing adapts to canvas size
|
||||
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; // ~80 CSS pixels between grid lines
|
||||
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
|
||||
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;
|
||||
|
||||
// Fill
|
||||
ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx];
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx];
|
||||
ctx.lineWidth = (i === patternEditorSelectedIdx) ? 3 : 1.5;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
|
||||
// Name label
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.7)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillText(rect.name, rx + 4, ry + 14);
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
|
||||
// Draw resize handles on selected rect
|
||||
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
|
||||
const rect = patternEditorRects[patternEditorSelectedIdx];
|
||||
const rx = rect.x * w;
|
||||
const ry = rect.y * h;
|
||||
const rw = rect.width * w;
|
||||
const rh = rect.height * h;
|
||||
const hs = 6; // handle size
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
const handles = [
|
||||
[rx, ry], [rx + rw / 2, ry], [rx + rw, ry],
|
||||
[rx, ry + rh / 2], [rx + rw, ry + rh / 2],
|
||||
[rx, ry + rh], [rx + rw / 2, ry + rh], [rx + rw, ry + rh],
|
||||
];
|
||||
handles.forEach(([hx, hy]) => {
|
||||
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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('mouseup', _patternCanvasMouseUp);
|
||||
canvas.addEventListener('mouseleave', _patternCanvasMouseUp);
|
||||
|
||||
// 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];
|
||||
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
|
||||
}, { passive: false });
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
_patternCanvasMouseUp(e);
|
||||
});
|
||||
|
||||
// Resize observer — update canvas internal resolution when container is resized
|
||||
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 resize handles on selected rect first
|
||||
if (patternEditorSelectedIdx >= 0) {
|
||||
const sr = patternEditorRects[patternEditorSelectedIdx];
|
||||
const rx = sr.x * w, ry = sr.y * h, rw = sr.width * w, rh = sr.height * h;
|
||||
const hs = 8;
|
||||
|
||||
const handlePositions = [
|
||||
{ name: 'nw', hx: rx, hy: ry },
|
||||
{ name: 'n', hx: rx + rw / 2, hy: ry },
|
||||
{ name: 'ne', hx: rx + rw, hy: ry },
|
||||
{ name: 'w', hx: rx, hy: ry + rh / 2 },
|
||||
{ name: 'e', hx: rx + rw, hy: ry + rh / 2 },
|
||||
{ name: 'sw', hx: rx, hy: ry + rh },
|
||||
{ name: 's', hx: rx + rw / 2, hy: ry + rh },
|
||||
{ name: 'se', hx: rx + rw, hy: ry + rh },
|
||||
];
|
||||
|
||||
for (const hp of handlePositions) {
|
||||
if (Math.abs(mx - hp.hx) <= hs && Math.abs(my - hp.hy) <= hs) {
|
||||
patternCanvasDragMode = `resize-${hp.name}`;
|
||||
patternCanvasDragStart = { mx, my };
|
||||
patternCanvasDragOrigRect = { ...sr };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hit-test rect bodies (reverse order for top-most first)
|
||||
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
||||
const r = patternEditorRects[i];
|
||||
const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
|
||||
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) {
|
||||
patternEditorSelectedIdx = i;
|
||||
patternCanvasDragMode = 'move';
|
||||
patternCanvasDragStart = { mx, my };
|
||||
patternCanvasDragOrigRect = { ...r };
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Click on empty space — deselect
|
||||
patternEditorSelectedIdx = -1;
|
||||
patternCanvasDragMode = null;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _patternCanvasMouseMove(e) {
|
||||
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) 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;
|
||||
|
||||
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; }
|
||||
|
||||
// Enforce minimums
|
||||
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; }
|
||||
|
||||
// Clamp to canvas
|
||||
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 _patternCanvasMouseUp() {
|
||||
if (patternCanvasDragMode) {
|
||||
patternCanvasDragMode = null;
|
||||
patternCanvasDragStart = null;
|
||||
patternCanvasDragOrigRect = null;
|
||||
renderPatternRectList(); // sync inputs after drag
|
||||
}
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
patternEditorBgImage = img;
|
||||
renderPatternCanvas();
|
||||
};
|
||||
img.src = data.full_capture.full_image;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to capture background:', error);
|
||||
showToast('Failed to capture background', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user