New hierarchical component, used for InstancesDebugger.py
This commit is contained in:
779
examples/canvas_graph_prototype.html
Normal file
779
examples/canvas_graph_prototype.html
Normal 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>
|
||||
449
examples/keyboard_support.js
Normal file
449
examples/keyboard_support.js
Normal 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
634
examples/mouse_support.js
Normal 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
309
examples/test_keyboard_support.html
Normal file
309
examples/test_keyboard_support.html
Normal 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>
|
||||
410
examples/test_mouse_support.html
Normal file
410
examples/test_mouse_support.html
Normal 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>mouseup actions:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple click (coexists with mousedown>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>mouseup actions)</h2>
|
||||
<div id="test-element-3" class="test-element" tabindex="0">
|
||||
Try mousedown>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>
|
||||
Reference in New Issue
Block a user