Polish Pattern Template UI: dialog sizing, KC editor layout, and conventions
- Fix dialog/canvas sizing: fit-content dialog follows canvas width, canvas max-width: 100% prevents overflow, horizontal resize supported - Move pattern template dropdown above FPS/mode/smoothing in KC editor - Remove emoji from pattern template dropdown options and auto-generated names - Remove placeholder option from pattern template select, default to first - Rename default pattern template from "Default" to "Full Screen" - Add UI conventions to CLAUDE.md (hint pattern, select dropdown rules) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -89,6 +89,29 @@ This is a monorepo containing:
|
||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
||||
- `server/CLAUDE.md`
|
||||
|
||||
## UI Conventions for Dialogs
|
||||
|
||||
### Hints
|
||||
|
||||
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="my-field" data-i18n="my.label">Label:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
|
||||
<input type="text" id="my-field">
|
||||
</div>
|
||||
```
|
||||
|
||||
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
|
||||
|
||||
### Select dropdowns
|
||||
|
||||
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Always test changes before marking as complete
|
||||
|
||||
@@ -4361,7 +4361,7 @@ function _autoGenerateKCName() {
|
||||
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
||||
const modeName = t(`kc.interpolation.${mode}`);
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
const patName = patSelect.selectedOptions[0]?.textContent?.trim() || '';
|
||||
const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${modeName})`;
|
||||
}
|
||||
|
||||
@@ -4389,12 +4389,13 @@ async function showKCEditor(targetId = null) {
|
||||
|
||||
// Populate pattern template select
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
patSelect.innerHTML = `<option value="">${t('kc.pattern_template.none')}</option>`;
|
||||
patSelect.innerHTML = '';
|
||||
patTemplates.forEach(pt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pt.id;
|
||||
opt.dataset.name = pt.name;
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
opt.textContent = `📄 ${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
|
||||
opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
|
||||
patSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
@@ -4673,6 +4674,8 @@ let patternEditorInitialValues = {};
|
||||
let patternCanvasDragMode = null;
|
||||
let patternCanvasDragStart = null;
|
||||
let patternCanvasDragOrigRect = null;
|
||||
let patternEditorHoveredIdx = -1;
|
||||
let patternEditorHoverHit = null; // 'move', 'n', 's', 'e', 'w', 'nw', etc.
|
||||
|
||||
const PATTERN_RECT_COLORS = [
|
||||
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
|
||||
@@ -4888,8 +4891,16 @@ function updatePatternRect(index, field, value) {
|
||||
|
||||
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 });
|
||||
// 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 });
|
||||
patternEditorSelectedIdx = patternEditorRects.length - 1;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
@@ -4947,54 +4958,293 @@ function renderPatternCanvas() {
|
||||
}
|
||||
|
||||
// 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
|
||||
// Fill — brighter when hovered or being dragged
|
||||
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 = (i === patternEditorSelectedIdx) ? 3 : 1.5;
|
||||
ctx.lineWidth = isSelected ? 3 : isHovered ? 2.5 : 1.5;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
|
||||
// Determine edge direction to highlight (during drag or hover)
|
||||
let edgeDir = null;
|
||||
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
|
||||
edgeDir = patternCanvasDragMode.replace('resize-', '');
|
||||
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
|
||||
edgeDir = patternEditorHoverHit;
|
||||
}
|
||||
|
||||
// Draw highlighted edge/corner indicator
|
||||
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 = '12px sans-serif';
|
||||
ctx.font = `${12 * dpr}px sans-serif`;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.7)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillText(rect.name, rx + 4, ry + 14);
|
||||
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 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],
|
||||
// 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 }, // top-left
|
||||
{ cx: w - abMargin, cy: abMargin }, // top-right
|
||||
{ cx: w / 2, cy: h / 2 }, // center
|
||||
{ cx: abMargin, cy: h - abMargin }, // bottom-left
|
||||
{ cx: w - abMargin, cy: h - abMargin }, // bottom-right
|
||||
];
|
||||
handles.forEach(([hx, hy]) => {
|
||||
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||
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 }, // top-left: rect starts at (0,0)
|
||||
{ ax: 1, ay: 0 }, // top-right: rect ends at (1,0)
|
||||
{ ax: 0.5, ay: 0.5 }, // center
|
||||
{ ax: 0, ay: 1 }, // bottom-left: rect starts at (0, 1-h)
|
||||
{ ax: 1, ay: 1 }, // bottom-right: rect ends at (1,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;
|
||||
}
|
||||
// Position based on anchor point
|
||||
let rx = anchor.ax - rw * anchor.ax; // 0→0, 0.5→centered, 1→1-rw
|
||||
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 });
|
||||
patternEditorSelectedIdx = patternEditorRects.length - 1;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
// Hit-test a point against a rect's edges/corners. Returns a resize direction
|
||||
// string ('n','s','e','w','nw','ne','sw','se') or 'move' if inside, or null.
|
||||
const _EDGE_THRESHOLD = 8; // pixels (in canvas coords)
|
||||
|
||||
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;
|
||||
|
||||
// Corners first (both edges near)
|
||||
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';
|
||||
|
||||
// Edges
|
||||
if (nearTop && inHRange) return 'n';
|
||||
if (nearBottom && inHRange) return 's';
|
||||
if (nearLeft && inVRange) return 'w';
|
||||
if (nearRight && inVRange) return 'e';
|
||||
|
||||
// Interior (move)
|
||||
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);
|
||||
patternCanvasDragMode = null;
|
||||
patternCanvasDragStart = null;
|
||||
patternCanvasDragOrigRect = 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;
|
||||
patternEditorHoveredIdx = newHoverIdx;
|
||||
patternEditorHoverHit = newHoverHit;
|
||||
}
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _attachPatternCanvasEvents() {
|
||||
@@ -5004,8 +5254,7 @@ function _attachPatternCanvasEvents() {
|
||||
|
||||
canvas.addEventListener('mousedown', _patternCanvasMouseDown);
|
||||
canvas.addEventListener('mousemove', _patternCanvasMouseMove);
|
||||
canvas.addEventListener('mouseup', _patternCanvasMouseUp);
|
||||
canvas.addEventListener('mouseleave', _patternCanvasMouseUp);
|
||||
canvas.addEventListener('mouseleave', _patternCanvasMouseLeave);
|
||||
|
||||
// Touch support
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
@@ -5016,10 +5265,24 @@ function _attachPatternCanvasEvents() {
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
|
||||
if (patternCanvasDragMode) {
|
||||
_patternCanvasDragMove({ clientX: touch.clientX, clientY: touch.clientY });
|
||||
} else {
|
||||
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
|
||||
}
|
||||
}, { passive: false });
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
_patternCanvasMouseUp(e);
|
||||
canvas.addEventListener('touchend', () => {
|
||||
if (patternCanvasDragMode) {
|
||||
window.removeEventListener('mousemove', _patternCanvasDragMove);
|
||||
window.removeEventListener('mouseup', _patternCanvasDragEnd);
|
||||
patternCanvasDragMode = null;
|
||||
patternCanvasDragStart = null;
|
||||
patternCanvasDragOrigRect = null;
|
||||
patternEditorHoveredIdx = -1;
|
||||
patternEditorHoverHit = null;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
// Resize observer — update canvas internal resolution when container is resized
|
||||
@@ -5052,57 +5315,68 @@ function _patternCanvasMouseDown(e) {
|
||||
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 };
|
||||
// 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) patternEditorSelectedIdx = -1;
|
||||
else if (patternEditorSelectedIdx > idx) patternEditorSelectedIdx--;
|
||||
patternEditorHoveredIdx = -1;
|
||||
patternEditorHoverHit = null;
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hit-test rect bodies (reverse order for top-most first)
|
||||
// Test all rects in reverse order (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;
|
||||
const hit = _hitTestRect(mx, my, r, w, h);
|
||||
if (!hit) continue;
|
||||
|
||||
patternEditorSelectedIdx = i;
|
||||
patternCanvasDragStart = { mx, my };
|
||||
patternCanvasDragOrigRect = { ...r };
|
||||
|
||||
if (hit === 'move') {
|
||||
patternCanvasDragMode = 'move';
|
||||
patternCanvasDragStart = { mx, my };
|
||||
patternCanvasDragOrigRect = { ...r };
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
return;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
} else {
|
||||
patternCanvasDragMode = `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
|
||||
patternEditorSelectedIdx = -1;
|
||||
patternCanvasDragMode = null;
|
||||
canvas.style.cursor = 'default';
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _patternCanvasMouseMove(e) {
|
||||
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
|
||||
// During drag, movement is handled by window-level _patternCanvasDragMove
|
||||
if (patternCanvasDragMode) return;
|
||||
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
const w = canvas.width;
|
||||
@@ -5113,45 +5387,33 @@ function _patternCanvasMouseMove(e) {
|
||||
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;
|
||||
let cursor = 'default';
|
||||
let newHoverIdx = -1;
|
||||
let newHoverHit = null;
|
||||
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;
|
||||
if (newHoverIdx !== patternEditorHoveredIdx || newHoverHit !== patternEditorHoverHit) {
|
||||
patternEditorHoveredIdx = newHoverIdx;
|
||||
patternEditorHoverHit = newHoverHit;
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _patternCanvasMouseUp() {
|
||||
if (patternCanvasDragMode) {
|
||||
patternCanvasDragMode = null;
|
||||
patternCanvasDragStart = null;
|
||||
patternCanvasDragOrigRect = null;
|
||||
renderPatternRectList(); // sync inputs after drag
|
||||
function _patternCanvasMouseLeave() {
|
||||
// During drag, window-level listeners handle everything
|
||||
if (patternCanvasDragMode) return;
|
||||
if (patternEditorHoveredIdx !== -1) {
|
||||
patternEditorHoveredIdx = -1;
|
||||
patternEditorHoverHit = null;
|
||||
renderPatternCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,15 @@
|
||||
<select id="kc-editor-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="kc-editor-pattern-template" data-i18n="kc.pattern_template">Pattern Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="kc.pattern_template.hint">Select the rectangle pattern to use for color extraction</small>
|
||||
<select id="kc-editor-pattern-template"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="kc-editor-fps" data-i18n="kc.fps">Extraction FPS:</label>
|
||||
@@ -376,15 +385,6 @@
|
||||
<input type="range" id="kc-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('kc-editor-smoothing-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="kc-editor-pattern-template" data-i18n="kc.pattern_template">Pattern Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="kc.pattern_template.hint">Select the rectangle pattern to use for color extraction</small>
|
||||
<select id="kc-editor-pattern-template"></select>
|
||||
</div>
|
||||
|
||||
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -407,18 +407,30 @@
|
||||
<input type="hidden" id="pattern-template-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
|
||||
<div class="label-row">
|
||||
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="pattern.name.hint">A descriptive name for this rectangle layout</small>
|
||||
<input type="text" id="pattern-template-name" data-i18n-placeholder="pattern.name.placeholder" placeholder="My Pattern Template" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
|
||||
<div class="label-row">
|
||||
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="pattern.description.hint">Optional notes about where or how this pattern is used</small>
|
||||
<input type="text" id="pattern-template-description" data-i18n-placeholder="pattern.description_placeholder" placeholder="Describe this pattern...">
|
||||
</div>
|
||||
|
||||
<!-- Visual Editor -->
|
||||
<div class="form-group">
|
||||
<label data-i18n="pattern.visual_editor">Visual Editor</label>
|
||||
<div class="label-row">
|
||||
<label data-i18n="pattern.visual_editor">Visual Editor</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="pattern.visual_editor.hint">Click + buttons to add rectangles. Drag edges to resize, drag inside to move.</small>
|
||||
<div class="pattern-bg-row">
|
||||
<select id="pattern-bg-source"></select>
|
||||
<button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg">📷</button>
|
||||
@@ -426,15 +438,10 @@
|
||||
<div class="pattern-canvas-container">
|
||||
<canvas id="pattern-canvas"></canvas>
|
||||
</div>
|
||||
<div class="pattern-canvas-toolbar">
|
||||
<button type="button" class="btn btn-secondary" onclick="addPatternRect()">+ <span data-i18n="pattern.rect.add">Add Rectangle</span></button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteSelectedPatternRect()" data-i18n="pattern.delete_selected">Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Precise coordinate list -->
|
||||
<div class="form-group">
|
||||
<label data-i18n="pattern.rectangles">Rectangles</label>
|
||||
<div id="pattern-rect-labels" class="pattern-rect-labels">
|
||||
<span data-i18n="pattern.rect.name">Name</span>
|
||||
<span data-i18n="pattern.rect.x">X</span>
|
||||
|
||||
@@ -396,5 +396,9 @@
|
||||
"pattern.capture_bg": "Capture Background",
|
||||
"pattern.source_for_bg": "Source for Background:",
|
||||
"pattern.source_for_bg.none": "-- Select source --",
|
||||
"pattern.delete_selected": "Delete Selected"
|
||||
"pattern.delete_selected": "Delete Selected",
|
||||
"pattern.name.hint": "A descriptive name for this rectangle layout",
|
||||
"pattern.description.hint": "Optional notes about where or how this pattern is used",
|
||||
"pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.",
|
||||
"pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)"
|
||||
}
|
||||
|
||||
@@ -396,5 +396,9 @@
|
||||
"pattern.capture_bg": "Захватить Фон",
|
||||
"pattern.source_for_bg": "Источник для Фона:",
|
||||
"pattern.source_for_bg.none": "-- Выберите источник --",
|
||||
"pattern.delete_selected": "Удалить Выбранный"
|
||||
"pattern.delete_selected": "Удалить Выбранный",
|
||||
"pattern.name.hint": "Описательное имя для этой раскладки прямоугольников",
|
||||
"pattern.description.hint": "Необязательные заметки о назначении этого паттерна",
|
||||
"pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.",
|
||||
"pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)"
|
||||
}
|
||||
|
||||
@@ -2708,8 +2708,9 @@ input:-webkit-autofill:focus {
|
||||
|
||||
/* Pattern Template Visual Editor */
|
||||
.modal-content-wide {
|
||||
max-width: 900px !important;
|
||||
width: 95% !important;
|
||||
width: fit-content;
|
||||
min-width: 500px;
|
||||
max-width: calc(100vw - 40px);
|
||||
max-height: calc(100vh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2717,6 +2718,7 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.modal-content-wide .modal-body {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -2728,7 +2730,10 @@ input:-webkit-autofill:focus {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 12px;
|
||||
resize: vertical;
|
||||
resize: both;
|
||||
width: 820px;
|
||||
min-width: 400px;
|
||||
max-width: 100%;
|
||||
min-height: 200px;
|
||||
height: 450px;
|
||||
max-height: calc(100vh - 400px);
|
||||
@@ -2738,21 +2743,30 @@ input:-webkit-autofill:focus {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pattern-canvas-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 0;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pattern-canvas-toolbar .btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pattern-bg-row {
|
||||
|
||||
@@ -41,7 +41,7 @@ class PatternTemplateStore:
|
||||
|
||||
template = PatternTemplate(
|
||||
id=template_id,
|
||||
name="Default",
|
||||
name="Full Screen",
|
||||
rectangles=[
|
||||
KeyColorRectangle(name="Full Frame", x=0.0, y=0.0, width=1.0, height=1.0),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user