Rust implementation of NextGraph, a Decentralized and local-first web 3.0 ecosystem
https://nextgraph.org
byzantine-fault-tolerancecrdtsdappsdecentralizede2eeeventual-consistencyjson-ldlocal-firstmarkdownocapoffline-firstp2pp2p-networkprivacy-protectionrdfrich-text-editorself-hostedsemantic-websparqlweb3collaboration
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
489 lines
14 KiB
489 lines
14 KiB
import {
|
|
GitgraphCore,
|
|
type GitgraphOptions,
|
|
Commit,
|
|
type GitgraphCommitOptions,
|
|
type RenderedData,
|
|
MergeStyle,
|
|
arrowSvgPath,
|
|
toSvgPath,
|
|
type Coordinate,
|
|
TemplateName,
|
|
templateExtend,
|
|
} from "../gitgraph-core";
|
|
|
|
import {
|
|
createSvg,
|
|
createG,
|
|
createText,
|
|
createCircle,
|
|
createUse,
|
|
createPath,
|
|
createClipPath,
|
|
createDefs,
|
|
createForeignObject,
|
|
} from "./svg-elements";
|
|
|
|
import { createTooltip, PADDING as TOOLTIP_PADDING } from "./tooltip";
|
|
|
|
type CommitOptions = GitgraphCommitOptions<SVGElement>;
|
|
|
|
export {
|
|
createGitgraph,
|
|
TemplateName,
|
|
templateExtend,
|
|
MergeStyle,
|
|
};
|
|
|
|
interface CommitYWithOffsets {
|
|
[key: number]: number;
|
|
}
|
|
|
|
function createGitgraph(
|
|
graphContainer: HTMLElement,
|
|
options?: GitgraphOptions & { responsive?: boolean },
|
|
) {
|
|
let commitsElements: {
|
|
[commitHash: string]: {
|
|
message: SVGGElement | null;
|
|
};
|
|
} = {};
|
|
// Store a map to replace commits y with the correct value,
|
|
// including the message offset. Allows custom, flexible message height.
|
|
// E.g. {20: 30} means for commit: y=20 -> y=30
|
|
// Offset should be computed when graph is rendered (componentDidUpdate).
|
|
let commitYWithOffsets: CommitYWithOffsets = {};
|
|
let shouldRecomputeOffsets = false;
|
|
let lastData: RenderedData<SVGElement>;
|
|
let $commits: SVGElement;
|
|
let commitMessagesX = 0;
|
|
let $tooltip: SVGElement | null = null;
|
|
|
|
// Create an `svg` context in which we'll render the graph.
|
|
const svg = createSvg();
|
|
adaptSvgOnUpdate(Boolean(options && options.responsive));
|
|
graphContainer.appendChild(svg);
|
|
|
|
if (options && options.responsive) {
|
|
graphContainer.setAttribute(
|
|
"style",
|
|
"display:inline-block; position: relative; width:100%; padding-bottom:100%; vertical-align:middle; overflow:hidden;",
|
|
);
|
|
}
|
|
|
|
// React on gitgraph updates to re-render the graph.
|
|
const gitgraph = new GitgraphCore(options);
|
|
gitgraph.subscribe((data) => {
|
|
shouldRecomputeOffsets = true;
|
|
render(data);
|
|
});
|
|
|
|
// Return usable API for end-user.
|
|
return gitgraph.getUserApi();
|
|
|
|
function render(data: RenderedData<SVGElement>): void {
|
|
// Reset before new rendering to flush previous state.
|
|
commitsElements = {};
|
|
|
|
const { commits, branchesPaths } = data;
|
|
commitMessagesX = data.commitMessagesX;
|
|
|
|
// Store data so we can re-render after offsets are computed.
|
|
lastData = data;
|
|
|
|
// Store $commits so we can compute offsets from actual height.
|
|
$commits = renderCommits(commits);
|
|
|
|
// Reset SVG with new content.
|
|
svg.innerHTML = "";
|
|
svg.appendChild(
|
|
createG({
|
|
// Translate graph left => left-most branch label is not cropped (horizontal)
|
|
// Translate graph down => top-most commit tooltip is not cropped
|
|
translate: { x: 0, y: TOOLTIP_PADDING },
|
|
children: [renderBranchesPaths(branchesPaths), $commits],
|
|
}),
|
|
);
|
|
}
|
|
|
|
function adaptSvgOnUpdate(adaptToContainer: boolean): void {
|
|
const observer = new MutationObserver(() => {
|
|
if (shouldRecomputeOffsets) {
|
|
shouldRecomputeOffsets = false;
|
|
computeOffsets();
|
|
render(lastData);
|
|
} else {
|
|
positionCommitsElements();
|
|
adaptGraphDimensions(adaptToContainer);
|
|
}
|
|
});
|
|
|
|
observer.observe(svg, {
|
|
attributes: false,
|
|
// Listen to subtree changes to react when we append the tooltip.
|
|
subtree: true,
|
|
childList: true,
|
|
});
|
|
|
|
function computeOffsets(): void {
|
|
const commits: Element[] = Array.from($commits.children);
|
|
let totalOffsetY = 0;
|
|
|
|
// In VerticalReverse orientation, commits are in the same order in the DOM.
|
|
const orientedCommits = commits;
|
|
|
|
commitYWithOffsets = orientedCommits.reduce<CommitYWithOffsets>(
|
|
(newOffsets, commit) => {
|
|
const commitY = parseInt(
|
|
commit.getAttribute("transform")!.split(",")[1].slice(0, -1),
|
|
10,
|
|
);
|
|
|
|
const firstForeignObject = commit.getElementsByTagName(
|
|
"foreignObject",
|
|
)[0];
|
|
const customHtmlMessage =
|
|
firstForeignObject && firstForeignObject.firstElementChild;
|
|
|
|
newOffsets[commitY] = commitY + totalOffsetY;
|
|
|
|
// Increment total offset after setting the offset
|
|
// => offset next commits accordingly.
|
|
totalOffsetY += getMessageHeight(customHtmlMessage);
|
|
|
|
return newOffsets;
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
function positionCommitsElements(): void {
|
|
if (gitgraph.isHorizontal) {
|
|
// Elements don't appear on horizontal mode, yet.
|
|
return;
|
|
}
|
|
|
|
const padding = 10;
|
|
|
|
// Ensure commits elements (branch labels, message…) are well positionned.
|
|
// It can't be done at render time since elements size is dynamic.
|
|
Object.keys(commitsElements).forEach((commitHash) => {
|
|
const { message } = commitsElements[commitHash];
|
|
|
|
// We'll store X position progressively and translate elements.
|
|
let x = commitMessagesX;
|
|
|
|
if (message) {
|
|
moveElement(message, x);
|
|
}
|
|
});
|
|
}
|
|
|
|
function adaptGraphDimensions(adaptToContainer: boolean): void {
|
|
const { height, width } = svg.getBBox();
|
|
|
|
// FIXME: In horizontal mode, we mimic @gitgraph/react behavior
|
|
// => it gets re-rendered after offsets are computed
|
|
// => it applies paddings twice!
|
|
//
|
|
// It works… by chance. Technically, we should compute what would
|
|
// *actually* go beyond the computed limits of the graph.
|
|
const horizontalCustomOffset = 50;
|
|
const verticalCustomOffset = 20;
|
|
|
|
const widthOffset = // Add `TOOLTIP_PADDING` so we don't crop the tooltip text.
|
|
|
|
TOOLTIP_PADDING;
|
|
|
|
const heightOffset =
|
|
// Add `TOOLTIP_PADDING` so we don't crop tooltip text
|
|
TOOLTIP_PADDING + verticalCustomOffset;
|
|
|
|
if (adaptToContainer) {
|
|
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
|
svg.setAttribute(
|
|
"viewBox",
|
|
`0 0 ${width + widthOffset} ${height + heightOffset}`,
|
|
);
|
|
} else {
|
|
svg.setAttribute("width", (width + widthOffset).toString());
|
|
svg.setAttribute("height", (height + heightOffset).toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
function moveElement(target: Element, x: number): void {
|
|
const transform = target.getAttribute("transform") || "translate(0, 0)";
|
|
target.setAttribute(
|
|
"transform",
|
|
transform.replace(/translate\(([\d\.]+),/, `translate(${x},`),
|
|
);
|
|
}
|
|
|
|
function renderBranchesPaths(
|
|
branchesPaths: RenderedData<SVGElement>["branchesPaths"],
|
|
): SVGElement {
|
|
const offset = gitgraph.template.commit.dot.size;
|
|
const isBezier = gitgraph.template.branch.mergeStyle === MergeStyle.Bezier;
|
|
|
|
const paths = Array.from(branchesPaths).map(([branch, coordinates]) => {
|
|
return createPath({
|
|
d: toSvgPath(
|
|
coordinates.map((coordinate) => coordinate.map(getWithCommitOffset)),
|
|
isBezier,
|
|
//gitgraph.isVertical,
|
|
),
|
|
fill: "none",
|
|
stroke: branch.computedColor || "",
|
|
strokeWidth: branch.style.lineWidth,
|
|
translate: {
|
|
x: offset,
|
|
y: offset,
|
|
},
|
|
});
|
|
});
|
|
|
|
return createG({ children: paths });
|
|
}
|
|
|
|
function renderCommits(commits: Commit[]): SVGGElement {
|
|
return createG({ children: commits.map(renderCommit) });
|
|
|
|
function renderCommit(commit: Commit): SVGGElement {
|
|
const { x, y } = getWithCommitOffset(commit);
|
|
|
|
return createG({
|
|
translate: { x, y },
|
|
children: [
|
|
renderDot(commit),
|
|
...renderArrows(commit),
|
|
|
|
createG({
|
|
translate: { x: -x, y: 0 },
|
|
children: [
|
|
renderMessage(commit),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
function renderArrows(commit: Commit): Array<SVGElement | null> {
|
|
if (!gitgraph.template.arrow.size) {
|
|
return [null];
|
|
}
|
|
|
|
const commitRadius = commit.style.dot.size;
|
|
|
|
return commit.parents.map((parentHash) => {
|
|
const parent = commits.find(({ hash }) => hash === parentHash);
|
|
if (!parent) return null;
|
|
|
|
// Starting point, relative to commit
|
|
const origin = { x: commitRadius, y: commitRadius };
|
|
|
|
const path = createPath({
|
|
d: arrowSvgPath(gitgraph, parent, commit),
|
|
fill: gitgraph.template.arrow.color || "",
|
|
});
|
|
|
|
return createG({ translate: origin, children: [path] });
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderMessage(commit: Commit): SVGElement | null {
|
|
if (!commit.style.message.display) {
|
|
return null;
|
|
}
|
|
|
|
let message;
|
|
|
|
if (commit.renderMessage) {
|
|
message = createG({ children: [] });
|
|
|
|
// Add message after observer is set up => react based on body height.
|
|
// We might refactor it by including `onChildrenUpdate()` to `createG()`.
|
|
adaptMessageBodyHeight(message);
|
|
message.appendChild(commit.renderMessage(commit));
|
|
|
|
setMessageRef(commit, message);
|
|
|
|
return message;
|
|
}
|
|
|
|
const text = createText({
|
|
content: commit.message,
|
|
fill: commit.style.message.color || "",
|
|
font: commit.style.message.font,
|
|
onClick: commit.onMessageClick,
|
|
});
|
|
|
|
message = createG({
|
|
translate: { x: 0, y: commit.style.dot.size },
|
|
children: [text],
|
|
});
|
|
|
|
if (commit.body) {
|
|
const body = createForeignObject({
|
|
width: 600,
|
|
translate: { x: 10, y: 0 },
|
|
content: commit.body,
|
|
});
|
|
|
|
// Add message after observer is set up => react based on body height.
|
|
// We might refactor it by including `onChildrenUpdate()` to `createG()`.
|
|
adaptMessageBodyHeight(message);
|
|
message.appendChild(body);
|
|
}
|
|
|
|
setMessageRef(commit, message);
|
|
|
|
return message;
|
|
}
|
|
|
|
function adaptMessageBodyHeight(message: SVGElement): void {
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach(({ target }) => setChildrenForeignObjectHeight(target));
|
|
});
|
|
|
|
observer.observe(message, {
|
|
attributes: false,
|
|
subtree: false,
|
|
childList: true,
|
|
});
|
|
|
|
function setChildrenForeignObjectHeight(node: Node): void {
|
|
if (node.nodeName === "foreignObject") {
|
|
// We have to access the first child's parentElement to retrieve
|
|
// the Element instead of the Node => we can compute dimensions.
|
|
const foreignObject = node.firstChild && node.firstChild.parentElement;
|
|
if (!foreignObject) return;
|
|
|
|
// Force the height of the foreignObject (browser issue)
|
|
foreignObject.setAttribute(
|
|
"height",
|
|
getMessageHeight(foreignObject.firstElementChild).toString(),
|
|
);
|
|
}
|
|
|
|
node.childNodes.forEach(setChildrenForeignObjectHeight);
|
|
}
|
|
}
|
|
|
|
function renderDot(commit: Commit): SVGElement {
|
|
if (commit.renderDot) {
|
|
return commit.renderDot(commit);
|
|
}
|
|
|
|
/*
|
|
In order to handle strokes, we need to do some complex stuff here… 😅
|
|
|
|
Problem: strokes are drawn inside & outside the circle.
|
|
But we want the stroke to be drawn inside only!
|
|
|
|
The outside overlaps with other elements, as we expect the dot to have a fixed size. So we want to crop the outside part.
|
|
|
|
Solution:
|
|
1. Create the circle in a <defs>
|
|
2. Define a clip path that references the circle
|
|
3. Use the clip path, adding the stroke.
|
|
4. Double stroke width as half of it will be clipped (the outside part).
|
|
|
|
Ref.: https://stackoverflow.com/a/32162431/3911841
|
|
|
|
P.S. there is a proposal for a stroke-alignment property,
|
|
but it's still a W3C Draft ¯\_(ツ)_/¯
|
|
https://svgwg.org/specs/strokes/#SpecifyingStrokeAlignment
|
|
*/
|
|
const circleId = commit.hash;
|
|
const circle = createCircle({
|
|
id: circleId,
|
|
radius: commit.style.dot.size,
|
|
fill: commit.style.dot.color || "",
|
|
});
|
|
|
|
const clipPathId = `clip-${commit.hash}`;
|
|
const circleClipPath = createClipPath();
|
|
circleClipPath.setAttribute("id", clipPathId);
|
|
circleClipPath.appendChild(createUse(circleId));
|
|
|
|
const useCirclePath = createUse(circleId);
|
|
useCirclePath.setAttribute("clip-path", `url(#${clipPathId})`);
|
|
useCirclePath.setAttribute("stroke", commit.style.dot.strokeColor || "");
|
|
const strokeWidth = commit.style.dot.strokeWidth
|
|
? commit.style.dot.strokeWidth * 2
|
|
: 0;
|
|
useCirclePath.setAttribute("stroke-width", strokeWidth.toString());
|
|
|
|
const dotText = commit.dotText
|
|
? createText({
|
|
content: commit.dotText,
|
|
font: commit.style.dot.font,
|
|
anchor: "middle",
|
|
translate: { x: commit.style.dot.size, y: commit.style.dot.size },
|
|
})
|
|
: null;
|
|
|
|
return createG({
|
|
onClick: commit.onClick,
|
|
onMouseOver: () => {
|
|
appendTooltipToGraph(commit);
|
|
commit.onMouseOver();
|
|
},
|
|
onMouseOut: () => {
|
|
if ($tooltip) $tooltip.remove();
|
|
commit.onMouseOut();
|
|
},
|
|
children: [createDefs([circle, circleClipPath]), useCirclePath, dotText],
|
|
});
|
|
}
|
|
|
|
function appendTooltipToGraph(commit: Commit): void {
|
|
if (!svg.firstChild) return;
|
|
|
|
const tooltip = commit.renderTooltip
|
|
? commit.renderTooltip(commit)
|
|
: createTooltip(commit);
|
|
|
|
$tooltip = createG({
|
|
translate: getWithCommitOffset(commit),
|
|
children: [tooltip],
|
|
});
|
|
|
|
svg.firstChild.appendChild($tooltip);
|
|
}
|
|
|
|
function getWithCommitOffset({ x, y }: Coordinate): Coordinate {
|
|
return { x, y: commitYWithOffsets[y] || y };
|
|
}
|
|
|
|
function setMessageRef(commit: Commit, message: SVGGElement | null): void {
|
|
if (!commitsElements[commit.hashAbbrev]) {
|
|
initCommitElements(commit);
|
|
}
|
|
|
|
commitsElements[commit.hashAbbrev].message = message;
|
|
}
|
|
|
|
|
|
function initCommitElements(commit: Commit): void {
|
|
commitsElements[commit.hashAbbrev] = {
|
|
message: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
function getMessageHeight(message: Element | null): number {
|
|
let messageHeight = 0;
|
|
|
|
if (message) {
|
|
const height = message.getBoundingClientRect().height;
|
|
const marginTopInPx = window.getComputedStyle(message).marginTop || "0px";
|
|
const marginTop = parseInt(marginTopInPx.replace("px", ""), 10);
|
|
|
|
messageHeight = height + marginTop;
|
|
}
|
|
|
|
return messageHeight;
|
|
}
|
|
|