UNPKG

@pdfme/schemas

Version:

TypeScript base PDF generator and React base UI. Open source, developed by the community, and completely free to use under the MIT license!

289 lines (256 loc) 9.13 kB
import type * as CSS from 'csstype'; import type { Font as FontKitFont } from 'fontkit'; import { UIRenderProps, getDefaultFont } from '@pdfme/common'; import type { TextSchema } from './types.js'; import { DEFAULT_FONT_SIZE, DEFAULT_ALIGNMENT, VERTICAL_ALIGN_TOP, VERTICAL_ALIGN_MIDDLE, VERTICAL_ALIGN_BOTTOM, DEFAULT_VERTICAL_ALIGNMENT, DEFAULT_LINE_HEIGHT, DEFAULT_CHARACTER_SPACING, DEFAULT_FONT_COLOR, PLACEHOLDER_FONT_COLOR, } from './constants.js'; import { calculateDynamicFontSize, getFontKitFont, getBrowserVerticalFontAdjustments, isFirefox, } from './helper.js'; import { isEditable } from '../utils.js'; const replaceUnsupportedChars = (text: string, fontKitFont: FontKitFont): string => { const charSupportCache: { [char: string]: boolean } = {}; const isCharSupported = (char: string): boolean => { if (char in charSupportCache) { return charSupportCache[char]; } const isSupported = fontKitFont.hasGlyphForCodePoint(char.codePointAt(0) || 0); charSupportCache[char] = isSupported; return isSupported; }; const segments = text.split(/(\r\n|\n|\r)/); return segments .map((segment) => { if (/\r\n|\n|\r/.test(segment)) { return segment; } return segment .split('') .map((char) => { if (/\s/.test(char) || char.charCodeAt(0) < 32) { return char; } return isCharSupported(char) ? char : '〿'; }) .join(''); }) .join(''); }; export const uiRender = async (arg: UIRenderProps<TextSchema>) => { const { value, schema, mode, onChange, stopEditing, tabIndex, placeholder, options, _cache } = arg; const usePlaceholder = isEditable(mode, schema) && placeholder && !value; const getText = (element: HTMLDivElement) => { let text = element.innerText; if (text.endsWith('\n')) { // contenteditable adds additional newline char retrieved with innerText text = text.slice(0, -1); } return text; }; const font = options?.font || getDefaultFont(); const fontKitFont = await getFontKitFont( schema.fontName, font, _cache as Map<string, import('fontkit').Font>, ); const textBlock = buildStyledTextContainer( arg, fontKitFont, usePlaceholder ? placeholder : value, ); const processedText = replaceUnsupportedChars(value, fontKitFont); if (!isEditable(mode, schema)) { // Read-only mode textBlock.innerHTML = processedText .split('') .map( (l, i) => `<span style="letter-spacing:${ String(value).length === i + 1 ? 0 : 'inherit' };">${l}</span>`, ) .join(''); return; } makeElementPlainTextContentEditable(textBlock); textBlock.tabIndex = tabIndex || 0; textBlock.innerText = mode === 'designer' ? value : processedText; textBlock.addEventListener('blur', (e: Event) => { if (onChange) onChange({ key: 'content', value: getText(e.target as HTMLDivElement) }); if (stopEditing) stopEditing(); }); if (schema.dynamicFontSize) { let dynamicFontSize: undefined | number = undefined; textBlock.addEventListener('keyup', () => { setTimeout(() => { // Use a regular function instead of an async one since we don't need await (() => { if (!textBlock.textContent) return; dynamicFontSize = calculateDynamicFontSize({ textSchema: schema, fontKitFont, value: getText(textBlock), startingFontSize: dynamicFontSize, }); textBlock.style.fontSize = `${dynamicFontSize}pt`; const { topAdj: newTopAdj, bottomAdj: newBottomAdj } = getBrowserVerticalFontAdjustments( fontKitFont, dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE, schema.lineHeight ?? DEFAULT_LINE_HEIGHT, schema.verticalAlignment ?? DEFAULT_VERTICAL_ALIGNMENT, ); textBlock.style.paddingTop = `${newTopAdj}px`; textBlock.style.marginBottom = `${newBottomAdj}px`; })(); }, 0); }); } if (usePlaceholder) { textBlock.style.color = PLACEHOLDER_FONT_COLOR; textBlock.addEventListener('focus', () => { if (textBlock.innerText === placeholder) { textBlock.innerText = ''; textBlock.style.color = schema.fontColor ?? DEFAULT_FONT_COLOR; } }); } if (mode === 'designer') { setTimeout(() => { textBlock.focus(); // Set the focus to the end of the editable element when you focus, as we would for a textarea const selection = window.getSelection(); const range = document.createRange(); if (selection && range) { range.selectNodeContents(textBlock); range.collapse(false); // Collapse range to the end selection?.removeAllRanges(); selection?.addRange(range); } }); } }; export const buildStyledTextContainer = ( arg: UIRenderProps<TextSchema>, fontKitFont: FontKitFont, value: string, ) => { const { schema, rootElement, mode } = arg; let dynamicFontSize: undefined | number = undefined; if (schema.dynamicFontSize && value) { dynamicFontSize = calculateDynamicFontSize({ textSchema: schema, fontKitFont, value, startingFontSize: dynamicFontSize, }); } // Depending on vertical alignment, we need to move the top or bottom of the font to keep // it within it's defined box and align it with the generated pdf. const { topAdj, bottomAdj } = getBrowserVerticalFontAdjustments( fontKitFont, dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE, schema.lineHeight ?? DEFAULT_LINE_HEIGHT, schema.verticalAlignment ?? DEFAULT_VERTICAL_ALIGNMENT, ); const topAdjustment = topAdj.toString(); const bottomAdjustment = bottomAdj.toString(); const container = document.createElement('div'); const containerStyle: CSS.Properties = { padding: 0, resize: 'none', backgroundColor: getBackgroundColor(value, schema), border: 'none', display: 'flex', flexDirection: 'column', justifyContent: mapVerticalAlignToFlex(schema.verticalAlignment), width: '100%', height: '100%', cursor: isEditable(mode, schema) ? 'text' : 'default', }; Object.assign(container.style, containerStyle); rootElement.innerHTML = ''; rootElement.appendChild(container); // text decoration const textDecorations = []; if (schema.strikethrough) textDecorations.push('line-through'); if (schema.underline) textDecorations.push('underline'); const textBlockStyle: CSS.Properties = { // Font formatting styles fontFamily: schema.fontName ? `'${schema.fontName}'` : 'inherit', color: schema.fontColor ? schema.fontColor : DEFAULT_FONT_COLOR, fontSize: `${dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE}pt`, letterSpacing: `${schema.characterSpacing ?? DEFAULT_CHARACTER_SPACING}pt`, lineHeight: `${schema.lineHeight ?? DEFAULT_LINE_HEIGHT}em`, textAlign: schema.alignment ?? DEFAULT_ALIGNMENT, whiteSpace: 'pre-wrap', wordBreak: 'break-word', // Block layout styles resize: 'none', border: 'none', outline: 'none', marginBottom: `${bottomAdjustment}px`, paddingTop: `${topAdjustment}px`, backgroundColor: 'transparent', textDecoration: textDecorations.join(' '), }; const textBlock = document.createElement('div'); textBlock.id = 'text-' + String(schema.id); Object.assign(textBlock.style, textBlockStyle); container.appendChild(textBlock); return textBlock; }; /** * Firefox doesn't support 'plaintext-only' contentEditable mode, which we want to avoid mark-up. * This function adds a workaround for Firefox to make the contentEditable element behave like 'plaintext-only'. */ export const makeElementPlainTextContentEditable = (element: HTMLElement) => { if (!isFirefox()) { element.contentEditable = 'plaintext-only'; return; } element.contentEditable = 'true'; element.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); document.execCommand('insertLineBreak', false, undefined); } }); element.addEventListener('paste', (e: ClipboardEvent) => { e.preventDefault(); const paste = e.clipboardData?.getData('text'); const selection = window.getSelection(); if (!selection?.rangeCount) return; selection.deleteFromDocument(); selection.getRangeAt(0).insertNode(document.createTextNode(paste || '')); selection.collapseToEnd(); }); }; export const mapVerticalAlignToFlex = (verticalAlignmentValue: string | undefined) => { switch (verticalAlignmentValue) { case VERTICAL_ALIGN_TOP: return 'flex-start'; case VERTICAL_ALIGN_MIDDLE: return 'center'; case VERTICAL_ALIGN_BOTTOM: return 'flex-end'; } return 'flex-start'; }; const getBackgroundColor = (value: string, schema: { backgroundColor?: string }) => { if (!value || !schema.backgroundColor) return 'transparent'; return schema.backgroundColor; };