The full CI run on 4c7861b had two red checks. Neither showed when
only the touched modules ran, because each needs a module before it.
- e2e 40c-iii (#347): a stroke on a tilemap drew the captions
"Comment" and "Held" again. They are a note and a group that e2e 36
leaves in a graph. AnnotationView gave its caption a new TextStyle
on every redraw, and Pixi draws a text again for a new style. The
graph redraws on every edit of the project. The caption now keeps
its style while colour and width stay the same.
- e2e 49a: a plain user deleting a shared file got 404 instead of
403. Since #344 the DELETE route asked whether the file exists
before whose it is. It now refuses a stranger first, so another
folder tells them nothing. The wiki's API page says so.
Tests:
- Unit annotation-view (new): 2 green, counter-check 1 red
- e2e 36,39,40,49 (melektaus-atx): both checks green, 49 then hit a
harness timeout (CDP Network.enable) once
- e2e 49 alone (melektaus-atx): ALL PASSED - 22 checks
- Gate (melektaus-atx): CHECK EXIT 0, 261 files, 4040 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
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;
|
||
}
|
||
}
|