mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/document-file-conversion. Conflicts were format-only (Tailwind class ordering, single-line vs multi-line) plus two semantic merges: - files.helpers.ts: combine main's pending-PDF download path with the branch's original-source-file (DOCX/PNG/JPEG) download path - download-pdf.ts: combine main's versionToFilenameSuffix helper with the branch's server-provided Content-Disposition filename support
This commit is contained in:
@@ -42,7 +42,7 @@ export type ApiRequestMetadata = {
|
||||
};
|
||||
|
||||
export const extractRequestMetadata = (req: Request): RequestMetadata => {
|
||||
let ip: string | undefined = undefined;
|
||||
let ip: string | undefined;
|
||||
|
||||
try {
|
||||
ip = getIpAddress(req);
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import Konva from 'konva';
|
||||
|
||||
import type { TFieldOverflowMode } from '../../types/field-meta';
|
||||
|
||||
type OverflowLayoutParams = {
|
||||
/** The resolved overflow mode ('crop' | 'auto' | 'horizontal' | 'vertical'). */
|
||||
overflowMode: TFieldOverflowMode;
|
||||
|
||||
/** True when rendering the field type name (like "Text", "Date", "Email") or a user label, not actual user content. */
|
||||
isLabel: boolean;
|
||||
|
||||
/** The text content to render. Used to determine if text overflows the field bounds. */
|
||||
textToRender: string;
|
||||
|
||||
/** Font size in pixels. */
|
||||
fontSize: number;
|
||||
|
||||
/** CSS font family string. */
|
||||
fontFamily: string;
|
||||
|
||||
/** Line height multiplier. */
|
||||
lineHeight: number;
|
||||
|
||||
/** Letter spacing in pixels. */
|
||||
letterSpacing: number;
|
||||
|
||||
/** Horizontal text alignment. */
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
|
||||
/** Vertical text alignment. */
|
||||
verticalAlign: 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Text x position within the group (e.g. padding offset). */
|
||||
baseX: number;
|
||||
|
||||
/** Text y position within the group. */
|
||||
baseY: number;
|
||||
|
||||
/** Text width at field bounds (fieldWidth minus any padding). */
|
||||
baseWidth: number;
|
||||
|
||||
/** Text height at field bounds (fieldHeight). */
|
||||
baseHeight: number;
|
||||
|
||||
/** Group x position on the page (fieldX from calculateFieldPosition). */
|
||||
groupX: number;
|
||||
|
||||
/** Group y position on the page (fieldY from calculateFieldPosition). */
|
||||
groupY: number;
|
||||
|
||||
/** Full page width in pixels. */
|
||||
pageWidth: number;
|
||||
|
||||
/** Full page height in pixels. */
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
type OverflowLayoutResult = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
wrap: 'word' | 'none';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
verticalAlign: 'top' | 'middle' | 'bottom';
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate layout metrics for the text within the field.
|
||||
*
|
||||
* Returns:
|
||||
* - exceedsWidth: whether the unwrapped text exceeds the field width
|
||||
* - exceedsHeightWhenWrapped: whether wrapping the text at field width exceeds the field height
|
||||
* - hasRoomForMoreThanOneLine: whether the field can fit 2+ lines of text
|
||||
*/
|
||||
const calculateLayout = (params: {
|
||||
textToRender: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
}): {
|
||||
exceedsWidth: boolean;
|
||||
exceedsHeightWhenWrapped: boolean;
|
||||
hasRoomForMoreThanOneLine: boolean;
|
||||
} => {
|
||||
const { textToRender, fontSize, fontFamily, lineHeight, letterSpacing, baseWidth, baseHeight } = params;
|
||||
|
||||
// Measure the text without width constraint to get natural width and single-line height.
|
||||
const unwrappedNode = new Konva.Text({
|
||||
text: textToRender,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
lineHeight,
|
||||
letterSpacing,
|
||||
});
|
||||
|
||||
const exceedsWidth = unwrappedNode.width() > baseWidth;
|
||||
const oneLineHeight = unwrappedNode.height();
|
||||
|
||||
unwrappedNode.destroy();
|
||||
|
||||
const hasRoomForMoreThanOneLine = baseHeight >= oneLineHeight * 2;
|
||||
|
||||
// Measure the text wrapped at field width to check vertical overflow.
|
||||
const wrappedNode = new Konva.Text({
|
||||
text: textToRender,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
lineHeight,
|
||||
letterSpacing,
|
||||
width: baseWidth,
|
||||
wrap: 'word',
|
||||
});
|
||||
|
||||
const exceedsHeightWhenWrapped = wrappedNode.height() > baseHeight;
|
||||
|
||||
wrappedNode.destroy();
|
||||
|
||||
return { exceedsWidth, exceedsHeightWhenWrapped, hasRoomForMoreThanOneLine };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate horizontal overflow layout based on text alignment.
|
||||
*
|
||||
* The text node is expanded beyond the field bounds toward the page edges.
|
||||
* - left-aligned: extends rightward to page right edge
|
||||
* - right-aligned: extends leftward to page left edge
|
||||
* - center-aligned: extends symmetrically toward the closer page edge
|
||||
*/
|
||||
const calculateHorizontalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
|
||||
const { textAlign, baseX, baseY, baseWidth, baseHeight, groupX, pageWidth } = params;
|
||||
|
||||
if (textAlign === 'right') {
|
||||
// Extend leftward to page left edge.
|
||||
// Right edge of text stays at (baseX + baseWidth) within the group.
|
||||
const newX = -groupX;
|
||||
const newWidth = groupX + baseX + baseWidth;
|
||||
|
||||
return {
|
||||
x: newX,
|
||||
y: baseY,
|
||||
width: newWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'none',
|
||||
textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (textAlign === 'center') {
|
||||
// Extend symmetrically from the text center toward the closer page edge.
|
||||
const leftSpace = groupX + baseX;
|
||||
const rightSpace = pageWidth - (groupX + baseX + baseWidth);
|
||||
const maxExtend = Math.min(leftSpace, rightSpace);
|
||||
|
||||
const newX = baseX - maxExtend;
|
||||
const newWidth = baseWidth + maxExtend * 2;
|
||||
|
||||
return {
|
||||
x: newX,
|
||||
y: baseY,
|
||||
width: newWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'none',
|
||||
textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: left-aligned — extend rightward to page right edge.
|
||||
const newWidth = pageWidth - groupX - baseX;
|
||||
|
||||
return {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: newWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'none',
|
||||
textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate vertical overflow layout based on vertical alignment.
|
||||
*
|
||||
* The text node keeps the field width (text wraps) and expands height toward the page edges.
|
||||
* - top aligned: extends downward to page bottom
|
||||
* - bottom aligned: extends upward to page top
|
||||
* - middle aligned: extends symmetrically up and down toward the closer page edge
|
||||
*/
|
||||
const calculateVerticalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
|
||||
const { verticalAlign, textAlign, baseX, baseY, baseWidth, baseHeight, groupY, pageHeight } = params;
|
||||
|
||||
if (verticalAlign === 'bottom') {
|
||||
// Extend upward to page top edge.
|
||||
// Bottom edge of text stays at (baseY + baseHeight) within the group.
|
||||
const newY = -groupY;
|
||||
const newHeight = groupY + baseY + baseHeight;
|
||||
|
||||
return {
|
||||
x: baseX,
|
||||
y: newY,
|
||||
width: baseWidth,
|
||||
height: newHeight,
|
||||
wrap: 'word',
|
||||
textAlign,
|
||||
verticalAlign: 'bottom',
|
||||
};
|
||||
}
|
||||
|
||||
if (verticalAlign === 'middle') {
|
||||
// Extend both up and down from the field center.
|
||||
// Text stays vertically centered at the original field position.
|
||||
const upSpace = groupY + baseY;
|
||||
const downSpace = pageHeight - (groupY + baseY + baseHeight);
|
||||
const maxExtend = Math.min(upSpace, downSpace);
|
||||
|
||||
const newY = baseY - maxExtend;
|
||||
const newHeight = baseHeight + maxExtend * 2;
|
||||
|
||||
return {
|
||||
x: baseX,
|
||||
y: newY,
|
||||
width: baseWidth,
|
||||
height: newHeight,
|
||||
wrap: 'word',
|
||||
textAlign,
|
||||
verticalAlign: 'middle',
|
||||
};
|
||||
}
|
||||
|
||||
// Default: top — extend downward to page bottom edge.
|
||||
const newHeight = pageHeight - groupY - baseY;
|
||||
|
||||
return {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: baseWidth,
|
||||
height: newHeight,
|
||||
wrap: 'word',
|
||||
textAlign,
|
||||
verticalAlign: 'top',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate overflow-aware text layout dimensions.
|
||||
*
|
||||
* Returns { x, y, width, height, wrap } to spread into a Konva.Text setAttrs() call.
|
||||
*
|
||||
* For 'crop' mode or placeholder content, returns the original field bounds (current behavior).
|
||||
* For 'horizontal'/'vertical'/'auto', expands the text node dimensions toward the page edges
|
||||
* based on text alignment and field position.
|
||||
*/
|
||||
export const calculateOverflowLayout = (params: OverflowLayoutParams): OverflowLayoutResult => {
|
||||
const { overflowMode, isLabel, baseX, baseY, baseWidth, baseHeight } = params;
|
||||
|
||||
// No overflow for placeholders or crop mode — return original field bounds.
|
||||
if (isLabel || overflowMode === 'crop') {
|
||||
return {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'word',
|
||||
textAlign: params.textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (overflowMode === 'horizontal') {
|
||||
return calculateHorizontalOverflow(params);
|
||||
}
|
||||
|
||||
if (overflowMode === 'vertical') {
|
||||
return calculateVerticalOverflow(params);
|
||||
}
|
||||
|
||||
// Auto mode: measure the text and field to decide overflow direction.
|
||||
const layout = calculateLayout({
|
||||
textToRender: params.textToRender,
|
||||
fontSize: params.fontSize,
|
||||
fontFamily: params.fontFamily,
|
||||
lineHeight: params.lineHeight,
|
||||
letterSpacing: params.letterSpacing,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
});
|
||||
|
||||
// Auto single-line: overflow horizontal only when text exceeds field width.
|
||||
// Center text align is overridden to left so it overflows right.
|
||||
if (!layout.hasRoomForMoreThanOneLine) {
|
||||
if (!layout.exceedsWidth) {
|
||||
// Text fits — keep original alignment, no overflow needed.
|
||||
return {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'none',
|
||||
textAlign: params.textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
return calculateHorizontalOverflow({
|
||||
...params,
|
||||
textAlign: params.textAlign === 'center' ? 'left' : params.textAlign,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto multi-line: overflow vertical only when wrapped text exceeds field height.
|
||||
// Middle vertical align is only overridden to top if the text actually overflows vertically.
|
||||
// If it fits, keep middle so the text stays centered within the field.
|
||||
if (!layout.exceedsHeightWhenWrapped) {
|
||||
// Text fits when wrapped — keep original alignment, no overflow needed.
|
||||
return {
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
wrap: 'word',
|
||||
textAlign: params.textAlign,
|
||||
verticalAlign: params.verticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
const verticalAlignOverride = params.verticalAlign === 'middle' ? 'top' : params.verticalAlign;
|
||||
|
||||
return calculateVerticalOverflow({
|
||||
...params,
|
||||
verticalAlign: verticalAlignOverride,
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,6 @@
|
||||
import { DEFAULT_RECT_BACKGROUND, getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import Konva from 'konva';
|
||||
|
||||
import {
|
||||
DEFAULT_RECT_BACKGROUND,
|
||||
getRecipientColorStyles,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
@@ -12,17 +8,10 @@ export const konvaTextFontFamily =
|
||||
'"Noto Sans", "Noto Sans Japanese", "Noto Sans Chinese", "Noto Sans Korean", sans-serif';
|
||||
export const konvaTextFill = 'black';
|
||||
|
||||
export const upsertFieldGroup = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Group => {
|
||||
export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Group => {
|
||||
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
||||
field,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
);
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
const fieldGroup: Konva.Group =
|
||||
pageLayer.findOne(`#${field.renderId}`) ||
|
||||
@@ -51,10 +40,7 @@ export const upsertFieldGroup = (
|
||||
return fieldGroup;
|
||||
};
|
||||
|
||||
export const upsertFieldRect = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Rect => {
|
||||
export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Rect => {
|
||||
const { pageWidth, pageHeight, mode, pageLayer, color } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
@@ -80,13 +66,7 @@ export const upsertFieldRect = (
|
||||
return fieldRect;
|
||||
};
|
||||
|
||||
export const createSpinner = ({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
}: {
|
||||
fieldWidth: number;
|
||||
fieldHeight: number;
|
||||
}) => {
|
||||
export const createSpinner = ({ fieldWidth, fieldHeight }: { fieldWidth: number; fieldHeight: number }) => {
|
||||
const loadingGroup = new Konva.Group({
|
||||
name: 'loading-spinner-group',
|
||||
});
|
||||
@@ -139,11 +119,7 @@ type CreateFieldHoverInteractionOptions = {
|
||||
/**
|
||||
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
|
||||
*/
|
||||
export const createFieldHoverInteraction = ({
|
||||
options,
|
||||
fieldGroup,
|
||||
fieldRect,
|
||||
}: CreateFieldHoverInteractionOptions) => {
|
||||
export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }: CreateFieldHoverInteractionOptions) => {
|
||||
const { mode } = options;
|
||||
|
||||
if (mode === 'export' || !options.color) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { FieldType, Signature } from '@prisma/client';
|
||||
import { type Field } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import type { Field, FieldType, Signature } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import type { TFieldMetaSchema } from '../../types/field-meta';
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
|
||||
// Do not change any of these values without consulting with the team.
|
||||
const checkboxFieldPadding = 8;
|
||||
@@ -22,10 +22,7 @@ const calculateCheckboxSize = (fontSize: number) => {
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
export const renderCheckboxFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
export const renderCheckboxFieldElement = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode, color } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
@@ -82,18 +79,17 @@ export const renderCheckboxFieldElement = (
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { squareElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateCheckboxSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateCheckboxSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
squareElement.setAttrs({
|
||||
x: itemInputX,
|
||||
@@ -148,18 +144,17 @@ export const renderCheckboxFieldElement = (
|
||||
|
||||
const itemSize = calculateCheckboxSize(fontSize);
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: index,
|
||||
itemSize,
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: index,
|
||||
itemSize,
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
const square = new Konva.Rect({
|
||||
internalCheckboxIndex: index,
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
type CalculateDropdownPositionOptions = {
|
||||
fieldWidth: number;
|
||||
@@ -46,10 +46,7 @@ const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) =>
|
||||
};
|
||||
};
|
||||
|
||||
export const renderDropdownFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
export const renderDropdownFieldElement = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode, translations, color } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
@@ -93,11 +90,10 @@ export const renderDropdownFieldElement = (
|
||||
selectedValue = field.customText;
|
||||
}
|
||||
|
||||
const { arrowX, arrowY, arrowSize, textX, textY, textWidth, textHeight } =
|
||||
calculateDropdownPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
});
|
||||
const { arrowX, arrowY, arrowSize, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
});
|
||||
|
||||
// Selected value text
|
||||
const selectedText = new Konva.Text({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import type { Signature } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { TFieldMetaSchema } from '../../types/field-meta';
|
||||
import { renderCheckboxFieldElement } from './render-checkbox-field';
|
||||
import { renderDropdownFieldElement } from './render-dropdown-field';
|
||||
@@ -79,14 +78,8 @@ export const renderField = ({
|
||||
|
||||
// If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type
|
||||
return match(field.type)
|
||||
.with(
|
||||
FieldType.INITIALS,
|
||||
FieldType.NAME,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
FieldType.TEXT,
|
||||
FieldType.NUMBER,
|
||||
() => renderGenericTextFieldElement(field, options),
|
||||
.with(FieldType.INITIALS, FieldType.NAME, FieldType.EMAIL, FieldType.DATE, FieldType.TEXT, FieldType.NUMBER, () =>
|
||||
renderGenericTextFieldElement(field, options),
|
||||
)
|
||||
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
|
||||
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
||||
FIELD_DEFAULT_LETTER_SPACING,
|
||||
FIELD_DEFAULT_LINE_HEIGHT,
|
||||
resolveFieldOverflowMode,
|
||||
} from '../../types/field-meta';
|
||||
import { calculateOverflowLayout } from './calculate-overflow-layout';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
konvaTextFill,
|
||||
@@ -20,10 +22,10 @@ import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
const DEFAULT_TEXT_X_PADDING = 6;
|
||||
|
||||
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
|
||||
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
|
||||
|
||||
@@ -41,7 +43,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
const textY = 0;
|
||||
const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
// By default, render the field name or label centered
|
||||
// By default, render the field name or label centered.
|
||||
// isLabel tracks whether we're rendering the field type name (like "Text", "Date", "Email")
|
||||
// or a user label — overflow should not apply to these, only to actual content.
|
||||
let isLabel = true;
|
||||
let textToRender: string = fieldMeta?.label || fieldTypeName;
|
||||
let textAlign: 'left' | 'center' | 'right' = 'center';
|
||||
let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
|
||||
@@ -53,6 +58,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
|
||||
|
||||
if (value) {
|
||||
isLabel = false;
|
||||
textToRender = value;
|
||||
|
||||
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
|
||||
@@ -73,6 +79,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
|
||||
|
||||
if (value) {
|
||||
isLabel = false;
|
||||
textToRender = value;
|
||||
|
||||
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
|
||||
@@ -84,6 +91,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
|
||||
// Override everything with value if it's inserted.
|
||||
if (field.inserted) {
|
||||
isLabel = false;
|
||||
textToRender = field.customText;
|
||||
|
||||
textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
|
||||
@@ -95,32 +103,60 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
}
|
||||
}
|
||||
|
||||
const overflowLayout = calculateOverflowLayout({
|
||||
overflowMode: resolveFieldOverflowMode(fieldMeta),
|
||||
isLabel,
|
||||
textToRender,
|
||||
fontSize: textFontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
lineHeight: textLineHeight,
|
||||
letterSpacing: textLetterSpacing,
|
||||
textAlign,
|
||||
verticalAlign: textVerticalAlign,
|
||||
baseX: textX + DEFAULT_TEXT_X_PADDING,
|
||||
baseY: textY,
|
||||
baseWidth: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
|
||||
baseHeight: fieldHeight,
|
||||
groupX: fieldX,
|
||||
groupY: fieldY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
// Note: Do not use native text padding since it's uniform.
|
||||
// We only want to have padding on the left and right hand sides.
|
||||
fieldText.setAttrs({
|
||||
x: textX + DEFAULT_TEXT_X_PADDING,
|
||||
y: textY,
|
||||
verticalAlign: textVerticalAlign,
|
||||
wrap: 'word',
|
||||
x: overflowLayout.x,
|
||||
y: overflowLayout.y,
|
||||
verticalAlign: overflowLayout.verticalAlign,
|
||||
wrap: overflowLayout.wrap,
|
||||
text: textToRender,
|
||||
fontSize: textFontSize,
|
||||
align: textAlign,
|
||||
align: overflowLayout.textAlign,
|
||||
lineHeight: textLineHeight,
|
||||
letterSpacing: textLetterSpacing,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
fill: konvaTextFill,
|
||||
width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
|
||||
height: fieldHeight,
|
||||
width: overflowLayout.width,
|
||||
height: overflowLayout.height,
|
||||
} satisfies Partial<Konva.TextConfig>);
|
||||
|
||||
return fieldText;
|
||||
return {
|
||||
fieldText,
|
||||
isLabel,
|
||||
textToRender,
|
||||
textFontSize,
|
||||
textAlign,
|
||||
textVerticalAlign,
|
||||
textLineHeight,
|
||||
textLetterSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
export const renderGenericTextFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
export const renderGenericTextFieldElement = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { mode = 'edit', pageLayer, color } = options;
|
||||
const { pageWidth, pageHeight } = options;
|
||||
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
@@ -136,13 +172,20 @@ export const renderGenericTextFieldElement = (
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldText = upsertFieldText(field, options);
|
||||
const {
|
||||
fieldText,
|
||||
isLabel,
|
||||
textToRender,
|
||||
textFontSize,
|
||||
textAlign,
|
||||
textVerticalAlign,
|
||||
textLineHeight,
|
||||
textLetterSpacing,
|
||||
} = upsertFieldText(field, options);
|
||||
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
@@ -154,17 +197,16 @@ export const renderGenericTextFieldElement = (
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Update text dimensions
|
||||
// During active transform, use crop dimensions (field bounds only).
|
||||
fieldText.x(DEFAULT_TEXT_X_PADDING);
|
||||
fieldText.y(0);
|
||||
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldText.height();
|
||||
fieldText.wrap('word');
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
@@ -172,12 +214,33 @@ export const renderGenericTextFieldElement = (
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
|
||||
fieldText.height(rectHeight);
|
||||
// Recalculate overflow layout with new field dimensions.
|
||||
const newOverflowLayout = calculateOverflowLayout({
|
||||
overflowMode: resolveFieldOverflowMode(fieldMeta),
|
||||
isLabel,
|
||||
textToRender,
|
||||
fontSize: textFontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
lineHeight: textLineHeight,
|
||||
letterSpacing: textLetterSpacing,
|
||||
textAlign,
|
||||
verticalAlign: textVerticalAlign,
|
||||
baseX: DEFAULT_TEXT_X_PADDING,
|
||||
baseY: 0,
|
||||
baseWidth: rectWidth - DEFAULT_TEXT_X_PADDING * 2,
|
||||
baseHeight: rectHeight,
|
||||
groupX: fieldGroup.x(),
|
||||
groupY: fieldGroup.y(),
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldText.height();
|
||||
fieldText.x(newOverflowLayout.x);
|
||||
fieldText.y(newOverflowLayout.y);
|
||||
fieldText.width(newOverflowLayout.width);
|
||||
fieldText.height(newOverflowLayout.height);
|
||||
fieldText.wrap(newOverflowLayout.wrap);
|
||||
fieldText.verticalAlign(newOverflowLayout.verticalAlign);
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
@@ -40,9 +40,7 @@ export function calculateSnapPositions(
|
||||
stage: Konva.Stage,
|
||||
excludeId?: string,
|
||||
): { horizontal: SnapPoint[]; vertical: SnapPoint[] } {
|
||||
const fieldGroups = stage
|
||||
.find('.field-group')
|
||||
.filter((node): node is Konva.Group => node instanceof Konva.Group);
|
||||
const fieldGroups = stage.find('.field-group').filter((node): node is Konva.Group => node instanceof Konva.Group);
|
||||
const horizontal: SnapPoint[] = [];
|
||||
const vertical: SnapPoint[] = [];
|
||||
|
||||
@@ -71,13 +69,8 @@ export function calculateSnapPositions(
|
||||
return { horizontal, vertical };
|
||||
}
|
||||
|
||||
export function calculateSnapSizes(
|
||||
stage: Konva.Stage,
|
||||
excludeId?: string,
|
||||
): { widths: number[]; heights: number[] } {
|
||||
const fieldGroups = stage
|
||||
.find('.field-group')
|
||||
.filter((node): node is Konva.Group => node instanceof Konva.Group);
|
||||
export function calculateSnapSizes(stage: Konva.Stage, excludeId?: string): { widths: number[]; heights: number[] } {
|
||||
const fieldGroups = stage.find('.field-group').filter((node): node is Konva.Group => node instanceof Konva.Group);
|
||||
const widths: number[] = [];
|
||||
const heights: number[] = [];
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
|
||||
// Do not change any of these values without consulting with the team.
|
||||
const radioFieldPadding = 8;
|
||||
@@ -21,10 +21,7 @@ const calculateRadioSize = (fontSize: number) => {
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
export const renderRadioFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
export const renderRadioFieldElement = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode, color } = options;
|
||||
|
||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||
@@ -73,18 +70,17 @@ export const renderRadioFieldElement = (
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { circleElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
circleElement.setAttrs({
|
||||
x: itemInputX,
|
||||
@@ -137,18 +133,17 @@ export const renderRadioFieldElement = (
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: index,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: index,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
// Circle which represents the radio button.
|
||||
const circle = new Konva.Circle({
|
||||
|
||||
@@ -2,16 +2,15 @@ import Konva from 'konva';
|
||||
|
||||
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
import type { TSignatureFieldMeta } from '../../types/field-meta';
|
||||
import { resolveFieldOverflowMode } from '../../types/field-meta';
|
||||
import { calculateOverflowLayout } from './calculate-overflow-layout';
|
||||
import { createFieldHoverInteraction, upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SkiaImage: any = undefined;
|
||||
let SkiaImage: any;
|
||||
|
||||
void (async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -40,13 +39,83 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
|
||||
};
|
||||
};
|
||||
|
||||
const createFieldSignature = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Text | Konva.Image => {
|
||||
type FieldSignature =
|
||||
| {
|
||||
node: Konva.Text;
|
||||
isImageSignature: false;
|
||||
isLabel: boolean;
|
||||
}
|
||||
| {
|
||||
node: Konva.Image;
|
||||
isImageSignature: true;
|
||||
isLabel: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pixel ratio used when caching the signature image as an offscreen bitmap.
|
||||
*
|
||||
* Konva's default redraw composites the source image with low-quality scaling
|
||||
* which makes signatures look blurry, especially when the source PNG is much
|
||||
* larger than the field. Caching at a high pixel ratio rasterises the shape
|
||||
* once into a sharp bitmap that is then reused on every redraw.
|
||||
*
|
||||
* Multiplied by `devicePixelRatio` to keep the cache crisp on retina displays.
|
||||
*/
|
||||
const SIGNATURE_IMAGE_CACHE_PIXEL_RATIO = 2;
|
||||
|
||||
/**
|
||||
* Build a Konva.Image for a base64 signature, sized to fit within the given
|
||||
* field dimensions. Works in both browser and Node.js (via skia-canvas).
|
||||
*/
|
||||
const createSignatureImage = (signatureImageAsBase64: string, fieldWidth: number, fieldHeight: number): Konva.Image => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const img = new Image();
|
||||
|
||||
const image = new Konva.Image({
|
||||
image: img,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
});
|
||||
|
||||
img.onload = () => {
|
||||
image.setAttrs({
|
||||
image: img,
|
||||
...getImageDimensions(img, fieldWidth, fieldHeight),
|
||||
});
|
||||
|
||||
// Cache the image as a high-resolution bitmap so it stays sharp on
|
||||
// redraws and zoom changes instead of being re-scaled from the source PNG
|
||||
// every time.
|
||||
image.cache({
|
||||
pixelRatio: SIGNATURE_IMAGE_CACHE_PIXEL_RATIO * (window.devicePixelRatio || 1),
|
||||
});
|
||||
};
|
||||
|
||||
img.src = signatureImageAsBase64;
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
// Node.js with skia-canvas
|
||||
if (!SkiaImage) {
|
||||
throw new Error('Skia image not found');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const img = new SkiaImage(signatureImageAsBase64) as unknown as HTMLImageElement;
|
||||
|
||||
return new Konva.Image({
|
||||
image: img,
|
||||
...getImageDimensions(img, fieldWidth, fieldHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const createFieldSignature = (field: FieldToRender, options: RenderFieldElementOptions): FieldSignature => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
|
||||
|
||||
const fieldText = new Konva.Text({
|
||||
@@ -67,6 +136,20 @@ const createFieldSignature = (
|
||||
// Handle edit mode.
|
||||
if (mode === 'edit') {
|
||||
textToRender = fieldTypeName;
|
||||
|
||||
// If the field has already been signed and we have the signature data
|
||||
// available, render it. Otherwise leave the field type label as a placeholder.
|
||||
if (field.inserted && signature?.typedSignature) {
|
||||
textToRender = signature.typedSignature;
|
||||
}
|
||||
|
||||
if (field.inserted && signature?.signatureImageAsBase64) {
|
||||
return {
|
||||
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
|
||||
isImageSignature: true,
|
||||
isLabel: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sign mode.
|
||||
@@ -82,68 +165,58 @@ const createFieldSignature = (
|
||||
}
|
||||
|
||||
if (signature?.signatureImageAsBase64) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Create a new HTML Image element
|
||||
const img = new Image();
|
||||
|
||||
const image = new Konva.Image({
|
||||
image: img,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
});
|
||||
|
||||
img.onload = () => {
|
||||
image.setAttrs({
|
||||
image: img,
|
||||
...getImageDimensions(img, fieldWidth, fieldHeight),
|
||||
});
|
||||
};
|
||||
|
||||
img.src = signature.signatureImageAsBase64;
|
||||
|
||||
return image;
|
||||
} else {
|
||||
// Node.js with skia-canvas
|
||||
if (!SkiaImage) {
|
||||
throw new Error('Skia image not found');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement;
|
||||
|
||||
const image = new Konva.Image({
|
||||
image: img,
|
||||
...getImageDimensions(img, fieldWidth, fieldHeight),
|
||||
});
|
||||
|
||||
return image;
|
||||
}
|
||||
return {
|
||||
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
|
||||
isImageSignature: true,
|
||||
isLabel: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fieldText.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
|
||||
|
||||
// Whether we're rendering the field type name (like "Signature") vs actual signed content.
|
||||
// Overflow should not apply to the label.
|
||||
const isLabel = !signature?.typedSignature;
|
||||
|
||||
const overflowLayout = calculateOverflowLayout({
|
||||
overflowMode: resolveFieldOverflowMode(fieldMeta),
|
||||
isLabel,
|
||||
textToRender,
|
||||
fontSize,
|
||||
fontFamily: 'Caveat, sans-serif',
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
wrap: 'char',
|
||||
baseX: textX,
|
||||
baseY: textY,
|
||||
baseWidth: fieldWidth,
|
||||
baseHeight: fieldHeight,
|
||||
groupX: fieldX,
|
||||
groupY: fieldY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
fieldText.setAttrs({
|
||||
x: overflowLayout.x,
|
||||
y: overflowLayout.y,
|
||||
verticalAlign: overflowLayout.verticalAlign,
|
||||
wrap: overflowLayout.wrap,
|
||||
text: textToRender,
|
||||
fontSize,
|
||||
fontFamily: 'Caveat, sans-serif',
|
||||
align: 'center',
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
align: overflowLayout.textAlign,
|
||||
width: overflowLayout.width,
|
||||
height: overflowLayout.height,
|
||||
} satisfies Partial<Konva.TextConfig>);
|
||||
|
||||
return fieldText;
|
||||
return { node: fieldText, isImageSignature: false, isLabel };
|
||||
};
|
||||
|
||||
export const renderSignatureFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
const { mode = 'edit', pageLayer, color } = options;
|
||||
export const renderSignatureFieldElement = (field: FieldToRender, options: RenderFieldElementOptions) => {
|
||||
const { mode = 'edit', pageLayer, pageWidth, pageHeight, color } = options;
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
@@ -160,13 +233,11 @@ export const renderSignatureFieldElement = (
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldSignature = createFieldSignature(field, options);
|
||||
const { node: fieldSignature, isImageSignature, isLabel } = createFieldSignature(field, options);
|
||||
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldSignature);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
@@ -178,17 +249,19 @@ export const renderSignatureFieldElement = (
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Update text dimensions
|
||||
// During active transform, use crop dimensions (field bounds only).
|
||||
if (!isImageSignature) {
|
||||
fieldSignature.x(0);
|
||||
fieldSignature.y(0);
|
||||
fieldSignature.wrap('word');
|
||||
}
|
||||
|
||||
fieldSignature.width(rectWidth);
|
||||
fieldSignature.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldSignature.height();
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldSignature.scaleX(1);
|
||||
fieldSignature.scaleY(1);
|
||||
@@ -196,12 +269,39 @@ export const renderSignatureFieldElement = (
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Update text dimensions
|
||||
fieldSignature.width(rectWidth); // Account for padding
|
||||
fieldSignature.height(rectHeight);
|
||||
if (!isImageSignature) {
|
||||
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldSignature.height();
|
||||
const newOverflowLayout = calculateOverflowLayout({
|
||||
overflowMode: resolveFieldOverflowMode(fieldMeta),
|
||||
isLabel,
|
||||
textToRender: fieldSignature.text(),
|
||||
fontSize: fieldSignature.fontSize(),
|
||||
fontFamily: 'Caveat, sans-serif',
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
baseX: 0,
|
||||
baseY: 0,
|
||||
baseWidth: rectWidth,
|
||||
baseHeight: rectHeight,
|
||||
groupX: fieldGroup.x(),
|
||||
groupY: fieldGroup.y(),
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
fieldSignature.x(newOverflowLayout.x);
|
||||
fieldSignature.y(newOverflowLayout.y);
|
||||
fieldSignature.width(newOverflowLayout.width);
|
||||
fieldSignature.height(newOverflowLayout.height);
|
||||
fieldSignature.wrap(newOverflowLayout.wrap);
|
||||
fieldSignature.verticalAlign(newOverflowLayout.verticalAlign);
|
||||
} else {
|
||||
fieldSignature.width(rectWidth);
|
||||
fieldSignature.height(rectHeight);
|
||||
}
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const generateTwitterIntent = (text: string, shareUrl: string) => {
|
||||
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
||||
text,
|
||||
)}%0A%0A${encodeURIComponent(shareUrl)}`;
|
||||
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}%0A%0A${encodeURIComponent(shareUrl)}`;
|
||||
};
|
||||
|
||||
@@ -36,13 +36,7 @@ export const generateDatabaseId = (prefix: DatabaseIdPrefix) => prefixedId(prefi
|
||||
|
||||
export const extractLegacyIds = (envelope: Pick<Envelope, 'type' | 'secondaryId'>) => {
|
||||
return {
|
||||
documentId:
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
? mapSecondaryIdToDocumentId(envelope.secondaryId)
|
||||
: null,
|
||||
templateId:
|
||||
envelope.type === EnvelopeType.TEMPLATE
|
||||
? mapSecondaryIdToTemplateId(envelope.secondaryId)
|
||||
: null,
|
||||
documentId: envelope.type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(envelope.secondaryId) : null,
|
||||
templateId: envelope.type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(envelope.secondaryId) : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import type {
|
||||
TGetPresignedPostUrlResponse,
|
||||
TUploadPdfResponse,
|
||||
} from '@documenso/remix/server/api/files/files.types';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
@@ -67,24 +63,19 @@ const putFileInDatabase = async (file: File) => {
|
||||
};
|
||||
|
||||
const putFileInS3 = async (file: File) => {
|
||||
const getPresignedUrlResponse = await fetch(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
}),
|
||||
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!getPresignedUrlResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`,
|
||||
);
|
||||
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
|
||||
}
|
||||
|
||||
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
|
||||
@@ -100,9 +91,7 @@ const putFileInS3 = async (file: File) => {
|
||||
});
|
||||
|
||||
if (!reponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to upload file "${file.name}", failed with status code ${reponse.status}`,
|
||||
);
|
||||
throw new Error(`Failed to upload file "${file.name}", failed with status code ${reponse.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import path from 'node:path';
|
||||
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
|
||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||
import { alphaid } from '../id';
|
||||
@@ -144,8 +138,7 @@ const getS3Client = () => {
|
||||
throw new Error('Invalid upload transport');
|
||||
}
|
||||
|
||||
const hasCredentials =
|
||||
env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
|
||||
const hasCredentials = env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
|
||||
|
||||
return new S3Client({
|
||||
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
|
||||
|
||||
Reference in New Issue
Block a user