update to node site

This commit is contained in:
2026-04-15 05:02:45 +10:00
parent 3c45406f6f
commit 7e7e6f2c22
710 changed files with 10532 additions and 8324 deletions

View File

@@ -0,0 +1,183 @@
import { ChatCommands } from './ChatCommands.js';
/**
* Autocomplete popup for slash commands and @mentions.
*/
export class ChatAutocomplete {
#items = [];
#selectedIndex = -1;
#trigger = null;
/**
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} containerEl
*/
constructor(inputEl, containerEl) {
this.inputEl = inputEl;
this.el = document.createElement('div');
this.el.className = 'autocomplete-popup';
this.el.style.display = 'none';
containerEl.appendChild(this.el);
inputEl.addEventListener('input', () => this.#onInput());
inputEl.addEventListener('keydown', (e) => this.#onKeydown(e));
inputEl.addEventListener('blur', () => setTimeout(() => this.hide(), 150));
}
#getMembersMap = () => new Map();
/**
* @param {() => Map<string, {displayName: string|null}>} fn
*/
setMembersProvider(fn) {
this.#getMembersMap = fn;
}
get isVisible() {
return this.el.style.display !== 'none';
}
#onInput() {
const val = this.inputEl.value;
const cursor = this.inputEl.selectionStart ?? val.length;
if (val.startsWith('/') && !val.slice(1).includes(' ')) {
const query = val.slice(1).toLowerCase();
const matches = ChatCommands.COMMANDS.filter(c =>
c.name.startsWith(query) || c.aliases.some(a => a.startsWith(query))
);
if (matches.length) {
this.#trigger = { type: 'command', start: 0 };
this.#show(matches.map(c => ({
label: `/${c.name}`,
hint: c.args || '',
desc: c.desc,
completion: `/${c.name} `,
})));
return;
}
}
const textBeforeCursor = val.slice(0, cursor);
const mentionMatch = textBeforeCursor.match(/(^|\s)@(\S*)$/);
if (mentionMatch) {
const query = mentionMatch[2].toLowerCase();
const atIndex = textBeforeCursor.lastIndexOf('@');
const members = [...this.#getMembersMap().entries()]
.filter(([uid, info]) => {
const name = (info.displayName || '').toLowerCase();
const localpart = uid.match(/^@([^:]+):/)?.[1]?.toLowerCase() ?? '';
return name.startsWith(query) || localpart.startsWith(query);
})
.slice(0, 10);
if (members.length) {
this.#trigger = { type: 'mention', start: atIndex };
this.#show(members.map(([uid, info]) => ({
label: info.displayName || uid.match(/^@([^:]+):/)?.[1] || uid,
hint: uid,
desc: '',
completion: uid,
})));
return;
}
}
this.hide();
}
#show(items) {
this.#items = items;
this.#selectedIndex = items.length ? 0 : -1;
this.#render();
this.el.style.display = 'block';
}
#render() {
this.el.innerHTML = '';
this.#items.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'ac-item' + (i === this.#selectedIndex ? ' selected' : '');
row.appendChild(Object.assign(document.createElement('span'), {
className: 'ac-label',
textContent: item.label,
}));
if (item.hint) {
row.appendChild(Object.assign(document.createElement('span'), {
className: 'ac-hint',
textContent: item.hint,
}));
}
if (item.desc) {
row.appendChild(Object.assign(document.createElement('span'), {
className: 'ac-desc',
textContent: item.desc,
}));
}
row.addEventListener('mousedown', (e) => {
e.preventDefault();
this.#apply(i);
});
this.el.appendChild(row);
});
}
#onKeydown(e) {
if (!this.isVisible) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
this.#selectedIndex = (this.#selectedIndex + 1) % this.#items.length;
this.#render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.#selectedIndex = (this.#selectedIndex - 1 + this.#items.length) % this.#items.length;
this.#render();
} else if (e.key === 'Tab') {
e.preventDefault();
if (this.#selectedIndex >= 0) this.#apply(this.#selectedIndex);
} else if (e.key === 'Enter') {
if (this.#selectedIndex >= 0) {
e.preventDefault();
e.stopImmediatePropagation();
this.#apply(this.#selectedIndex);
}
} else if (e.key === 'Escape') {
e.preventDefault();
this.hide();
}
}
#apply(index) {
const item = this.#items[index];
if (!item || !this.#trigger) return;
const val = this.inputEl.value;
const cursor = this.inputEl.selectionStart ?? val.length;
if (this.#trigger.type === 'command') {
this.inputEl.value = item.completion;
this.inputEl.selectionStart = this.inputEl.selectionEnd = item.completion.length;
} else {
const before = val.slice(0, this.#trigger.start);
const after = val.slice(cursor);
const inserted = item.completion + ' ';
this.inputEl.value = before + inserted + after;
const pos = before.length + inserted.length;
this.inputEl.selectionStart = this.inputEl.selectionEnd = pos;
}
this.hide();
this.inputEl.focus();
this.inputEl.dispatchEvent(new Event('input'));
}
hide() {
this.#items = [];
this.#selectedIndex = -1;
this.#trigger = null;
this.el.style.display = 'none';
}
}

View File

@@ -0,0 +1,63 @@
/**
* Handles IRC-style slash commands for the chat node.
*/
export class ChatCommands {
static COMMANDS = [
{ name: 'nick', args: '<name>', desc: 'Change display name', aliases: [] },
{ name: 'help', args: '', desc: 'Show command list', aliases: [] },
];
/**
* @param {import('./ChatNode.js').ChatNode} chat
*/
constructor(chat) {
this.chat = chat;
}
get client() { return this.chat.getClient(); }
/**
* @param {string} message
*/
async handle(message) {
const parts = message.slice(1).split(' ');
const command = parts[0].toLowerCase();
const args = parts.slice(1);
const aliasMap = new Map();
for (const def of ChatCommands.COMMANDS) {
aliasMap.set(def.name, def.name);
for (const alias of def.aliases) aliasMap.set(alias, def.name);
}
const canonical = aliasMap.get(command);
if (!canonical || typeof this[canonical] !== 'function') {
this.chat.addErrorMessage(`Unknown command: /${command}. Type /help for commands.`);
return;
}
await this[canonical](args);
}
async nick(args) {
if (!args[0]) {
this.chat.addErrorMessage('Usage: /nick NewNickname');
return;
}
try {
if (!await this.client.setDisplayName(args[0])) {
throw new Error('Server refused');
}
this.chat.addSystemMessage(`Nickname changed to ${args[0]}`);
} catch (e) {
this.chat.addErrorMessage(`Failed to change nickname: ${e.message}`);
}
}
help() {
[
'─── Commands ───────────────────────',
'Profile: /nick <name>',
'─────────────────────────────────────',
].forEach(l => this.chat.addSystemMessage(l));
}
}

500
website/scripts/ChatNode.js Normal file
View File

@@ -0,0 +1,500 @@
import MxjsClient, { ClientEvents } from 'https://unpkg.com/@litruv/mxjs-lite/dist/mxjs-lite.min.js';
import { ChatCommands } from './ChatCommands.js';
import { ChatAutocomplete } from './ChatAutocomplete.js';
/**
* Handles chat functionality for a Matrix room using mxjs-lite.
*/
export class ChatNode {
/** @type {MxjsClient | null} */
#client = null;
/** @type {string | null} */
#roomId = null;
/** @type {HTMLElement | null} */
#messagesContainer = null;
/** @type {HTMLInputElement | null} */
#messageInput = null;
/** @type {HTMLElement | null} */
#statusElement = null;
/** @type {string} */
#homeserver = '';
/** @type {string} */
#roomAlias = '';
/** @type {boolean} */
#isConnected = false;
/** @type {Map<string, { displayName: string, avatarUrl: string | null }>} */
#members = new Map();
/** @type {Set<string>} */
#renderedEventIds = new Set();
/** @type {boolean} */
#isLoadingHistory = false;
/** @type {((username: string, message: string) => void) | null} */
#onMessageCallback = null;
/** @type {ChatCommands | null} */
#commands = null;
/** @type {ChatAutocomplete | null} */
#autocomplete = null;
/** @type {string} */
#storageKey = '';
/** @type {string | (() => string)} */
#guestName = '';
/**
* @param {string} homeserver Matrix homeserver URL
* @param {string} roomAlias Room alias to join (e.g., #general:matrix.org)
*/
constructor(homeserver, roomAlias) {
this.#homeserver = homeserver;
this.#roomAlias = roomAlias;
this.#storageKey = `mxjs_chat_${homeserver}_${roomAlias}`;
}
/**
* Get the Matrix client instance for external use
* @returns {MxjsClient | null}
*/
getClient() {
return this.#client;
}
/**
* Set callback for message events
* @param {(username: string, message: string) => void} callback
*/
setOnMessage(callback) {
this.#onMessageCallback = callback;
}
/**
* Get members map for autocomplete
* @returns {Map<string, { displayName: string | null }>}
*/
getMembers() {
return this.#members;
}
/**
* Adds a system message (public for commands)
* @param {string} text
*/
addSystemMessage(text) {
this.#addSystemMessage(text);
}
/**
* Adds an error message (public for commands)
* @param {string} text
*/
addErrorMessage(text) {
this.#addErrorMessage(text);
}
/**
* Initializes the chat node with DOM elements.
*
* @param {HTMLElement} messagesContainer Container for messages
* @param {HTMLInputElement} messageInput Input field for messages
* @param {HTMLElement} statusElement Status indicator element
* @param {HTMLElement} inputContainer Input container for autocomplete
* @param {string} homeserver Homeserver URL (can be empty if provided via connection)
* @param {string} roomAlias Room alias (can be empty if provided via connection)
* @param {string | (() => string)} guestName Guest display name or function to resolve it
*/
initialize(messagesContainer, messageInput, statusElement, inputContainer, homeserver, roomAlias, guestName = '') {
this.#messagesContainer = messagesContainer;
this.#messageInput = messageInput;
this.#statusElement = statusElement;
this.#guestName = guestName;
// Use provided values or fallback to constructor values
if (homeserver) this.#homeserver = homeserver;
if (roomAlias) this.#roomAlias = roomAlias;
// Setup autocomplete
this.#autocomplete = new ChatAutocomplete(messageInput, inputContainer);
this.#autocomplete.setMembersProvider(() => this.getMembers());
// Setup commands
this.#commands = new ChatCommands(this);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !this.#autocomplete.isVisible) this.#sendMessage();
});
this.#updateStatus('disconnected', 'Not connected');
}
/**
* Try to auto-connect with saved credentials
*/
async #tryAutoConnect() {
try {
const saved = localStorage.getItem(this.#storageKey);
if (!saved) return;
const { accessToken, userId, deviceId } = JSON.parse(saved);
if (!accessToken || !userId) return;
this.#updateStatus('connecting', 'Auto-connecting...');
this.#client = new MxjsClient({ homeserver: this.#homeserver });
this.#client.accessToken = accessToken;
this.#client.userId = userId;
if (deviceId) this.#client.deviceId = deviceId;
const joinResult = await this.#client.joinRoom(this.#roomAlias);
if (!joinResult) throw new Error('Failed to join room');
this.#roomId = joinResult.roomId;
this.#bindEvents();
await this.#client.startSync(30000);
this.#isConnected = true;
this.#updateStatus('connected', 'Connected');
this.#addSystemMessage('Connected to chat');
} catch (error) {
console.error('Auto-connect failed:', error);
localStorage.removeItem(this.#storageKey);
this.#client = null;
}
}
/**
* Connects to the Matrix homeserver as a guest.
*/
async #connect() {
try {
this.#updateStatus('connecting', 'Connecting...');
this.#client = new MxjsClient({ homeserver: this.#homeserver });
const authResult = await this.#client.registerGuest();
if (!authResult) throw new Error('Guest registration failed');
// Save credentials
localStorage.setItem(this.#storageKey, JSON.stringify({
accessToken: authResult.accessToken,
userId: authResult.userId,
deviceId: authResult.deviceId || null
}));
// Set display name if provided
const nameToSet = typeof this.#guestName === 'function' ? this.#guestName() : this.#guestName;
if (nameToSet?.trim()) {
console.log('[ChatNode] Setting display name:', nameToSet);
try {
await this.#client.setDisplayName(nameToSet.trim());
console.log('[ChatNode] Display name set successfully');
} catch (e) {
console.warn('Failed to set display name:', e);
}
} else {
console.log('[ChatNode] No guest name provided, skipping setDisplayName');
}
const joinResult = await this.#client.joinRoom(this.#roomAlias);
if (!joinResult) throw new Error('Failed to join room');
this.#roomId = joinResult.roomId;
this.#bindEvents();
await this.#client.startSync(30000);
this.#isConnected = true;
this.#updateStatus('connected', 'Connected');
this.#addSystemMessage('Connected to chat');
} catch (error) {
console.error(' Chat connection error:', error);
this.#updateStatus('error', 'Connection failed');
this.#addErrorMessage(`Connection failed: ${error.message}`);
}
}
/**
* Reconnects to chat (disconnect and connect again)
*/
async #reconnect() {
this.disconnect();
this.#renderedEventIds.clear();
if (this.#messagesContainer) this.#messagesContainer.innerHTML = '';
await this.#connect();
}
/**
* Binds Matrix client events.
*/
#bindEvents() {
const c = this.#client;
if (!c) return;
c.on(ClientEvents.Ready, () => {
this.#loadHistory();
});
c.on(ClientEvents.MessageCreate, ({ roomId, event }) => {
if (roomId !== this.#roomId) return;
// Don't trigger callbacks for messages during history load
this.#renderMessage(event, this.#isLoadingHistory);
});
c.on(ClientEvents.MessageUpdate, ({ roomId, edits, newBody }) => {
if (roomId !== this.#roomId || !this.#messagesContainer) return;
const msgEl = this.#messagesContainer.querySelector(`[data-event-id="${edits}"]`);
if (!msgEl) return;
const contentEl = msgEl.querySelector('.chat-msg-content');
if (contentEl) contentEl.textContent = newBody;
if (!msgEl.querySelector('.chat-msg-edited')) {
msgEl.appendChild(Object.assign(document.createElement('span'), {
className: 'chat-msg-edited',
textContent: '(edited)'
}));
}
});
c.on(ClientEvents.MessageDelete, ({ roomId, redacts }) => {
if (roomId !== this.#roomId || !this.#messagesContainer) return;
const msgEl = this.#messagesContainer.querySelector(`[data-event-id="${redacts}"]`);
if (msgEl) msgEl.classList.add('chat-msg-deleted');
});
c.on(ClientEvents.MemberUpdate, ({ roomId, change }) => {
if (roomId !== this.#roomId) return;
if (change.type === 'join' || change.type === 'rename') {
this.#members.set(change.userId, {
displayName: change.displayName || this.#extractLocalpart(change.userId),
avatarUrl: change.avatarUrl || null
});
} else if (change.type === 'leave' || change.type === 'kick' || change.type === 'ban') {
this.#members.delete(change.userId);
}
});
}
/**
* Loads recent message history.
*/
async #loadHistory() {
if (!this.#client || !this.#roomId) return;
this.#isLoadingHistory = true;
try {
const members = await this.#client.getJoinedMembers(this.#roomId);
if (members?.members) {
for (const m of members.members) {
this.#members.set(m.userId, {
displayName: m.displayName || this.#extractLocalpart(m.userId),
avatarUrl: m.avatarUrl || null
});
}
}
const history = await this.#client.getMessages(this.#roomId, { limit: 20 });
if (!history?.messages) return;
const messages = [...history.messages].reverse();
for (const event of messages) {
if (event.event_id && this.#renderedEventIds.has(event.event_id)) continue;
if (event.type === 'm.room.message' && event.content?.body) {
this.#renderMessage(event, true);
}
}
} catch (error) {
console.error('Failed to load history:', error);
} finally {
this.#isLoadingHistory = false;
}
}
/**
* Renders a message event.
*
* @param {Object} event Matrix message event
* @param {boolean} isHistorical Whether this is a historical message (don't trigger callback)
*/
#renderMessage(event, isHistorical = false) {
if (!this.#messagesContainer || !event.content?.body) return;
if (event.event_id) {
if (this.#renderedEventIds.has(event.event_id)) return;
this.#renderedEventIds.add(event.event_id);
}
const member = this.#members.get(event.sender);
const displayName = member?.displayName || this.#extractLocalpart(event.sender);
const timestamp = event.origin_server_ts || Date.now();
const isSelf = event.sender === this.#client?.userId;
// Trigger onMessage callback only for new messages (not historical)
if (this.#onMessageCallback && !isSelf && !isHistorical) {
this.#onMessageCallback(displayName, event.content.body);
}
const msgEl = document.createElement('div');
msgEl.className = `chat-msg ${isSelf ? 'chat-msg-self' : ''}`;
if (event.event_id) msgEl.dataset.eventId = event.event_id;
const timeEl = document.createElement('span');
timeEl.className = 'chat-msg-time';
timeEl.textContent = this.#formatTime(timestamp);
const senderEl = document.createElement('span');
senderEl.className = 'chat-msg-sender';
senderEl.textContent = displayName;
const contentEl = document.createElement('span');
contentEl.className = 'chat-msg-content';
contentEl.textContent = event.content.body;
msgEl.append(timeEl, senderEl, contentEl);
this.#messagesContainer.appendChild(msgEl);
this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight;
}
/**
* Adds a system message.
*
* @param {string} text Message text
*/
#addSystemMessage(text) {
if (!this.#messagesContainer) return;
const msgEl = document.createElement('div');
msgEl.className = 'chat-msg-system';
msgEl.textContent = `*** ${text}`;
this.#messagesContainer.appendChild(msgEl);
this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight;
}
/**
* Adds an error message.
*
* @param {string} text Message text
*/
#addErrorMessage(text) {
if (!this.#messagesContainer) return;
const msgEl = document.createElement('div');
msgEl.className = 'chat-msg-error';
msgEl.textContent = `*** ERROR: ${text}`;
this.#messagesContainer.appendChild(msgEl);
this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight;
}
/**
* Sends a message to the room.
*/
async #sendMessage() {
if (!this.#client || !this.#roomId || !this.#messageInput || !this.#isConnected) return;
const message = this.#messageInput.value.trim();
if (!message) return;
this.#messageInput.disabled = true;
try {
// Handle commands
if (message.startsWith('/')) {
this.#messageInput.value = '';
await this.#commands?.handle(message);
return;
}
const result = await this.#client.sendMessage(this.#roomId, message);
if (!result?.eventId) throw new Error('Failed to send message');
this.#messageInput.value = '';
} catch (error) {
console.error('Failed to send message:', error);
this.#addErrorMessage(`Failed to send: ${error.message}`);
} finally {
this.#messageInput.disabled = false;
this.#messageInput.focus();
}
}
/**
* Formats a timestamp.
*
* @param {number} ts Timestamp in milliseconds
* @returns {string} Formatted time
*/
#formatTime(ts) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Extracts the localpart from a Matrix user ID.
*
* @param {string} userId Matrix user ID
* @returns {string} Localpart
*/
#extractLocalpart(userId) {
const match = userId.match(/^@([^:]+):/);
return match ? match[1] : userId;
}
/**
* Updates the status indicator.
*
* @param {string} state State (connecting, connected, error, disconnected)
* @param {string} text Status text
*/
#updateStatus(state, text) {
if (!this.#statusElement) return;
this.#statusElement.className = `chat-status chat-status-${state}`;
this.#statusElement.textContent = text;
}
/**
* Public method to trigger connection.
*/
connect() {
if (this.#client && this.#client.isReady()) {
console.log('[ChatNode] Already connected');
return;
}
this.#connect();
}
/**
* Checks if the chat is connected.
*
* @returns {boolean}
*/
isConnected() {
return this.#client?.isReady() ?? false;
}
/**
* Disconnects from the chat.
*/
disconnect() {
if (this.#client) {
this.#client.stopSync();
this.#client = null;
}
this.#isConnected = false;
this.#roomId = null;
this.#members.clear();
this.#updateStatus('disconnected', 'Disconnected');
}
}

View File

@@ -0,0 +1,218 @@
import { getTypeColor } from "./getTypeColor.js";
/**
* @typedef {import('./GraphConnection.js').GraphConnection} GraphConnection
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
* @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator
*/
/**
* Renders bezier SVG connections between node pins.
* Matches the geometry logic of BlueprintWorkspace.#renderConnections / #computeConnectionGeometry.
*/
export class ConnectionRenderer {
/** @type {SVGElement} */
#svg;
/** @type {HTMLElement} */
#canvas;
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {WorkspaceNavigator} */
#nav;
/** @type {Map<string, SVGPathElement>} */
#paths = new Map();
/** @type {Set<SVGPathElement>} */
#highlightedPaths = new Set();
/**
* @param {SVGElement} svg The connection layer SVG element (world-space, inside worldLayer).
* @param {HTMLElement} canvas The workspace canvas element (used for bounding rect).
* @param {NodeRenderer} nodeRenderer
* @param {WorkspaceNavigator} nav
*/
constructor(svg, canvas, nodeRenderer, nav) {
this.#svg = svg;
this.#canvas = canvas;
this.#nodeRenderer = nodeRenderer;
this.#nav = nav;
}
/**
* Redraws all connections. Call after DOM layout settles (e.g. rAF).
*/
render() {
const canvasRect = this.#canvas.getBoundingClientRect();
const cw = canvasRect.width;
const ch = canvasRect.height;
const zoom = this.#nav.zoomLevel;
const connections = this.#nodeRenderer.getConnections();
const activeIds = new Set();
connections.forEach(conn => {
activeIds.add(conn.id);
let path = this.#paths.get(conn.id);
if (!path) {
path = this.#createPath(conn.kind);
this.#svg.appendChild(path);
this.#paths.set(conn.id, path);
}
const geometry = this.#computeGeometry(conn, canvasRect);
if (!geometry) {
path.setAttribute("d", "");
return;
}
const { start, end } = geometry;
const cpOffset = Math.max(60 * zoom, Math.abs(end.x - start.x) * 0.5);
// Viewport cull: AABB of all 4 bezier control points vs canvas
const MARGIN = 100 * zoom;
const minX = Math.min(start.x, start.x + cpOffset, end.x - cpOffset, end.x);
const maxX = Math.max(start.x, start.x + cpOffset, end.x - cpOffset, end.x);
const minY = Math.min(start.y, end.y);
const maxY = Math.max(start.y, end.y);
if (maxX < -MARGIN || minX > cw + MARGIN || maxY < -MARGIN || minY > ch + MARGIN) {
path.setAttribute("d", "");
return;
}
path.setAttribute("d",
`M ${start.x} ${start.y} C ${start.x + cpOffset} ${start.y} ${end.x - cpOffset} ${end.y} ${end.x} ${end.y}`
);
path.style.stroke = getTypeColor(conn.kind);
});
// Remove stale paths
this.#paths.forEach((path, id) => {
if (!activeIds.has(id)) {
path.remove();
this.#paths.delete(id);
}
});
}
// ─── Public — highlighting ─────────────────────────────────────────────────
/**
* Highlights all SVG paths connected to the given pin.
*
* @param {string} nodeId
* @param {string} pinId
*/
highlightPin(nodeId, pinId) {
this.clearHighlight();
for (const conn of this.#nodeRenderer.getConnections()) {
const matches =
(conn.from.nodeId === nodeId && conn.from.pinId === pinId) ||
(conn.to.nodeId === nodeId && conn.to.pinId === pinId);
if (!matches) continue;
const path = this.#paths.get(conn.id);
if (path) {
path.dataset.highlighted = "true";
this.#highlightedPaths.add(path);
}
}
}
/**
* Removes highlight from all previously highlighted paths.
*/
clearHighlight() {
this.#highlightedPaths.forEach(p => delete p.dataset.highlighted);
this.#highlightedPaths.clear();
}
/**
* Animates a glowing pulse sweeping along a clone of the path, leaving the original untouched.
* Resolves once the animation finishes.
*
* @param {string} connId
* @param {number} [durationMs]
* @returns {Promise<void>}
*/
async activatePath(connId, durationMs = 200) {
const path = this.#paths.get(connId);
if (!path) {
await new Promise(r => window.setTimeout(r, durationMs));
return;
}
const len = path.getTotalLength();
if (!len) {
await new Promise(r => window.setTimeout(r, durationMs));
return;
}
// Clone the path so the original is never mutated.
const ghost = /** @type {SVGPathElement} */ (path.cloneNode(false));
const pulse = Math.min(100, Math.max(30, len * 0.35));
ghost.style.strokeDasharray = `${pulse} ${len + pulse}`;
ghost.style.strokeDashoffset = `${pulse}`;
ghost.style.pointerEvents = "none";
ghost.removeAttribute("data-highlighted");
ghost.removeAttribute("data-executing");
path.insertAdjacentElement("afterend", ghost);
const anim = ghost.animate(
[
{ strokeDashoffset: pulse, strokeWidth: "7", filter: "brightness(5) drop-shadow(0 0 14px currentColor)" },
{ strokeDashoffset: -(len * 0.5), strokeWidth: "5", filter: "brightness(3) drop-shadow(0 0 8px currentColor)", offset: 0.5 },
{ strokeDashoffset: -(len + pulse), strokeWidth: "2.5", filter: "brightness(1) drop-shadow(0 0 0px currentColor)" },
],
{ duration: durationMs, easing: "ease-in", fill: "forwards" }
);
await anim.finished;
ghost.remove();
}
// ─── Private ──────────────────────────────────────────────────────────────
/**
* @param {string} kind
* @returns {SVGPathElement}
*/
#createPath(kind) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("fill", "none");
path.setAttribute("stroke-width", "2.5");
path.setAttribute("stroke-linecap", "round");
path.dataset.kind = kind;
return path;
}
/**
* Returns screen-space coordinates for a connection's pin handles.
*
* @param {GraphConnection} conn
* @param {DOMRect} canvasRect
* @returns {{ start: {x:number,y:number}, end: {x:number,y:number} } | null}
*/
#computeGeometry(conn, canvasRect) {
const fromHandle = this.#nodeRenderer.getPinHandle(conn.from.nodeId, conn.from.pinId, "output");
const toHandle = this.#nodeRenderer.getPinHandle(conn.to.nodeId, conn.to.pinId, "input");
if (!fromHandle || !toHandle) return null;
const fromRect = fromHandle.getBoundingClientRect();
const toRect = toHandle.getBoundingClientRect();
return {
start: {
x: fromRect.left - canvasRect.left + fromRect.width / 2,
y: fromRect.top - canvasRect.top + fromRect.height / 2,
},
end: {
x: toRect.left - canvasRect.left + toRect.width / 2,
y: toRect.top - canvasRect.top + toRect.height / 2,
},
};
}
}

View File

@@ -0,0 +1,479 @@
/**
* Debug overlay panel — top-right corner.
* Displays live viewport info and exposes the draggable-items toggle.
*/
export class DebugPanel {
/** @type {HTMLElement} */
#panel;
/** @type {HTMLElement} */
#zoomEl;
/** @type {HTMLElement} */
#dprEl;
/** @type {HTMLElement | null} */
#viewportEl;
/** @type {HTMLElement} */
#centerEl;
/** @type {HTMLElement} */
#viewboxEl;
/** @type {HTMLElement} */
#anchorEl;
/** @type {HTMLInputElement} */
#draggableCheckbox;
/** @type {HTMLElement} */
#activeItemRow;
/** @type {HTMLElement} */
#activeItemLabel;
/** @type {HTMLElement} */
#activeItemPos;
/** @type {HTMLButtonElement} */
#copyBtn;
/** @type {HTMLButtonElement} */
#copyJsonBtn;
/** @type {HTMLButtonElement} */
#showPropertiesBtn;
/** @type {HTMLElement} */
#nodeEditorDivider;
/** @type {HTMLElement} */
#nodeEditor;
/** @type {HTMLInputElement} */
#nodeIdInput;
/** @type {HTMLInputElement} */
#nodeTypeInput;
/** @type {HTMLInputElement} */
#nodeTitleInput;
/** @type {HTMLInputElement} */
#nodeXInput;
/** @type {HTMLInputElement} */
#nodeYInput;
/** @type {HTMLInputElement} */
#nodeWidthInput;
/** @type {HTMLElement} */
#inputsList;
/** @type {HTMLElement} */
#outputsList;
/** @type {HTMLButtonElement} */
#addInputBtn;
/** @type {HTMLButtonElement} */
#addOutputBtn;
/** @type {HTMLInputElement} */
#worldboxCheckbox;
/** @type {HTMLInputElement} */
#viewboxCheckbox;
/** @type {((enabled: boolean) => void) | null} */
#onDraggableChange = null;
/** @type {((enabled: boolean) => void) | null} */
#onWorldBoxChange = null;
/** @type {((enabled: boolean) => void) | null} */
#onViewboxChange = null;
/** @type {(() => void) | null} */
#onCopyNode = null;
/** @type {(() => void) | null} */
#onCopyGraph = null;
/** @type {(() => void) | null} */
#onShowProperties = null;
/** @type {((nodeId: string, updates: any) => void) | null} */
#onNodeUpdate = null;
/** @type {string | null} */
#lastActiveId = null;
/**
* @param {HTMLElement} panel - The `.debug-panel` element.
* @param {((enabled: boolean) => void) | null} [onDraggableChange]
* @param {(() => void) | null} [onCopyNode]
* @param {((nodeId: string, updates: any) => void) | null} [onNodeUpdate]
* @param {((enabled: boolean) => void) | null} [onWorldBoxChange]
* @param {(() => void) | null} [onCopyGraph]
* @param {((enabled: boolean) => void) | null} [onViewboxChange]
* @param {(() => void) | null} [onShowProperties]
*/
constructor(panel, onDraggableChange = null, onCopyNode = null, onNodeUpdate = null, onWorldBoxChange = null, onCopyGraph = null, onViewboxChange = null, onShowProperties = null) {
this.#panel = panel;
this.#onDraggableChange = onDraggableChange;
this.#onCopyNode = onCopyNode;
this.#onCopyGraph = onCopyGraph;
this.#onNodeUpdate = onNodeUpdate;
this.#onWorldBoxChange = onWorldBoxChange;
this.#onViewboxChange = onViewboxChange;
this.#onShowProperties = onShowProperties;
this.#zoomEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-zoom"));
this.#dprEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-dpr"));
this.#viewportEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewport"));
this.#centerEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-center"));
this.#viewboxEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewbox"));
this.#anchorEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-anchor"));
this.#draggableCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-draggable-cb"));
this.#activeItemRow = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-row"));
this.#activeItemLabel = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-label"));
this.#activeItemPos = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-pos"));
this.#copyBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-btn"));
this.#copyJsonBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-json-btn"));
this.#showPropertiesBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-show-properties-btn"));
this.#nodeEditorDivider = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditorDivider"));
this.#nodeEditor = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditor"));
this.#nodeIdInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-id"));
this.#nodeTypeInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-type"));
this.#nodeTitleInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-title"));
this.#nodeXInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-x"));
this.#nodeYInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-y"));
this.#nodeWidthInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-width"));
this.#inputsList = /** @type {HTMLElement} */ (document.getElementById("debugInputsList"));
this.#outputsList = /** @type {HTMLElement} */ (document.getElementById("debugOutputsList"));
this.#addInputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-input-btn"));
this.#addOutputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-output-btn"));
this.#worldboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-worldbox-cb"));
this.#viewboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-viewbox-cb"));
const toggleBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-toggle-btn"));
toggleBtn?.addEventListener("click", () => {
panel.classList.toggle("collapsed");
});
this.#draggableCheckbox.addEventListener("change", () => {
this.#onDraggableChange?.(this.#draggableCheckbox.checked);
});
this.#worldboxCheckbox?.addEventListener("change", () => {
this.#onWorldBoxChange?.(this.#worldboxCheckbox.checked);
});
this.#viewboxCheckbox?.addEventListener("change", () => {
this.#onViewboxChange?.(this.#viewboxCheckbox.checked);
});
this.#copyBtn.addEventListener("click", () => {
this.#onCopyNode?.();
});
this.#copyJsonBtn?.addEventListener("click", () => {
this.#onCopyGraph?.();
});
this.#showPropertiesBtn?.addEventListener("click", () => {
this.#onShowProperties?.();
});
this.#nodeTitleInput.addEventListener("input", () => this.#handleNodeChange());
this.#nodeXInput.addEventListener("input", () => this.#handleNodeChange());
this.#nodeYInput.addEventListener("input", () => this.#handleNodeChange());
this.#nodeWidthInput.addEventListener("input", () => this.#handleNodeChange());
this.#addInputBtn.addEventListener("click", () => this.#handleAddPin("input"));
this.#addOutputBtn.addEventListener("click", () => this.#handleAddPin("output"));
}
// ─── Public ───────────────────────────────────────────────────────────────
/** @returns {string | null} */
get lastActiveId() { return this.#lastActiveId; }
/**
* Sets the draggable checkbox to the given value without firing the change callback.
*
* @param {boolean} enabled
*/
setDraggable(enabled) {
this.#draggableCheckbox.checked = enabled;
}
/**
* Updates all displayed values.
*
* @param {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }} viewInfo
* @param {string | null} [activeId]
* @param {{ x: number, y: number, width: number, height: number } | null} [activeRect]
* @param {any} [nodeData] - Full node data object for editor
*/
update(viewInfo, activeId = null, activeRect = null, nodeData = null) {
const { zoom, center, viewbox } = viewInfo;
this.#zoomEl.textContent = zoom.toFixed(3);
this.#dprEl.textContent = (window.devicePixelRatio || 1).toFixed(2);
// Use document.documentElement if window dimensions are 0
const vpWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const vpHeight = window.innerHeight || document.documentElement.clientHeight || 0;
if (this.#viewportEl) {
this.#viewportEl.textContent = `${vpWidth} × ${vpHeight}`;
}
this.#centerEl.textContent = `${this.#fmt(center.x)}, ${this.#fmt(center.y)}`;
this.#viewboxEl.textContent =
`${this.#fmt(viewbox.x)}, ${this.#fmt(viewbox.y)} ${this.#fmt(viewbox.width)} \u00d7 ${this.#fmt(viewbox.height)}`;
if (activeRect && viewbox.width > 0 && viewbox.height > 0) {
// 0 = node left/top edge at viewport left/top, 1 = node right/bottom edge at viewport right/bottom.
const ax = (activeRect.x - viewbox.x) / Math.max(1, viewbox.width - activeRect.width);
const ay = (activeRect.y - viewbox.y) / Math.max(1, viewbox.height - activeRect.height);
this.#anchorEl.textContent = `${ax.toFixed(2)}, ${ay.toFixed(2)}`;
} else {
this.#anchorEl.textContent = '\u2014';
}
if (activeId && activeRect) {
this.#lastActiveId = activeId;
this.#activeItemRow.hidden = false;
this.#activeItemLabel.textContent = activeId;
this.#activeItemPos.textContent =
`${this.#fmt(activeRect.x)}, ${this.#fmt(activeRect.y)}`;
if (nodeData) {
this.#populateNodeEditor(nodeData);
}
} else {
this.#activeItemRow.hidden = true;
this.#hideNodeEditor();
}
}
/**
* Populates node editor with node data.
*
* @param {any} node
*/
#populateNodeEditor(node) {
this.#nodeEditorDivider.hidden = false;
this.#nodeEditor.hidden = false;
this.#nodeIdInput.value = node.id || '';
this.#nodeTypeInput.value = node.type || '';
this.#nodeTitleInput.value = node.title || '';
this.#nodeXInput.value = String(node.position?.x ?? 0);
this.#nodeYInput.value = String(node.position?.y ?? 0);
this.#nodeWidthInput.value = node.width != null ? String(node.width) : '';
this.#renderPinsList(node.inputs || [], "input");
this.#renderPinsList(node.outputs || [], "output");
}
/**
* Renders pin list.
*
* @param {any[]} pins
* @param {('input'|'output')} direction
*/
#renderPinsList(pins, direction) {
const container = direction === "input" ? this.#inputsList : this.#outputsList;
container.innerHTML = '';
pins.forEach((pin, index) => {
const pinEl = document.createElement("div");
pinEl.className = "debug-pin-item";
pinEl.innerHTML = `
<div class="debug-pin-row">
<input class="debug-pin-input debug-pin-id" type="text" placeholder="id" value="${pin.id || ''}" data-index="${index}" data-field="id" />
<input class="debug-pin-input debug-pin-name" type="text" placeholder="name" value="${pin.name || ''}" data-index="${index}" data-field="name" />
<select class="debug-pin-input debug-pin-kind" data-index="${index}" data-field="kind">
<option value="exec" ${pin.kind === 'exec' ? 'selected' : ''}>exec</option>
<option value="number" ${pin.kind === 'number' ? 'selected' : ''}>number</option>
<option value="string" ${pin.kind === 'string' ? 'selected' : ''}>string</option>
<option value="boolean" ${pin.kind === 'boolean' ? 'selected' : ''}>boolean</option>
</select>
<button class="debug-pin-remove" data-index="${index}" title="Remove">×</button>
</div>
<div class="debug-pin-row">
<input class="debug-pin-input debug-pin-default" type="text" placeholder="default value" value="${pin.defaultValue ?? ''}" data-index="${index}" data-field="defaultValue" />
</div>
`;
pinEl.querySelectorAll('.debug-pin-input').forEach(input => {
input.addEventListener('input', () => this.#handlePinChange(direction));
input.addEventListener('change', () => this.#handlePinChange(direction));
});
const removeBtn = pinEl.querySelector('.debug-pin-remove');
removeBtn?.addEventListener('click', () => this.#handleRemovePin(direction, index));
container.appendChild(pinEl);
});
}
/**
* Hides node editor.
*/
#hideNodeEditor() {
this.#nodeEditorDivider.hidden = true;
this.#nodeEditor.hidden = true;
}
/**
* Handles node property changes.
*/
#handleNodeChange() {
if (!this.#lastActiveId || !this.#onNodeUpdate) return;
const updates = {
title: this.#nodeTitleInput.value,
position: {
x: parseFloat(this.#nodeXInput.value) || 0,
y: parseFloat(this.#nodeYInput.value) || 0
}
};
const widthVal = this.#nodeWidthInput.value.trim();
if (widthVal !== '') {
updates.width = parseFloat(widthVal) || null;
}
this.#onNodeUpdate(this.#lastActiveId, updates);
}
/**
* Handles pin property changes.
*
* @param {('input'|'output')} direction
*/
#handlePinChange(direction) {
if (!this.#lastActiveId || !this.#onNodeUpdate) return;
const container = direction === "input" ? this.#inputsList : this.#outputsList;
const pins = [];
container.querySelectorAll('.debug-pin-item').forEach(pinEl => {
const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id'));
const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name'));
const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind'));
const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default'));
const pin = {
id: idInput.value || `pin_${Date.now()}`,
name: nameInput.value || '',
kind: kindSelect.value,
direction: direction
};
if (defaultInput.value.trim() !== '') {
pin.defaultValue = defaultInput.value;
}
pins.push(pin);
});
this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins });
}
/**
* Handles adding a new pin.
*
* @param {('input'|'output')} direction
*/
#handleAddPin(direction) {
if (!this.#lastActiveId || !this.#onNodeUpdate) return;
const container = direction === "input" ? this.#inputsList : this.#outputsList;
const newPin = {
id: `pin_${Date.now()}`,
name: '',
kind: 'exec',
direction: direction
};
// Get existing pins and add new one
const pins = [];
container.querySelectorAll('.debug-pin-item').forEach(pinEl => {
const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id'));
const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name'));
const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind'));
const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default'));
const pin = {
id: idInput.value,
name: nameInput.value,
kind: kindSelect.value,
direction: direction
};
if (defaultInput.value.trim() !== '') {
pin.defaultValue = defaultInput.value;
}
pins.push(pin);
});
pins.push(newPin);
this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins });
}
/**
* Handles removing a pin.
*
* @param {('input'|'output')} direction
* @param {number} index
*/
#handleRemovePin(direction, index) {
if (!this.#lastActiveId || !this.#onNodeUpdate) return;
const container = direction === "input" ? this.#inputsList : this.#outputsList;
const pins = [];
container.querySelectorAll('.debug-pin-item').forEach((pinEl, i) => {
if (i === index) return; // Skip removed pin
const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id'));
const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name'));
const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind'));
const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default'));
const pin = {
id: idInput.value,
name: nameInput.value,
kind: kindSelect.value,
direction: direction
};
if (defaultInput.value.trim() !== '') {
pin.defaultValue = defaultInput.value;
}
pins.push(pin);
});
this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins });
}
// ─── Private ──────────────────────────────────────────────────────────────
/**
* Formats a world-space number to a compact fixed-point string.
*
* @param {number} n
* @returns {string}
*/
#fmt(n) {
return n.toFixed(1);
}
}

View File

@@ -0,0 +1,62 @@
/**
* Simple global variable registry for sharing values between nodes.
*/
export class GlobalVariables {
/** @type {Map<string, any>} */
static #variables = new Map();
/**
* Sets a global variable.
*
* @param {string} name Variable name
* @param {any} value Variable value
*/
static set(name, value) {
this.#variables.set(name, value);
}
/**
* Gets a global variable.
*
* @param {string} name Variable name
* @returns {any} Variable value or undefined
*/
static get(name) {
return this.#variables.get(name);
}
/**
* Checks if a variable exists.
*
* @param {string} name Variable name
* @returns {boolean}
*/
static has(name) {
return this.#variables.has(name);
}
/**
* Deletes a variable.
*
* @param {string} name Variable name
*/
static delete(name) {
this.#variables.delete(name);
}
/**
* Clears all variables.
*/
static clear() {
this.#variables.clear();
}
/**
* Gets all variable names.
*
* @returns {string[]}
*/
static keys() {
return Array.from(this.#variables.keys());
}
}

View File

@@ -0,0 +1,19 @@
/**
* Represents a comment box for annotating/grouping nodes.
* Similar to Unreal Engine blueprint comments.
*/
export class GraphComment {
/**
* @param {{ id: string, title: string, position: {x:number,y:number}, size: {width:number,height:number}, color?: string, opacity?: number }} init
*/
constructor(init) {
this.id = init.id;
this.title = init.title;
this.position = { ...init.position };
this.size = { ...init.size };
/** @type {string} */
this.color = init.color || "#4a5568";
/** @type {number} */
this.opacity = init.opacity ?? 0.15;
}
}

View File

@@ -0,0 +1,21 @@
/**
* @typedef {{ nodeId: string, pinId: string }} PinRef
*/
/**
* Represents a directional connection between two node pins.
*/
export class GraphConnection {
/**
* @param {PinRef} from Output pin reference.
* @param {PinRef} to Input pin reference.
* @param {string} kind Pin type kind.
* @param {string} [id]
*/
constructor(from, to, kind, id) {
this.id = id ?? (globalThis.crypto?.randomUUID?.() ?? `conn_${Math.random().toString(36).slice(2, 10)}`);
this.from = { ...from };
this.to = { ...to };
this.kind = kind;
}
}

View File

@@ -0,0 +1,148 @@
/**
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
* @typedef {(nodeId: string, value: string) => void} PrintHandler
* @typedef {(connId: string, kind: string) => Promise<void>} StepHandler
*/
import { GlobalVariables } from './GlobalVariables.js';
import { NodeRegistry, NodeExecutionContext } from './nodes/index.js';
/**
* Walks exec connections from a starting output pin and runs node logic
* for recognised node types (print, etc.).
*
* Execution is fully async — each exec hop awaits the optional onStep callback,
* allowing callers to animate wires between steps.
*
* Resolution order for string values:
* 1. The defaultValue on the output pin of the connected source node.
* 2. The defaultValue on the input pin itself (unconnected fallback).
*/
export class GraphExecutor {
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {PrintHandler | null} */
#onPrint;
/**
* Called with (connId, kind) before traversing each connection.
* Awaited for exec wires; fire-and-forget for data wires.
*
* @type {StepHandler | null}
*/
#onStep;
/**
* @param {NodeRenderer} nodeRenderer
* @param {PrintHandler | null} [onPrint] - Invoked when a print node executes.
* @param {StepHandler | null} [onStep] - Awaited when traversing exec connections.
*/
constructor(nodeRenderer, onPrint = null, onStep = null) {
this.#nodeRenderer = nodeRenderer;
this.#onPrint = onPrint;
this.#onStep = onStep;
}
// ─── Public ───────────────────────────────────────────────────────────────
/**
* Starts async execution from a node's exec output pin.
*
* @param {string} nodeId
* @param {string} [pinId]
* @returns {Promise<void>}
*/
execute(nodeId, pinId = "exec_out") {
return this.#runExecPin(nodeId, pinId);
}
// ─── Private ──────────────────────────────────────────────────────────────
/**
* Follows a single exec output connection, animates it, then executes the target node.
*
* @param {string} nodeId
* @param {string} pinId
* @returns {Promise<void>}
*/
async #runExecPin(nodeId, pinId) {
const conn = this.#nodeRenderer.getConnections()
.find(c => c.from.nodeId === nodeId && c.from.pinId === pinId);
if (!conn) return;
if (this.#onStep) await this.#onStep(conn.id, conn.kind);
await this.#executeNode(conn.to.nodeId);
}
/**
* Executes the logic for a single node then advances through exec_out.
*
* @param {string} nodeId
* @returns {Promise<void>}
*/
async #executeNode(nodeId) {
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
if (!node) return;
const ctx = new NodeExecutionContext(
nodeId,
this.#nodeRenderer,
(nId, pId) => this.#resolveInput(nId, pId),
(nId, pId) => this.#runExecPin(nId, pId),
this.#onPrint,
this.#onStep
);
const handler = NodeRegistry.BlueprintPure_Get(node.type);
if (handler) {
try {
await handler.BlueprintCallable_Execute(ctx);
} catch (error) {
console.error(`[Node ${nodeId}] Execution error:`, error);
}
}
// Always continue exec flow
await this.#runExecPin(nodeId, "exec_out");
}
/**
* Resolves an input value by following connections and handling special node types.
*
* @param {string} nodeId
* @param {string} pinId
* @returns {any}
*/
#resolveInput(nodeId, pinId) {
const conn = this.#nodeRenderer.getConnections().find(
c => c.to.nodeId === nodeId && c.to.pinId === pinId
);
if (conn) {
// Auto-flash data wire when a value is resolved (fire-and-forget)
this.#onStep?.(conn.id, conn.kind);
const sourceNode = this.#nodeRenderer.getNodes().find(n => n.id === conn.from.nodeId);
const sourcePin = sourceNode?.getPin(conn.from.pinId);
if (sourceNode?.type === "get_matrix_chat" && conn.from.pinId === "value") {
if (GlobalVariables.has("MatrixChat")) return GlobalVariables.get("MatrixChat");
}
if (sourceNode?.type === "random_name" && conn.from.pinId === "name") {
return sourceNode._generatedName || "";
}
if (sourceNode?.type === "append" && conn.from.pinId === "result") {
const input1 = this.#resolveInput(sourceNode.id, "input1");
const input2 = this.#resolveInput(sourceNode.id, "input2");
const input3 = this.#resolveInput(sourceNode.id, "input3");
return input1 + input2 + input3;
}
if (sourcePin?.defaultValue != null) return sourcePin.defaultValue;
}
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
return node?.getPin(pinId)?.defaultValue ?? "";
}
}

View File

@@ -0,0 +1,45 @@
/**
* @typedef {Object} PinDescriptor
* @property {string} id Unique pin identifier scoped to the node.
* @property {string} name Display name.
* @property {('input'|'output')} direction Pin direction.
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin type kind.
* @property {string} [defaultValue] Optional default value displayed inline for string/number-kind pins.
* @property {number} [min] Optional minimum value for number pins.
* @property {number} [max] Optional maximum value for number pins.
*/
/**
* Represents a blueprint node instance.
*/
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, 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;
this.type = init.type;
this.title = init.title;
this.position = { ...init.position };
/** @type {number | undefined} */
this.width = init.width;
this.inputs = init.inputs.map(p => ({ ...p }));
this.outputs = init.outputs.map(p => ({ ...p }));
/** @type {string | undefined} */
this.markdownSrc = init.markdownSrc;
/** @type {string | undefined} */
this.imageSrc = init.imageSrc;
/** @type {string | undefined} */
this.lottieSrc = init.lottieSrc;
/** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */
this.focusOptions = init.focusOptions;
}
/**
* @param {string} pinId
* @returns {PinDescriptor | undefined}
*/
getPin(pinId) {
return [...this.inputs, ...this.outputs].find(p => p.id === pinId);
}
}

View File

@@ -0,0 +1,698 @@
/**
* @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
*/
/**
* Handles pointer-driven drag movement and sway animation for world-space items.
*
* Ported directly from Picograph's WorkspaceDragManager + WorkspaceGeometry:
* - Anchor-ratio drag (cursor grabs exactly where it hits the element)
* - Ghost placeholder for nodes while dragging
* - movementX-preferred velocity → rotation blend (±7° clamp, grab-point pivot)
* - 0.85×/frame rotation decay RAF loop
* - RAF-coalesced connection refresh during drag, immediate flush on drop
* - Grid snapping on ghost preview and drop (dragging remains smooth)
*/
export class ItemDragger {
/** Grid size for snapping (matches CSS --grid-size) */
static GRID_SIZE = 20;
/** @type {HTMLElement} */
#canvas;
/** @type {WorkspaceNavigator} */
#nav;
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {boolean} */
#enabled = false;
/** @type {{ id: string, el: HTMLElement, pos: { x: number, y: number } }[]} */
#images = [];
/**
* Per-item apply-transform callbacks.
*
* @type {Map<string, (x: number, y: number, rotation: number) => void>}
*/
#applyTransform = new Map();
/** @type {Map<string, { x: number, y: number }>} */
#itemPositions = new Map();
// ─── Rotation (matches workspace.nodeDragRotation in Picograph) ────────────
/** @type {Map<string, number>} */
#dragRotation = new Map();
/**
* Stores the grab-ratio pivot point per item, used to set transform-origin during rotation.
*
* @type {Map<string, { ratioX: number, ratioY: number }>}
*/
#dragOrigin = new Map();
/** @type {number | null} */
#decayFrame = null;
// ─── Ghost (node drag placeholder) ────────────────────────────────────────
/** @type {{ el: HTMLElement, nodeId: string, width: number, height: number } | null} */
#ghost = null;
// ─── Connection refresh coalescing ─────────────────────────────────────────
/** @type {number | null} */
#connRefreshFrame = null;
// ─── Edge-pan (auto-scroll while dragging near canvas edges) ──────────────
/**
* @type {{ clientX: number, clientY: number,
* anchors: Map<string, {ratioX: number, ratioY: number, width: number, height: number}>,
* primaryId: string, useGhost: boolean } | null}
*/
#edgePanState = null;
/** @type {number | null} */
#edgePanFrame = null;
/** @type {boolean} */
#dragging = false;
/** @type {(() => void) | null} */
#cancelActiveDrag = null;
// ─── Active item (debug panel) ─────────────────────────────────────────────
/** @type {string | null} */
#activeId = null;
/** @type {{ x: number, y: number } | null} */
#activePos = null;
/** @type {((id: string | null, pos: { x: number, y: number } | null) => void) | null} */
#onActiveItemChange = null;
/** @type {(() => void) | null} */
#onItemMoved = null;
/**
* @param {HTMLElement} canvas - The workspace canvas element.
* @param {WorkspaceNavigator} nav
* @param {NodeRenderer} nodeRenderer
* @param {((id: string | null, pos: { x: number, y: number } | null) => void) | null} [onActiveItemChange]
* @param {(() => void) | null} [onItemMoved]
*/
constructor(canvas, nav, nodeRenderer, onActiveItemChange = null, onItemMoved = null) {
this.#canvas = canvas;
this.#nav = nav;
this.#nodeRenderer = nodeRenderer;
this.#onActiveItemChange = onActiveItemChange;
this.#onItemMoved = onItemMoved;
}
// ─── Public ───────────────────────────────────────────────────────────────
/** @returns {string | null} */
get activeId() { return this.#activeId; }
/** @returns {{ x: number, y: number } | null} */
get activePos() { return this.#activePos; }
/** True while a drag gesture is actively in progress. */
get isDragging() { return this.#dragging; }
/**
* Immediately drops any active drag, snapping the item to grid at its current position.
* Call this when a pinch-to-zoom starts so the two gestures don't fight each other.
*/
cancelDrag() {
this.#cancelActiveDrag?.();
}
/** @param {boolean} v */
set enabled(v) {
this.#enabled = v;
this.#images.forEach(({ el }) => {
el.style.pointerEvents = v ? "auto" : "none";
el.style.cursor = v ? "grab" : "";
});
this.#nodeRenderer.getNodeElements().forEach((el) => {
el.dataset.draggable = v ? "true" : "false";
});
if (!v) {
this.#removeGhost();
this.#stopDecay();
}
}
/** @returns {boolean} */
get enabled() { return this.#enabled; }
/**
* Registers an image element as draggable.
*
* @param {string} id
* @param {HTMLElement} el
* @param {{ x: number, y: number }} initialPos
*/
registerImage(id, el, initialPos) {
const entry = { id, el, pos: { ...initialPos } };
this.#images.push(entry);
this.#itemPositions.set(id, { ...initialPos });
this.#applyTransform.set(id, (x, y, rot) => {
entry.pos.x = x;
entry.pos.y = y;
el.style.transform = this.#buildTransform(x, y, rot);
});
el.addEventListener("pointerdown", (e) => {
if (!this.#enabled) return;
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);
const zoom = this.#nav.zoomLevel || 1;
const rect = el.getBoundingClientRect();
const width = rect.width / zoom || 512;
const height = rect.height / zoom || 512;
const ratioX = width > 0 ? (pointerWorld.x - entry.pos.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - entry.pos.y) / height : 0;
this.#beginDrag(id, e, el, new Map([[id, { ratioX, ratioY, width, height }]]), false);
});
}
/**
* Registers a node element as draggable. Call after the node has been added to NodeRenderer.
*
* @param {string} nodeId
*/
registerNode(nodeId) {
const el = this.#nodeRenderer.getNodeElements().get(nodeId);
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
if (!el || !node) return;
this.#itemPositions.set(nodeId, { ...node.position });
this.#applyTransform.set(nodeId, (x, y, rot) => {
this.#nodeRenderer.setNodeTransform(nodeId, x, y, rot);
});
el.addEventListener("pointerdown", (e) => {
if (!this.#enabled) return;
const target = /** @type {HTMLElement} */ (e.target);
if (target.closest(".pin-handle")) return;
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);
const zoom = this.#nav.zoomLevel || 1;
const rect = el.getBoundingClientRect();
const width = Math.max(1, rect.width / zoom);
const height = Math.max(1, rect.height / zoom);
const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0;
this.#beginDrag(nodeId, e, el, new Map([[nodeId, { ratioX, ratioY, width, height }]]), true);
});
}
// ─── Drag ─────────────────────────────────────────────────────────────────
/**
* @param {string} primaryId
* @param {PointerEvent} event
* @param {HTMLElement} primaryEl
* @param {Map<string, { ratioX: number, ratioY: number, width: number, height: number }>} anchors
* @param {boolean} useGhost
*/
#beginDrag(primaryId, event, primaryEl, anchors, useGhost) {
// Don't start a drag while a pinch-to-zoom is already running.
// This can happen when both fingers land simultaneously: touchstart (pinch)
// fires before pointerdown, so the guard here catches the race.
if (this.#nav.isPinching) return;
anchors.forEach((_, id) => this.#dragRotation.delete(id));
// Stop any previous edge-pan and mark drag as active
this.#stopEdgePan();
this.#dragging = true;
// Store grab-point ratios and apply them as transform-origin for pivot rotation
anchors.forEach((anchor, id) => {
this.#dragOrigin.set(id, { ratioX: anchor.ratioX, ratioY: anchor.ratioY });
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = `${anchor.ratioX * 100}% ${anchor.ratioY * 100}%`;
});
primaryEl.style.transition = "";
let prevClientX = event.clientX;
let prevClientY = event.clientY;
let prevMoveTime = performance.now();
// Ghost placeholder (nodes only)
if (useGhost) {
const anchor = anchors.get(primaryId);
if (anchor) {
const initialPointer = this.#clientToWorkspace(event.clientX, event.clientY);
const ghostPos = {
x: initialPointer.x - anchor.ratioX * anchor.width,
y: initialPointer.y - anchor.ratioY * anchor.height,
};
this.#ensureGhost(primaryId, primaryEl, anchor.width, anchor.height, ghostPos);
}
}
this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null);
// Initialise edge-pan state so #tickEdgePan can reference anchors without closure mutation
this.#edgePanState = { clientX: event.clientX, clientY: event.clientY, anchors, primaryId, useGhost };
const handlePointerMove = (/** @type {PointerEvent} */ moveE) => {
if (moveE.pointerId !== event.pointerId) return;
const pointer = this.#clientToWorkspace(moveE.clientX, moveE.clientY);
anchors.forEach((anchor, id) => {
const newX = pointer.x - anchor.ratioX * anchor.width;
const newY = pointer.y - anchor.ratioY * anchor.height;
this.#itemPositions.set(id, { x: newX, y: newY });
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(newX, newY, rot);
if (useGhost && id === primaryId) {
this.#updateGhost(newX, newY);
}
});
this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null);
// Update edge-pan pointer so the RAF loop knows where the pointer is
this.#edgePanState.clientX = moveE.clientX;
this.#edgePanState.clientY = moveE.clientY;
if (this.#edgePanFrame === null) {
this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan());
}
// Compute time-normalised velocity so touch (coalesced, less frequent events) and
// mouse (per-frame events) produce the same rotation magnitude at the same finger/cursor speed.
// Target frame budget: 16 ms. Clamp dt to 860 ms to avoid spikes on tab-switch etc.
const now = performance.now();
const dt = Math.max(8, Math.min(60, now - prevMoveTime));
prevMoveTime = now;
const rawDelta = moveE.clientX - prevClientX;
const velocityX = (rawDelta / dt) * 16;
anchors.forEach((_, id) => this.#nudgeRotation(id, velocityX));
prevClientX = moveE.clientX;
prevClientY = moveE.clientY;
this.#scheduleConnectionRefresh();
};
const handlePointerFinish = (/** @type {PointerEvent} */ finishE) => {
if (finishE.pointerId !== event.pointerId) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerFinish);
window.removeEventListener("pointercancel", handlePointerFinish);
this.#cancelActiveDrag = null;
this.#stopEdgePan();
this.#dragging = false;
// Commit position from final pointer coords, snapped to grid
if (Number.isFinite(finishE.clientX)) {
const pointer = this.#clientToWorkspace(finishE.clientX, finishE.clientY);
anchors.forEach((anchor, id) => {
const rawX = pointer.x - anchor.ratioX * anchor.width;
const rawY = pointer.y - anchor.ratioY * anchor.height;
const snapped = this.#snapToGrid(rawX, rawY);
this.#itemPositions.set(id, { x: snapped.x, y: snapped.y });
});
}
// Zero out rotations with snap-back transition, then decay
anchors.forEach((_, id) => this.#setDragRotation(id, 0));
if (useGhost) {
const pos = this.#itemPositions.get(primaryId);
if (pos) this.#updateGhost(pos.x, pos.y);
window.setTimeout(() => this.#removeGhost(), 160);
}
// Keep selection active after drag ends
this.#flushConnectionRefresh();
};
// Exposed so external callers (e.g. pinch-to-zoom start) can drop the drag cleanly
this.#cancelActiveDrag = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerFinish);
window.removeEventListener("pointercancel", handlePointerFinish);
this.#cancelActiveDrag = null;
this.#stopEdgePan();
this.#dragging = false;
// Snap current live positions to grid
anchors.forEach((_, id) => {
const pos = this.#itemPositions.get(id);
if (!pos) return;
const snapped = this.#snapToGrid(pos.x, pos.y);
this.#itemPositions.set(id, snapped);
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(snapped.x, snapped.y, rot);
});
anchors.forEach((_, id) => this.#setDragRotation(id, 0));
if (useGhost) {
const pos = this.#itemPositions.get(primaryId);
if (pos) this.#updateGhost(pos.x, pos.y);
window.setTimeout(() => this.#removeGhost(), 160);
}
this.#flushConnectionRefresh();
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerFinish);
window.addEventListener("pointercancel", handlePointerFinish);
}
// ─── Ghost ────────────────────────────────────────────────────────────────
/**
* @param {string} nodeId
* @param {HTMLElement} nodeEl
* @param {number} width
* @param {number} height
* @param {{ x: number, y: number }} pos
*/
#ensureGhost(nodeId, nodeEl, width, height, pos) {
this.#removeGhost();
const el = document.createElement("div");
el.className = "node-drag-ghost";
el.style.width = `${width}px`;
el.style.height = `${height}px`;
el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;
el.setAttribute("role", "presentation");
if (nodeEl.parentElement) {
nodeEl.parentElement.insertBefore(el, nodeEl);
} else {
nodeEl.appendChild(el);
}
this.#ghost = { el, nodeId, width, height };
}
/**
* @param {number} x
* @param {number} y
*/
#updateGhost(x, y) {
if (!this.#ghost) return;
const snapped = this.#snapToGrid(x, y);
this.#ghost.el.style.transform = `translate3d(${snapped.x}px, ${snapped.y}px, 0)`;
}
#removeGhost() {
if (!this.#ghost) return;
if (this.#ghost.el.isConnected) this.#ghost.el.remove();
this.#ghost = null;
}
// ─── Edge-pan (auto-scroll while dragging near canvas edges) ─────────────
/**
* Stops the edge-pan RAF loop and clears its state.
*/
#stopEdgePan() {
if (this.#edgePanFrame !== null) {
cancelAnimationFrame(this.#edgePanFrame);
this.#edgePanFrame = null;
}
this.#edgePanState = null;
}
/**
* RAF loop that scrolls the viewport when the drag pointer is within 10% of any canvas edge,
* then re-positions the dragged item so it stays under the pointer.
* Runs continuously while the pointer stays in the edge zone; stops when it leaves.
*/
#tickEdgePan() {
this.#edgePanFrame = null;
const state = this.#edgePanState;
if (!state) return;
const rect = this.#canvas.getBoundingClientRect();
const { clientX, clientY, anchors, primaryId, useGhost } = state;
const edgeW = rect.width * 0.10;
const edgeH = rect.height * 0.10;
/** Max pan speed in screen pixels per frame */
const MAX_SPEED = 14;
const cx = clientX - rect.left;
const cy = clientY - rect.top;
let screenDx = 0;
let screenDy = 0;
if (cx < edgeW) screenDx = MAX_SPEED * (1 - cx / edgeW);
if (cx > rect.width - edgeW) screenDx = -MAX_SPEED * (1 - (rect.width - cx) / edgeW);
if (cy < edgeH) screenDy = MAX_SPEED * (1 - cy / edgeH);
if (cy > rect.height - edgeH) screenDy = -MAX_SPEED * (1 - (rect.height - cy) / edgeH);
if (screenDx !== 0 || screenDy !== 0) {
const zoom = this.#nav.zoomLevel || 1;
this.#nav.panBy(screenDx / zoom, screenDy / zoom);
// Re-derive world position from the (now-updated) viewport so node follows pointer
const pointer = this.#clientToWorkspace(clientX, clientY);
anchors.forEach((anchor, id) => {
const newX = pointer.x - anchor.ratioX * anchor.width;
const newY = pointer.y - anchor.ratioY * anchor.height;
this.#itemPositions.set(id, { x: newX, y: newY });
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(newX, newY, rot);
if (useGhost && id === primaryId) {
this.#updateGhost(newX, newY);
}
});
this.#scheduleConnectionRefresh();
this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan());
}
// Pointer outside the edge zone — loop pauses; next pointermove will restart it if needed
}
// ─── Rotation ─────────────────────────────────────────────────────────────
/**
* Blends horizontal velocity into an item's rotation state.
* Exact port of nudgeNodeDragRotation from WorkspaceDragManager.
*
* @param {string} id
* @param {number} velocityX
*/
#nudgeRotation(id, velocityX) {
if (!Number.isFinite(velocityX)) return;
const existing = this.#dragRotation.get(id) ?? 0;
const blended = existing * 0.92 + velocityX * 0.18;
this.#setDragRotation(id, blended);
}
/**
* Applies or clears a rotation and updates the item's transform.
* Exact port of setNodeDragRotation from WorkspaceDragManager.
*
* @param {string} id
* @param {number} angle
*/
#setDragRotation(id, angle) {
const sanitized = Number.isFinite(angle) ? angle : 0;
const clamped = Math.max(-7, Math.min(7, sanitized));
if (Math.abs(clamped) < 0.01) {
this.#dragRotation.delete(id);
this.#dragOrigin.delete(id);
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = "";
} else {
this.#dragRotation.set(id, clamped);
}
const pos = this.#itemPositions.get(id);
if (pos) {
this.#applyTransform.get(id)?.(pos.x, pos.y, clamped);
}
if (this.#dragRotation.size > 0) {
this.#ensureDecay();
} else {
this.#stopDecay();
}
}
/**
* Returns the DOM element for a registered item (image or node).
*
* @param {string} id
* @returns {HTMLElement | null}
*/
#getItemElement(id) {
const img = this.#images.find(i => i.id === id);
if (img) return img.el;
return this.#nodeRenderer.getNodeElements().get(id) ?? null;
}
// ─── Decay ────────────────────────────────────────────────────────────────
/**
* Ensures the rotation decay RAF loop is running.
* Exact port of ensureNodeRotationDecay from WorkspaceDragManager.
*/
#ensureDecay() {
if (this.#decayFrame !== null) return;
const step = () => {
let hasActive = false;
this.#dragRotation.forEach((angle, id) => {
const pos = this.#itemPositions.get(id);
if (!pos) { this.#dragRotation.delete(id); return; }
const damped = angle * 0.85;
if (Math.abs(damped) < 0.05) {
this.#dragRotation.delete(id);
this.#dragOrigin.delete(id);
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = "";
this.#applyTransform.get(id)?.(pos.x, pos.y, 0);
} else {
this.#dragRotation.set(id, damped);
this.#applyTransform.get(id)?.(pos.x, pos.y, damped);
hasActive = true;
}
});
this.#scheduleConnectionRefresh();
if (hasActive) {
this.#decayFrame = requestAnimationFrame(step);
} else {
this.#decayFrame = null;
this.#flushConnectionRefresh();
}
};
this.#decayFrame = requestAnimationFrame(step);
}
/**
* Stops the decay loop if running.
* Exact port of stopNodeRotationDecay from WorkspaceDragManager.
*/
#stopDecay() {
if (this.#decayFrame === null) return;
cancelAnimationFrame(this.#decayFrame);
this.#decayFrame = null;
}
// ─── Connection refresh coalescing ────────────────────────────────────────
/**
* Queues a single RAF to refresh connections.
* Matches scheduleConnectionRefresh in BlueprintWorkspace.
*/
#scheduleConnectionRefresh() {
if (this.#connRefreshFrame !== null) return;
this.#connRefreshFrame = requestAnimationFrame(() => {
this.#connRefreshFrame = null;
this.#onItemMoved?.();
});
}
/**
* Immediately refreshes connections, cancelling any pending RAF.
* Matches flushConnectionRefresh in BlueprintWorkspace.
*/
#flushConnectionRefresh() {
if (this.#connRefreshFrame !== null) {
cancelAnimationFrame(this.#connRefreshFrame);
this.#connRefreshFrame = null;
}
this.#onItemMoved?.();
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Snaps a world-space position to the grid.
*
* @param {number} x
* @param {number} y
* @returns {{ x: number, y: number }}
*/
#snapToGrid(x, y) {
const gridSize = ItemDragger.GRID_SIZE;
return {
x: Math.round(x / gridSize) * gridSize,
y: Math.round(y / gridSize) * gridSize,
};
}
/**
* Converts client coordinates to world space.
* Exact port of clientToWorkspace from WorkspaceDragManager.
*
* @param {number} clientX
* @param {number} clientY
* @returns {{ x: number, y: number }}
*/
#clientToWorkspace(clientX, clientY) {
const rect = this.#canvas.getBoundingClientRect();
const zoom = Math.max(0.01, this.#nav.zoomLevel || 1);
const offset = this.#nav.getEffectiveOffset();
return {
x: (clientX - rect.left) / zoom - offset.x,
y: (clientY - rect.top) / zoom - offset.y,
};
}
/**
* Builds a CSS transform matching WorkspaceGeometry.positionToTransform in Picograph.
*
* @param {number} x
* @param {number} y
* @param {number} rotation
* @returns {string}
*/
#buildTransform(x, y, rotation) {
const rotZ = Number.isFinite(rotation) ? rotation : 0;
if (!rotZ) return `translate3d(${x}px, ${y}px, 0)`;
return `translate3d(${x}px, ${y}px, 0) rotate(${rotZ}deg)`;
}
/**
* @param {string | null} id
* @param {{ x: number, y: number } | null} pos
*/
#setActive(id, pos) {
this.#activeId = id;
this.#activePos = pos;
this.#onActiveItemChange?.(id, pos);
}
/**
* Clear the current selection
*/
clearSelection() {
this.#setActive(null, null);
}
/**
* Get the currently active item ID
* @returns {string | null}
*/
getActiveId() {
return this.#activeId;
}
}

View File

@@ -0,0 +1,175 @@
/**
* Lightweight markdown-to-HTML renderer.
* Supports: headings (h1h3), bold, italic, inline code, code blocks,
* blockquotes, unordered lists, ordered lists, horizontal rules, links, paragraphs.
* No external dependencies.
*/
/** @type {Array<{ pattern: RegExp, svg: string }>} */
const LINK_ICONS = [
{
pattern: /youtube\.com|youtu\.be/,
svg: `<svg class="md-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FF0000" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path fill="#FFFFFF" d="M9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>`,
},
{
pattern: /github\.com/,
svg: `<svg class="md-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFFFFF" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>`,
},
{
pattern: /bsky\.app/,
svg: `<svg class="md-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#0085FF" d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>`,
},
{
pattern: /matrix\.to|matrix\.org/,
svg: `<svg class="md-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFFFFF" d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>`,
},
];
export class MarkdownRenderer {
/**
* Converts a markdown string to sanitized HTML.
*
* @param {string} markdown - Raw markdown text.
* @returns {string} HTML string safe for innerHTML insertion.
*/
static render(markdown) {
let html = markdown
// Normalize line endings
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Split into blocks separated by blank lines
const blocks = html.split(/\n{2,}/);
const parts = blocks.map(block => MarkdownRenderer.#renderBlock(block.trim()));
return parts.filter(Boolean).join("\n");
}
// ─── Block-level ──────────────────────────────────────────────────────────
/**
* @param {string} block
* @returns {string}
*/
static #renderBlock(block) {
if (!block) return "";
// Fenced code block
const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/);
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>`;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(block)) {
return `<hr class="md-hr" />`;
}
// Headings
const headingMatch = block.match(/^(#{1,3})\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}>`;
}
// Blockquote
if (/^> /.test(block)) {
const inner = block.replace(/^> ?/gm, "");
return `<blockquote class="md-blockquote">${MarkdownRenderer.#renderInline(inner)}</blockquote>`;
}
// Unordered list
if (/^[-*+] /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^[-*+]\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ul class="md-ul">${items.join("")}</ul>`;
}
// Ordered list
if (/^\d+\. /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^\d+\.\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ol class="md-ol">${items.join("")}</ol>`;
}
// Paragraph (handle single-line line breaks within block)
const lines = block.split("\n").map(l => MarkdownRenderer.#renderInline(l));
return `<p class="md-p">${lines.join("<br />")}</p>`;
}
// ─── Inline-level ─────────────────────────────────────────────────────────
/**
* @param {string} text
* @returns {string}
*/
static #renderInline(text) {
// Escape HTML first, then re-apply formatting
let out = MarkdownRenderer.#escape(text);
// Inline code (must come before bold/italic to avoid breaking backtick content)
out = out.replace(/`([^`]+)`/g, (_, code) =>
`<code class="md-code">${code}</code>`
);
// Bold + italic
out = out.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
// Bold
out = out.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
// Links [text](url)
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, linkText, url) => {
const safeUrl = MarkdownRenderer.#sanitizeUrl(url);
if (!safeUrl) return linkText;
const iconEntry = LINK_ICONS.find(e => e.pattern.test(safeUrl));
const prefix = iconEntry ? iconEntry.svg : "";
const pipeIdx = linkText.indexOf("|");
const inner = pipeIdx !== -1
? `<span class="md-link-title">${linkText.slice(0, pipeIdx)}</span><span class="md-link-sub">${linkText.slice(pipeIdx + 1)}</span>`
: linkText;
return `<a class="md-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${prefix}${inner}</a>`;
}
);
return out;
}
// ─── Utilities ────────────────────────────────────────────────────────────
/**
* Escapes HTML special characters.
*
* @param {string} str
* @returns {string}
*/
static #escape(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Returns a URL only if it uses a safe scheme; otherwise null.
*
* @param {string} url
* @returns {string | null}
*/
static #sanitizeUrl(url) {
const trimmed = url.trim();
if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed;
return null;
}
}

View File

@@ -0,0 +1,715 @@
import { getTypeColor } from "./getTypeColor.js";
import { MarkdownRenderer } from "./MarkdownRenderer.js";
import { NodeRegistry, NodeRenderContext } from "./nodes/index.js";
/**
* @typedef {import('./GraphNode.js').GraphNode} GraphNode
* @typedef {import('./GraphNode.js').PinDescriptor} PinDescriptor
* @typedef {import('./GraphConnection.js').GraphConnection} GraphConnection
* @typedef {import('./GraphComment.js').GraphComment} GraphComment
*/
/**
* Renders static blueprint nodes into a DOM layer.
* Stripped-down port of WorkspaceNodeRenderer — no drag, no selection, no context menus.
*/
export class NodeRenderer {
/** @type {HTMLElement} */
#nodeLayer;
/** @type {HTMLTemplateElement} */
#nodeTemplate;
/** @type {HTMLTemplateElement} */
#pinTemplate;
/** @type {Map<string, GraphNode>} */
#nodes = new Map();
/** @type {Map<string, HTMLElement>} */
#nodeElements = new Map();
/** @type {Map<string, GraphConnection>} */
#connections = new Map();
/** @type {Map<string, GraphComment>} */
#comments = new Map();
/** @type {Map<string, HTMLElement>} */
#commentElements = new Map();
/** @type {HTMLElement | null} */
#commentLayer = null;
/**
* Active interval handles keyed by timer node ID.
*
* @type {Map<string, number>}
*/
#timerIntervals = new Map();
/** @type {((nodeId: string, pinId: string, direction: string) => void) | null} */
#onNodeClick = null;
/** @type {((nodeId: string, pinId?: string) => void) | null} */
#onNodeExecute = null;
/** @type {(() => void) | null} */
#onConnectionRemoved = null;
/**
* Promises for all async markdown loads, used by contentReady().
*
* @type {Promise<void>[]}
*/
#markdownPromises = [];
/**
* Active chat node instances keyed by node ID.
*
* @type {Map<string, import('./ChatNode.js').ChatNode>}
*/
#chatInstances = new Map();
/** @type {NodeRenderContext | null} */
#renderCtx = null;
/**
* @param {HTMLElement} nodeLayer
* @param {HTMLTemplateElement} nodeTemplate
* @param {HTMLTemplateElement} pinTemplate
* @param {((nodeId: string, pinId: string, direction: string) => void) | null} [onNodeClick]
* @param {((nodeId: string, pinId?: string) => void) | null} [onNodeExecute]
* @param {(() => void) | null} [onConnectionRemoved]
*/
constructor(nodeLayer, nodeTemplate, pinTemplate, onNodeClick = null, onNodeExecute = null, onConnectionRemoved = null) {
this.#nodeLayer = nodeLayer;
this.#nodeTemplate = nodeTemplate;
this.#pinTemplate = pinTemplate;
this.#onNodeClick = onNodeClick;
this.#onNodeExecute = onNodeExecute;
this.#onConnectionRemoved = onConnectionRemoved;
this.#renderCtx = new NodeRenderContext({
loadMarkdown: (src, target) => this.#loadMarkdown(src, target),
addMarkdownPromise: (p) => this.#markdownPromises.push(p),
onNodeExecute: (nodeId, pinId) => this.#onNodeExecute?.(nodeId, pinId),
registerChatInstance: (nodeId, instance) => this.#chatInstances.set(nodeId, instance),
startTimer: (nodeId, toggle) => this.#startTimer(nodeId, toggle),
stopTimer: (nodeId) => this.#stopTimer(nodeId),
svgPlay: () => NodeRenderer.#svgPlay(),
svgStop: () => NodeRenderer.#svgStop(),
resolveInputValue: (nodeId, pinId) => this.#resolveInputValue(nodeId, pinId),
});
}
// ─── Public ───────────────────────────────────────────────────────────────
/**
* Adds a node to the graph and renders it into the DOM.
*
* @param {GraphNode} node
*/
addNode(node) {
this.#nodes.set(node.id, node);
this.#renderNode(node);
}
/**
* Adds a comment box to the graph and renders it into the DOM.
*
* @param {GraphComment} comment
*/
addComment(comment) {
this.#comments.set(comment.id, comment);
this.#renderComment(comment);
}
/**
* Removes all connections touching a specific pin and refreshes pin state.
* Calls onConnectionRemoved if any were removed.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
*/
disconnectPin(nodeId, pinId, direction) {
let removed = false;
for (const [id, conn] of this.#connections) {
const matches = direction === "output"
? conn.from.nodeId === nodeId && conn.from.pinId === pinId
: conn.to.nodeId === nodeId && conn.to.pinId === pinId;
if (matches) {
const otherNodeId = direction === "output" ? conn.to.nodeId : conn.from.nodeId;
this.#connections.delete(id);
this.#refreshPinStates(nodeId);
this.#refreshPinStates(otherNodeId);
removed = true;
}
}
if (removed) this.#onConnectionRemoved?.();
}
/**
* Adds a connection with kind-aware deduplication:
* - Exec pins: one-out (only one connection may leave a given exec output), many-in allowed.
* - Data pins: many-out allowed, one-in (only one connection may enter a given data input).
*
* @param {GraphConnection} connection
*/
addConnection(connection) {
/** @type {Set<string>} */
const staleNodes = new Set();
for (const [id, existing] of this.#connections) {
const isDuplicate = connection.kind === "exec"
? existing.from.nodeId === connection.from.nodeId && existing.from.pinId === connection.from.pinId
: existing.to.nodeId === connection.to.nodeId && existing.to.pinId === connection.to.pinId;
if (isDuplicate) {
staleNodes.add(existing.from.nodeId);
staleNodes.add(existing.to.nodeId);
this.#connections.delete(id);
break;
}
}
this.#connections.set(connection.id, connection);
staleNodes.add(connection.from.nodeId);
staleNodes.add(connection.to.nodeId);
for (const nodeId of staleNodes) this.#refreshPinStates(nodeId);
}
/**
* Returns all current connections.
*
* @returns {GraphConnection[]}
*/
getConnections() {
return [...this.#connections.values()];
}
/**
* Returns all current nodes.
*
* @returns {GraphNode[]}
*/
getNodes() {
return [...this.#nodes.values()];
}
/**
* Returns the world-space bounding rect of a node using its position and DOM size.
*
* @param {string} nodeId
* @returns {{ x: number, y: number, width: number, height: number } | null}
*/
/**
* Returns a promise that resolves once all markdown content has finished loading.
* Uses allSettled so a single failed fetch does not block others.
*
* @returns {Promise<void>}
*/
contentReady() {
return Promise.allSettled(this.#markdownPromises).then(() => {});
}
getNodeWorldRect(nodeId) {
const node = this.#nodes.get(nodeId);
const el = this.#nodeElements.get(nodeId);
if (!node || !el) return null;
return {
x: node.position.x,
y: node.position.y,
width: el.offsetWidth || 220,
height: el.offsetHeight || 80,
};
}
/**
* Returns the first node connected to the given node (via any connection).
*
* @param {string} nodeId
* @returns {string | null} The other node's ID, or null.
*/
getFirstConnectedNodeId(nodeId) {
for (const conn of this.#connections.values()) {
if (conn.from.nodeId === nodeId) return conn.to.nodeId;
if (conn.to.nodeId === nodeId) return conn.from.nodeId;
}
return null;
}
/**
* Returns the node ID connected to a specific pin.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {string | null}
*/
getConnectedNodeId(nodeId, pinId, direction) {
for (const conn of this.#connections.values()) {
if (direction === "output" && conn.from.nodeId === nodeId && conn.from.pinId === pinId) return conn.to.nodeId;
if (direction === "input" && conn.to.nodeId === nodeId && conn.to.pinId === pinId) return conn.from.nodeId;
}
return null;
}
/**
* Returns the DOM element for a node.
*
* @param {string} nodeId
* @returns {HTMLElement | undefined}
*/
getNodeElement(nodeId) {
return this.#nodeElements.get(nodeId);
}
/**
* Returns the pin handle element for use in connection geometry calculations.
*
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {HTMLElement | null}
*/
getPinHandle(nodeId, pinId, direction) {
const article = this.#nodeElements.get(nodeId);
if (!article) return null;
const container = direction === "input"
? article.querySelector(".node-inputs")
: article.querySelector(".node-outputs");
if (!container) return null;
const pin = container.querySelector(`[data-pin-id="${pinId}"]`);
return pin ? pin.querySelector(".pin-handle") : null;
}
/**
* Updates a node's world-space position and its DOM transform, with optional rotation for sway.
* Rotation formula matches WorkspaceGeometry.positionToTransform in Picograph.
*
* @param {string} nodeId
* @param {number} x
* @param {number} y
* @param {number} [rotation] - Z-rotation in degrees; also produces a perspective Y-axis lean.
*/
setNodeTransform(nodeId, x, y, rotation = 0) {
const node = this.#nodes.get(nodeId);
const el = this.#nodeElements.get(nodeId);
if (!node || !el) return;
node.position.x = x;
node.position.y = y;
el.style.transform = NodeRenderer.#buildTransform(x, y, rotation);
}
/**
* Updates a node's world-space position (no rotation).
*
* @param {string} nodeId
* @param {number} x
* @param {number} y
*/
setNodePosition(nodeId, x, y) {
this.setNodeTransform(nodeId, x, y, 0);
}
/**
* Builds a CSS transform string from a world position and optional rotation.
*
* @param {number} x
* @param {number} y
* @param {number} rotation
* @returns {string}
*/
static #buildTransform(x, y, rotation) {
if (!rotation) return `translate3d(${x}px, ${y}px, 0)`;
return `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg)`;
}
/**
* Returns all rendered node elements keyed by node ID.
*
* @returns {Map<string, HTMLElement>}
*/
getNodeElements() {
return this.#nodeElements;
}
// ─── Rendering ────────────────────────────────────────────────────────────
/** @param {GraphNode} node */
#renderNode(node) {
const fragment = /** @type {DocumentFragment} */ (this.#nodeTemplate.content.cloneNode(true));
const article = /** @type {HTMLElement} */ (fragment.querySelector(".blueprint-node"));
const title = /** @type {HTMLElement} */ (article.querySelector(".node-title"));
const inputs = /** @type {HTMLElement} */ (article.querySelector(".node-inputs"));
const outputs = /** @type {HTMLElement} */ (article.querySelector(".node-outputs"));
article.dataset.nodeId = node.id;
article.dataset.nodeType = node.type;
title.textContent = node.title;
article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`;
if (node.width != null) article.style.width = `${node.width}px`;
inputs.innerHTML = "";
outputs.innerHTML = "";
node.inputs.forEach(pin => {
const el = this.#createPinElement(node.id, pin, "input");
inputs.appendChild(el);
});
node.outputs.forEach(pin => {
const el = this.#createPinElement(node.id, pin, "output");
outputs.appendChild(el);
});
// Dispatch type-specific rendering to the registered node handler
const handler = NodeRegistry.BlueprintPure_Get(node.type);
if (handler) {
handler.BlueprintNativeEvent_OnRender(article, node, this.#renderCtx);
}
this.#nodeLayer.appendChild(article);
this.#nodeElements.set(node.id, article);
}
/**
* Fetches a markdown file and renders it into the target element.
*
* @param {string} src - Relative URL to the .md file.
* @param {HTMLElement} target - Container to populate.
* @returns {Promise<void>}
*/
async #loadMarkdown(src, target) {
try {
const response = await fetch(src);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
target.innerHTML = MarkdownRenderer.render(text);
} catch (err) {
target.innerHTML = `<p class="md-error">Failed to load content.</p>`;
console.error(`[NodeRenderer] Could not load markdown "${src}":`, err);
}
}
/**
* Renders a comment box into the DOM.
*
* @param {GraphComment} comment
*/
#renderComment(comment) {
// Create comment layer if it doesn't exist
if (!this.#commentLayer) {
this.#commentLayer = document.createElement("div");
this.#commentLayer.className = "comment-layer";
this.#nodeLayer.insertBefore(this.#commentLayer, this.#nodeLayer.firstChild);
}
const box = document.createElement("div");
box.className = "blueprint-comment";
box.dataset.commentId = comment.id;
box.style.transform = `translate3d(${comment.position.x}px, ${comment.position.y}px, 0)`;
box.style.width = `${comment.size.width}px`;
box.style.height = `${comment.size.height}px`;
// Create the colored background layer
const bg = document.createElement("div");
bg.className = "comment-background";
bg.style.backgroundColor = comment.color;
bg.style.opacity = comment.opacity.toString();
box.appendChild(bg);
const titleBar = document.createElement("div");
titleBar.className = "comment-title";
titleBar.textContent = comment.title;
box.appendChild(titleBar);
this.#commentLayer.appendChild(box);
this.#commentElements.set(comment.id, box);
}
// ─── Timer ────────────────────────────────────────────────────────────────
/**
* Starts the interval loop for a timer node, updating toggle button state.
*
* @param {string} nodeId
* @param {HTMLButtonElement} toggle
*/
#startTimer(nodeId, toggle) {
this.#stopTimer(nodeId);
const node = this.#nodes.get(nodeId);
const intervalPin = node?.inputs.find(p => p.id === "interval");
const seconds = Math.max(0.1, Math.min(10, parseFloat(intervalPin?.defaultValue ?? "1") || 1));
toggle.dataset.running = "true";
toggle.innerHTML = NodeRenderer.#svgStop();
const handle = window.setInterval(() => {
this.#onNodeExecute?.(nodeId);
}, seconds * 1000);
this.#timerIntervals.set(nodeId, handle);
}
/**
* Stops a running timer and resets the toggle button state.
*
* @param {string} nodeId
*/
#stopTimer(nodeId) {
const handle = this.#timerIntervals.get(nodeId);
if (handle != null) {
window.clearInterval(handle);
this.#timerIntervals.delete(nodeId);
}
const el = this.#nodeElements.get(nodeId);
const toggle = /** @type {HTMLButtonElement | null} */ (el?.querySelector(".node-timer-btn"));
if (toggle) {
toggle.dataset.running = "false";
toggle.innerHTML = NodeRenderer.#svgPlay();
toggle.title = "Start timer";
}
}
/** @returns {string} */
static #svgPlay() {
return `<svg width="9" height="9" viewBox="0 0 9 9" fill="currentColor" aria-hidden="true"><polygon points="1,0 9,4.5 1,9"/></svg>`;
}
/** @returns {string} */
static #svgStop() {
return `<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="8" height="8" rx="1"/></svg>`;
}
/**
* Resolves input value by following connections (for render-time use).
*
* @param {string} nodeId
* @param {string} pinId
* @returns {any}
*/
#resolveInputValue(nodeId, pinId) {
try {
console.log('[NodeRenderer#resolveInputValue] Looking for', nodeId, pinId);
console.log('[NodeRenderer#resolveInputValue] Available connections:', this.#connections);
console.log('[NodeRenderer#resolveInputValue] Available nodes:', Array.from(this.#nodes.keys()));
const conn = Array.from(this.#connections.values()).find(
c => c.to.nodeId === nodeId && c.to.pinId === pinId
);
console.log('[NodeRenderer#resolveInputValue] Found connection:', conn);
if (conn) {
const sourceNode = this.#nodes.get(conn.from.nodeId);
console.log('[NodeRenderer] Resolving', nodeId, pinId, 'from', conn.from.nodeId, 'sourceNode:', sourceNode);
// Handle random_name node
if (sourceNode?.type === "random_name" && conn.from.pinId === "name") {
console.log('[NodeRenderer] Random name value:', sourceNode._generatedName);
return sourceNode._generatedName || "";
}
// Fallback to source pin defaultValue
const sourcePin = sourceNode?.getPin?.(conn.from.pinId);
if (sourcePin?.defaultValue != null) {
return sourcePin.defaultValue;
}
}
// No connection, use local pin defaultValue
const node = this.#nodes.get(nodeId);
const pin = node?.getPin?.(pinId);
return pin?.defaultValue ?? "";
} catch (err) {
console.error(`Failed to resolve input ${nodeId}.${pinId}:`, err);
return "";
}
}
/**
* @param {string} nodeId
* @param {PinDescriptor} pin
* @param {('input'|'output')} direction
* @returns {HTMLElement}
*/
#createPinElement(nodeId, pin, direction) {
const fragment = /** @type {DocumentFragment} */ (this.#pinTemplate.content.cloneNode(true));
const container = /** @type {HTMLElement} */ (fragment.firstElementChild);
const label = /** @type {HTMLElement} */ (container.querySelector(".pin-label"));
const handle = /** @type {HTMLElement} */ (container.querySelector(".pin-handle"));
container.dataset.pinId = pin.id;
container.dataset.type = pin.kind;
container.dataset.direction = direction;
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
// Exec and string pins are clickable: focus the connected node
if (this.#onNodeClick && (pin.kind === "exec" || pin.kind === "string")) {
container.classList.add("is-clickable");
/** @param {MouseEvent} e */
const handleClick = (e) => {
e.stopPropagation();
if (e.altKey) return;
if (container.classList.contains("is-disconnected")) return;
this.#onNodeClick(nodeId, pin.id, direction);
};
handle.addEventListener("click", handleClick);
label.addEventListener("click", handleClick);
}
// Alt+click on any pin handle disconnects it
handle.addEventListener("click", (e) => {
if (!e.altKey) return;
e.stopPropagation();
this.disconnectPin(nodeId, pin.id, direction);
});
const isStandardExec = pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
if (isStandardExec && !pin.name) {
label.textContent = "";
label.classList.add("is-hidden");
} else {
label.textContent = pin.name;
}
// String pins: render an editable inline value chip
// For outputs: only on pure/string nodes
// For inputs: always show if there's a defaultValue
const node = this.#nodes.get(nodeId);
const isPureOrStringNode = node && (node.type === "pure" || node.type === "string");
const shouldShowStringValue = pin.kind === "string" && pin.defaultValue != null &&
((direction === "output" && isPureOrStringNode) || direction === "input");
if (shouldShowStringValue) {
const valueChip = document.createElement("span");
valueChip.className = "pin-value";
valueChip.textContent = pin.defaultValue;
valueChip.contentEditable = "true";
valueChip.spellcheck = false;
// Prevent workspace pan/drag from stealing pointer events while editing
valueChip.addEventListener("pointerdown", (e) => e.stopPropagation());
// Prevent pin click navigation from firing when clicking into the chip
valueChip.addEventListener("click", (e) => e.stopPropagation());
// Keep caret inside on Enter instead of inserting a <br>
valueChip.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
valueChip.blur();
}
});
// Sync back to the live pin descriptor so GraphExecutor reads the new value
valueChip.addEventListener("input", () => {
pin.defaultValue = valueChip.textContent ?? "";
});
// Clean up any accidental HTML on blur
valueChip.addEventListener("blur", () => {
const text = valueChip.textContent ?? "";
pin.defaultValue = text;
valueChip.textContent = text;
});
container.appendChild(valueChip);
}
// Number pins with defaultValue: render a clamped numeric input on inputs only
if (pin.kind === "number" && pin.defaultValue != null && direction === "input") {
const numInput = document.createElement("input");
numInput.type = "number";
numInput.className = "pin-value pin-value--number";
numInput.value = pin.defaultValue;
if (pin.min != null) numInput.min = String(pin.min);
if (pin.max != null) numInput.max = String(pin.max);
numInput.step = "0.1";
numInput.addEventListener("pointerdown", (e) => e.stopPropagation());
numInput.addEventListener("click", (e) => e.stopPropagation());
const clampAndSync = () => {
let v = parseFloat(numInput.value);
if (!Number.isFinite(v)) v = parseFloat(pin.defaultValue ?? "1");
if (pin.min != null) v = Math.max(pin.min, v);
if (pin.max != null) v = Math.min(pin.max, v);
const str = String(Math.round(v * 100) / 100);
numInput.value = str;
pin.defaultValue = str;
};
numInput.addEventListener("change", clampAndSync);
numInput.addEventListener("blur", clampAndSync);
container.appendChild(numInput);
}
// Start disconnected — addConnection() will update these
container.classList.add("is-disconnected");
return container;
}
/**
* Re-evaluates connected/disconnected classes for all pins on a node.
*
* @param {string} nodeId
*/
#refreshPinStates(nodeId) {
const node = this.#nodes.get(nodeId);
if (!node) return;
[...node.inputs, ...node.outputs].forEach(pin => {
this.#applyPinState(nodeId, pin.id, pin.direction);
});
}
/**
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
*/
#applyPinState(nodeId, pinId, direction) {
const handle = this.getPinHandle(nodeId, pinId, direction);
const pin = this.#findPinContainer(nodeId, pinId, direction);
if (!pin) return;
const connected = [...this.#connections.values()].some(c =>
(c.from.nodeId === nodeId && c.from.pinId === pinId) ||
(c.to.nodeId === nodeId && c.to.pinId === pinId)
);
pin.classList.toggle("is-connected", connected);
pin.classList.toggle("is-disconnected", !connected);
// Hide inline value chip on input pins when connected
if (direction === "input") {
const chip = pin.querySelector(".pin-value");
if (chip) chip.style.display = connected ? "none" : "";
}
}
/**
* @param {string} nodeId
* @param {string} pinId
* @param {('input'|'output')} direction
* @returns {HTMLElement | null}
*/
#findPinContainer(nodeId, pinId, direction) {
const article = this.#nodeElements.get(nodeId);
if (!article) return null;
const section = direction === "input"
? article.querySelector(".node-inputs")
: article.querySelector(".node-outputs");
return section?.querySelector(`[data-pin-id="${pinId}"]`) ?? null;
}
}

View File

@@ -0,0 +1,516 @@
import { getTypeColor } from "./getTypeColor.js";
import { GraphConnection } from "./GraphConnection.js";
/**
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
* @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator
* @typedef {import('./ConnectionRenderer.js').ConnectionRenderer} ConnectionRenderer
*/
/**
* @typedef {{ nodeId: string, pinId: string }} PinRef
*/
/**
* @typedef {{
* direction: 'input' | 'output',
* source: PinRef | undefined,
* target: PinRef | undefined,
* path: SVGPathElement,
* circle: SVGCircleElement,
* anchorX: number,
* anchorY: number,
* lastPointerX: number,
* lastPointerY: number,
* }} PendingConnection
*/
/**
* Handles pin-to-pin connection dragging in world-space.
*
* Mirrors the gesture flow from Picograph's BlueprintWorkspace:
* pointerdown on handle → temp SVG path follows cursor → pointerup on
* compatible pin → commit connection.
*
* Only active when {@link PinConnectionManager.enabled} is true.
*/
export class PinConnectionManager {
/** @type {HTMLElement} */
#canvas;
/** @type {SVGElement} */
#svg;
/** @type {HTMLElement} */
#nodeLayer;
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {WorkspaceNavigator} */
#nav;
/** @type {ConnectionRenderer} */
#connRenderer;
/** @type {PendingConnection | null} */
#pending = null;
/** @type {boolean} */
#enabled = false;
/**
* @param {HTMLElement} canvas - Workspace canvas element.
* @param {SVGElement} svg - Connection layer SVG element.
* @param {HTMLElement} nodeLayer - Node layer element.
* @param {NodeRenderer} nodeRenderer
* @param {WorkspaceNavigator} nav
* @param {ConnectionRenderer} connRenderer
*/
constructor(canvas, svg, nodeLayer, nodeRenderer, nav, connRenderer) {
this.#canvas = canvas;
this.#svg = svg;
this.#nodeLayer = nodeLayer;
this.#nodeRenderer = nodeRenderer;
this.#nav = nav;
this.#connRenderer = connRenderer;
this.#nodeLayer.addEventListener("pointerdown", (e) => {
if (!this.#enabled) return;
this.#handleNodeLayerPointerDown(e);
});
this.#nodeLayer.addEventListener("pointerenter", (e) => this.#handlePinHover(e, true), true);
this.#nodeLayer.addEventListener("pointerleave", (e) => this.#handlePinHover(e, false), true);
}
// ─── Public ───────────────────────────────────────────────────────────────
/** @param {boolean} v */
set enabled(v) { this.#enabled = v; }
/** @returns {boolean} */
get enabled() { return this.#enabled; }
/** @returns {PendingConnection | null} */
get pending() { return this.#pending; }
// ─── Private — event detection ─────────────────────────────────────────────
/**
* Highlights or clears connection wires when the pointer enters or leaves a pin handle.
*
* @param {PointerEvent} e
* @param {boolean} entering
*/
#handlePinHover(e, entering) {
const target = /** @type {HTMLElement} */ (e.target);
const handle = target.closest(".pin-handle");
if (!handle) return;
const pinContainer = handle.closest("[data-pin-id]");
const nodeArticle = handle.closest("[data-node-id]");
if (!pinContainer || !nodeArticle) return;
const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
if (!nodeId || !pinId) return;
if (entering) {
this.#connRenderer.highlightPin(nodeId, pinId);
} else {
this.#connRenderer.clearHighlight();
}
}
/**
* @param {PointerEvent} e
*/
#handleNodeLayerPointerDown(e) {
if (e.button !== 0) return;
const target = /** @type {HTMLElement} */ (e.target);
const handle = target.closest(".pin-handle");
if (!handle) return;
const pinContainer = handle.closest("[data-pin-id]");
const nodeArticle = handle.closest("[data-node-id]");
if (!pinContainer || !nodeArticle) return;
const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
const direction = /** @type {'input'|'output'} */ (
/** @type {HTMLElement} */ (pinContainer).dataset.direction
);
if (!nodeId || !pinId || !direction) return;
// Retrieve the pin kind so we can colour the temp wire
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
if (!node) return;
const pin = node.getPin(pinId);
if (!pin) return;
this.#startPendingConnectionGesture(e, nodeId, pinId, direction, pin.kind);
}
// ─── Private — gesture ────────────────────────────────────────────────────
/**
* Mirrors Picograph's `#startPendingConnectionGesture`.
*
* @param {PointerEvent} event
* @param {string} nodeId
* @param {string} pinId
* @param {'input'|'output'} direction
* @param {string} kind
*/
#startPendingConnectionGesture(event, nodeId, pinId, direction, kind) {
if (this.#pending) this.#cancelPendingConnection();
event.stopPropagation();
event.preventDefault();
const path = this.#createConnectionPath(kind);
this.#svg.appendChild(path);
// Add endpoint circle for visual cursor alignment
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", "4");
circle.setAttribute("fill", getTypeColor(kind));
circle.setAttribute("opacity", "0.9");
circle.dataset.pending = "true";
this.#svg.appendChild(circle);
// Compute initial anchor position in screen-space
const canvasRect = this.#canvas.getBoundingClientRect();
const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction);
const startRect = startHandle ? startHandle.getBoundingClientRect() : null;
const initAnchorX = startRect ? startRect.left - canvasRect.left + startRect.width / 2 : 0;
const initAnchorY = startRect ? startRect.top - canvasRect.top + startRect.height / 2 : 0;
this.#pending = {
direction,
source: direction === "output" ? { nodeId, pinId } : undefined,
target: direction === "input" ? { nodeId, pinId } : undefined,
path,
circle,
anchorX: initAnchorX,
anchorY: initAnchorY,
lastPointerX: initAnchorX,
lastPointerY: initAnchorY,
};
const handlePointerMove = (/** @type {PointerEvent} */ moveE) => {
if (moveE.pointerId !== event.pointerId) return;
this.#updatePendingConnectionPath(moveE.clientX, moveE.clientY, nodeId, pinId, direction);
};
const handlePointerUp = (/** @type {PointerEvent} */ upE) => {
if (upE.pointerId !== event.pointerId) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
this.#tryFinalizeAtPoint(upE.clientX, upE.clientY);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
/**
* Mirrors Picograph's `#updatePendingConnectionPath`.
*
* @param {number} clientX
* @param {number} clientY
* @param {string} nodeId
* @param {string} pinId
* @param {'input'|'output'} direction
*/
#updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) {
if (!this.#pending) return;
const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction);
if (!startHandle) return;
const canvasRect = this.#canvas.getBoundingClientRect();
const startRect = startHandle.getBoundingClientRect();
// Screen-space coordinates
const anchor = {
x: startRect.left - canvasRect.left + startRect.width / 2,
y: startRect.top - canvasRect.top + startRect.height / 2,
};
const pointer = {
x: clientX - canvasRect.left,
y: clientY - canvasRect.top,
};
// Keep anchor and pointer up-to-date for springback animation
this.#pending.anchorX = anchor.x;
this.#pending.anchorY = anchor.y;
this.#pending.lastPointerX = pointer.x;
this.#pending.lastPointerY = pointer.y;
const start = direction === "output" ? anchor : pointer;
const end = direction === "output" ? pointer : anchor;
const cpOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
// Lerp pointer-side control point based on drag direction
// When pointer.x >= anchor.x: factor = 0 (forward, offset = -cpOffset)
// When pointer.x < anchor.x: factor increases (backward, offset lerps to +cpOffset)
const dragDelta = pointer.x - anchor.x;
const lerpFactor = Math.max(0, Math.min(1, -dragDelta / (cpOffset * 2)));
const pointerCpOffset = -cpOffset + (lerpFactor * cpOffset * 2); // lerp from -cpOffset to +cpOffset
// Make the pointer-side control point curve toward the pin
let cp1x, cp1y, cp2x, cp2y;
if (direction === "output") {
// start = anchor (pin), end = pointer
// Anchor side always extends right, pointer side lerps
cp1x = start.x + cpOffset;
cp1y = start.y;
cp2x = end.x + pointerCpOffset;
cp2y = end.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor
} else {
// start = pointer, end = anchor (pin)
// Pointer side lerps, anchor side always extends left
cp1x = start.x + pointerCpOffset;
cp1y = start.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor
cp2x = end.x - cpOffset;
cp2y = end.y;
}
const d = `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`;
this.#pending.path.dataset.active = "true";
this.#pending.path.setAttribute("d", d);
// Update circle position to pointer location
this.#pending.circle.setAttribute("cx", pointer.x.toString());
this.#pending.circle.setAttribute("cy", pointer.y.toString());
}
/**
* Checks the elements under the pointer on release for a compatible pin.
* Mirrors the drop detection pattern from Picograph's WorkspaceNodeRenderer's
* `pointerup` handler on pin containers.
*
* @param {number} clientX
* @param {number} clientY
*/
#tryFinalizeAtPoint(clientX, clientY) {
if (!this.#pending) return;
const elements = document.elementsFromPoint(clientX, clientY);
for (const el of elements) {
if (!(el instanceof HTMLElement)) continue;
const pinContainer = el.closest("[data-pin-id]");
const nodeArticle = el.closest("[data-node-id]");
if (!pinContainer || !nodeArticle) continue;
const targetNodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
const targetPinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
const targetDirection = /** @type {'input'|'output'} */ (
/** @type {HTMLElement} */ (pinContainer).dataset.direction
);
if (!targetNodeId || !targetPinId || !targetDirection) continue;
this.#finalizeConnection(targetNodeId, targetPinId, targetDirection);
return;
}
this.#cancelPendingConnection();
}
/**
* Mirrors Picograph's `#finalizeConnection`.
*
* @param {string} nodeId
* @param {string} pinId
* @param {'input'|'output'} direction
*/
#finalizeConnection(nodeId, pinId, direction) {
if (!this.#pending) return;
const { source, target } = this.#pending;
/** @type {PinRef | null} */
let fromRef = null;
/** @type {PinRef | null} */
let toRef = null;
if (direction === "input" && source) {
fromRef = source;
toRef = { nodeId, pinId };
} else if (direction === "output" && target) {
fromRef = { nodeId, pinId };
toRef = target;
}
if (fromRef && toRef && fromRef.nodeId !== toRef.nodeId) {
const fromNode = this.#nodeRenderer.getNodes().find(n => n.id === fromRef.nodeId);
const toNode = this.#nodeRenderer.getNodes().find(n => n.id === toRef.nodeId);
const fromPin = fromNode?.getPin(fromRef.pinId);
const toPin = toNode?.getPin(toRef.pinId);
if (fromPin && toPin && this.#kindsCompatible(fromPin.kind, toPin.kind)) {
const conn = new GraphConnection(fromRef, toRef, fromPin.kind);
this.#nodeRenderer.addConnection(conn);
this.#pending.path.remove();
this.#pending.circle.remove();
this.#pending = null;
this.#connRenderer.render();
return;
}
}
this.#cancelPendingConnection();
}
/**
* Mirrors Picograph's `#cancelPendingConnection`.
* Launches a springback animation before removing the wire.
*/
#cancelPendingConnection() {
if (!this.#pending) return;
const { path, circle, direction, anchorX, anchorY, lastPointerX, lastPointerY } = this.#pending;
this.#pending = null;
// Remove circle immediately (no springback needed)
circle.remove();
this.#animateWireSpringback(path, direction, anchorX, anchorY, lastPointerX, lastPointerY);
}
/**
* Retracts the wire with a whip character: the free end snaps home quickly
* while the bezier arch sags and decays at a slower rate, giving the visual
* impression of a real cable being released.
*
* @param {SVGPathElement} path
* @param {'input'|'output'} direction
* @param {number} anchorX - Anchor screen-space X.
* @param {number} anchorY - Anchor screen-space Y.
* @param {number} fromX - Free-end X at release.
* @param {number} fromY - Free-end Y at release.
*/
#animateWireSpringback(path, direction, anchorX, anchorY, fromX, fromY) {
// Endpoint snaps home fast; arch shape collapses slower — two different rates
// produce the non-linear whip character.
const endpointDecay = 0.022; // px/ms half-life ~31ms — fast snap
const archDecay = 0.007; // half-life ~99ms — slow arch collapse
const wobbleDecay = 0.008; // wobble amplitude decay (slower than endpoint, faster than arch)
let px = fromX;
let py = fromY;
// Initial sag magnitude: proportional to release distance, capped.
const releaseDist = Math.sqrt((fromX - anchorX) ** 2 + (fromY - anchorY) ** 2);
let sag = Math.min(releaseDist * 0.1, 30); // Reduced from 0.4/120
let wobbleAmplitude = Math.min(releaseDist * 0.4, 80); // Increased from 0.3/60
let lastTime = performance.now();
let phase = 0;
const wobbleFreq = 0.018; // radians per ms (~2.8 Hz) - increased frequency
/** @param {number} now */
const tick = (now) => {
const dt = Math.min(now - lastTime, 32);
lastTime = now;
// Endpoint: fast exponential decay toward anchor
const ef = Math.exp(-endpointDecay * dt);
px = anchorX + (px - anchorX) * ef;
py = anchorY + (py - anchorY) * ef;
// Arch sag: slower exponential decay
const af = Math.exp(-archDecay * dt);
sag *= af;
// Wobble: amplitude decay + phase advance
const wf = Math.exp(-wobbleDecay * dt);
wobbleAmplitude *= wf;
phase += wobbleFreq * dt;
const dx = anchorX - px;
const dy = anchorY - py;
const dist = Math.sqrt(dx * dx + dy * dy);
const start = direction === "output" ? { x: anchorX, y: anchorY } : { x: px, y: py };
const end = direction === "output" ? { x: px, y: py } : { x: anchorX, y: anchorY };
// Control points: standard horizontal tension + perpendicular sag offset + sine wave wobble
const mx = (start.x + end.x) / 2;
const my = (start.y + end.y) / 2;
const len = Math.max(dist, 1);
// Perpendicular unit vector (rotated 90°)
const perpX = -(end.y - start.y) / len;
const perpY = (end.x - start.x) / len;
const tension = Math.max(20, dist * 0.45);
// Apply sine wave wobble to anchor-side control point only
const wobble = Math.sin(phase) * wobbleAmplitude;
// Anchor is at 'start' for output pins, 'end' for input pins
const cp1x = start.x + tension + perpX * (sag + (direction === "output" ? wobble : 0));
const cp1y = start.y + perpY * (sag + (direction === "output" ? wobble : 0));
const cp2x = end.x - tension + perpX * (sag + (direction === "input" ? wobble : 0));
const cp2y = end.y + perpY * (sag + (direction === "input" ? wobble : 0));
path.setAttribute("d",
`M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
);
if (dist < 1 && sag < 1 && wobbleAmplitude < 0.5) {
path.remove();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}
// ─── Private — helpers ────────────────────────────────────────────────────
/**
* Creates a styled SVG path element for the pending wire.
* Matches Picograph's `#createConnectionPath`.
*
* @param {string} kind
* @returns {SVGPathElement}
*/
#createConnectionPath(kind) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("fill", "none");
path.setAttribute("stroke-width", "2.5");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-dasharray", "6 4");
path.style.stroke = getTypeColor(kind);
path.style.opacity = "0.8";
path.dataset.kind = kind;
path.dataset.pending = "true";
return path;
}
/**
* Returns true if two pin kinds can be connected.
* Mirrors Picograph's type compatibility (any pin accepts `any`).
*
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
#kindsCompatible(a, b) {
return a === b || a === "any" || b === "any";
}
}

View File

@@ -0,0 +1,71 @@
/**
* Manages a Twitch-chat-style stacked speech bubble above a print node element.
*
* Messages enter at the bottom of the stack, push older messages upward, and
* individually fade out after 5 seconds — matching the Twitch chat feel.
*/
export class PrintBubble {
/** @type {HTMLElement} */
#stack;
/** Each active message and its cleanup timer. @type {Array<{ el: HTMLElement, timer: number }>} */
#entries = [];
static #FADE_START_MS = 4500;
static #FADE_DURATION_MS = 500;
/**
* @param {HTMLElement} nodeEl - The node element to attach bubbles to.
*/
constructor(nodeEl) {
this.#stack = document.createElement("div");
this.#stack.className = "print-bubble-stack";
this.#stack.setAttribute("role", "log");
this.#stack.setAttribute("aria-live", "polite");
nodeEl.appendChild(this.#stack);
}
/**
* Pushes a new message onto the bubble stack.
*
* @param {string} text - The message to display.
*/
push(text) {
const msg = document.createElement("div");
msg.className = "print-bubble-msg";
msg.textContent = String(text);
this.#stack.appendChild(msg);
const entry = { el: msg, timer: 0 };
this.#entries.push(entry);
entry.timer = window.setTimeout(() => {
this.#fadeOut(entry);
}, PrintBubble.#FADE_START_MS);
}
/**
* Triggers the fade-out animation and removes the message element.
*
* @param {{ el: HTMLElement, timer: number }} entry
*/
#fadeOut(entry) {
entry.el.classList.add("is-fading");
window.setTimeout(() => {
entry.el.remove();
const idx = this.#entries.indexOf(entry);
if (idx !== -1) this.#entries.splice(idx, 1);
}, PrintBubble.#FADE_DURATION_MS);
}
/**
* Immediately clears all active messages and their timers.
*/
clear() {
for (const entry of this.#entries) {
window.clearTimeout(entry.timer);
entry.el.remove();
}
this.#entries = [];
}
}

View File

@@ -0,0 +1,386 @@
/**
* Properties Panel - Visual editor for nodes, focus options, and graph settings
* Similar to Photoshop's properties panel
*/
export class PropertiesPanel {
/** @type {HTMLElement} */
#panel;
/** @type {(() => void) | null} */
#onChange = null;
/** @type {any} */
#currentNode = null;
/** @type {any} */
#graphData = null;
/**
* @param {HTMLElement} panel
* @param {(() => void) | null} [onChange]
*/
constructor(panel, onChange = null) {
this.#panel = panel;
this.#onChange = onChange;
this.#bindEvents();
}
#bindEvents() {
// Toggle button (close)
const toggleBtn = this.#panel.querySelector('.properties-toggle-btn');
toggleBtn?.addEventListener('click', () => {
this.#panel.classList.toggle('collapsed');
});
// Tab button (open when collapsed)
const tabBtn = this.#panel.querySelector('.properties-panel-tab');
tabBtn?.addEventListener('click', () => {
this.#panel.classList.remove('collapsed');
});
// Collapsible sections
this.#panel.querySelectorAll('.section-header.collapsible').forEach(header => {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
});
});
// Node property inputs
document.getElementById('propNodeTitle')?.addEventListener('input', () => this.#updateNodeProperty('title'));
document.getElementById('propNodeX')?.addEventListener('input', () => this.#updateNodeProperty('x'));
document.getElementById('propNodeY')?.addEventListener('input', () => this.#updateNodeProperty('y'));
document.getElementById('propNodeWidth')?.addEventListener('input', () => this.#updateNodeProperty('width'));
// Focus options inputs
document.getElementById('propFocusDuration')?.addEventListener('input', () => this.#updateFocusOptions());
document.getElementById('propFocusAnchorX')?.addEventListener('input', () => this.#updateFocusOptions());
document.getElementById('propFocusAnchorY')?.addEventListener('input', () => this.#updateFocusOptions());
document.getElementById('propWorldBoxWidth')?.addEventListener('input', () => this.#updateFocusOptions());
document.getElementById('propWorldBoxHeight')?.addEventListener('input', () => this.#updateFocusOptions());
// Add breakpoint button
document.getElementById('addBreakpointBtn')?.addEventListener('click', () => this.#addBreakpoint());
// Apply settings button
document.getElementById('applySettingsBtn')?.addEventListener('click', () => this.#applyGraphSettings());
// Graph settings
document.getElementById('propGraphDraggable')?.addEventListener('change', () => {
if (this.#graphData?.settings) {
this.#graphData.settings.draggable = document.getElementById('propGraphDraggable').checked;
this.#onChange?.();
}
});
}
/**
* Load a node into the properties panel
*
* @param {any} node
*/
loadNode(node) {
this.#currentNode = node;
if (!node) {
document.getElementById('nodePropertiesSection').style.display = 'none';
document.getElementById('focusOptionsSection').style.display = 'none';
document.getElementById('noSelectionMessage').style.display = 'block';
return;
}
document.getElementById('nodePropertiesSection').style.display = 'block';
document.getElementById('focusOptionsSection').style.display = 'block';
document.getElementById('noSelectionMessage').style.display = 'none';
// Populate node properties
const idInput = document.getElementById('propNodeId');
const typeInput = document.getElementById('propNodeType');
const titleInput = document.getElementById('propNodeTitle');
const xInput = document.getElementById('propNodeX');
const yInput = document.getElementById('propNodeY');
const widthInput = document.getElementById('propNodeWidth');
if (idInput) idInput.value = node.id || '';
if (typeInput) typeInput.value = node.type || '';
if (titleInput) titleInput.value = node.title || '';
if (xInput) xInput.value = node.position?.x ?? 0;
if (yInput) yInput.value = node.position?.y ?? 0;
if (widthInput) widthInput.value = node.width || '';
// Populate focus options
const focusOpts = node.focusOptions || {};
const durationInput = document.getElementById('propFocusDuration');
const anchorXInput = document.getElementById('propFocusAnchorX');
const anchorYInput = document.getElementById('propFocusAnchorY');
const boxWidthInput = document.getElementById('propWorldBoxWidth');
const boxHeightInput = document.getElementById('propWorldBoxHeight');
if (durationInput) durationInput.value = focusOpts.durationMs || '';
if (anchorXInput) anchorXInput.value = focusOpts.anchorX ?? '';
if (anchorYInput) anchorYInput.value = focusOpts.anchorY ?? '';
if (boxWidthInput) boxWidthInput.value = focusOpts.minWorldBox?.width || '';
if (boxHeightInput) boxHeightInput.value = focusOpts.minWorldBox?.height || '';
// Render breakpoints
this.#renderBreakpoints();
}
/**
* Load graph settings
*
* @param {any} graphData
*/
loadGraphSettings(graphData) {
this.#graphData = graphData;
const draggableCheck = document.getElementById('propGraphDraggable');
const introDurationInput = document.getElementById('propIntroDuration');
const viewXInput = document.getElementById('propInitViewX');
const viewYInput = document.getElementById('propInitViewY');
const viewWidthInput = document.getElementById('propInitViewWidth');
const viewHeightInput = document.getElementById('propInitViewHeight');
const settings = graphData?.settings || {};
const viewbox = settings.initialViewbox || {};
if (draggableCheck) draggableCheck.checked = settings.draggable ?? true;
if (introDurationInput) introDurationInput.value = settings.introDurationMs || '';
if (viewXInput) viewXInput.value = viewbox.x || '';
if (viewYInput) viewYInput.value = viewbox.y || '';
if (viewWidthInput) viewWidthInput.value = viewbox.width || '';
if (viewHeightInput) viewHeightInput.value = viewbox.height || '';
}
#updateNodeProperty(prop) {
if (!this.#currentNode) return;
const titleInput = document.getElementById('propNodeTitle');
const xInput = document.getElementById('propNodeX');
const yInput = document.getElementById('propNodeY');
const widthInput = document.getElementById('propNodeWidth');
if (prop === 'title' && titleInput) {
this.#currentNode.title = titleInput.value;
} else if (prop === 'x' && xInput) {
this.#currentNode.position.x = parseFloat(xInput.value) || 0;
} else if (prop === 'y' && yInput) {
this.#currentNode.position.y = parseFloat(yInput.value) || 0;
} else if (prop === 'width' && widthInput) {
this.#currentNode.width = widthInput.value ? parseFloat(widthInput.value) : undefined;
}
this.#onChange?.();
}
#updateFocusOptions() {
if (!this.#currentNode) return;
if (!this.#currentNode.focusOptions) {
this.#currentNode.focusOptions = {};
}
const durationInput = document.getElementById('propFocusDuration');
const anchorXInput = document.getElementById('propFocusAnchorX');
const anchorYInput = document.getElementById('propFocusAnchorY');
const boxWidthInput = document.getElementById('propWorldBoxWidth');
const boxHeightInput = document.getElementById('propWorldBoxHeight');
if (durationInput?.value) {
this.#currentNode.focusOptions.durationMs = parseFloat(durationInput.value);
}
if (anchorXInput?.value) {
this.#currentNode.focusOptions.anchorX = parseFloat(anchorXInput.value);
}
if (anchorYInput?.value) {
this.#currentNode.focusOptions.anchorY = parseFloat(anchorYInput.value);
}
if (boxWidthInput?.value || boxHeightInput?.value) {
if (!this.#currentNode.focusOptions.minWorldBox) {
this.#currentNode.focusOptions.minWorldBox = {};
}
if (boxWidthInput?.value) {
this.#currentNode.focusOptions.minWorldBox.width = parseFloat(boxWidthInput.value);
}
if (boxHeightInput?.value) {
this.#currentNode.focusOptions.minWorldBox.height = parseFloat(boxHeightInput.value);
}
}
this.#onChange?.();
}
#renderBreakpoints() {
const container = document.getElementById('breakpointsList');
if (!container) return;
container.innerHTML = '';
const responsiveBoxes = this.#currentNode?.focusOptions?.responsiveWorldBox;
if (!responsiveBoxes) return;
const breakpoints = Array.isArray(responsiveBoxes) ? responsiveBoxes : [responsiveBoxes];
breakpoints.forEach((bp, index) => {
const item = document.createElement('div');
item.className = 'breakpoint-item';
item.innerHTML = `
<div class="breakpoint-header">
<div class="breakpoint-title">Breakpoint ${index + 1}</div>
<button class="breakpoint-remove" data-index="${index}">Remove</button>
</div>
<div class="prop-row">
<label class="prop-label">Min Viewport Width</label>
<input type="number" class="prop-input bp-min-width" data-index="${index}" value="${bp.minViewportWidth || 0}" />
</div>
<div class="prop-row">
<label class="prop-label">WorldBox Width</label>
<input type="number" class="prop-input bp-box-width" data-index="${index}" value="${bp.minWorldBox?.width || ''}" />
</div>
<div class="prop-row">
<label class="prop-label">WorldBox Height</label>
<input type="number" class="prop-input bp-box-height" data-index="${index}" value="${bp.minWorldBox?.height || ''}" />
</div>
<div class="prop-row">
<label class="prop-label">Anchor X</label>
<input type="number" class="prop-input bp-anchor-x" data-index="${index}" step="0.1" min="0" max="1" value="${bp.anchorX ?? ''}" />
</div>
<div class="prop-row">
<label class="prop-label">Anchor Y</label>
<input type="number" class="prop-input bp-anchor-y" data-index="${index}" step="0.1" min="0" max="1" value="${bp.anchorY ?? ''}" />
</div>
`;
container.appendChild(item);
// Bind events
item.querySelector('.breakpoint-remove')?.addEventListener('click', () => this.#removeBreakpoint(index));
item.querySelectorAll('input').forEach(input => {
input.addEventListener('input', () => this.#updateBreakpoint(index));
});
});
}
#addBreakpoint() {
if (!this.#currentNode) return;
if (!this.#currentNode.focusOptions) {
this.#currentNode.focusOptions = {};
}
if (!this.#currentNode.focusOptions.responsiveWorldBox) {
this.#currentNode.focusOptions.responsiveWorldBox = [];
} else if (!Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)) {
this.#currentNode.focusOptions.responsiveWorldBox = [this.#currentNode.focusOptions.responsiveWorldBox];
}
this.#currentNode.focusOptions.responsiveWorldBox.push({
minViewportWidth: 768,
minWorldBox: { width: 800, height: 600 },
anchorX: 0.5,
anchorY: 0.5
});
this.#renderBreakpoints();
this.#onChange?.();
}
#removeBreakpoint(index) {
if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return;
const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)
? this.#currentNode.focusOptions.responsiveWorldBox
: [this.#currentNode.focusOptions.responsiveWorldBox];
breakpoints.splice(index, 1);
if (breakpoints.length === 0) {
delete this.#currentNode.focusOptions.responsiveWorldBox;
} else {
this.#currentNode.focusOptions.responsiveWorldBox = breakpoints;
}
this.#renderBreakpoints();
this.#onChange?.();
}
#updateBreakpoint(index) {
if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return;
const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)
? this.#currentNode.focusOptions.responsiveWorldBox
: [this.#currentNode.focusOptions.responsiveWorldBox];
const bp = breakpoints[index];
if (!bp) return;
const minWidthInput = document.querySelector(`.bp-min-width[data-index="${index}"]`);
const boxWidthInput = document.querySelector(`.bp-box-width[data-index="${index}"]`);
const boxHeightInput = document.querySelector(`.bp-box-height[data-index="${index}"]`);
const anchorXInput = document.querySelector(`.bp-anchor-x[data-index="${index}"]`);
const anchorYInput = document.querySelector(`.bp-anchor-y[data-index="${index}"]`);
if (minWidthInput?.value) bp.minViewportWidth = parseFloat(minWidthInput.value);
if (!bp.minWorldBox) bp.minWorldBox = {};
if (boxWidthInput?.value) bp.minWorldBox.width = parseFloat(boxWidthInput.value);
if (boxHeightInput?.value) bp.minWorldBox.height = parseFloat(boxHeightInput.value);
if (anchorXInput?.value) bp.anchorX = parseFloat(anchorXInput.value);
if (anchorYInput?.value) bp.anchorY = parseFloat(anchorYInput.value);
this.#onChange?.();
}
#applyGraphSettings() {
if (!this.#graphData) return;
const introDurationInput = document.getElementById('propIntroDuration');
const viewXInput = document.getElementById('propInitViewX');
const viewYInput = document.getElementById('propInitViewY');
const viewWidthInput = document.getElementById('propInitViewWidth');
const viewHeightInput = document.getElementById('propInitViewHeight');
if (!this.#graphData.settings) this.#graphData.settings = {};
if (!this.#graphData.settings.initialViewbox) this.#graphData.settings.initialViewbox = {};
if (introDurationInput?.value) {
this.#graphData.settings.introDurationMs = parseFloat(introDurationInput.value);
}
const viewbox = this.#graphData.settings.initialViewbox;
if (viewXInput?.value) viewbox.x = parseFloat(viewXInput.value);
if (viewYInput?.value) viewbox.y = parseFloat(viewYInput.value);
if (viewWidthInput?.value) viewbox.width = parseFloat(viewWidthInput.value);
if (viewHeightInput?.value) viewbox.height = parseFloat(viewHeightInput.value);
this.#onChange?.();
// Show confirmation
const btn = document.getElementById('applySettingsBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Applied ✓';
btn.style.background = 'rgba(60, 180, 100, 1)';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
}
/** @returns {boolean} */
isCollapsed() {
return this.#panel.classList.contains('collapsed');
}
/** Collapse the panel */
collapse() {
this.#panel.classList.add('collapsed');
}
/** Expand the panel */
expand() {
this.#panel.classList.remove('collapsed');
}
}

View File

@@ -0,0 +1,120 @@
/**
* Manages spatial audio playback with distance-based volume and stereo panning.
*/
export class SpatialAudio {
/** @type {AudioContext} */
#audioContext;
/** @type {Map<string, AudioBuffer>} */
#buffers = new Map();
/** @type {() => {x: number, y: number, width: number, height: number}} */
#getViewport;
/**
* @param {() => {x: number, y: number, width: number, height: number}} getViewport - Returns current viewport bounds in world-space
*/
constructor(getViewport) {
this.#audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.#getViewport = getViewport;
}
/**
* Preloads an audio file.
*
* @param {string} key - Identifier for this audio
* @param {string} url - Path to audio file
*/
async load(key, url) {
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.#audioContext.decodeAudioData(arrayBuffer);
this.#buffers.set(key, audioBuffer);
} catch (err) {
console.error(`[SpatialAudio] Failed to load "${url}":`, err);
}
}
/**
* Plays a sound at a world-space position with spatial audio.
* Volume is based on how much of the viewport the source occupies.
* Sounds muffle progressively as the source moves off-screen.
*
* @param {string} key - Audio identifier
* @param {number} worldX - X position in world-space (anchor center)
* @param {number} worldY - Y position in world-space (anchor center)
* @param {number} worldWidth - Width in world-space
* @param {number} worldHeight - Height in world-space
* @param {{ minVolume?: number, maxVolume?: number, sizeThreshold?: number }} [options]
*/
play(key, worldX, worldY, worldWidth, worldHeight, options = {}) {
const buffer = this.#buffers.get(key);
if (!buffer) {
console.warn(`[SpatialAudio] Audio "${key}" not loaded`);
return;
}
const viewport = this.#getViewport();
// Calculate what percentage of viewport the source occupies
const viewportArea = viewport.width * viewport.height;
const sourceArea = worldWidth * worldHeight;
const areaPercentage = sourceArea / viewportArea;
// Volume based on size percentage (larger on screen = louder)
const minVolume = options.minVolume ?? 0;
const maxVolume = options.maxVolume ?? 1.0;
const sizeThreshold = options.sizeThreshold ?? 0.05;
const sizeFactor = Math.min(1, areaPercentage / sizeThreshold);
const volume = minVolume + (maxVolume - minVolume) * sizeFactor;
// Viewport intersection — how much of the node is actually visible (01)
const nodeLeft = worldX - worldWidth / 2;
const nodeTop = worldY - worldHeight / 2;
const nodeRight = worldX + worldWidth / 2;
const nodeBottom = worldY + worldHeight / 2;
const overlapW = Math.max(0, Math.min(nodeRight, viewport.x + viewport.width) - Math.max(nodeLeft, viewport.x));
const overlapH = Math.max(0, Math.min(nodeBottom, viewport.y + viewport.height) - Math.max(nodeTop, viewport.y));
const overlapArea = overlapW * overlapH;
const visibilityFraction = sourceArea > 0 ? Math.min(1, overlapArea / sourceArea) : 0;
// Stereo panning based on horizontal position relative to viewport centre
const centerX = viewport.x + viewport.width / 2;
const dx = worldX - centerX;
const panRange = viewport.width / 2;
const pan = Math.max(-1, Math.min(1, dx / panRange));
// Muffling: low-pass filter whose cutoff drops as the node leaves the screen.
// Fully on-screen → 20 000 Hz (effectively open)
// Fully off-screen → 400 Hz (thick muffle)
const MUFFLE_OPEN = 20000;
const MUFFLE_CLOSED = 300;
const cutoff = MUFFLE_CLOSED + (MUFFLE_OPEN - MUFFLE_CLOSED) * visibilityFraction;
// Create audio nodes
const source = this.#audioContext.createBufferSource();
source.buffer = buffer;
const gainNode = this.#audioContext.createGain();
gainNode.gain.value = volume;
const panNode = this.#audioContext.createStereoPanner();
panNode.pan.value = pan;
const filterNode = this.#audioContext.createBiquadFilter();
filterNode.type = "lowpass";
filterNode.frequency.value = cutoff;
filterNode.Q.value = 0.7;
// Connect: source → filter → gain → pan → destination
source.connect(filterNode);
filterNode.connect(gainNode);
gainNode.connect(panNode);
panNode.connect(this.#audioContext.destination);
source.start(0);
}
}

View File

@@ -0,0 +1,802 @@
/**
* Handles pan and zoom for the graph workspace canvas.
* Ported directly from WorkspaceNavigationManager in Picograph.
* Grid is CSS-driven; this class updates CSS custom properties and background position.
*/
export class WorkspaceNavigator {
/** @type {HTMLElement} */
#canvas;
/** @type {HTMLElement | null} */
#worldLayer = null;
/** @type {(() => void) | null} */
#onAfterTransform = null;
/** @type {{ x: number, y: number }} */
#backgroundOffset = { x: 0, y: 0 };
/** @type {{ x: number, y: number }} */
#pendingLayerOffset = { x: 0, y: 0 };
/** @type {number} */
#zoomLevel = 1;
/** @type {number} */
#targetZoomLevel = 1;
/** @type {{ min: number, max: number, step: number }} */
#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
/** @type {number | null} */
#smoothZoomRafId = null;
/** @type {number | null} */
#focusAnimRafId = null;
/** @type {number} */
#focusAnimGen = 0;
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
#smoothZoomFocus = 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;
/** @type {number} */
#lastPanTimestamp = 0;
/** @type {{ width: number, height: number }} */
#lastCanvasSize = { width: 0, height: 0 };
/** @type {{ touches: Map<number, {x: number, y: number}>, initialDistance: number, initialZoom: number, initialBackgroundOffset: {x: number, y: number}, center: {x: number, y: number} } | null} */
#pinchState = null;
/** @type {(() => boolean) | null} */
#isDragInProgress = null;
/** @type {(() => void) | null} */
#onPinchStart = null;
/**
* @param {HTMLElement} canvas - The `.workspace-canvas` element.
* @param {HTMLElement | null} [worldLayer] - Optional world-space layer to transform with pan/zoom.
* @param {(() => void) | null} [onAfterTransform] - Called after every transform update.
*/
constructor(canvas, worldLayer = null, onAfterTransform = null) {
this.#canvas = canvas;
this.#worldLayer = worldLayer;
this.#onAfterTransform = onAfterTransform;
const initialRect = canvas.getBoundingClientRect();
this.#lastCanvasSize = { width: initialRect.width, height: initialRect.height };
this.#updateZoomDisplay();
this.#bindEvents();
this.#bindResizeObserver();
}
/**
* Sets the callback fired after every transform update.
* Assign this AFTER constructing dependent renderers to avoid temporal dead zone issues.
*
* @param {(() => void) | null} cb
*/
setOnAfterTransform(cb) {
this.#onAfterTransform = cb;
}
/**
* Registers a callback that returns true when a node drag is actively in progress.
* When true, single-finger touch and pointer pan will be suppressed so the drag
* can move the node without competing with the pan handler.
*
* @param {(() => boolean) | null} fn
*/
setDragInProgressChecker(fn) {
this.#isDragInProgress = fn;
}
/**
* Registers a callback fired when a two-finger pinch gesture begins.
* Use this to cancel any active node drag before the zoom takes over.
*
* @param {(() => void) | null} fn
*/
setOnPinchStart(fn) {
this.#onPinchStart = fn;
}
/** True while a two-finger pinch gesture is in progress. */
get isPinching() { return this.#pinchState !== null; }
/**
* Pans the viewport by a world-space delta.
* Used by ItemDragger's edge-pan loop to scroll while dragging a node.
*
* @param {number} worldDx
* @param {number} worldDy
*/
panBy(worldDx, worldDy) {
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + worldDx,
y: this.#backgroundOffset.y + worldDy,
});
}
// ─── Public ───────────────────────────────────────────────────────────────
/** Current zoom level. */
get zoom() { return this.#zoomLevel; }
/** @returns {number} */
get zoomLevel() { return this.#zoomLevel; }
/**
* Returns the total effective offset (backgroundOffset + pendingLayerOffset).
* Matches WorkspaceNavigationManager.getEffectiveOffset() in Picograph.
*
* @returns {{ x: number, y: number }}
*/
getEffectiveOffset() {
return {
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
};
}
/** Reset view to origin, zoom 1. */
reset() {
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
this.#backgroundOffset = { x: 0, y: 0 };
this.#pendingLayerOffset = { x: 0, y: 0 };
this.#zoomLevel = 1;
this.#targetZoomLevel = 1;
this.#updateZoomDisplay();
}
/**
* Animates the viewport to frame a world-space rect.
* Respects a minimum framing fraction so nodes never appear too small.
*
* @param {{ x: number, y: number, width: number, height: number }} worldRect World-space bounds to frame.
* @param {{ 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 }} [options]
* anchorX/anchorY are screen-space fractions (01) for where the rect centre lands.
* 0.5,0.5 = screen centre (default). 0.0,0.5 = left edge centre. 0.0,0.0 = top-left.
* responsiveWorldBox allows overriding minWorldBox, anchorX, anchorY based on viewport width.
* Can be a single breakpoint object or an array of breakpoints (evaluated largest to smallest).
*/
animateFocusOnRect(worldRect, options = {}) {
let {
paddingFraction = 0.0,
durationMs = 550,
minWorldBox = null, // { width, height } — minimum world-space area that must be visible
responsiveWorldBox = null, // { minViewportWidth, minWorldBox, anchorX?, anchorY? } — overrides when viewport is smaller
anchorX = 0.5, // screen-space X fraction where rect centre is placed
anchorY = 0.5, // screen-space Y fraction where rect centre is placed
} = options;
// Cancel any ongoing smooth zoom and commit pending offset
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
const pending = this.#pendingLayerOffset;
this.#pendingLayerOffset = { x: 0, y: 0 };
if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + pending.x,
y: this.#backgroundOffset.y + pending.y,
});
}
}
const canvasRect = this.#canvas.getBoundingClientRect();
const vw = canvasRect.width;
const vh = canvasRect.height;
// Check if we should use responsive settings
if (responsiveWorldBox) {
const breakpoints = Array.isArray(responsiveWorldBox) ? responsiveWorldBox : [responsiveWorldBox];
// Sort ascending — smallest threshold that still exceeds window width wins (most specific match)
const sorted = [...breakpoints].sort((a, b) => (a.minViewportWidth ?? 0) - (b.minViewportWidth ?? 0));
// Find first matching breakpoint where window is smaller than threshold
const windowWidth = window.innerWidth;
for (const bp of sorted) {
if (windowWidth < (bp.minViewportWidth ?? Infinity)) {
minWorldBox = bp.minWorldBox ?? minWorldBox;
anchorX = bp.anchorX ?? anchorX;
anchorY = bp.anchorY ?? anchorY;
break;
}
}
}
const pad = Math.min(vw, vh) * paddingFraction;
const availW = Math.max(1, vw - pad * 2);
const availH = Math.max(1, vh - pad * 2);
// The framing rect is the union of worldRect and the minWorldBox centred on it
let frameW = Math.max(1, worldRect.width);
let frameH = Math.max(1, worldRect.height);
if (minWorldBox) {
// Expand frame to at least minWorldBox dimensions, centred on worldRect centre
frameW = Math.max(frameW, minWorldBox.width ?? 0);
frameH = Math.max(frameH, minWorldBox.height ?? 0);
}
// Zoom to fit the frame rect inside the available viewport
let targetZoom = Math.min(availW / frameW, availH / frameH);
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
// Calculate the target world center based on node and minWorldBox
const worldCx = worldRect.x + Math.max(1, worldRect.width) / 2;
const worldCy = worldRect.y + Math.max(1, worldRect.height) / 2;
let targetWorldCenterX = worldCx;
let targetWorldCenterY = worldCy;
if (minWorldBox) {
// When minWorldBox exists, anchor positions the node WITHIN the box.
// The box itself should be centered on screen (ignoring anchor for box positioning).
// Calculate worldbox bounds with node positioned at anchor within it
const boxX = worldCx - minWorldBox.width * anchorX;
const boxY = worldCy - minWorldBox.height * anchorY;
targetWorldCenterX = boxX + minWorldBox.width / 2;
targetWorldCenterY = boxY + minWorldBox.height / 2;
}
// Position the target point at viewport center
const centerScreenX = pad + availW / 2;
const centerScreenY = pad + availH / 2;
const targetOffsetX = centerScreenX / targetZoom - targetWorldCenterX;
const targetOffsetY = centerScreenY / targetZoom - targetWorldCenterY;
const fromZoom = this.#zoomLevel;
const fromOffsetX = this.#backgroundOffset.x;
const fromOffsetY = this.#backgroundOffset.y;
const gen = ++this.#focusAnimGen;
const start = this.#timestamp();
const tick = () => {
if (this.#focusAnimGen !== gen) return;
const elapsed = this.#timestamp() - start;
const t = Math.min(elapsed / durationMs, 1);
const eased = 1 - Math.pow(1 - t, 3);
this.#zoomLevel = fromZoom + (targetZoom - fromZoom) * eased;
this.#targetZoomLevel = this.#zoomLevel;
this.#setBackgroundOffset({
x: fromOffsetX + (targetOffsetX - fromOffsetX) * eased,
y: fromOffsetY + (targetOffsetY - fromOffsetY) * eased,
});
this.#updateZoomDisplay();
if (t < 1) {
this.#focusAnimRafId = requestAnimationFrame(tick);
} else {
this.#focusAnimRafId = null;
this.#zoomLevel = targetZoom;
this.#targetZoomLevel = targetZoom;
}
};
this.#focusAnimRafId = requestAnimationFrame(tick);
}
/**
* Instantly fits a world-space rect into the viewport with optional padding.
* The zoom is chosen to fit the rect; aspect-ratio mismatches are letter-boxed.
*
* @param {{ x: number, y: number, width: number, height: number }} worldRect
* @param {{ paddingFraction?: number }} [options]
*/
focusOnRect(worldRect, { paddingFraction = 0, anchorX = 0.5, anchorY = 0.5 } = {}) {
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++;
}
this.#pendingLayerOffset = { x: 0, y: 0 };
const canvas = this.#canvas.getBoundingClientRect();
const vw = canvas.width;
const vh = canvas.height;
const pad = Math.min(vw, vh) * paddingFraction;
const availW = Math.max(1, vw - pad * 2);
const availH = Math.max(1, vh - pad * 2);
const zoom = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max,
Math.min(availW / Math.max(1, worldRect.width), availH / Math.max(1, worldRect.height))
)
);
const centerScreenX = pad + anchorX * availW;
const centerScreenY = pad + anchorY * availH;
const cx = worldRect.x + worldRect.width / 2;
const cy = worldRect.y + worldRect.height / 2;
this.#zoomLevel = zoom;
this.#targetZoomLevel = zoom;
this.#setBackgroundOffset({
x: centerScreenX / zoom - cx,
y: centerScreenY / zoom - cy,
});
this.#updateZoomDisplay();
}
/**
* Immediately centres the viewport on a world-space point at the given zoom.
* @param {{ x: number, y: number }} worldPoint World-space coordinates to centre on.
* @param {number} [zoom] Zoom level to snap to.
*/
focusOnWorld(worldPoint, zoom = 1) {
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
this.#pendingLayerOffset = { x: 0, y: 0 };
const clamped = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, zoom));
this.#zoomLevel = clamped;
this.#targetZoomLevel = clamped;
const rect = this.#canvas.getBoundingClientRect();
this.#setBackgroundOffset({
x: rect.width / 2 / clamped - worldPoint.x,
y: rect.height / 2 / clamped - worldPoint.y,
});
this.#updateZoomDisplay();
}
/**
* Returns the current viewport info in world space.
*
* @returns {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }}
*/
getViewInfo() {
const rect = this.#canvas.getBoundingClientRect();
const zoom = this.#zoomLevel || 1;
const eff = this.getEffectiveOffset();
const vx = -eff.x;
const vy = -eff.y;
const vw = rect.width / zoom;
const vh = rect.height / zoom;
return {
zoom,
viewbox: { x: vx, y: vy, width: vw, height: vh },
center: { x: vx + vw / 2, y: vy + vh / 2 },
};
}
// ─── Events ───────────────────────────────────────────────────────────────
#bindResizeObserver() {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize: newW, blockSize: newH } = entry.contentBoxSize[0];
const dw = newW - this.#lastCanvasSize.width;
const dh = newH - this.#lastCanvasSize.height;
this.#lastCanvasSize = { width: newW, height: newH };
if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue;
const zoom = this.#zoomLevel || 1;
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + dw / 2 / zoom,
y: this.#backgroundOffset.y + dh / 2 / zoom,
});
this.#updateZoomDisplay();
}
});
observer.observe(this.#canvas);
}
#bindEvents() {
this.#canvas.addEventListener("wheel", (e) => this.#onWheel(e), { passive: false });
this.#canvas.addEventListener("pointerdown", (e) => this.#onPointerDown(e));
this.#canvas.addEventListener("touchstart", (e) => this.#onTouchStart(e), { passive: false });
this.#canvas.addEventListener("touchmove", (e) => this.#onTouchMove(e), { passive: false });
this.#canvas.addEventListener("touchend", (e) => this.#onTouchEnd(e), { passive: false });
this.#canvas.addEventListener("touchcancel", (e) => this.#onTouchEnd(e), { passive: false });
this.#canvas.addEventListener("contextmenu", (e) => {
// Suppress context menu if pan just ended (matches original 200ms threshold)
if (this.#timestamp() - this.#lastPanTimestamp < 200) {
e.preventDefault();
return;
}
e.preventDefault();
});
}
/** @param {WheelEvent} e */
#onWheel(e) {
if (!Number.isFinite(e.deltaY) || e.deltaY === 0) return;
const rect = this.#canvas.getBoundingClientRect();
const screenPoint = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return;
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
}
/** @param {PointerEvent} e */
#onPointerDown(e) {
if (e.button !== 0 && e.button !== 2) return;
this.#beginPan(e);
}
// ─── Touch (Pinch-to-Zoom) ────────────────────────────────────────────────
/** @param {TouchEvent} e */
#onTouchStart(e) {
if (e.touches.length === 2) {
e.preventDefault();
// Drop any active node drag before pinch-zoom takes control
this.#onPinchStart?.();
const rect = this.#canvas.getBoundingClientRect();
const t1 = e.touches[0];
const t2 = e.touches[1];
const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
const distance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const center = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
this.#pinchState = {
touches: new Map([
[t1.identifier, p1],
[t2.identifier, p2]
]),
initialDistance: distance,
initialZoom: this.#zoomLevel,
initialBackgroundOffset: { ...this.#backgroundOffset },
center: center
};
// Cancel pan state when pinch starts
this.#panState = null;
} else if (e.touches.length === 1 && !this.#panState) {
// If a node drag is in progress, suppress single-finger pan so the node moves instead
if (this.#isDragInProgress?.()) return;
// Single finger pan - only if not already panning
const touch = e.touches[0];
this.#panState = {
pointerId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
currentClientX: touch.clientX,
currentClientY: touch.clientY,
hasMoved: false,
backgroundOrigin: { ...this.#backgroundOffset },
lastDelta: { x: 0, y: 0 },
};
}
}
/** @param {TouchEvent} e */
#onTouchMove(e) {
// Handle two-finger pinch + pan
if (this.#pinchState && e.touches.length === 2) {
e.preventDefault();
const rect = this.#canvas.getBoundingClientRect();
const t1 = e.touches[0];
const t2 = e.touches[1];
const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
// Calculate current distance for zoom
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const scale = currentDistance / this.#pinchState.initialDistance;
let newZoom = this.#pinchState.initialZoom * scale;
newZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, newZoom));
// Calculate current center for pan
const currentCenter = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
// Pan delta in screen space
const panDeltaScreen = {
x: currentCenter.x - this.#pinchState.center.x,
y: currentCenter.y - this.#pinchState.center.y
};
const previousZoom = this.#zoomLevel;
const initialCenter = this.#pinchState.center;
// World point under initial pinch center (using initial background offset)
const worldPoint = {
x: initialCenter.x / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.x,
y: initialCenter.y / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.y
};
this.#zoomLevel = newZoom;
this.#targetZoomLevel = newZoom;
// Adjust offset to keep world point under pinch center AND apply pan
this.#setBackgroundOffset({
x: initialCenter.x / newZoom - worldPoint.x + panDeltaScreen.x / newZoom,
y: initialCenter.y / newZoom - worldPoint.y + panDeltaScreen.y / newZoom
});
this.#updateZoomDisplay();
return;
}
// Handle single-finger pan
if (this.#panState && e.touches.length === 1) {
const touch = e.touches[0];
this.#panState.currentClientX = touch.clientX;
this.#panState.currentClientY = touch.clientY;
const deltaXScreen = touch.clientX - this.#panState.startX;
const deltaYScreen = touch.clientY - this.#panState.startY;
const zoom = this.#zoomLevel || 1;
const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };
if (!this.#panState.hasMoved) {
if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
e.preventDefault();
this.#panState.hasMoved = true;
}
if (this.#panState.hasMoved) {
e.preventDefault();
}
this.#panState.lastDelta = worldDelta;
this.#setBackgroundOffset({
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
});
this.#updateZoomDisplay();
}
}
/** @param {TouchEvent} e */
#onTouchEnd(e) {
// Clear pinch state if we go below 2 touches
if (this.#pinchState && e.touches.length < 2) {
this.#pinchState = null;
}
// Clear pan state if no touches remain
if (this.#panState && e.touches.length === 0) {
const finalState = this.#panState;
this.#panState = null;
if (finalState.hasMoved) {
this.#lastPanTimestamp = this.#timestamp();
}
}
}
// ─── Pan ─────────────────────────────────────────────────────────────────
/** @param {PointerEvent} event */
#beginPan(event) {
if (this.#panState || this.#pinchState) return;
// Do not start a pan while a node drag is actively in progress
if (this.#isDragInProgress?.()) return;
const state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
currentClientX: event.clientX,
currentClientY: event.clientY,
hasMoved: false,
backgroundOrigin: { ...this.#backgroundOffset },
lastDelta: { x: 0, y: 0 },
};
/** @param {PointerEvent} moveEvent */
const handlePointerMove = (moveEvent) => {
if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) return;
this.#panState.currentClientX = moveEvent.clientX;
this.#panState.currentClientY = moveEvent.clientY;
const deltaXScreen = moveEvent.clientX - this.#panState.startX;
const deltaYScreen = moveEvent.clientY - this.#panState.startY;
const zoom = this.#zoomLevel || 1;
const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };
if (!this.#panState.hasMoved) {
if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
moveEvent.preventDefault();
this.#panState.hasMoved = true;
}
this.#panState.lastDelta = worldDelta;
this.#setBackgroundOffset({
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
});
this.#updateZoomDisplay();
};
/** @param {PointerEvent} upEvent */
const handlePointerUp = (upEvent) => {
if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
const finalState = this.#panState;
this.#panState = null;
if (finalState.hasMoved) {
this.#lastPanTimestamp = this.#timestamp();
}
};
this.#panState = state;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
// ─── Smooth Zoom ──────────────────────────────────────────────────────────
/**
* @param {{ x: number, y: number }} screenPoint
* @param {number} direction
* @param {{ clientX?: number, clientY?: number }} [pointer]
*/
#pushSmoothZoom(screenPoint, direction, pointer = {}) {
if (!direction) return;
// Cancel any in-progress rect focus animation
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
const step = direction * this.#zoomConfig.step;
this.#targetZoomLevel = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
);
this.#smoothZoomFocus = { screenPoint, pointer };
if (this.#smoothZoomRafId === null) {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
}
#tickSmoothZoom() {
this.#smoothZoomRafId = null;
if (!this.#smoothZoomFocus) return;
const target = this.#targetZoomLevel;
const current = this.#zoomLevel;
const diff = target - current;
const settled = Math.abs(diff) < 0.001;
const newZoom = settled
? target
: Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));
const { screenPoint } = this.#smoothZoomFocus;
const previousZoom = current;
// World point under cursor (using old zoom)
const worldPoint = {
x: screenPoint.x / previousZoom,
y: screenPoint.y / previousZoom,
};
this.#zoomLevel = newZoom;
const scale = previousZoom / newZoom;
const shift = {
x: worldPoint.x * (scale - 1),
y: worldPoint.y * (scale - 1),
};
this.#pendingLayerOffset.x += shift.x;
this.#pendingLayerOffset.y += shift.y;
// Rebase active pan so world deltas stay stable mid-zoom
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
this.#rebasePanAfterZoom(this.#smoothZoomFocus.pointer);
}
this.#canvas.style.setProperty("--workspace-zoom", `${newZoom}`);
this.#applyBackgroundOffset();
if (settled) {
this.#smoothZoomFocus = null;
const finalOffset = this.#pendingLayerOffset;
this.#pendingLayerOffset = { x: 0, y: 0 };
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
// Commit pivot into backgroundOffset (no nodes to translate on the website)
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + finalOffset.x,
y: this.#backgroundOffset.y + finalOffset.y,
});
// Also rebase pan origin so pan doesn't jump
if (this.#panState) {
this.#panState.backgroundOrigin.x += finalOffset.x;
this.#panState.backgroundOrigin.y += finalOffset.y;
}
}
this.#updateZoomDisplay();
} else {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
}
// ─── Transform helpers ────────────────────────────────────────────────────
/** @param {{ x: number, y: number }} offset */
#setBackgroundOffset(offset) {
this.#backgroundOffset = {
x: Number.isFinite(offset.x) ? offset.x : 0,
y: Number.isFinite(offset.y) ? offset.y : 0,
};
this.#applyBackgroundOffset();
}
#applyBackgroundOffset() {
const zoom = this.#zoomLevel || 1;
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
const position = `${scaledX}px ${scaledY}px`;
this.#canvas.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
this.#applyWorldLayerTransform();
}
#updateZoomDisplay() {
const zoom = this.#zoomLevel || 1;
this.#canvas.style.setProperty("--workspace-zoom", `${zoom}`);
this.#applyBackgroundOffset(); // also calls #applyWorldLayerTransform
}
#applyWorldLayerTransform() {
if (!this.#worldLayer) return;
const zoom = this.#zoomLevel || 1;
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
this.#worldLayer.style.transformOrigin = "0 0";
this.#worldLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
this.#onAfterTransform?.();
}
/** @param {{ clientX?: number, clientY?: number }} [pointer] */
#rebasePanAfterZoom(pointer) {
if (!this.#panState) return;
const zoom = this.#zoomLevel || 1;
const clientX = Number.isFinite(this.#panState.currentClientX)
? this.#panState.currentClientX
: pointer?.clientX;
const clientY = Number.isFinite(this.#panState.currentClientY)
? this.#panState.currentClientY
: pointer?.clientY;
if (Number.isFinite(clientX)) {
this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom;
}
if (Number.isFinite(clientY)) {
this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom;
}
}
/** @returns {number} */
#timestamp() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
}

View File

@@ -1,25 +0,0 @@
/**
* About command - Information about this terminal
*/
export default {
description: 'About this terminal',
execute: (term, writeClickable, VERSION) => {
return [
'',
'╔════════════════════════════════════════════════════════════╗',
'║ LIT.RUV.WTF TERMINAL ║',
'╚════════════════════════════════════════════════════════════╝',
'',
' A classic terminal interface built with xterm.js',
' Features: Keyboard navigation, Mouse support, CRT effects',
' Version: ' + VERSION,
' Built: ' + new Date().getFullYear(),
'',
' Technologies:',
' • xterm.js - Terminal emulator',
' • JavaScript - Terminal logic',
' • CSS3 - Classic CRT styling',
''
].join('\r\n');
}
};

View File

@@ -1,24 +0,0 @@
/**
* Banner command - Display welcome banner
*/
export default {
description: 'Display welcome banner',
execute: (term, writeClickable, VERSION, args, commandHistory, welcomeBannerFull, welcomeBannerCompact, welcomeBannerMinimal) => {
const cols = term.cols;
if (cols >= 78) {
term.writeln(welcomeBannerFull.split('\r\n').slice(0, -3).join('\r\n'));
writeClickable(' Type [command=help] for available commands.');
term.writeln(' Use ↑/↓ arrows to navigate command history.');
term.writeln('');
} else if (cols >= 40) {
term.writeln(welcomeBannerCompact.split('\r\n').slice(0, -2).join('\r\n'));
writeClickable(' Welcome! Type [command=help] for commands.');
term.writeln('');
} else {
term.writeln(welcomeBannerMinimal.split('\r\n').slice(0, -2).join('\r\n'));
writeClickable(' Type [command=help]');
term.writeln('');
}
return null;
}
};

View File

@@ -1,79 +0,0 @@
/**
* Bluesky command - Fetch recent posts from Bluesky
*/
export default {
description: 'Fetch recent posts from Bluesky',
execute: async (term, writeClickable, VERSION, args) => {
const actor = 'lit.mates.dev';
const limit = args[0] ? parseInt(args[0]) : 5;
try {
term.writeln('\r\n Fetching posts from Bluesky...\r\n');
const response = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=${limit}`
);
if (!response.ok) {
return ' Error: Unable to fetch Bluesky posts';
}
const data = await response.json();
if (!data.feed || data.feed.length === 0) {
return ' No posts found.';
}
let output = [' ╔════════════════════════════════════════════════════╗'];
output.push(' ║ BLUESKY POSTS - @lit.mates.dev ║');
output.push(' ╚════════════════════════════════════════════════════╝');
output.push('');
// Reverse to show oldest first, latest at bottom
const reversedFeed = [...data.feed].reverse();
reversedFeed.forEach((item, idx) => {
const post = item.post;
const text = post.record.text;
const createdAt = new Date(post.record.createdAt);
const date = createdAt.toLocaleDateString();
const time = createdAt.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
// Extract post ID from URI (at://did:plc:xxx/app.bsky.feed.post/{postId})
const postId = post.uri.split('/').pop();
const postUrl = `https://bsky.app/profile/${actor}/post/${postId}`;
output.push(` [${idx + 1}] ${date} ${time}`);
output.push(` ${postUrl}`);
output.push(' ────────────────────────────────────────');
// Wrap text to max 50 chars
const words = text.split(' ');
let line = ' ';
words.forEach(word => {
if (line.length + word.length + 1 > 52) {
output.push(line);
line = ' ' + word;
} else {
line += (line.length > 2 ? ' ' : '') + word;
}
});
if (line.length > 2) output.push(line);
output.push('');
output.push(`${post.likeCount || 0}${post.repostCount || 0} 💬 ${post.replyCount || 0}`);
output.push('');
});
output.push(` Usage: bluesky [count] (default: 5, max: 20)`);
output.push('');
return output.join('\r\n');
} catch (error) {
return ' Error: Failed to connect to Bluesky API';
}
}
};

View File

@@ -1,10 +0,0 @@
/**
* Clear command - Clear terminal screen
*/
export default {
description: 'Clear terminal screen',
execute: (term) => {
term.clear();
return null;
}
};

View File

@@ -1,38 +0,0 @@
/**
* Color command - Change color scheme
*/
export default {
description: 'Change color scheme',
execute: (term, writeClickable, VERSION, args) => {
const scheme = args[0] || '';
const schemes = {
green: { bg: '#001800', fg: '#00ff00', border: '#0f0' },
amber: { bg: '#1a0f00', fg: '#ffb000', border: '#ffb000' },
blue: { bg: '#000818', fg: '#00a0ff', border: '#00a0ff' },
white: { bg: '#0a0a0a', fg: '#e0e0e0', border: '#999' }
};
if (!scheme || !schemes[scheme]) {
return [
'',
' Available color schemes:',
' • green - Classic green terminal',
' • amber - Amber monochrome',
' • blue - IBM blue',
' • white - White phosphor',
'',
' Usage: color [scheme]',
''
].join('\r\n');
}
const colors = schemes[scheme];
term.options.theme.background = colors.bg;
term.options.theme.foreground = colors.fg;
document.querySelector('.container').style.borderColor = colors.border;
document.querySelector('.container').style.background = colors.bg;
document.body.style.color = colors.fg;
return `\r\n Color scheme changed to: ${scheme}\r\n`;
}
};

View File

@@ -1,16 +0,0 @@
/**
* Contact command - Contact information
*/
export default {
description: 'Contact information',
execute: () => {
return [
'',
' Contact Information:',
' ────────────────────',
' Email: contact@lit.ruv.wtf',
' Web: https://lit.ruv.wtf',
''
].join('\r\n');
}
};

View File

@@ -1,20 +0,0 @@
/**
* Date command - Display current date and time
*/
export default {
description: 'Display current date and time',
execute: () => {
const now = new Date();
return [
'',
' ' + now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}),
' ' + now.toLocaleTimeString('en-US'),
''
].join('\r\n');
}
};

View File

@@ -1,17 +0,0 @@
/**
* Donate command - Support the developer
*/
export default {
description: 'Support via donation',
execute: () => {
return [
'',
' If you enjoy what I do, consider buying me a iced coffee!',
'',
' 💳 Donate: https://donate.stripe.com/9AQdRv6ttfv40Ra289',
'',
' Your support allows me to continue developing and maintaining my projects, and is greatly appreciated!',
''
].join('\r\n');
}
};

View File

@@ -1,9 +0,0 @@
/**
* Echo command - Echo back message
*/
export default {
description: 'Echo back message',
execute: (term, writeClickable, VERSION, args) => {
return args.join(' ') || '';
}
};

View File

@@ -1,9 +0,0 @@
/**
* GitHub command - Open GitHub repository
*/
export default {
description: 'Open GitHub repository',
execute: () => {
return '\r\n Opening GitHub...\r\n (This would open your repository URL)\r\n';
}
};

View File

@@ -1,33 +0,0 @@
/**
* Help command - Display available commands
*/
export default {
description: 'Display available commands',
execute: (term, writeClickable) => {
term.writeln('');
term.writeln('╔════════════════════════════════════════════════════════════╗');
term.writeln('║ AVAILABLE COMMANDS ║');
term.writeln('╚════════════════════════════════════════════════════════════╝');
term.writeln('');
writeClickable(' [command=help] - Display this help message');
writeClickable(' [command=about] - Information about this terminal');
writeClickable(' [command=clear] - Clear the terminal screen');
term.writeln(' echo - Echo back your message (usage: echo [message])');
writeClickable(' [command=date] - Display current date and time');
writeClickable(' [command=whoami] - Display current user information');
writeClickable(' [command=history] - Show command history');
writeClickable(' [command=color] - Change terminal color scheme');
writeClickable(' [command=banner] - Display welcome banner');
writeClickable(' [command=bluesky] - Fetch recent posts from Bluesky');
writeClickable(' [command=chat] - Enter interactive chat (type /quit to exit)');
writeClickable(' [command=numbermatch] - Play number matching puzzle game');
writeClickable(' [command=github] - Visit GitHub repository');
writeClickable(' [command=contact] - Display contact information');
writeClickable(' [command=privacy] - Display privacy policy');
term.writeln('');
term.writeln('Navigate: Use ↑/↓ arrows for command history');
term.writeln('Mouse: Click commands to run them');
term.writeln('');
return null;
}
};

View File

@@ -1,17 +0,0 @@
/**
* History command - Show command history
*/
export default {
description: 'Show command history',
execute: (term, writeClickable, VERSION, args, commandHistory) => {
if (commandHistory.length === 0) {
return '\r\n No command history yet.\r\n';
}
let output = ['\r\n Command History:', ' ───────────────'];
commandHistory.forEach((cmd, idx) => {
output.push(` ${(idx + 1).toString().padStart(3, ' ')} ${cmd}`);
});
output.push('');
return output.join('\r\n');
}
};

View File

@@ -1,951 +0,0 @@
/**
* Number Match game command - A terminal-based number matching puzzle game
* Match pairs of numbers that are equal or sum to 10
*/
/**
* SAM (Software Automatic Mouth) speech synthesizer instance
* Uses default SAM voice preset for classic C64 feel
* @type {object|null}
*/
let sam = null;
/**
* Initialize SAM speech synthesizer with SAM voice
* @returns {object|null} SAM instance or null if unavailable
*/
function initSam() {
if (sam) return sam;
if (typeof SamJs !== 'undefined') {
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
}
return sam;
}
/**
* Speak text using SAM
* @param {string} text - Text to speak
* @returns {void}
*/
function samSpeak(text) {
const samInstance = initSam();
if (samInstance) {
try {
samInstance.speak(text);
} catch (_err) {
// Silently fail if speech doesn't work
}
}
}
/**
* Play a click/select sound
* @returns {void}
*/
function playClickSound() {
if (window.terminalSounds) {
const sounds = window.terminalSounds;
sounds.play(sounds.pickRandom(sounds.files.typing), 0.25);
}
}
/**
* Play a match success sound
* @returns {void}
*/
function playMatchSound() {
if (window.terminalSounds) {
const sounds = window.terminalSounds;
sounds.play(sounds.pickRandom(sounds.files.enter), 0.3);
}
}
/**
* Play an error/invalid sound
* @returns {void}
*/
function playErrorSound() {
if (window.terminalSounds) {
const sounds = window.terminalSounds;
sounds.play(sounds.files.scroll, 0.2);
}
}
/**
* Shuffle an array in place using the Fisher-Yates algorithm.
* @template T
* @param {T[]} array - Array to shuffle
* @returns {T[]} The shuffled array
*/
function shuffle(array) {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
/**
* Core game state manager for Number Match logic.
*/
class NumberMatchGame {
/**
* @param {{ width?: number, rows?: number }} [options] - Configuration object
*/
constructor(options = {}) {
const { width = 9, rows = 4 } = options;
this.width = width;
this.initialRows = rows;
this.reset();
}
/**
* Regenerate the board with a fresh puzzle.
* @returns {void}
*/
reset() {
const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
this.tiles = initialState.boardTiles;
}
/**
* Get a shallow copy of the current tiles.
* @returns {(number|null)[]} Current tile values
*/
getTiles() {
return [...this.tiles];
}
/**
* Count the remaining non-null tiles.
* @returns {number} Number of tiles left on the board
*/
getRemainingCount() {
return this.tiles.filter((tile) => tile !== null).length;
}
/**
* Determine whether the board has been cleared.
* @returns {boolean} True when all tiles have been removed
*/
isComplete() {
return this.tiles.every((tile) => tile === null);
}
/**
* Attempt to match and remove two indices.
* @param {number} firstIndex - Index of the first tile
* @param {number} secondIndex - Index of the second tile
* @returns {boolean} True if the match succeeded
*/
selectPair(firstIndex, secondIndex) {
if (!this.canPair(firstIndex, secondIndex)) {
return false;
}
this.tiles[firstIndex] = null;
this.tiles[secondIndex] = null;
this.compactEmptyRows();
return true;
}
/**
* Check whether the two indices can form a valid pair.
* @param {number} firstIndex - Index of the first tile
* @param {number} secondIndex - Index of the second tile
* @returns {boolean} True if the tiles satisfy match rules and path constraints
*/
canPair(firstIndex, secondIndex) {
if (firstIndex === secondIndex) {
return false;
}
if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) ||
!NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
return false;
}
const valueA = this.tiles[firstIndex];
const valueB = this.tiles[secondIndex];
if (valueA === null || valueB === null) {
return false;
}
if (!(valueA === valueB || valueA + valueB === 10)) {
return false;
}
return this.hasClearPath(firstIndex, secondIndex);
}
/**
* Append remaining tiles in reading order.
* @returns {boolean} True when tiles were appended
*/
addNumbers() {
this.compactEmptyRows();
const remaining = this.tiles.filter((tile) => tile !== null);
if (remaining.length === 0) {
return false;
}
this.trimTrailingEmptySlots();
this.tiles.push(...remaining);
return true;
}
/**
* Remove trailing null placeholders from the board.
* @returns {void}
*/
trimTrailingEmptySlots() {
while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
this.tiles.pop();
}
}
/**
* Verify that a straight path between two indices is unobstructed.
* @param {number} firstIndex - One tile index
* @param {number} secondIndex - The other tile index
* @returns {boolean} True if any allowed path between the indices is clear
*/
hasClearPath(firstIndex, secondIndex) {
const start = Math.min(firstIndex, secondIndex);
const end = Math.max(firstIndex, secondIndex);
const rowStart = Math.floor(start / this.width);
const rowEnd = Math.floor(end / this.width);
const colStart = start % this.width;
const colEnd = end % this.width;
// Horizontal (same row, adjacent or with empty cells between)
if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
return true;
}
// Vertical (same column)
const diff = end - start;
if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
return true;
}
// Diagonal down-right (row increases, col increases)
const rowDelta = rowEnd - rowStart;
const colDelta = colEnd - colStart;
if (rowDelta === colDelta && rowDelta > 0) {
// Down-right diagonal: step = width + 1
if (this.isSegmentClear(start, end, this.width + 1)) {
return true;
}
}
// Diagonal down-left (row increases, col decreases)
if (rowDelta === -colDelta && rowDelta > 0) {
// Down-left diagonal: step = width - 1
if (this.isSegmentClear(start, end, this.width - 1)) {
return true;
}
}
// Wrap-around: scan reading order across row boundaries (right to end of row,
// down to next row, left-to-right until reaching the second tile)
if (rowStart !== rowEnd && this.isSegmentClear(start, end, 1)) {
return true;
}
return false;
}
/**
* Remove any fully empty rows from the board to keep the grid compact.
* @returns {void}
*/
compactEmptyRows() {
let index = 0;
while (index < this.tiles.length) {
const remaining = this.tiles.length - index;
const span = Math.min(this.width, remaining);
const slice = this.tiles.slice(index, index + span);
const isEmptyRow = slice.every((value) => value === null);
if (isEmptyRow) {
this.tiles.splice(index, span);
continue;
}
index += this.width;
}
}
/**
* Verify that the indices along a stepped path are empty.
* @param {number} start - Starting index (inclusive)
* @param {number} end - Ending index (inclusive)
* @param {number} step - Increment applied each iteration
* @returns {boolean} True if no non-null tiles exist between start and end
*/
isSegmentClear(start, end, step) {
for (let current = start + step; current < end; current += step) {
if (this.tiles[current] !== null) {
return false;
}
}
return true;
}
/**
* Find a valid pair hint.
* @returns {[number, number]|null} A pair of indices or null if none available
*/
findHint() {
const tiles = this.tiles;
for (let i = 0; i < tiles.length; i++) {
if (tiles[i] === null) continue;
for (let j = i + 1; j < tiles.length; j++) {
if (tiles[j] === null) continue;
if (this.canPair(i, j)) {
return [i, j];
}
}
}
return null;
}
/**
* Determine whether the provided index is valid for the current tile array.
* @param {number} index - Index to evaluate
* @param {(number|null)[]} tiles - Tile array
* @returns {boolean} True when the index is within bounds
*/
static isValidIndex(index, tiles) {
return index >= 0 && index < tiles.length;
}
/**
* Create a randomized board configuration.
* @param {number} width - Number of columns
* @param {number} rows - Number of initial rows
* @returns {{ boardTiles: number[] }} Generated tile set
*/
static generateInitialState(width, rows) {
const boardCapacity = width * rows;
const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
const forcedAssignments = new Map();
const horizontalStarts = [];
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
horizontalStarts.push(rowIndex * width + colIndex);
}
}
const shuffledStarts = shuffle(horizontalStarts);
const reserved = new Set();
let startCursor = 0;
forcedPairs.forEach((pair) => {
let startIndex = null;
while (startCursor < shuffledStarts.length) {
const candidate = shuffledStarts[startCursor];
startCursor += 1;
if (reserved.has(candidate) || reserved.has(candidate + 1)) {
continue;
}
startIndex = candidate;
break;
}
if (startIndex === null) {
return;
}
forcedAssignments.set(startIndex, pair[0]);
forcedAssignments.set(startIndex + 1, pair[1]);
reserved.add(startIndex);
reserved.add(startIndex + 1);
});
const boardTiles = new Array(boardCapacity).fill(null);
for (let index = 0; index < boardCapacity; index += 1) {
const forcedValue = forcedAssignments.get(index);
if (typeof forcedValue === 'number') {
boardTiles[index] = forcedValue;
continue;
}
const rawNextForced = forcedAssignments.get(index + 1);
const nextForcedValue = typeof rawNextForced === 'number' ? rawNextForced : null;
boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
}
return { boardTiles };
}
/**
* Produce a random pair of values that form a legal match.
* @returns {[number, number]} Two values that either match or sum to ten
*/
static generatePair() {
const complementPairs = [[1, 9], [2, 8], [3, 7], [4, 6], [5, 5]];
const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
if (Math.random() < 0.5) {
const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
return [value, value];
}
const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
return [...pair];
}
/**
* Create the predetermined matches that appear on the initial board.
* @param {number} target - Total matches to prepare
* @returns {Array<[number, number]>} Ordered list of forced pairs
*/
static getForcedPairs(target) {
const essentialPairs = [[5, 5]];
const complementPool = shuffle([
[1, 9], [2, 8], [3, 7], [4, 6],
[9, 1], [8, 2], [7, 3], [6, 4]
]);
const pairs = [];
for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
pairs.push(essentialPairs[i]);
}
let complementIndex = 0;
while (pairs.length < target && complementIndex < complementPool.length) {
pairs.push(complementPool[complementIndex]);
complementIndex += 1;
}
while (pairs.length < target) {
pairs.push(NumberMatchGame.generatePair());
}
return shuffle(pairs);
}
/**
* Pick a filler value that will not immediately create an accessible match.
* @param {Array<number|null>} tiles - Current partially filled board
* @param {number} width - Board width
* @param {number} index - Index to populate
* @param {number|null} nextForcedValue - Upcoming forced value, if one exists
* @returns {number} Safe filler digit
*/
static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
const attempt = (respectNext) => {
const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
const candidates = [];
for (let value = 1; value <= 9; value += 1) {
if (!forbidden.has(value)) {
candidates.push(value);
}
}
return candidates;
};
let candidates = attempt(true);
if (candidates.length === 0) {
candidates = attempt(false);
}
if (candidates.length === 0) {
return Math.floor(Math.random() * 9) + 1;
}
return candidates[Math.floor(Math.random() * candidates.length)];
}
/**
* Collect forbidden values based on already placed neighbors.
* @param {Array<number|null>} tiles - Current board state
* @param {number} width - Board width
* @param {number} index - Target index
* @param {number|null} nextForcedValue - Forced value to appear next in sequence, if any
* @returns {Set<number>} Values that should not be used at the index
*/
static collectForbiddenValues(tiles, width, index, nextForcedValue) {
const forbidden = new Set();
const register = NumberMatchGame.registerForbiddenValue;
if (index > 0) {
register(forbidden, tiles[index - 1]);
}
const row = Math.floor(index / width);
const col = index % width;
if (row > 0) {
register(forbidden, tiles[index - width]);
if (col > 0) {
register(forbidden, tiles[index - width - 1]);
}
if (col < width - 1) {
register(forbidden, tiles[index - width + 1]);
}
}
if (typeof nextForcedValue === 'number') {
register(forbidden, nextForcedValue);
}
return forbidden;
}
/**
* Register a value and its complement as forbidden for immediate placement.
* @param {Set<number>} forbidden - Collection of disallowed values
* @param {number|null|undefined} value - Value to mark as forbidden
* @returns {void}
*/
static registerForbiddenValue(forbidden, value) {
if (typeof value !== 'number') {
return;
}
forbidden.add(value);
const complement = NumberMatchGame.getComplement(value);
forbidden.add(complement);
}
/**
* Compute the complement digit that forms a sum-to-ten relationship.
* @param {number} value - Value between 1 and 9
* @returns {number} Complement digit
*/
static getComplement(value) {
if (value === 5) {
return 5;
}
const complement = 10 - value;
return Math.min(9, Math.max(1, complement));
}
}
/**
* Game mode state for terminal integration
*/
export const gameMode = {
active: false,
game: null,
selectedIndex: null,
matchesCleared: 0,
term: null,
boardLines: 0,
message: ''
};
/**
* Calculate how many lines the board display uses
* @param {NumberMatchGame} game - Game instance
* @returns {number} Number of lines to move up
*/
function getBoardLineCount(game) {
const rows = Math.ceil(game.getTiles().length / game.width);
// header + top border + rows + blank lines between rows + bottom border + empty + status + message + controls
// (prompt uses write not writeln, so doesn't add a line)
return 1 + 1 + rows + Math.max(0, rows - 1) + 1 + 1 + 1 + 1 + 1;
}
/**
* Render the game board as ASCII art (initial draw)
* @param {Terminal} term - xterm.js terminal instance
* @param {NumberMatchGame} game - Game instance
* @param {number|null} selectedIndex - Currently selected tile index
* @param {string} message - Status message to display
* @returns {void}
*/
function renderBoard(term, game, selectedIndex, message = '') {
const tiles = game.getTiles();
const width = game.width;
const rows = Math.ceil(tiles.length / width);
// Column headers
let header = ' ';
for (let c = 0; c < width; c++) {
header += ` ${c + 1} `;
}
term.writeln(`\x1b[90m${header}\x1b[0m`);
// Top border
term.writeln(`${'───'.repeat(width)}`);
for (let row = 0; row < rows; row++) {
let line = ` ${String.fromCharCode(65 + row)}`;
for (let col = 0; col < width; col++) {
const idx = row * width + col;
if (idx >= tiles.length) {
line += ' ';
continue;
}
const value = tiles[idx];
if (value === null) {
line += ' · ';
} else if (idx === selectedIndex) {
// Highlight selected tile
line += `\x1b[7m ${value} \x1b[0m`;
} else {
line += ` ${value} `;
}
}
line += '│';
term.writeln(line);
if (row < rows - 1) {
term.writeln(`${' '.repeat(width)}`);
}
}
// Bottom border
term.writeln(`${'───'.repeat(width)}`);
// Status line
term.writeln('');
const remaining = game.getRemainingCount();
term.writeln(` \x1b[33mMatches:\x1b[0m ${gameMode.matchesCleared} \x1b[33mRemaining:\x1b[0m ${remaining} `);
// Message line (padded to clear previous)
const msgText = message || '';
term.writeln(` ${msgText}`.padEnd(50));
// Clickable controls line
term.writeln(' \x1b[90m[ \x1b[36madd\x1b[90m ] [ \x1b[36mhint\x1b[90m ] [ \x1b[36mnew\x1b[90m ] [ \x1b[36mquit\x1b[90m ]\x1b[0m');
// Prompt
term.write('\x1b[33mgame>\x1b[0m ');
gameMode.boardLines = getBoardLineCount(game);
gameMode.message = message;
}
/**
* Update the board in place without scrolling
* @param {Terminal} term - xterm.js terminal instance
* @param {NumberMatchGame} game - Game instance
* @param {number|null} selectedIndex - Currently selected tile index
* @param {string} message - Status message to display
* @returns {void}
*/
function updateBoardInPlace(term, game, selectedIndex, message = '') {
const linesToMoveUp = gameMode.boardLines;
// Move cursor up to start of board, go to column 0, clear to end of screen
term.write(`\x1b[${linesToMoveUp}A\r\x1b[J`);
// Redraw from current position
renderBoard(term, game, selectedIndex, message);
}
/**
* Display help text
* @param {Terminal} term - xterm.js terminal instance
* @returns {void}
*/
function renderHelp(term) {
term.writeln('');
term.writeln(' \x1b[36mCommands:\x1b[0m');
term.writeln(' A1 B2 - Select two tiles (e.g., A1 A2 or B3 C3)');
term.writeln(' add - Duplicate remaining numbers to end');
term.writeln(' hint - Show a valid pair');
term.writeln(' new - Start a new game');
term.writeln(' quit - Exit the game');
term.writeln('');
term.writeln(' \x1b[36mRules:\x1b[0m Match numbers that are equal or sum to 10');
term.writeln(' Pairs must be adjacent horizontally or vertically');
term.writeln(' (with only empty spaces between them)');
}
/**
* Parse coordinate input like "A1" to index
* @param {string} coord - Coordinate string (e.g., "A1", "B3")
* @param {number} width - Board width
* @returns {number|null} Tile index or null if invalid
*/
function parseCoordinate(coord, width) {
const match = coord.toUpperCase().match(/^([A-Z])(\d+)$/);
if (!match) return null;
const row = match[1].charCodeAt(0) - 65;
const col = parseInt(match[2], 10) - 1;
if (row < 0 || col < 0 || col >= width) return null;
return row * width + col;
}
/**
* Process game input
* @param {Terminal} term - xterm.js terminal instance
* @param {string} input - User input
* @returns {boolean} True if game should continue
*/
export function processGameInput(term, input) {
if (!gameMode.active || !gameMode.game) {
return false;
}
const cmd = input.trim().toLowerCase();
const game = gameMode.game;
if (cmd === 'quit' || cmd === 'exit' || cmd === '/quit') {
gameMode.active = false;
gameMode.game = null;
gameMode.selectedIndex = null;
gameMode.matchesCleared = 0;
gameMode.term = null;
gameMode.boardLines = 0;
term.writeln(' Game exited.\r\n');
return false;
}
if (cmd === 'new' || cmd === 'reset') {
game.reset();
gameMode.selectedIndex = null;
gameMode.matchesCleared = 0;
term.writeln('');
renderBoard(term, game, null, 'New game started!');
return true;
}
if (cmd === 'add') {
const added = game.addNumbers();
term.writeln('');
if (added) {
renderBoard(term, game, gameMode.selectedIndex, 'Numbers duplicated to end');
} else {
renderBoard(term, game, gameMode.selectedIndex, '\x1b[31mNo numbers to add\x1b[0m');
}
return true;
}
if (cmd === 'hint') {
const hint = game.findHint();
if (hint) {
const [i, j] = hint;
const width = game.width;
const coord1 = String.fromCharCode(65 + Math.floor(i / width)) + ((i % width) + 1);
const coord2 = String.fromCharCode(65 + Math.floor(j / width)) + ((j % width) + 1);
term.writeln(` \x1b[33mHint:\x1b[0m Try ${coord1} ${coord2}`);
} else {
term.writeln(' \x1b[31mNo matches. Try "add"\x1b[0m');
}
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
if (cmd === 'help' || cmd === '?') {
renderHelp(term);
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
// Try to parse as coordinates
const parts = input.trim().toUpperCase().split(/\s+/);
if (parts.length === 2) {
const idx1 = parseCoordinate(parts[0], game.width);
const idx2 = parseCoordinate(parts[1], game.width);
if (idx1 === null || idx2 === null) {
term.writeln(' \x1b[31mInvalid coordinates. Use format: A1 B2\x1b[0m');
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
const tiles = game.getTiles();
if (idx1 >= tiles.length || idx2 >= tiles.length) {
term.writeln(' \x1b[31mCoordinates out of range.\x1b[0m');
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
if (tiles[idx1] === null || tiles[idx2] === null) {
term.writeln(' \x1b[31mOne or both tiles are empty.\x1b[0m');
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
const v1 = tiles[idx1];
const v2 = tiles[idx2];
const matched = game.selectPair(idx1, idx2);
term.writeln('');
if (matched) {
gameMode.matchesCleared++;
samSpeak('match');
if (game.isComplete()) {
samSpeak('Condrat ulationz');
term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m');
term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m');
term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m');
term.writeln('');
term.writeln(` Total matches: ${gameMode.matchesCleared}`);
term.writeln(' Type "new" for another game or "quit" to exit.');
term.write('\x1b[33mgame>\x1b[0m ');
gameMode.boardLines = 0;
return true;
}
renderBoard(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`);
} else {
samSpeak('no');
if (v1 !== v2 && v1 + v2 !== 10) {
renderBoard(term, game, null, `\x1b[31m${v1} and ${v2} don't match\x1b[0m`);
} else {
renderBoard(term, game, null, `\x1b[31mNo clear path\x1b[0m`);
}
}
return true;
}
if (parts.length === 1 && parts[0]) {
const idx = parseCoordinate(parts[0], game.width);
if (idx !== null) {
term.writeln(' \x1b[33mEnter two coordinates (e.g., A1 A2)\x1b[0m');
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
}
if (cmd) {
term.writeln(' \x1b[31mUnknown command. Type "help"\x1b[0m');
}
term.write('\x1b[33mgame>\x1b[0m ');
return true;
}
/**
* Start a new number match game session
* @param {Terminal} term - xterm.js terminal instance
* @returns {void}
*/
export function startGame(term) {
gameMode.active = true;
gameMode.game = new NumberMatchGame({ width: 9, rows: 4 });
gameMode.selectedIndex = null;
gameMode.matchesCleared = 0;
gameMode.term = term;
gameMode.boardLines = 0;
gameMode.message = '';
term.writeln('');
term.writeln(' ╔════════════════════════════════════╗');
term.writeln(' ║ NUMBER MATCH ║');
term.writeln(' ╚════════════════════════════════════╝');
term.writeln('');
term.writeln(' Match pairs: equal numbers or sum to 10');
term.writeln(' \x1b[90mClick tiles or type coordinates (e.g., A1 A2)\x1b[0m');
term.writeln(' \x1b[90mCommands: add, hint, new, quit\x1b[0m');
term.writeln('');
samSpeak('Number Match');
renderBoard(term, gameMode.game, null, '');
}
/**
* Handle a tile click from the terminal link provider
* @param {string} coord - Coordinate string (e.g., "A1", "B3")
* @returns {void}
*/
export function handleTileClick(coord) {
if (!gameMode.active || !gameMode.game || !gameMode.term) {
return;
}
const term = gameMode.term;
const game = gameMode.game;
const clickedIndex = parseCoordinate(coord, game.width);
if (clickedIndex === null) {
return;
}
const tiles = game.getTiles();
if (clickedIndex >= tiles.length || tiles[clickedIndex] === null) {
return;
}
// If no tile selected, select this one
if (gameMode.selectedIndex === null) {
gameMode.selectedIndex = clickedIndex;
playClickSound();
samSpeak(String(tiles[clickedIndex]));
updateBoardInPlace(term, game, clickedIndex, `\x1b[33mSelected ${coord}. Click another to match.\x1b[0m`);
return;
}
// If same tile clicked, deselect
if (gameMode.selectedIndex === clickedIndex) {
gameMode.selectedIndex = null;
playClickSound();
updateBoardInPlace(term, game, null, '');
return;
}
// Try to match the two tiles
const firstIndex = gameMode.selectedIndex;
const prevTiles = game.getTiles();
const v1 = prevTiles[firstIndex];
const v2 = prevTiles[clickedIndex];
const matched = game.selectPair(firstIndex, clickedIndex);
gameMode.selectedIndex = null;
if (matched) {
gameMode.matchesCleared++;
playMatchSound();
samSpeak('match');
if (game.isComplete()) {
// Game won - need to redraw fresh for victory screen
playMatchSound();
samSpeak('Condrat ulationz');
term.write(`\x1b[${gameMode.boardLines}A`);
for (let i = 0; i < gameMode.boardLines; i++) {
term.writeln('\x1b[2K');
}
term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m');
term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m');
term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m');
term.writeln('');
term.writeln(` Total matches: ${gameMode.matchesCleared}`);
term.writeln(' Type "new" for another game or "quit" to exit.');
term.write('\x1b[33mgame>\x1b[0m ');
gameMode.boardLines = 0;
return;
}
updateBoardInPlace(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`);
} else {
playErrorSound();
samSpeak('no');
if (v1 !== v2 && v1 + v2 !== 10) {
updateBoardInPlace(term, game, null, `\x1b[31m${v1} and ${v2} don't match or sum to 10\x1b[0m`);
} else {
updateBoardInPlace(term, game, null, `\x1b[31mNo clear path between tiles\x1b[0m`);
}
}
}
/**
* Number Match command export
*/
export default {
description: 'Play the Number Match puzzle game',
execute: (term, writeClickable, VERSION, args) => {
startGame(term);
return null;
}
};

View File

@@ -1,36 +0,0 @@
/**
* Privacy command - Privacy policy
*/
export default {
description: 'Privacy policy',
execute: () => {
return [
'',
'╔════════════════════════════════════════════════════════════╗',
'║ PRIVACY POLICY ║',
'╚════════════════════════════════════════════════════════════╝',
'',
' Data Collection:',
' ────────────────',
' • This terminal uses localStorage to save your chat session',
' • Chat messages are stored on our Matrix homeserver',
' • No cookies or tracking scripts are used',
' • No analytics or third-party tracking',
'',
' Matrix Chat:',
' ────────────',
' • Chat credentials stored locally in your browser',
' • Messages sent through Matrix protocol (b.ruv.wtf)',
' • Use "chat disconnect" to clear stored credentials',
'',
' Your Rights:',
' ────────────',
' • Clear localStorage anytime via browser settings',
' • Request data deletion: contact@lit.ruv.wtf',
' • All code is open source and auditable',
'',
' Updates: Privacy policy last updated March 2026',
''
].join('\r\n');
}
};

View File

@@ -1,56 +0,0 @@
/**
* SAM Say command - Use SAM (Software Automatic Mouth) to speak text
*/
/**
* SAM (Software Automatic Mouth) speech synthesizer instance
* @type {object|null}
*/
let sam = null;
/**
* Initialize SAM speech synthesizer with SAM voice
* @returns {object|null} SAM instance or null if unavailable
*/
function initSam() {
if (sam) return sam;
if (typeof SamJs !== 'undefined') {
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
}
return sam;
}
/**
* Speak text using SAM
* @param {string} text - Text to speak
* @returns {void}
*/
function samSpeak(text) {
const samInstance = initSam();
if (samInstance) {
try {
samInstance.speak(text);
} catch (_err) {
// Silently fail if speech doesn't work
}
}
}
export default {
description: 'Use SAM speech synthesizer to speak text',
execute: (term, writeClickable, VERSION, args) => {
const message = args.join(' ');
if (!message) {
return 'Usage: samsay <message>';
}
if (typeof SamJs === 'undefined') {
return 'SAM speech synthesizer is not available.';
}
samSpeak(message);
return `SAM says: ${message}`;
}
};

View File

@@ -1,15 +0,0 @@
/**
* Whoami command - Display user information
*/
export default {
description: 'Display user information',
execute: () => {
return [
'',
' User: visitor@lit.ruv.wtf',
' Session: ' + Date.now(),
' Terminal: xterm.js',
''
].join('\r\n');
}
};

View File

@@ -0,0 +1,21 @@
/** @type {Record<string, string>} */
const TYPE_COLORS = {
exec: "#ffffff",
number: "#3ee581",
boolean: "#ff4f4f",
string: "#ff66ff",
table: "#5ec4ff",
color: "#5b8ef5",
object: "#64b5f6",
any: "#8c919d",
};
/**
* Returns the CSS color for a given pin kind.
*
* @param {string} kind
* @returns {string}
*/
export function getTypeColor(kind) {
return TYPE_COLORS[kind] ?? TYPE_COLORS.any;
}

672
website/scripts/main.js Normal file
View File

@@ -0,0 +1,672 @@
import { WorkspaceNavigator } from "./WorkspaceNavigator.js";
import { NodeRenderer } from "./NodeRenderer.js";
import { ConnectionRenderer } from "./ConnectionRenderer.js";
import { GraphNode } from "./GraphNode.js";
import { GraphConnection } from "./GraphConnection.js";
import { GraphComment } from "./GraphComment.js";
import { DebugPanel } from "./DebugPanel.js";
import { PropertiesPanel } from "./PropertiesPanel.js";
import { ItemDragger } from "./ItemDragger.js";
import { PinConnectionManager } from "./PinConnectionManager.js";
import { GraphExecutor } from "./GraphExecutor.js";
import { PrintBubble } from "./PrintBubble.js";
import { SpatialAudio } from "./SpatialAudio.js";
import { getTypeColor } from "./getTypeColor.js";
const canvas = document.getElementById("workspaceCanvas");
const worldLayer = document.getElementById("worldLayer");
const nodeLayer = document.getElementById("nodeLayer");
const connLayerSvg = document.getElementById("connectionLayer");
const worldBoxLayer = document.getElementById("worldBoxLayer");
const viewboxLayer = document.getElementById("viewboxLayer");
const jsonEditorModal = document.getElementById("jsonEditorModal");
const jsonEditorTextarea = document.getElementById("jsonEditorTextarea");
const resetBtn = document.getElementById("resetViewBtn");
const draggableToggle = document.getElementById("draggableToggle");
const debugPanelEl = document.getElementById("debugPanel");
const propertiesPanelEl = document.getElementById("propertiesPanel");
/** @type {HTMLTemplateElement} */
const nodeTemplate = document.getElementById("nodeTemplate");
/** @type {HTMLTemplateElement} */
const pinTemplate = document.getElementById("pinTemplate");
const nav = new WorkspaceNavigator(canvas, worldLayer);
const audio = new SpatialAudio(() => nav.getViewInfo().viewbox);
const nodeRend = new NodeRenderer(nodeLayer, nodeTemplate, pinTemplate, (nodeId, pinId, direction) => {
const targetId = nodeRend.getConnectedNodeId(nodeId, pinId, direction);
if (!targetId) return;
const rect = nodeRend.getNodeWorldRect(targetId);
if (!rect) return;
const focusOptions = nodeRend.getNodes().find(n => n.id === targetId)?.focusOptions ?? {};
nav.animateFocusOnRect(rect, focusOptions);
history.pushState({ nodeId: targetId }, "", `#${targetId}`);
}, (nodeId, pinId) => executor.execute(nodeId, pinId), () => connRend.render());
/** @type {Map<string, PrintBubble>} */
const printBubbles = new Map();
const executor = new GraphExecutor(nodeRend, (nodeId, value) => {
printBubbles.get(nodeId)?.push(value);
// Play spatial audio at node position
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (node) {
const rect = nodeRend.getNodeWorldRect(nodeId);
if (rect) {
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
audio.play('pop', centerX, centerY, rect.width, rect.height);
}
}
}, async (connId, kind) => {
await connRend.activatePath(connId, kind === "exec" ? 200 : 150);
});
const connRend = new ConnectionRenderer(connLayerSvg, canvas, nodeRend, nav);
const pinConns = new PinConnectionManager(canvas, connLayerSvg, nodeLayer, nodeRend, nav, connRend);
// Properties Panel - Initialize early so it can be referenced
const propertiesPanel = new PropertiesPanel(propertiesPanelEl, () => {
connRend.render();
renderWorldBoxes();
renderViewbox();
});
const dragger = new ItemDragger(canvas, nav, nodeRend,
() => {
const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null;
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
propertiesPanel.loadNode(activeNode);
},
() => { connRend.render(); }
);
// Suppress navigator pan while a node drag is actively in progress (enables edge-pan on mobile + mouse)
nav.setDragInProgressChecker(() => dragger.isDragging);
// Drop the active drag immediately when a pinch-to-zoom starts so the two gestures don't conflict
nav.setOnPinchStart(() => dragger.cancelDrag());
const debugPanel = new DebugPanel(debugPanelEl,
(enabled) => { dragger.enabled = enabled; pinConns.enabled = enabled; },
() => {
const liveGraph = {
settings: graphData.settings,
images: graphData.images,
nodes: nodeRend.getNodes().map(n => ({
id: n.id,
type: n.type,
title: n.title,
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
...(n.width !== undefined ? { width: n.width } : {}),
...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}),
...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}),
...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}),
...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}),
inputs: n.inputs,
outputs: n.outputs,
})),
connections: nodeRend.getConnections().map(c => ({
id: c.id,
from: c.from,
to: c.to,
kind: c.kind,
})),
};
navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2));
}, (nodeId, updates) => {
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (!node) return;
let needsRerender = false;
if (updates.title !== undefined) node.title = updates.title;
if (updates.position) {
nodeRend.setNodePosition(nodeId, updates.position.x, updates.position.y);
}
if (updates.width !== undefined) {
node.width = updates.width;
const el = nodeRend.getNodeElement(nodeId);
if (el) el.style.width = updates.width != null ? `${updates.width}px` : '';
}
if (updates.inputs !== undefined) {
node.inputs = updates.inputs;
needsRerender = true;
}
if (updates.outputs !== undefined) {
node.outputs = updates.outputs;
needsRerender = true;
}
const nodeEl = nodeRend.getNodeElement(nodeId);
if (nodeEl && updates.title !== undefined) {
const titleEl = nodeEl.querySelector('.node-title');
if (titleEl) titleEl.textContent = updates.title;
}
// Re-render node if pins changed
if (needsRerender && nodeEl) {
const parent = nodeEl.parentElement;
nodeEl.remove();
// Re-add using internal render method
const fragment = nodeTemplate.content.cloneNode(true);
const article = fragment.querySelector('.blueprint-node');
const title = article.querySelector('.node-title');
const inputs = article.querySelector('.node-inputs');
const outputs = article.querySelector('.node-outputs');
article.dataset.nodeId = node.id;
article.dataset.nodeType = node.type;
title.textContent = node.title;
article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`;
if (node.width != null) article.style.width = `${node.width}px`;
// Re-render pins using pinTemplate
inputs.innerHTML = '';
outputs.innerHTML = '';
node.inputs.forEach(pin => {
const pinFrag = pinTemplate.content.cloneNode(true);
const container = pinFrag.firstElementChild;
const label = container.querySelector('.pin-label');
const handle = container.querySelector('.pin-handle');
container.dataset.pinId = pin.id;
container.dataset.type = pin.kind;
container.dataset.direction = 'input';
container.classList.add('is-disconnected');
container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind));
const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out');
if (isStandardExec && !pin.name) {
label.textContent = '';
label.classList.add('is-hidden');
} else {
label.textContent = pin.name;
}
inputs.appendChild(container);
});
node.outputs.forEach(pin => {
const pinFrag = pinTemplate.content.cloneNode(true);
const container = pinFrag.firstElementChild;
const label = container.querySelector('.pin-label');
const handle = container.querySelector('.pin-handle');
container.dataset.pinId = pin.id;
container.dataset.type = pin.kind;
container.dataset.direction = 'output';
container.classList.add('is-disconnected');
container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind));
const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out');
if (isStandardExec && !pin.name) {
label.textContent = '';
label.classList.add('is-hidden');
} else {
label.textContent = pin.name;
}
outputs.appendChild(container);
});
parent?.appendChild(article);
// Update internal node elements map
const nodeElements = nodeRend.getNodeElements();
nodeElements.set(nodeId, article);
// Update debug panel with refreshed node
const activeNode = nodeRend.getNodes().find(n => n.id === dragger.activeId);
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
}
connRend.render();
},
(enabled) => {
// WorldBox toggle
if (worldBoxLayer) {
worldBoxLayer.style.display = enabled ? 'block' : 'none';
renderWorldBoxes();
}
},
() => {
// Copy entire graph as JSON
const liveGraph = {
settings: graphData.settings,
images: graphData.images,
comments: graphData.comments,
nodes: nodeRend.getNodes().map(n => ({
id: n.id,
type: n.type,
title: n.title,
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
...(n.width !== undefined ? { width: n.width } : {}),
...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}),
...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}),
...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}),
...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}),
inputs: n.inputs,
outputs: n.outputs,
})),
connections: nodeRend.getConnections().map(c => ({
id: c.id,
from: c.from,
to: c.to,
kind: c.kind,
})),
};
navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2));
},
(enabled) => {
// Viewbox toggle
if (viewboxLayer) {
viewboxLayer.style.display = enabled ? 'block' : 'none';
renderViewbox();
}
},
() => {
// Show Properties Panel
propertiesPanel.expand();
}
);
// Connection SVG is screen-space — must re-render on every pan/zoom so paths track node positions.
let hasTransformed = false;
let trackTransform = false;
nav.setOnAfterTransform(() => {
connRend.render();
renderWorldBoxes();
renderViewbox();
const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null;
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
if (trackTransform && !hasTransformed) {
hasTransformed = true;
canvas.classList.add('has-transformed');
}
});
// ─── WorldBox Visualizer ──────────────────────────────────────────────────────
/**
* Renders worldbox overlays for all nodes with focusOptions
*/
function renderWorldBoxes() {
if (!worldBoxLayer || worldBoxLayer.style.display === 'none') return;
// Clear existing
worldBoxLayer.innerHTML = '';
const viewInfo = nav.getViewInfo();
const zoom = viewInfo.zoom;
const offset = nav.getEffectiveOffset();
const canvasRect = canvas.getBoundingClientRect();
nodeRend.getNodes().forEach(node => {
if (!node.focusOptions?.minWorldBox && !node.focusOptions?.responsiveWorldBox) return;
const rect = nodeRend.getNodeWorldRect(node.id);
if (!rect) return;
const nodeCenterX = rect.x + rect.width / 2;
const nodeCenterY = rect.y + rect.height / 2;
// Determine which breakpoint would be active at current viewport width
const vw = window.innerWidth;
let activeBreakpointIndex = -1; // -1 = base worldBox is active
if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) {
const sorted = [...node.focusOptions.responsiveWorldBox]
.map((bp, i) => ({ bp, i }))
.sort((a, b) => (a.bp.minViewportWidth ?? 0) - (b.bp.minViewportWidth ?? 0));
for (const { bp, i } of sorted) {
if (vw < (bp.minViewportWidth ?? Infinity)) {
activeBreakpointIndex = i;
break;
}
}
}
// Render base worldbox if it exists
if (node.focusOptions.minWorldBox) {
const worldBox = node.focusOptions.minWorldBox;
const anchorX = node.focusOptions.anchorX ?? 0.5;
const anchorY = node.focusOptions.anchorY ?? 0.5;
const isActive = activeBreakpointIndex === -1;
// The viewport region is offset from the node center based on anchor.
// anchor=0.5 means node is centered in viewport (no offset).
// The box shifts so the node sits at the anchor position within it.
const boxX = nodeCenterX - worldBox.width * anchorX;
const boxY = nodeCenterY - worldBox.height * anchorY;
// Convert to screen space
const screenX = (boxX + offset.x) * zoom;
const screenY = (boxY + offset.y) * zoom;
const screenW = worldBox.width * zoom;
const screenH = worldBox.height * zoom;
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
worldBoxLayer.appendChild(rectEl);
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label');
textEl.setAttribute('x', String(screenX + 6));
textEl.setAttribute('y', String(screenY + 14));
textEl.textContent = `${node.id} (${worldBox.width}×${worldBox.height})`;
worldBoxLayer.appendChild(textEl);
// Draw crosshair at the node center (the focus point)
const anchorScreenX = (nodeCenterX + offset.x) * zoom;
const anchorScreenY = (nodeCenterY + offset.y) * zoom;
const crossSize = 8;
const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
hLine.setAttribute('x1', String(anchorScreenX - crossSize));
hLine.setAttribute('y1', String(anchorScreenY));
hLine.setAttribute('x2', String(anchorScreenX + crossSize));
hLine.setAttribute('y2', String(anchorScreenY));
worldBoxLayer.appendChild(hLine);
const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
vLine.setAttribute('x1', String(anchorScreenX));
vLine.setAttribute('y1', String(anchorScreenY - crossSize));
vLine.setAttribute('x2', String(anchorScreenX));
vLine.setAttribute('y2', String(anchorScreenY + crossSize));
worldBoxLayer.appendChild(vLine);
}
// Render responsive worldboxes
if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) {
node.focusOptions.responsiveWorldBox.forEach((responsive, index) => {
const worldBox = responsive.minWorldBox;
if (!worldBox) return;
const anchorX = responsive.anchorX ?? 0.5;
const anchorY = responsive.anchorY ?? 0.5;
const isActive = activeBreakpointIndex === index;
// The viewport region is offset from the node center based on anchor.
const boxX = nodeCenterX - worldBox.width * anchorX;
const boxY = nodeCenterY - worldBox.height * anchorY;
// Convert to screen space
const screenX = (boxX + offset.x) * zoom;
const screenY = (boxY + offset.y) * zoom;
const screenW = worldBox.width * zoom;
const screenH = worldBox.height * zoom;
const opacity = isActive ? '1' : String(0.4 + (index * 0.15));
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
rectEl.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(rectEl);
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label');
textEl.setAttribute('x', String(screenX + 6));
textEl.setAttribute('y', String(screenY + 14 + (index * 16)));
textEl.textContent = `@${responsive.minViewportWidth}px: ${worldBox.width}×${worldBox.height}`;
textEl.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(textEl);
// Draw crosshair at the node center (the focus point)
const anchorScreenX = (nodeCenterX + offset.x) * zoom;
const anchorScreenY = (nodeCenterY + offset.y) * zoom;
const crossSize = 8;
const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
hLine.setAttribute('x1', String(anchorScreenX - crossSize));
hLine.setAttribute('y1', String(anchorScreenY));
hLine.setAttribute('x2', String(anchorScreenX + crossSize));
hLine.setAttribute('y2', String(anchorScreenY));
hLine.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(hLine);
const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
vLine.setAttribute('x1', String(anchorScreenX));
vLine.setAttribute('y1', String(anchorScreenY - crossSize));
vLine.setAttribute('x2', String(anchorScreenX));
vLine.setAttribute('y2', String(anchorScreenY + crossSize));
vLine.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(vLine);
});
}
});
}
// ─── Viewbox Visualizer ───────────────────────────────────────────────────────
/**
* Renders the current viewbox overlay showing visible world area
*/
function renderViewbox() {
if (!viewboxLayer || viewboxLayer.style.display === 'none') return;
// Clear existing
viewboxLayer.innerHTML = '';
const viewInfo = nav.getViewInfo();
const zoom = viewInfo.zoom;
const offset = nav.getEffectiveOffset();
const viewbox = viewInfo.viewbox;
// Convert viewbox (world coords) to screen space
const screenX = (viewbox.x + offset.x) * zoom;
const screenY = (viewbox.y + offset.y) * zoom;
const screenW = viewbox.width * zoom;
const screenH = viewbox.height * zoom;
// Create rectangle
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', 'viewbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
viewboxLayer.appendChild(rectEl);
// Add label
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', 'viewbox-label');
textEl.setAttribute('x', String(screenX + 10));
textEl.setAttribute('y', String(screenY + 20));
textEl.textContent = `Viewbox (${Math.round(viewbox.width)}×${Math.round(viewbox.height)})`;
viewboxLayer.appendChild(textEl);
}
// ─── Load graph from JSON ──────────────────────────────────────────────────
const response = await fetch("data/graph.json");
let graphData = await response.json();
// Load spatial audio
await audio.load('pop', 'data/pop.mp3');
graphData.comments?.forEach(c => nodeRend.addComment(new GraphComment(c)));
graphData.nodes.forEach(n => nodeRend.addNode(new GraphNode(n)));
graphData.connections.forEach(c => nodeRend.addConnection(new GraphConnection(c.from, c.to, c.kind, c.id)));
// Load graph settings into properties panel
propertiesPanel.loadGraphSettings(graphData);
// Attach a PrintBubble to every print-type node
graphData.nodes.forEach(n => {
if (n.type !== "print") return;
const el = nodeRend.getNodeElements().get(n.id);
if (el) printBubbles.set(n.id, new PrintBubble(el));
});
for (const img of graphData.images ?? []) {
if (img.inline) {
// Fetch and inline SVG for direct style control
try {
const response = await fetch(img.src);
const svgText = await response.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
const svgEl = svgDoc.documentElement;
svgEl.classList.add('world-image');
// Remove percentage dimensions, set explicit width
svgEl.removeAttribute('height');
svgEl.setAttribute('width', String(img.width));
// Preserve viewBox for aspect ratio
if (!svgEl.hasAttribute('viewBox')) {
const w = svgEl.getAttribute('width');
const h = svgEl.getAttribute('height');
if (w && h) svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
}
svgEl.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`;
svgEl.style.display = 'block';
// Apply cssStyle to all paths/shapes in SVG
const shapes = svgEl.querySelectorAll('path, rect, circle, ellipse, polygon, polyline');
if (img.cssStyle) {
const styleProps = img.cssStyle.split(';').filter(s => s.trim());
styleProps.forEach(prop => {
const [key, val] = prop.split(':').map(s => s.trim());
if (!key || !val) return;
if (key === 'fill' || key === 'stroke' || key === 'stroke-width' || key === 'opacity') {
shapes.forEach(el => {
// Skip elements with fill="none" (background/artboard rects)
if (el.getAttribute('fill') === 'none') return;
el.style[key] = val;
});
} else {
svgEl.style[key] = val;
}
});
} else {
// Default fill to white if no style specified
shapes.forEach(el => {
if (el.getAttribute('fill') === 'none') return; // Skip background
if (!el.hasAttribute('fill') && !el.style.fill) {
el.style.fill = '#ffffff';
}
});
}
worldLayer.prepend(svgEl);
dragger.registerImage(img.src, svgEl, { ...img.position });
console.log('Inlined SVG:', img.src, svgEl);
} catch (err) {
console.error(`Failed to inline SVG: ${img.src}`, err);
}
} else {
// Standard img element
const el = document.createElement("img");
el.className = "world-image";
el.src = img.src;
el.width = img.width;
el.alt = "";
el.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`;
if (img.cssStyle) {
el.style.cssText += `;${img.cssStyle}`;
}
el.addEventListener("dragstart", (e) => e.preventDefault());
worldLayer.prepend(el);
dragger.registerImage(img.src, el, { ...img.position });
}
}
graphData.nodes.forEach(n => dragger.registerNode(n.id));
// ─── Initial focus ─────────────────────────────────────────────────────────
const settings = graphData.settings ?? {};
const introDurationMs = settings.introDurationMs ?? 700;
const initialViewbox = settings.initialViewbox ?? { x: 0, y: 0, width: 1280, height: 720 };
const initialDraggable = settings.draggable ?? false;
dragger.enabled = initialDraggable;
pinConns.enabled = initialDraggable;
debugPanel.setDraggable(initialDraggable);
// Snap to a tiny zoom first so the intro animation has somewhere to travel from
const introRect = {
x: initialViewbox.x + initialViewbox.width / 2 - 50,
y: initialViewbox.y + initialViewbox.height / 2 - 50,
width: 100,
height: 100,
};
const anchorId = location.hash.slice(1);
const anchorNode = anchorId ? nodeRend.getNodes().find(n => n.id === anchorId) : null;
const anchorRect = anchorNode ? nodeRend.getNodeWorldRect(anchorNode.id) : null;
// Seed the current history entry so popstate can restore it when navigating back
history.replaceState({ nodeId: anchorId || null }, "");
nav.focusOnRect(introRect);
// Initial connection render — one rAF so layout is measured after nodes are in the DOM.
requestAnimationFrame(() => connRend.render());
if (anchorNode) {
// Wait for all markdown to finish loading so info nodes have their real height,
// then one rAF to let the browser measure layout before animating.
nodeRend.contentReady().then(() => requestAnimationFrame(() => {
const rect = nodeRend.getNodeWorldRect(anchorNode.id);
if (rect) {
nav.animateFocusOnRect(rect, { ...(anchorNode.focusOptions ?? {}), durationMs: introDurationMs });
} else {
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
}
setTimeout(() => { trackTransform = true; }, introDurationMs + 100);
}));
} else {
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
setTimeout(() => { trackTransform = true; }, introDurationMs + 100);
}
resetBtn.addEventListener("click", () => {
history.pushState({ nodeId: null }, "", location.pathname + location.search);
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
});
window.addEventListener("popstate", (e) => {
const nodeId = (e.state?.nodeId ?? location.hash.slice(1)) || null;
if (nodeId) {
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (node) {
nodeRend.contentReady().then(() => requestAnimationFrame(() => {
const rect = nodeRend.getNodeWorldRect(node.id);
if (rect) nav.animateFocusOnRect(rect, { ...(node.focusOptions ?? {}), durationMs: introDurationMs });
}));
return;
}
}
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
});
draggableToggle.addEventListener("click", () => {
const isActive = draggableToggle.classList.toggle("active");
dragger.enabled = isActive;
pinConns.enabled = isActive;
});
// Clear selection when clicking on canvas background
canvas.addEventListener("click", (e) => {
// Only clear if clicking directly on canvas (not on nodes or other elements)
if (e.target === canvas) {
dragger.clearSelection();
propertiesPanel.loadNode(null);
}
});
// Initialize dragging as disabled
dragger.enabled = false;
pinConns.enabled = false;

View File

@@ -0,0 +1,40 @@
/**
* @file BindEventNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Binds the OnMessage event on a ChatNode. When a message arrives, updates
* the username/message output pins and fires the event_out exec pin.
*/
export class BindEventNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "bind_event";
/**
* @UFUNCTION(BlueprintCallable)
* @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx
*/
async BlueprintCallable_Execute(ctx) {
const chatInstance = ctx.BlueprintPure_ResolveInput("target");
const node = ctx.BlueprintPure_GetNode();
if (!chatInstance || typeof chatInstance.setOnMessage !== "function" || !node) return;
chatInstance.setOnMessage((username, message) => {
const usernamePin = node.outputs.find(p => p.id === "username");
const messagePin = node.outputs.find(p => p.id === "message");
if (usernamePin) usernamePin.defaultValue = username;
if (messagePin) messagePin.defaultValue = message;
// Fire event_out — async, fire-and-forget
ctx.BlueprintCallable_RunExecPin("event_out").catch(console.error);
});
}
}
NodeRegistry.UCLASS_Register(BindEventNode);

View File

@@ -0,0 +1,43 @@
/**
* @file ButtonNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Renders a play button in the header that triggers node execution.
*/
export class ButtonNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "button";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
const header = article.querySelector(".node-header");
if (!header) return;
const btn = document.createElement("button");
btn.className = "node-run-btn";
btn.title = "Execute";
btn.innerHTML = `<svg width="9" height="9" viewBox="0 0 9 9" fill="currentColor" aria-hidden="true"><polygon points="1,0 9,4.5 1,9"/></svg>`;
btn.addEventListener("click", (e) => {
e.stopPropagation();
renderCtx.BlueprintCallable_TriggerNodeExecute(graphNode.id);
btn.classList.add("node-run-btn--flash");
btn.addEventListener("animationend", () => btn.classList.remove("node-run-btn--flash"), { once: true });
});
header.appendChild(btn);
}
}
NodeRegistry.UCLASS_Register(ButtonNode);

View File

@@ -0,0 +1,29 @@
/**
* @file ChatConnectNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Calls connect() on a ChatNode instance received via the target input.
*/
export class ChatConnectNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "chat_connect";
/**
* @UFUNCTION(BlueprintCallable)
* @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx
*/
async BlueprintCallable_Execute(ctx) {
const chatInstance = ctx.BlueprintPure_ResolveInput("target");
if (chatInstance && typeof chatInstance.connect === "function") {
chatInstance.connect();
}
}
}
NodeRegistry.UCLASS_Register(ChatConnectNode);

View File

@@ -0,0 +1,49 @@
/**
* @file CustomEventNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
const HAND_SVG = `<svg viewBox="0 0 106.17 122.88" fill="currentColor"><path d="M29.96,67.49c-0.16-0.09-0.32-0.19-0.47-0.31c-1.95-1.56-4.08-3.29-5.94-4.81c-2.69-2.2-5.8-4.76-7.97-6.55 c-1.49-1.23-3.17-2.07-4.75-2.39c-1.02-0.2-1.95-0.18-2.67,0.12c-0.59,0.24-1.1,0.72-1.45,1.48c-0.45,0.99-0.66,2.41-0.54,4.32 c0.11,1.69,0.7,3.55,1.48,5.33c1.16,2.63,2.73,5.04,3.89,6.59c0.07,0.09,0.13,0.19,0.19,0.29l23.32,33.31 c0.3,0.43,0.47,0.91,0.53,1.4l0.01,0c0.46,3.85,1.28,6.73,2.49,8.54c0.88,1.31,2.01,1.98,3.42,1.94l0.07,0v-0.01h36.38 c0.09,0,0.17,0,0.26,0.01c2.28-0.03,4.36-0.71,6.25-2.02c2.09-1.44,3.99-3.68,5.72-6.7c0.03-0.05,0.06-0.11,0.1-0.16 c0.67-1.15,1.55-2.6,2.41-4.02c3.72-6.13,6.96-11.45,7.35-19.04L99.8,74.34c-0.02-0.15-0.03-0.3-0.03-0.45 c0-0.14,0.02-1.13,0.03-2.46c0.09-6.92,0.19-15.48-6.14-16.56h-4.05l-0.04,0c-0.02,1.95-0.15,3.93-0.27,5.86 c-0.11,1.71-0.21,3.37-0.21,4.95c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08c0-1.58,0.12-3.42,0.24-5.33 c0.41-6.51,0.89-13.99-4.33-14.93H74.8c-0.23,0-0.45-0.02-0.66-0.07c0.04,2.36-0.12,4.81-0.27,7.16c-0.11,1.71-0.21,3.37-0.21,4.95 c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08c0-1.58,0.12-3.42,0.24-5.33c0.41-6.51,0.89-13.99-4.33-14.93h-4.05 c-0.28,0-0.55-0.04-0.8-0.11V49c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08V17.05c0-5.35-2.18-8.73-4.97-10.14 c-1.02-0.52-2.12-0.78-3.21-0.78c-1.08,0-2.18,0.26-3.19,0.77c-2.76,1.4-4.92,4.79-4.92,10.28v56c0,1.7-1.38,3.08-3.08,3.08 c-1.7,0-3.08-1.38-3.08-3.08V67.49L29.96,67.49z M58.57,31.15c0.26-0.07,0.53-0.11,0.8-0.11h4.24c0.24,0,0.47,0.03,0.69,0.08 c5.65,0.88,8.17,4.18,9.2,8.43c0.39-0.18,0.83-0.29,1.3-0.29h4.24c0.24,0,0.47,0.03,0.69,0.08c6.08,0.94,8.53,4.69,9.41,9.41 c0.15-0.02,0.31-0.04,0.47-0.04h4.24c0.24,0,0.47,0.03,0.69,0.08c11.64,1.8,11.5,13.37,11.38,22.71c0,0.33-0.01,0.68-0.01,2.35 l0,0.07l0.24,10.77c0.01,0.11,0.01,0.23,0,0.34c-0.45,9.16-4.07,15.12-8.24,21.98c-0.7,1.14-1.41,2.32-2.34,3.93 c-0.02,0.04-0.04,0.08-0.07,0.13c-2.18,3.8-4.7,6.71-7.57,8.69c-2.92,2.02-6.16,3.06-9.71,3.1c-0.09,0.01-0.19,0.01-0.28,0.01 H41.58v-0.01c-3.66,0.07-6.5-1.53-8.59-4.66c-1.68-2.51-2.79-6.03-3.4-10.47L6.73,75.07c-0.03-0.04-0.07-0.08-0.1-0.12 c-1.36-1.82-3.21-4.65-4.59-7.79C1,64.8,0.21,62.24,0.05,59.74c-0.2-2.97,0.22-5.36,1.06-7.23c1.05-2.32,2.72-3.83,4.74-4.66 c1.89-0.77,4.01-0.88,6.16-0.45c2.57,0.51,5.22,1.81,7.49,3.68c1.86,1.54,4.95,4.07,7.95,6.52l2.52,2.06V17.18 c0-8.14,3.63-13.39,8.28-15.76C40.12,0.47,42.17,0,44.23,0c2.05,0,4.1,0.48,5.98,1.43c4.69,2.37,8.36,7.62,8.36,15.62V31.15 L58.57,31.15z"/></svg>`;
/**
* @UCLASS(BlueprintType)
* Entry-point event node. Adds pulsate animation and a hand-pointer hint.
*/
export class CustomEventNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "custom_event";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
*/
BlueprintNativeEvent_OnRender(article, graphNode) {
const outputs = article.querySelector(".node-outputs");
if (!outputs) return;
const execPin = outputs.querySelector('[data-pin-id="exec_out"]');
if (!execPin) return;
execPin.classList.add("pin--pulsate");
execPin.style.position = "relative";
const handHint = document.createElement("div");
handHint.className = "pin-hint-hand";
handHint.innerHTML = HAND_SVG;
handHint.style.color = "#fff";
execPin.appendChild(handHint);
// Dismiss hand permanently on first click of the pin (handle or label)
const dismiss = () => {
article.classList.add("hint-dismissed");
execPin.removeEventListener("click", dismiss);
};
execPin.addEventListener("click", dismiss);
}
}
NodeRegistry.UCLASS_Register(CustomEventNode);

View File

@@ -0,0 +1,38 @@
/**
* @file GetMatrixChatNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
import { GlobalVariables } from '../GlobalVariables.js';
/**
* @UCLASS(BlueprintType)
* Pure getter node that exposes the "MatrixChat" global as an object output.
* Styled with a semi-transparent blue header to visually denote a variable getter.
*/
export class GetMatrixChatNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "get_matrix_chat";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
*/
BlueprintNativeEvent_OnRender(article, graphNode) {
const header = article.querySelector(".node-header");
if (header) {
header.style.background = "rgba(100, 181, 246, 0.2)";
header.style.borderColor = "rgba(100, 181, 246, 0.4)";
}
const outputPin = graphNode.outputs.find(p => p.id === "value");
if (outputPin && GlobalVariables.has("MatrixChat")) {
outputPin.defaultValue = GlobalVariables.get("MatrixChat");
}
}
}
NodeRegistry.UCLASS_Register(GetMatrixChatNode);

View File

@@ -0,0 +1,35 @@
/**
* @file InfoNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Renders a markdown body below the node header.
*/
export class InfoNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "info";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
if (!graphNode.markdownSrc) return;
const body = document.createElement("div");
body.className = "node-body";
body.setAttribute("aria-live", "polite");
article.appendChild(body);
renderCtx.BlueprintCallable_LoadMarkdown(graphNode.markdownSrc, body);
}
}
NodeRegistry.UCLASS_Register(InfoNode);

View File

@@ -0,0 +1,43 @@
/**
* @file LottieNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Renders a Lottie animation JSON file inside a node body using the
* `<lottie-player>` web component (loaded via CDN in index.html).
* Set `lottieSrc` on the graph node data to point at a Lottie JSON file.
*/
export class LottieNode extends NodeBase {
/** @type {string} */
static NodeType = "lottie";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
*/
BlueprintNativeEvent_OnRender(article, graphNode) {
if (!graphNode.lottieSrc) return;
const body = document.createElement("div");
body.className = "node-body node-body--lottie";
const player = document.createElement("lottie-player");
player.setAttribute("src", graphNode.lottieSrc);
player.setAttribute("background", "transparent");
player.setAttribute("speed", "1");
player.setAttribute("loop", "");
player.setAttribute("autoplay", "");
player.className = "node-lottie";
body.appendChild(player);
article.appendChild(body);
}
}
NodeRegistry.UCLASS_Register(LottieNode);

View File

@@ -0,0 +1,84 @@
/**
* @file MatrixChatNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
import { ChatNode } from '../ChatNode.js';
import { GlobalVariables } from '../GlobalVariables.js';
/**
* @UCLASS(BlueprintType)
* Renders a full Matrix chat UI inside the node body.
* Instantiates a ChatNode and registers it as the "MatrixChat" global.
*/
export class MatrixChatNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "chat";
/**
* @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 node-body--chat";
const statusBar = document.createElement("div");
statusBar.className = "chat-status-bar";
const statusEl = document.createElement("span");
statusEl.className = "chat-status chat-status-disconnected";
statusEl.textContent = "Not connected";
statusBar.appendChild(statusEl);
const messagesContainer = document.createElement("div");
messagesContainer.className = "chat-messages";
const inputContainer = document.createElement("div");
inputContainer.className = "chat-input-container";
const messageInput = document.createElement("input");
messageInput.type = "text";
messageInput.className = "chat-input";
messageInput.placeholder = "Type a message or /nick...";
messageInput.addEventListener("focus", () => {
// On mobile the virtual keyboard shrinks the viewport. Delay lets the
// keyboard finish animating before we scroll the input into view.
window.setTimeout(() => {
messageInput.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 300);
});
inputContainer.appendChild(messageInput);
body.appendChild(statusBar);
body.appendChild(messagesContainer);
body.appendChild(inputContainer);
article.appendChild(body);
const homeserverPin = graphNode.inputs.find(p => p.id === "homeserver");
const roomPin = graphNode.inputs.find(p => p.id === "room");
const homeserver = homeserverPin?.defaultValue || "https://matrix.org";
const roomAlias = roomPin?.defaultValue || "#general:matrix.org";
const chatInstance = new ChatNode(homeserver, roomAlias);
// Pass resolver function so chat can resolve name at connect time
chatInstance.initialize(
messagesContainer,
messageInput,
statusEl,
inputContainer,
homeserver,
roomAlias,
() => renderCtx.BlueprintPure_ResolveInputValue?.(graphNode.id, "guestName") || ""
);
renderCtx.BlueprintCallable_RegisterChatInstance(graphNode.id, chatInstance);
GlobalVariables.set("MatrixChat", chatInstance);
}
}
NodeRegistry.UCLASS_Register(MatrixChatNode);

View File

@@ -0,0 +1,53 @@
/**
* @file NodeBase.js
* Abstract base class for all Blueprint graph node types.
*
* Method prefixes mirror Unreal Engine UFUNCTION macro conventions:
* BlueprintNativeEvent_ — has a native default implementation; subclasses may override
* BlueprintCallable_ — has exec-pin side effects; called during graph traversal
* BlueprintPure_ — side-effect free; does not advance exec flow
*/
/**
* @typedef {import('../GraphNode.js').GraphNode} GraphNode
* @typedef {import('./NodeRenderContext.js').NodeRenderContext} NodeRenderContext
* @typedef {import('./NodeExecutionContext.js').NodeExecutionContext} NodeExecutionContext
*/
/**
* @UCLASS(Abstract)
* Base class all node handlers inherit from. Registered via NodeRegistry.
*/
export class NodeBase {
/**
* The graph type string that maps to this class in the registry.
* Must be overridden by every subclass.
* @UCLASS(BlueprintType)
* @type {string}
*/
static NodeType = "";
/**
* Called after the node's base DOM (header, pins) has been built.
* Override to inject type-specific UI into the article element.
*
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {GraphNode} graphNode
* @param {NodeRenderContext} renderCtx
*/
// eslint-disable-next-line no-unused-vars
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {}
/**
* Called when this node is reached during exec graph traversal.
* Override to implement node-specific behaviour.
* Exec flow continues automatically after this returns.
*
* @UFUNCTION(BlueprintCallable)
* @param {NodeExecutionContext} ctx
* @returns {Promise<void>}
*/
// eslint-disable-next-line no-unused-vars
async BlueprintCallable_Execute(ctx) {}
}

View File

@@ -0,0 +1,113 @@
/**
* @file NodeExecutionContext.js
* Runtime context passed to node handlers during graph traversal.
*/
/**
* @typedef {import('../GraphNode.js').GraphNode} GraphNode
* @typedef {import('../GraphConnection.js').GraphConnection} GraphConnection
* @typedef {import('../NodeRenderer.js').NodeRenderer} NodeRenderer
*/
/**
* Provides controlled access to runtime execution services.
* Passed to BlueprintCallable_Execute on every node handler invocation.
*/
export class NodeExecutionContext {
/** @type {string} */
#nodeId;
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {(nodeId: string, pinId: string) => any} */
#resolveInputFn;
/** @type {(nodeId: string, pinId: string) => Promise<void>} */
#runExecPinFn;
/** @type {((nodeId: string, value: string) => void) | null} */
#onPrint;
/** @type {((connId: string, kind: string) => Promise<void>) | null} */
#onStep;
/**
* @param {string} nodeId
* @param {NodeRenderer} nodeRenderer
* @param {(nodeId: string, pinId: string) => any} resolveInputFn
* @param {(nodeId: string, pinId: string) => Promise<void>} runExecPinFn
* @param {((nodeId: string, value: string) => void) | null} onPrint
* @param {((connId: string, kind: string) => Promise<void>) | null} onStep
*/
constructor(nodeId, nodeRenderer, resolveInputFn, runExecPinFn, onPrint, onStep) {
this.#nodeId = nodeId;
this.#nodeRenderer = nodeRenderer;
this.#resolveInputFn = resolveInputFn;
this.#runExecPinFn = runExecPinFn;
this.#onPrint = onPrint;
this.#onStep = onStep;
}
/**
* Returns the ID of the currently executing node.
*
* @UFUNCTION(BlueprintPure)
* @returns {string}
*/
BlueprintPure_GetNodeId() {
return this.#nodeId;
}
/**
* Returns the GraphNode data for the currently executing node.
*
* @UFUNCTION(BlueprintPure)
* @returns {GraphNode | null}
*/
BlueprintPure_GetNode() {
return this.#nodeRenderer.getNodes().find(n => n.id === this.#nodeId) ?? null;
}
/**
* Resolves the runtime value of a named input pin, following connections.
*
* @UFUNCTION(BlueprintPure)
* @param {string} pinId
* @returns {any}
*/
BlueprintPure_ResolveInput(pinId) {
return this.#resolveInputFn(this.#nodeId, pinId);
}
/**
* Returns all live graph connections.
*
* @UFUNCTION(BlueprintPure)
* @returns {GraphConnection[]}
*/
BlueprintPure_GetConnections() {
return this.#nodeRenderer.getConnections();
}
/**
* Triggers exec flow from an output pin on this node.
*
* @UFUNCTION(BlueprintCallable)
* @param {string} pinId
* @returns {Promise<void>}
*/
BlueprintCallable_RunExecPin(pinId) {
return this.#runExecPinFn(this.#nodeId, pinId);
}
/**
* Fires the print callback with a string value.
*
* @UFUNCTION(BlueprintCallable)
* @param {string} value
*/
BlueprintCallable_Print(value) {
this.#onPrint?.(this.#nodeId, value);
}
}

View File

@@ -0,0 +1,50 @@
/**
* @file NodeRegistry.js
* Static registry mapping node type strings to their handler instances.
*/
import { NodeBase } from './NodeBase.js';
/**
* Static registry for all Blueprint node types.
* Node classes self-register by calling UCLASS_Register during module init.
*/
export class NodeRegistry {
/** @type {Map<string, NodeBase>} */
static #registry = new Map();
/**
* Registers a node class by its static NodeType.
* Call once per class, typically at the bottom of the class file.
*
* @UFUNCTION(BlueprintCallable)
* @param {typeof NodeBase} NodeClass
*/
static UCLASS_Register(NodeClass) {
if (!NodeClass.NodeType) {
throw new Error(`[NodeRegistry] "${NodeClass.name}" is missing a static NodeType.`);
}
this.#registry.set(NodeClass.NodeType, new NodeClass());
}
/**
* Returns the handler instance for a given node type, or null.
*
* @UFUNCTION(BlueprintPure)
* @param {string} nodeType
* @returns {NodeBase | null}
*/
static BlueprintPure_Get(nodeType) {
return this.#registry.get(nodeType) ?? null;
}
/**
* Returns all currently registered type strings.
*
* @UFUNCTION(BlueprintPure)
* @returns {string[]}
*/
static BlueprintPure_GetRegisteredTypes() {
return [...this.#registry.keys()];
}
}

View File

@@ -0,0 +1,137 @@
/**
* @file NodeRenderContext.js
* Render-time context passed to node handlers when building their DOM.
*/
/**
* @typedef {import('../GraphNode.js').GraphNode} GraphNode
*/
/**
* Wraps all render-time services nodes may need during BlueprintNativeEvent_OnRender.
* Keeps NodeRenderer internals encapsulated while giving nodes controlled access.
*/
export class NodeRenderContext {
/** @type {(src: string, target: HTMLElement) => Promise<void>} */
#loadMarkdown;
/** @type {(p: Promise<void>) => void} */
#addMarkdownPromise;
/** @type {((nodeId: string, pinId?: string) => void) | null} */
#onNodeExecute;
/** @type {(nodeId: string, instance: any) => void} */
#registerChatInstance;
/** @type {(nodeId: string, toggleEl: HTMLButtonElement) => void} */
#startTimer;
/** @type {(nodeId: string) => void} */
#stopTimer;
/** @type {() => string} */
#svgPlay;
/** @type {() => string} */
#svgStop;
/** @type {(nodeId: string, pinId: string) => any} */
#resolveInputValue;
/**
* @param {{
* loadMarkdown: (src: string, target: HTMLElement) => Promise<void>,
* addMarkdownPromise: (p: Promise<void>) => void,
* onNodeExecute: ((nodeId: string, pinId?: string) => void) | null,
* registerChatInstance: (nodeId: string, instance: any) => void,
* startTimer: (nodeId: string, toggleEl: HTMLButtonElement) => void,
* stopTimer: (nodeId: string) => void,
* svgPlay: () => string,
* svgStop: () => string,
* resolveInputValue: (nodeId: string, pinId: string) => any,
* }} callbacks
*/
constructor(callbacks) {
this.#loadMarkdown = callbacks.loadMarkdown;
this.#addMarkdownPromise = callbacks.addMarkdownPromise;
this.#onNodeExecute = callbacks.onNodeExecute;
this.#registerChatInstance = callbacks.registerChatInstance;
this.#startTimer = callbacks.startTimer;
this.#stopTimer = callbacks.stopTimer;
this.#svgPlay = callbacks.svgPlay;
this.#svgStop = callbacks.svgStop;
this.#resolveInputValue = callbacks.resolveInputValue;
}
/**
* @UFUNCTION(BlueprintCallable)
* @param {string} src
* @param {HTMLElement} target
*/
BlueprintCallable_LoadMarkdown(src, target) {
this.#addMarkdownPromise(this.#loadMarkdown(src, target));
}
/**
* @UFUNCTION(BlueprintCallable)
* @param {string} nodeId
* @param {string} [pinId]
*/
BlueprintCallable_TriggerNodeExecute(nodeId, pinId) {
this.#onNodeExecute?.(nodeId, pinId);
}
/**
* @UFUNCTION(BlueprintCallable)
* @param {string} nodeId
* @param {any} instance
*/
BlueprintCallable_RegisterChatInstance(nodeId, instance) {
this.#registerChatInstance(nodeId, instance);
}
/**
* @UFUNCTION(BlueprintCallable)
* @param {string} nodeId
* @param {HTMLButtonElement} toggleEl
*/
BlueprintCallable_StartTimer(nodeId, toggleEl) {
this.#startTimer(nodeId, toggleEl);
}
/**
* @UFUNCTION(BlueprintCallable)
* @param {string} nodeId
*/
BlueprintCallable_StopTimer(nodeId) {
this.#stopTimer(nodeId);
}
/**
* @UFUNCTION(BlueprintPure)
* @returns {string}
*/
BlueprintPure_GetSvgPlay() {
return this.#svgPlay();
}
/**
* @UFUNCTION(BlueprintPure)
* @returns {string}
*/
BlueprintPure_GetSvgStop() {
return this.#svgStop();
}
/**
* @UFUNCTION(BlueprintPure)
* Resolves an input pin value by following connections.
* @param {string} nodeId
* @param {string} pinId
* @returns {any}
*/
BlueprintPure_ResolveInputValue(nodeId, pinId) {
return this.#resolveInputValue(nodeId, pinId);
}
}

View File

@@ -0,0 +1,27 @@
/**
* @file PrintNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Logs a string value and fires it through the print callback.
*/
export class PrintNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "print";
/**
* @UFUNCTION(BlueprintCallable)
* @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx
*/
async BlueprintCallable_Execute(ctx) {
const value = ctx.BlueprintPure_ResolveInput("value");
ctx.BlueprintCallable_Print(value);
}
}
NodeRegistry.UCLASS_Register(PrintNode);

View File

@@ -0,0 +1,38 @@
/**
* @file PureNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Pure data node — optionally renders an image body. No exec logic.
*/
export class PureNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "pure";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
*/
BlueprintNativeEvent_OnRender(article, graphNode) {
if (!graphNode.imageSrc) return;
const body = document.createElement("div");
body.className = "node-body node-body--image";
const img = document.createElement("img");
img.src = graphNode.imageSrc;
img.alt = "";
img.className = "node-image";
body.appendChild(img);
article.appendChild(body);
}
}
NodeRegistry.UCLASS_Register(PureNode);

View File

@@ -0,0 +1,124 @@
/**
* @file RandomNameNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Generates random names from first/second part combinations.
* Pre-runs on load and has a button to regenerate.
*/
export class RandomNameNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "random_name";
/** @type {Array<string>} */
static #firstParts = [];
/** @type {Array<string>} */
static #secondParts = [];
/** @type {Promise<void> | null} */
static #loadPromise = null;
/**
* Load name parts from JSON
*/
static async #loadNameParts() {
if (this.#loadPromise) return this.#loadPromise;
this.#loadPromise = (async () => {
try {
const response = await fetch('data/nameParts.json');
const data = await response.json();
this.#firstParts = data.firstParts || [];
this.#secondParts = data.secondParts || [];
} catch (err) {
console.error('Failed to load name parts:', err);
this.#firstParts = ['Random'];
this.#secondParts = ['Name'];
}
})();
return this.#loadPromise;
}
/**
* Generate random name
* @returns {string}
*/
static generateName() {
if (this.#firstParts.length === 0 || this.#secondParts.length === 0) {
return 'Loading...';
}
const first = this.#firstParts[Math.floor(Math.random() * this.#firstParts.length)];
const second = this.#secondParts[Math.floor(Math.random() * this.#secondParts.length)];
return `${first}${second}`;
}
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
async BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
await RandomNameNode.#loadNameParts();
// Generate initial name if not set
if (!graphNode._generatedName) {
graphNode._generatedName = RandomNameNode.generateName();
}
console.log('[RandomNameNode] Generated name:', graphNode._generatedName, 'for node', graphNode.id);
// Add button to header
const header = article.querySelector(".node-header");
if (header) {
const btn = document.createElement("button");
btn.className = "node-run-btn";
btn.title = "Generate New Name";
btn.innerHTML = `<svg width="9" height="9" viewBox="0 0 9 9" fill="currentColor" aria-hidden="true"><polygon points="1,0 9,4.5 1,9"/></svg>`;
btn.addEventListener("click", (e) => {
e.stopPropagation();
graphNode._generatedName = RandomNameNode.generateName();
nameDisplay.textContent = graphNode._generatedName;
btn.classList.add("node-run-btn--flash");
btn.addEventListener("animationend", () => btn.classList.remove("node-run-btn--flash"), { once: true });
});
header.appendChild(btn);
}
// Display name in body
const body = document.createElement("div");
body.className = "node-body node-body--text";
const nameDisplay = document.createElement("div");
nameDisplay.className = "node-name-display";
nameDisplay.textContent = graphNode._generatedName;
body.appendChild(nameDisplay);
article.appendChild(body);
}
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {string} pinId
* @returns {any}
*/
BlueprintNativeEvent_GetOutputValue(graphNode, pinId) {
if (pinId === 'name') {
return graphNode._generatedName || '';
}
return null;
}
}
NodeRegistry.UCLASS_Register(RandomNameNode);

View File

@@ -0,0 +1,36 @@
/**
* @file SequenceNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Fires all then_N outputs in order, then continues exec flow.
*/
export class SequenceNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "sequence";
/**
* @UFUNCTION(BlueprintCallable)
* @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx
*/
async BlueprintCallable_Execute(ctx) {
const node = ctx.BlueprintPure_GetNode();
if (!node) return;
// Fire every then_N output in order
const thenPins = node.outputs
.filter(p => p.id.startsWith("then_"))
.sort((a, b) => a.id.localeCompare(b.id));
for (const pin of thenPins) {
await ctx.BlueprintCallable_RunExecPin(pin.id);
}
}
}
NodeRegistry.UCLASS_Register(SequenceNode);

View File

@@ -0,0 +1,46 @@
/**
* @file TimerNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Renders a toggle button in the header that starts/stops an interval timer.
*/
export class TimerNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "timer";
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
const header = article.querySelector(".node-header");
if (!header) return;
const toggle = document.createElement("button");
toggle.className = "node-timer-btn";
toggle.title = "Start timer";
toggle.innerHTML = renderCtx.BlueprintPure_GetSvgPlay();
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const running = toggle.dataset.running === "true";
if (running) {
renderCtx.BlueprintCallable_StopTimer(graphNode.id);
} else {
renderCtx.BlueprintCallable_StartTimer(graphNode.id, toggle);
}
});
header.appendChild(toggle);
}
}
NodeRegistry.UCLASS_Register(TimerNode);

View File

@@ -0,0 +1,25 @@
/**
* @file index.js
* Imports all node classes so they self-register with NodeRegistry.
* Import this module once at app startup before rendering any nodes.
*/
export { NodeBase } from './NodeBase.js';
export { NodeRegistry } from './NodeRegistry.js';
export { NodeExecutionContext } from './NodeExecutionContext.js';
export { NodeRenderContext } from './NodeRenderContext.js';
// Node types — each file calls NodeRegistry.UCLASS_Register on load
export { PrintNode } from './PrintNode.js';
export { SequenceNode } from './SequenceNode.js';
export { CustomEventNode } from './CustomEventNode.js';
export { ButtonNode } from './ButtonNode.js';
export { TimerNode } from './TimerNode.js';
export { InfoNode } from './InfoNode.js';
export { PureNode } from './PureNode.js';
export { MatrixChatNode } from './MatrixChatNode.js';
export { GetMatrixChatNode } from './GetMatrixChatNode.js';
export { ChatConnectNode } from './ChatConnectNode.js';
export { BindEventNode } from './BindEventNode.js';
export { LottieNode } from './LottieNode.js';
export { RandomNameNode } from './RandomNameNode.js';