This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 51bb04af14 fix(frontend): pixel-aware operator caption truncation
(#5071)
51bb04af14 is described below
commit 51bb04af14ea8d614d607a46d137d8e2b598e3fe
Author: Chen Li <[email protected]>
AuthorDate: Fri May 15 17:16:57 2026 -0700
fix(frontend): pixel-aware operator caption truncation (#5071)
### What changes were proposed in this PR?
Long operator captions overflow the canvas bounding box (#5070). Cap the
rendered caption by **pixel width**, not character count, so it stays
visually contained.
- `JointUIService.MAX_OPERATOR_NAME_PIXELS = 200` — pixel budget for the
rendered caption.
- `measureOperatorNameWidth(text)` — cached Canvas 2D measurement at
`14px sans-serif`, with a 7-px-per-char fallback for SSR / jsdom.
- `truncateOperatorDisplayName(name, measure?)` — binary-searches the
longest **grapheme-cluster** prefix that fits the budget after reserving
room for a U+2026 ellipsis. Uses `Intl.Segmenter` (falls back to
code-point iteration) so emoji surrogate pairs and ZWJ sequences stay
intact.
- Helper applied at the two render paths: initial element creation and
`changeOperatorJointDisplayName`.
The operator model's `customDisplayName` is **not** modified — only the
rendered SVG text is truncated; the full caption stays editable in the
property panel.
#### Live editor demo

Three real operators with deliberately contrasting captions. The W and i
operators have **identical character counts (60)** but render very
differently — a char-count cap would treat them the same; the pixel cap
correctly truncates the wide one and leaves the narrow one alone.
### Any related issues, documentation, discussions?
Fixes apache/texera#5070.
### How was this PR tested?
6 unit tests for `truncateOperatorDisplayName` (inject a deterministic
measurer so the spec doesn't need a real canvas): within-budget,
over-budget, empty, CJK, emoji surrogate pairs (no orphan surrogates),
and ZWJ family-emoji cluster. All pass via `ng test --watch=false
--include='**/joint-ui.service.spec.ts'`. Verified visually in the live
workflow editor.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.7)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
.../service/joint-ui/joint-ui.service.spec.ts | 179 +++++++++++++++++++++
.../workspace/service/joint-ui/joint-ui.service.ts | 58 ++++++-
2 files changed, 235 insertions(+), 2 deletions(-)
diff --git
a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
index a2b72d009c..ccefd937de 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
@@ -327,9 +327,188 @@
// });
// });
+import { of } from "rxjs";
+import * as joint from "jointjs";
+import { JointUIService, operatorNameClass } from "./joint-ui.service";
+import { OperatorPredicate } from "../../types/workflow-common.interface";
+
describe("JointUIService", () => {
// Pre-existing spec body is commented out. Placeholder keeps Vitest's
// discovery happy; rewriting the real tests against the new test
// runner is tracked in #4861.
it.todo("add unit tests for JointUIService");
+
+ describe("truncateOperatorDisplayName", () => {
+ // Deterministic measurer: 10px per character. With the 200-px budget,
+ // 20 chars fit exactly; longer strings get truncated to a prefix plus "…".
+ const measure = (text: string) => text.length * 10;
+ const budget = JointUIService.MAX_OPERATOR_NAME_PIXELS;
+ const charsThatFit = budget / 10;
+
+ it("returns the name unchanged when it fits within the pixel budget", ()
=> {
+ const name = "a".repeat(charsThatFit);
+ expect(JointUIService.truncateOperatorDisplayName(name,
measure)).toBe(name);
+ });
+
+ it("truncates and appends an ellipsis when the name exceeds the budget",
() => {
+ const name = "a".repeat(charsThatFit + 10);
+ const result = JointUIService.truncateOperatorDisplayName(name, measure);
+ expect(result.endsWith("…")).toBe(true);
+ expect(measure(result)).toBeLessThanOrEqual(budget);
+ // Ellipsis takes 10px, leaving 190px for the prefix → 19 chars.
+ expect(result).toBe("a".repeat(charsThatFit - 1) + "…");
+ });
+
+ it("returns an empty string unchanged", () => {
+ expect(JointUIService.truncateOperatorDisplayName("", measure)).toBe("");
+ });
+
+ it("truncates CJK characters at code-point boundaries", () => {
+ // CJK characters are each a single code point (UTF-16 length 1) — the
+ // 10-px measurer treats them like any other char. 19 chars fit in the
+ // 190-px prefix budget once the ellipsis is reserved.
+ const name = "你".repeat(charsThatFit + 5);
+ const result = JointUIService.truncateOperatorDisplayName(name, measure);
+ expect(result).toBe("你".repeat(charsThatFit - 1) + "…");
+ expect(measure(result)).toBeLessThanOrEqual(budget);
+ });
+
+ it("truncates emoji at grapheme boundaries (no orphan surrogates)", () => {
+ // 🎉 is U+1F389, a single grapheme but a UTF-16 surrogate pair (length
2).
+ // With the 10-px-per-code-unit measurer each 🎉 costs 20 px.
+ const name = "🎉".repeat(20);
+ const result = JointUIService.truncateOperatorDisplayName(name, measure);
+ // Prefix budget 190 / 20 px per emoji = 9 full emojis kept.
+ expect(result).toBe("🎉".repeat(9) + "…");
+ // Result must be re-iterable as the same set of grapheme clusters —
+ // i.e. no half-surrogate at the boundary.
+ const segments = Array.from(result);
+ expect(segments).toEqual([..."🎉".repeat(9), "…"]);
+ });
+
+ it("keeps a ZWJ grapheme cluster (family emoji) intact when truncating",
() => {
+ // 👨👩👧👦 is one grapheme cluster but 11 UTF-16 code units (4 emojis
joined
+ // by 3 ZWJ chars). With the 10-px measurer each family costs 110 px,
+ // so the 190-px prefix budget keeps exactly one family.
+ const name = "👨👩👧👦".repeat(5);
+ const result = JointUIService.truncateOperatorDisplayName(name, measure);
+ // Skip the strict assertion if Intl.Segmenter isn't available; the
+ // code-point fallback would split the cluster, which we cannot avoid
+ // without the segmenter.
+ const hasSegmenter = typeof Intl !== "undefined" && typeof
Intl.Segmenter === "function";
+ if (hasSegmenter) {
+ expect(result).toBe("👨👩👧👦" + "…");
+ }
+ expect(result.endsWith("…")).toBe(true);
+ });
+
+ it("falls back to code-point iteration when Intl.Segmenter is
unavailable", () => {
+ const intlAsAny = Intl as unknown as { Segmenter?: typeof Intl.Segmenter
};
+ const original = intlAsAny.Segmenter;
+ delete intlAsAny.Segmenter;
+ try {
+ // Surrogate-pair safety still holds via Array.from.
+ const result =
JointUIService.truncateOperatorDisplayName("🎉".repeat(20), measure);
+ expect(result).toBe("🎉".repeat(9) + "…");
+ } finally {
+ intlAsAny.Segmenter = original;
+ }
+ });
+
+ it("uses the default canvas-based measurer when no measurer is injected",
() => {
+ // Stub getContext → null so the default measurer routes through the
+ // fallback path (avoids jsdom's "Not implemented" warning spam from
+ // the dozens of measurer calls the binary search makes).
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
+ (HTMLCanvasElement.prototype as unknown as { getContext: () => null
}).getContext = () => null;
+ (JointUIService as unknown as { measureCtx: CanvasRenderingContext2D |
null }).measureCtx = null;
+ try {
+ const result =
JointUIService.truncateOperatorDisplayName("a".repeat(100));
+ expect(result.endsWith("…")).toBe(true);
+ expect(result.length).toBeLessThan(100);
+ } finally {
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
+ (JointUIService as unknown as { measureCtx: CanvasRenderingContext2D |
null }).measureCtx = null;
+ }
+ });
+ });
+
+ describe("measureOperatorNameWidth", () => {
+ // Static cache lives on the class; reset it between tests so each one
+ // starts from a clean slate and re-enters getMeasureContext.
+ const resetCache = () => {
+ (JointUIService as unknown as { measureCtx: CanvasRenderingContext2D |
null }).measureCtx = null;
+ };
+ beforeEach(resetCache);
+ afterEach(resetCache);
+
+ it("falls back to a per-char approximation when no canvas 2D context is
available", () => {
+ // Stub the prototype to return null explicitly — this mirrors the
+ // production behavior in environments that don't support canvas, and
+ // avoids jsdom's "Not implemented: getContext" warning spam.
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
+ (HTMLCanvasElement.prototype as unknown as { getContext: () => null
}).getContext = () => null;
+ try {
+ expect(JointUIService.measureOperatorNameWidth("")).toBe(0);
+
expect(JointUIService.measureOperatorNameWidth("hello")).toBe("hello".length *
7);
+ } finally {
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
+ }
+ });
+
+ it("uses Canvas measureText when a 2D context is available, and caches
it", () => {
+ const measureSpy = vi.fn((s: string) => ({ width: s.length * 12 }));
+ const fakeCtx = { font: "", measureText: measureSpy } as unknown as
CanvasRenderingContext2D;
+ const getContextSpy = vi.fn(() => fakeCtx);
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
+ // Stub only on the prototype; restored in finally.
+ (HTMLCanvasElement.prototype as unknown as { getContext: typeof
getContextSpy }).getContext = getContextSpy;
+ try {
+ expect(JointUIService.measureOperatorNameWidth("hello")).toBe(5 * 12);
+ // Second call hits the cached-ctx branch — should not create another
canvas.
+ expect(JointUIService.measureOperatorNameWidth("hi")).toBe(2 * 12);
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ expect(measureSpy).toHaveBeenCalledTimes(2);
+ } finally {
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
+ }
+ });
+ });
+
+ describe("changeOperatorJointDisplayName", () => {
+ it("writes the truncated caption to the joint model's text attr", () => {
+ // Stub getContext → null so the binary-search inside
+ // truncateOperatorDisplayName routes through the fallback measurer
+ // instead of spamming jsdom's "Not implemented: getContext" warning.
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
+ (HTMLCanvasElement.prototype as unknown as { getContext: () => null
}).getContext = () => null;
+ (JointUIService as unknown as { measureCtx: CanvasRenderingContext2D |
null }).measureCtx = null;
+ try {
+ const attrSpy = vi.fn();
+ const getModelByIdSpy = vi.fn(() => ({ attr: attrSpy }));
+ const jointPaper = { getModelById: getModelByIdSpy } as unknown as
joint.dia.Paper;
+ // changeOperatorJointDisplayName is an instance method but uses no
+ // `this` state; pass a minimal metadata stub so the constructor's
+ // subscribe doesn't throw.
+ const metadataStub = { getOperatorMetadata: () => of({ operators: [],
groups: [] }) };
+ const service = new JointUIService(metadataStub as never);
+
+ const operator = { operatorID: "op-1" } as OperatorPredicate;
+ // Long enough to force truncation under the 200-px budget.
+ const longName = "abcdefghij".repeat(20);
+ service.changeOperatorJointDisplayName(operator, jointPaper, longName);
+
+ expect(getModelByIdSpy).toHaveBeenCalledWith("op-1");
+ expect(attrSpy).toHaveBeenCalledTimes(1);
+ const [selector, rendered] = attrSpy.mock.calls[0];
+ expect(selector).toBe(`.${operatorNameClass}/text`);
+ expect(typeof rendered).toBe("string");
+ expect((rendered as string).endsWith("…")).toBe(true);
+ expect((rendered as string).length).toBeLessThan(longName.length);
+ } finally {
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
+ (JointUIService as unknown as { measureCtx: CanvasRenderingContext2D |
null }).measureCtx = null;
+ }
+ });
+ });
});
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
index 6bc05f7e3b..d5d8f78c58 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
@@ -210,6 +210,56 @@ export class JointUIService {
public static readonly DEFAULT_GROUP_MARGIN_BOTTOM = 40;
public static readonly DEFAULT_COMMENT_WIDTH = 32;
public static readonly DEFAULT_COMMENT_HEIGHT = 32;
+ public static readonly MAX_OPERATOR_NAME_PIXELS = 200;
+ private static readonly OPERATOR_NAME_FONT = "14px sans-serif";
+ private static measureCtx: CanvasRenderingContext2D | null = null;
+
+ private static getMeasureContext(): CanvasRenderingContext2D | null {
+ if (JointUIService.measureCtx) return JointUIService.measureCtx;
+ const ctx = document.createElement("canvas").getContext("2d");
+ if (!ctx) return null;
+ ctx.font = JointUIService.OPERATOR_NAME_FONT;
+ JointUIService.measureCtx = ctx;
+ return ctx;
+ }
+
+ public static measureOperatorNameWidth(text: string): number {
+ const ctx = JointUIService.getMeasureContext();
+ if (ctx) return ctx.measureText(text).width;
+ // Fallback for jsdom-without-canvas: approximate at ~14px sans-serif.
+ return text.length * 7;
+ }
+
+ // Split a string into grapheme clusters so truncation does not break
+ // surrogate pairs (emoji) or ZWJ sequences (e.g. family emoji, flags).
+ // Falls back to code-point iteration if Intl.Segmenter is unavailable.
+ private static splitGraphemes(name: string): string[] {
+ if (typeof Intl.Segmenter === "function") {
+ return Array.from(new Intl.Segmenter().segment(name), s => s.segment);
+ }
+ return Array.from(name);
+ }
+
+ public static truncateOperatorDisplayName(
+ name: string,
+ measure: (text: string) => number = JointUIService.measureOperatorNameWidth
+ ): string {
+ if (!name) return name;
+ const budget = JointUIService.MAX_OPERATOR_NAME_PIXELS;
+ if (measure(name) <= budget) return name;
+ const ellipsis = "…";
+ const prefixBudget = budget - measure(ellipsis);
+ const graphemes = JointUIService.splitGraphemes(name);
+ // Binary-search the longest grapheme prefix that fits inside prefixBudget.
+ let lo = 0;
+ let hi = graphemes.length;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >>> 1;
+ if (measure(graphemes.slice(0, mid).join("")) <= prefixBudget) lo = mid;
+ else hi = mid - 1;
+ }
+ return graphemes.slice(0, lo).join("") + ellipsis;
+ }
private operatorSchemas: ReadonlyArray<OperatorSchema> = [];
@@ -253,7 +303,9 @@ export class JointUIService {
},
attrs: JointUIService.getCustomOperatorStyleAttrs(
operator,
- operator.customDisplayName ??
operatorSchema.additionalMetadata.userFriendlyName,
+ JointUIService.truncateOperatorDisplayName(
+ operator.customDisplayName ??
operatorSchema.additionalMetadata.userFriendlyName
+ ),
operatorSchema.operatorType,
operatorSchema.additionalMetadata.userFriendlyName
),
@@ -503,7 +555,9 @@ export class JointUIService {
jointPaper: joint.dia.Paper,
displayName: string
): void {
-
jointPaper.getModelById(operator.operatorID).attr(`.${operatorNameClass}/text`,
displayName);
+ jointPaper
+ .getModelById(operator.operatorID)
+ .attr(`.${operatorNameClass}/text`,
JointUIService.truncateOperatorDisplayName(displayName));
}
public getCommentElement(commentBox: CommentBox): joint.dia.Element {