mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 10:46:03 +10:00
update to node site
This commit is contained in:
183
website/scripts/ChatAutocomplete.js
Normal file
183
website/scripts/ChatAutocomplete.js
Normal 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';
|
||||
}
|
||||
}
|
||||
63
website/scripts/ChatCommands.js
Normal file
63
website/scripts/ChatCommands.js
Normal 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
500
website/scripts/ChatNode.js
Normal 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');
|
||||
}
|
||||
}
|
||||
218
website/scripts/ConnectionRenderer.js
Normal file
218
website/scripts/ConnectionRenderer.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
479
website/scripts/DebugPanel.js
Normal file
479
website/scripts/DebugPanel.js
Normal 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);
|
||||
}
|
||||
}
|
||||
62
website/scripts/GlobalVariables.js
Normal file
62
website/scripts/GlobalVariables.js
Normal 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());
|
||||
}
|
||||
}
|
||||
19
website/scripts/GraphComment.js
Normal file
19
website/scripts/GraphComment.js
Normal 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;
|
||||
}
|
||||
}
|
||||
21
website/scripts/GraphConnection.js
Normal file
21
website/scripts/GraphConnection.js
Normal 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;
|
||||
}
|
||||
}
|
||||
148
website/scripts/GraphExecutor.js
Normal file
148
website/scripts/GraphExecutor.js
Normal 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 ?? "";
|
||||
}
|
||||
}
|
||||
45
website/scripts/GraphNode.js
Normal file
45
website/scripts/GraphNode.js
Normal 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);
|
||||
}
|
||||
}
|
||||
698
website/scripts/ItemDragger.js
Normal file
698
website/scripts/ItemDragger.js
Normal 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 8–60 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;
|
||||
}
|
||||
}
|
||||
|
||||
175
website/scripts/MarkdownRenderer.js
Normal file
175
website/scripts/MarkdownRenderer.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Lightweight markdown-to-HTML renderer.
|
||||
* Supports: headings (h1–h3), 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
715
website/scripts/NodeRenderer.js
Normal file
715
website/scripts/NodeRenderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
516
website/scripts/PinConnectionManager.js
Normal file
516
website/scripts/PinConnectionManager.js
Normal 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";
|
||||
}
|
||||
}
|
||||
71
website/scripts/PrintBubble.js
Normal file
71
website/scripts/PrintBubble.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
386
website/scripts/PropertiesPanel.js
Normal file
386
website/scripts/PropertiesPanel.js
Normal 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');
|
||||
}
|
||||
}
|
||||
120
website/scripts/SpatialAudio.js
Normal file
120
website/scripts/SpatialAudio.js
Normal 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 (0–1)
|
||||
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);
|
||||
}
|
||||
}
|
||||
802
website/scripts/WorkspaceNavigator.js
Normal file
802
website/scripts/WorkspaceNavigator.js
Normal 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 (0–1) 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Clear command - Clear terminal screen
|
||||
*/
|
||||
export default {
|
||||
description: 'Clear terminal screen',
|
||||
execute: (term) => {
|
||||
term.clear();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -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`;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Echo command - Echo back message
|
||||
*/
|
||||
export default {
|
||||
description: 'Echo back message',
|
||||
execute: (term, writeClickable, VERSION, args) => {
|
||||
return args.join(' ') || '';
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
21
website/scripts/getTypeColor.js
Normal file
21
website/scripts/getTypeColor.js
Normal 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
672
website/scripts/main.js
Normal 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;
|
||||
40
website/scripts/nodes/BindEventNode.js
Normal file
40
website/scripts/nodes/BindEventNode.js
Normal 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);
|
||||
43
website/scripts/nodes/ButtonNode.js
Normal file
43
website/scripts/nodes/ButtonNode.js
Normal 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);
|
||||
29
website/scripts/nodes/ChatConnectNode.js
Normal file
29
website/scripts/nodes/ChatConnectNode.js
Normal 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);
|
||||
49
website/scripts/nodes/CustomEventNode.js
Normal file
49
website/scripts/nodes/CustomEventNode.js
Normal 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);
|
||||
38
website/scripts/nodes/GetMatrixChatNode.js
Normal file
38
website/scripts/nodes/GetMatrixChatNode.js
Normal 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);
|
||||
35
website/scripts/nodes/InfoNode.js
Normal file
35
website/scripts/nodes/InfoNode.js
Normal 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);
|
||||
43
website/scripts/nodes/LottieNode.js
Normal file
43
website/scripts/nodes/LottieNode.js
Normal 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);
|
||||
84
website/scripts/nodes/MatrixChatNode.js
Normal file
84
website/scripts/nodes/MatrixChatNode.js
Normal 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);
|
||||
53
website/scripts/nodes/NodeBase.js
Normal file
53
website/scripts/nodes/NodeBase.js
Normal 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) {}
|
||||
}
|
||||
113
website/scripts/nodes/NodeExecutionContext.js
Normal file
113
website/scripts/nodes/NodeExecutionContext.js
Normal 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);
|
||||
}
|
||||
}
|
||||
50
website/scripts/nodes/NodeRegistry.js
Normal file
50
website/scripts/nodes/NodeRegistry.js
Normal 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()];
|
||||
}
|
||||
}
|
||||
137
website/scripts/nodes/NodeRenderContext.js
Normal file
137
website/scripts/nodes/NodeRenderContext.js
Normal 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);
|
||||
}
|
||||
}
|
||||
27
website/scripts/nodes/PrintNode.js
Normal file
27
website/scripts/nodes/PrintNode.js
Normal 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);
|
||||
38
website/scripts/nodes/PureNode.js
Normal file
38
website/scripts/nodes/PureNode.js
Normal 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);
|
||||
124
website/scripts/nodes/RandomNameNode.js
Normal file
124
website/scripts/nodes/RandomNameNode.js
Normal 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);
|
||||
36
website/scripts/nodes/SequenceNode.js
Normal file
36
website/scripts/nodes/SequenceNode.js
Normal 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);
|
||||
46
website/scripts/nodes/TimerNode.js
Normal file
46
website/scripts/nodes/TimerNode.js
Normal 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);
|
||||
25
website/scripts/nodes/index.js
Normal file
25
website/scripts/nodes/index.js
Normal 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';
|
||||
Reference in New Issue
Block a user