Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
04e8ec67ee fix: Optimize CurveEditor with typed arrays for better JIT performance (#9110)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:04:58 +01:00
20 changed files with 264 additions and 70 deletions

View File

@@ -15,25 +15,30 @@ export function createMonotoneInterpolator(
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
const xs = new Float64Array(n)
const ys = new Float64Array(n)
const deltas: number[] = []
const slopes: number[] = []
for (let i = 0; i < n; i++) {
xs[i] = sorted[i][0]
ys[i] = sorted[i][1]
}
const deltas = new Float64Array(n - 1)
const slopes = new Float64Array(n)
for (let i = 0; i < n - 1; i++) {
const dx = xs[i + 1] - xs[i]
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
deltas[i] = dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx
}
slopes.push(deltas[0] ?? 0)
slopes[0] = deltas[0] ?? 0
for (let i = 1; i < n - 1; i++) {
if (deltas[i - 1] * deltas[i] <= 0) {
slopes.push(0)
slopes[i] = 0
} else {
slopes.push((deltas[i - 1] + deltas[i]) / 2)
slopes[i] = (deltas[i - 1] + deltas[i]) / 2
}
}
slopes.push(deltas[n - 2] ?? 0)
slopes[n - 1] = deltas[n - 2] ?? 0
for (let i = 0; i < n - 1; i++) {
if (deltas[i] === 0) {

View File

@@ -5494,12 +5494,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @todo Split tooltip from hover, so it can be drawn / eased separately
*/
drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LinkSegment): void {
ctx.save()
const pos = link._pos
ctx.fillStyle = 'black'
ctx.beginPath()
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
ctx.save()
const transform = ctx.getTransform()
ctx.translate(pos[0], pos[1])
// Assertion: Number.isFinite guarantees this is a number.
if (Number.isFinite(link._centreAngle))
@@ -5507,7 +5506,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.moveTo(-2, -3)
ctx.lineTo(+4, 0)
ctx.lineTo(-2, +3)
ctx.restore()
ctx.setTransform(transform)
} else if (
this.linkMarkerShape == null ||
this.linkMarkerShape === LinkMarkerShape.Circle
@@ -5518,16 +5517,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @ts-expect-error TODO: Better value typing
const { data } = link
if (data == null) {
ctx.restore()
return
}
if (data == null) return
// @ts-expect-error TODO: Better value typing
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) {
ctx.restore()
return
}
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return
let text: string | null = null
@@ -5537,10 +5530,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
else if (data.toToolTip) text = data.toToolTip()
else text = `[${data.constructor.name}]`
if (text == null) {
ctx.restore()
return
}
if (text == null) return
// Hard-coded tooltip limit
text = text.substring(0, 30)
@@ -5564,7 +5554,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.textAlign = 'center'
ctx.fillStyle = '#CEC'
ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3)
ctx.restore()
}
/**

View File

@@ -91,7 +91,7 @@ export function strokeShape(
}
// Set up context
ctx.save()
const { lineWidth, strokeStyle } = ctx
ctx.lineWidth = thickness
ctx.globalAlpha = 0.8
ctx.strokeStyle = color
@@ -138,7 +138,12 @@ export function strokeShape(
// Stroke the shape
ctx.stroke()
ctx.restore()
// Reset context
ctx.lineWidth = lineWidth
ctx.strokeStyle = strokeStyle
// TODO: Store and reset value properly. Callers currently expect this behaviour (e.g. muted nodes).
ctx.globalAlpha = 1
}
/**
@@ -211,24 +216,18 @@ export function drawTextInArea({
}: IDrawTextInAreaOptions) {
const { left, right, bottom, width, centreX } = area
ctx.save()
// Text already fits
const fullWidth = ctx.measureText(text).width
if (fullWidth <= width) {
ctx.textAlign = align
const x = align === 'left' ? left : align === 'right' ? right : centreX
ctx.fillText(text, x, bottom)
ctx.restore()
return
}
// Need to truncate text
const truncated = truncateTextToWidth(ctx, text, width)
if (truncated.length === 0) {
ctx.restore()
return
}
if (truncated.length === 0) return
// Draw text - left-aligned to prevent bouncing during resize
ctx.textAlign = 'left'
@@ -239,6 +238,4 @@ export function drawTextInArea({
ctx.textAlign = 'right'
const ellipsis = truncated.at(-1)!
ctx.fillText(ellipsis, right, bottom, ctx.measureText(ellipsis).width * 0.75)
ctx.restore()
}

View File

@@ -318,9 +318,15 @@ export abstract class SubgraphIONodeBase<
| SubgraphOutput,
editorAlpha?: number
): void {
ctx.save()
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
this.drawProtected(ctx, colorContext, fromSlot, editorAlpha)
ctx.restore()
Object.assign(ctx, {
lineWidth,
strokeStyle,
fillStyle,
font,
textBaseline
})
}
/** @internal Leaves {@link ctx} dirty. */

View File

@@ -39,8 +39,8 @@ export class AssetWidget
options: DrawWidgetOptions
) {
const { width, showText = true } = options
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -48,7 +48,8 @@ export class AssetWidget
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick() {

View File

@@ -62,7 +62,8 @@ export abstract class BaseSteppedWidget<
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
if (options.showText) {
@@ -71,6 +72,7 @@ export abstract class BaseSteppedWidget<
this.drawTruncatingText({ ctx, width: options.width })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
}

View File

@@ -13,8 +13,6 @@ export class BooleanWidget
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
) {
ctx.save()
const { width, showText = true } = options
const { height, y } = this
const { margin } = BaseWidget
@@ -30,8 +28,6 @@ export class BooleanWidget
this.drawLabel(ctx, margin * 2)
this.drawValue(ctx, width - 40)
}
ctx.restore()
}
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {

View File

@@ -25,7 +25,8 @@ export class ButtonWidget
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { height, y } = this
const { margin } = BaseWidget
@@ -47,7 +48,8 @@ export class ButtonWidget
// Draw button text
if (showText) this.drawLabel(ctx, width * 0.5)
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IChartWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class ChartWidget
override type = 'chart' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Chart')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Chart: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -29,7 +29,7 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
ctx.save()
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -62,7 +62,7 @@ export class ColorWidget
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
ctx.restore()
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick({ e, node, canvas }: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IFileUploadWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class FileUploadWidget
override type = 'fileupload' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Fileupload')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Fileupload: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IGalleriaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class GalleriaWidget
override type = 'galleria' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Galleria')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Galleria: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IImageCompareWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class ImageCompareWidget
override type = 'imagecompare' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCompare')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `ImageCompare: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -35,7 +35,8 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
): void {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { y } = this
const { margin } = BaseWidget
@@ -176,7 +177,8 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
)
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick(): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMarkdownWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class MarkdownWidget
override type = 'markdown' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Markdown')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Markdown: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMultiSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class MultiSelectWidget
override type = 'multiselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'MultiSelect')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `MultiSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { ISelectButtonWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class SelectButtonWidget
override type = 'selectbutton' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'SelectButton')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `SelectButton: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -22,7 +22,8 @@ export class SliderWidget
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { height, y } = this
const { margin } = BaseWidget
@@ -66,7 +67,8 @@ export class SliderWidget
)
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
/**

View File

@@ -24,8 +24,8 @@ export class TextWidget
options: DrawWidgetOptions
) {
const { width, showText = true } = options
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -33,7 +33,8 @@ export class TextWidget
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick({ e, node, canvas }: WidgetEventOptions) {

View File

@@ -432,7 +432,7 @@ export class CanvasPathRenderer {
const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
// Draw arrow triangle (matching original shape)
ctx.save()
const transform = ctx.getTransform()
ctx.translate(posA.x, posA.y)
ctx.rotate(angle)
ctx.fillStyle = color
@@ -441,7 +441,7 @@ export class CanvasPathRenderer {
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.restore()
ctx.setTransform(transform)
}
}
@@ -803,19 +803,20 @@ export class CanvasPathRenderer {
): void {
if (!link.centerPos) return
ctx.save()
ctx.beginPath()
if (
context.style.centerMarkerShape === 'arrow' &&
link.centerAngle !== undefined
) {
const transform = ctx.getTransform()
ctx.translate(link.centerPos.x, link.centerPos.y)
ctx.rotate(link.centerAngle)
// The math is off, but it currently looks better in chromium (from original)
ctx.moveTo(-3.2, -5)
ctx.lineTo(7, 0)
ctx.lineTo(-3.2, 5)
ctx.setTransform(transform)
} else {
// Default to circle
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
@@ -823,14 +824,15 @@ export class CanvasPathRenderer {
// Apply disabled pattern or color
if (link.disabled && context.patterns?.disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = context.patterns.disabled
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
} else {
ctx.fillStyle = color
ctx.fill()
}
ctx.restore()
}
}