Spaces:
Running
on
Zero
Running
on
Zero
/** | |
* File: comfy_shared.js | |
* Project: comfy_mtb | |
* Author: Mel Massadian | |
* | |
* Copyright (c) 2023-2024 Mel Massadian | |
* | |
*/ | |
// Reference the shared typedefs file | |
/// <reference path="../types/typedefs.js" /> | |
import { app } from '../../scripts/app.js' | |
import { api } from '../../scripts/api.js' | |
// #region base utils | |
// - crude uuid | |
export function makeUUID() { | |
let dt = new Date().getTime() | |
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { | |
const r = ((dt + Math.random() * 16) % 16) | 0 | |
dt = Math.floor(dt / 16) | |
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) | |
}) | |
return uuid | |
} | |
//- local storage manager | |
export class LocalStorageManager { | |
constructor(namespace) { | |
this.namespace = namespace | |
} | |
_namespacedKey(key) { | |
return `${this.namespace}:${key}` | |
} | |
set(key, value) { | |
const serializedValue = JSON.stringify(value) | |
localStorage.setItem(this._namespacedKey(key), serializedValue) | |
} | |
get(key, default_val = null) { | |
const value = localStorage.getItem(this._namespacedKey(key)) | |
return value ? JSON.parse(value) : default_val | |
} | |
remove(key) { | |
localStorage.removeItem(this._namespacedKey(key)) | |
} | |
clear() { | |
for (const key of Object.keys(localStorage).filter((k) => | |
k.startsWith(`${this.namespace}:`), | |
)) { | |
localStorage.removeItem(key) | |
} | |
} | |
} | |
// - log utilities | |
function createLogger(emoji, color, consoleMethod = 'log') { | |
return (message, ...args) => { | |
if (window.MTB?.DEBUG) { | |
console[consoleMethod]( | |
`%c${emoji} ${message}`, | |
`color: ${color};`, | |
...args, | |
) | |
} | |
} | |
} | |
export const infoLogger = createLogger('ℹ️', 'yellow') | |
export const warnLogger = createLogger('⚠️', 'orange', 'warn') | |
export const errorLogger = createLogger('🔥', 'red', 'error') | |
export const successLogger = createLogger('✅', 'green') | |
export const log = (...args) => { | |
if (window.MTB?.DEBUG) { | |
console.debug(...args) | |
} | |
} | |
/** | |
* Deep merge two objects. | |
* @param {Object} target - The target object to merge into. | |
* @param {...Object} sources - The source objects to merge from. | |
* @returns {Object} - The merged object. | |
*/ | |
export function deepMerge(target, ...sources) { | |
if (!sources.length) return target | |
const source = sources.shift() | |
for (const key in source) { | |
if (source[key] instanceof Object) { | |
if (!target[key]) Object.assign(target, { [key]: {} }) | |
deepMerge(target[key], source[key]) | |
} else { | |
Object.assign(target, { [key]: source[key] }) | |
} | |
} | |
return deepMerge(target, ...sources) | |
} | |
// #endregion | |
// #region widget utils | |
export const CONVERTED_TYPE = 'converted-widget' | |
export function hideWidget(node, widget, suffix = '') { | |
widget.origType = widget.type | |
widget.hidden = true | |
widget.origComputeSize = widget.computeSize | |
widget.origSerializeValue = widget.serializeValue | |
widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically | |
widget.type = CONVERTED_TYPE + suffix | |
widget.serializeValue = () => { | |
// Prevent serializing the widget if we have no input linked | |
const { link } = node.inputs.find((i) => i.widget?.name === widget.name) | |
if (link == null) { | |
return undefined | |
} | |
return widget.origSerializeValue | |
? widget.origSerializeValue() | |
: widget.value | |
} | |
// Hide any linked widgets, e.g. seed+seedControl | |
if (widget.linkedWidgets) { | |
for (const w of widget.linkedWidgets) { | |
hideWidget(node, w, `:${widget.name}`) | |
} | |
} | |
} | |
/** | |
* Show widget | |
* | |
* @param {import("../../../web/types/litegraph.d.ts").IWidget} widget - target widget | |
*/ | |
export function showWidget(widget) { | |
widget.type = widget.origType | |
widget.computeSize = widget.origComputeSize | |
widget.serializeValue = widget.origSerializeValue | |
delete widget.origType | |
delete widget.origComputeSize | |
delete widget.origSerializeValue | |
// Hide any linked widgets, e.g. seed+seedControl | |
if (widget.linkedWidgets) { | |
for (const w of widget.linkedWidgets) { | |
showWidget(w) | |
} | |
} | |
} | |
export function convertToWidget(node, widget) { | |
showWidget(widget) | |
const sz = node.size | |
node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name)) | |
for (const widget of node.widgets) { | |
widget.last_y -= LiteGraph.NODE_SLOT_HEIGHT | |
} | |
// Restore original size but grow if needed | |
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]) | |
} | |
export function convertToInput(node, widget, config) { | |
hideWidget(node, widget) | |
const { linkType } = getWidgetType(config) | |
// Add input and store widget config for creating on primitive node | |
const sz = node.size | |
node.addInput(widget.name, linkType, { | |
widget: { name: widget.name, config }, | |
}) | |
for (const widget of node.widgets) { | |
widget.last_y += LiteGraph.NODE_SLOT_HEIGHT | |
} | |
// Restore original size but grow if needed | |
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]) | |
} | |
export function hideWidgetForGood(node, widget, suffix = '') { | |
widget.origType = widget.type | |
widget.origComputeSize = widget.computeSize | |
widget.origSerializeValue = widget.serializeValue | |
widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically | |
widget.type = CONVERTED_TYPE + suffix | |
// widget.serializeValue = () => { | |
// // Prevent serializing the widget if we have no input linked | |
// const w = node.inputs?.find((i) => i.widget?.name === widget.name); | |
// if (w?.link == null) { | |
// return undefined; | |
// } | |
// return widget.origSerializeValue ? widget.origSerializeValue() : widget.value; | |
// }; | |
// Hide any linked widgets, e.g. seed+seedControl | |
if (widget.linkedWidgets) { | |
for (const w of widget.linkedWidgets) { | |
hideWidgetForGood(node, w, `:${widget.name}`) | |
} | |
} | |
} | |
export function fixWidgets(node) { | |
if (node.inputs) { | |
for (const input of node.inputs) { | |
log(input) | |
if (input.widget || node.widgets) { | |
// if (newTypes.includes(input.type)) { | |
const matching_widget = node.widgets.find((w) => w.name === input.name) | |
if (matching_widget) { | |
// if (matching_widget.hidden) { | |
// log(`Already hidden skipping ${matching_widget.name}`) | |
// continue | |
// } | |
const w = node.widgets.find((w) => w.name === matching_widget.name) | |
if (w && w.type !== CONVERTED_TYPE) { | |
log(w) | |
log(`hidding ${w.name}(${w.type}) from ${node.type}`) | |
log(node) | |
hideWidget(node, w) | |
} else { | |
log(`converting to widget ${w}`) | |
convertToWidget(node, input) | |
} | |
} | |
} | |
} | |
} | |
} | |
export function inner_value_change(widget, val, event = undefined) { | |
let value = val | |
if (widget.type === 'number' || widget.type === 'BBOX') { | |
value = Number(value) | |
} else if (widget.type === 'BOOL') { | |
value = Boolean(value) | |
} | |
widget.value = corrected_value | |
if ( | |
widget.options?.property && | |
node.properties[widget.options.property] !== undefined | |
) { | |
node.setProperty(widget.options.property, value) | |
} | |
if (widget.callback) { | |
widget.callback(widget.value, app.canvas, node, pos, event) | |
} | |
} | |
export const getNamedWidget = (node, ...names) => { | |
const out = {} | |
for (const name of names) { | |
out[name] = node.widgets.find((w) => w.name === name) | |
} | |
return out | |
} | |
/** | |
* @param {LGraphNode} node | |
* @param {LLink} link | |
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}} | |
*/ | |
export const nodesFromLink = (node, link) => { | |
const fromNode = app.graph.getNodeById(link.origin_id) | |
const toNode = app.graph.getNodeById(link.target_id) | |
let tp = 'error' | |
if (fromNode.id === node.id) { | |
tp = 'outgoing' | |
} else if (toNode.id === node.id) { | |
tp = 'incoming' | |
} | |
return { to: toNode, from: fromNode, type: tp } | |
} | |
export const hasWidgets = (node) => { | |
if (!node.widgets || !node.widgets?.[Symbol.iterator]) { | |
return false | |
} | |
return true | |
} | |
export const cleanupNode = (node) => { | |
if (!hasWidgets(node)) { | |
return | |
} | |
for (const w of node.widgets) { | |
if (w.canvas) { | |
w.canvas.remove() | |
} | |
if (w.inputEl) { | |
w.inputEl.remove() | |
} | |
// calls the widget remove callback | |
w.onRemoved?.() | |
} | |
} | |
export function offsetDOMWidget( | |
widget, | |
ctx, | |
node, | |
widgetWidth, | |
widgetY, | |
height, | |
) { | |
const margin = 10 | |
const elRect = ctx.canvas.getBoundingClientRect() | |
const transform = new DOMMatrix() | |
.scaleSelf( | |
elRect.width / ctx.canvas.width, | |
elRect.height / ctx.canvas.height, | |
) | |
.multiplySelf(ctx.getTransform()) | |
.translateSelf(margin, margin + widgetY) | |
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d) | |
Object.assign(widget.inputEl.style, { | |
transformOrigin: '0 0', | |
transform: scale, | |
left: `${transform.a + transform.e}px`, | |
top: `${transform.d + transform.f}px`, | |
width: `${widgetWidth - margin * 2}px`, | |
// height: `${(widget.parent?.inputHeight || 32) - (margin * 2)}px`, | |
height: `${(height || widget.parent?.inputHeight || 32) - margin * 2}px`, | |
position: 'absolute', | |
background: !node.color ? '' : node.color, | |
color: !node.color ? '' : 'white', | |
zIndex: 5, //app.graph._nodes.indexOf(node), | |
}) | |
} | |
/** | |
* Extracts the type and link type from a widget config object. | |
* @param {*} config | |
* @returns | |
*/ | |
export function getWidgetType(config) { | |
// Special handling for COMBO so we restrict links based on the entries | |
let type = config?.[0] | |
let linkType = type | |
if (Array.isArray(type)) { | |
type = 'COMBO' | |
linkType = linkType.join(',') | |
} | |
return { type, linkType } | |
} | |
// #endregion | |
// #region dynamic connections | |
/** | |
* @param {NodeType} nodeType The nodetype to attach the documentation to | |
* @param {str} prefix A prefix added to each dynamic inputs | |
* @param {str | [str]} inputType The datatype(s) of those dynamic inputs | |
* @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} opts | |
* @returns | |
*/ | |
export const setupDynamicConnections = (nodeType, prefix, inputType, opts) => { | |
infoLogger( | |
'Setting up dynamic connections for', | |
Object.getOwnPropertyDescriptors(nodeType).title.value, | |
) | |
/** @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} */ | |
const options = opts || {} | |
const onNodeCreated = nodeType.prototype.onNodeCreated | |
const inputList = typeof inputType === 'object' | |
nodeType.prototype.onNodeCreated = function () { | |
const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined | |
this.addInput(`${prefix}_1`, inputList ? '*' : inputType) | |
return r | |
} | |
const onConnectionsChange = nodeType.prototype.onConnectionsChange | |
/** | |
* @param {OnConnectionsChangeParams} args | |
*/ | |
nodeType.prototype.onConnectionsChange = function (...args) { | |
const [type, slotIndex, isConnected, link, ioSlot] = args | |
options.link = link | |
options.ioSlot = ioSlot | |
const r = onConnectionsChange | |
? onConnectionsChange.apply(this, [ | |
type, | |
slotIndex, | |
isConnected, | |
link, | |
ioSlot, | |
]) | |
: undefined | |
options.DEBUG = { | |
node: this, | |
type, | |
slotIndex, | |
isConnected, | |
link, | |
ioSlot, | |
} | |
dynamic_connection( | |
this, | |
slotIndex, | |
isConnected, | |
`${prefix}_`, | |
inputType, | |
options, | |
) | |
return r | |
} | |
} | |
/** | |
* Main logic around dynamic inputs | |
* | |
* @param {LGraphNode} node - The target node | |
* @param {number} index - The slot index of the currently changed connection | |
* @param {bool} connected - Was this event connecting or disconnecting | |
* @param {string} [connectionPrefix] - The common prefix of the dynamic inputs | |
* @param {string|[string]} [connectionType] - The type of the dynamic connection | |
* @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options | |
*/ | |
export const dynamic_connection = ( | |
node, | |
index, | |
connected, | |
connectionPrefix = 'input_', | |
connectionType = '*', | |
opts = undefined, | |
) => { | |
/* @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options*/ | |
const options = opts || {} | |
if ( | |
node.inputs.length > 0 && | |
!node.inputs[index].name.startsWith(connectionPrefix) | |
) { | |
return | |
} | |
const listConnection = typeof connectionType === 'object' | |
const conType = listConnection ? '*' : connectionType | |
const nameArray = options.nameArray || [] | |
const clean_inputs = () => { | |
if (node.inputs.length === 0) return | |
let w_count = node.widgets?.length || 0 | |
let i_count = node.inputs?.length || 0 | |
infoLogger(`Cleaning inputs: [BEFORE] (w: ${w_count} | inputs: ${i_count})`) | |
const to_remove = [] | |
for (let n = 1; n < node.inputs.length; n++) { | |
const element = node.inputs[n] | |
if (!element.link) { | |
if (node.widgets) { | |
const w = node.widgets.find((w) => w.name === element.name) | |
if (w) { | |
w.onRemoved?.() | |
node.widgets.length = node.widgets.length - 1 | |
} | |
} | |
infoLogger(`Removing input ${n}`) | |
to_remove.push(n) | |
} | |
} | |
for (let i = 0; i < to_remove.length; i++) { | |
const id = to_remove[i] | |
node.removeInput(id) | |
i_count -= 1 | |
} | |
node.inputs.length = i_count | |
w_count = node.widgets?.length || 0 | |
i_count = node.inputs?.length || 0 | |
infoLogger(`Cleaning inputs: [AFTER] (w: ${w_count} | inputs: ${i_count})`) | |
infoLogger('Cleaning inputs: making it sequential again') | |
// make inputs sequential again | |
for (let i = 0; i < node.inputs.length; i++) { | |
let name = `${connectionPrefix}${i + 1}` | |
if (nameArray.length > 0) { | |
name = i < nameArray.length ? nameArray[i] : name | |
} | |
node.inputs[i].label = name | |
node.inputs[i].name = name | |
} | |
} | |
if (!connected) { | |
if (!options.link) { | |
infoLogger('Disconnecting', { options }) | |
clean_inputs() | |
} else { | |
if (!options.ioSlot.link) { | |
node.connectionTransit = true | |
} else { | |
node.connectionTransit = false | |
clean_inputs() | |
} | |
infoLogger('Reconnecting', { options }) | |
} | |
} | |
if (connected) { | |
if (options.link) { | |
const { from, to, type } = nodesFromLink(node, options.link) | |
if (type === 'outgoing') return | |
infoLogger('Connecting', { options, from, to, type }) | |
} else { | |
infoLogger('Connecting', { options }) | |
} | |
if (node.connectionTransit) { | |
infoLogger('In Transit') | |
node.connectionTransit = false | |
} | |
// Remove inputs and their widget if not linked. | |
clean_inputs() | |
if (node.inputs.length === 0) return | |
// add an extra input | |
if (node.inputs[node.inputs.length - 1].link !== null) { | |
const nextIndex = node.inputs.length | |
const name = | |
nextIndex < nameArray.length | |
? nameArray[nextIndex] | |
: `${connectionPrefix}${nextIndex + 1}` | |
infoLogger(`Adding input ${nextIndex + 1} (${name})`) | |
node.addInput(name, conType) | |
} | |
} | |
} | |
// #endregion | |
// #region color utils | |
export function isColorBright(rgb, threshold = 240) { | |
const brightess = getBrightness(rgb) | |
return brightess > threshold | |
} | |
function getBrightness(rgbObj) { | |
return Math.round( | |
(Number.parseInt(rgbObj[0]) * 299 + | |
Number.parseInt(rgbObj[1]) * 587 + | |
Number.parseInt(rgbObj[2]) * 114) / | |
1000, | |
) | |
} | |
// #endregion | |
// #region html/css utils | |
/** | |
* Calculate total height of DOM element child | |
* | |
* @param {HTMLElement} parentElement - The target dom element | |
* @returns {number} the total height | |
*/ | |
export function calculateTotalChildrenHeight(parentElement) { | |
let totalHeight = 0 | |
for (const child of parentElement.children) { | |
const style = window.getComputedStyle(child) | |
// Get height as an integer (without 'px') | |
const height = Number.parseInt(style.height, 10) | |
// Get vertical margin as integers | |
const marginTop = Number.parseInt(style.marginTop, 10) | |
const marginBottom = Number.parseInt(style.marginBottom, 10) | |
// Sum up height and vertical margins | |
totalHeight += height + marginTop + marginBottom | |
} | |
return totalHeight | |
} | |
export const loadScript = ( | |
FILE_URL, | |
async = true, | |
type = 'text/javascript', | |
) => { | |
return new Promise((resolve, reject) => { | |
try { | |
// Check if the script already exists | |
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`) | |
if (existingScript) { | |
resolve({ status: true, message: 'Script already loaded' }) | |
return | |
} | |
const scriptEle = document.createElement('script') | |
scriptEle.type = type | |
scriptEle.async = async | |
scriptEle.src = FILE_URL | |
scriptEle.addEventListener('load', (_ev) => { | |
resolve({ status: true }) | |
}) | |
scriptEle.addEventListener('error', (_ev) => { | |
reject({ | |
status: false, | |
message: `Failed to load the script ${FILE_URL}`, | |
}) | |
}) | |
document.body.appendChild(scriptEle) | |
} catch (error) { | |
reject(error) | |
} | |
}) | |
} | |
// #endregion | |
// #region documentation widget | |
const create_documentation_stylesheet = () => { | |
const tag = 'mtb-documentation-stylesheet' | |
let styleTag = document.head.querySelector(tag) | |
if (!styleTag) { | |
styleTag = document.createElement('style') | |
styleTag.type = 'text/css' | |
styleTag.id = tag | |
styleTag.innerHTML = ` | |
.documentation-popup { | |
background: var(--comfy-menu-bg); | |
position: absolute; | |
color: var(--fg-color); | |
font: 12px monospace; | |
line-height: 1.5em; | |
padding: 10px; | |
border-radius: 6px; | |
pointer-events: "inherit"; | |
z-index: 5; | |
overflow: hidden; | |
} | |
.documentation-wrapper { | |
padding: 0 2em; | |
overflow: auto; | |
max-height: 100%; | |
/* Scrollbar styling for Chrome */ | |
&::-webkit-scrollbar { | |
width: 6px; | |
} | |
&::-webkit-scrollbar-track { | |
background: var(--bg-color); | |
} | |
&::-webkit-scrollbar-thumb { | |
background-color: var(--fg-color); | |
border-radius: 6px; | |
border: 3px solid var(--bg-color); | |
} | |
/* Scrollbar styling for Firefox */ | |
scrollbar-width: thin; | |
scrollbar-color: var(--fg-color) var(--bg-color); | |
a { | |
color: yellow; | |
} | |
a:visited { | |
color: orange; | |
} | |
a:hover { | |
color: red; | |
} | |
} | |
.documentation-popup img { | |
max-width: 100%; | |
} | |
.documentation-popup table { | |
border-collapse: collapse; | |
border: 1px var(--border-color) solid; | |
} | |
.documentation-popup th, | |
.documentation-popup td { | |
border: 1px var(--border-color) solid; | |
} | |
.documentation-popup th { | |
background-color: var(--comfy-input-bg); | |
}` | |
document.head.appendChild(styleTag) | |
} | |
} | |
let parserPromise | |
const callbackQueue = [] | |
function runQueuedCallbacks() { | |
while (callbackQueue.length) { | |
const cb = callbackQueue.shift() | |
cb(window.MTB.mdParser) | |
} | |
} | |
function loadParser(shiki) { | |
if (!parserPromise) { | |
parserPromise = import( | |
shiki | |
? '/mtb_async/mtb_markdown_plus.umd.js' | |
: '/mtb_async/mtb_markdown.umd.js' | |
) | |
.then((_module) => | |
shiki ? MTBMarkdownPlus.getParser() : MTBMarkdown.getParser(), | |
) | |
.then((instance) => { | |
window.MTB.mdParser = instance | |
runQueuedCallbacks() | |
return instance | |
}) | |
.catch((error) => { | |
console.error('Error loading the parser:', error) | |
}) | |
} | |
return parserPromise | |
} | |
export const ensureMarkdownParser = async (callback) => { | |
infoLogger('Ensuring md parser') | |
let use_shiki = false | |
try { | |
use_shiki = await api.getSetting('mtb.Use Shiki') | |
} catch (e) { | |
console.warn('Option not available yet', e) | |
} | |
if (window.MTB?.mdParser) { | |
infoLogger('Markdown parser found') | |
callback?.(window.MTB.mdParser) | |
return window.MTB.mdParser | |
} | |
if (!parserPromise) { | |
infoLogger('Running promise to fetch parser') | |
try { | |
loadParser(use_shiki) //.then(() => { | |
// callback?.(window.MTB.mdParser) | |
// }) | |
} catch (error) { | |
console.error('Error loading the parser:', error) | |
} | |
} else { | |
infoLogger('A similar promise is already running, waiting for it to finish') | |
} | |
if (callback) { | |
callbackQueue.push(callback) | |
} | |
await parserPromise | |
await parserPromise | |
return window.MTB.mdParser | |
} | |
/** | |
* Add documentation widget to the given node. | |
* | |
* This method will add a `docCtrl` property to the node | |
* that contains the AbortController that manages all the events | |
* defined inside it (global and instance ones) without explicit | |
* cleanup method for each. | |
* | |
* @param {NodeData} nodeData | |
* @param {NodeType} nodeType | |
* @param {DocumentationOptions} opts | |
*/ | |
export const addDocumentation = ( | |
nodeData, | |
nodeType, | |
opts = { icon_size: 14, icon_margin: 4 }, | |
) => { | |
if (!nodeData.description) { | |
infoLogger( | |
`Skipping ${nodeData.name} doesn't have a description, skipping...`, | |
) | |
return | |
} | |
const options = opts || {} | |
const iconSize = options.icon_size || 14 | |
const iconMargin = options.icon_margin || 4 | |
let docElement = null | |
let wrapper = null | |
const onRem = nodeType.prototype.onRemoved | |
nodeType.prototype.onRemoved = function () { | |
const r = onRem ? onRem.apply(this, []) : undefined | |
if (docElement) { | |
docElement.remove() | |
docElement = null | |
} | |
if (wrapper) { | |
wrapper.remove() | |
wrapper = null | |
} | |
return r | |
} | |
const drawFg = nodeType.prototype.onDrawForeground | |
/** | |
* @param {OnDrawForegroundParams} args | |
*/ | |
nodeType.prototype.onDrawForeground = function (...args) { | |
const [ctx, _canvas] = args | |
const r = drawFg ? drawFg.apply(this, args) : undefined | |
if (this.flags.collapsed) return r | |
// icon position | |
const x = this.size[0] - iconSize - iconMargin | |
let resizeHandle | |
// create it | |
if (this.show_doc && docElement === null) { | |
create_documentation_stylesheet() | |
docElement = document.createElement('div') | |
docElement.classList.add('documentation-popup') | |
document.body.appendChild(docElement) | |
wrapper = document.createElement('div') | |
wrapper.classList.add('documentation-wrapper') | |
docElement.appendChild(wrapper) | |
// wrapper.innerHTML = documentationConverter.makeHtml(nodeData.description) | |
ensureMarkdownParser().then(() => { | |
MTB.mdParser.parse(nodeData.description).then((e) => { | |
wrapper.innerHTML = e | |
// resize handle | |
resizeHandle = document.createElement('div') | |
resizeHandle.classList.add('doc-resize-handle') | |
resizeHandle.style.width = '0' | |
resizeHandle.style.height = '0' | |
resizeHandle.style.position = 'absolute' | |
resizeHandle.style.bottom = '0' | |
resizeHandle.style.right = '0' | |
resizeHandle.style.cursor = 'se-resize' | |
resizeHandle.style.userSelect = 'none' | |
resizeHandle.style.borderWidth = '15px' | |
resizeHandle.style.borderStyle = 'solid' | |
resizeHandle.style.borderColor = | |
'transparent var(--border-color) var(--border-color) transparent' | |
wrapper.appendChild(resizeHandle) | |
let isResizing = false | |
let startX | |
let startY | |
let startWidth | |
let startHeight | |
resizeHandle.addEventListener( | |
'mousedown', | |
(e) => { | |
e.stopPropagation() | |
isResizing = true | |
startX = e.clientX | |
startY = e.clientY | |
startWidth = Number.parseInt( | |
document.defaultView.getComputedStyle(docElement).width, | |
10, | |
) | |
startHeight = Number.parseInt( | |
document.defaultView.getComputedStyle(docElement).height, | |
10, | |
) | |
}, | |
{ signal: this.docCtrl.signal }, | |
) | |
document.addEventListener( | |
'mousemove', | |
(e) => { | |
if (!isResizing) return | |
const scale = app.canvas.ds.scale | |
const newWidth = startWidth + (e.clientX - startX) / scale | |
const newHeight = startHeight + (e.clientY - startY) / scale | |
docElement.style.width = `${newWidth}px` | |
docElement.style.height = `${newHeight}px` | |
this.docPos = { | |
width: `${newWidth}px`, | |
height: `${newHeight}px`, | |
} | |
}, | |
{ signal: this.docCtrl.signal }, | |
) | |
document.addEventListener( | |
'mouseup', | |
() => { | |
isResizing = false | |
}, | |
{ signal: this.docCtrl.signal }, | |
) | |
}) | |
}) | |
} else if (!this.show_doc && docElement !== null) { | |
docElement.remove() | |
docElement = null | |
} | |
// reposition | |
if (this.show_doc && docElement !== null) { | |
const rect = ctx.canvas.getBoundingClientRect() | |
const dpi = Math.max(1.0, window.devicePixelRatio) | |
const scaleX = rect.width / ctx.canvas.width | |
const scaleY = rect.height / ctx.canvas.height | |
const transform = new DOMMatrix() | |
.scaleSelf(scaleX, scaleY) | |
.multiplySelf(ctx.getTransform()) | |
.translateSelf(this.size[0] * scaleX * dpi, 0) | |
.translateSelf(10, -32) | |
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d) | |
Object.assign(docElement.style, { | |
transformOrigin: '0 0', | |
transform: scale, | |
left: `${transform.a + rect.x + transform.e}px`, | |
top: `${transform.d + rect.y + transform.f}px`, | |
width: this.docPos ? this.docPos.width : `${this.size[0] * 1.5}px`, | |
height: this.docPos?.height, | |
}) | |
if (this.docPos === undefined) { | |
this.docPos = { | |
width: docElement.style.width, | |
height: docElement.style.height, | |
} | |
} | |
} | |
ctx.save() | |
ctx.translate(x, iconSize - 34) | |
ctx.scale(iconSize / 32, iconSize / 32) | |
ctx.strokeStyle = 'rgba(255,255,255,0.3)' | |
ctx.lineCap = 'round' | |
ctx.lineJoin = 'round' | |
ctx.lineWidth = 2.4 | |
ctx.font = 'bold 36px monospace' | |
ctx.fillText('?', 0, 24) | |
// ctx.font = `bold ${this.show_doc ? 36 : 24}px monospace` | |
// ctx.fillText(`${this.show_doc ? '▼' : '▶'}`, 24, 24) | |
ctx.restore() | |
return r | |
} | |
const mouseDown = nodeType.prototype.onMouseDown | |
/** | |
* @param {OnMouseDownParams} args | |
*/ | |
nodeType.prototype.onMouseDown = function (...args) { | |
const [_event, localPos, _graphCanvas] = args | |
const r = mouseDown ? mouseDown.apply(this, args) : undefined | |
const iconX = this.size[0] - iconSize - iconMargin | |
const iconY = iconSize - 34 | |
if ( | |
localPos[0] > iconX && | |
localPos[0] < iconX + iconSize && | |
localPos[1] > iconY && | |
localPos[1] < iconY + iconSize | |
) { | |
// Pencil icon was clicked, open the editor | |
// this.openEditorDialog(); | |
if (this.show_doc === undefined) { | |
this.show_doc = true | |
} else { | |
this.show_doc = !this.show_doc | |
} | |
if (this.show_doc) { | |
this.docCtrl = new AbortController() | |
} else { | |
this.docCtrl.abort() | |
} | |
return true // Return true to indicate the event was handled | |
} | |
return r // Return false to let the event propagate | |
// return r; | |
} | |
} | |
// #endregion | |
// #region node extensions | |
/** | |
* Extend an object, either replacing the original property or extending it. | |
* @param {Object} object - The object to which the property belongs. | |
* @param {string} property - The name of the property to chain the callback to. | |
* @param {Function} callback - The callback function to be chained. | |
*/ | |
export function extendPrototype(object, property, callback) { | |
if (object === undefined) { | |
console.error('Could not extend undefined object', { object, property }) | |
return | |
} | |
if (property in object) { | |
const callback_orig = object[property] | |
object[property] = function (...args) { | |
const r = callback_orig.apply(this, args) | |
callback.apply(this, args) | |
return r | |
} | |
} else { | |
object[property] = callback | |
} | |
} | |
/** | |
* Appends a callback to the extra menu options of a given node type. | |
* @param {NodeType} nodeType | |
* @param {(app,options) => ContextMenuItem[]} cb | |
*/ | |
export function addMenuHandler(nodeType, cb) { | |
const getOpts = nodeType.prototype.getExtraMenuOptions | |
/** | |
* @returns {ContextMenuItem[]} items | |
*/ | |
nodeType.prototype.getExtraMenuOptions = function (app, options) { | |
const r = getOpts.apply(this, [app, options]) || [] | |
const newItems = cb.apply(this, [app, options]) || [] | |
return [...r, ...newItems] | |
} | |
} | |
/** Prefixes the node title with '[DEPRECATED]' and log the deprecation reason to the console.*/ | |
export const addDeprecation = (nodeType, reason) => { | |
const title = nodeType.title | |
nodeType.title = `[DEPRECATED] ${title}` | |
// console.log(nodeType) | |
const styles = { | |
title: 'font-size:1.3em;font-weight:900;color:yellow; background: black', | |
reason: 'font-size:1.2em', | |
} | |
console.log( | |
`%c! ${title} is deprecated:%c ${reason}`, | |
styles.title, | |
styles.reason, | |
) | |
} | |
// #endregion | |
// #region API / graph utilities | |
export const getAPIInputs = () => { | |
const inputs = {} | |
let counter = 1 | |
for (const node of getNodes(true)) { | |
const widgets = node.widgets | |
if (node.properties.mtb_api && node.properties.useAPI) { | |
if (node.properties.mtb_api.inputs) { | |
for (const currentName in node.properties.mtb_api.inputs) { | |
const current = node.properties.mtb_api.inputs[currentName] | |
if (current.enabled) { | |
const inputName = current.name || currentName | |
const widget = widgets.find((w) => w.name === currentName) | |
if (!widget) continue | |
if (!(inputName in inputs)) { | |
inputs[inputName] = { | |
...current, | |
id: counter, | |
name: inputName, | |
type: current.type, | |
node_id: node.id, | |
widgets: [], | |
} | |
} | |
inputs[inputName].widgets.push(widget) | |
counter = counter + 1 | |
} | |
} | |
} | |
} | |
} | |
return inputs | |
} | |
export const getNodes = (skip_unused) => { | |
const nodes = [] | |
for (const outerNode of app.graph.computeExecutionOrder(false)) { | |
const skipNode = | |
(outerNode.mode === 2 || outerNode.mode === 4) && skip_unused | |
const innerNodes = | |
!skipNode && outerNode.getInnerNodes | |
? outerNode.getInnerNodes() | |
: [outerNode] | |
for (const node of innerNodes) { | |
if ((node.mode === 2 || node.mode === 4) && skip_unused) { | |
continue | |
} | |
nodes.push(node) | |
} | |
} | |
return nodes | |
} | |