New hierarchical component, used for InstancesDebugger.py

This commit is contained in:
2026-02-21 23:53:05 +01:00
parent 9a25591edf
commit 44691be30f
9 changed files with 1789 additions and 32 deletions

View File

@@ -0,0 +1,779 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>InstancesDebugger — Canvas Prototype v2</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #7d8590;
--accent: #388bfd;
--selected: #f0883e;
--match: #e3b341;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Toolbar ─────────────────────────────────────── */
#toolbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 14px;
height: 44px;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
user-select: none;
}
.app-icon { font-size: 16px; color: var(--accent); margin-right: 2px; }
#title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-right: 2px;
flex-shrink: 0;
}
.tb-sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
flex-shrink: 0;
}
.tb-btn {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: color .12s, background .12s, border-color .12s;
white-space: nowrap;
}
.tb-btn:hover { color: var(--text); background: var(--surface2); border-color: var(--border); }
.tb-btn svg { flex-shrink: 0; }
#search-wrapper {
position: relative;
flex: 1;
max-width: 240px;
}
.search-icon {
position: absolute;
left: 9px; top: 50%;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
}
#search {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px 5px 30px;
color: var(--text);
font-size: 12px;
outline: none;
transition: border-color .15s;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--muted); }
#match-count {
position: absolute;
right: 9px; top: 50%;
transform: translateY(-50%);
font-size: 10px;
color: var(--muted);
}
#zoom-display {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: right;
}
#hint {
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
/* ── Main ────────────────────────────────────────── */
#main { display: flex; flex: 1; overflow: hidden; }
#canvas-container {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
}
#canvas-container.panning { cursor: grabbing; }
#canvas-container.hovering { cursor: pointer; }
#graph-canvas { position: absolute; top: 0; left: 0; display: block; }
/* ── Legend ──────────────────────────────────────── */
#legend {
position: absolute;
bottom: 14px; left: 14px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 13px;
font-size: 11px;
color: var(--muted);
pointer-events: none;
backdrop-filter: blur(4px);
}
.leg { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.leg:last-child { margin-bottom: 0; }
.leg-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
/* ── Properties panel ────────────────────────────── */
#props-panel {
width: 270px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
transition: width .2s;
}
#props-panel.empty { align-items: center; justify-content: center; }
#props-empty { color: var(--muted); font-size: 13px; }
#props-scroll { overflow-y: auto; flex: 1; }
.ph {
padding: 11px 14px 8px;
background: var(--bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ph-label { font-size: 13px; font-weight: 600; font-family: monospace; }
.ph-kind {
display: inline-block;
margin-top: 5px;
font-size: 10px; font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
color: #fff;
}
.ps { /* props section */
padding: 4px 14px;
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .07em;
color: var(--accent);
background: rgba(56, 139, 253, 0.07);
border-bottom: 1px solid var(--border);
}
.pr { /* props row */
display: flex;
padding: 4px 14px;
border-bottom: 1px solid rgba(48, 54, 61, 0.6);
font-size: 12px; font-family: monospace;
}
.pk { color: var(--muted); flex: 0 0 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 6px; }
.pv { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
</head>
<body>
<div id="toolbar">
<span class="app-icon"></span>
<span id="title">InstancesDebugger</span>
<div class="tb-sep"></div>
<!-- Expand all -->
<button class="tb-btn" id="expand-all-btn" title="Expand all nodes">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 4l5 5 5-5"/>
<path d="M2 8l5 5 5-5"/>
</svg>
Expand
</button>
<!-- Collapse all -->
<button class="tb-btn" id="collapse-all-btn" title="Collapse all nodes">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6l5-5 5 5"/>
<path d="M2 10l5-5 5 5"/>
</svg>
Collapse
</button>
<div class="tb-sep"></div>
<div id="search-wrapper">
<svg class="search-icon" width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="7" cy="7" r="5"/><path d="m11 11 3 3"/>
</svg>
<input id="search" type="text" placeholder="Filter instances…" autocomplete="off" spellcheck="false">
<span id="match-count"></span>
</div>
<div class="tb-sep"></div>
<button class="tb-btn" id="fit-btn" title="Fit all nodes">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M1 5V1h4M15 5V1h-4M1 11v4h4M15 11v4h-4"/>
<rect x="4" y="4" width="8" height="8" rx="1"/>
</svg>
Fit
</button>
<span id="zoom-display">100%</span>
<span id="hint">Wheel · Drag · Click</span>
</div>
<div id="main">
<div id="canvas-container">
<canvas id="graph-canvas"></canvas>
<div id="legend">
<div class="leg"><div class="leg-dot" style="background:#2563eb"></div>RootInstance</div>
<div class="leg"><div class="leg-dot" style="background:#7c3aed"></div>SingleInstance</div>
<div class="leg"><div class="leg-dot" style="background:#047857"></div>MultipleInstance</div>
<div class="leg"><div class="leg-dot" style="background:#b45309"></div>UniqueInstance</div>
</div>
</div>
<div id="props-panel" class="empty">
<span id="props-empty">Click a node to inspect</span>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════
const NODES = [
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance' },
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout' },
{ id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
{ id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' },
{ id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger' },
{ id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' },
{ id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager' },
{ id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid' },
{ id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' },
];
const EDGES = [
{ from: 'app', to: 'layout' },
{ from: 'app', to: 'instances_debugger' },
{ from: 'app', to: 'data_grid_manager' },
{ from: 'app', to: 'auth_proxy' },
{ from: 'layout', to: 'left_panel' },
{ from: 'layout', to: 'right_panel' },
{ from: 'instances_debugger', to: 'dbg_panel' },
{ from: 'dbg_panel', to: 'canvas_graph' },
{ from: 'data_grid_manager', to: 'my_grid' },
{ from: 'my_grid', to: 'grid_toolbar' },
{ from: 'my_grid', to: 'grid_search' },
];
const DETAILS = {
app: { Main: { Id: 'app', 'Parent Id': '—' }, State: { _name: 'AppState' } },
layout: { Main: { Id: 'layout', 'Parent Id': 'app' }, State: { _name: 'LayoutState', left_open: 'true', right_open: 'true' } },
left_panel: { Main: { Id: 'left_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '240px' } },
right_panel: { Main: { Id: 'right_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '320px' } },
instances_debugger: { Main: { Id: 'instances_debugger', 'Parent Id': 'app' }, State: { _name: 'InstancesDebuggerState' }, Commands: { ShowInstance: 'on_show_node'} },
dbg_panel: { Main: { Id: 'dbg#panel', 'Parent Id': 'instances_debugger' }, State: { _name: 'PanelState', width: '280px' } },
canvas_graph: { Main: { Id: 'dbg#canvas_graph', 'Parent Id': 'dbg#panel' }, State: { _name: 'CanvasGraphState', nodes: '12', edges: '11' } },
data_grid_manager: { Main: { Id: 'data_grid_manager', 'Parent Id': 'app' }, State: { _name: 'DataGridsManagerState', grids: '1' } },
my_grid: { Main: { Id: 'my_grid', 'Parent Id': 'data_grid_manager' }, State: { _name: 'DataGridState', rows: '42', cols: '5', page: '0' }, Commands: { DeleteRow: 'on_delete', AddRow: 'on_add' } },
grid_toolbar: { Main: { Id: 'my_grid#toolbar', 'Parent Id': 'my_grid' }, State: { _name: 'ToolbarState' } },
grid_search: { Main: { Id: 'my_grid#search', 'Parent Id': 'my_grid' }, State: { _name: 'SearchState', query: '' } },
auth_proxy: { Main: { Id: 'auth_proxy', 'Parent Id': 'app' }, State: { _name: 'AuthProxyState', user: 'admin@example.com', roles: 'admin' } },
};
// ═══════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════
const NODE_W = 178;
const NODE_H = 36;
const LEVEL_H = 84; // vertical distance between levels
const LEAF_GAP = 22; // horizontal gap between leaf slots
const CHEV_ZONE = 26; // rightmost px = toggle hit zone
const TYPE_COLOR = {
root: '#2563eb',
single: '#7c3aed',
multiple: '#047857',
unique: '#b45309',
};
// ═══════════════════════════════════════════════════════════
// Graph structure
// ═══════════════════════════════════════════════════════════
const childMap = {};
const hasParentSet = new Set();
for (const n of NODES) childMap[n.id] = [];
for (const e of EDGES) {
(childMap[e.from] = childMap[e.from] || []).push(e.to);
hasParentSet.add(e.to);
}
function hasChildren(id) { return (childMap[id] || []).length > 0; }
// ═══════════════════════════════════════════════════════════
// Collapse state
// ═══════════════════════════════════════════════════════════
const collapsed = new Set();
function getHiddenSet() {
const hidden = new Set();
function addDesc(id) {
for (const c of childMap[id] || []) {
if (!hidden.has(c)) { hidden.add(c); addDesc(c); }
}
}
for (const id of collapsed) addDesc(id);
return hidden;
}
function visNodes() {
const h = getHiddenSet();
return NODES.filter(n => !h.has(n.id));
}
function visEdges(vn) {
const vi = new Set(vn.map(n => n.id));
return EDGES.filter(e => vi.has(e.from) && vi.has(e.to));
}
// ═══════════════════════════════════════════════════════════
// Layout engine (Reingold-Tilford simplified)
// ═══════════════════════════════════════════════════════════
function computeLayout(nodes, edges) {
const cm = {};
const hp = new Set();
for (const n of nodes) cm[n.id] = [];
for (const e of edges) { (cm[e.from] = cm[e.from] || []).push(e.to); hp.add(e.to); }
const roots = nodes.filter(n => !hp.has(n.id)).map(n => n.id);
const depth = {};
const q = roots.map(r => [r, 0]);
while (q.length) {
const [id, d] = q.shift();
if (depth[id] !== undefined) continue;
depth[id] = d;
for (const c of cm[id] || []) q.push([c, d + 1]);
}
const pos = {};
for (const n of nodes) pos[n.id] = { x: 0, y: (depth[n.id] || 0) * LEVEL_H };
let slot = 0;
function dfs(id) {
const children = cm[id] || [];
if (children.length === 0) { pos[id].x = slot++ * (NODE_W + LEAF_GAP); return; }
for (const c of children) dfs(c);
const xs = children.map(c => pos[c].x);
pos[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
}
for (const r of roots) dfs(r);
return pos;
}
let pos = {};
function recomputeLayout() {
const vn = visNodes();
pos = computeLayout(vn, visEdges(vn));
}
recomputeLayout();
// ═══════════════════════════════════════════════════════════
// Canvas
// ═══════════════════════════════════════════════════════════
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('canvas-container');
const zoomEl = document.getElementById('zoom-display');
let transform = { x: 0, y: 0, scale: 1 };
let selectedId = null;
let filterQuery = '';
// ── Dot grid background (Figma-style, fixed screen-space dots) ──
function drawDotGrid() {
const spacing = 24;
const ox = ((transform.x % spacing) + spacing) % spacing;
const oy = ((transform.y % spacing) + spacing) % spacing;
ctx.fillStyle = 'rgba(125,133,144,0.12)';
for (let x = ox - spacing; x < canvas.width + spacing; x += spacing)
for (let y = oy - spacing; y < canvas.height + spacing; y += spacing) {
ctx.beginPath();
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
ctx.fill();
}
}
// ── Main draw ────────────────────────────────────────────
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDotGrid();
const q = filterQuery.trim().toLowerCase();
const matchIds = q
? new Set(NODES.filter(n =>
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
).map(n => n.id))
: null;
// Update match count display
const matchCountEl = document.getElementById('match-count');
matchCountEl.textContent = matchIds ? `${matchIds.size}` : '';
const vn = visNodes();
ctx.save();
ctx.translate(transform.x, transform.y);
ctx.scale(transform.scale, transform.scale);
// Edges
for (const edge of EDGES) {
const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
const x1 = p1.x, y1 = p1.y + NODE_H / 2;
const x2 = p2.x, y2 = p2.y - NODE_H / 2;
const cy = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1, cy, x2, cy, x2, y2);
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Nodes
for (const node of vn) {
const p = pos[node.id];
if (!p) continue;
const isSel = node.id === selectedId;
const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = matchIds !== null && !matchIds.has(node.id);
drawNode(node, p.x, p.y, isSel, isMatch, isDim);
}
ctx.restore();
// Update zoom indicator
zoomEl.textContent = `${Math.round(transform.scale * 100)}%`;
}
// ── Node renderer ────────────────────────────────────────
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
const x = cx - hw, y = cy - hh;
const color = TYPE_COLOR[node.type] || '#334155';
ctx.globalAlpha = isDim ? 0.15 : 1;
// Glow for selected
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; }
// Background — dark card
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill();
ctx.shadowBlur = 0;
// Left color strip (clipped)
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.clip();
ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H);
ctx.restore();
// Border
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
if (isSel) {
ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 1.5;
} else if (isMatch) {
ctx.strokeStyle = '#e3b341';
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = `${color}44`;
ctx.lineWidth = 1;
}
ctx.stroke();
// Kind badge (top-right, small pill)
const kindText = node.kind;
ctx.font = '9px system-ui';
const rawW = ctx.measureText(kindText).width;
const badgeW = Math.min(rawW + 8, 66);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
const badgeY = y + (NODE_H - 14) / 2;
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.fillStyle = `${color}22`;
ctx.fill();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Truncate kind if needed
let kLabel = kindText;
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6) kLabel = kLabel.slice(0, -1);
if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
// Label
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const labelX = x + 12;
const labelMaxW = badgeX - labelX - 6;
let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…';
ctx.fillText(label, labelX, cy);
// Chevron toggle (if has children)
if (hasChildren(node.id)) {
const chevX = x + NODE_W - CHEV_ZONE / 2 - 1;
const isCollapsed = collapsed.has(node.id);
drawChevron(ctx, chevX, cy, !isCollapsed, color);
}
ctx.globalAlpha = 1;
}
function drawChevron(ctx, cx, cy, pointDown, color) {
const s = 4;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
if (pointDown) {
ctx.moveTo(cx - s, cy - s * 0.5);
ctx.lineTo(cx, cy + s * 0.5);
ctx.lineTo(cx + s, cy - s * 0.5);
} else {
ctx.moveTo(cx - s * 0.5, cy - s);
ctx.lineTo(cx + s * 0.5, cy);
ctx.lineTo(cx - s * 0.5, cy + s);
}
ctx.stroke();
}
// ═══════════════════════════════════════════════════════════
// Fit all
// ═══════════════════════════════════════════════════════════
function fitAll() {
const vn = visNodes();
if (vn.length === 0) return;
const xs = vn.map(n => pos[n.id]?.x ?? 0);
const ys = vn.map(n => pos[n.id]?.y ?? 0);
const pad = 48;
const minX = Math.min(...xs) - NODE_W / 2 - pad;
const maxX = Math.max(...xs) + NODE_W / 2 + pad;
const minY = Math.min(...ys) - NODE_H / 2 - pad;
const maxY = Math.max(...ys) + NODE_H / 2 + pad;
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
draw();
}
// ═══════════════════════════════════════════════════════════
// Hit testing — returns { node, isToggle }
// ═══════════════════════════════════════════════════════════
function hitTest(sx, sy) {
const wx = (sx - transform.x) / transform.scale;
const wy = (sy - transform.y) / transform.scale;
const vn = visNodes();
for (let i = vn.length - 1; i >= 0; i--) {
const n = vn[i];
const p = pos[n.id];
if (!p) continue;
if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= NODE_H / 2) {
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle };
}
}
return null;
}
// ═══════════════════════════════════════════════════════════
// Properties panel
// ═══════════════════════════════════════════════════════════
const propsPanel = document.getElementById('props-panel');
function showProperties(node) {
const details = DETAILS[node.id] || {};
const color = TYPE_COLOR[node.type] || '#334155';
let html = `
<div class="ph">
<div class="ph-label">${node.label}</div>
<span class="ph-kind" style="background:${color}">${node.kind}</span>
</div>
<div id="props-scroll">`;
for (const [section, rows] of Object.entries(details)) {
html += `<div class="ps">${section}</div>`;
for (const [k, v] of Object.entries(rows))
html += `<div class="pr"><span class="pk" title="${k}">${k}</span><span class="pv" title="${v}">${v}</span></div>`;
}
html += '</div>';
propsPanel.innerHTML = html;
propsPanel.classList.remove('empty');
}
function clearProperties() {
propsPanel.innerHTML = '<span id="props-empty">Click a node to inspect</span>';
propsPanel.classList.add('empty');
}
// ═══════════════════════════════════════════════════════════
// Interaction
// ═══════════════════════════════════════════════════════════
let isPanning = false;
let panOrigin = { x: 0, y: 0 };
let tfAtStart = null;
let didMove = false;
canvas.addEventListener('mousedown', e => {
isPanning = true; didMove = false;
panOrigin = { x: e.clientX, y: e.clientY };
tfAtStart = { ...transform };
container.classList.add('panning');
container.classList.remove('hovering');
});
window.addEventListener('mousemove', e => {
if (isPanning) {
const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy;
draw();
return;
}
// Hover cursor
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
container.classList.toggle('hovering', !!hit);
});
window.addEventListener('mouseup', e => {
if (!isPanning) return;
isPanning = false;
container.classList.remove('panning');
if (!didMove) {
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit) {
if (hit.isToggle) {
// Toggle collapse
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
else collapsed.add(hit.node.id);
recomputeLayout();
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; clearProperties();
}
} else {
selectedId = hit.node.id;
showProperties(hit.node);
}
} else {
selectedId = null;
clearProperties();
}
draw();
}
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const f = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.max(0.12, Math.min(3.5, transform.scale * f));
transform.x = mx - (mx - transform.x) * (ns / transform.scale);
transform.y = my - (my - transform.y) * (ns / transform.scale);
transform.scale = ns;
draw();
}, { passive: false });
// ─── Search ──────────────────────────────────────────────
document.getElementById('search').addEventListener('input', e => {
filterQuery = e.target.value; draw();
});
// ─── Fit ─────────────────────────────────────────────────
document.getElementById('fit-btn').addEventListener('click', fitAll);
// ─── Expand / Collapse all ───────────────────────────────
document.getElementById('expand-all-btn').addEventListener('click', () => {
collapsed.clear(); recomputeLayout(); draw();
});
document.getElementById('collapse-all-btn').addEventListener('click', () => {
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; clearProperties();
}
recomputeLayout(); draw();
});
// ─── Resize (zoom stable) ───────────────────────────────
new ResizeObserver(() => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
}).observe(container);
// ─── Init ────────────────────────────────────────────────
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
setTimeout(fitAll, 30);
</script>
</body>
</html>

View File

@@ -0,0 +1,449 @@
/**
* Create keyboard bindings
*/
(function () {
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
const KeyboardRegistry = {
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
currentKeys: new Set(),
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500 // 500ms timeout for sequences
};
/**
* Normalize key names to lowercase for case-insensitive comparison
* @param {string} key - The key to normalize
* @returns {string} - Normalized key name
*/
function normalizeKey(key) {
const keyMap = {
'control': 'ctrl',
'escape': 'esc',
'delete': 'del'
};
const normalized = key.toLowerCase();
return keyMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of keys for Map indexing
* @param {Set} keySet - Set of normalized keys
* @returns {string} - Sorted string representation
*/
function setToKey(keySet) {
return Array.from(keySet).sort().join('+');
}
/**
* Parse a single element (can be a single key or a simultaneous combination)
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
* @returns {Set} - Set of normalized keys
*/
function parseElement(element) {
if (element.includes('+')) {
// Simultaneous combination
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
}
// Single key
return new Set([normalizeKey(element.trim())]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a key or simultaneous combination)
return [parseElement(combination)];
}
/**
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTreeNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root tree node
*/
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
let currentNode = root;
for (const keySet of sequence) {
const key = setToKey(keySet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
* @returns {Object|null} - Current node or null if no match
*/
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where typing should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus is inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle keyboard events and trigger matching combinations
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyboardEvent(event) {
const key = normalizeKey(event.key);
// Add key to current pressed keys
KeyboardRegistry.currentKeys.add(key);
console.debug("Received key", key);
// Create a snapshot of current keyboard state
const snapshot = new Set(KeyboardRegistry.currentKeys);
// Add snapshot to history
KeyboardRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
KeyboardRegistry.pendingMatches = [];
}
// Collect match information for all elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
// Check all registered elements for matching combinations
for (const [elementId, data] of KeyboardRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
// Check if focus is inside this element (element itself or any child)
const isInside = element.contains(document.activeElement);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree, continue to next element
console.debug("No match in tree for event", key);
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has a URL)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
// Track if ANY element has longer sequences possible
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
KeyboardRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but AT LEAST ONE element has longer sequences possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
KeyboardRegistry.pendingMatches = currentMatches;
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
KeyboardRegistry.snapshotHistory = [];
KeyboardRegistry.pendingMatches = [];
KeyboardRegistry.pendingTimeout = null;
}, KeyboardRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
KeyboardRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
// This handles invalid sequences like "A C" when only "A B" exists
if (!foundAnyMatch) {
KeyboardRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (KeyboardRegistry.snapshotHistory.length > 10) {
KeyboardRegistry.snapshotHistory = [];
}
}
/**
* Handle keyup event to remove keys from current pressed keys
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyUp(event) {
const key = normalizeKey(event.key);
KeyboardRegistry.currentKeys.delete(key);
}
/**
* Attach the global keyboard event listener if not already attached
*/
function attachGlobalListener() {
if (!KeyboardRegistry.listenerAttached) {
document.addEventListener('keydown', handleKeyboardEvent);
document.addEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = true;
}
}
/**
* Detach the global keyboard event listener
*/
function detachGlobalListener() {
if (KeyboardRegistry.listenerAttached) {
document.removeEventListener('keydown', handleKeyboardEvent);
document.removeEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = false;
// Clean up all state
KeyboardRegistry.currentKeys.clear();
KeyboardRegistry.snapshotHistory = [];
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
}
KeyboardRegistry.pendingMatches = [];
}
}
/**
* Add keyboard support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_keyboard_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
KeyboardRegistry.elements.set(elementId, {
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove keyboard support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_keyboard_support = function (elementId) {
// Remove from registry
if (!KeyboardRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in keyboard registry!");
return;
}
KeyboardRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (KeyboardRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

634
examples/mouse_support.js Normal file
View File

@@ -0,0 +1,634 @@
/**
* Create mouse bindings
*/
(function () {
/**
* Global registry to store mouse shortcuts for multiple elements
*/
const MouseRegistry = {
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500, // 500ms timeout for sequences
clickHandler: null,
contextmenuHandler: null
};
/**
* Normalize mouse action names
* @param {string} action - The action to normalize
* @returns {string} - Normalized action name
*/
function normalizeAction(action) {
const normalized = action.toLowerCase().trim();
// Handle aliases
const aliasMap = {
'rclick': 'right_click'
};
return aliasMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of actions for Map indexing
* @param {Set} actionSet - Set of normalized actions
* @returns {string} - Sorted string representation
*/
function setToKey(actionSet) {
return Array.from(actionSet).sort().join('+');
}
/**
* Parse a single element (can be a simple click or click with modifiers)
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
* @returns {Set} - Set of normalized actions
*/
function parseElement(element) {
if (element.includes('+')) {
// Click with modifiers
return new Set(element.split('+').map(a => normalizeAction(a)));
}
// Simple click
return new Set([normalizeAction(element)]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "click right_click")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a click or click with modifiers)
return [parseElement(combination)];
}
/**
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTreeNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root tree node
*/
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const actionSet of sequence) {
const key = setToKey(actionSet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
* @returns {Object|null} - Current node or null if no match
*/
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where clicking should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Get the element that was actually clicked (from registered elements)
* @param {Element} target - The clicked element
* @returns {string|null} - Element ID if found, null otherwise
*/
function findRegisteredElement(target) {
// Check if target itself is registered
if (target.id && MouseRegistry.elements.has(target.id)) {
return target.id;
}
// Check if any parent is registered
let current = target.parentElement;
while (current) {
if (current.id && MouseRegistry.elements.has(current.id)) {
return current.id;
}
current = current.parentElement;
}
return null;
}
/**
* Create a snapshot from mouse event
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
* @returns {Set} - Set of actions representing this click
*/
function createSnapshot(event, baseAction) {
const actions = new Set([baseAction]);
// Add modifiers if present
if (event.ctrlKey || event.metaKey) {
actions.add('ctrl');
}
if (event.shiftKey) {
actions.add('shift');
}
if (event.altKey) {
actions.add('alt');
}
return actions;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the click was inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle mouse events and trigger matching combinations
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
*/
function handleMouseEvent(event, baseAction) {
// Different behavior for click vs right_click
if (baseAction === 'click') {
// Click: trigger for ALL registered elements (useful for closing modals/popups)
handleGlobalClick(event);
} else if (baseAction === 'right_click') {
// Right-click: trigger ONLY if clicked on a registered element
handleElementRightClick(event);
}
}
/**
* Handle global click events (triggers for all registered elements)
* @param {MouseEvent} event - The mouse event
*/
function handleGlobalClick(event) {
console.debug("Global click detected");
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for ALL registered elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
for (const [elementId, data] of MouseRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
// Check if click was inside this element
const isInside = element.contains(event.target);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Handle right-click events (triggers only for clicked element)
* @param {MouseEvent} event - The mouse event
*/
function handleElementRightClick(event) {
// Find which registered element was clicked
const elementId = findRegisteredElement(event.target);
if (!elementId) {
// Right-click wasn't on a registered element - don't prevent default
// This allows browser context menu to appear
return;
}
console.debug("Right-click on registered element", elementId);
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
const clickedInside = true;
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'right_click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for this element
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
const data = MouseRegistry.elements.get(elementId);
if (!data) return;
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
console.debug("No match in tree for right-click");
// Clear history for invalid sequences
MouseRegistry.snapshotHistory = [];
return;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: true // Right-click only triggers when clicking on element
});
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Attach the global mouse event listeners if not already attached
*/
function attachGlobalListener() {
if (!MouseRegistry.listenerAttached) {
// Store handler references for proper removal
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
document.addEventListener('click', MouseRegistry.clickHandler);
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = true;
}
}
/**
* Detach the global mouse event listeners
*/
function detachGlobalListener() {
if (MouseRegistry.listenerAttached) {
document.removeEventListener('click', MouseRegistry.clickHandler);
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = false;
// Clean up handler references
MouseRegistry.clickHandler = null;
MouseRegistry.contextmenuHandler = null;
// Clean up all state
MouseRegistry.snapshotHistory = [];
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
}
MouseRegistry.pendingMatches = [];
}
}
/**
* Add mouse support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_mouse_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
MouseRegistry.elements.set(elementId, {
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove mouse support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_mouse_support = function (elementId) {
// Remove from registry
if (!MouseRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in mouse registry!");
return;
}
MouseRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (MouseRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyboard Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.shortcuts-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.shortcuts-list h3 {
margin-top: 0;
}
.shortcuts-list ul {
margin: 10px 0;
padding-left: 20px;
}
.shortcuts-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
h1, h2 {
color: #333;
}
</style>
</head>
<body>
<h1>Keyboard Support Test Page</h1>
<div class="shortcuts-list">
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
<p><strong>Simple keys:</strong></p>
<ul>
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
<li><code>esc</code> - Escape key (POST)</li>
</ul>
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
<ul>
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
</ul>
<p><strong>Sequences with various configs:</strong></p>
<ul>
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
<li><code>A B C</code> - Triple sequence (GET request)</li>
<li><code>shift shift</code> - Press Shift twice in sequence</li>
</ul>
<p><strong>Complex:</strong></p>
<ul>
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
</ul>
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
</div>
<div class="test-container">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
<strong>Parameters Explained:</strong><br>
<code>has_focus</code>: Whether the registered element itself has focus<br>
<code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
</p>
</div>
<div class="test-container">
<h2>Test Element 1</h2>
<div id="test-element" class="test-element" tabindex="0">
Click me to focus, then try keyboard shortcuts
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_keyboard_support('test-element-2');
logEvent('Element 2 keyboard support removed',
'ESC and Shift Shift no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Keyboard Support Removed';
}
</script>
<!-- Include keyboard support script -->
<script src="keyboard_support.js"></script>
<!-- Initialize keyboard support -->
<script>
const combinations = {
"a": {
"hx-post": "/test/key-a"
},
"Ctrl+S": {
"hx-post": "/test/save",
"hx-swap": "innerHTML"
},
"Ctrl+C": {
"hx-post": "/test/copy",
"hx-target": "#result",
"hx-swap": "outerHTML"
},
"A B": {
"hx-post": "/test/sequence-ab",
"hx-vals": {"extra": "data"}
},
"A B C": {
"hx-get": "/test/sequence-abc"
},
"Ctrl+C C": {
"hx-post": "/test/complex-ctrl-c-c"
},
"Ctrl+C Ctrl+C": {
"hx-post": "/test/complex-ctrl-c-twice"
},
"shift shift": {
"hx-post": "/test/shift-shift"
},
"esc": {
"hx-post": "/test/escape"
}
};
add_keyboard_support('test-element', JSON.stringify(combinations));
// Add second element that also listens to ESC and shift shift
const combinations2 = {
"esc": {
"hx-post": "/test/escape-element2"
},
"shift shift": {
"hx-post": "/test/shift-shift-element2"
}
};
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
// Log initial state
logEvent('Keyboard support initialized',
'Element 1: All shortcuts configured with HTMX options',
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
</script>
</body>
</html>

View File

@@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mouse Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
user-select: none;
}
.test-element:hover {
background-color: #e0e0e0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.actions-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.actions-list h3 {
margin-top: 0;
}
.actions-list ul {
margin: 10px 0;
padding-left: 20px;
}
.actions-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
.remove-button {
background-color: #FF5722;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
}
.remove-button:hover {
background-color: #E64A19;
}
.remove-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
h1, h2 {
color: #333;
}
.note {
background-color: #e3f2fd;
border-left: 4px solid #2196F3;
padding: 10px 15px;
margin: 10px 0;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>Mouse Support Test Page</h1>
<div class="actions-list">
<h3>🖱️ Configured Mouse Actions</h3>
<p><strong>Element 1 - All Actions:</strong></p>
<ul>
<li><code>click</code> - Simple left click</li>
<li><code>right_click</code> - Right click (context menu blocked)</li>
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
<li><code>shift+click</code> - Shift + Click</li>
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
<li><code>click right_click</code> - Click then right-click within 500ms</li>
<li><code>click click</code> - Click twice in sequence</li>
</ul>
<p><strong>Element 2 - Using rclick alias:</strong></p>
<ul>
<li><code>click</code> - Simple click</li>
<li><code>rclick</code> - Right click (using rclick alias)</li>
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
</ul>
<p><strong>Element 3 - Mousedown&gt;mouseup actions:</strong></p>
<ul>
<li><code>click</code> - Simple click (coexists with mousedown&gt;mouseup)</li>
<li><code>mousedown>mouseup</code> - Left press and release (with JS values)</li>
<li><code>ctrl+mousedown>mouseup</code> - Ctrl + press and release</li>
<li><code>rmousedown>mouseup</code> - Right press and release</li>
<li><code>click mousedown>mouseup</code> - Click then press-and-release sequence</li>
</ul>
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
</div>
<div class="note">
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
</div>
<div class="note">
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
Try right-clicking outside the test elements - the browser's context menu will appear normally.
</div>
<div class="note">
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
</div>
<div class="test-container">
<h2>Test Element 1 (All Actions)</h2>
<div id="test-element-1" class="test-element" tabindex="0">
Try different mouse actions here!<br>
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (Using rclick alias)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element uses "rclick" alias for right-click<br>
Also has a "click rclick" sequence
</div>
<button class="remove-button" onclick="removeElement2()">
Remove Element 2 Mouse Support
</button>
</div>
<div class="test-container">
<h2>Test Element 3 (Mousedown&gt;mouseup actions)</h2>
<div id="test-element-3" class="test-element" tabindex="0">
Try mousedown&gt;mouseup actions here!<br>
Press and hold, then release. Also try Ctrl+drag, right-drag, and click then drag.
</div>
</div>
<div class="test-container">
<h2>Test Input (normal clicking should work here)</h2>
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
style="width: 100%; padding: 10px; font-size: 14px;">
</div>
<div class="test-container" style="background-color: #f9f9f9;">
<h2>🎯 Click Outside Test Area</h2>
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
</p>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Click inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_mouse_support('test-element-2');
logEvent('Element 2 mouse support removed',
'Click and right-click no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Mouse Support Removed';
}
</script>
<!-- Include mouse support script -->
<script src="mouse_support.js"></script>
<!-- Initialize mouse support -->
<script>
// Element 1 - Full configuration
const combinations1 = {
"click": {
"hx-post": "/test/click"
},
"right_click": {
"hx-post": "/test/right-click"
},
"ctrl+click": {
"hx-post": "/test/ctrl-click",
"hx-swap": "innerHTML"
},
"shift+click": {
"hx-post": "/test/shift-click",
"hx-target": "#result"
},
"ctrl+shift+click": {
"hx-post": "/test/ctrl-shift-click",
"hx-vals": {"modifier": "both"}
},
"click right_click": {
"hx-post": "/test/click-then-right-click",
"hx-vals": {"type": "sequence"}
},
"click click": {
"hx-post": "/test/double-click-sequence"
}
};
add_mouse_support('test-element-1', JSON.stringify(combinations1));
// Element 2 - Using rclick alias
const combinations2 = {
"click": {
"hx-post": "/test/element2-click"
},
"rclick": { // Using rclick alias instead of right_click
"hx-post": "/test/element2-rclick"
},
"click rclick": { // Sequence using rclick alias
"hx-post": "/test/element2-click-rclick-sequence"
}
};
add_mouse_support('test-element-2', JSON.stringify(combinations2));
// JS function for dynamic mousedown>mouseup values
// Returns cell-like data for testing
window.getCellId = function(event, element, combination) {
const x = event.clientX;
const y = event.clientY;
return {
cell_id: `cell-${Math.floor(x / 50)}-${Math.floor(y / 50)}`,
x: x,
y: y
};
};
// Element 3 - Mousedown>mouseup actions
const combinations3 = {
"click": {
"hx-post": "/test/element3-click"
},
"mousedown>mouseup": {
"hx-post": "/test/element3-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"ctrl+mousedown>mouseup": {
"hx-post": "/test/element3-ctrl-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"rmousedown>mouseup": {
"hx-post": "/test/element3-rmousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"click mousedown>mouseup": {
"hx-post": "/test/element3-click-then-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
}
};
add_mouse_support('test-element-3', JSON.stringify(combinations3));
// Log initial state
logEvent('Mouse support initialized',
'Element 1: All mouse actions configured',
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
'Element 3: Mousedown>mouseup actions (with JS getCellId function)',
'Smart timeout: 500ms for sequences', false);
</script>
</body>
</html>