feat(spreadsheet): C 격자 — Ctrl+휠 확대·축소(10~400%, 칸·머리·선 굵기 같이)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
This commit is contained in:
@@ -91,25 +91,26 @@ export function resolveBoxBorders(
|
||||
};
|
||||
}
|
||||
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */
|
||||
function borderCss(side?: BorderSide): string {
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
|
||||
function borderCss(side: BorderSide | undefined, scale: number): string {
|
||||
if (!side) return "";
|
||||
const color = side.색 ?? "#000000";
|
||||
const w = (px: number): number => Math.max(1, Math.round(px * scale));
|
||||
switch (side.선) {
|
||||
case "hair":
|
||||
return `1px dotted ${color}`;
|
||||
return `${w(1)}px dotted ${color}`;
|
||||
case "thin":
|
||||
return `1px solid ${color}`;
|
||||
return `${w(1)}px solid ${color}`;
|
||||
case "medium":
|
||||
return `2px solid ${color}`;
|
||||
return `${w(2)}px solid ${color}`;
|
||||
case "thick":
|
||||
return `3px solid ${color}`;
|
||||
return `${w(3)}px solid ${color}`;
|
||||
case "double":
|
||||
return `3px double ${color}`;
|
||||
return `${w(3)}px double ${color}`;
|
||||
case "dotted":
|
||||
return `1px dotted ${color}`;
|
||||
return `${w(1)}px dotted ${color}`;
|
||||
case "dashed":
|
||||
return `1px dashed ${color}`;
|
||||
return `${w(1)}px dashed ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,16 +130,18 @@ const VALIGN_CSS: Record<VAlign, string> = {
|
||||
bottom: "flex-end",
|
||||
};
|
||||
|
||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */
|
||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
|
||||
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
|
||||
export function applyCellStyle(
|
||||
cell: HTMLElement,
|
||||
style: CellStyle,
|
||||
borders: ResolvedBorders,
|
||||
hAlign: HAlign,
|
||||
scale = 1,
|
||||
): void {
|
||||
const s = cell.style;
|
||||
s.fontFamily = style.글꼴 ?? "";
|
||||
s.fontSize = style.크기 ? `${style.크기}pt` : "";
|
||||
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
|
||||
s.fontWeight = style.굵게 ? "700" : "400";
|
||||
s.fontStyle = style.기울임 ? "italic" : "normal";
|
||||
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
@@ -146,10 +149,10 @@ export function applyCellStyle(
|
||||
.join(" ");
|
||||
s.color = style.글자색 ?? "";
|
||||
s.backgroundColor = style.채움 ?? "";
|
||||
s.borderTop = borderCss(borders.위);
|
||||
s.borderLeft = borderCss(borders.왼);
|
||||
s.borderBottom = borderCss(borders.아래);
|
||||
s.borderRight = borderCss(borders.오른);
|
||||
s.borderTop = borderCss(borders.위, scale);
|
||||
s.borderLeft = borderCss(borders.왼, scale);
|
||||
s.borderBottom = borderCss(borders.아래, scale);
|
||||
s.borderRight = borderCss(borders.오른, scale);
|
||||
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
|
||||
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
||||
|
||||
|
||||
@@ -152,8 +152,12 @@
|
||||
}
|
||||
|
||||
.aislo-grid--gridlines .aislo-cell {
|
||||
border-right: 1px solid var(--aislo-grid-line);
|
||||
border-bottom: 1px solid var(--aislo-grid-line);
|
||||
border-right-style: solid;
|
||||
border-bottom-style: solid;
|
||||
border-right-width: var(--aislo-grid-gridline-w, 1px);
|
||||
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
|
||||
border-right-color: var(--aislo-grid-line);
|
||||
border-bottom-color: var(--aislo-grid-line);
|
||||
}
|
||||
|
||||
.aislo-cell__text {
|
||||
|
||||
@@ -49,10 +49,12 @@ import type {
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
const HEAD_ROW_H = 24;
|
||||
const HEAD_COL_W = 46;
|
||||
const HEAD_ROW_H_BASE = 24;
|
||||
const HEAD_COL_W_BASE = 46;
|
||||
const RESIZE_HOTZONE = 4;
|
||||
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 4;
|
||||
|
||||
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
|
||||
interface CellPool {
|
||||
@@ -125,6 +127,17 @@ function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
|
||||
function scaledGeometry(base: LineGeometry, factor: number): LineGeometry {
|
||||
if (factor === 1) return base;
|
||||
return {
|
||||
size: (i) => base.size(i) * factor,
|
||||
offset: (i) => base.offset(i) * factor,
|
||||
total: (i) => base.total(i) * factor,
|
||||
indexAt: (px) => base.indexAt(px / factor),
|
||||
};
|
||||
}
|
||||
|
||||
interface CellDisplay {
|
||||
text: string;
|
||||
hAlign: HAlign;
|
||||
@@ -207,8 +220,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const colHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
const rowHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
|
||||
let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
||||
let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
||||
let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
||||
let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
||||
let colGeo: LineGeometry = baseColGeo;
|
||||
let rowGeo: LineGeometry = baseRowGeo;
|
||||
let extent = computeExtent(ctx.sheet());
|
||||
let merges = buildMergeMap(ctx.sheet());
|
||||
let frozenRows = 0;
|
||||
@@ -216,6 +231,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
let frozenW = 0;
|
||||
let frozenH = 0;
|
||||
let lastSheetId = "";
|
||||
let zoom = 1;
|
||||
const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom);
|
||||
const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom);
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
@@ -224,45 +242,48 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
function layoutPanes(): void {
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
|
||||
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`;
|
||||
root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`);
|
||||
frozenW = colGeo.total(frozenCols);
|
||||
frozenH = rowGeo.total(frozenRows);
|
||||
corner.style.width = `${HEAD_COL_W}px`;
|
||||
corner.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadFrozen.style.left = `${HEAD_COL_W}px`;
|
||||
corner.style.width = `${headColW()}px`;
|
||||
corner.style.height = `${headRowH()}px`;
|
||||
colHeadFrozen.style.left = `${headColW()}px`;
|
||||
colHeadFrozen.style.top = "0";
|
||||
colHeadFrozen.style.width = `${frozenW}px`;
|
||||
colHeadFrozen.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
colHeadFrozen.style.height = `${headRowH()}px`;
|
||||
colHeadScroll.style.left = `${headColW() + frozenW}px`;
|
||||
colHeadScroll.style.top = "0";
|
||||
colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
colHeadScroll.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
colHeadScroll.style.height = `${headRowH()}px`;
|
||||
rowHeadFrozen.style.left = "0";
|
||||
rowHeadFrozen.style.top = `${HEAD_ROW_H}px`;
|
||||
rowHeadFrozen.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadFrozen.style.top = `${headRowH()}px`;
|
||||
rowHeadFrozen.style.width = `${headColW()}px`;
|
||||
rowHeadFrozen.style.height = `${frozenH}px`;
|
||||
rowHeadScroll.style.left = "0";
|
||||
rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
rowHeadScroll.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneTL.style.left = `${HEAD_COL_W}px`;
|
||||
paneTL.style.top = `${HEAD_ROW_H}px`;
|
||||
rowHeadScroll.style.top = `${headRowH() + frozenH}px`;
|
||||
rowHeadScroll.style.width = `${headColW()}px`;
|
||||
rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
paneTL.style.left = `${headColW()}px`;
|
||||
paneTL.style.top = `${headRowH()}px`;
|
||||
paneTL.style.width = `${frozenW}px`;
|
||||
paneTL.style.height = `${frozenH}px`;
|
||||
paneTR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneTR.style.top = `${HEAD_ROW_H}px`;
|
||||
paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneTR.style.left = `${headColW() + frozenW}px`;
|
||||
paneTR.style.top = `${headRowH()}px`;
|
||||
paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
paneTR.style.height = `${frozenH}px`;
|
||||
paneBL.style.left = `${HEAD_COL_W}px`;
|
||||
paneBL.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBL.style.left = `${headColW()}px`;
|
||||
paneBL.style.top = `${headRowH() + frozenH}px`;
|
||||
paneBL.style.width = `${frozenW}px`;
|
||||
paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneBR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneBR.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
paneBR.style.left = `${headColW() + frozenW}px`;
|
||||
paneBR.style.top = `${headRowH() + frozenH}px`;
|
||||
paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
|
||||
overlay.style.left = `${HEAD_COL_W}px`;
|
||||
overlay.style.top = `${HEAD_ROW_H}px`;
|
||||
overlay.style.left = `${headColW()}px`;
|
||||
overlay.style.top = `${headRowH()}px`;
|
||||
overlay.style.right = "0";
|
||||
overlay.style.bottom = "0";
|
||||
renderVisible();
|
||||
@@ -280,6 +301,22 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
}
|
||||
paneBR.addEventListener("scroll", onScroll);
|
||||
|
||||
/** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */
|
||||
function onWheel(ev: WheelEvent): void {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const before = zoom;
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
|
||||
if (next === before) return;
|
||||
zoom = next;
|
||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
||||
paneBR.scrollLeft *= zoom / before;
|
||||
paneBR.scrollTop *= zoom / before;
|
||||
layoutPanes();
|
||||
}
|
||||
root.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
|
||||
|
||||
function renderRowOfCells(
|
||||
@@ -329,7 +366,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
extent.cols - 1,
|
||||
);
|
||||
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
|
||||
if (display.color) cellEl.style.color = display.color;
|
||||
const comment = sheet.comments?.[addr];
|
||||
setCellText(cellEl, display.text, display.title || comment || undefined);
|
||||
@@ -420,8 +457,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
root.classList.toggle("aislo-grid--gridlines", gridlines());
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
const scrollW = Math.max(0, w - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH);
|
||||
const scrollW = Math.max(0, w - headColW() - frozenW);
|
||||
const scrollH = Math.max(0, h - headRowH() - frozenH);
|
||||
const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1));
|
||||
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft));
|
||||
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1);
|
||||
@@ -485,10 +522,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
|
||||
function render(): void {
|
||||
const sheet = ctx.sheet();
|
||||
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
|
||||
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : "";
|
||||
colGeo = buildColGeometry(ctx.book, sheet);
|
||||
rowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
baseColGeo = buildColGeometry(ctx.book, sheet);
|
||||
baseRowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
||||
extent = computeExtent(sheet);
|
||||
merges = buildMergeMap(sheet);
|
||||
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
|
||||
@@ -534,8 +571,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const box = boxOf(r, c);
|
||||
const inFrozenCol = c < frozenCols;
|
||||
const inFrozenRow = r < frozenRows;
|
||||
const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
||||
const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
||||
const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
||||
const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
||||
return { x, y, w: box.w, h: box.h };
|
||||
}
|
||||
|
||||
@@ -559,8 +596,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const inFrozenCol = range.c0 < frozenCols;
|
||||
const inFrozenRow = range.r0 < frozenRows;
|
||||
const screen: CellBox = {
|
||||
x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
|
||||
y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
|
||||
x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
|
||||
y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
};
|
||||
@@ -579,8 +616,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const inFrozenRow = sel.활성.r < frozenRows;
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__fill-handle";
|
||||
handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
|
||||
handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
|
||||
handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
|
||||
handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
|
||||
selLayer!.append(handle);
|
||||
}
|
||||
|
||||
@@ -606,8 +643,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const inFrozenRow = ref.범위.r0 < frozenRows;
|
||||
const node = document.createElement("div");
|
||||
node.className = "aislo-grid__ref-highlight";
|
||||
node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
|
||||
node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
|
||||
node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
|
||||
node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
|
||||
node.style.width = `${merged.w}px`;
|
||||
node.style.height = `${merged.h}px`;
|
||||
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
|
||||
@@ -620,17 +657,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const x = clientX - bounds.left;
|
||||
const y = clientY - bounds.top;
|
||||
if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null;
|
||||
if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 };
|
||||
if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 };
|
||||
const contentX =
|
||||
x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0);
|
||||
x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0);
|
||||
const contentY =
|
||||
y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0);
|
||||
if (y < HEAD_ROW_H) {
|
||||
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
|
||||
if (y < headRowH()) {
|
||||
const c = colGeo.indexAt(contentX);
|
||||
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
|
||||
}
|
||||
if (x < HEAD_COL_W) {
|
||||
if (x < headColW()) {
|
||||
const r = rowGeo.indexAt(contentY);
|
||||
const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 };
|
||||
@@ -669,8 +706,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
}
|
||||
|
||||
function visibleRange(): CellRange {
|
||||
const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH);
|
||||
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
|
||||
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
|
||||
return {
|
||||
r0: rowGeo.indexAt(paneBR.scrollTop),
|
||||
c0: colGeo.indexAt(paneBR.scrollLeft),
|
||||
|
||||
Reference in New Issue
Block a user