Remove the Raphael.js dependency by switching to SVG API (#43845).

Patch by Mizuki ISHIKAWA (user:ishikawa999).


git-svn-id: https://svn.redmine.org/redmine/trunk@24469 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Go MAEDA
2026-03-01 06:58:28 +00:00
parent 5d4a738692
commit df2257eff0
6 changed files with 250 additions and 89 deletions

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,88 @@
*/
var revisionGraph = null;
const SVG_NS = 'http://www.w3.org/2000/svg';
const XLINK_NS = 'http://www.w3.org/1999/xlink';
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function buildRevisionGraph(holder) {
const svg = createSvgElement('svg');
const pathsLayer = createSvgElement('g');
const nodesLayer = createSvgElement('g');
const overlaysLayer = createSvgElement('g');
svg.setAttribute('aria-hidden', 'true');
svg.style.display = 'block';
svg.style.overflow = 'visible';
svg.appendChild(pathsLayer);
svg.appendChild(nodesLayer);
svg.appendChild(overlaysLayer);
while (holder.firstChild) {
holder.removeChild(holder.firstChild);
}
holder.appendChild(svg);
return {
holder: holder,
svg: svg,
pathsLayer: pathsLayer,
nodesLayer: nodesLayer,
overlaysLayer: overlaysLayer
};
}
function clearRevisionGraph(graph) {
[graph.pathsLayer, graph.nodesLayer, graph.overlaysLayer].forEach(layer => layer.replaceChildren());
}
function setRevisionGraphSize(graph, width, height) {
const graphWidth = Math.max(1, Math.ceil(width));
const graphHeight = Math.max(1, Math.ceil(height));
graph.svg.setAttribute('width', graphWidth);
graph.svg.setAttribute('height', graphHeight);
graph.svg.setAttribute('viewBox', '0 0 ' + graphWidth + ' ' + graphHeight);
}
function drawPath(graph, pathData, attrs) {
const path = createSvgElement('path');
const d = pathData.map(function(item) { return String(item); }).join(' ');
path.setAttribute('d', d);
Object.keys(attrs).forEach(function(name) {
path.setAttribute(name, String(attrs[name]));
});
graph.pathsLayer.appendChild(path);
}
function drawCircle(layer, x, y, r, attrs) {
const circle = createSvgElement('circle');
circle.setAttribute('cx', String(x));
circle.setAttribute('cy', String(y));
circle.setAttribute('r', String(r));
Object.keys(attrs).forEach(function(name) {
circle.setAttribute(name, String(attrs[name]));
});
layer.appendChild(circle);
return circle;
}
// Generates a distinct, consistent HSL color for each index.
function colorBySpace(index) {
const hue = (index * 27) % 360;
const band = Math.floor(index / 14);
const saturation = Math.max(40, 72 - (band % 3) * 12);
const lightness = Math.min(56, 42 + (band % 2) * 6);
return 'hsl(' + hue + ', ' + saturation + '%, ' + lightness + '%)';
}
function drawRevisionGraph(holder, commits_hash, graph_space) {
var XSTEP = 20,
@@ -13,14 +95,17 @@ function drawRevisionGraph(holder, commits_hash, graph_space) {
commits = $.map(commits_by_scmid, function(val,i){return val;});
var max_rdmid = commits.length - 1;
var commit_table_rows = $('table.changesets tr.changeset');
if (!revisionGraph || revisionGraph.holder !== holder) {
revisionGraph = buildRevisionGraph(holder);
}
const graph = revisionGraph;
clearRevisionGraph(graph);
// create graph
if(revisionGraph != null)
revisionGraph.clear();
else
revisionGraph = Raphael(holder);
if (commit_table_rows.length === 0) {
setRevisionGraphSize(graph, 1, 1);
return;
}
var top = revisionGraph.set();
// init dimensions
var graph_x_offset = commit_table_rows.first().find('td').first().position().left - $(holder).position().left,
graph_y_offset = $(holder).position().top,
@@ -35,17 +120,16 @@ function drawRevisionGraph(holder, commits_hash, graph_space) {
case "middle":
return row.position().top + (row.height() / 2) - graph_y_offset;
default:
return row.position().top + - graph_y_offset + CIRCLE_INROW_OFFSET;
}
return row.position().top - graph_y_offset + CIRCLE_INROW_OFFSET;
}
};
revisionGraph.setSize(graph_right_side, graph_bottom);
setRevisionGraphSize(graph, graph_right_side, graph_bottom);
// init colors
var colors = [];
Raphael.getColor.reset();
for (var k = 0; k <= graph_space; k++) {
colors.push(Raphael.getColor());
for (let k = 0; k <= graph_space; k++) {
colors.push(colorBySpace(k));
}
var parent_commit;
@@ -58,11 +142,10 @@ function drawRevisionGraph(holder, commits_hash, graph_space) {
y = yForRow(max_rdmid - commit.rdmid);
x = graph_x_offset + XSTEP / 2 + XSTEP * commit.space;
revisionGraph.circle(x, y, 3)
.attr({
fill: colors[commit.space],
stroke: 'none'
}).toFront();
drawCircle(graph.nodesLayer, x, y, 3, {
fill: colors[commit.space],
stroke: 'none'
});
// check for parents in the same column
let noVerticalParents = true;
@@ -91,48 +174,52 @@ function drawRevisionGraph(holder, commits_hash, graph_space) {
if (parent_commit.space === commit.space) {
// vertical path
path = revisionGraph.path([
path = [
'M', x, y,
'V', parent_y]);
'V', parent_y];
} else if (noVerticalParents) {
// branch start (Bezier curve)
path = revisionGraph.path([
path = [
'M', x, y,
'C', x, y + controlPointDelta, x, parent_y - controlPointDelta, parent_x, parent_y]);
'C', x, y + controlPointDelta, x, parent_y - controlPointDelta, parent_x, parent_y];
} else if (!parent_commit.hasOwnProperty('vertical_children')) {
// branch end (Bezier curve)
path = revisionGraph.path([
path = [
'M', x, y,
'C', parent_x, y + controlPointDelta, parent_x, parent_y, parent_x, parent_y]);
'C', parent_x, y + controlPointDelta, parent_x, parent_y, parent_x, parent_y];
} else {
// path to a commit in a different branch (Bezier curve)
path = revisionGraph.path([
path = [
'M', x, y,
'C', parent_x, y, x, parent_y, parent_x, parent_y]);
'C', parent_x, y, x, parent_y, parent_x, parent_y];
}
} else {
// vertical path ending at the bottom of the revisionGraph
path = revisionGraph.path([
path = [
'M', x, y,
'V', graph_bottom]);
'V', graph_bottom];
}
path.attr({stroke: colors[commit.space], "stroke-width": 1.5}).toBack();
drawPath(graph, path, {stroke: colors[commit.space], 'stroke-width': 1.5, fill: 'none'});
});
let overlayLayer = graph.overlaysLayer;
if (commit.href) {
overlayLayer = createSvgElement('a');
overlayLayer.setAttribute('href', commit.href);
overlayLayer.setAttributeNS(XLINK_NS, 'href', commit.href);
graph.overlaysLayer.appendChild(overlayLayer);
}
revision_dot_overlay = drawCircle(overlayLayer, x, y, 10, {
fill: '#000',
opacity: 0,
cursor: commit.href ? 'pointer' : 'default'
});
revision_dot_overlay = revisionGraph.circle(x, y, 10);
revision_dot_overlay
.attr({
fill: '#000',
opacity: 0,
cursor: 'pointer',
href: commit.href
});
if(commit.refs != null && commit.refs.length > 0) {
title = document.createElementNS(revisionGraph.canvas.namespaceURI, 'title');
title = createSvgElement('title');
title.appendChild(document.createTextNode(commit.refs));
revision_dot_overlay.node.appendChild(title);
revision_dot_overlay.appendChild(title);
}
top.push(revision_dot_overlay);
});
top.toFront();
};

View File

@@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus"
const RELATION_STROKE_WIDTH = 2
const SVG_NS = "http://www.w3.org/2000/svg"
export default class extends Controller {
static targets = ["ganttArea", "drawArea", "subjectsContainer"]
@@ -16,10 +17,10 @@ export default class extends Controller {
#drawRight = 0
#drawLeft = 0
#drawPaper = null
#drawPaperGroup = null
initialize() {
this.$ = window.jQuery
this.Raphael = window.Raphael
}
connect() {
@@ -35,6 +36,7 @@ export default class extends Controller {
if (this.#drawPaper) {
this.#drawPaper.remove()
this.#drawPaper = null
this.#drawPaperGroup = null
}
}
@@ -73,13 +75,9 @@ export default class extends Controller {
}
#drawProgressLineAndRelations() {
if (this.#drawPaper) {
this.#drawPaper.clear()
} else {
this.#drawPaper = this.Raphael(this.drawAreaTarget)
}
this.#setupDrawArea()
this.#setupDrawPaper()
this.#drawPaperGroup?.replaceChildren(); // Clear previous drawings
if (this.showProgressValue) {
this.#drawGanttProgressLines()
@@ -91,6 +89,42 @@ export default class extends Controller {
}
#setupDrawPaper() {
const width = Math.ceil(this.$(this.drawAreaTarget).width() || 0)
const height = Math.ceil(this.$(this.drawAreaTarget).height() || 0)
if (!this.#drawPaper) {
this.#drawPaper = document.createElementNS(SVG_NS, "svg")
this.#drawPaper.setAttribute("aria-hidden", "true")
this.#drawPaper.style.position = "absolute"
this.#drawPaper.style.inset = "0"
this.#drawPaper.style.pointerEvents = "none"
this.#drawPaperGroup = document.createElementNS(SVG_NS, "g")
this.#drawPaper.appendChild(this.#drawPaperGroup)
this.drawAreaTarget.appendChild(this.#drawPaper)
}
const safeWidth = Math.max(width, 1)
const safeHeight = Math.max(height, 1)
this.#drawPaper.setAttribute("width", String(safeWidth))
this.#drawPaper.setAttribute("height", String(safeHeight))
this.#drawPaper.setAttribute("viewBox", `0 0 ${safeWidth} ${safeHeight}`)
}
#drawPath(pathData, attributes = {}) {
if (!this.#drawPaperGroup) return
const path = document.createElementNS(SVG_NS, "path")
path.setAttribute("d", pathData.map((item) => String(item)).join(" "))
Object.entries(attributes).forEach(([name, value]) => {
path.setAttribute(name, String(value))
})
this.#drawPaperGroup.appendChild(path)
}
#setupDrawArea() {
const $drawArea = this.$(this.drawAreaTarget)
const $ganttArea = this.hasGanttAreaTarget ? this.$(this.ganttAreaTarget) : null
@@ -168,83 +202,90 @@ export default class extends Controller {
const issueFromRightRel = issueFromRight + landscapeMargin
const issueToLeftRel = issueToLeft - landscapeMargin
this.#drawPaper
.path([
this.#drawPath(
[
"M",
issueFromRight + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueFromTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
if (issueFromRightRel < issueToLeftRel) {
this.#drawPaper
.path([
this.#drawPath(
[
"M",
issueFromRightRel + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
this.#drawPath(
[
"M",
issueFromRightRel + this.#drawLeft,
issueToTop,
"L",
issueToLeft + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
} else {
const issueMiddleTop = issueToTop + issueHeight * (issueFromTop > issueToTop ? 1 : -1)
this.#drawPaper
.path([
this.#drawPath(
[
"M",
issueFromRightRel + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueMiddleTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
this.#drawPath(
[
"M",
issueFromRightRel + this.#drawLeft,
issueMiddleTop,
"L",
issueToLeftRel + this.#drawLeft,
issueMiddleTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
this.#drawPath(
[
"M",
issueToLeftRel + this.#drawLeft,
issueMiddleTop,
"L",
issueToLeftRel + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
this.#drawPath(
[
"M",
issueToLeftRel + this.#drawLeft,
issueToTop,
"L",
issueToLeft + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
],
{ stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" }
)
}
this.#drawPaper
.path([
this.#drawPath(
[
"M",
issueToLeft + this.#drawLeft,
issueToTop,
@@ -255,13 +296,12 @@ export default class extends Controller {
0,
4 * RELATION_STROKE_WIDTH,
"z"
])
.attr({
],
{
stroke: "none",
fill: color,
"stroke-linecap": "butt",
"stroke-linejoin": "miter"
})
fill: color
}
)
})
}
@@ -344,9 +384,11 @@ export default class extends Controller {
const x1 = previous.left === 0 ? 0 : previous.left + this.#drawLeft
const x2 = current.left === 0 ? 0 : current.left + this.#drawLeft
this.#drawPaper
.path(["M", x1, previous.top, "L", x2, current.top])
.attr({ stroke: color, "stroke-width": 2 })
this.#drawPath(["M", x1, previous.top, "L", x2, current.top], {
stroke: color,
"stroke-width": 2,
fill: "none"
})
}
}
}

View File

@@ -23,7 +23,6 @@
<% content_for :header_tags do %>
<%= stylesheet_link_tag 'gantt', media: 'all' %>
<%= javascript_include_tag 'raphael' %>
<% end %>
<%= context_menu %>

View File

@@ -13,6 +13,5 @@ $(window).resize(revisionGraphHandler);
<div id="holder" class="revision-graph"></div>
<% content_for :header_tags do %>
<%= javascript_include_tag 'raphael' %>
<%= javascript_include_tag 'revision_graph' %>
<% end %>

View File

@@ -20,6 +20,9 @@
require_relative '../application_system_test_case'
class RepositoriesTest < ApplicationSystemTestCase
REPOSITORY_PATH = Rails.root.join('tmp/test/git_repository').to_s
REPOSITORY_PATH.tr!('/', "\\") if Redmine::Platform.mswin?
def setup
@project = Project.find(1)
@repository = Repository::Subversion.create(:project => @project,
@@ -42,4 +45,36 @@ class RepositoriesTest < ApplicationSystemTestCase
assert page.has_css?("a.administration")
end
end
if repository_configured?('git')
def test_revisions_page_renders_revision_graph_as_svg
skip "SCM command is unavailable" unless Repository::Git.scm_available
git_repository =
Repository::Git.create(
:project => @project,
:identifier => 'graph-test',
:url => REPOSITORY_PATH,
:path_encoding => 'ISO-8859-1'
)
assert git_repository
git_repository.fetch_changesets
revision = git_repository.changesets.order(committed_on: :desc, id: :desc).first&.revision
assert revision.present?
log_user('admin', 'admin')
visit("/projects/#{@project.identifier}/repository/graph-test/revisions")
assert_selector 'div.revision-graph svg'
assert_selector 'div.revision-graph svg path'
assert_selector 'div.revision-graph svg circle'
assert_selector "div.revision-graph svg a[href*='/revisions/#{revision}']"
assert_selector(
:xpath,
"//div[contains(@class,'revision-graph')]/*[local-name()='svg' and namespace-uri()='http://www.w3.org/2000/svg']"
)
end
end
end