优化
This commit is contained in:
@@ -19,14 +19,13 @@ import {
|
||||
} from './elementBox';
|
||||
import {
|
||||
iterTableCells,
|
||||
getCellRectMm,
|
||||
getCellAnchor,
|
||||
getCellData,
|
||||
getEffectiveCellStyle,
|
||||
getTableRows,
|
||||
getTableCols,
|
||||
getTableRowHeights,
|
||||
getTableColWidths,
|
||||
buildTableGridBoundariesPx,
|
||||
getCellRectPx,
|
||||
} from './tableUtils';
|
||||
|
||||
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
||||
@@ -90,6 +89,34 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return Promise.resolve(null);
|
||||
return loadImage(trimmed).catch(() => null);
|
||||
}
|
||||
|
||||
async function resolveElementImage(
|
||||
elem: TemplateElement,
|
||||
primarySrc: string,
|
||||
mode: ContentResolveMode
|
||||
): Promise<{ img: HTMLImageElement | null; showPlaceholder: boolean }> {
|
||||
const primary = primarySrc.trim();
|
||||
const fallback = (elem.defaultImage ?? '').trim();
|
||||
|
||||
let img = await loadImageOrNull(primary);
|
||||
if (!img && fallback) {
|
||||
img = await loadImageOrNull(fallback);
|
||||
}
|
||||
|
||||
if (img) return { img, showPlaceholder: false };
|
||||
|
||||
if (mode === 'design' && !primary && !fallback) {
|
||||
return { img: null, showPlaceholder: true };
|
||||
}
|
||||
|
||||
return { img: null, showPlaceholder: false };
|
||||
}
|
||||
|
||||
function drawImageWithFit(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
@@ -226,7 +253,7 @@ function layoutVerticalTextColumns(
|
||||
return columns;
|
||||
}
|
||||
|
||||
function drawTextInRect(
|
||||
function drawTextContentInBox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
boxX: number,
|
||||
@@ -236,9 +263,15 @@ function drawTextInRect(
|
||||
value: string,
|
||||
scale: number
|
||||
) {
|
||||
const pad = 2 * scale;
|
||||
const writableW = Math.max(1, boxW - pad * 2);
|
||||
const writableH = Math.max(1, boxH - pad * 2);
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const padPx = {
|
||||
top: Math.round(pad.top * scale),
|
||||
right: Math.round(pad.right * scale),
|
||||
bottom: Math.round(pad.bottom * scale),
|
||||
left: Math.round(pad.left * scale),
|
||||
};
|
||||
const writableW = Math.max(1, boxW - padPx.left - padPx.right);
|
||||
const writableH = Math.max(1, boxH - padPx.top - padPx.bottom);
|
||||
|
||||
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
|
||||
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
|
||||
@@ -267,8 +300,8 @@ function drawTextInRect(
|
||||
|
||||
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
|
||||
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
|
||||
const transformOriginX = boxX + boxW / 2;
|
||||
const transformOriginY = boxY + boxH / 2;
|
||||
const transformOriginX = boxX + padPx.left + writableW / 2;
|
||||
const transformOriginY = boxY + padPx.top + writableH / 2;
|
||||
|
||||
ctx.save();
|
||||
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||
@@ -285,11 +318,11 @@ function drawTextInRect(
|
||||
|
||||
let startX: number;
|
||||
if (elem.textAlign === 'center') {
|
||||
startX = boxX + pad + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
startX = boxX + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
} else if (elem.textAlign === 'right') {
|
||||
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
} else {
|
||||
startX = boxX + pad + columnWidth / 2;
|
||||
startX = boxX + padPx.left + columnWidth / 2;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
@@ -298,17 +331,18 @@ function drawTextInRect(
|
||||
const colTextHeight = column.length * lineHeight;
|
||||
let colStartY: number;
|
||||
if (vAlign === 'top') {
|
||||
colStartY = boxY + pad + lineHeight / 2;
|
||||
colStartY = boxY + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2;
|
||||
colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
colStartY = boxY + pad + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
|
||||
colStartY =
|
||||
boxY + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
const colX = startX + col * columnWidth;
|
||||
for (let i = 0; i < column.length; i++) {
|
||||
const charY = colStartY + i * lineHeight;
|
||||
if (charY - lineHeight / 2 <= boxY + boxH - pad) {
|
||||
if (charY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
|
||||
ctx.fillText(column[i], colX, charY);
|
||||
}
|
||||
}
|
||||
@@ -317,31 +351,31 @@ function drawTextInRect(
|
||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
let textX = boxX + pad;
|
||||
let textX = boxX + padPx.left;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = boxX + boxW / 2;
|
||||
textX = boxX + padPx.left + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = boxX + boxW - pad;
|
||||
textX = boxX + padPx.left + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = boxX + pad + 2;
|
||||
textX = boxX + padPx.left + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = boxY + pad + lineHeight / 2;
|
||||
startY = boxY + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2;
|
||||
startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY = boxY + pad + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
startY = boxY + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= boxY + boxH - pad) {
|
||||
if (lineY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
@@ -376,19 +410,12 @@ function drawTableGridLines(
|
||||
const borderColor = elem.tableBorderColor ?? '#374151';
|
||||
const rows = getTableRows(elem);
|
||||
const cols = getTableCols(elem);
|
||||
const rowHeights = getTableRowHeights(elem);
|
||||
const colWidths = getTableColWidths(elem);
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
|
||||
|
||||
const x0 = pad.left;
|
||||
const y0 = pad.top;
|
||||
const totalW = colWidths.reduce((a, b) => a + b, 0);
|
||||
const totalH = rowHeights.reduce((a, b) => a + b, 0);
|
||||
|
||||
const left = ex + x0 * scale;
|
||||
const top = ey + y0 * scale;
|
||||
const width = totalW * scale;
|
||||
const height = totalH * scale;
|
||||
const left = colBounds[0];
|
||||
const top = rowBounds[0];
|
||||
const width = colBounds[colBounds.length - 1] - left;
|
||||
const height = rowBounds[rowBounds.length - 1] - top;
|
||||
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = borderW;
|
||||
@@ -402,45 +429,31 @@ function drawTableGridLines(
|
||||
Math.max(0, height - borderW)
|
||||
);
|
||||
|
||||
let yAcc = y0;
|
||||
for (let r = 1; r < rows; r++) {
|
||||
yAcc += rowHeights[r - 1];
|
||||
const y = alignStrokeCoord(ey + yAcc * scale);
|
||||
let xAcc = x0;
|
||||
const y = alignStrokeCoord(rowBounds[r]);
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const w = colWidths[c];
|
||||
const anchorAbove = getCellAnchor(elem, r - 1, c);
|
||||
const anchorBelow = getCellAnchor(elem, r, c);
|
||||
const sameMerge =
|
||||
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
|
||||
if (!sameMerge) {
|
||||
const x1 = alignStrokeCoord(ex + xAcc * scale);
|
||||
const x2 = alignStrokeCoord(ex + (xAcc + w) * scale);
|
||||
ctx.moveTo(x1, y);
|
||||
ctx.lineTo(x2, y);
|
||||
ctx.moveTo(alignStrokeCoord(colBounds[c]), y);
|
||||
ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y);
|
||||
}
|
||||
xAcc += w;
|
||||
}
|
||||
}
|
||||
|
||||
let xAcc = x0;
|
||||
for (let c = 1; c < cols; c++) {
|
||||
xAcc += colWidths[c - 1];
|
||||
const x = alignStrokeCoord(ex + xAcc * scale);
|
||||
let yAcc2 = y0;
|
||||
const x = alignStrokeCoord(colBounds[c]);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const h = rowHeights[r];
|
||||
const anchorLeft = getCellAnchor(elem, r, c - 1);
|
||||
const anchorRight = getCellAnchor(elem, r, c);
|
||||
const sameMerge =
|
||||
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
|
||||
if (!sameMerge) {
|
||||
const y1 = alignStrokeCoord(ey + yAcc2 * scale);
|
||||
const y2 = alignStrokeCoord(ey + (yAcc2 + h) * scale);
|
||||
ctx.moveTo(x, y1);
|
||||
ctx.lineTo(x, y2);
|
||||
ctx.moveTo(x, alignStrokeCoord(rowBounds[r]));
|
||||
ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1]));
|
||||
}
|
||||
yAcc2 += h;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,19 +479,26 @@ async function drawTableElement(
|
||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
||||
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
|
||||
const gridX = colBounds[0];
|
||||
const gridY = rowBounds[0];
|
||||
const gridW = colBounds[colBounds.length - 1] - gridX;
|
||||
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(inner.x, inner.y, inner.w, inner.h);
|
||||
ctx.rect(gridX, gridY, gridW, gridH);
|
||||
ctx.clip();
|
||||
|
||||
const anchors = iterTableCells(elem);
|
||||
for (const { row, col } of anchors) {
|
||||
const rect = getCellRectMm(elem, row, col);
|
||||
const cellX = ex + Math.round(rect.x * scale);
|
||||
const cellY = ey + Math.round(rect.y * scale);
|
||||
const cellW = Math.round(rect.width * scale);
|
||||
const cellH = Math.round(rect.height * scale);
|
||||
const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
|
||||
elem,
|
||||
row,
|
||||
col,
|
||||
scale,
|
||||
ex,
|
||||
ey
|
||||
);
|
||||
|
||||
const cellData = getCellData(elem, row, col);
|
||||
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
|
||||
@@ -493,7 +513,23 @@ async function drawTableElement(
|
||||
const displayText = String(cellValue ?? '').trim();
|
||||
if (!displayText) continue;
|
||||
|
||||
drawTextInRect(ctx, textElem, cellX, cellY, cellW, cellH, displayText, scale);
|
||||
drawTextContentInBox(
|
||||
ctx,
|
||||
{
|
||||
...textElem,
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
cellX,
|
||||
cellY,
|
||||
cellW,
|
||||
cellH,
|
||||
displayText,
|
||||
scale
|
||||
);
|
||||
}
|
||||
|
||||
drawTableGridLines(ctx, elem, ex, ey, scale);
|
||||
@@ -561,8 +597,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
|
||||
const scale = scaleOption;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(50, Math.round(widthMm * scale));
|
||||
canvas.height = Math.max(50, Math.round(heightMm * scale));
|
||||
canvas.width = Math.max(1, Math.round(widthMm * scale));
|
||||
canvas.height = Math.max(1, Math.round(heightMm * scale));
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
@@ -618,133 +654,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
|
||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
||||
|
||||
const padPx = (() => {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
return {
|
||||
top: Math.round(pad.top * scale),
|
||||
right: Math.round(pad.right * scale),
|
||||
bottom: Math.round(pad.bottom * scale),
|
||||
left: Math.round(pad.left * scale),
|
||||
};
|
||||
})();
|
||||
const writableW = Math.max(1, ew - padPx.left - padPx.right);
|
||||
const writableH = Math.max(1, eh - padPx.top - padPx.bottom);
|
||||
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
|
||||
|
||||
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
|
||||
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
|
||||
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
|
||||
const fontFamily =
|
||||
elem.fontFamily === 'mono'
|
||||
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
|
||||
: elem.fontFamily === 'serif'
|
||||
? 'Georgia, Times New Roman, serif'
|
||||
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
|
||||
|
||||
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
|
||||
ctx.fillStyle = elem.textColor || '#111827';
|
||||
|
||||
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
|
||||
`${letterSpacingPx}px`;
|
||||
}
|
||||
|
||||
const wordWrap = elem.wordWrap !== false;
|
||||
const lineHeight = fontSizePx * 1.25;
|
||||
const columnWidth = lineHeight;
|
||||
const isVertical = elem.textWritingMode === 'vertical';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
|
||||
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
|
||||
const transformOriginX = ex + padPx.left + writableW / 2;
|
||||
const transformOriginY = ey + padPx.top + writableH / 2;
|
||||
|
||||
ctx.save();
|
||||
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||
ctx.translate(transformOriginX, transformOriginY);
|
||||
ctx.scale(textScaleX, textScaleY);
|
||||
ctx.translate(-transformOriginX, -transformOriginY);
|
||||
}
|
||||
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
|
||||
if (isVertical) {
|
||||
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||
const totalColumnsWidth = columns.length * columnWidth;
|
||||
|
||||
let startX: number;
|
||||
if (elem.textAlign === 'center') {
|
||||
startX = ex + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
} else if (elem.textAlign === 'right') {
|
||||
startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
} else {
|
||||
startX = ex + padPx.left + columnWidth / 2;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
const column = columns[col];
|
||||
const colTextHeight = column.length * lineHeight;
|
||||
let colStartY: number;
|
||||
if (vAlign === 'top') {
|
||||
colStartY = ey + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
colStartY =
|
||||
ey + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
const colX = startX + col * columnWidth;
|
||||
for (let i = 0; i < column.length; i++) {
|
||||
const charY = colStartY + i * lineHeight;
|
||||
if (charY - lineHeight / 2 <= ey + eh - padPx.bottom) {
|
||||
ctx.fillText(column[i], colX, charY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
let textX = ex + padPx.left;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = ex + padPx.left + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = ex + padPx.left + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = ex + padPx.left + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = ey + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY =
|
||||
ey + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= ey + eh - padPx.bottom) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
|
||||
}
|
||||
ctx.restore();
|
||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
||||
} else if (elem.type === 'barcode') {
|
||||
@@ -813,22 +724,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
}
|
||||
}
|
||||
} else if (elem.type === 'image') {
|
||||
const primarySrc = value ? String(value).trim() : '';
|
||||
const { img, showPlaceholder } = await resolveElementImage(elem, primarySrc, mode);
|
||||
|
||||
if (!img && !showPlaceholder) {
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const src = value ? String(value).trim() : '';
|
||||
if (!src) {
|
||||
if (mode === 'design') {
|
||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||
}
|
||||
if (img) {
|
||||
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
||||
} else {
|
||||
try {
|
||||
const img = await loadImage(src);
|
||||
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
||||
} catch {
|
||||
if (mode === 'design') {
|
||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '加载失败');
|
||||
}
|
||||
}
|
||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||
}
|
||||
} else if (elem.type === 'table') {
|
||||
await drawTableElement(
|
||||
|
||||
Reference in New Issue
Block a user