Finished node canvas UI tweaks. Failed to add connection highlighting

This commit is contained in:
Richard Osborne
2026-01-01 16:11:21 +01:00
parent 2e46ab7ea7
commit cfaf78fb15
23 changed files with 14880 additions and 285 deletions

View File

@@ -74,69 +74,69 @@
/* ---------------------------------------------------------------------------
NODE TYPE COLORS
Subtle variations to distinguish node types on canvas
Using desaturated colors so they don't compete with the red accent
Modern, vibrant colors to distinguish node types on canvas
Brightened and more saturated for better visual distinction
--------------------------------------------------------------------------- */
/* Node-Pink - For Custom/User nodes */
--base-color-node-pink-100: #fdf2f8;
--base-color-node-pink-200: #f5d0e5;
--base-color-node-pink-300: #e8a8ca;
--base-color-node-pink-400: #d87caa;
--base-color-node-pink-500: #c2578a;
--base-color-node-pink-600: #a63d6f;
--base-color-node-pink-700: #862d56;
--base-color-node-pink-800: #6b2445;
--base-color-node-pink-900: #521c35;
--base-color-node-pink-1000: #2d0e1c;
/* Node-Pink - For Custom/User nodes - Vibrant Pink */
--base-color-node-pink-100: #fef2f8;
--base-color-node-pink-200: #fce0ed;
--base-color-node-pink-300: #f9b5d8;
--base-color-node-pink-400: #f58ac3;
--base-color-node-pink-500: #ec5ca8;
--base-color-node-pink-600: #d63a8e;
--base-color-node-pink-700: #b02872;
--base-color-node-pink-800: #8a1f5b;
--base-color-node-pink-900: #641746;
--base-color-node-pink-1000: #3a0d28;
/* Node-Purple - For Component nodes */
--base-color-node-purple-100: #f8f5fa;
--base-color-node-purple-200: #e8dff0;
--base-color-node-purple-300: #d4c4e3;
--base-color-node-purple-400: #b8a0cf;
--base-color-node-purple-500: #9a7bb8;
--base-color-node-purple-600: #7d5a9e;
--base-color-node-purple-700: #624382;
--base-color-node-purple-800: #4b3366;
--base-color-node-purple-900: #37264b;
--base-color-node-purple-1000: #1e1429;
/* Node-Purple - For Component nodes - Vibrant Purple */
--base-color-node-purple-100: #faf7fc;
--base-color-node-purple-200: #f0e8f8;
--base-color-node-purple-300: #ddc5ef;
--base-color-node-purple-400: #c99ee5;
--base-color-node-purple-500: #b176db;
--base-color-node-purple-600: #9854c6;
--base-color-node-purple-700: #7d3da5;
--base-color-node-purple-800: #612e82;
--base-color-node-purple-900: #46215e;
--base-color-node-purple-1000: #291436;
/* Node-Green - For Data nodes */
--base-color-node-green-100: #f4f7f4;
--base-color-node-green-200: #d8e5d8;
--base-color-node-green-300: #b5cfb5;
--base-color-node-green-400: #8eb58e;
--base-color-node-green-500: #6a996a;
--base-color-node-green-600: #4d7d4d;
--base-color-node-green-700: #3a613a;
--base-color-node-green-800: #2c4a2c;
--base-color-node-green-900: #203520;
--base-color-node-green-1000: #111c11;
/* Node-Green - For Data nodes - Vibrant Green */
--base-color-node-green-100: #f2fdf2;
--base-color-node-green-200: #ddf5dd;
--base-color-node-green-300: #b5e8b5;
--base-color-node-green-400: #8ddb8d;
--base-color-node-green-500: #5fcb5f;
--base-color-node-green-600: #3db83d;
--base-color-node-green-700: #2d9a2d;
--base-color-node-green-800: #227822;
--base-color-node-green-900: #185618;
--base-color-node-green-1000: #0d2f0d;
/* Node-Gray - For Logic nodes */
--base-color-node-grey-100: #f5f5f5;
--base-color-node-grey-200: #e0e0e0;
--base-color-node-grey-300: #c2c2c2;
--base-color-node-grey-400: #9e9e9e;
--base-color-node-grey-500: #757575;
--base-color-node-grey-600: #5c5c5c;
--base-color-node-grey-700: #454545;
--base-color-node-grey-800: #333333;
--base-color-node-grey-900: #212121;
--base-color-node-grey-1000: #0d0d0d;
/* Node-Gray - For Logic nodes - Slightly warmer gray */
--base-color-node-grey-100: #f8f8f8;
--base-color-node-grey-200: #e8e8e8;
--base-color-node-grey-300: #cccccc;
--base-color-node-grey-400: #acacac;
--base-color-node-grey-500: #888888;
--base-color-node-grey-600: #6a6a6a;
--base-color-node-grey-700: #525252;
--base-color-node-grey-800: #3a3a3a;
--base-color-node-grey-900: #262626;
--base-color-node-grey-1000: #141414;
/* Node-Blue - For Visual nodes */
--base-color-node-blue-100: #f4f6f8;
--base-color-node-blue-200: #dce3eb;
--base-color-node-blue-300: #bccad9;
--base-color-node-blue-400: #96adc2;
--base-color-node-blue-500: #7090a9;
--base-color-node-blue-600: #53758f;
--base-color-node-blue-700: #3e5a72;
--base-color-node-blue-800: #2f4557;
--base-color-node-blue-900: #22323f;
--base-color-node-blue-1000: #121b22;
/* Node-Blue - For Visual nodes - Vibrant Blue */
--base-color-node-blue-100: #f3f8fc;
--base-color-node-blue-200: #deeaf7;
--base-color-node-blue-300: #b8d7ee;
--base-color-node-blue-400: #91c3e5;
--base-color-node-blue-500: #62aed9;
--base-color-node-blue-600: #3d96ca;
--base-color-node-blue-700: #2c7aac;
--base-color-node-blue-800: #21608a;
--base-color-node-blue-900: #174563;
--base-color-node-blue-1000: #0d2638;
/* ---------------------------------------------------------------------------
LEGACY ALIASES - For backwards compatibility

View File

@@ -605,6 +605,45 @@ export class NodeGraphNode extends Model {
}
}
// Get the comment text for this node
getComment(): string | undefined {
return this.metadata?.comment;
}
// Check if this node has a comment
hasComment(): boolean {
return !!this.metadata?.comment?.trim();
}
// Set or clear the comment for this node, supports undo
setComment(comment: string | undefined, args?: { undo?: any; label?: any }) {
const _this = this;
const oldComment = this.getComment();
if (!this.metadata) this.metadata = {};
// Store trimmed comment or undefined if empty
this.metadata.comment = comment?.trim() || undefined;
// Notify listeners of the change
this.notifyListeners('commentChanged', { comment: this.metadata.comment });
// Undo support
if (args && args.undo) {
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
undo.push({
label: args.label || 'Edit node comment',
do: function () {
_this.setComment(comment);
},
undo: function () {
_this.setComment(oldComment);
}
});
}
}
// Set a parameter for the node instance
setParameter(name: string, value, args?) {
const _this = this;

View File

@@ -536,3 +536,122 @@
.confirm-modal .cancel-button:hover {
background-color: var(--theme-color-bg-3);
}
/* String Input Popup */
.string-input-popup {
padding: 16px;
width: 500px;
min-width: 400px;
max-width: 600px;
}
.string-input-popup-label {
display: block;
color: var(--theme-color-fg-default);
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.string-input-popup-editor-wrapper {
position: relative;
display: flex;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
overflow: hidden;
/* Fixed height to prevent modal from growing */
height: 200px;
max-height: 200px;
}
.string-input-popup-line-numbers {
flex-shrink: 0;
/* Match textarea padding exactly for alignment */
padding: 12px 8px 12px 12px;
background-color: var(--theme-color-bg-2);
border-right: 1px solid var(--theme-color-border-default);
color: var(--theme-color-fg-muted);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
font-size: 13px;
line-height: 20.8px;
text-align: right;
user-select: none;
pointer-events: none;
min-width: 36px;
/* Allow scroll sync - hide scrollbar but allow scrollTop changes */
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
white-space: pre;
}
/* Hide scrollbar for line numbers in WebKit browsers */
.string-input-popup-line-numbers::-webkit-scrollbar {
display: none;
}
.string-input-popup-textarea {
flex: 1;
/* Fill the fixed-height wrapper */
height: 100%;
padding: 12px;
background-color: transparent;
border: none;
border-radius: 0;
color: var(--theme-color-fg-default);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
font-size: 13px;
line-height: 20.8px;
resize: none;
box-sizing: border-box;
outline: none;
overflow-y: auto;
}
.string-input-popup-textarea:focus {
outline: none;
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
}
.string-input-popup-textarea::placeholder {
color: var(--theme-color-fg-muted);
font-style: italic;
}
.string-input-popup-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.string-input-popup-button-ok,
.string-input-popup-button-cancel {
padding: 8px 20px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
}
.string-input-popup-button-ok {
background-color: var(--theme-color-primary);
color: var(--theme-color-bg-2);
}
.string-input-popup-button-ok:hover {
background-color: var(--theme-color-primary-hover);
}
.string-input-popup-button-cancel {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default);
}
.string-input-popup-button-cancel:hover {
background-color: var(--theme-color-bg-5);
}

View File

@@ -1,8 +1,13 @@
<!-- prettier-ignore -->
<!-- IMPORTANT: This is a LEGACY HTML TEMPLATE, not JSX! Use 'class' not 'className' -->
<div class="string-input-popup">
<label class="string-input-popup-label" data-text="label"></label>
<input class="string-input-popup-input sidebar-input"></input>
<div class="string-input-popup-editor-wrapper">
<div class="string-input-popup-line-numbers" aria-hidden="true"></div>
<textarea class="string-input-popup-input string-input-popup-textarea sidebar-input" rows="8" placeholder="// Add your comment here..." spellcheck="false"></textarea>
</div>
<div class="string-input-popup-buttons">
<button class="string-input-popup-button-ok" data-click="onOkClicked" data-text="okLabel"></button>
<button class="string-input-popup-button-cancel" data-click="onCancelClicked" data-text="cancelLabel"></button>
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { ProjectModel } from '../../models/projectmodel';
import { ViewerConnection } from '../../ViewerConnection';
import { NodeGraphEditor } from '../nodegrapheditor';
import PopupLayer from '../popuplayer';
import { fillRoundRect, roundRect, strokeRoundRect, truncateText } from './canvasHelpers';
import { NodeGraphEditorConnection } from './NodeGraphEditorConnection';
function _getColorForAnnotation(annotation) {
@@ -288,6 +289,7 @@ export class NodeGraphEditorNode {
public static readonly attachedThreshold = 20;
public static readonly propertyConnectionHeight = 20;
public static readonly verticalSpacing = 8;
public static readonly cornerRadius = 6;
model: NodeGraphNode;
x: number;
@@ -315,6 +317,8 @@ export class NodeGraphEditorNode {
iconSize: number;
iconRotation: number;
commentIconBounds: { x: number; y: number; width: number; height: number } | undefined;
constructor(model) {
this.model = model;
this.x = model.x || 0;
@@ -519,6 +523,16 @@ export class NodeGraphEditorNode {
PopupLayer.instance.hideTooltip();
if (this.owner.highlighted === this) {
// Check if clicking on comment icon
const inCommentIcon = this.commentIconBounds && this.isPointInCommentIcon(pos);
if (inCommentIcon) {
// Show comment edit prompt
this.showCommentEditPopup();
evt.stopPropagation && evt.stopPropagation();
return;
}
if (this.borderHighlighted || this.connectionDragAreaHighlighted) {
// User starts dragging from the border or connection area with circle icon
this.owner.startDraggingConnection(this);
@@ -615,19 +629,17 @@ export class NodeGraphEditorNode {
ctx.textBaseline = 'middle';
ctx.save();
// Clip
ctx.beginPath();
ctx.rect(x, y, this.nodeSize.width, this.nodeSize.height);
// Clip to rounded rectangle
roundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NodeGraphEditorNode.cornerRadius);
ctx.clip();
// Bg
// Bg - Use rounded rectangle for modern appearance
ctx.fillStyle = nc.header;
ctx.fillRect(x, y, this.nodeSize.width, this.nodeSize.height);
fillRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NodeGraphEditorNode.cornerRadius);
const titlebarHeight = this.titlebarHeight();
// Darken plate
//ctx.globalAlpha = 0.07;
// Darken plate (body area below title)
ctx.fillStyle = nc.base;
ctx.fillRect(x, y + titlebarHeight, this.nodeSize.width, this.nodeSize.height - titlebarHeight);
@@ -637,7 +649,7 @@ export class NodeGraphEditorNode {
ctx.globalCompositeOperation = 'hard-light'; // additive blending looks better
ctx.globalAlpha = 0.19;
ctx.fillStyle = nc.text;
ctx.fillRect(x, y, this.nodeSize.width, this.nodeSize.height);
fillRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NodeGraphEditorNode.cornerRadius);
ctx.globalCompositeOperation = prevCompOperation;
ctx.globalAlpha = 1;
}
@@ -694,7 +706,7 @@ export class NodeGraphEditorNode {
// Title
ctx.fillStyle = nc.text;
ctx.font = '12px Inter-Regular';
ctx.font = '12px Inter-Medium';
ctx.textBaseline = 'top';
textWordWrap(
ctx,
@@ -711,7 +723,7 @@ export class NodeGraphEditorNode {
ctx.save();
ctx.fillStyle = nc.text;
ctx.globalAlpha = 0.65;
ctx.font = '12px Inter-Regular';
ctx.font = '12px Inter-Medium';
ctx.textBaseline = 'top';
textWordWrap(
ctx,
@@ -726,6 +738,85 @@ export class NodeGraphEditorNode {
ctx.restore();
}
// Draw comment icon (if node has comment OR is highlighted)
// Position on right side, before the node icon area if present
const hasComment = this.model.hasComment();
if (hasComment || isHighligthed) {
const commentIconSize = 14;
// Adjust offset based on whether node icon is present
// If icon exists, offset more to avoid overlap; if not, position closer to edge
const commentIconRightOffset = this.icon ? 30 : 10;
const commentIconX =
x + this.nodeSize.width - connectionDragAreaWidth - commentIconSize - commentIconRightOffset;
const commentIconY = y + titlebarHeight / 2 - commentIconSize / 2;
// Store bounds for click detection
this.commentIconBounds = {
x: commentIconX,
y: commentIconY,
width: commentIconSize,
height: commentIconSize
};
ctx.save();
// Set opacity based on whether comment exists
ctx.globalAlpha = hasComment ? 1.0 : 0.4;
ctx.fillStyle = nc.text;
ctx.strokeStyle = nc.text;
ctx.lineWidth = 1.5;
// Draw speech bubble (rounded rectangle)
const bubbleWidth = commentIconSize;
const bubbleHeight = commentIconSize * 0.8;
const bubbleRadius = 2;
// Main bubble body
ctx.beginPath();
ctx.moveTo(commentIconX + bubbleRadius, commentIconY);
ctx.lineTo(commentIconX + bubbleWidth - bubbleRadius, commentIconY);
ctx.quadraticCurveTo(
commentIconX + bubbleWidth,
commentIconY,
commentIconX + bubbleWidth,
commentIconY + bubbleRadius
);
ctx.lineTo(commentIconX + bubbleWidth, commentIconY + bubbleHeight - bubbleRadius);
ctx.quadraticCurveTo(
commentIconX + bubbleWidth,
commentIconY + bubbleHeight,
commentIconX + bubbleWidth - bubbleRadius,
commentIconY + bubbleHeight
);
// Draw tail (small triangle at bottom)
const tailWidth = 3;
const tailHeight = 3;
const tailX = commentIconX + bubbleWidth * 0.7;
ctx.lineTo(tailX + tailWidth, commentIconY + bubbleHeight);
ctx.lineTo(tailX, commentIconY + bubbleHeight + tailHeight);
ctx.lineTo(tailX - tailWidth / 2, commentIconY + bubbleHeight);
// Complete the bubble
ctx.lineTo(commentIconX + bubbleRadius, commentIconY + bubbleHeight);
ctx.quadraticCurveTo(
commentIconX,
commentIconY + bubbleHeight,
commentIconX,
commentIconY + bubbleHeight - bubbleRadius
);
ctx.lineTo(commentIconX, commentIconY + bubbleRadius);
ctx.quadraticCurveTo(commentIconX, commentIconY, commentIconX + bubbleRadius, commentIconY);
ctx.closePath();
ctx.stroke();
ctx.restore();
} else {
// Clear bounds when not visible
this.commentIconBounds = undefined;
}
ctx.restore(); // Restore clip so we can draw border
if (isHighligthed) {
@@ -745,16 +836,21 @@ export class NodeGraphEditorNode {
// );
}
// Border
// Border - Use rounded rectangles for modern appearance
const health = this.model.getHealth();
if (!health.healthy) {
ctx.setLineDash([5]);
ctx.lineWidth = 1;
ctx.strokeStyle = '#F57569';
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.rect(x - 1, y - 1, this.nodeSize.width + 2, this.nodeSize.height + 2);
ctx.stroke();
strokeRoundRect(
ctx,
x - 1,
y - 1,
this.nodeSize.width + 2,
this.nodeSize.height + 2,
NodeGraphEditorNode.cornerRadius + 1
);
ctx.setLineDash([]); // Restore line dash
ctx.globalAlpha = 1;
}
@@ -762,9 +858,7 @@ export class NodeGraphEditorNode {
if (this.selected || this.borderHighlighted || this.connectionDragAreaHighlighted) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x, y, this.nodeSize.width, this.nodeSize.height);
ctx.stroke();
strokeRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NodeGraphEditorNode.cornerRadius);
}
if (this.model.annotation) {
@@ -773,9 +867,7 @@ export class NodeGraphEditorNode {
else if (this.model.annotation === 'Created') ctx.strokeStyle = '#5BF59E';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x, y, this.nodeSize.width, this.nodeSize.height);
ctx.stroke();
strokeRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NodeGraphEditorNode.cornerRadius);
}
// Paint plugs
@@ -793,35 +885,61 @@ export class NodeGraphEditorNode {
}
function dot(side, color) {
const cx = x + (side === 'left' ? 0 : _this.nodeSize.width);
const radius = 6; // Back to normal size
// Draw main port indicator
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x + (side === 'left' ? 0 : _this.nodeSize.width), ty, 4, 0, 2 * Math.PI, false);
ctx.arc(cx, ty, radius, 0, 2 * Math.PI, false);
ctx.fill();
// Add subtle inner highlight for depth
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.beginPath();
ctx.arc(cx - 0.5, ty - 0.5, radius * 0.4, 0, 2 * Math.PI, false);
ctx.fill();
}
function drawPlugs(plugs, offset) {
ctx.font = '11px Inter-Regular';
ctx.font = '11px Inter-Medium';
ctx.textBaseline = 'middle';
ctx.globalAlpha = 1;
for (const i in plugs) {
const p = plugs[i];
// Label
// Calculate Y position for this port
ty = p.index * NodeGraphEditorNode.propertyConnectionHeight + offset;
if (p.loc === 'left' || p.loc === 'middle') tx = x + horizontalSpacing;
else if (p.loc === 'right') tx = x + _this.nodeSize.width - horizontalSpacing;
else tx = x + _this.nodeSize.width / 2;
// Draw labels at normal positions
if (p.loc === 'left' || p.loc === 'middle') {
// Left-aligned labels
tx = x + horizontalSpacing;
} else if (p.loc === 'right') {
// Right-aligned labels
tx = x + _this.nodeSize.width - horizontalSpacing;
} else {
tx = x + _this.nodeSize.width / 2;
}
ctx.fillStyle = nc.text;
ctx.textAlign = p.loc === 'right' ? 'right' : 'left';
ctx.fillText(p.displayName ? p.displayName : p.property, tx, ty);
// Truncate port labels to prevent overflow
const label = p.displayName ? p.displayName : p.property;
const portAreaWidth =
p.loc === 'middle'
? _this.nodeSize.width - 2 * horizontalSpacing
: _this.nodeSize.width - horizontalSpacing - 8;
const truncatedLabel = truncateText(ctx, label, portAreaWidth);
// Plug
if (p.leftCons.length) {
ctx.fillText(truncatedLabel, tx, ty);
// Plug - Left side
if (p.leftCons.length || p.leftIcon) {
var connectionColors = NodeLibrary.instance.colorSchemeForConnectionType(
NodeLibrary.nameForPortType(p.leftCons[0].fromPort ? p.leftCons[0].fromPort.type : undefined)
NodeLibrary.nameForPortType(p.leftCons[0]?.fromPort ? p.leftCons[0].fromPort.type : undefined)
);
var color = _.find(p.leftCons, function (p) {
return p.isHighlighted();
@@ -834,7 +952,7 @@ export class NodeGraphEditorNode {
return p.isHighlighted();
}) || p.leftCons[p.leftCons.length - 1];
if (topConnection.model.annotation) {
if (topConnection && topConnection.model.annotation) {
color = _getColorForAnnotation(topConnection.model.annotation);
}
@@ -845,9 +963,10 @@ export class NodeGraphEditorNode {
}
}
if (p.rightCons.length) {
// Plug - Right side
if (p.rightCons.length || p.rightIcon) {
connectionColors = NodeLibrary.instance.colorSchemeForConnectionType(
NodeLibrary.nameForPortType(p.rightCons[0].fromPort ? p.rightCons[0].fromPort.type : undefined)
NodeLibrary.nameForPortType(p.rightCons[0]?.fromPort ? p.rightCons[0].fromPort.type : undefined)
);
color = _.find(p.rightCons, function (p) {
return p.isHighlighted();
@@ -860,7 +979,7 @@ export class NodeGraphEditorNode {
return p.isHighlighted();
}) || p.rightCons[p.rightCons.length - 1];
if (topConnection.model.annotation) {
if (topConnection && topConnection.model.annotation) {
color = _getColorForAnnotation(topConnection.model.annotation);
}
@@ -1088,4 +1207,75 @@ export class NodeGraphEditorNode {
this.parent = undefined;
}
}
/**
* Check if a point (in local node coordinates) is within the comment icon bounds
*/
isPointInCommentIcon(pos: { x: number; y: number }): boolean {
if (!this.commentIconBounds) return false;
// Convert local pos to global for comparison with commentIconBounds (which are in global coords)
const globalX = pos.x + this.global.x;
const globalY = pos.y + this.global.y;
const bounds = this.commentIconBounds;
const padding = 4; // Extra hit area padding for easier clicking
return (
globalX >= bounds.x - padding &&
globalX <= bounds.x + bounds.width + padding &&
globalY >= bounds.y - padding &&
globalY <= bounds.y + bounds.height + padding
);
}
/**
* Show a popup for editing the node comment
*/
showCommentEditPopup() {
const currentComment = this.model.getComment() || '';
const nodeLabel = this.model.label || 'Node';
const model = this.model;
const owner = this.owner;
// Use PopupLayer.StringInputPopup for Electron compatibility
const popup = new PopupLayer.StringInputPopup({
label: `Comment for "${nodeLabel}"`,
okLabel: 'Save',
cancelLabel: 'Cancel',
onOk: (newComment: string) => {
// Set comment with undo support
model.setComment(newComment || undefined, {
undo: true,
label: newComment ? 'Edit node comment' : 'Remove node comment'
});
// Repaint to update the icon appearance
owner.repaint();
PopupLayer.instance.hidePopup();
},
onCancel: () => {
PopupLayer.instance.hidePopup();
}
});
// Render popup BEFORE showing it
popup.render();
// Set initial value after render
popup.$('.string-input-popup-input').val(currentComment);
// Use requestAnimationFrame + setTimeout to ensure we're past both the current
// event cycle AND any pending DOM updates. This prevents the PopupLayer's body
// click handler from immediately closing the popup.
requestAnimationFrame(() => {
setTimeout(() => {
PopupLayer.instance.showPopup({
content: popup,
position: 'screen-center',
isBackgroundDimmed: true
});
}, 100); // 100ms delay to be extra safe
});
}
}

View File

@@ -0,0 +1,196 @@
/**
* Canvas Helper Utilities for Node Graph Rendering
*
* Provides utility functions for drawing rounded rectangles and text truncation
* on HTML5 Canvas. Used primarily for modernizing node appearance in the graph editor.
*
* @module canvasHelpers
* @since TASK-000I-A
*/
/**
* Corner radius configuration for rounded rectangles
* Can be a single number for all corners, or an object specifying each corner
*/
export type CornerRadius =
| number
| {
tl: number; // top-left
tr: number; // top-right
br: number; // bottom-right
bl: number; // bottom-left
};
/**
* Draw a rounded rectangle path (does not fill or stroke)
*
* Uses arcTo() for drawing rounded corners. This method only creates the path;
* you must call ctx.fill() or ctx.stroke() afterwards.
*
* @param ctx - Canvas rendering context
* @param x - X coordinate of top-left corner
* @param y - Y coordinate of top-left corner
* @param width - Width of rectangle
* @param height - Height of rectangle
* @param radius - Corner radius (number for all corners, or object for individual corners)
*
* @example
* ```typescript
* roundRect(ctx, 10, 10, 100, 50, 6);
* ctx.fill();
* ```
*/
export function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: CornerRadius
): void {
// Normalize radius to object format
const r = typeof radius === 'number' ? { tl: radius, tr: radius, br: radius, bl: radius } : radius;
// Clamp radius to reasonable values (can't be larger than half the smallest dimension)
const maxRadius = Math.min(width, height) / 2;
const tl = Math.min(r.tl, maxRadius);
const tr = Math.min(r.tr, maxRadius);
const br = Math.min(r.br, maxRadius);
const bl = Math.min(r.bl, maxRadius);
ctx.beginPath();
ctx.moveTo(x + tl, y);
// Top edge and top-right corner
ctx.lineTo(x + width - tr, y);
ctx.arcTo(x + width, y, x + width, y + tr, tr);
// Right edge and bottom-right corner
ctx.lineTo(x + width, y + height - br);
ctx.arcTo(x + width, y + height, x + width - br, y + height, br);
// Bottom edge and bottom-left corner
ctx.lineTo(x + bl, y + height);
ctx.arcTo(x, y + height, x, y + height - bl, bl);
// Left edge and top-left corner
ctx.lineTo(x, y + tl);
ctx.arcTo(x, y, x + tl, y, tl);
ctx.closePath();
}
/**
* Fill a rounded rectangle
*
* Convenience wrapper that creates a rounded rectangle path and fills it.
*
* @param ctx - Canvas rendering context
* @param x - X coordinate of top-left corner
* @param y - Y coordinate of top-left corner
* @param width - Width of rectangle
* @param height - Height of rectangle
* @param radius - Corner radius
*
* @example
* ```typescript
* ctx.fillStyle = '#333';
* fillRoundRect(ctx, 10, 10, 100, 50, 6);
* ```
*/
export function fillRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: CornerRadius
): void {
roundRect(ctx, x, y, width, height, radius);
ctx.fill();
}
/**
* Stroke a rounded rectangle
*
* Convenience wrapper that creates a rounded rectangle path and strokes it.
*
* @param ctx - Canvas rendering context
* @param x - X coordinate of top-left corner
* @param y - Y coordinate of top-left corner
* @param width - Width of rectangle
* @param height - Height of rectangle
* @param radius - Corner radius
*
* @example
* ```typescript
* ctx.strokeStyle = '#fff';
* ctx.lineWidth = 2;
* strokeRoundRect(ctx, 10, 10, 100, 50, 6);
* ```
*/
export function strokeRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: CornerRadius
): void {
roundRect(ctx, x, y, width, height, radius);
ctx.stroke();
}
/**
* Truncate text to fit within a maximum width, adding ellipsis if needed
*
* Efficiently truncates text by measuring progressively shorter strings
* until one fits within the specified width. Uses the context's current font settings.
*
* @param ctx - Canvas rendering context (with font already set)
* @param text - Text to truncate
* @param maxWidth - Maximum width in pixels
* @returns Truncated text with '…' appended if truncation occurred
*
* @example
* ```typescript
* ctx.font = '12px Inter-Regular';
* const displayText = truncateText(ctx, 'Very Long Port Name', 80);
* // Returns "Very Long Po…" if it doesn't fit
* ctx.fillText(displayText, x, y);
* ```
*/
export function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
// If the text already fits, return it as-is
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
const ellipsisWidth = ctx.measureText(ellipsis).width;
// If even the ellipsis doesn't fit, just return it
if (ellipsisWidth > maxWidth) {
return ellipsis;
}
// Binary search for the optimal truncation point
let left = 0;
let right = text.length;
let result = '';
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const truncated = text.slice(0, mid);
const width = ctx.measureText(truncated + ellipsis).width;
if (width <= maxWidth) {
result = truncated;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result + ellipsis;
}

View File

@@ -0,0 +1,210 @@
/**
* Port Type Icons for Node Graph Editor
*
* Provides simple, minimal icon indicators for port data types.
* Uses Unicode characters for reliability and clarity at small sizes.
*
* @module portIcons
* @since TASK-000I-C2
*/
/**
* Supported port types in the Noodl system
*/
export type PortType =
| 'signal'
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'color'
| 'any'
| 'component'
| 'enum';
/**
* Icon representation for a port type
*/
export interface PortIcon {
/** Unicode character to display */
char: string;
/** Optional description for debugging */
description?: string;
}
/**
* Icon definitions for each port type
* Using simple, clear Unicode characters that render well at small sizes
*/
export const PORT_ICONS: Record<PortType, PortIcon> = {
signal: {
char: '⚡',
description: 'Signal/Event trigger'
},
string: {
char: 'T',
description: 'Text/String data'
},
number: {
char: '#',
description: 'Numeric data'
},
boolean: {
char: '◐',
description: 'True/False value'
},
object: {
char: '{ }',
description: 'Object/Record'
},
array: {
char: '[ ]',
description: 'Array/List'
},
color: {
char: '●',
description: 'Color value'
},
any: {
char: '◇',
description: 'Any type'
},
component: {
char: '◈',
description: 'Component reference'
},
enum: {
char: '≡',
description: 'Enumeration/List'
}
};
/**
* Visual constants for port icon rendering
*/
export const PORT_ICON_SIZE = 10; // Font size in pixels
export const PORT_ICON_PADDING = 4; // Space between icon and label
/**
* Map Noodl internal type names to icon types
*
* Noodl uses various type names internally - this function normalizes them
* to our standard PortType set for consistent icon display.
*
* @param type - The internal Noodl type name (may be undefined)
* @returns The corresponding PortType for icon selection
*
* @example
* ```typescript
* getPortIconType('*') // returns 'signal'
* getPortIconType('string') // returns 'string'
* getPortIconType(undefined) // returns 'any'
* ```
*/
export function getPortIconType(type: string | undefined): PortType {
// Handle undefined or non-string types (runtime safety)
if (!type || typeof type !== 'string') return 'any';
// Normalize to lowercase for case-insensitive matching
const normalizedType = type.toLowerCase();
// Direct type mappings
const typeMap: Record<string, PortType> = {
// Signal types
signal: 'signal',
'*': 'signal',
// Primitive types
string: 'string',
number: 'number',
boolean: 'boolean',
// Complex types
object: 'object',
array: 'array',
color: 'color',
// Special types
component: 'component',
enum: 'enum',
// Aliases
text: 'string',
bool: 'boolean',
list: 'array',
json: 'object'
};
return typeMap[normalizedType] || 'any';
}
/**
* Draw a port type icon on canvas
*
* Renders a small icon character indicating the port's data type.
* The icon is drawn with the specified color and at the given position.
*
* @param ctx - Canvas rendering context
* @param type - The port type to render an icon for
* @param x - X coordinate (center of icon)
* @param y - Y coordinate (center of icon)
* @param color - Color to render the icon (CSS color string)
* @param alpha - Optional opacity override (0-1)
*
* @example
* ```typescript
* drawPortIcon(ctx, 'signal', 100, 50, '#ff0000', 0.8);
* drawPortIcon(ctx, 'number', 150, 50, 'rgba(255, 255, 255, 0.6)');
* ```
*/
export function drawPortIcon(
ctx: CanvasRenderingContext2D,
type: PortType,
x: number,
y: number,
color: string,
alpha: number = 1
): void {
const icon = PORT_ICONS[type];
if (!icon) {
console.warn(`Unknown port type: ${type}`);
return;
}
ctx.save();
// Set rendering properties
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.font = `${PORT_ICON_SIZE}px Inter-Regular`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Draw the icon character
ctx.fillText(icon.char, x, y);
ctx.restore();
}
/**
* Get the visual width of an icon (for layout calculations)
*
* Measures the actual rendered width of a port icon character.
* Useful for positioning labels correctly after icons.
*
* @param ctx - Canvas rendering context (with font already set)
* @param type - The port type
* @returns Width in pixels
*/
export function getPortIconWidth(ctx: CanvasRenderingContext2D, type: PortType): number {
const icon = PORT_ICONS[type];
if (!icon) return 0;
ctx.save();
ctx.font = `${PORT_ICON_SIZE}px Inter-Regular`;
const width = ctx.measureText(icon.char).width;
ctx.restore();
return width;
}

View File

@@ -281,8 +281,14 @@ PopupLayer.prototype.showPopup = function (args) {
args.content.owner = this;
this.$('.popup-layer-popup-content').append(content);
var contentWidth = content.outerWidth(true);
var contentHeight = content.outerHeight(true);
// Force a reflow to ensure the element is measurable
void this.$('.popup-layer-popup-content')[0].offsetHeight;
// Query the actual appended element to measure dimensions
var popupContent = this.$('.popup-layer-popup-content');
var contentWidth = popupContent.children().first().outerWidth(true);
var contentHeight = popupContent.children().first().outerHeight(true);
if (args.position === 'screen-center') {
if (args.isBackgroundDimmed) {
@@ -921,13 +927,17 @@ PopupLayer.StringInputPopup.prototype = Object.create(View.prototype);
PopupLayer.StringInputPopup.prototype.render = function () {
this.el = this.bindView($(StringInputPopupTemplate), this);
this.$('.string-input-popup-input')
.off('keypress')
.on('keypress', (e) => {
// Only close on Enter for single-line inputs, not textareas
const input = this.$('.string-input-popup-input');
const isTextarea = input.is('textarea');
if (!isTextarea) {
input.off('keypress').on('keypress', (e) => {
if (e.which == 13) {
this.onOkClicked();
}
});
}
return this.el;
};
@@ -949,8 +959,49 @@ PopupLayer.StringInputPopup.prototype.onCancelClicked = function () {
this.owner.hidePopup();
};
PopupLayer.StringInputPopup.prototype.updateLineNumbers = function () {
const textarea = this.$('.string-input-popup-input')[0];
const lineNumbersEl = this.$('.string-input-popup-line-numbers')[0];
if (!textarea || !lineNumbersEl) return;
// Count lines based on textarea value
const text = textarea.value;
const lines = text ? text.split('\n').length : 1;
// Always show at least 8 lines (matching rows="8")
const displayLines = Math.max(8, lines);
// Generate line numbers
let lineNumbersHTML = '';
for (let i = 1; i <= displayLines; i++) {
lineNumbersHTML += i + '\n';
}
lineNumbersEl.textContent = lineNumbersHTML;
// Sync scroll
lineNumbersEl.scrollTop = textarea.scrollTop;
};
PopupLayer.StringInputPopup.prototype.onOpen = function () {
this.$('.string-input-popup-input').focus();
const textarea = this.$('.string-input-popup-input');
// Initial line numbers
this.updateLineNumbers();
// Update line numbers on input
textarea.on('input', () => this.updateLineNumbers());
// Sync scroll between textarea and line numbers
textarea.on('scroll', () => {
const lineNumbersEl = this.$('.string-input-popup-line-numbers')[0];
if (lineNumbersEl) {
lineNumbersEl.scrollTop = textarea[0].scrollTop;
}
});
textarea.focus();
};
// ---------------------------------------------------------------------

View File

@@ -161,49 +161,49 @@ function generateNodeLibrary(nodeRegister) {
colors: {
nodes: {
component: {
base: '#643D8B',
baseHighlighted: '#79559b',
header: '#4E2877',
headerHighlighted: '#643d8b',
outline: '#4E2877',
base: '#8B4DAB',
baseHighlighted: '#A167C5',
header: '#6B2D8B',
headerHighlighted: '#8B4DAB',
outline: '#6B2D8B',
outlineHighlighted: '#b58900',
text: '#dbd0e4'
text: '#FFFFFF'
},
visual: {
base: '#315272',
baseHighlighted: '#4d6784',
header: '#173E5D',
headerHighlighted: '#315272',
outline: '#173E5D',
base: '#4A7CA8',
baseHighlighted: '#6496C2',
header: '#2A5C88',
headerHighlighted: '#4A7CA8',
outline: '#2A5C88',
outlineHighlighted: '#b58900',
text: '#cfd5de'
text: '#FFFFFF'
},
data: {
base: '#465524',
baseHighlighted: '#5b6a37',
header: '#314110',
headerHighlighted: '#465524',
outline: '#314110',
base: '#6B8F3C',
baseHighlighted: '#85A956',
header: '#4B6F1C',
headerHighlighted: '#6B8F3C',
outline: '#4B6F1C',
outlineHighlighted: '#b58900',
text: '#d2d6c5'
text: '#FFFFFF'
},
javascript: {
base: '#7E3660',
baseHighlighted: '#944e74',
header: '#67214B',
headerHighlighted: '#7e3660',
outline: '#67214B',
base: '#B84D7C',
baseHighlighted: '#CE6796',
header: '#982D5C',
headerHighlighted: '#B84D7C',
outline: '#982D5C',
outlineHighlighted: '#d57bab',
text: '#e4cfd9'
text: '#FFFFFF'
},
default: {
base: '#4C4F59',
baseHighlighted: '#62656e',
header: '#373B45',
headerHighlighted: '#4c4f59',
outline: '#373B45',
base: '#6C6F79',
baseHighlighted: '#868993',
header: '#4C4F59',
headerHighlighted: '#6C6F79',
outline: '#4C4F59',
outlineHighlighted: '#b58900',
text: '#d3d4d6'
text: '#FFFFFF'
}
},
connections: {