@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!
437 lines (396 loc) • 15.1 kB
text/typescript
import type { UIRenderProps, Mode } from '@pdfme/common';
import type { TableSchema, CellStyle, Styles } from './types.js';
import { px2mm, ZOOM } from '@pdfme/common';
import { createSingleTable } from './tableHelper.js';
import { getBody, getBodyWithRange } from './helper.js';
import cell from './cell.js';
import { Row } from './classes.js';
const buttonSize = 30;
function createButton(options: {
width: number;
height: number;
top: string;
left?: string;
right?: string;
text: string;
onClick: (e: MouseEvent) => void;
}): HTMLButtonElement {
const button = document.createElement('button');
button.style.width = `${options.width}px`;
button.style.height = `${options.height}px`;
button.style.position = 'absolute';
button.style.top = options.top;
if (options.left !== undefined) {
button.style.left = options.left;
}
if (options.right !== undefined) {
button.style.right = options.right;
}
button.innerText = options.text;
button.onclick = options.onClick;
return button;
}
type RowType = InstanceType<typeof Row>;
const cellUiRender = cell.ui;
const convertToCellStyle = (styles: Styles): CellStyle => ({
fontName: styles.fontName,
alignment: styles.alignment,
verticalAlignment: styles.verticalAlignment,
fontSize: styles.fontSize,
lineHeight: styles.lineHeight,
characterSpacing: styles.characterSpacing,
backgroundColor: styles.backgroundColor,
// ---
fontColor: styles.textColor,
borderColor: styles.lineColor,
borderWidth: styles.lineWidth,
padding: styles.cellPadding,
});
const calcResizedHeadWidthPercentages = (arg: {
currentHeadWidthPercentages: number[];
currentHeadWidths: number[];
changedHeadWidth: number;
changedHeadIndex: number;
}) => {
const { currentHeadWidthPercentages, currentHeadWidths, changedHeadWidth, changedHeadIndex } =
arg;
const headWidthPercentages = [...currentHeadWidthPercentages];
const totalWidth = currentHeadWidths.reduce((a, b) => a + b, 0);
const changedWidthPercentage = (changedHeadWidth / totalWidth) * 100;
const originalNextWidthPercentage = headWidthPercentages[changedHeadIndex + 1] ?? 0;
const adjustment = headWidthPercentages[changedHeadIndex] - changedWidthPercentage;
headWidthPercentages[changedHeadIndex] = changedWidthPercentage;
if (changedHeadIndex + 1 < headWidthPercentages.length) {
headWidthPercentages[changedHeadIndex + 1] = originalNextWidthPercentage + adjustment;
}
return headWidthPercentages;
};
const setBorder = (
div: HTMLDivElement,
borderPosition: 'Top' | 'Left' | 'Right' | 'Bottom',
arg: UIRenderProps<TableSchema>,
) => {
div.style[`border${borderPosition}`] = `${String(arg.schema.tableStyles.borderWidth)}mm solid ${
arg.schema.tableStyles.borderColor
}`;
};
const drawBorder = (
div: HTMLDivElement,
row: RowType,
colIndex: number,
rowIndex: number,
rowsLength: number,
arg: UIRenderProps<TableSchema>,
) => {
const isFirstColumn = colIndex === 0;
const isLastColumn = colIndex === Object.values(row.cells).length - 1;
const isLastRow = rowIndex === rowsLength - 1;
if (row.section === 'head') {
setBorder(div, 'Top', arg);
if (isFirstColumn) setBorder(div, 'Left', arg);
if (isLastColumn) setBorder(div, 'Right', arg);
if ((JSON.parse(arg.value || '[]') as string[][]).length === 0) {
setBorder(div, 'Bottom', arg);
}
} else if (row.section === 'body') {
if (!arg.schema.showHead && rowIndex === 0) {
setBorder(div, 'Top', arg);
}
if (isFirstColumn) setBorder(div, 'Left', arg);
if (isLastColumn) setBorder(div, 'Right', arg);
if (isLastRow) setBorder(div, 'Bottom', arg);
}
};
const renderRowUi = (args: {
rows: RowType[];
arg: UIRenderProps<TableSchema>;
editingPosition: { rowIndex: number; colIndex: number };
onChangeEditingPosition: (position: { rowIndex: number; colIndex: number }) => void;
offsetY?: number;
}) => {
const { rows, arg, onChangeEditingPosition, offsetY = 0, editingPosition } = args;
const value = JSON.parse(arg.value || '[]') as string[][];
let rowOffsetY = offsetY;
rows.forEach((row, rowIndex) => {
const { cells, height, section } = row;
let colOffsetX = 0;
Object.values(cells).forEach((cell, colIndex) => {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = `${rowOffsetY}mm`;
div.style.left = `${colOffsetX}mm`;
div.style.width = `${cell.width}mm`;
div.style.height = `${cell.height}mm`;
div.style.boxSizing = 'border-box';
drawBorder(div, row, colIndex, rowIndex, rows.length, arg);
div.style.cursor =
arg.mode === 'designer' || (arg.mode === 'form' && section === 'body') ? 'text' : 'default';
div.addEventListener('click', () => {
if (arg.mode === 'viewer') return;
onChangeEditingPosition({ rowIndex, colIndex });
});
arg.rootElement.appendChild(div);
const isEditing =
editingPosition.rowIndex === rowIndex && editingPosition.colIndex === colIndex;
let mode: Mode = 'viewer';
if (arg.mode === 'form') {
mode = section === 'body' && isEditing && !arg.schema.readOnly ? 'designer' : 'viewer';
} else if (arg.mode === 'designer') {
mode = isEditing ? 'designer' : 'form';
}
void cellUiRender({
...arg,
stopEditing: () => {
if (arg.mode === 'form') {
resetEditingPosition();
}
},
mode,
onChange: (v) => {
if (!arg.onChange) return;
const newValue = (Array.isArray(v) ? v[0].value : v.value) as string;
if (section === 'body') {
const startRange = arg.schema.__bodyRange?.start ?? 0;
value[rowIndex + startRange][colIndex] = newValue;
arg.onChange({ key: 'content', value: JSON.stringify(value) });
} else {
const newHead = [...arg.schema.head];
newHead[colIndex] = newValue;
arg.onChange({ key: 'head', value: newHead });
}
},
value: cell.raw,
placeholder: '',
rootElement: div,
schema: {
name: '',
type: 'cell',
content: cell.raw,
position: { x: colOffsetX, y: rowOffsetY },
width: cell.width,
height: cell.height,
...convertToCellStyle(cell.styles),
},
});
colOffsetX += cell.width;
});
rowOffsetY += height;
});
};
const headEditingPosition = { rowIndex: -1, colIndex: -1 };
const bodyEditingPosition = { rowIndex: -1, colIndex: -1 };
const resetEditingPosition = () => {
headEditingPosition.rowIndex = -1;
headEditingPosition.colIndex = -1;
bodyEditingPosition.rowIndex = -1;
bodyEditingPosition.colIndex = -1;
};
export const uiRender = async (arg: UIRenderProps<TableSchema>) => {
const { rootElement, onChange, schema, value, mode, scale} = arg;
const body = getBody(value);
const bodyWidthRange = getBodyWithRange(value, schema.__bodyRange);
const table = await createSingleTable(bodyWidthRange, arg);
const showHead = table.settings.showHead;
rootElement.innerHTML = '';
const handleChangeEditingPosition = (
newPosition: { rowIndex: number; colIndex: number },
editingPosition: { rowIndex: number; colIndex: number },
) => {
resetEditingPosition();
editingPosition.rowIndex = newPosition.rowIndex;
editingPosition.colIndex = newPosition.colIndex;
void uiRender(arg);
};
if (showHead) {
renderRowUi({
rows: table.head,
arg,
editingPosition: headEditingPosition,
onChangeEditingPosition: (p) => handleChangeEditingPosition(p, headEditingPosition),
});
}
const offsetY = showHead ? table.getHeadHeight() : 0;
renderRowUi({
rows: table.body,
arg,
editingPosition: bodyEditingPosition,
onChangeEditingPosition: (p) => {
handleChangeEditingPosition(p, bodyEditingPosition);
},
offsetY,
});
const createAddRowButton = () =>
createButton({
width: buttonSize,
height: buttonSize,
top: `${table.getHeight()}mm`,
left: `calc(50% - ${buttonSize / 2}px)`,
text: '+',
onClick: () => {
const newRow = Array(schema.head.length).fill('') as string[];
if (onChange) onChange({ key: 'content', value: JSON.stringify(body.concat([newRow])) });
},
});
const createRemoveRowButtons = () => {
let offsetY = showHead ? table.getHeadHeight() : 0;
return table.body.map((row, i) => {
offsetY = offsetY + row.height;
const removeRowButton = createButton({
width: buttonSize,
height: buttonSize,
top: `${offsetY - px2mm(buttonSize)}mm`,
right: `-${buttonSize}px`,
text: '-',
onClick: () => {
const newTableBody = body.filter((_, j) => j !== i + (schema.__bodyRange?.start ?? 0));
if (onChange) onChange({ key: 'content', value: JSON.stringify(newTableBody) });
},
});
return removeRowButton;
});
};
if (mode === 'form' && onChange && !schema.readOnly) {
if (
schema.__bodyRange?.end === undefined ||
schema.__bodyRange.end >= (JSON.parse(value || '[]') as string[][]).length
) {
rootElement.appendChild(createAddRowButton());
}
createRemoveRowButtons().forEach((button) => rootElement.appendChild(button));
}
if (mode === 'designer' && onChange) {
const addColumnButton = createButton({
width: buttonSize,
height: buttonSize,
top: `${(showHead ? table.getHeadHeight() : 0) - px2mm(buttonSize)}mm`,
right: `-${buttonSize}px`,
text: '+',
onClick: (e) => {
e.preventDefault();
const newColumnWidthPercentage = 25;
const totalCurrentWidth = schema.headWidthPercentages.reduce(
(acc, width) => acc + width,
0,
);
const scalingRatio = (100 - newColumnWidthPercentage) / totalCurrentWidth;
const scaledWidths = schema.headWidthPercentages.map((width) => width * scalingRatio);
onChange([
{ key: 'head', value: schema.head.concat(`Head ${schema.head.length + 1}`) },
{ key: 'headWidthPercentages', value: scaledWidths.concat(newColumnWidthPercentage) },
{
key: 'content',
value: JSON.stringify(bodyWidthRange.map((row, i) => row.concat(`Row ${i + 1}`))),
},
]);
},
});
rootElement.appendChild(addColumnButton);
rootElement.appendChild(createAddRowButton());
createRemoveRowButtons().forEach((button) => rootElement.appendChild(button));
let offsetX = 0;
table.columns.forEach((column, i, columns) => {
if (columns.length === 1) return;
offsetX = offsetX + column.width;
const removeColumnButton = createButton({
width: buttonSize,
height: buttonSize,
top: `${-buttonSize}px`,
left: `${offsetX - px2mm(buttonSize)}mm`,
text: '-',
onClick: () => {
const totalWidthMinusRemoved = schema.headWidthPercentages.reduce(
(sum, width, j) => (j !== i ? sum + width : sum),
0,
);
// TODO Should also remove the deleted columnStyles when deleting
onChange([
{ key: 'head', value: schema.head.filter((_, j) => j !== i) },
{
key: 'headWidthPercentages',
value: schema.headWidthPercentages
.filter((_, j) => j !== i)
.map((width) => (width / totalWidthMinusRemoved) * 100),
},
{
key: 'content',
value: JSON.stringify(bodyWidthRange.map((row) => row.filter((_, j) => j !== i))),
},
]);
},
});
rootElement.appendChild(removeColumnButton);
if (i === table.columns.length - 1) return;
const dragHandle = document.createElement('div');
const lineWidth = 5;
dragHandle.style.width = `${lineWidth}px`;
dragHandle.style.height = '100%';
dragHandle.style.backgroundColor = '#eee';
dragHandle.style.opacity = '0.5';
dragHandle.style.cursor = 'col-resize';
dragHandle.style.position = 'absolute';
dragHandle.style.zIndex = '10';
dragHandle.style.left = `${offsetX - px2mm(lineWidth) / 2}mm`;
dragHandle.style.top = '0';
const setColor = (e: MouseEvent) => {
const handle = e.target as HTMLDivElement;
handle.style.backgroundColor = '#2196f3';
};
const resetColor = (e: MouseEvent) => {
const handle = e.target as HTMLDivElement;
handle.style.backgroundColor = '#eee';
};
dragHandle.addEventListener('mouseover', setColor);
dragHandle.addEventListener('mouseout', resetColor);
const prevColumnLeft = offsetX - column.width;
const nextColumnRight = offsetX - px2mm(lineWidth) + table.columns[i + 1].width;
dragHandle.addEventListener('mousedown', (e) => {
resetEditingPosition();
const handle = e.target as HTMLDivElement;
dragHandle.removeEventListener('mouseover', setColor);
dragHandle.removeEventListener('mouseout', resetColor);
const startClientX = e.clientX;
const startLeft = Number(handle.style.left.replace('mm', ''));
let move = 0;
const mouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startClientX;
const moveX = deltaX / ZOOM / scale;
let newLeft = startLeft + moveX;
if (newLeft < prevColumnLeft) {
newLeft = prevColumnLeft;
}
if (newLeft >= nextColumnRight) {
newLeft = nextColumnRight;
}
handle.style.left = `${newLeft}mm`;
move = newLeft - startLeft;
};
rootElement.addEventListener('mousemove', mouseMove);
const commitResize = () => {
if (move !== 0) {
const newHeadWidthPercentages = calcResizedHeadWidthPercentages({
currentHeadWidthPercentages: schema.headWidthPercentages,
currentHeadWidths: table.columns.map((column) => column.width),
changedHeadWidth: table.columns[i].width + move,
changedHeadIndex: i,
});
onChange({ key: 'headWidthPercentages', value: newHeadWidthPercentages });
}
move = 0;
dragHandle.addEventListener('mouseover', setColor);
dragHandle.addEventListener('mouseout', resetColor);
rootElement.removeEventListener('mousemove', mouseMove);
rootElement.removeEventListener('mouseup', commitResize);
};
rootElement.addEventListener('mouseup', commitResize);
});
rootElement.appendChild(dragHandle);
});
}
if (mode === 'viewer') {
resetEditingPosition();
}
const tableHeight = showHead ? table.getHeight() : table.getBodyHeight();
if (schema.height !== tableHeight && onChange) {
onChange({ key: 'height', value: tableHeight });
}
};