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

@@ -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();
};
// ---------------------------------------------------------------------