Add gated changeset application and graph controls
This commit is contained in:
parent
3c15e26283
commit
78335c8973
20 changed files with 1813 additions and 453 deletions
|
|
@ -33,7 +33,7 @@ from .errors import DocForgeError
|
|||
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
|
||||
from .project import project_root_fingerprint
|
||||
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@11"
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@12"
|
||||
DEFAULT_EDGE_LIMIT = 100
|
||||
MAX_EDGE_LIMIT = 400
|
||||
MAX_LINEAGE_EDGE_LIMIT = 1_000
|
||||
|
|
@ -65,7 +65,9 @@ class VisualizationIndexSnapshot:
|
|||
|
||||
def __init__(self, index: ProjectIndex, checked: dict[str, object]) -> None:
|
||||
self.path = index.path
|
||||
self.project_root = index.project.descriptor.root
|
||||
self.title = index.project.descriptor.title
|
||||
self.max_source_bytes = index.project.descriptor.limits.max_source_bytes
|
||||
self.max_query_chars = index.project.descriptor.limits.max_query_chars
|
||||
self.max_results = index.project.descriptor.limits.max_results
|
||||
self.max_depth = index.project.descriptor.limits.max_traversal_depth
|
||||
|
|
@ -77,19 +79,30 @@ class VisualizationIndexSnapshot:
|
|||
snapshot = cls.__new__(cls)
|
||||
path = spec["path"]
|
||||
title = spec["title"]
|
||||
project_root = spec["project_root"]
|
||||
max_source_bytes = spec["max_source_bytes"]
|
||||
max_query_chars = spec["max_query_chars"]
|
||||
max_results = spec["max_results"]
|
||||
max_depth = spec["max_depth"]
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or not isinstance(title, str)
|
||||
or not isinstance(project_root, str)
|
||||
or type(max_source_bytes) is not int
|
||||
or type(max_query_chars) is not int
|
||||
or type(max_results) is not int
|
||||
or type(max_depth) is not int
|
||||
):
|
||||
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
|
||||
snapshot.path = Path(path)
|
||||
try:
|
||||
snapshot.project_root = Path(project_root).resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise DocForgeError("invalid_index", "Visualization project root is invalid") from error
|
||||
if not snapshot.project_root.is_dir():
|
||||
raise DocForgeError("invalid_index", "Visualization project root is invalid")
|
||||
snapshot.title = title
|
||||
snapshot.max_source_bytes = max_source_bytes
|
||||
snapshot.max_query_chars = max_query_chars
|
||||
snapshot.max_results = max_results
|
||||
snapshot.max_depth = max_depth
|
||||
|
|
@ -104,7 +117,9 @@ class VisualizationIndexSnapshot:
|
|||
def spec(self) -> dict[str, object]:
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"project_root": str(self.project_root),
|
||||
"title": self.title,
|
||||
"max_source_bytes": self.max_source_bytes,
|
||||
"max_query_chars": self.max_query_chars,
|
||||
"max_results": self.max_results,
|
||||
"max_depth": self.max_depth,
|
||||
|
|
@ -288,6 +303,68 @@ class VisualizationIndexSnapshot:
|
|||
snapshot=True,
|
||||
)
|
||||
|
||||
def source(self, node_id: str) -> dict[str, object]:
|
||||
"""Return one node's bounded, project-confined UTF-8 source file."""
|
||||
|
||||
with self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
|
||||
(node_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise DocForgeError(
|
||||
"missing_node",
|
||||
"No node has the requested stable ID",
|
||||
node_id=node_id,
|
||||
)
|
||||
relative = Path(row["source_path"])
|
||||
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
||||
raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id)
|
||||
source = self.project_root / relative
|
||||
try:
|
||||
resolved = source.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"missing_source",
|
||||
"Node source file is unavailable",
|
||||
node_id=node_id,
|
||||
) from error
|
||||
if (
|
||||
source.is_symlink()
|
||||
or resolved != source
|
||||
or not source.is_relative_to(self.project_root)
|
||||
or not source.is_file()
|
||||
):
|
||||
raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id)
|
||||
if source.stat().st_size > self.max_source_bytes:
|
||||
raise DocForgeError(
|
||||
"source_too_large",
|
||||
"Node source exceeds the configured source limit",
|
||||
node_id=node_id,
|
||||
)
|
||||
raw = source.read_bytes()
|
||||
if len(raw) > self.max_source_bytes:
|
||||
raise DocForgeError(
|
||||
"source_too_large",
|
||||
"Node source exceeds the configured source limit",
|
||||
node_id=node_id,
|
||||
)
|
||||
try:
|
||||
content = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise DocForgeError(
|
||||
"invalid_source",
|
||||
"Node source is not UTF-8",
|
||||
node_id=node_id,
|
||||
) from error
|
||||
return self._result(
|
||||
node_id=node_id,
|
||||
source_path=row["source_path"],
|
||||
source_anchor=row["source_anchor"],
|
||||
content=content,
|
||||
snapshot=True,
|
||||
)
|
||||
|
||||
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
|
||||
"""Return every bounded, directed ancestry path terminating at ``node_id``.
|
||||
|
||||
|
|
@ -686,6 +763,9 @@ class VisualizationRunner:
|
|||
elif parsed.path == f"{prefix}/api/node":
|
||||
self._touch_lease()
|
||||
payload = self._node(reader, params)
|
||||
elif parsed.path == f"{prefix}/api/source":
|
||||
self._touch_lease()
|
||||
payload = self._source(reader, params)
|
||||
elif parsed.path == f"{prefix}/api/lineage":
|
||||
self._touch_lease()
|
||||
payload = self._lineage(reader, params)
|
||||
|
|
@ -709,6 +789,9 @@ class VisualizationRunner:
|
|||
"invalid_filter": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_depth": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_limit": HTTPStatus.BAD_REQUEST,
|
||||
"path_escape": HTTPStatus.FORBIDDEN,
|
||||
"missing_source": HTTPStatus.NOT_FOUND,
|
||||
"source_too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
self._respond_json(
|
||||
handler,
|
||||
|
|
@ -756,6 +839,16 @@ class VisualizationRunner:
|
|||
)
|
||||
return reader.node(node_id, depth=depth, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def _source(
|
||||
reader: VisualizationIndexSnapshot,
|
||||
params: dict[str, list[str]],
|
||||
) -> dict[str, object]:
|
||||
node_id = _one(params, "id").strip()
|
||||
if not node_id:
|
||||
raise DocForgeError("missing_node", "One exact node ID is required")
|
||||
return reader.source(node_id)
|
||||
|
||||
def _lineage(
|
||||
self,
|
||||
reader: VisualizationIndexSnapshot,
|
||||
|
|
@ -1428,7 +1521,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
.canvas.dragging svg { cursor: grabbing; }
|
||||
.viewport-controls {
|
||||
position: absolute; z-index: 2; top: 12px; right: 12px;
|
||||
display: grid; grid-template-columns: repeat(3, 36px) auto;
|
||||
display: grid; grid-template-columns: repeat(3, 36px) auto auto;
|
||||
align-items: center; gap: 6px; padding: 6px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: rgba(7, 16, 26, .9); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
|
||||
|
|
@ -1444,6 +1537,14 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
min-width: 48px; padding: 0 5px; color: var(--muted);
|
||||
font-variant-numeric: tabular-nums; text-align: right;
|
||||
}
|
||||
.restore-hidden {
|
||||
min-width: 92px; height: 34px; border: 1px solid #31526d; border-radius: 7px;
|
||||
padding: 0 10px; background: #102b3d; color: var(--text); font-size: 11px;
|
||||
}
|
||||
.restore-hidden:hover, .restore-hidden:focus-visible {
|
||||
border-color: var(--accent); outline: 2px solid transparent;
|
||||
}
|
||||
.restore-hidden[hidden] { display: none; }
|
||||
.viewport-hint {
|
||||
position: absolute; z-index: 1; left: 12px; bottom: 12px;
|
||||
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
|
||||
|
|
@ -1547,9 +1648,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
dialog::backdrop { background: rgba(2, 8, 14, .48); }
|
||||
.dialog-shell {
|
||||
display: grid; grid-template-rows: auto auto auto; width: fit-content;
|
||||
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: fit-content;
|
||||
min-width: min(360px, calc(100vw - 20px)); max-width: min(760px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
height: 100%; max-height: calc(100vh - 32px); overflow: hidden;
|
||||
}
|
||||
.dialog-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
|
|
@ -1583,6 +1684,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
border-top: 1px solid var(--line); background: var(--panel-2);
|
||||
}
|
||||
.compact-dialog .dialog-actions { padding: 9px 12px; background: rgba(12, 35, 51, .72); }
|
||||
.source-dialog {
|
||||
width: min(920px, calc(100vw - 32px)); height: min(760px, calc(100vh - 32px));
|
||||
}
|
||||
.source-dialog .dialog-shell {
|
||||
width: 100%; max-width: none; min-width: 0; height: 100%;
|
||||
}
|
||||
.source-code {
|
||||
display: block; margin: 0; min-width: max-content; font: 12px/1.55 ui-monospace, monospace;
|
||||
counter-reset: source-line;
|
||||
}
|
||||
.source-line { display: block; min-height: 1.55em; padding: 0 12px 0 58px; position: relative; }
|
||||
.source-line::before {
|
||||
position: absolute; left: 0; width: 46px; color: #63809a; text-align: right;
|
||||
content: attr(data-line);
|
||||
}
|
||||
.source-line.target { background: rgba(81, 215, 255, .16); color: #fff; }
|
||||
.error { color: #ff9aac; }
|
||||
@media (max-width: 980px) {
|
||||
:root { --left-width: 240px; --right-width: 260px; }
|
||||
|
|
@ -1646,6 +1763,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
<button class="viewport-control" id="reset-view" type="button"
|
||||
title="Reset view" aria-label="Reset graph view">⌂</button>
|
||||
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
|
||||
<button class="restore-hidden" id="restore-hidden" type="button" hidden>
|
||||
Restore hidden
|
||||
</button>
|
||||
</div>
|
||||
<details class="relationship-key" id="relationship-key" open>
|
||||
<summary>
|
||||
|
|
@ -1691,6 +1811,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
</div>
|
||||
<div class="dialog-body" id="node-dialog-details"></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="open-node-source" type="button">Open source</button>
|
||||
<button class="button" id="hide-node" type="button">Hide node</button>
|
||||
<button class="button" id="explore-node" type="button">Explore neighborhood</button>
|
||||
<button class="button" id="dismiss-node-dialog" type="button">Close</button>
|
||||
</div>
|
||||
|
|
@ -1700,10 +1822,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
<div class="dialog-shell">
|
||||
<div class="dialog-body" id="node-card-details"></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="open-card-source" type="button">Open source</button>
|
||||
<button class="button" id="hide-card-node" type="button">Hide node</button>
|
||||
<button class="button" id="explore-card-node" type="button">Explore neighborhood</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog class="source-dialog" id="source-dialog" aria-labelledby="source-dialog-label">
|
||||
<div class="dialog-shell">
|
||||
<div class="dialog-head">
|
||||
<strong id="source-dialog-label">Node source</strong>
|
||||
<button class="dialog-close" id="close-source-dialog" type="button"
|
||||
aria-label="Close source">×</button>
|
||||
</div>
|
||||
<div class="dialog-body"><code class="source-code" id="source-code"></code></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="dismiss-source-dialog" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<script>
|
||||
const base = location.pathname.replace(/\/?$/, "/");
|
||||
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
|
||||
|
|
@ -1722,6 +1859,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
suppressClick: false,
|
||||
inspectedNode: null,
|
||||
cardNode: null,
|
||||
hiddenNodes: new Set(),
|
||||
dialogDrag: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
|
@ -2338,7 +2476,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
&& data.nodes.some((node) => node.node_id === state.selectedNode)
|
||||
? state.selectedNode
|
||||
: data.root;
|
||||
const view = state.mode === "flow" ? buildFlowGraph(data) : data;
|
||||
const completeView = state.mode === "flow" ? buildFlowGraph(data) : data;
|
||||
const visibleIds = new Set(
|
||||
completeView.nodes
|
||||
.filter((node) => node.node_id === completeView.root
|
||||
|| !state.hiddenNodes.has(node.node_id))
|
||||
.map((node) => node.node_id),
|
||||
);
|
||||
const view = {
|
||||
...completeView,
|
||||
nodes: completeView.nodes.filter((node) => visibleIds.has(node.node_id)),
|
||||
edges: completeView.edges.filter(
|
||||
(edge) => visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id),
|
||||
),
|
||||
};
|
||||
const hiddenCount = completeView.nodes.length - view.nodes.length;
|
||||
const restore = $("restore-hidden");
|
||||
restore.hidden = state.hiddenNodes.size === 0;
|
||||
restore.textContent = `Restore hidden (${state.hiddenNodes.size})`;
|
||||
restore.title = hiddenCount
|
||||
? `${hiddenCount} hidden in this view; restore all hidden nodes`
|
||||
: "Restore hidden nodes from other views";
|
||||
state.selectedNode = view.nodes.some((node) => node.node_id === selectedCandidate)
|
||||
? selectedCandidate
|
||||
: view.root;
|
||||
|
|
@ -2447,6 +2605,72 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
svg.append(definitions, edgeLayer, nodeLayer);
|
||||
}
|
||||
function hideNode(nodeId) {
|
||||
if (!state.graph || nodeId === state.root) {
|
||||
setStatus("The focus node cannot be hidden. Focus another node first.", true);
|
||||
return;
|
||||
}
|
||||
state.hiddenNodes.add(nodeId);
|
||||
closeNodeCard();
|
||||
closeNodeDialog();
|
||||
renderGraph(state.graph, true);
|
||||
setStatus(`Hidden ${nodeId}. Restore hidden nodes from the graph controls.`);
|
||||
}
|
||||
function restoreHiddenNodes() {
|
||||
const count = state.hiddenNodes.size;
|
||||
state.hiddenNodes.clear();
|
||||
if (state.graph) renderGraph(state.graph, true);
|
||||
setStatus(`Restored ${count} hidden node${count === 1 ? "" : "s"}.`);
|
||||
}
|
||||
function anchorLine(content, anchor) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
if (!anchor) return 1;
|
||||
const numeric = /^(?:L|line[-_: ]?)?(\d+)$/i.exec(anchor.trim());
|
||||
if (numeric) return Math.min(lines.length, Math.max(1, Number(numeric[1])));
|
||||
const nodeAnchor = /^node-(\d+)$/i.exec(anchor.trim());
|
||||
if (nodeAnchor) {
|
||||
const wanted = Number(nodeAnchor[1]);
|
||||
let count = 0;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index].trim() === "[[nodes]]") count += 1;
|
||||
if (count === wanted) return index + 1;
|
||||
}
|
||||
}
|
||||
const plain = anchor.replace(/^#/, "").trim().toLowerCase();
|
||||
const slug = (value) => value.toLowerCase().trim()
|
||||
.replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
const direct = lines.findIndex((line) => line.toLowerCase().includes(plain));
|
||||
if (direct >= 0) return direct + 1;
|
||||
const heading = lines.findIndex((line) => slug(line) === slug(plain));
|
||||
return heading >= 0 ? heading + 1 : 1;
|
||||
}
|
||||
async function openSource(nodeId) {
|
||||
try {
|
||||
setStatus(`Opening source for ${nodeId}…`);
|
||||
const data = await api(`source?${new URLSearchParams({id: nodeId})}`);
|
||||
const code = $("source-code");
|
||||
code.replaceChildren();
|
||||
const targetLine = anchorLine(data.content, data.source_anchor);
|
||||
for (const [index, text] of data.content.split(/\r?\n/).entries()) {
|
||||
const line = document.createElement("span");
|
||||
line.className = `source-line${index + 1 === targetLine ? " target" : ""}`;
|
||||
line.dataset.line = String(index + 1);
|
||||
line.textContent = text || " ";
|
||||
code.append(line);
|
||||
}
|
||||
$("source-dialog-label").textContent = data.source_anchor
|
||||
? `${data.source_path} · ${data.source_anchor}`
|
||||
: data.source_path;
|
||||
const dialog = $("source-dialog");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
requestAnimationFrame(() => {
|
||||
code.querySelector(".target")?.scrollIntoView({block: "center"});
|
||||
});
|
||||
setStatus(`Opened ${data.source_path} at ${data.source_anchor || "the first line"}.`);
|
||||
} catch (error) {
|
||||
setStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
function renderDetails(details, node, data, interactiveBadges = false, includeContent = true) {
|
||||
details.replaceChildren();
|
||||
const heading = document.createElement("div");
|
||||
|
|
@ -2491,7 +2715,17 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
const dt = document.createElement("dt");
|
||||
const dd = document.createElement("dd");
|
||||
dt.textContent = label;
|
||||
dd.textContent = escapeText(value);
|
||||
if (label === "Source") {
|
||||
const source = document.createElement("button");
|
||||
source.type = "button";
|
||||
source.className = "badge badge-button";
|
||||
source.textContent = escapeText(value);
|
||||
source.title = `Open ${value} at ${node.source_anchor || "the first line"}`;
|
||||
source.addEventListener("click", () => openSource(node.node_id));
|
||||
dd.append(source);
|
||||
} else {
|
||||
dd.textContent = escapeText(value);
|
||||
}
|
||||
row.append(dt, dd);
|
||||
dl.append(row);
|
||||
}
|
||||
|
|
@ -2536,6 +2770,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
const data = await api(`node?${params}`);
|
||||
state.cardNode = nodeId;
|
||||
renderDetails($("node-card-details"), data.node, data, true, false);
|
||||
$("hide-card-node").disabled = nodeId === state.root;
|
||||
$("hide-card-node").title = nodeId === state.root
|
||||
? "Focus another node before hiding this one"
|
||||
: "Hide this node from the current visualization";
|
||||
const dialog = $("node-card");
|
||||
if (!dialog.open) {
|
||||
dialog.style.visibility = "hidden";
|
||||
|
|
@ -2558,6 +2796,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
const data = await api(`node?${params}`);
|
||||
state.inspectedNode = nodeId;
|
||||
renderDetails($("node-dialog-details"), data.node, data);
|
||||
$("hide-node").disabled = nodeId === state.root;
|
||||
$("hide-node").title = nodeId === state.root
|
||||
? "Focus another node before hiding this one"
|
||||
: "Hide this node from the current visualization";
|
||||
$("node-dialog-label").textContent = short(data.node.title, 72);
|
||||
const dialog = $("node-dialog");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
|
|
@ -2734,8 +2976,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
$("zoom-in").addEventListener("click", () => zoomAt(.8));
|
||||
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
|
||||
$("reset-view").addEventListener("click", resetViewport);
|
||||
$("restore-hidden").addEventListener("click", restoreHiddenNodes);
|
||||
$("close-node-dialog").addEventListener("click", closeNodeDialog);
|
||||
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
|
||||
$("close-source-dialog").addEventListener("click", () => $("source-dialog").close());
|
||||
$("dismiss-source-dialog").addEventListener("click", () => $("source-dialog").close());
|
||||
setupPanelResizer("left");
|
||||
setupPanelResizer("right");
|
||||
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
|
||||
|
|
@ -2747,6 +2992,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
closeNodeDialog();
|
||||
if (nodeId) await loadNode(nodeId);
|
||||
});
|
||||
$("open-node-source").addEventListener("click", () => {
|
||||
if (state.inspectedNode) openSource(state.inspectedNode);
|
||||
});
|
||||
$("open-card-source").addEventListener("click", () => {
|
||||
if (state.cardNode) openSource(state.cardNode);
|
||||
});
|
||||
$("hide-node").addEventListener("click", () => {
|
||||
if (state.inspectedNode) hideNode(state.inspectedNode);
|
||||
});
|
||||
$("hide-card-node").addEventListener("click", () => {
|
||||
if (state.cardNode) hideNode(state.cardNode);
|
||||
});
|
||||
$("explore-card-node").addEventListener("click", async () => {
|
||||
const nodeId = state.cardNode;
|
||||
closeNodeCard();
|
||||
|
|
@ -2776,13 +3033,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && $("source-dialog").open) {
|
||||
event.preventDefault();
|
||||
$("source-dialog").close();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && $("node-card").open) {
|
||||
event.preventDefault();
|
||||
closeNodeCard();
|
||||
return;
|
||||
}
|
||||
if (event.code !== "Space" || event.defaultPrevented
|
||||
|| $("node-dialog").open || $("node-card").open) {
|
||||
|| $("node-dialog").open || $("node-card").open || $("source-dialog").open) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue