mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Add the crate dependency graph visualization to the contributor guide (#3907)
* Add the crate dependency graph visualization to the contributor guide * Code review fixes * And more
This commit is contained in:
19
website/.build-scripts/generate-crate-hierarchy.ts
Normal file
19
website/.build-scripts/generate-crate-hierarchy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import fs from "fs";
|
||||
|
||||
import { instance } from "@viz-js/viz";
|
||||
|
||||
const [inputFile, outputFile] = process.argv.slice(2);
|
||||
if (!inputFile || !outputFile) {
|
||||
console.error("Usage: node generate-crate-hierarchy.ts <input.dot> <output.svg>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dot = fs.readFileSync(inputFile, "utf-8");
|
||||
|
||||
const viz = await instance();
|
||||
const svg = viz.renderString(dot, { format: "svg" });
|
||||
|
||||
fs.writeFileSync(outputFile, svg);
|
||||
console.log(`SVG output written to: ${outputFile}`);
|
||||
@@ -5,8 +5,8 @@ page_template = "book.html"
|
||||
|
||||
[extra]
|
||||
order = 2 # Chapter number
|
||||
js = ["/js/component/youtube-embed.js"]
|
||||
css = ["/component/youtube-embed.css"]
|
||||
js = ["/js/component/youtube-embed.js", "/js/page/contributor-guide/crate-hierarchy.js"]
|
||||
css = ["/component/youtube-embed.css", "/page/contributor-guide/crate-hierarchy.css"]
|
||||
+++
|
||||
|
||||
The best introduction for getting up-to-speed with Graphite contribution comes from watching this webcast recording. Before asking questions in Discord, please watch the full video because it gives a comprehensive overview of most things you will need to know.
|
||||
@@ -37,6 +37,16 @@ The frontend is the GUI for Graphite which users see and interact with. It is bu
|
||||
|
||||
[Graphene](../graphene/) is the node graph engine which manages and renders the documents. It is itself a programming language, where Graphene programs are compiled while being edited live by the user, and where executing the program renders the document.
|
||||
|
||||
## Crate dependency graph
|
||||
|
||||
This diagram shows the structure of the crates that comprise the Graphite codebase and how they depend on each other. Every Arrow points from a crate to another which it depends on.
|
||||
|
||||
<div class="crate-hierarchy">
|
||||
|
||||
<!-- replacements::crate_hierarchy() -->
|
||||
|
||||
</div>
|
||||
|
||||
## Frontend/backend communication
|
||||
|
||||
Frontend-to-backend communication is achieved through a thin Rust translation layer in [`/frontend/wasm/src/editor_api.rs`](https://github.com/GraphiteEditor/Graphite/tree/master/frontend/wasm/src/editor_api.rs) which wraps the editor backend's Rust-based message system API and provides the TypeScript-compatible API of callable functions. These wrapper functions are compiled by [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) into autogenerated TS functions that serve as an entry point from TS into the Wasm binary.
|
||||
|
||||
8
website/package-lock.json
generated
8
website/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.9",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
@@ -1232,6 +1233,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@viz-js/viz": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@viz-js/viz/-/viz-3.25.0.tgz",
|
||||
"integrity": "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"scripts": {
|
||||
"postinstall": "node .build-scripts/install.ts",
|
||||
"generate-editor-structure": "node .build-scripts/generate-editor-structure.ts generated/hierarchical_message_system_tree.txt generated/hierarchical_message_system_tree.html",
|
||||
"generate-crate-hierarchy": "node .build-scripts/generate-crate-hierarchy.ts generated/crate_hierarchy.dot generated/crate_hierarchy.svg",
|
||||
"check": "tsc --noEmit && eslint",
|
||||
"fix": "eslint --fix"
|
||||
},
|
||||
@@ -21,11 +22,12 @@
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.9",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.8.0",
|
||||
"sass": "1.97.2",
|
||||
"tar": "^7.5.6",
|
||||
|
||||
73
website/sass/page/contributor-guide/crate-hierarchy.scss
Normal file
73
website/sass/page/contributor-guide/crate-hierarchy.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
.crate-hierarchy {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
|
||||
.crate-hierarchy-controls {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--color-navy);
|
||||
user-select: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
// + and - icon geometry
|
||||
&::before,
|
||||
&.zoom-in::after {
|
||||
content: "";
|
||||
background: white;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
&.zoom-in::after {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crate-hierarchy-viewport {
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
transform-origin: 0 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
text {
|
||||
font-family: inherit;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
website/static/js/page/contributor-guide/crate-hierarchy.js
Normal file
181
website/static/js/page/contributor-guide/crate-hierarchy.js
Normal file
@@ -0,0 +1,181 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const container = document.querySelector(".crate-hierarchy");
|
||||
if (!container) return;
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
if (!svg) return;
|
||||
|
||||
// Wrap SVG in a viewport container
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "crate-hierarchy-viewport";
|
||||
svg?.parentNode?.insertBefore(viewport, svg);
|
||||
viewport.appendChild(svg);
|
||||
|
||||
// Remove any width/height attributes so CSS controls sizing
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
|
||||
// Create zoom controls
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "crate-hierarchy-controls";
|
||||
controls.innerHTML = `<button class="zoom-in"></button><button class="zoom-out"></button>`;
|
||||
container.insertBefore(controls, viewport);
|
||||
const zoomInBtn = controls.querySelector(".zoom-in");
|
||||
const zoomOutBtn = controls.querySelector(".zoom-out");
|
||||
if (!(zoomInBtn instanceof HTMLButtonElement) || !(zoomOutBtn instanceof HTMLButtonElement)) return;
|
||||
|
||||
// Lock the viewport height to the SVG's natural rendered height (ignoring any zoom transform)
|
||||
const updateViewportHeight = () => {
|
||||
const prevTransform = svg.style.transform;
|
||||
svg.style.transform = "";
|
||||
viewport.style.height = `${svg.getBoundingClientRect().height}px`;
|
||||
svg.style.transform = prevTransform;
|
||||
};
|
||||
updateViewportHeight();
|
||||
window.addEventListener("resize", () => {
|
||||
updateViewportHeight();
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
const ZOOM_STEP = 0.15;
|
||||
const BUTTON_ZOOM_STEP = 0.5;
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
let scale = MIN_SCALE;
|
||||
let panX = 0;
|
||||
let panY = 0;
|
||||
let animationFrameId = 0;
|
||||
let isDragging = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let panStartX = 0;
|
||||
let panStartY = 0;
|
||||
|
||||
function clampPan() {
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const viewportW = viewportRect.width;
|
||||
const viewportH = viewportRect.height;
|
||||
|
||||
// The SVG is scaled to fill the viewport width at scale=1
|
||||
const scaledW = viewportW * scale;
|
||||
const scaledH = svg?.getBoundingClientRect()?.height || 0;
|
||||
|
||||
// How much overflow exists on each axis
|
||||
const overflowX = Math.max(0, scaledW - viewportW);
|
||||
const overflowY = Math.max(0, scaledH - viewportH);
|
||||
|
||||
// Pan is constrained so scaled content edges don't pull away from viewport edges
|
||||
panX = Math.min(0, Math.max(-overflowX, panX));
|
||||
panY = Math.min(0, Math.max(-overflowY, panY));
|
||||
}
|
||||
|
||||
function updateButtons() {
|
||||
if (zoomInBtn instanceof HTMLButtonElement) zoomInBtn.disabled = scale >= MAX_SCALE;
|
||||
if (zoomOutBtn instanceof HTMLButtonElement) zoomOutBtn.disabled = scale <= MIN_SCALE;
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
clampPan();
|
||||
if (svg) svg.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
function zoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newScale) {
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
|
||||
// Point in viewport-local coordinates
|
||||
const pointX = clientX - viewportRect.left;
|
||||
const pointY = clientY - viewportRect.top;
|
||||
|
||||
// Where this point maps in the pre-zoom content
|
||||
const contentX = (pointX - panX) / scale;
|
||||
const contentY = (pointY - panY) / scale;
|
||||
|
||||
scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
|
||||
|
||||
// Adjust pan so the same content point stays under the cursor
|
||||
panX = pointX - contentX * scale;
|
||||
panY = pointY - contentY * scale;
|
||||
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function animateZoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newTargetScale) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
|
||||
const targetScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newTargetScale));
|
||||
const startScale = scale;
|
||||
const startPanX = panX;
|
||||
const startPanY = panY;
|
||||
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const pointX = clientX - viewportRect.left;
|
||||
const pointY = clientY - viewportRect.top;
|
||||
const contentX = (pointX - panX) / scale;
|
||||
const contentY = (pointY - panY) / scale;
|
||||
|
||||
const targetPanX = pointX - contentX * targetScale;
|
||||
const targetPanY = pointY - contentY * targetScale;
|
||||
|
||||
const startTime = performance.now();
|
||||
const step = (/** @type {number} */ now) => {
|
||||
const t = Math.min(1, (now - startTime) / ANIMATION_DURATION);
|
||||
const ease = t * (2 - t); // ease-out quadratic
|
||||
scale = startScale + (targetScale - startScale) * ease;
|
||||
panX = startPanX + (targetPanX - startPanX) * ease;
|
||||
panY = startPanY + (targetPanY - startPanY) * ease;
|
||||
applyTransform();
|
||||
if (t < 1) animationFrameId = requestAnimationFrame(step);
|
||||
};
|
||||
animationFrameId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Scroll wheel zoom
|
||||
viewport.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
zoomAt(e.clientX, e.clientY, scale + delta);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// Button zoom (animated, zoom toward center of viewport)
|
||||
zoomInBtn?.addEventListener("click", () => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale + BUTTON_ZOOM_STEP);
|
||||
});
|
||||
zoomOutBtn?.addEventListener("click", () => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale - BUTTON_ZOOM_STEP);
|
||||
});
|
||||
|
||||
// Click-drag to pan
|
||||
viewport.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
panStartX = panX;
|
||||
panStartY = panY;
|
||||
viewport.setPointerCapture(e.pointerId);
|
||||
viewport.style.cursor = "grabbing";
|
||||
});
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
if (!isDragging) return;
|
||||
panX = panStartX + (e.clientX - dragStartX);
|
||||
panY = panStartY + (e.clientY - dragStartY);
|
||||
applyTransform();
|
||||
});
|
||||
window.addEventListener("pointerup", () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
viewport.style.cursor = "";
|
||||
});
|
||||
|
||||
applyTransform();
|
||||
});
|
||||
@@ -133,10 +133,12 @@
|
||||
{%- filter replace(from = "<!-- replacements::blog_posts(count = 2) -->", to = replacements::blog_posts(count = 2)) -%}
|
||||
{%- filter replace(from = "<!-- replacements::text_balancer() -->", to = replacements::text_balancer()) -%}
|
||||
{%- filter replace(from = "<!-- replacements::hierarchical_message_system_tree() -->", to = replacements::hierarchical_message_system_tree()) -%}
|
||||
{%- filter replace(from = "<!-- replacements::crate_hierarchy() -->", to = replacements::crate_hierarchy()) -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
{%- endfilter -%}
|
||||
{%- endfilter -%}
|
||||
{%- endfilter -%}
|
||||
{%- endfilter -%}
|
||||
{# This is a comment. It exists to prevent the {%- -%} on the lines above from removing the line break between the `content` block and `</main>` #}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -45,9 +45,20 @@
|
||||
|
||||
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
|
||||
|
||||
cd tools/editor-message-tree
|
||||
cargo run
|
||||
cd ../../website
|
||||
cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt
|
||||
cd website
|
||||
npm run generate-editor-structure</pre>" -%}
|
||||
{{ content | default(value = fallback) | safe }}
|
||||
{% endmacro hierarchical_message_system_tree %}
|
||||
|
||||
{% macro crate_hierarchy() %}
|
||||
{%- set content = load_data(path = "../generated/crate_hierarchy.svg", format = "plain", required = false) -%}
|
||||
{%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
|
||||
|
||||
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
|
||||
|
||||
cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot
|
||||
cd website
|
||||
npm run generate-crate-hierarchy</pre>" -%}
|
||||
{{ content | default(value = fallback) | safe }}
|
||||
{% endmacro crate_hierarchy %}
|
||||
|
||||
Reference in New Issue
Block a user