mirror of
https://github.com/redmine/redmine.git
synced 2026-07-07 20:43:39 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
<% content_for :header_tags do %>
|
||||
<%= stylesheet_link_tag 'gantt', media: 'all' %>
|
||||
<%= javascript_include_tag 'raphael' %>
|
||||
<% end %>
|
||||
|
||||
<%= context_menu %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user