feat : update
This commit is contained in:
@@ -3,15 +3,20 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addExtraSheetColumn,
|
||||
addExtraSheetRow,
|
||||
clearExtraSheetCellFormatting,
|
||||
createExtraSheet,
|
||||
deleteExtraSheetColumn,
|
||||
deleteExtraSheetRow,
|
||||
extraSheetSchema,
|
||||
extraSheetRangeToTsv,
|
||||
extraSheetToMatrix,
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
normalizeExtraSheetRichText,
|
||||
parseExtraSheetTsv,
|
||||
pasteExtraSheetText,
|
||||
resizeExtraSheetColumn,
|
||||
styleExtraSheetCells,
|
||||
updateExtraSheetCell,
|
||||
updateExtraSheetCells,
|
||||
@@ -152,6 +157,57 @@ describe("Extra sheet", () => {
|
||||
expect(extraSheetToMatrix(legacy)[0][0]).toEqual({
|
||||
value: "",
|
||||
backgroundColor: undefined,
|
||||
alignment: undefined,
|
||||
});
|
||||
|
||||
const parsed = extraSheetSchema.parse(legacy);
|
||||
expect(parsed.columns[0].width).toBe(140);
|
||||
expect(parsed.rows[0].alignments).toEqual({});
|
||||
});
|
||||
|
||||
it("persists bounded column widths and cell alignment", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const resized = resizeExtraSheetColumn(sheet, 0, 999);
|
||||
const aligned = updateExtraSheetCells(resized, [{ row: 0, column: 0 }], {
|
||||
alignment: "center",
|
||||
backgroundColor: "#123456",
|
||||
});
|
||||
|
||||
expect(aligned.columns[0].width).toBe(480);
|
||||
expect(extraSheetToMatrix(aligned)[0][0]).toMatchObject({
|
||||
alignment: "center",
|
||||
backgroundColor: "#123456",
|
||||
});
|
||||
expect(extraSheetSchema.safeParse(aligned).success).toBe(true);
|
||||
});
|
||||
|
||||
it("clears visual formatting without clearing cell content", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const columnId = sheet.columns[0].id;
|
||||
sheet.rows[0].cells[columnId] = '<p><strong><span style="color: #FF0000">Alpha</span></strong></p>';
|
||||
sheet.rows[0].backgrounds[columnId] = "#123456";
|
||||
sheet.rows[0].alignments[columnId] = "right";
|
||||
|
||||
const cleared = clearExtraSheetCellFormatting(sheet, [{ row: 0, column: 0 }]);
|
||||
expect(cleared.rows[0].cells[columnId]).toBe("<p>Alpha</p>");
|
||||
expect(cleared.rows[0].backgrounds).toEqual({});
|
||||
expect(cleared.rows[0].alignments).toEqual({});
|
||||
});
|
||||
|
||||
it("copies plain TSV and pastes multiline cells while expanding the grid", () => {
|
||||
const createId = idFactory();
|
||||
const sheet = createExtraSheet(createId);
|
||||
const firstColumn = sheet.columns[0].id;
|
||||
sheet.rows[0].cells[firstColumn] = "<p>Alpha<br>Beta</p>";
|
||||
const copied = extraSheetRangeToTsv(sheet, { row: 0, column: 0 }, { row: 0, column: 0 });
|
||||
|
||||
expect(copied).toBe('"Alpha\nBeta"');
|
||||
expect(parseExtraSheetTsv('"Alpha\nBeta"\tGamma')).toEqual([["Alpha\nBeta", "Gamma"]]);
|
||||
|
||||
const pasted = pasteExtraSheetText(sheet, { row: 2, column: 2 }, '"Alpha\nBeta"\tGamma', createId);
|
||||
expect(pasted.rows).toHaveLength(3);
|
||||
expect(pasted.columns).toHaveLength(4);
|
||||
expect(pasted.rows[2].cells[pasted.columns[2].id]).toBe("<p>Alpha<br>Beta</p>");
|
||||
expect(pasted.rows[2].cells[pasted.columns[3].id]).toBe("<p>Gamma</p>");
|
||||
});
|
||||
});
|
||||
|
||||
+195
-10
@@ -2,21 +2,31 @@ import * as z from "zod";
|
||||
|
||||
import {
|
||||
applyRichTextStylePatch,
|
||||
clearRichTextFormatting,
|
||||
extraRichTextToPlainText,
|
||||
normalizeExtraRichText,
|
||||
type RichTextStylePatch,
|
||||
} from "@/lib/rich-text";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
export const DEFAULT_EXTRA_SHEET_COLUMN_WIDTH = 140;
|
||||
export const MIN_EXTRA_SHEET_COLUMN_WIDTH = 96;
|
||||
export const MAX_EXTRA_SHEET_COLUMN_WIDTH = 480;
|
||||
export const extraSheetAlignmentSchema = z.enum(["left", "center", "right"]);
|
||||
export type ExtraSheetAlignment = z.infer<typeof extraSheetAlignmentSchema>;
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
columns: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
label: z.string().trim().min(1).max(120),
|
||||
width: z.number().int().min(MIN_EXTRA_SHEET_COLUMN_WIDTH).max(MAX_EXTRA_SHEET_COLUMN_WIDTH)
|
||||
.default(DEFAULT_EXTRA_SHEET_COLUMN_WIDTH),
|
||||
})).min(1),
|
||||
rows: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
cells: z.record(z.string(), z.string().max(10_000)),
|
||||
backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}),
|
||||
alignments: z.record(z.string(), extraSheetAlignmentSchema).default({}),
|
||||
})).min(1),
|
||||
}).superRefine((sheet, context) => {
|
||||
const columnIds = new Set(sheet.columns.map((column) => column.id));
|
||||
@@ -32,7 +42,11 @@ export const extraSheetSchema = z.strictObject({
|
||||
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
sheet.rows.forEach((row, rowIndex) => {
|
||||
[...Object.keys(row.cells), ...Object.keys(row.backgrounds ?? {})].forEach((columnId) => {
|
||||
[
|
||||
...Object.keys(row.cells),
|
||||
...Object.keys(row.backgrounds ?? {}),
|
||||
...Object.keys(row.alignments ?? {}),
|
||||
].forEach((columnId) => {
|
||||
if (!columnIds.has(columnId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
@@ -46,7 +60,11 @@ export const extraSheetSchema = z.strictObject({
|
||||
|
||||
export type ExtraSheet = z.output<typeof extraSheetSchema>;
|
||||
|
||||
export type ExtraSheetCell = { value: string; backgroundColor?: string };
|
||||
export type ExtraSheetCell = {
|
||||
value: string;
|
||||
backgroundColor?: string;
|
||||
alignment?: ExtraSheetAlignment;
|
||||
};
|
||||
export type ExtraSheetMatrix = Array<Array<ExtraSheetCell | undefined>>;
|
||||
type SheetPoint = { row: number; column: number };
|
||||
|
||||
@@ -61,6 +79,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
|
||||
const columns = [1, 2].map((index) => ({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: `คอลัมน์ ${index}`,
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
}));
|
||||
return {
|
||||
columns,
|
||||
@@ -68,6 +87,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
|
||||
id: createId("extra-sheet-row"),
|
||||
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -77,6 +97,7 @@ export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
|
||||
sheet.columns.map((column) => ({
|
||||
value: row.cells[column.id] ?? "",
|
||||
backgroundColor: row.backgrounds?.[column.id],
|
||||
alignment: row.alignments?.[column.id],
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -95,6 +116,7 @@ export function matrixToExtraSheet(
|
||||
columns.push({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: nextExtraSheetColumnLabel(columns),
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
});
|
||||
}
|
||||
const rowCount = Math.max(sheet.rows.length, matrix.length);
|
||||
@@ -109,6 +131,11 @@ export function matrixToExtraSheet(
|
||||
?? sheet.rows[rowIndex]?.backgrounds?.[column.id];
|
||||
return backgroundColor ? [[column.id, backgroundColor]] : [];
|
||||
})),
|
||||
alignments: Object.fromEntries(columns.flatMap((column, columnIndex) => {
|
||||
const alignment = matrix[rowIndex]?.[columnIndex]?.alignment
|
||||
?? sheet.rows[rowIndex]?.alignments?.[column.id];
|
||||
return alignment ? [[column.id, alignment]] : [];
|
||||
})),
|
||||
}));
|
||||
return { columns, rows };
|
||||
}
|
||||
@@ -118,20 +145,65 @@ export function addExtraSheetColumn(
|
||||
label: string,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
return insertExtraSheetColumn(sheet, sheet.columns.length, label, id);
|
||||
}
|
||||
|
||||
export function insertExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
label: string,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.columns.length));
|
||||
const columns = [...sheet.columns];
|
||||
columns.splice(position, 0, {
|
||||
id,
|
||||
label,
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
});
|
||||
return {
|
||||
columns: [...sheet.columns, { id, label }],
|
||||
columns,
|
||||
rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
|
||||
};
|
||||
}
|
||||
|
||||
export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
|
||||
return insertExtraSheetRow(sheet, sheet.rows.length, id);
|
||||
}
|
||||
|
||||
export function insertExtraSheetRow(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.rows.length));
|
||||
const rows = [...sheet.rows];
|
||||
rows.splice(position, 0, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
});
|
||||
return {
|
||||
...sheet,
|
||||
rows: [...sheet.rows, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
}],
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
width: number,
|
||||
): ExtraSheet {
|
||||
if (!sheet.columns[index]) return sheet;
|
||||
const normalized = Math.round(Math.max(
|
||||
MIN_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
Math.min(MAX_EXTRA_SHEET_COLUMN_WIDTH, width),
|
||||
));
|
||||
return {
|
||||
...sheet,
|
||||
columns: sheet.columns.map((column, columnIndex) =>
|
||||
columnIndex === index ? { ...column, width: normalized } : column),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +241,7 @@ export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraS
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
|
||||
backgrounds: Object.fromEntries(Object.entries(row.backgrounds ?? {}).filter(([key]) => key !== columnId)),
|
||||
alignments: Object.fromEntries(Object.entries(row.alignments ?? {}).filter(([key]) => key !== columnId)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -198,7 +271,11 @@ export function updateExtraSheetCell(
|
||||
export function updateExtraSheetCells(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
update: { value?: string; backgroundColor?: string | null },
|
||||
update: {
|
||||
value?: string;
|
||||
backgroundColor?: string | null;
|
||||
alignment?: ExtraSheetAlignment | null;
|
||||
},
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
return {
|
||||
@@ -206,13 +283,121 @@ export function updateExtraSheetCells(
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
sheet.columns.forEach((column, columnIndex) => {
|
||||
if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
|
||||
if (update.value !== undefined) cells[column.id] = update.value;
|
||||
if (update.backgroundColor === null) delete backgrounds[column.id];
|
||||
else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor;
|
||||
if (update.alignment === null) delete alignments[column.id];
|
||||
else if (update.alignment !== undefined) alignments[column.id] = update.alignment;
|
||||
});
|
||||
return { ...row, cells, backgrounds };
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearExtraSheetCellFormatting(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
sheet.columns.forEach((column, columnIndex) => {
|
||||
if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
|
||||
cells[column.id] = clearRichTextFormatting(cells[column.id] ?? "");
|
||||
delete backgrounds[column.id];
|
||||
delete alignments[column.id];
|
||||
});
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function quoteTsvValue(value: string): string {
|
||||
return /[\t\r\n"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
||||
}
|
||||
|
||||
export function extraSheetRangeToTsv(
|
||||
sheet: ExtraSheet,
|
||||
start: SheetPoint,
|
||||
end: SheetPoint,
|
||||
): string {
|
||||
const top = Math.min(start.row, end.row);
|
||||
const bottom = Math.max(start.row, end.row);
|
||||
const left = Math.min(start.column, end.column);
|
||||
const right = Math.max(start.column, end.column);
|
||||
return sheet.rows.slice(top, bottom + 1).map((row) =>
|
||||
sheet.columns.slice(left, right + 1).map((column) =>
|
||||
quoteTsvValue(extraRichTextToPlainText(row.cells[column.id] ?? "")),
|
||||
).join("\t"),
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
export function parseExtraSheetTsv(value: string): string[][] {
|
||||
const rows: string[][] = [[]];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === '"') {
|
||||
if (quoted && value[index + 1] === '"') {
|
||||
field += '"';
|
||||
index += 1;
|
||||
} else quoted = !quoted;
|
||||
} else if (character === "\t" && !quoted) {
|
||||
rows.at(-1)!.push(field);
|
||||
field = "";
|
||||
} else if ((character === "\n" || character === "\r") && !quoted) {
|
||||
if (character === "\r" && value[index + 1] === "\n") index += 1;
|
||||
rows.at(-1)!.push(field);
|
||||
rows.push([]);
|
||||
field = "";
|
||||
} else field += character;
|
||||
}
|
||||
rows.at(-1)!.push(field);
|
||||
return rows.length > 1 && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === ""
|
||||
? rows.slice(0, -1)
|
||||
: rows;
|
||||
}
|
||||
|
||||
export function pasteExtraSheetText(
|
||||
sheet: ExtraSheet,
|
||||
start: SheetPoint,
|
||||
text: string,
|
||||
createId: (prefix: string) => string,
|
||||
): ExtraSheet {
|
||||
const pasted = parseExtraSheetTsv(text);
|
||||
if (!pasted.length) return sheet;
|
||||
let next = sheet;
|
||||
const requiredRows = start.row + pasted.length;
|
||||
const requiredColumns = start.column + Math.max(...pasted.map((row) => row.length));
|
||||
while (next.rows.length < requiredRows) {
|
||||
next = addExtraSheetRow(next, createId("extra-sheet-row"));
|
||||
}
|
||||
while (next.columns.length < requiredColumns) {
|
||||
next = addExtraSheetColumn(
|
||||
next,
|
||||
nextExtraSheetColumnLabel(next.columns),
|
||||
createId("extra-sheet-column"),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
rows: next.rows.map((row, rowIndex) => {
|
||||
const pastedRow = pasted[rowIndex - start.row];
|
||||
if (!pastedRow) return row;
|
||||
const cells = { ...row.cells };
|
||||
pastedRow.forEach((cell, offset) => {
|
||||
const column = next.columns[start.column + offset];
|
||||
if (column) cells[column.id] = normalizeExtraRichText(cell);
|
||||
});
|
||||
return { ...row, cells };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user