import { Graphics, Rectangle, type Text, TextStyle } from 'pixi.js'; import type { AnnotationKind, GraphAnnotation } from '../../core/graph'; import { readableTextColor, Theme, truncate, Widget, crispText } from '../../ui'; /** Height of a group's title bar – the same as a node's, so the two line up in the eye. */ export const ANNOTATION_TITLE_HEIGHT = 24; /** Side of the square at the bottom right corner that resizes the box. */ export const RESIZE_HANDLE = 14; const PAD = 8; /** * One group or comment, drawn behind the nodes in graph coordinates. * * A group is hit on its title bar and its resize handle only: its body has to stay transparent to * the pointer, or a rubber band could never be drawn across the nodes it holds. A comment frames * nothing, so it is grabbed anywhere. */ export class AnnotationView extends Widget { private readonly box = new Graphics(); private readonly caption: Text; /** Colour and width of the caption's style, see `redraw`. */ private styleKey = ''; selected = false; constructor( public annotation: GraphAnnotation, readonly kind: AnnotationKind, ) { super(); this.addChild(this.box); this.caption = crispText({ text: '', style: this.captionStyle(0xffffff) }); this.caption.position.set(PAD, kind === 'group' ? 0 : PAD); this.addChild(this.caption); this.cursor = Theme.hoverCursor; this.sync(annotation); } sync(annotation: GraphAnnotation): void { this.annotation = annotation; this.position.set(annotation.x, annotation.y); this.resize(annotation.w, annotation.h); // `resize` puts the full rectangle back every time, changed size or not, so this comes after it. this.applyHitArea(); this.redraw(); } get color(): number { return this.annotation.color ?? Theme.colors.annotation; } /** Black or white, picked from the fill it sits on – the price of letting the picker run free. */ get textColor(): number { return readableTextColor(this.color); } private captionStyle(color: number): TextStyle { return new TextStyle({ fontFamily: Theme.font, fontSize: Theme.fontSize, fill: color, fontWeight: this.kind === 'group' ? '600' : '400', wordWrap: this.kind === 'note', wordWrapWidth: Math.max(this.w - PAD * 2, 1), lineHeight: Theme.fontSize + 4, }); } /** Where the box answers the pointer: the title bar of a group, the whole card of a comment. */ private applyHitArea(): void { if (this.kind === 'note') { this.hitArea = new Rectangle(0, 0, this.w, this.h); return; } this.hitArea = { contains: (x: number, y: number) => (x >= 0 && x <= this.w && y >= 0 && y <= ANNOTATION_TITLE_HEIGHT) || this.handleAt(x, y), }; } /** Whether this local point is on the corner that resizes – outside the hit area for a group, * so the canvas asks for it before it asks who is under the pointer. */ handleAt(x: number, y: number): boolean { return x >= this.w - RESIZE_HANDLE && x <= this.w && y >= this.h - RESIZE_HANDLE && y <= this.h; } override redraw(): void { const c = Theme.colors; const color = this.color; const text = this.textColor; this.box.clear(); if (this.kind === 'group') { // Translucent body, solid title: the nodes inside must stay readable, the title must not. this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill({ color, alpha: 0.12 }); this.box.roundRect(0, 0, this.w, ANNOTATION_TITLE_HEIGHT, Theme.radius).fill(color); this.box.rect(0, ANNOTATION_TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(color); } else { this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(color); } this.box .roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius) .stroke({ width: this.selected ? 2 : 1, color: this.selected ? c.nodeSelected : color, alpha: 0.9 }); // A corner that can be grabbed says so, in the text colour of the box it belongs to. this.box .moveTo(this.w - RESIZE_HANDLE, this.h - 2) .lineTo(this.w - 2, this.h - RESIZE_HANDLE) .moveTo(this.w - RESIZE_HANDLE / 2, this.h - 2) .lineTo(this.w - 2, this.h - RESIZE_HANDLE / 2) .stroke({ width: 1, color: text, alpha: 0.5 }); // A new style draws the caption again even when nothing changed, and the graph redraws on every // edit of the project, a stroke on a tilemap included (#347). So only a new colour or width sets one. const styleKey = `${text}|${this.w}`; if (styleKey !== this.styleKey) { this.styleKey = styleKey; this.caption.style = this.captionStyle(text); } this.caption.text = this.kind === 'group' ? truncate(this.annotation.text, this.caption.style, Math.max(this.w - PAD * 2, 1)) : this.annotation.text; if (this.kind === 'group') this.caption.y = (ANNOTATION_TITLE_HEIGHT - this.caption.height) / 2; } /** What the last redraw put on screen, for the checks. */ drawnText(): string { return this.caption.text; } }