diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 80a1d27510171dbe6304dd6902a52f1f6daf03e1..cb693bfdc8eeef3d0c17b1ad813e1854e955d82e 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,8 +1,9 @@ -/* eslint-disable func-names, consistent-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; +import { ENTER_KEY, NUMPAD_ENTER_KEY } from '~/lib/utils/keys'; import { __, sprintf } from '~/locale'; import Raphael from './raphael'; @@ -16,6 +17,7 @@ export default class BranchGraph { this.scrollLeft = this.scrollLeft.bind(this); this.scrollUp = this.scrollUp.bind(this); this.scrollDown = this.scrollDown.bind(this); + this.scrollHandler = this.handleScroll.bind(this); this.preparedCommits = {}; this.mtime = 0; this.mspace = 0; @@ -26,6 +28,7 @@ export default class BranchGraph { this.unitTime = 30; this.unitSpace = 10; this.prev_start = -1; + this.isDestroyed = false; this.load(); } @@ -174,10 +177,57 @@ export default class BranchGraph { } } + handleScroll() { + if (this.isDestroyed) return; + + // Throttle scroll events + if (this.scrollTimeout) return; + + // Tested with _.debounce, but the redraw was not as smooth or consistent + this.scrollTimeout = setTimeout(() => { + this.renderPartialGraph(); + this.scrollTimeout = null; + }, 16); // 1000 milliseconds / 60 frames = 16.66... milliseconds per frame + } + bindEvents() { - const { element } = this; + if (this.isDestroyed) return; + this.element.on('scroll', this.scrollHandler); + return () => this.unbindEvents(); + } - return $(element).scroll(() => this.renderPartialGraph()); + unbindEvents() { + if (this.element) { + this.element.off('scroll', this.scrollHandler); + } + + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + this.scrollTimeout = null; + } + } + + destroy() { + this.isDestroyed = true; + this.unbindEvents(); + + // Clean up Raphael for GC + if (this.r) { + this.r.remove(); + this.r = null; + } + + // Clean up data structures for GC + this.preparedCommits = null; + this.parents = null; + this.commits = null; + this.element = null; + this.options = null; + } + + removeTooltip() { + this.top.remove(this.tooltip); + return this.tooltip && this.tooltip.remove() && delete this.tooltip; } scrollDown() { @@ -208,6 +258,21 @@ export default class BranchGraph { return this.element.scrollTop(0); } + /** + * Shows a tooltip for a commit at the specified position + * @param {Object} options - The tooltip configuration options + * @param {number} options.x - The x-coordinate for tooltip positioning + * @param {number} options.y - The y-coordinate for tooltip positioning + * @param {Object} options.commit - The commit object containing commit data + * @returns {Object} The tooltip element brought to front + */ + showTooltip(options) { + const { x, y, commit } = options; + this.tooltip = this.r.commitTooltip(x + 5, y, commit); + this.top.push(this.tooltip.insertBefore(this.node)); + return this.tooltip.toFront(); + } + appendLabel(x, y, commit) { if (!commit.refs) { return; @@ -245,25 +310,60 @@ export default class BranchGraph { } appendAnchor(x, y, commit) { - const { r, top, options } = this; - r.circle(x, y, 10) - .attr({ - fill: '#000', - opacity: 0, - cursor: 'pointer', - }) - .click(() => visitUrl(options.commit_url.replace('%s', commit.id), true)) - .hover( - function () { - this.tooltip = r.commitTooltip(x + 5, y, commit); - top.push(this.tooltip.insertBefore(this)); - return this.tooltip.toFront(); - }, - function () { - top.remove(this.tooltip); - return this.tooltip && this.tooltip.remove() && delete this.tooltip; - }, - ); + const { r, options } = this; + const circle = r.circle(x, y, 10); + + circle.attr({ + fill: '#000', + opacity: 0, + cursor: 'pointer', + }); + + const { node } = circle; + node.setAttribute('tabindex', '0'); + node.setAttribute('role', 'link'); + node.setAttribute( + 'aria-label', + sprintf(__('%{commitMessage}, by %{authorName}. Opens in a new window.'), { + commitMessage: commit.message.split('\n', 1)[0], + authorName: commit.author.name || commit.authorName, + }), + ); + + // Create a single unified event handler instead of multiple functions + // This reduces memory overhead from multiple function closures + const handleInteraction = (e) => { + const { type, key, keyCode } = e; + const normalizedEnterKey = ENTER_KEY || NUMPAD_ENTER_KEY; + + switch (type) { + case 'focus': + case 'mouseover': + this.showTooltip({ x, y, commit }); + break; + case 'blur': + case 'mouseout': + this.removeTooltip(); + break; + case 'keydown': + if (key === normalizedEnterKey || keyCode === 13) { + visitUrl(options.commit_url.replace('%s', commit.id), true); + } + break; + case 'click': + visitUrl(options.commit_url.replace('%s', commit.id), true); + break; + default: + break; + } + }; + + // Add all event listeners using the same handler function + // This is more memory efficient than creating separate handler functions + const events = ['focus', 'blur', 'keydown', 'mouseover', 'mouseout', 'click']; + for (let i = 0; i < events.length; i += 1) { + node.addEventListener(events[i], handleInteraction); + } } drawDot(x, y, commit) { @@ -275,28 +375,13 @@ export default class BranchGraph { const avatarBoxX = this.offsetX + this.unitSpace * this.mspace + 10; const avatarBoxY = y - 10; - const commitAuthorName = sprintf(__('Authored by %{authorName}'), { - authorName: commit.author.name, - }); - const image = r.image(commit.author.icon, avatarBoxX, avatarBoxY, 20, 20); + r.image(commit.author.icon, avatarBoxX, avatarBoxY, 20, 20); r.rect(avatarBoxX, avatarBoxY, 20, 20).attr({ stroke: this.colors[commit.space], 'stroke-width': 2, }); - /** - * Must set a constant when making multiple node() calls - * @see https://svgwg.org/svg2-draft/struct.html#TermCoreAttribute - * - * Tested and verified working in the following screen readers: - * MacOS - Safari, Chrome + VoiceOver - * Win11 - Chrome + NVDA, JAWS - */ - image.node.setAttribute('aria-label', commitAuthorName); - - image.node.setAttribute('role', 'img'); - return r .text(this.offsetX + this.unitSpace * this.mspace + 40, y, commit.message.split('\n')[0]) .attr({ diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 10fa77267ce866d2ed7ec50196865eed9dc25e9d..1cd785cae6dfc5640ba9dc4db8912231131e5c52 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -8,9 +8,14 @@ export default class Network { this.opts = opts; this.filter_ref = $('#filter_ref'); this.network_graph = $('.network-graph'); + this.network_graph.css({ height: `${vph}px` }); this.filter_ref.click(() => this.submit()); this.branch_graph = new BranchGraph(this.network_graph, this.opts); - this.network_graph.css({ height: `${vph}px` }); + this.resetBodyStyles(); + } + + // eslint-disable-next-line class-methods-use-this + resetBodyStyles() { $('body').css({ 'overflow-y': 'hidden' }); $('.content-wrapper').css({ 'padding-bottom': 0 }); } @@ -18,4 +23,11 @@ export default class Network { submit() { return this.filter_ref.closest('form').submit(); } + + destroy() { + if (this.branch_graph) { + this.branch_graph.destroy(); + this.resetBodyStyles(); + } + } } diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 58b703bdfdab6acb1ab22393a138fd531f24bc32..e40a0e52e902f8e599048d89795f13f592bed32b 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -36,14 +36,29 @@ const initRefSwitcher = () => { initRefSwitcher(); (() => { - if (!$('.network-graph').length) return; + let networkGraph = null; - const networkGraph = new Network({ - url: $('.network-graph').attr('data-url'), - commit_url: $('.network-graph').attr('data-commit-url'), - ref: $('.network-graph').attr('data-ref'), - commit_id: $('.network-graph').attr('data-commit-id'), - }); + const initNetworkGraph = () => { + if (!$('.network-graph').length) return; + + networkGraph = new Network({ + url: $('.network-graph').attr('data-url'), + commit_url: $('.network-graph').attr('data-commit-url'), + ref: $('.network-graph').attr('data-ref'), + commit_id: $('.network-graph').attr('data-commit-id'), + }); + + addShortcutsExtension(ShortcutsNetwork, networkGraph.branch_graph); + }; + + const cleanupNetworkGraph = () => { + if (networkGraph) { + networkGraph.destroy(); + networkGraph = null; + } + }; + + initNetworkGraph(); - addShortcutsExtension(ShortcutsNetwork, networkGraph.branch_graph); + window.addEventListener('beforeunload', cleanupNetworkGraph); })(); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b002d721a5106ffec42ae9435950882321abac1f..a6ac48e76d8695e032c0a9f6f372adcc185bbce3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -702,6 +702,9 @@ msgstr "" msgid "%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable." msgstr "" +msgid "%{commitMessage}, by %{authorName}. Opens in a new window." +msgstr "" + msgid "%{commit_author_link} authored %{commit_authored_timeago}" msgstr "" @@ -9562,9 +9565,6 @@ msgstr "" msgid "Authored %{timeago} by %{author}" msgstr "" -msgid "Authored by %{authorName}" -msgstr "" - msgid "Authorization code:" msgstr ""