mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(composer): align wrapped inline chip fragments (#7242)
**Category:** fix **User Impact:** Wrapped channel and mention chips in the chat composer now align continuation text with the chip edge while keeping the icon on the first line. **Problem:** Plain composer decorations used absolute icons plus cloned icon-sized padding, so every wrapped fragment inherited an empty icon gap and the icon aligned against the union of all lines. **Solution:** Keep the icon in the first fragment's inline flow and restore normal chip padding on continuation fragments, while explicitly leaving the separate wrapping Buzz-link and sent-message rendering paths unchanged. <details> <summary>File changes</summary> **desktop/src/shared/styles/globals/composer.css** Scopes in-flow icon geometry and normal continuation padding to plain composer mention and channel decorations, excluding wrapping atom-link chips and preserving the human-icon vertical correction. **desktop/tests/e2e/mentions.spec.ts** Adds a rendered narrow-composer regression that checks two fragments, static icon geometry, first-line icon space, and continuation-line alignment to ordinary chip padding. </details> ## Reproduction steps 1. Open a channel in Buzz Desktop. 2. Narrow the chat composer enough for `#all-replies` to wrap. 3. Confirm the channel icon occupies only the first line and `replies` starts at the chip's normal left padding rather than an icon-sized inset. 4. Send or view a long inline Buzz chip in the message list at a constrained width. 5. Confirm its icon remains attached to the leading fragment and its remaining label continues cleanly on following lines. ## Screenshots **Composer — wrapped `#all-replies` channel reference**  **Message list — existing wrapped inline-chip rendering preserved**  Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -259,10 +259,10 @@
|
||||
|
||||
Plain-text mention decorations stay inline because inline-flex pulls the
|
||||
caret into their decorated range and swallows the trailing space after an
|
||||
@mention. Composer Buzz links are atom nodes, but their labels may still
|
||||
fragment between characters; only the icon and leading label fragment stay
|
||||
together. The line-height keeps each inline decoration box tall enough to
|
||||
paint both block-padding edges without clipping. */
|
||||
@mention. Their generated icon participates in the first line's inline
|
||||
flow: absolute positioning anchors it to the union of every wrapped line,
|
||||
and icon-sized left padding is cloned onto every continuation fragment.
|
||||
Composer Buzz links are atom nodes with their own leading-fragment wrapper. */
|
||||
.rich-text-composer .tiptap .mention-chip {
|
||||
display: inline;
|
||||
min-height: 0;
|
||||
@@ -272,3 +272,25 @@
|
||||
var(--inline-chip-padding-block-end)
|
||||
);
|
||||
}
|
||||
|
||||
.rich-text-composer
|
||||
.tiptap
|
||||
.mention-chip.inline-chip-with-icon:not(.wrapping-inline-chip) {
|
||||
padding-left: var(--inline-chip-padding-inline);
|
||||
}
|
||||
|
||||
.rich-text-composer
|
||||
.tiptap
|
||||
.mention-chip.inline-chip-with-icon:not(.wrapping-inline-chip)::before {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
margin-right: var(--inline-chip-icon-gap);
|
||||
transform: none;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
.rich-text-composer
|
||||
.tiptap
|
||||
.mention-chip.inline-chip-icon-human:not(.wrapping-inline-chip)::before {
|
||||
transform: translateY(0.0625rem);
|
||||
}
|
||||
|
||||
@@ -1190,6 +1190,84 @@ test("typing an unregistered @token before existing text is left alone", async (
|
||||
await expect(input).toHaveText("hello @zzq world");
|
||||
});
|
||||
|
||||
test("wrapped channel references keep the icon on the first composer line", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await page.getByTestId("message-input-scroll").evaluate((element) => {
|
||||
element.style.width = "74px";
|
||||
});
|
||||
await input.fill("#all-replies");
|
||||
|
||||
const channelChip = input.locator(".inline-chip-icon-channel", {
|
||||
hasText: "all-replies",
|
||||
});
|
||||
await expect(channelChip).toBeVisible();
|
||||
// CSSOM exposes pseudo-element styles but not their rendered box.
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const { root } = await cdp.send("DOM.getDocument", {
|
||||
depth: -1,
|
||||
pierce: true,
|
||||
});
|
||||
const { nodeId } = await cdp.send("DOM.querySelector", {
|
||||
nodeId: root.nodeId,
|
||||
selector: ".rich-text-composer .inline-chip-icon-channel",
|
||||
});
|
||||
const { node } = await cdp.send("DOM.describeNode", { nodeId, depth: 1 });
|
||||
const before = node.pseudoElements?.find(
|
||||
(pseudo) => pseudo.pseudoType === "before",
|
||||
);
|
||||
if (!before) {
|
||||
throw new Error("Channel chip is missing its generated icon");
|
||||
}
|
||||
const iconBox = await cdp.send("DOM.getBoxModel", { nodeId: before.nodeId });
|
||||
const iconTop = iconBox.model.border[1];
|
||||
await cdp.detach();
|
||||
const geometry = await channelChip.evaluate((element) => {
|
||||
const textNode = element.firstChild;
|
||||
if (!(textNode instanceof Text)) {
|
||||
throw new Error("Channel chip is missing its decorated text node");
|
||||
}
|
||||
const textRange = document.createRange();
|
||||
textRange.selectNodeContents(textNode);
|
||||
const rects = (source: DOMRectList) =>
|
||||
Array.from(source, (rect) => ({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
}));
|
||||
const iconStyle = getComputedStyle(element, "::before");
|
||||
const tokenProbe = document.createElement("span");
|
||||
tokenProbe.style.cssText =
|
||||
"position:fixed;width:var(--inline-chip-padding-inline)";
|
||||
element.append(tokenProbe);
|
||||
const tokenPadding = tokenProbe.getBoundingClientRect().width;
|
||||
tokenProbe.remove();
|
||||
return {
|
||||
chipRects: rects(element.getClientRects()),
|
||||
iconPosition: iconStyle.position,
|
||||
iconTransform: iconStyle.transform,
|
||||
tokenPadding,
|
||||
textRects: rects(textRange.getClientRects()),
|
||||
};
|
||||
});
|
||||
expect(geometry.chipRects).toHaveLength(2);
|
||||
expect(geometry.textRects).toHaveLength(2);
|
||||
expect(geometry.iconPosition).toBe("static");
|
||||
expect(geometry.iconTransform).toBe("none");
|
||||
expect(
|
||||
geometry.textRects[0].left - geometry.chipRects[0].left,
|
||||
).toBeGreaterThan(geometry.tokenPadding);
|
||||
expect(geometry.textRects[1].left - geometry.chipRects[1].left).toBeCloseTo(
|
||||
geometry.tokenPadding,
|
||||
0,
|
||||
);
|
||||
expect(iconTop - geometry.textRects[0].top).toBeCloseTo(2.5, 0);
|
||||
});
|
||||
|
||||
test("channel references keep caret movement through the channel name", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user