fix: handle hover styling for field containers

This commit is contained in:
Lucas Smith
2026-06-22 16:14:04 +10:00
parent e2754ac314
commit ffbfdbf735
5 changed files with 140 additions and 58 deletions
@@ -1,10 +1,11 @@
import {
FIELD_PROBE_ANCHOR_SELECTOR,
FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME,
FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME,
} from '@documenso/ui/lib/field-root-container-classes';
import { colord } from 'colord';
import type { FieldCanvasStyle, FieldRenderMode, FieldToRender } from './field-renderer';
import type { FieldCanvasStyle, FieldCanvasStyleProps, FieldRenderMode, FieldToRender } from './field-renderer';
export type FieldCanvasStyleCache = Map<string, FieldCanvasStyle | undefined>;
@@ -98,6 +99,25 @@ const createFieldProbeElement = (field: FieldToRender): HTMLElement => {
return $probe;
};
/**
* Map a probe's computed style onto the canvas-handled prop set. Shared by the
* resting and hover reads so both honor every property the renderer can apply
* (see `field-generic-items.ts`): fill, stroke, stroke width, corner radius and
* group opacity.
*/
const extractCanvasStyleProps = (computedStyle: CSSStyleDeclaration): FieldCanvasStyleProps => {
const borderWidth = getPixelValue(computedStyle.borderTopWidth);
const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth);
return {
backgroundColor: getRenderableColor(computedStyle.backgroundColor),
borderColor: hasBorderStyle ? getRenderableColor(computedStyle.borderTopColor) : undefined,
borderRadius: getPixelValue(computedStyle.borderTopLeftRadius),
borderWidth: hasBorderStyle ? borderWidth : undefined,
opacity: getOpacityValue(computedStyle.opacity),
};
};
const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyle | undefined => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return undefined;
@@ -119,18 +139,17 @@ const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyl
$anchor.appendChild($probe);
try {
const computedStyle = window.getComputedStyle($probe);
const borderWidth = getPixelValue(computedStyle.borderTopWidth);
const borderColor = getRenderableColor(computedStyle.borderTopColor);
const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth);
const borderRadius = getPixelValue(computedStyle.borderTopLeftRadius);
const restingStyle = extractCanvasStyleProps(window.getComputedStyle($probe));
// Reuse the same element for the hover read: adding the hover class makes
// `getComputedStyle` resolve the hovered cascade synchronously — no real
// pointer or `:hover` pseudo-class needed.
$probe.classList.add(FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME);
const hoverStyle = extractCanvasStyleProps(window.getComputedStyle($probe));
return {
backgroundColor: getRenderableColor(computedStyle.backgroundColor),
borderColor: hasBorderStyle ? borderColor : undefined,
borderRadius,
borderWidth: hasBorderStyle ? borderWidth : undefined,
opacity: getOpacityValue(computedStyle.opacity),
...restingStyle,
hover: hoverStyle,
};
} finally {
$probe.remove();
@@ -8,6 +8,11 @@ export const konvaTextFontFamily =
'"Noto Sans", "Noto Sans Japanese", "Noto Sans Chinese", "Noto Sans Korean", sans-serif';
export const konvaTextFill = 'black';
// Renderer defaults, shared between `upsertFieldRect` and the hover interaction so
// hover tweens return the field to its actual idle state rather than a guess.
const DEFAULT_FIELD_STROKE_WIDTH = 2;
const DEFAULT_FIELD_CORNER_RADIUS = 2;
export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
@@ -59,8 +64,8 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen
height: fieldHeight,
fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'),
strokeWidth: fieldCanvasStyle?.borderWidth ?? 2,
cornerRadius: fieldCanvasStyle?.borderRadius ?? 2,
strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH,
cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS,
strokeScaleEnabled: false,
visible: mode !== 'export',
} satisfies Partial<Konva.RectConfig>);
@@ -119,71 +124,92 @@ type CreateFieldHoverInteractionOptions = {
fieldRect: Konva.Rect;
};
type FieldHoverTweenState = {
fill: string;
stroke: string;
strokeWidth: number;
cornerRadius: number;
opacity: number;
};
/**
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
*
* Resting and hover states both derive from the resolved field canvas style (read
* from a probe, so custom embed CSS is honored — hover via the
* `field--FieldRootContainerHover` class). Every canvas-handled property is
* respected on hover: fill, stroke, stroke width, corner radius and opacity. When
* the embedder hasn't customized a hover property (its probed value matches the
* resting value), it falls back to the resting value — except the background,
* which falls back to the default recipient hover color to preserve the built-in
* hover effect for teams without custom branding.
*/
export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }: CreateFieldHoverInteractionOptions) => {
const { mode } = options;
const { mode, color, fieldCanvasStyle } = options;
if (mode === 'export' || !options.color) {
if (mode === 'export' || !color) {
return;
}
if (options.fieldCanvasStyle?.backgroundColor) {
return;
}
const recipientStyles = getRecipientColorStyles(color);
const hoverColor = getRecipientColorStyles(options.color).baseRingHover;
const restingState: FieldHoverTweenState = {
fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
stroke: fieldCanvasStyle?.borderColor ?? recipientStyles.baseRing,
strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH,
cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS,
opacity: fieldCanvasStyle?.opacity ?? 1,
};
fieldGroup.on('mouseover', () => {
const layer = fieldRect.getLayer();
if (!layer) {
const hover = fieldCanvasStyle?.hover;
// A hover prop is "customized" only when it differs from the resting value;
// otherwise the hover class resolved to nothing and we keep the resting value.
const pickHover = <T>(hoverValue: T | undefined, restingValue: T, customizedFallback: T): T => {
if (hoverValue === undefined || hoverValue === restingValue) {
return customizedFallback;
}
return hoverValue;
};
const hoverState: FieldHoverTweenState = {
// Background is special: when uncustomized, fall back to the recipient hover
// color rather than the resting fill so the default hover effect still shows.
fill: pickHover(hover?.backgroundColor, restingState.fill, recipientStyles.baseRingHover),
stroke: pickHover(hover?.borderColor, restingState.stroke, restingState.stroke),
strokeWidth: pickHover(hover?.borderWidth, restingState.strokeWidth, restingState.strokeWidth),
cornerRadius: pickHover(hover?.borderRadius, restingState.cornerRadius, restingState.cornerRadius),
opacity: pickHover(hover?.opacity, restingState.opacity, restingState.opacity),
};
const tweenTo = (state: FieldHoverTweenState) => {
if (!fieldRect.getLayer()) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
fill: state.fill,
stroke: state.stroke,
strokeWidth: state.strokeWidth,
cornerRadius: state.cornerRadius,
}).play();
});
fieldGroup.on('mouseout', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
node: fieldGroup,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
opacity: state.opacity,
}).play();
});
};
fieldGroup.on('transformstart', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
// Field groups are reused across re-renders (upserted via `findOne`), so clear
// any previously-bound hover listeners before re-registering to avoid stacking.
fieldGroup.off('mouseover.fieldHover mouseout.fieldHover transformstart.fieldHover transformend.fieldHover');
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('transformend', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.on('mouseover.fieldHover', () => tweenTo(hoverState));
fieldGroup.on('mouseout.fieldHover', () => tweenTo(restingState));
fieldGroup.on('transformstart.fieldHover', () => tweenTo(hoverState));
fieldGroup.on('transformend.fieldHover', () => tweenTo(restingState));
};
@@ -44,7 +44,7 @@ export type RenderFieldElementOptions = {
translations: Record<FieldType, string> | null;
};
export type FieldCanvasStyle = {
export type FieldCanvasStyleProps = {
backgroundColor?: string;
borderColor?: string;
borderRadius?: number;
@@ -52,6 +52,16 @@ export type FieldCanvasStyle = {
opacity?: number;
};
export type FieldCanvasStyle = FieldCanvasStyleProps & {
/**
* The custom hover state, resolved from the `field--FieldRootContainerHover`
* class. Mirrors the resting prop set so any canvas-handled property can change
* on hover. Properties left unset fall back to the resting value (and the
* background falls back to the default recipient hover color).
*/
hover?: FieldCanvasStyleProps;
};
/**
* Converts a fields percentage based values to pixel based values.
*/