dots-viewer: Refactor JavaScript into modular architecture

Split monolithic embedded JavaScript in overlay.html into dedicated modules:
- tooltip.js: Custom tooltip functionality with interactive copy/paste mode
- pipeline-navigation.js: Clickable pipeline-dot references for navigation
- text-ellipsizer.js: Text ellipsizing with tooltip integration
- svg-overlay-manager.js: Main coordinator orchestrating all functionality

Part-of: <https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/9547>
This commit is contained in:
Thibault Saunier 2025-08-10 10:55:47 -04:00 committed by GStreamer Marge Bot
parent 4d5fa1c43b
commit d3ad6136ef
5 changed files with 623 additions and 127 deletions

View File

@ -0,0 +1,125 @@
/**
* Pipeline dot navigation functionality
*
* Makes pipeline-dot references clickable for navigation between pipeline views.
* Handles URL generation and navigation for pipeline dot references.
*/
class PipelineNavigationManager {
constructor(tooltipManager) {
this.tooltipManager = tooltipManager;
}
/**
* Processes all pipeline-dot references in SVG to make them clickable
* @param {jQuery} $svg - jQuery object containing the SVG element
*/
setupPipelineDotNavigation($svg) {
$svg.find(".cluster").each((index, cluster) => {
$(cluster).find("text, tspan").each((index, element) => {
const text = element.textContent;
if (this.isPipelineDotReference(text)) {
this.makePipelineDotClickable(element, text);
}
});
});
}
/**
* Checks if text content is a pipeline-dot reference
* @param {string} text - Text content to check
* @returns {boolean} True if text is a pipeline-dot reference
*/
isPipelineDotReference(text) {
return text && text.startsWith("pipeline-dot=") && text.includes(".dot");
}
/**
* Makes a pipeline-dot element clickable with proper styling and handlers
* @param {Element} element - DOM element to make clickable
* @param {string} text - Original text content
*/
makePipelineDotClickable(element, text) {
const pipelineDot = this.extractPipelineDotFilename(text);
// Style as a clickable link
$(element).css({
'text-decoration': 'underline',
'cursor': 'pointer',
'color': '#007acc'
});
// Add click handler for navigation
$(element).off('click.pipeline-nav').on('click.pipeline-nav', (evt) => {
evt.preventDefault();
evt.stopPropagation();
// Only hide tooltip if it's not in interactive mode
if (this.tooltipManager && !this.tooltipManager.isInteractive()) {
this.tooltipManager.hideTooltip();
}
this.navigateToPipeline(pipelineDot);
});
// Add hover effects
this.setupHoverEffects(element);
}
/**
* Extracts the pipeline dot filename from the full text
* @param {string} text - Full pipeline-dot text
* @returns {string} Extracted filename
*/
extractPipelineDotFilename(text) {
let pipelineDot = text;
if (pipelineDot.includes("pipeline-dot=\"")) {
pipelineDot = pipelineDot.replace(/pipeline-dot="([^"]+)"/, "$1");
} else {
pipelineDot = pipelineDot.replace(/pipeline-dot=([^\s]+)/, "$1");
}
return pipelineDot;
}
/**
* Sets up hover effects for clickable pipeline-dot links
* @param {Element} element - DOM element to add hover effects to
*/
setupHoverEffects(element) {
$(element).on('mouseenter', function () {
$(this).css({
'color': '#0056b3',
'cursor': 'pointer'
});
}).on('mouseleave', function () {
$(this).css({
'color': '#007acc',
'cursor': 'pointer'
});
});
}
/**
* Navigates to the specified pipeline
* @param {string} pipelineDot - Pipeline dot filename to navigate to
*/
navigateToPipeline(pipelineDot) {
// Check if we're in an iframe (overlay.html context)
if (window.parent !== window) {
// We're in an iframe, navigate the parent window
const newUrl = window.parent.location.origin + "/?pipeline=" + encodeURIComponent(pipelineDot);
window.parent.location.href = newUrl;
} else {
// We're in the main window
const newUrl = window.location.origin + "/?pipeline=" + encodeURIComponent(pipelineDot);
window.location.href = newUrl;
}
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = PipelineNavigationManager;
} else {
window.PipelineNavigationManager = PipelineNavigationManager;
}

View File

@ -0,0 +1,173 @@
/**
* SVG Overlay Manager
*
* Main coordinator for all SVG overlay functionality including tooltips,
* text ellipsizing, pipeline navigation, and drag-to-pan interaction.
*/
class SvgOverlayManager {
constructor() {
this.tooltipManager = null;
this.pipelineNavigationManager = null;
this.textEllipsizerManager = null;
this.gv = null;
}
/**
* Initializes all managers and sets up the SVG overlay functionality
*/
init() {
// Initialize managers
this.tooltipManager = new TooltipManager();
this.pipelineNavigationManager = new PipelineNavigationManager(this.tooltipManager);
this.textEllipsizerManager = new TextEllipsizerManager(this.tooltipManager, this.pipelineNavigationManager);
// Set up GraphViz SVG functionality
this.setupGraphvizSvg();
}
/**
* Sets up GraphViz SVG functionality and event handlers
*/
setupGraphvizSvg() {
$("#graph").graphviz({
url: this.getSvgUrl(),
ready: () => {
this.onSvgReady();
}
});
}
/**
* Called when SVG is loaded and ready
*/
onSvgReady() {
this.gv = $("#graph").data('graphviz.svg');
const $svg = $("#graph svg");
// Set up node click functionality for highlighting
this.setupNodeHighlighting();
// Set up smart drag-to-pan behavior that doesn't interfere with text selection
this.setupSmartDragBehavior();
// Set up keyboard shortcuts
this.setupKeyboardShortcuts();
// Set up save SVG functionality
this.setupSaveSvg();
// Process SVG content
this.processSvgContent($svg);
}
/**
* Processes SVG content with all managers
* @param {jQuery} $svg - jQuery object containing the SVG element
*/
processSvgContent($svg) {
// Set up pipeline-dot navigation
this.pipelineNavigationManager.setupPipelineDotNavigation($svg);
// Process text ellipsizing (this must come after pipeline navigation setup)
this.textEllipsizerManager.ellipsizeLongText($svg);
}
/**
* Sets up node click functionality for highlighting connected nodes
*/
setupNodeHighlighting() {
this.gv.nodes().on('click', function () {
const gv = $("#graph").data('graphviz.svg');
let $set = $();
$set.push(this);
$set = $set.add(gv.linkedFrom(this, true));
$set = $set.add(gv.linkedTo(this, true));
gv.highlight($set, true);
gv.bringToFront($set);
});
}
/**
* Sets up smart drag behavior that allows text selection while preserving pan functionality
*/
setupSmartDragBehavior() {
const graphDiv = document.getElementById('graph');
// Intercept mousedown events to prevent dragscroll on text elements
graphDiv.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'text' || e.target.tagName === 'tspan') {
if (e.target.textContent &&
e.target.textContent.startsWith("pipeline-dot=") &&
e.target.textContent.includes(".dot")) {
return; // Let pipeline dot click handler take precedence
}
e.stopPropagation(); // Stop dragscroll for regular text
return true;
}
}, true);
}
/**
* Sets up keyboard shortcuts for the SVG viewer
*/
setupKeyboardShortcuts() {
$(document).on('keyup', (evt) => {
if (evt.key == "Escape") {
this.gv.highlight();
} else if (evt.key == "w") {
this.gv.scaleInView((this.gv.zoom.percentage + 100));
} else if (evt.key == "s") {
this.gv.scaleInView((this.gv.zoom.percentage - 100) || 100);
}
});
}
/**
* Sets up SVG save functionality
*/
setupSaveSvg() {
$("#save-svg").on('click', () => {
const svgElement = $("#graph svg")[0];
const svgData = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const title = document.getElementById("title").textContent.trim();
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = title + ".svg";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
});
}
/**
* Gets the SVG URL from query parameters
* @returns {string} SVG URL
*/
getSvgUrl() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('svg');
}
/**
* Gets the title from query parameters and sets it in the document
*/
setTitle() {
const urlParams = new URLSearchParams(window.location.search);
const title = urlParams.get('title');
if (title) {
document.getElementById("title").textContent = title;
document.title = "Dots viewer: " + title;
}
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = SvgOverlayManager;
} else {
window.SvgOverlayManager = SvgOverlayManager;
}

View File

@ -0,0 +1,160 @@
/**
* Text ellipsizing functionality for SVG elements
*
* Ellipsizes long text content (>80 chars) and provides tooltips with full content.
* Integrates with tooltip system for interactive copy/paste functionality.
*/
class TextEllipsizerManager {
constructor(tooltipManager, pipelineNavigationManager) {
this.tooltipManager = tooltipManager;
this.pipelineNavigationManager = pipelineNavigationManager;
this.maxLength = 80;
}
/**
* Processes all text elements in SVG to ellipsize long content
* @param {jQuery} $svg - jQuery object containing the SVG element
*/
ellipsizeLongText($svg) {
$svg.find("text, tspan").each((index, element) => {
const text = element.textContent;
if (this.shouldEllipsize(text)) {
this.ellipsizeElement(element, text);
}
});
}
/**
* Determines if text should be ellipsized
* @param {string} text - Text content to check
* @returns {boolean} True if text should be ellipsized
*/
shouldEllipsize(text) {
return text && text.length > this.maxLength;
}
/**
* Ellipsizes an element and sets up tooltip functionality
* @param {Element} element - DOM element to ellipsize
* @param {string} originalText - Original full text content
*/
ellipsizeElement(element, originalText) {
const ellipsizedText = originalText.substring(0, 77) + "...";
// Update the text content
$(element).text(ellipsizedText);
// Store original text and mark as having tooltip
$(element).data('original-text', originalText);
$(element).attr('data-has-tooltip', 'true');
// Style to indicate there's more content
$(element).css({
'cursor': 'help'
});
this.setupTooltipHandlers(element, originalText);
}
/**
* Sets up tooltip handlers for ellipsized elements
* @param {Element} element - DOM element to add handlers to
* @param {string} originalText - Original full text content
*/
setupTooltipHandlers(element, originalText) {
const isPipelineDot = this.pipelineNavigationManager.isPipelineDotReference(originalText);
if (!isPipelineDot) {
this.setupRegularTooltipHandlers(element, originalText);
} else {
this.setupPipelineDotTooltipHandlers(element, originalText);
}
}
/**
* Sets up tooltip handlers for regular (non-pipeline-dot) elements
* @param {Element} element - DOM element to add handlers to
* @param {string} originalText - Original full text content
*/
setupRegularTooltipHandlers(element, originalText) {
$(element).on('mouseenter', (e) => {
this.tooltipManager.showTooltip(element, originalText, e);
});
$(element).on('mousemove', (e) => {
if (!this.tooltipManager.isInteractive()) {
this.tooltipManager.showTooltip(element, originalText, e);
}
});
$(element).on('mouseleave.tooltip', () => {
// Don't hide tooltip on mouseleave if it's interactive
if (!this.tooltipManager.isInteractive()) {
this.tooltipManager.hideTooltip();
}
});
// Double-click to make tooltip interactive
$(element).on('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.tooltipManager.$tooltip.hasClass('show')) {
this.tooltipManager.makeTooltipInteractive();
}
});
}
/**
* Sets up tooltip handlers for pipeline-dot elements (with navigation functionality)
* @param {Element} element - DOM element to add handlers to
* @param {string} originalText - Original full text content
*/
setupPipelineDotTooltipHandlers(element, originalText) {
// Make it clickable for navigation
this.pipelineNavigationManager.makePipelineDotClickable(element, originalText);
// Add tooltip functionality
$(element).on('mouseenter', (e) => {
$(element).css({
'color': '#0056b3',
'cursor': 'pointer'
});
this.tooltipManager.showTooltip(element, originalText, e);
});
$(element).on('mousemove', (e) => {
if (!this.tooltipManager.isInteractive()) {
this.tooltipManager.showTooltip(element, originalText, e);
}
});
$(element).on('mouseleave.tooltip', () => {
$(element).css({
'color': '#007acc',
'cursor': 'pointer'
});
// Don't hide tooltip on mouseleave if it's interactive
if (!this.tooltipManager.isInteractive()) {
this.tooltipManager.hideTooltip();
}
});
// Right-click to make tooltip interactive (since left-click navigates)
$(element).on('contextmenu', (e) => {
if (this.tooltipManager.$tooltip.hasClass('show')) {
e.preventDefault();
e.stopPropagation();
this.tooltipManager.makeTooltipInteractive();
}
});
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = TextEllipsizerManager;
} else {
window.TextEllipsizerManager = TextEllipsizerManager;
}

View File

@ -0,0 +1,118 @@
/**
* Custom tooltip functionality for SVG elements
*
* Provides hoverable tooltips with interactive copy/paste mode for ellipsized text.
* Interactive mode allows users to select and copy tooltip content.
*/
class TooltipManager {
constructor() {
this.$tooltip = null;
this.tooltipText = '';
this.isTooltipInteractive = false;
this.currentTooltipElement = null;
this.init();
}
init() {
// Create custom tooltip element
this.$tooltip = $('<div class="custom-tooltip"></div>').appendTo('body');
// Set up document click handler for hiding tooltips
this.setupDocumentClickHandler();
}
/**
* Shows tooltip with given text at event position
* @param {Element} element - Element that triggered the tooltip
* @param {string} text - Text to display in tooltip
* @param {Event} event - Mouse event for positioning
*/
showTooltip(element, text, event) {
this.tooltipText = text;
this.currentTooltipElement = element;
this.$tooltip.text(text);
this.$tooltip.css({
left: event.pageX + 10 + 'px',
top: event.pageY - 30 + 'px'
}).removeClass('interactive').addClass('show');
this.isTooltipInteractive = false;
}
/**
* Makes tooltip interactive (selectable and fixed position)
*/
makeTooltipInteractive() {
if (!this.isTooltipInteractive && this.currentTooltipElement) {
this.$tooltip.addClass('interactive');
this.isTooltipInteractive = true;
// Position tooltip in a fixed position relative to the element
const elementOffset = $(this.currentTooltipElement).offset();
this.$tooltip.css({
left: Math.min(elementOffset.left + 20, window.innerWidth - 420) + 'px',
top: Math.max(elementOffset.top - 80, 10) + 'px',
'pointer-events': 'auto'
});
// Prevent mouseleave from hiding the tooltip by removing the handler temporarily
$(this.currentTooltipElement).off('mouseleave.tooltip');
// Select all text when made interactive
setTimeout(() => {
const range = document.createRange();
range.selectNodeContents(this.$tooltip[0]);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}, 10);
}
}
/**
* Hides the tooltip and resets state
*/
hideTooltip() {
this.$tooltip.removeClass('show interactive');
this.isTooltipInteractive = false;
this.currentTooltipElement = null;
}
/**
* Sets up document click handler to hide tooltips when clicking outside
*/
setupDocumentClickHandler() {
$(document).on('click', (e) => {
if (this.isTooltipInteractive) {
// Only hide interactive tooltip when clicking outside both tooltip and original element
if (!$(e.target).closest('.custom-tooltip').length &&
!$(e.target).is('[data-has-tooltip]') &&
e.target !== this.currentTooltipElement) {
this.hideTooltip();
}
} else {
// Hide non-interactive tooltip when clicking anywhere except on elements with tooltips
if (!$(e.target).is('[data-has-tooltip]')) {
this.hideTooltip();
}
}
});
}
/**
* Checks if tooltip is currently in interactive mode
* @returns {boolean} True if tooltip is interactive
*/
isInteractive() {
return this.isTooltipInteractive;
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = TooltipManager;
} else {
window.TooltipManager = TooltipManager;
}

View File

@ -39,13 +39,51 @@ furnished to do so, subject to the following conditions:
text-align: left;
}
.custom-tooltip {
position: absolute;
background-color: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
max-width: 400px;
word-wrap: break-word;
white-space: normal;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
.custom-tooltip.show {
opacity: 1;
}
.custom-tooltip.interactive {
pointer-events: auto;
cursor: text;
border: 2px solid #555;
}
.custom-tooltip.interactive::after {
content: " (Text selected - Ctrl+C to copy)";
font-size: 10px;
color: #aaa;
font-style: italic;
}
</style>
<body>
<h1 style="text-align: center" id="title"> {{ TITLE }}</h1>
<div id="graph" class="dragscroll" style="width: 100%; height: 100%; overflow: scroll;"></div>
<div class="floating-rectangle" id="instructions">
Click node to highlight<br/>Shift-Ctrl-scroll or w/s to zoom<br/>Esc to unhighlight
Click node to highlight<br/>Shift-Ctrl-scroll or w/s to zoom<br/>Esc to unhighlight<br/>Double-click ellipsized text to copy
</div>
<div class="floating-rectangle" id="actions" style="top: auto; bottom: 100px; left: 10px;">
@ -54,133 +92,15 @@ furnished to do so, subject to the following conditions:
<script src='/dist/bundle.js'></script>
<script type="text/javascript" src="/js/jquery.graphviz.svg.js"></script>
<script type="text/javascript" src="/js/tooltip.js"></script>
<script type="text/javascript" src="/js/pipeline-navigation.js"></script>
<script type="text/javascript" src="/js/text-ellipsizer.js"></script>
<script type="text/javascript" src="/js/svg-overlay-manager.js"></script>
<script type="text/javascript">
let url = new URL(window.location.href);
let searchParams = new URLSearchParams(url.search);
document.getElementById("title").innerHTML = searchParams.get("title");
// Make text selection work while preserving drag functionality
function setupTextSelectionWithDrag() {
const graphDiv = document.getElementById('graph');
graphDiv.addEventListener('mousedown', function(e) {
if (e.target.tagName === 'text' || e.target.tagName === 'tspan') {
if (e.target.textContent && e.target.textContent.startsWith("pipeline-dot=") && e.target.textContent.includes(".dot")) {
// Let the pipeline dot click handler take precedence
return;
}
e.stopPropagation();
// Allow normal text selection behavior
return true;
}
}, true);
graphDiv.addEventListener('mouseover', function(e) {
if (e.target.tagName === 'text' || e.target.tagName === 'tspan') {
if (!(e.target.textContent && e.target.textContent.startsWith("pipeline-dot=") && e.target.textContent.includes(".dot"))) {
e.target.style.cursor = 'text';
}
}
});
graphDiv.addEventListener('mouseout', function(e) {
if (e.target.tagName === 'text' || e.target.tagName === 'tspan') {
if (!(e.target.textContent && e.target.textContent.startsWith("pipeline-dot=") && e.target.textContent.includes(".dot"))) {
e.target.style.cursor = '';
}
}
});
}
$(document).ready(function(){
// Setup text selection behavior
setupTextSelectionWithDrag();
$("#graph").graphviz({
url: searchParams.get("svg"),
ready: function() {
var gv = this;
gv.nodes().click(function () {
var $set = $();
$set.push(this);
$set = $set.add(gv.linkedFrom(this, true));
$set = $set.add(gv.linkedTo(this, true));
gv.highlight($set, true);
gv.bringToFront($set);
});
$(document).keydown(function (evt) {
if (evt.key == "Escape") {
gv.highlight();
} else if (evt.key == "w") {
gv.scaleInView((gv.zoom.percentage + 100));
} else if (evt.key == "s") {
gv.scaleInView((gv.zoom.percentage - 100) || 100);
}
});
$("#save-svg").click(function() {
const svgElement = $("#graph svg")[0];
const svgData = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgData], {type: "image/svg+xml;charset=utf-8"});
const url = URL.createObjectURL(blob);
const title = document.getElementById("title").textContent.trim();
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = title + ".svg";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
});
// Make pipeline dot references clickable for navigation between pipeline views
$("#graph svg .cluster").each(function() {
$(this).find("text, tspan").each(function() {
var element = this;
if (element.textContent && element.textContent.startsWith("pipeline-dot=") && element.textContent.includes(".dot")) {
// Extract the pipeline dot filename from the text
var pipelineDot = element.textContent;
if (pipelineDot.includes("pipeline-dot=\"")) {
pipelineDot = pipelineDot.replace(/pipeline-dot="([^"]+)"/, "$1");
} else {
pipelineDot = pipelineDot.replace(/pipeline-dot=([^\s]+)/, "$1");
}
// Style as a clickable link
$(element).css({
'text-decoration': 'underline',
'cursor': 'pointer',
'color': '#007acc'
});
// Add click handler for navigation
$(element).off('click.pipeline-nav').on('click.pipeline-nav', function(evt) {
evt.preventDefault();
evt.stopPropagation();
var newUrl = window.location.origin + "/?pipeline=" + encodeURIComponent(pipelineDot);
window.location.href = newUrl;
});
// Add hover effects
$(element).hover(
function() {
$(this).css({
'color': '#0056b3',
'cursor': 'pointer'
});
},
function() {
$(this).css({
'color': '#007acc',
'cursor': 'pointer'
});
}
);
}
});
});
}
});
$(document).ready(() => {
const svgOverlayManager = new SvgOverlayManager();
svgOverlayManager.setTitle();
svgOverlayManager.init();
});
</script>
</body>