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
    
    ![Pixel-aware truncation: same char count, different
    
widths](https://github.com/chenlica/texera/releases/download/issue-assets-5070/pr-5071-pixel-truncation.png)
    
    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 {

Reply via email to