Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 147 additions & 13 deletions src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@ const BUTTON_SIZE_PX = 18;
const FIELD_INSET_PX = 8;
const PADDING_RESERVE_PX = BUTTON_SIZE_PX + FIELD_INSET_PX * 2;
const SUCCESS_STATE_MS = 650;
const INLINE_OBSTACLE_SELECTOR = [
"button",
"a[href]",
"input",
"textarea",
"select",
"[contenteditable='true']",
"[role='button']",
"[role='combobox']",
"[role='textbox']",
].join(", ");

export const MANUAL_ATTACH_BUTTON_CLASS = "ft-manual-attach-button";
export const MANUAL_ATTACH_TOOLTIP = "Click to enable FluentTyper for this field.";

export type ManualAttachTarget = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
type ManualAttachSurfaceTone = "light" | "dark";

interface RgbaColor {
r: number;
g: number;
b: number;
a: number;
}

interface ParentPositionState {
count: number;
Expand All @@ -28,6 +47,7 @@ interface ManualAttachUiHandle {
button: HTMLButtonElement;
icon: HTMLImageElement;
checkmark: HTMLSpanElement;
surfaceTone: ManualAttachSurfaceTone;
originalPaddingInlineEnd: string;
successTimer: ReturnType<typeof setTimeout> | null;
successPending: boolean;
Expand All @@ -53,6 +73,7 @@ export class ManualAttachUiManager {
const existing = this.handles.get(element);
if (existing) {
this.updatePlacement(element, existing);
this.updateSurfaceTone(element, existing);
if (!existing.successPending) {
this.applyIdleState(existing);
}
Expand Down Expand Up @@ -137,6 +158,7 @@ export class ManualAttachUiManager {
transition:
"transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease",
outline: "none",
backdropFilter: "blur(8px) saturate(1.12)",
});

const icon = element.ownerDocument.createElement("img");
Expand Down Expand Up @@ -176,6 +198,7 @@ export class ManualAttachUiManager {
button,
icon,
checkmark,
surfaceTone: this.resolveSurfaceTone(element),
originalPaddingInlineEnd: this.getInlineEndPaddingStyleValue(element),
successTimer: null,
successPending: false,
Expand Down Expand Up @@ -223,15 +246,20 @@ export class ManualAttachUiManager {
}

private applyIdleState(handle: ManualAttachUiHandle): void {
const isDarkSurface = handle.surfaceTone === "dark";
Object.assign(handle.button.style, {
backgroundColor: "rgba(255, 255, 255, 0.82)",
borderColor: "transparent",
boxShadow: "none",
backgroundColor: isDarkSurface ? "rgba(15, 23, 42, 0.92)" : "rgba(255, 255, 255, 0.9)",
borderColor: isDarkSurface ? "rgba(148, 163, 184, 0.34)" : "rgba(148, 163, 184, 0.2)",
boxShadow: isDarkSurface
? "0 10px 18px -14px rgba(2, 6, 23, 0.96)"
: "0 8px 14px -14px rgba(15, 23, 42, 0.4)",
transform: "scale(1)",
});
Object.assign(handle.icon.style, {
opacity: "0.55",
filter: "grayscale(1) saturate(0) contrast(0.98)",
opacity: isDarkSurface ? "0.96" : "0.72",
filter: isDarkSurface
? "drop-shadow(0 1px 2px rgba(2, 6, 23, 0.45))"
: "grayscale(0.24) saturate(0.78) contrast(1.02)",
});
Object.assign(handle.checkmark.style, {
opacity: "0",
Expand All @@ -240,15 +268,18 @@ export class ManualAttachUiManager {
}

private applyHoverState(handle: ManualAttachUiHandle): void {
const isDarkSurface = handle.surfaceTone === "dark";
Object.assign(handle.button.style, {
backgroundColor: "rgba(255, 255, 255, 0.96)",
borderColor: "rgba(14, 165, 233, 0.24)",
boxShadow: "0 6px 14px -10px rgba(14, 165, 233, 0.9)",
backgroundColor: isDarkSurface ? "rgba(30, 41, 59, 0.98)" : "rgba(255, 255, 255, 0.98)",
borderColor: isDarkSurface ? "rgba(96, 165, 250, 0.48)" : "rgba(14, 165, 233, 0.32)",
boxShadow: isDarkSurface
? "0 10px 20px -12px rgba(96, 165, 250, 0.62)"
: "0 8px 18px -12px rgba(14, 165, 233, 0.58)",
transform: "scale(1.05)",
});
Object.assign(handle.icon.style, {
opacity: "1",
filter: "none",
filter: isDarkSurface ? "drop-shadow(0 1px 2px rgba(2, 6, 23, 0.42))" : "none",
});
Object.assign(handle.checkmark.style, {
opacity: "0",
Expand All @@ -257,22 +288,30 @@ export class ManualAttachUiManager {
}

private applySuccessState(handle: ManualAttachUiHandle): void {
const isDarkSurface = handle.surfaceTone === "dark";
Object.assign(handle.button.style, {
backgroundColor: "rgba(236, 253, 245, 0.98)",
borderColor: "rgba(16, 185, 129, 0.32)",
boxShadow: "0 8px 18px -12px rgba(4, 120, 87, 0.95)",
backgroundColor: isDarkSurface ? "rgba(6, 78, 59, 0.94)" : "rgba(236, 253, 245, 0.98)",
borderColor: isDarkSurface ? "rgba(52, 211, 153, 0.48)" : "rgba(16, 185, 129, 0.32)",
boxShadow: isDarkSurface
? "0 10px 20px -12px rgba(4, 120, 87, 0.92)"
: "0 8px 18px -12px rgba(4, 120, 87, 0.95)",
transform: "scale(1.08)",
});
Object.assign(handle.icon.style, {
opacity: "0",
filter: "none",
});
Object.assign(handle.checkmark.style, {
color: isDarkSurface ? "#d1fae5" : "#047857",
opacity: "1",
transform: "scale(1)",
});
}

private updateSurfaceTone(element: ManualAttachTarget, handle: ManualAttachUiHandle): void {
handle.surfaceTone = this.resolveSurfaceTone(element);
}

private updatePlacement(element: ManualAttachTarget, handle: ManualAttachUiHandle): void {
const elementRect = element.getBoundingClientRect();
const isTextarea = element.tagName.toLowerCase() === "textarea";
Expand Down Expand Up @@ -351,11 +390,15 @@ export class ManualAttachUiManager {
}
const elementRect = element.getBoundingClientRect();
const elementMidpoint = elementRect.left + elementRect.width / 2;
const sameWrapperCandidates = Array.from(positioningParent.querySelectorAll("*")).filter(
// Only consider peer layout boxes around the editor. Deep descendants inside
// complex editors (like Slack) can appear/disappear while typing and should
// not yank the manual-attach control around.
const sameWrapperCandidates = Array.from(positioningParent.children).filter(
(candidate): candidate is HTMLElement =>
candidate instanceof HTMLElement &&
candidate !== element &&
candidate !== handle.container &&
this.isInlineObstacleCandidate(candidate) &&
!element.contains(candidate) &&
!candidate.contains(element),
);
Expand Down Expand Up @@ -385,6 +428,97 @@ export class ManualAttachUiManager {
return candidateRect.bottom > elementRect.top && candidateRect.top < elementRect.bottom;
}

private isInlineObstacleCandidate(candidate: HTMLElement): boolean {
if (candidate.getAttribute("aria-hidden") === "true") {
return false;
}
if (candidate.matches(INLINE_OBSTACLE_SELECTOR)) {
return true;
}
return candidate.querySelector(INLINE_OBSTACLE_SELECTOR) instanceof HTMLElement;
}

private resolveSurfaceTone(element: ManualAttachTarget): ManualAttachSurfaceTone {
const colorSources = [element, ...this.collectAncestorElements(element)];
for (const candidate of colorSources) {
const backgroundColor =
candidate.ownerDocument.defaultView?.getComputedStyle(candidate).backgroundColor;
const parsed = this.parseCssColor(backgroundColor);
if (parsed && parsed.a > 0.05) {
return this.relativeLuminance(parsed) < 0.36 ? "dark" : "light";
}
}
return element.ownerDocument.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}

private collectAncestorElements(element: ManualAttachTarget): HTMLElement[] {
const ancestors: HTMLElement[] = [];
let current: HTMLElement | null = element;
while (current) {
const parentElement = current.parentElement;
if (this.isHtmlElement(parentElement, current.ownerDocument)) {
ancestors.push(parentElement);
current = parentElement;
continue;
}
const rootNode = current.getRootNode();
if (
this.isShadowRoot(rootNode, current.ownerDocument) &&
this.isHtmlElement(rootNode.host, current.ownerDocument)
) {
ancestors.push(rootNode.host);
current = rootNode.host;
continue;
}
current = null;
}
const body = element.ownerDocument.body;
if (body && !ancestors.includes(body)) {
ancestors.push(body);
}
return ancestors;
}

private parseCssColor(rawValue: string | undefined): RgbaColor | null {
if (!rawValue) {
return null;
}
const value = rawValue.trim().toLowerCase();
const rgbMatch = value.match(
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d*\.?\d+))?\s*\)$/,
);
if (!rgbMatch) {
return null;
}
const r = Number.parseInt(rgbMatch[1], 10);
const g = Number.parseInt(rgbMatch[2], 10);
const b = Number.parseInt(rgbMatch[3], 10);
const a = rgbMatch[4] === undefined ? 1 : Number.parseFloat(rgbMatch[4]);
if ([r, g, b, a].some((part) => Number.isNaN(part))) {
return null;
}
return {
r: this.clampChannel(r),
g: this.clampChannel(g),
b: this.clampChannel(b),
a: Math.min(Math.max(a, 0), 1),
};
}

private relativeLuminance(color: RgbaColor): number {
const channels = [color.r, color.g, color.b].map((channel) => {
const normalized = this.clampChannel(channel) / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}

private clampChannel(channel: number): number {
return Math.min(Math.max(Math.round(channel), 0), 255);
}

private resolveOffsetTop(
height: number,
options: {
Expand Down
92 changes: 92 additions & 0 deletions tests/SuggestionManagerRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,78 @@ describe("SuggestionManagerRuntime", () => {
expect(getManualAttachButton(editorShell)).not.toBeNull();
});

test("ignores nested decorative descendants when positioning contenteditable manual attach icon", () => {
const runtime = makeRuntime();
const shell = document.createElement("div");
const editorShell = document.createElement("div");
const editable = document.createElement("div");
const decorationLayer = document.createElement("div");
const decorationIcon = document.createElement("span");
const list = document.createElement("div");
list.id = "editable-list";
list.setAttribute("role", "listbox");
editable.setAttribute("contenteditable", "true");
Object.defineProperty(editable, "isContentEditable", {
configurable: true,
value: true,
});
editable.tabIndex = 0;
editable.setAttribute("role", "combobox");
editable.setAttribute("aria-expanded", "true");
editable.setAttribute("aria-controls", "editable-list");
decorationLayer.appendChild(decorationIcon);
editorShell.append(editable, decorationLayer);
shell.appendChild(editorShell);
document.body.append(shell, list);
mockRect(shell, { left: 10, top: 20, width: 260, height: 52 });
mockRect(editorShell, { left: 86, top: 24, width: 150, height: 40 });
mockRect(editable, { left: 94, top: 30, width: 150, height: 28 });
mockRect(decorationLayer, { left: 214, top: 26, width: 18, height: 28 });
mockRect(decorationIcon, { left: 214, top: 30, width: 14, height: 14 });

runtime.queryAndAttachHelper();

const container = getManualAttachContainer(editorShell);
expect(container).not.toBeNull();
expect(container?.style.left).toBe("132px");
});

test("uses a high-contrast dark surface treatment for manual attach icon", () => {
const runtime = makeRuntime();
const shell = document.createElement("div");
const editorShell = document.createElement("div");
const editable = document.createElement("div");
const list = document.createElement("div");
list.id = "editable-list";
list.setAttribute("role", "listbox");
shell.style.backgroundColor = "rgb(29, 28, 29)";
editorShell.style.backgroundColor = "rgb(29, 28, 29)";
editable.setAttribute("contenteditable", "true");
Object.defineProperty(editable, "isContentEditable", {
configurable: true,
value: true,
});
editable.tabIndex = 0;
editable.setAttribute("role", "combobox");
editable.setAttribute("aria-expanded", "true");
editable.setAttribute("aria-controls", "editable-list");
editorShell.appendChild(editable);
shell.appendChild(editorShell);
document.body.append(shell, list);
mockRect(shell, { left: 10, top: 20, width: 260, height: 52 });
mockRect(editorShell, { left: 86, top: 24, width: 150, height: 40 });
mockRect(editable, { left: 94, top: 30, width: 150, height: 28 });

runtime.queryAndAttachHelper();

const button = getManualAttachButton(editorShell);
const icon = button?.querySelector("img");
expect(button).not.toBeNull();
expect(button?.style.backgroundColor).toBe("rgba(15, 23, 42, 0.92)");
expect(button?.style.borderColor).toBe("rgba(148, 163, 184, 0.34)");
expect(icon?.style.filter).not.toContain("grayscale");
});

test("ignores non-overlapping rows when resolving contenteditable manual attach obstacles", () => {
const runtime = makeRuntime();
const shell = document.createElement("div");
Expand Down Expand Up @@ -1538,6 +1610,26 @@ describe("SuggestionManagerRuntime", () => {
expect(getManualAttachButton(host)).toBeNull();
});

test("uses dark surface styling for a shadow-hosted conflicting field on a dark host", () => {
const runtime = makeRuntime();
const host = document.createElement("div");
host.style.backgroundColor = "rgb(29, 28, 29)";
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const list = document.createElement("datalist");
list.id = "cities";
const shadowInput = document.createElement("input");
shadowInput.type = "text";
shadowInput.setAttribute("list", "cities");
shadow.append(list, shadowInput);

runtime.queryAndAttachHelper();

const button = getManualAttachButton(shadow);
expect(button).not.toBeNull();
expect(button?.style.backgroundColor).toBe("rgba(15, 23, 42, 0.92)");
});

test("removes the manual attach icon when a shadow-hosted conflicting field is removed", () => {
const runtime = makeRuntime();
const host = document.createElement("div");
Expand Down
Loading