Compare commits

...

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
fe20f29b09 fix: Audit and cleanup CanvasRenderingContext2D usage to ensure .save()/.restore() pattern (#9021)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:56:12 +01:00
19 changed files with 61 additions and 250 deletions

View File

@@ -5494,11 +5494,12 @@ 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) {
const transform = ctx.getTransform()
ctx.save()
ctx.translate(pos[0], pos[1])
// Assertion: Number.isFinite guarantees this is a number.
if (Number.isFinite(link._centreAngle))
@@ -5506,7 +5507,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.moveTo(-2, -3)
ctx.lineTo(+4, 0)
ctx.lineTo(-2, +3)
ctx.setTransform(transform)
ctx.restore()
} else if (
this.linkMarkerShape == null ||
this.linkMarkerShape === LinkMarkerShape.Circle
@@ -5517,10 +5518,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @ts-expect-error TODO: Better value typing
const { data } = link
if (data == null) return
if (data == null) {
ctx.restore()
return
}
// @ts-expect-error TODO: Better value typing
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) {
ctx.restore()
return
}
let text: string | null = null
@@ -5530,7 +5537,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
else if (data.toToolTip) text = data.toToolTip()
else text = `[${data.constructor.name}]`
if (text == null) return
if (text == null) {
ctx.restore()
return
}
// Hard-coded tooltip limit
text = text.substring(0, 30)
@@ -5554,6 +5564,7 @@ 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
const { lineWidth, strokeStyle } = ctx
ctx.save()
ctx.lineWidth = thickness
ctx.globalAlpha = 0.8
ctx.strokeStyle = color
@@ -138,12 +138,7 @@ export function strokeShape(
// Stroke the shape
ctx.stroke()
// 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
ctx.restore()
}
/**
@@ -216,18 +211,24 @@ 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) return
if (truncated.length === 0) {
ctx.restore()
return
}
// Draw text - left-aligned to prevent bouncing during resize
ctx.textAlign = 'left'
@@ -238,4 +239,6 @@ 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,15 +318,9 @@ export abstract class SubgraphIONodeBase<
| SubgraphOutput,
editorAlpha?: number
): void {
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
ctx.save()
this.drawProtected(ctx, colorContext, fromSlot, editorAlpha)
Object.assign(ctx, {
lineWidth,
strokeStyle,
fillStyle,
font,
textBaseline
})
ctx.restore()
}
/** @internal Leaves {@link ctx} dirty. */

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IChartWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class ChartWidget
override type = 'chart' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'Chart')
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -29,7 +29,7 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
ctx.save()
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)
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
ctx.restore()
}
onClick({ e, node, canvas }: WidgetEventOptions): void {

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IFileUploadWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class FileUploadWidget
override type = 'fileupload' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'Fileupload')
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IGalleriaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class GalleriaWidget
override type = 'galleria' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'Galleria')
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IImageCompareWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class ImageCompareWidget
override type = 'imagecompare' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'ImageCompare')
}
onClick(_options: WidgetEventOptions): void {

View File

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

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IMarkdownWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class MarkdownWidget
override type = 'markdown' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'Markdown')
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { IMultiSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class MultiSelectWidget
override type = 'multiselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'MultiSelect')
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,5 +1,3 @@
import { t } from '@/i18n'
import type { ISelectButtonWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -15,32 +13,7 @@ export class SelectButtonWidget
override type = 'selectbutton' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
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
})
this.drawVueOnlyWarning(ctx, options, 'SelectButton')
}
onClick(_options: WidgetEventOptions): void {

View File

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

View File

@@ -24,8 +24,8 @@ export class TextWidget
options: DrawWidgetOptions
) {
const { width, showText = true } = options
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
ctx.save()
this.drawWidgetShape(ctx, options)
@@ -33,8 +33,7 @@ export class TextWidget
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
ctx.restore()
}
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)
const transform = ctx.getTransform()
ctx.save()
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.setTransform(transform)
ctx.restore()
}
}
@@ -803,20 +803,19 @@ 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)
@@ -824,15 +823,14 @@ 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()
}
}