mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
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:
180
build.js
180
build.js
@@ -1,6 +1,179 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML front matter from markdown content
|
||||||
|
* @param {string} content - The markdown file content
|
||||||
|
* @returns {{metadata: Object, content: string}}
|
||||||
|
*/
|
||||||
|
function parseMarkdownWithFrontmatter(content) {
|
||||||
|
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
||||||
|
const match = content.match(frontmatterRegex);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { metadata: {}, content: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlContent = match[1];
|
||||||
|
const markdownContent = match[2];
|
||||||
|
|
||||||
|
// Simple YAML parser for basic key: value pairs
|
||||||
|
const metadata = {};
|
||||||
|
const lines = yamlContent.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const colonIndex = line.indexOf(':');
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const key = line.substring(0, colonIndex).trim();
|
||||||
|
const value = line.substring(colonIndex + 1).trim();
|
||||||
|
// Remove quotes if present
|
||||||
|
metadata[key] = value.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata, content: markdownContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process blog markdown files and generate blogs.json, also update graph.json
|
||||||
|
*/
|
||||||
|
async function processBlogPosts() {
|
||||||
|
const blogDir = path.join(__dirname, 'website', 'data', 'blog');
|
||||||
|
const outputPath = path.join(__dirname, 'website', 'data', 'blogs.json');
|
||||||
|
const graphPath = path.join(__dirname, 'website', 'data', 'graph.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(blogDir);
|
||||||
|
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||||
|
|
||||||
|
const posts = [];
|
||||||
|
|
||||||
|
for (const file of mdFiles) {
|
||||||
|
const filePath = path.join(blogDir, file);
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const { metadata, content: markdown } = parseMarkdownWithFrontmatter(content);
|
||||||
|
|
||||||
|
const slug = path.basename(file, '.md');
|
||||||
|
|
||||||
|
// Extract and process tags separately
|
||||||
|
const tags = metadata.tags ? metadata.tags.split(',').map(t => t.trim()) : [];
|
||||||
|
|
||||||
|
// Remove processed fields from metadata
|
||||||
|
const { tags: _tags, title, date, author, ...extraMetadata } = metadata;
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
slug,
|
||||||
|
title: title || slug,
|
||||||
|
date: date || null,
|
||||||
|
author: author || null,
|
||||||
|
tags,
|
||||||
|
content: markdown,
|
||||||
|
...extraMetadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending (newest first)
|
||||||
|
posts.sort((a, b) => {
|
||||||
|
if (!a.date) return 1;
|
||||||
|
if (!b.date) return -1;
|
||||||
|
return new Date(b.date) - new Date(a.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(outputPath, JSON.stringify(posts, null, 2));
|
||||||
|
console.log(`✓ Processed ${posts.length} blog post(s)`);
|
||||||
|
|
||||||
|
// Update graph.json with blog nodes
|
||||||
|
if (posts.length > 0) {
|
||||||
|
await updateGraphWithBlogNodes(posts, graphPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.log('⚠ No blog directory found, skipping blog processing');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update graph.json with blog post nodes
|
||||||
|
* @param {Array} posts - Array of blog post objects
|
||||||
|
* @param {string} graphPath - Path to graph.json
|
||||||
|
*/
|
||||||
|
async function updateGraphWithBlogNodes(posts, graphPath) {
|
||||||
|
const graphData = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Remove existing blog nodes and their connections
|
||||||
|
graphData.nodes = graphData.nodes.filter(node => node.type !== 'blog_post');
|
||||||
|
graphData.connections = graphData.connections.filter(conn => {
|
||||||
|
const fromNode = graphData.nodes.find(n => n.id === conn.from.nodeId);
|
||||||
|
const toNode = graphData.nodes.find(n => n.id === conn.to.nodeId);
|
||||||
|
return fromNode && toNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startX = 1500;
|
||||||
|
const startY = 900;
|
||||||
|
const horizontalSpacing = 700;
|
||||||
|
const nodeWidth = 600;
|
||||||
|
|
||||||
|
const blogNodes = [];
|
||||||
|
const blogConnections = [];
|
||||||
|
|
||||||
|
posts.forEach((post, index) => {
|
||||||
|
const nodeId = `blog_${post.slug}`;
|
||||||
|
const x = startX + (index * horizontalSpacing);
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
id: nodeId,
|
||||||
|
type: 'blog_post',
|
||||||
|
title: post.title,
|
||||||
|
blogSlug: post.slug,
|
||||||
|
position: {
|
||||||
|
x: x,
|
||||||
|
y: startY
|
||||||
|
},
|
||||||
|
width: nodeWidth,
|
||||||
|
inputs: index > 0 ? [{
|
||||||
|
id: 'exec_in',
|
||||||
|
name: 'Previous',
|
||||||
|
direction: 'input',
|
||||||
|
kind: 'exec'
|
||||||
|
}] : [],
|
||||||
|
outputs: index < posts.length - 1 ? [{
|
||||||
|
id: 'exec_out',
|
||||||
|
name: 'Next',
|
||||||
|
direction: 'output',
|
||||||
|
kind: 'exec'
|
||||||
|
}] : []
|
||||||
|
};
|
||||||
|
|
||||||
|
blogNodes.push(node);
|
||||||
|
|
||||||
|
// Create connection to next post
|
||||||
|
if (index < posts.length - 1) {
|
||||||
|
const nextNodeId = `blog_${posts[index + 1].slug}`;
|
||||||
|
blogConnections.push({
|
||||||
|
id: `blog_connection_${index}`,
|
||||||
|
from: {
|
||||||
|
nodeId: nodeId,
|
||||||
|
pinId: 'exec_out'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
nodeId: nextNodeId,
|
||||||
|
pinId: 'exec_in'
|
||||||
|
},
|
||||||
|
kind: 'exec'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add blog nodes and connections to graph
|
||||||
|
graphData.nodes.push(...blogNodes);
|
||||||
|
graphData.connections.push(...blogConnections);
|
||||||
|
|
||||||
|
await fs.writeFile(graphPath, JSON.stringify(graphData, null, 2));
|
||||||
|
console.log(`✓ Added ${blogNodes.length} blog node(s) to graph`);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy files recursively from website to build
|
// Copy files recursively from website to build
|
||||||
async function copyWebsiteFiles() {
|
async function copyWebsiteFiles() {
|
||||||
const websiteDir = path.join(__dirname, 'website');
|
const websiteDir = path.join(__dirname, 'website');
|
||||||
@@ -43,4 +216,9 @@ async function copyWebsiteFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the build
|
// Run the build
|
||||||
copyWebsiteFiles();
|
async function build() {
|
||||||
|
await processBlogPosts();
|
||||||
|
await copyWebsiteFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
|
|||||||
125
website/data/blog/markdown-test.md
Normal file
125
website/data/blog/markdown-test.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
title: Markdown Formatting Test
|
||||||
|
date: 2026-04-20
|
||||||
|
author: Test Author
|
||||||
|
tags: test, formatting, markdown
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heading 1
|
||||||
|
|
||||||
|
## Heading 2
|
||||||
|
|
||||||
|
### Heading 3
|
||||||
|
|
||||||
|
#### Heading 4
|
||||||
|
|
||||||
|
##### Heading 5
|
||||||
|
|
||||||
|
###### Heading 6
|
||||||
|
|
||||||
|
## Text Formatting
|
||||||
|
|
||||||
|
This is **bold text** and this is *italic text* and this is ***bold italic text***.
|
||||||
|
|
||||||
|
Here's some `inline code` in a sentence.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
Here's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
Unordered list:
|
||||||
|
- First item
|
||||||
|
- Second item
|
||||||
|
- Third item
|
||||||
|
|
||||||
|
Ordered list:
|
||||||
|
1. First step
|
||||||
|
2. Second step
|
||||||
|
3. Third step
|
||||||
|
|
||||||
|
## Code Blocks
|
||||||
|
|
||||||
|
Standard JavaScript:
|
||||||
|
```javascript
|
||||||
|
const hello = "world";
|
||||||
|
console.log(hello);
|
||||||
|
|
||||||
|
function test() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Python with syntax highlighting:
|
||||||
|
```python
|
||||||
|
def hello_world():
|
||||||
|
print("Hello, World!")
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
Code with max height (200px):
|
||||||
|
```javascript {maxHeight: 200}
|
||||||
|
// This is a long code block that will scroll
|
||||||
|
const data = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
function processData(arr) {
|
||||||
|
return arr.map(x => x * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(processData(data));
|
||||||
|
|
||||||
|
// Adding more lines to demonstrate scrolling
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
console.log(`Iteration ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even more content
|
||||||
|
const obj = {
|
||||||
|
name: "Test",
|
||||||
|
value: 42,
|
||||||
|
nested: {
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blockquotes
|
||||||
|
|
||||||
|
> This is a blockquote.
|
||||||
|
> It can span multiple lines.
|
||||||
|
> And continues here.
|
||||||
|
|
||||||
|
## Horizontal Rule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Mixed Content
|
||||||
|
|
||||||
|
You can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.
|
||||||
|
|
||||||
|
### Nested Lists
|
||||||
|
|
||||||
|
- Top level item
|
||||||
|
- Another top level
|
||||||
|
- Nested item (if supported)
|
||||||
|
- Another nested
|
||||||
|
|
||||||
|
1. Numbered item
|
||||||
|
2. Another numbered
|
||||||
|
- Mixed with bullets
|
||||||
|
- More bullets
|
||||||
|
|
||||||
|
## Special Characters
|
||||||
|
|
||||||
|
Testing special chars: < > & " '
|
||||||
|
|
||||||
|
## Long Paragraph
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||||
|
|
||||||
|
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||||
52
website/data/blog/node-graphs.md
Normal file
52
website/data/blog/node-graphs.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
title: Building Interactive Node Graphs
|
||||||
|
date: 2026-04-15
|
||||||
|
author: Max Litruv Boonzaayer
|
||||||
|
tags: javascript, graph-systems, tutorial
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building Interactive Node Graphs
|
||||||
|
|
||||||
|
In this post, I'll share some insights on building interactive node-based graph systems for the web.
|
||||||
|
|
||||||
|
## Why Node Graphs?
|
||||||
|
|
||||||
|
Node graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:
|
||||||
|
|
||||||
|
- **Visual Clarity**: See the flow of data and execution at a glance
|
||||||
|
- **Modularity**: Each node is a self-contained unit
|
||||||
|
- **Flexibility**: Easy to add new node types and behaviors
|
||||||
|
|
||||||
|
## The Architecture
|
||||||
|
|
||||||
|
The system is built on several key classes:
|
||||||
|
|
||||||
|
### NodeBase
|
||||||
|
The abstract base class that all nodes inherit from. It defines the blueprint interface:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class NodeBase {
|
||||||
|
static NodeType = "";
|
||||||
|
static BlueprintPure_GetDefaultPins() { }
|
||||||
|
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }
|
||||||
|
async BlueprintCallable_Execute(ctx) { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node Registry
|
||||||
|
Automatically registers node types and creates instances based on type strings.
|
||||||
|
|
||||||
|
### Execution Context
|
||||||
|
Handles the graph traversal and execution flow when nodes are triggered.
|
||||||
|
|
||||||
|
## Creating Custom Nodes
|
||||||
|
|
||||||
|
To create a new node type:
|
||||||
|
|
||||||
|
1. Extend `NodeBase`
|
||||||
|
2. Set a unique `NodeType`
|
||||||
|
3. Define pins with `BlueprintPure_GetDefaultPins()`
|
||||||
|
4. Implement rendering in `BlueprintNativeEvent_OnRender()`
|
||||||
|
5. Implement logic in `BlueprintCallable_Execute()`
|
||||||
|
|
||||||
|
That's the blueprint system in a nutshell!
|
||||||
46
website/data/blog/welcome.md
Normal file
46
website/data/blog/welcome.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Welcome to My Blog
|
||||||
|
date: 2026-04-19
|
||||||
|
author: Max Litruv Boonzaayer
|
||||||
|
tags: welcome, meta, introduction
|
||||||
|
---
|
||||||
|
|
||||||
|
# Welcome to My Blog
|
||||||
|
|
||||||
|
This is my first blog post! I'm excited to share my thoughts and experiences with you.
|
||||||
|
|
||||||
|
## What This Blog Is About
|
||||||
|
|
||||||
|
This blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 📝 Markdown support with YAML front matter
|
||||||
|
- 📅 Date-based organization
|
||||||
|
- 🏷️ Tag system for categorization
|
||||||
|
- 🎨 Interactive node-based visualization
|
||||||
|
- 📁 Media support in `data/blog/media/`
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
The build system automatically:
|
||||||
|
1. Scans the `data/blog/` directory for `.md` files
|
||||||
|
2. Parses YAML front matter for metadata
|
||||||
|
3. Generates a `blogs.json` file
|
||||||
|
4. Makes posts available to the BlogPostNode
|
||||||
|
|
||||||
|
You can reference images like this:
|
||||||
|

|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Here's some example code:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const greeting = "Hello, World!";
|
||||||
|
console.log(greeting);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Stay tuned for more posts! This is just the beginning of an exciting journey.
|
||||||
38
website/data/blogs.json
Normal file
38
website/data/blogs.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "markdown-test",
|
||||||
|
"title": "Markdown Formatting Test",
|
||||||
|
"date": "2026-04-20",
|
||||||
|
"author": "Test Author",
|
||||||
|
"tags": [
|
||||||
|
"test",
|
||||||
|
"formatting",
|
||||||
|
"markdown"
|
||||||
|
],
|
||||||
|
"content": "\r\n# Heading 1\r\n\r\n## Heading 2\r\n\r\n### Heading 3\r\n\r\n#### Heading 4\r\n\r\n##### Heading 5\r\n\r\n###### Heading 6\r\n\r\n## Text Formatting\r\n\r\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\r\n\r\nHere's some `inline code` in a sentence.\r\n\r\n## Links\r\n\r\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\r\n\r\n## Lists\r\n\r\nUnordered list:\r\n- First item\r\n- Second item\r\n- Third item\r\n\r\nOrdered list:\r\n1. First step\r\n2. Second step\r\n3. Third step\r\n\r\n## Code Blocks\r\n\r\nStandard JavaScript:\r\n```javascript\r\nconst hello = \"world\";\r\nconsole.log(hello);\r\n\r\nfunction test() {\r\n return true;\r\n}\r\n```\r\n\r\nPython with syntax highlighting:\r\n```python\r\ndef hello_world():\r\n print(\"Hello, World!\")\r\n return True\r\n```\r\n\r\nCode with max height (200px):\r\n```javascript {maxHeight: 200}\r\n// This is a long code block that will scroll\r\nconst data = [1, 2, 3, 4, 5];\r\n\r\nfunction processData(arr) {\r\n return arr.map(x => x * 2);\r\n}\r\n\r\nconsole.log(processData(data));\r\n\r\n// Adding more lines to demonstrate scrolling\r\nfor (let i = 0; i < 10; i++) {\r\n console.log(`Iteration ${i}`);\r\n}\r\n\r\n// Even more content\r\nconst obj = {\r\n name: \"Test\",\r\n value: 42,\r\n nested: {\r\n deep: true\r\n }\r\n};\r\n```\r\n\r\n## Blockquotes\r\n\r\n> This is a blockquote.\r\n> It can span multiple lines.\r\n> And continues here.\r\n\r\n## Horizontal Rule\r\n\r\n---\r\n\r\n## Images\r\n\r\n\r\n\r\n## Mixed Content\r\n\r\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.\r\n\r\n### Nested Lists\r\n\r\n- Top level item\r\n- Another top level\r\n - Nested item (if supported)\r\n - Another nested\r\n\r\n1. Numbered item\r\n2. Another numbered\r\n - Mixed with bullets\r\n - More bullets\r\n\r\n## Special Characters\r\n\r\nTesting special chars: < > & \" ' \r\n\r\n## Long Paragraph\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\r\n\r\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "welcome",
|
||||||
|
"title": "Welcome to My Blog",
|
||||||
|
"date": "2026-04-19",
|
||||||
|
"author": "Max Litruv Boonzaayer",
|
||||||
|
"tags": [
|
||||||
|
"welcome",
|
||||||
|
"meta",
|
||||||
|
"introduction"
|
||||||
|
],
|
||||||
|
"content": "\r\n# Welcome to My Blog\r\n\r\nThis is my first blog post! I'm excited to share my thoughts and experiences with you.\r\n\r\n## What This Blog Is About\r\n\r\nThis blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?\r\n\r\n### Features\r\n\r\n- 📝 Markdown support with YAML front matter\r\n- 📅 Date-based organization\r\n- 🏷️ Tag system for categorization\r\n- 🎨 Interactive node-based visualization\r\n- 📁 Media support in `data/blog/media/`\r\n\r\n## Technical Details\r\n\r\nThe build system automatically:\r\n1. Scans the `data/blog/` directory for `.md` files\r\n2. Parses YAML front matter for metadata\r\n3. Generates a `blogs.json` file\r\n4. Makes posts available to the BlogPostNode\r\n\r\nYou can reference images like this:\r\n\r\n\r\n## Code Examples\r\n\r\nHere's some example code:\r\n\r\n```javascript\r\nconst greeting = \"Hello, World!\";\r\nconsole.log(greeting);\r\n```\r\n\r\n## Conclusion\r\n\r\nStay tuned for more posts! This is just the beginning of an exciting journey.\r\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "node-graphs",
|
||||||
|
"title": "Building Interactive Node Graphs",
|
||||||
|
"date": "2026-04-15",
|
||||||
|
"author": "Max Litruv Boonzaayer",
|
||||||
|
"tags": [
|
||||||
|
"javascript",
|
||||||
|
"graph-systems",
|
||||||
|
"tutorial"
|
||||||
|
],
|
||||||
|
"content": "\r\n# Building Interactive Node Graphs\r\n\r\nIn this post, I'll share some insights on building interactive node-based graph systems for the web.\r\n\r\n## Why Node Graphs?\r\n\r\nNode graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:\r\n\r\n- **Visual Clarity**: See the flow of data and execution at a glance\r\n- **Modularity**: Each node is a self-contained unit\r\n- **Flexibility**: Easy to add new node types and behaviors\r\n\r\n## The Architecture\r\n\r\nThe system is built on several key classes:\r\n\r\n### NodeBase\r\nThe abstract base class that all nodes inherit from. It defines the blueprint interface:\r\n\r\n```javascript\r\nclass NodeBase {\r\n static NodeType = \"\";\r\n static BlueprintPure_GetDefaultPins() { }\r\n BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }\r\n async BlueprintCallable_Execute(ctx) { }\r\n}\r\n```\r\n\r\n### Node Registry\r\nAutomatically registers node types and creates instances based on type strings.\r\n\r\n### Execution Context\r\nHandles the graph traversal and execution flow when nodes are triggered.\r\n\r\n## Creating Custom Nodes\r\n\r\nTo create a new node type:\r\n\r\n1. Extend `NodeBase`\r\n2. Set a unique `NodeType`\r\n3. Define pins with `BlueprintPure_GetDefaultPins()`\r\n4. Implement rendering in `BlueprintNativeEvent_OnRender()`\r\n5. Implement logic in `BlueprintCallable_Execute()`\r\n\r\nThat's the blueprint system in a nutshell!\r\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
"type": "sequence",
|
"type": "sequence",
|
||||||
"title": "Sequence",
|
"title": "Sequence",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1180,
|
"x": 1080,
|
||||||
"y": 120
|
"y": 20
|
||||||
},
|
},
|
||||||
"focusOptions": {
|
"focusOptions": {
|
||||||
"minWorldBox": {
|
"minWorldBox": {
|
||||||
@@ -137,8 +137,8 @@
|
|||||||
"type": "info",
|
"type": "info",
|
||||||
"title": "Who Am I",
|
"title": "Who Am I",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1780,
|
"x": 1600,
|
||||||
"y": -640
|
"y": -860
|
||||||
},
|
},
|
||||||
"width": 340,
|
"width": 340,
|
||||||
"markdownSrc": "data/whoami.md",
|
"markdownSrc": "data/whoami.md",
|
||||||
@@ -186,8 +186,8 @@
|
|||||||
"type": "info",
|
"type": "info",
|
||||||
"title": "Find Me Online",
|
"title": "Find Me Online",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2460,
|
"x": 2420,
|
||||||
"y": 140
|
"y": -280
|
||||||
},
|
},
|
||||||
"markdownSrc": "data/socials.md",
|
"markdownSrc": "data/socials.md",
|
||||||
"focusOptions": {
|
"focusOptions": {
|
||||||
@@ -211,8 +211,8 @@
|
|||||||
"type": "pure",
|
"type": "pure",
|
||||||
"title": "String Node",
|
"title": "String Node",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2260,
|
"x": 2180,
|
||||||
"y": -280
|
"y": -500
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -230,8 +230,8 @@
|
|||||||
"type": "button",
|
"type": "button",
|
||||||
"title": "Run",
|
"title": "Run",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2180,
|
"x": 2100,
|
||||||
"y": -360
|
"y": -600
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -248,8 +248,8 @@
|
|||||||
"type": "timer",
|
"type": "timer",
|
||||||
"title": "Timer",
|
"title": "Timer",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2180,
|
"x": 2100,
|
||||||
"y": -460
|
"y": -700
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -276,8 +276,8 @@
|
|||||||
"type": "print",
|
"type": "print",
|
||||||
"title": "Print String",
|
"title": "Print String",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2540,
|
"x": 2380,
|
||||||
"y": -460
|
"y": -680
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -308,8 +308,8 @@
|
|||||||
"type": "pure",
|
"type": "pure",
|
||||||
"title": "Homeserver",
|
"title": "Homeserver",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1520,
|
"x": 1480,
|
||||||
"y": 1020
|
"y": 340
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -327,8 +327,8 @@
|
|||||||
"type": "pure",
|
"type": "pure",
|
||||||
"title": "Room",
|
"title": "Room",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1520,
|
"x": 1480,
|
||||||
"y": 1100
|
"y": 420
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -346,8 +346,8 @@
|
|||||||
"type": "chat",
|
"type": "chat",
|
||||||
"title": "Matrix Chat",
|
"title": "Matrix Chat",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1800,
|
"x": 1740,
|
||||||
"y": 920
|
"y": 220
|
||||||
},
|
},
|
||||||
"width": 400,
|
"width": 400,
|
||||||
"focusOptions": {
|
"focusOptions": {
|
||||||
@@ -420,8 +420,8 @@
|
|||||||
"type": "random_name",
|
"type": "random_name",
|
||||||
"title": "Random Name",
|
"title": "Random Name",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1520,
|
"x": 1480,
|
||||||
"y": 1180
|
"y": 500
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -438,8 +438,8 @@
|
|||||||
"type": "button",
|
"type": "button",
|
||||||
"title": "Connect Chat",
|
"title": "Connect Chat",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1960,
|
"x": 1920,
|
||||||
"y": 820
|
"y": 120
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -456,8 +456,8 @@
|
|||||||
"type": "chat_connect",
|
"type": "chat_connect",
|
||||||
"title": "Connect Chat",
|
"title": "Connect Chat",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2220,
|
"x": 2260,
|
||||||
"y": 880
|
"y": 220
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -487,8 +487,8 @@
|
|||||||
"type": "get_matrix_chat",
|
"type": "get_matrix_chat",
|
||||||
"title": "Get MatrixChat",
|
"title": "Get MatrixChat",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 1520,
|
"x": 1500,
|
||||||
"y": 940
|
"y": 240
|
||||||
},
|
},
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -506,8 +506,8 @@
|
|||||||
"type": "bind_event",
|
"type": "bind_event",
|
||||||
"title": "Bind: Chat On Message",
|
"title": "Bind: Chat On Message",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2460,
|
"x": 2540,
|
||||||
"y": 880
|
"y": 220
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -557,8 +557,8 @@
|
|||||||
"type": "append",
|
"type": "append",
|
||||||
"title": "Append",
|
"title": "Append",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2700,
|
"x": 2780,
|
||||||
"y": 960
|
"y": 280
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -597,8 +597,8 @@
|
|||||||
"type": "print",
|
"type": "print",
|
||||||
"title": "Print Chat Message",
|
"title": "Print Chat Message",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 2940,
|
"x": 3040,
|
||||||
"y": 900
|
"y": 220
|
||||||
},
|
},
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -623,6 +623,73 @@
|
|||||||
"kind": "exec"
|
"kind": "exec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog_markdown-test",
|
||||||
|
"type": "blog_post",
|
||||||
|
"title": "Markdown Formatting Test",
|
||||||
|
"blogSlug": "markdown-test",
|
||||||
|
"position": {
|
||||||
|
"x": 1500,
|
||||||
|
"y": 900
|
||||||
|
},
|
||||||
|
"width": 600,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "exec_out",
|
||||||
|
"name": "Next",
|
||||||
|
"direction": "output",
|
||||||
|
"kind": "exec"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog_welcome",
|
||||||
|
"type": "blog_post",
|
||||||
|
"title": "Welcome to My Blog",
|
||||||
|
"blogSlug": "welcome",
|
||||||
|
"position": {
|
||||||
|
"x": 2200,
|
||||||
|
"y": 900
|
||||||
|
},
|
||||||
|
"width": 600,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "exec_in",
|
||||||
|
"name": "Previous",
|
||||||
|
"direction": "input",
|
||||||
|
"kind": "exec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "exec_out",
|
||||||
|
"name": "Next",
|
||||||
|
"direction": "output",
|
||||||
|
"kind": "exec"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog_node-graphs",
|
||||||
|
"type": "blog_post",
|
||||||
|
"title": "Building Interactive Node Graphs",
|
||||||
|
"blogSlug": "node-graphs",
|
||||||
|
"position": {
|
||||||
|
"x": 2900,
|
||||||
|
"y": 900
|
||||||
|
},
|
||||||
|
"width": 600,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "exec_in",
|
||||||
|
"name": "Previous",
|
||||||
|
"direction": "input",
|
||||||
|
"kind": "exec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": [
|
"connections": [
|
||||||
@@ -853,6 +920,30 @@
|
|||||||
"pinId": "target"
|
"pinId": "target"
|
||||||
},
|
},
|
||||||
"kind": "object"
|
"kind": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog_connection_0",
|
||||||
|
"from": {
|
||||||
|
"nodeId": "blog_markdown-test",
|
||||||
|
"pinId": "exec_out"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"nodeId": "blog_welcome",
|
||||||
|
"pinId": "exec_in"
|
||||||
|
},
|
||||||
|
"kind": "exec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog_connection_1",
|
||||||
|
"from": {
|
||||||
|
"nodeId": "blog_welcome",
|
||||||
|
"pinId": "exec_out"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"nodeId": "blog_node-graphs",
|
||||||
|
"pinId": "exec_in"
|
||||||
|
},
|
||||||
|
"kind": "exec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,15 @@
|
|||||||
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
|
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles/main.css">
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||||
<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
|
<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
export class GraphNode {
|
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) {
|
constructor(init) {
|
||||||
this.id = init.id;
|
this.id = init.id;
|
||||||
@@ -31,6 +31,8 @@ export class GraphNode {
|
|||||||
this.imageSrc = init.imageSrc;
|
this.imageSrc = init.imageSrc;
|
||||||
/** @type {string | undefined} */
|
/** @type {string | undefined} */
|
||||||
this.lottieSrc = init.lottieSrc;
|
this.lottieSrc = init.lottieSrc;
|
||||||
|
/** @type {string | undefined} */
|
||||||
|
this.blogSlug = init.blogSlug;
|
||||||
/** @type {boolean | undefined} */
|
/** @type {boolean | undefined} */
|
||||||
this.userSpawned = init.userSpawned;
|
this.userSpawned = init.userSpawned;
|
||||||
/** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */
|
/** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */
|
||||||
|
|||||||
@@ -37,9 +37,22 @@ export class MarkdownRenderer {
|
|||||||
.replace(/\r\n/g, "\n")
|
.replace(/\r\n/g, "\n")
|
||||||
.replace(/\r/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
|
// Split into blocks separated by blank lines
|
||||||
const blocks = html.split(/\n{2,}/);
|
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");
|
return parts.filter(Boolean).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +66,32 @@ export class MarkdownRenderer {
|
|||||||
if (!block) return "";
|
if (!block) return "";
|
||||||
|
|
||||||
// Fenced code block
|
// Fenced code block
|
||||||
const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/);
|
const fenceMatch = block.match(/^```(\w*)(?:\s*\{([^}]+)\})?\s*\n([\s\S]*?)\n?```$/);
|
||||||
if (fenceMatch) {
|
if (fenceMatch) {
|
||||||
const lang = MarkdownRenderer.#escape(fenceMatch[1] || "");
|
const lang = fenceMatch[1] || "";
|
||||||
const code = MarkdownRenderer.#escape(fenceMatch[2]);
|
const options = fenceMatch[2] || "";
|
||||||
return `<pre class="md-pre"><code${lang ? ` class="lang-${lang}"` : ""}>${code}</code></pre>`;
|
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
|
// Horizontal rule
|
||||||
@@ -66,7 +100,7 @@ export class MarkdownRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Headings
|
// Headings
|
||||||
const headingMatch = block.match(/^(#{1,3})\s+(.+)$/m);
|
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m);
|
||||||
if (headingMatch && block.split("\n").length === 1) {
|
if (headingMatch && block.split("\n").length === 1) {
|
||||||
const level = headingMatch[1].length;
|
const level = headingMatch[1].length;
|
||||||
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
|
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export class WorkspaceNavigator {
|
|||||||
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
|
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
|
||||||
#smoothZoomFocus = 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} */
|
/** @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;
|
#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;
|
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();
|
e.preventDefault();
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
|
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
|
// Do not start a pan while a node drag is actively in progress
|
||||||
if (this.#isDragInProgress?.()) return;
|
if (this.#isDragInProgress?.()) return;
|
||||||
|
|
||||||
|
// Cancel any in-progress smooth pan
|
||||||
|
if (this.#smoothPanRafId !== null) {
|
||||||
|
cancelAnimationFrame(this.#smoothPanRafId);
|
||||||
|
this.#smoothPanRafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
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 ────────────────────────────────────────────────────
|
// ─── Transform helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @param {{ x: number, y: number }} offset */
|
/** @param {{ x: number, y: number }} offset */
|
||||||
@@ -760,6 +851,8 @@ export class WorkspaceNavigator {
|
|||||||
x: Number.isFinite(offset.x) ? offset.x : 0,
|
x: Number.isFinite(offset.x) ? offset.x : 0,
|
||||||
y: Number.isFinite(offset.y) ? offset.y : 0,
|
y: Number.isFinite(offset.y) ? offset.y : 0,
|
||||||
};
|
};
|
||||||
|
// Keep target pan offset in sync
|
||||||
|
this.#targetPanOffset = { ...this.#backgroundOffset };
|
||||||
this.#applyBackgroundOffset();
|
this.#applyBackgroundOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
website/scripts/nodes/BlogPostNode.js
Normal file
114
website/scripts/nodes/BlogPostNode.js
Normal 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);
|
||||||
@@ -16,6 +16,7 @@ export { CustomEventNode } from './CustomEventNode.js';
|
|||||||
export { ButtonNode } from './ButtonNode.js';
|
export { ButtonNode } from './ButtonNode.js';
|
||||||
export { TimerNode } from './TimerNode.js';
|
export { TimerNode } from './TimerNode.js';
|
||||||
export { InfoNode } from './InfoNode.js';
|
export { InfoNode } from './InfoNode.js';
|
||||||
|
export { BlogPostNode } from './BlogPostNode.js';
|
||||||
export { PureNode } from './PureNode.js';
|
export { PureNode } from './PureNode.js';
|
||||||
export { MatrixChatNode } from './MatrixChatNode.js';
|
export { MatrixChatNode } from './MatrixChatNode.js';
|
||||||
export { GetMatrixChatNode } from './GetMatrixChatNode.js';
|
export { GetMatrixChatNode } from './GetMatrixChatNode.js';
|
||||||
|
|||||||
@@ -1332,6 +1332,26 @@ svg.world-image {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
overflow-y: visible;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-body .md-h1,
|
.node-body .md-h1,
|
||||||
@@ -1385,10 +1405,95 @@ svg.world-image {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.5em 0.7em;
|
padding: 0.5em 0.7em;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace;
|
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace;
|
||||||
font-size: 0.76em;
|
font-size: 0.76em;
|
||||||
color: #a8daff;
|
color: #a8daff;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prism syntax highlighting overrides */
|
||||||
|
.node-body .md-pre .token.comment,
|
||||||
|
.node-body .md-pre .token.prolog,
|
||||||
|
.node-body .md-pre .token.doctype,
|
||||||
|
.node-body .md-pre .token.cdata {
|
||||||
|
color: #6a9955;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.punctuation {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.property,
|
||||||
|
.node-body .md-pre .token.tag,
|
||||||
|
.node-body .md-pre .token.boolean,
|
||||||
|
.node-body .md-pre .token.number,
|
||||||
|
.node-body .md-pre .token.constant,
|
||||||
|
.node-body .md-pre .token.symbol,
|
||||||
|
.node-body .md-pre .token.deleted {
|
||||||
|
color: #b5cea8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.selector,
|
||||||
|
.node-body .md-pre .token.attr-name,
|
||||||
|
.node-body .md-pre .token.string,
|
||||||
|
.node-body .md-pre .token.char,
|
||||||
|
.node-body .md-pre .token.builtin,
|
||||||
|
.node-body .md-pre .token.inserted {
|
||||||
|
color: #ce9178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.operator,
|
||||||
|
.node-body .md-pre .token.entity,
|
||||||
|
.node-body .md-pre .token.url,
|
||||||
|
.node-body .md-pre .language-css .token.string,
|
||||||
|
.node-body .md-pre .style .token.string {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.atrule,
|
||||||
|
.node-body .md-pre .token.attr-value,
|
||||||
|
.node-body .md-pre .token.keyword {
|
||||||
|
color: #569cd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.function,
|
||||||
|
.node-body .md-pre .token.class-name {
|
||||||
|
color: #dcdcaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-body .md-pre .token.regex,
|
||||||
|
.node-body .md-pre .token.important,
|
||||||
|
.node-body .md-pre .token.variable {
|
||||||
|
color: #9cdcfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-body .md-hr {
|
.node-body .md-hr {
|
||||||
|
|||||||
Reference in New Issue
Block a user