feat: Add blog post functionality with Markdown support

- Introduced BlogPostNode to render blog posts with metadata and content.
- Updated graph.json to include new blog post nodes.
- Enhanced MarkdownRenderer to support code block options for max height.
- Added CSS styles for improved code block scrolling and syntax highlighting.
- Created example blog posts: "Markdown Formatting Test", "Welcome to My Blog", and "Building Interactive Node Graphs".
- Implemented dynamic loading of blog data from blogs.json.
- Updated WorkspaceNavigator to allow smooth panning over blog nodes.
This commit is contained in:
2026-04-19 09:09:53 +10:00
parent 34c2aa4316
commit 40fd8e8e42
13 changed files with 930 additions and 42 deletions

View File

@@ -14,7 +14,7 @@
*/
export class GraphNode {
/**
* @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, userSpawned?: boolean, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init
* @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, blogSlug?: string, userSpawned?: boolean, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init
*/
constructor(init) {
this.id = init.id;
@@ -31,6 +31,8 @@ export class GraphNode {
this.imageSrc = init.imageSrc;
/** @type {string | undefined} */
this.lottieSrc = init.lottieSrc;
/** @type {string | undefined} */
this.blogSlug = init.blogSlug;
/** @type {boolean | undefined} */
this.userSpawned = init.userSpawned;
/** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */

View File

@@ -37,9 +37,22 @@ export class MarkdownRenderer {
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Extract and protect code blocks first (prevents splitting on blank lines inside code)
const codeBlocks = [];
html = html.replace(/```[\s\S]*?```/g, (match) => {
const placeholder = `\n___CODEBLOCK_${codeBlocks.length}___\n`;
codeBlocks.push(match);
return placeholder;
});
// Split into blocks separated by blank lines
const blocks = html.split(/\n{2,}/);
const parts = blocks.map(block => MarkdownRenderer.#renderBlock(block.trim()));
const parts = blocks.map(block => {
block = block.trim();
// Restore code blocks
block = block.replace(/___CODEBLOCK_(\d+)___/g, (_, idx) => codeBlocks[parseInt(idx)]);
return MarkdownRenderer.#renderBlock(block);
});
return parts.filter(Boolean).join("\n");
}
@@ -53,11 +66,32 @@ export class MarkdownRenderer {
if (!block) return "";
// Fenced code block
const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/);
const fenceMatch = block.match(/^```(\w*)(?:\s*\{([^}]+)\})?\s*\n([\s\S]*?)\n?```$/);
if (fenceMatch) {
const lang = MarkdownRenderer.#escape(fenceMatch[1] || "");
const code = MarkdownRenderer.#escape(fenceMatch[2]);
return `<pre class="md-pre"><code${lang ? ` class="lang-${lang}"` : ""}>${code}</code></pre>`;
const lang = fenceMatch[1] || "";
const options = fenceMatch[2] || "";
const code = fenceMatch[3];
// Parse options (e.g., {maxHeight: 300})
let maxHeight = null;
if (options) {
const maxHeightMatch = options.match(/maxHeight\s*:\s*(\d+)/);
if (maxHeightMatch) {
maxHeight = maxHeightMatch[1];
}
}
// Use Prism for syntax highlighting if available and language is specified
let highlightedCode = code;
if (lang && typeof Prism !== 'undefined' && Prism.languages[lang]) {
highlightedCode = Prism.highlight(code, Prism.languages[lang], lang);
} else {
highlightedCode = MarkdownRenderer.#escape(code);
}
const langClass = lang ? ` language-${lang}` : "";
const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : "";
return `<pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre>`;
}
// Horizontal rule
@@ -66,7 +100,7 @@ export class MarkdownRenderer {
}
// Headings
const headingMatch = block.match(/^(#{1,3})\s+(.+)$/m);
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m);
if (headingMatch && block.split("\n").length === 1) {
const level = headingMatch[1].length;
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;

View File

@@ -40,6 +40,12 @@ export class WorkspaceNavigator {
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
#smoothZoomFocus = null;
/** @type {number | null} */
#smoothPanRafId = null;
/** @type {{ x: number, y: number }} */
#targetPanOffset = { x: 0, y: 0 };
/** @type {{ pointerId: number, startX: number, startY: number, currentClientX: number, currentClientY: number, hasMoved: boolean, backgroundOrigin: { x: number, y: number }, lastDelta: { x: number, y: number } } | null} */
#panState = null;
@@ -436,6 +442,37 @@ export class WorkspaceNavigator {
if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return;
// Check if scrolling over a code block with max-height - let it scroll
const codeBlock = e.target.closest('.md-pre');
if (codeBlock && codeBlock.scrollHeight > codeBlock.clientHeight) {
const scrollTop = codeBlock.scrollTop;
const scrollHeight = codeBlock.scrollHeight;
const clientHeight = codeBlock.clientHeight;
const tolerance = 1; // Add tolerance for precision issues
const canScrollDown = scrollTop < (scrollHeight - clientHeight - tolerance);
const canScrollUp = scrollTop > tolerance;
// Allow scrolling if there's room to scroll in that direction
if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
return; // Don't prevent default, let the element scroll
}
// If at the edge, fall through to camera pan
}
// Check if scrolling over an info or blog_post node - pan the camera vertically
const blueprintNode = e.target.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
e.preventDefault();
const panAmount = e.deltaY * 0.5;
this.#pushSmoothPan(panAmount);
return;
}
}
// Not over a scrollable node or code block, proceed with zoom
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
@@ -608,6 +645,12 @@ export class WorkspaceNavigator {
// Do not start a pan while a node drag is actively in progress
if (this.#isDragInProgress?.()) return;
// Cancel any in-progress smooth pan
if (this.#smoothPanRafId !== null) {
cancelAnimationFrame(this.#smoothPanRafId);
this.#smoothPanRafId = null;
}
const state = {
pointerId: event.pointerId,
startX: event.clientX,
@@ -752,6 +795,54 @@ export class WorkspaceNavigator {
}
}
// ─── Smooth Pan ───────────────────────────────────────────────────────────
/**
* @param {number} deltaY
*/
#pushSmoothPan(deltaY) {
// Cancel any in-progress zoom or focus animation
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
// Accumulate the pan delta
this.#targetPanOffset.y -= deltaY / this.#zoomLevel;
// Start animation loop if not already running
if (this.#smoothPanRafId === null) {
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
#tickSmoothPan() {
this.#smoothPanRafId = null;
const targetY = this.#targetPanOffset.y;
const currentY = this.#backgroundOffset.y;
const diffY = targetY - currentY;
const settled = Math.abs(diffY) < 0.01;
if (settled) {
// Snap to target and stop
this.#backgroundOffset.y = targetY;
this.#targetPanOffset.y = targetY;
this.#applyBackgroundOffset();
} else {
// Ease toward target
this.#backgroundOffset.y += diffY * 0.25;
this.#applyBackgroundOffset();
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
// ─── Transform helpers ────────────────────────────────────────────────────
/** @param {{ x: number, y: number }} offset */
@@ -760,6 +851,8 @@ export class WorkspaceNavigator {
x: Number.isFinite(offset.x) ? offset.x : 0,
y: Number.isFinite(offset.y) ? offset.y : 0,
};
// Keep target pan offset in sync
this.#targetPanOffset = { ...this.#backgroundOffset };
this.#applyBackgroundOffset();
}

View File

@@ -0,0 +1,114 @@
/**
* @file BlogPostNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Displays a blog post with metadata (title, date, author) and markdown content.
*/
export class BlogPostNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "blog_post";
/**
* @UFUNCTION(BlueprintPure)
*/
static BlueprintPure_GetDefaultPins() {
return {
inputs: [],
outputs: []
};
}
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
const body = document.createElement("div");
body.className = "node-body";
body.setAttribute("aria-live", "polite");
// Load blog data
const loadBlogData = async () => {
try {
const response = await fetch('data/blogs.json');
const blogs = await response.json();
// Find the blog post by slug
const post = blogs.find(b => b.slug === graphNode.blogSlug);
if (!post) {
body.innerHTML = `<p style="color: #ff6b6b;">Blog post "${graphNode.blogSlug}" not found.</p>`;
return;
}
// Render metadata header
const metaDiv = document.createElement("div");
metaDiv.className = "blog-meta";
metaDiv.style.cssText = "margin-bottom: 1em; padding-bottom: 0.5em; border-bottom: 1px solid rgba(255,255,255,0.1);";
if (post.title) {
const titleEl = document.createElement("h2");
titleEl.textContent = post.title;
titleEl.style.cssText = "margin: 0 0 0.5em 0; font-size: 1.5em;";
metaDiv.appendChild(titleEl);
}
const metaInfo = [];
if (post.date) {
const dateObj = new Date(post.date);
metaInfo.push(`📅 ${dateObj.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`);
}
if (post.author) {
metaInfo.push(`✍️ ${post.author}`);
}
if (metaInfo.length > 0) {
const infoEl = document.createElement("div");
infoEl.style.cssText = "color: rgba(255,255,255,0.6); font-size: 0.9em;";
infoEl.textContent = metaInfo.join(' • ');
metaDiv.appendChild(infoEl);
}
if (post.tags && post.tags.length > 0) {
const tagsDiv = document.createElement("div");
tagsDiv.style.cssText = "margin-top: 0.5em;";
post.tags.forEach(tag => {
const tagEl = document.createElement("span");
tagEl.textContent = `#${tag}`;
tagEl.style.cssText = "display: inline-block; margin-right: 0.5em; padding: 0.2em 0.5em; background: rgba(100,150,255,0.2); border-radius: 3px; font-size: 0.85em;";
tagsDiv.appendChild(tagEl);
});
metaDiv.appendChild(tagsDiv);
}
body.appendChild(metaDiv);
// Render markdown content directly
const contentDiv = document.createElement("div");
contentDiv.className = "blog-content";
body.appendChild(contentDiv);
// Import and use MarkdownRenderer directly
const { MarkdownRenderer } = await import('../MarkdownRenderer.js');
contentDiv.innerHTML = MarkdownRenderer.render(post.content);
} catch (error) {
body.innerHTML = `<p style="color: #ff6b6b;">Error loading blog post: ${error.message}</p>`;
console.error('Failed to load blog post:', error);
}
};
article.appendChild(body);
loadBlogData();
}
}
NodeRegistry.UCLASS_Register(BlogPostNode);

View File

@@ -16,6 +16,7 @@ export { CustomEventNode } from './CustomEventNode.js';
export { ButtonNode } from './ButtonNode.js';
export { TimerNode } from './TimerNode.js';
export { InfoNode } from './InfoNode.js';
export { BlogPostNode } from './BlogPostNode.js';
export { PureNode } from './PureNode.js';
export { MatrixChatNode } from './MatrixChatNode.js';
export { GetMatrixChatNode } from './GetMatrixChatNode.js';