mirror of
https://github.com/documenso/documenso.git
synced 2026-07-09 20:45:00 +10:00
fix: handle hover styling for field containers
This commit is contained in:
@@ -134,6 +134,15 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
|
||||
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
|
||||
| `.embed--DocumentCompleted` | Completion screen after signing |
|
||||
| `.field--FieldRootContainer` | Base container for document fields |
|
||||
| `.field--FieldRootContainerHover` | Hover state for document fields |
|
||||
|
||||
<Callout type="info">
|
||||
Fields are rendered onto a canvas, which has no real `:hover` pseudo-class. To
|
||||
style a field's hover state, target the `.field--FieldRootContainerHover` class
|
||||
instead of `:hover`. The properties that take effect on hover are
|
||||
`background-color`, `border-color`, `border-width`, `border-radius` and
|
||||
`opacity`. Any of these you leave unset keep their resting value.
|
||||
</Callout>
|
||||
|
||||
### Field Data Attributes
|
||||
|
||||
@@ -159,6 +168,12 @@ Fields expose data attributes for state-based styling:
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Style the field hover state */
|
||||
.field--FieldRootContainerHover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Custom widget styling */
|
||||
.embed--DocumentWidget {
|
||||
background-color: #ffffff;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,18 @@ export const FIELD_ROOT_CONTAINER_CLASS_NAME = `${FIELD_ROOT_CONTAINER_SHARED_CL
|
||||
|
||||
export const FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME = `field--FieldRootContainerProbe ${FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME}`;
|
||||
|
||||
/**
|
||||
* Class embedders target to style a field's hover state, e.g.
|
||||
* `.field--FieldRootContainerHover { background-color: ... }`.
|
||||
*
|
||||
* Canvas-rendered fields can't use the `:hover` pseudo-class (there's no real
|
||||
* DOM element to hover), so the hover probe applies this class permanently to
|
||||
* read the hovered cascade synchronously via `getComputedStyle`. Using a real
|
||||
* class instead of `:hover` keeps resolution deterministic — no stylesheet
|
||||
* parsing, pseudo-class synthesis, or source-order/specificity guesswork.
|
||||
*/
|
||||
export const FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME = 'field--FieldRootContainerHover';
|
||||
|
||||
/**
|
||||
* Selector for the element the probe is appended into when reading computed
|
||||
* field styles. It must be an ancestor of where real fields render so the probe
|
||||
|
||||
Reference in New Issue
Block a user