feat : i dont even know what am i doing right now
This commit is contained in:
@@ -4,6 +4,14 @@ import {
|
||||
addExtraSheetColumn,
|
||||
addExtraSheetRow,
|
||||
clearExtraSheetCellFormatting,
|
||||
canMergeExtraSheetRange,
|
||||
expandExtraSheetRange,
|
||||
extraSheetAnchor,
|
||||
extraSheetMergeMap,
|
||||
insertExtraSheetColumn,
|
||||
insertExtraSheetRow,
|
||||
mergeExtraSheetRange,
|
||||
unmergeExtraSheetRange,
|
||||
createExtraSheet,
|
||||
deleteExtraSheetColumn,
|
||||
deleteExtraSheetRow,
|
||||
@@ -14,6 +22,7 @@ import {
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
nextExtraSheetPoint,
|
||||
normalizeExtraSheetRichText,
|
||||
parseExtraSheetTsv,
|
||||
pasteExtraSheetText,
|
||||
@@ -183,6 +192,7 @@ describe("Extra sheet", () => {
|
||||
value: "",
|
||||
backgroundColor: undefined,
|
||||
alignment: undefined,
|
||||
verticalAlignment: undefined,
|
||||
});
|
||||
|
||||
const parsed = extraSheetSchema.parse(legacy);
|
||||
@@ -236,3 +246,144 @@ describe("Extra sheet", () => {
|
||||
expect(pasted.rows[2].cells[pasted.columns[8].id]).toBe("<p>Gamma</p>");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Extra sheet merged cells", () => {
|
||||
const rectangle = { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } };
|
||||
|
||||
it.each([
|
||||
{ start: { row: 0, column: 0 }, end: { row: 0, column: 2 } },
|
||||
{ start: { row: 0, column: 0 }, end: { row: 2, column: 0 } },
|
||||
rectangle,
|
||||
])("merges rectangular ranges while preserving rich text in reading order", (range) => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const column = sheet.columns[0].id;
|
||||
sheet.rows[0].cells[column] = "<p><strong>First</strong></p>";
|
||||
sheet.rows[range.end.row].cells[sheet.columns[range.end.column].id] = "Last";
|
||||
sheet.rows[0].backgrounds[column] = "#123456";
|
||||
const merged = mergeExtraSheetRange(sheet, range);
|
||||
expect(merged.rows[0].cells[column]).toBe("<p><strong>First</strong></p><p>Last</p>");
|
||||
expect(merged.rows[range.end.row].cells[sheet.columns[range.end.column].id]).toBe("");
|
||||
expect(merged.rows[0].backgrounds[column]).toBe("#123456");
|
||||
expect(extraSheetAnchor(merged, range.end)).toEqual(range.start);
|
||||
expect(extraSheetSchema.safeParse(merged).success).toBe(true);
|
||||
expect(sheet.rows[0].cells[column]).toBe("<p><strong>First</strong></p>");
|
||||
const unmerged = unmergeExtraSheetRange(merged, { start: range.end, end: range.end });
|
||||
expect(unmerged.merges).toEqual([]);
|
||||
expect(unmerged.rows[0].cells[column]).toBe(merged.rows[0].cells[column]);
|
||||
});
|
||||
|
||||
it("rejects invalid ranges and partial overlaps, and combines contained merges", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const merged = mergeExtraSheetRange(sheet, rectangle);
|
||||
expect(canMergeExtraSheetRange(merged, rectangle)).toBe(false);
|
||||
expect(canMergeExtraSheetRange(merged, { start: { row: 1, column: 1 }, end: { row: 2, column: 2 } })).toBe(false);
|
||||
expect(canMergeExtraSheetRange(sheet, { start: { row: 0, column: 0 }, end: { row: 99, column: 99 } })).toBe(false);
|
||||
const larger = { start: { row: 0, column: 0 }, end: { row: 2, column: 2 } };
|
||||
expect(mergeExtraSheetRange(merged, larger).merges).toHaveLength(1);
|
||||
for (const merges of [
|
||||
[...merged.merges!, ...merged.merges!],
|
||||
[{ rowIds: [sheet.rows[0].id], columnIds: [sheet.columns[0].id] }],
|
||||
[{ rowIds: [sheet.rows[0].id, sheet.rows[2].id], columnIds: [sheet.columns[0].id] }],
|
||||
[{ rowIds: ["missing"], columnIds: [sheet.columns[0].id, sheet.columns[1].id] }],
|
||||
[{ rowIds: [sheet.rows[0].id, sheet.rows[0].id], columnIds: [sheet.columns[0].id] }],
|
||||
]) expect(extraSheetSchema.safeParse({ ...sheet, merges }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects oversized combined content without changing cells", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
sheet.rows[0].cells[sheet.columns[0].id] = "A".repeat(6000);
|
||||
sheet.rows[0].cells[sheet.columns[1].id] = "B".repeat(6000);
|
||||
const before = JSON.stringify(sheet);
|
||||
expect(() => mergeExtraSheetRange(sheet, rectangle)).toThrow("10,000");
|
||||
expect(JSON.stringify(sheet)).toBe(before);
|
||||
});
|
||||
|
||||
it("expands selections transitively and includes empty merge extents in display cropping", () => {
|
||||
let sheet = createExtraSheet(idFactory());
|
||||
sheet = mergeExtraSheetRange(sheet, { start: { row: 1, column: 1 }, end: { row: 2, column: 2 } });
|
||||
sheet = mergeExtraSheetRange(sheet, { start: { row: 0, column: 3 }, end: { row: 1, column: 4 } });
|
||||
expect(expandExtraSheetRange(sheet, { row: 2, column: 2 }, { row: 2, column: 3 })).toEqual({
|
||||
start: { row: 0, column: 1 }, end: { row: 2, column: 4 },
|
||||
});
|
||||
const used = extraSheetUsedRange(sheet)!;
|
||||
expect(used.columns).toHaveLength(4);
|
||||
expect(used.rows).toHaveLength(3);
|
||||
expect(extraSheetSchema.safeParse(used).success).toBe(true);
|
||||
expect(extraSheetMergeMap(used).get("2:1")?.start).toEqual({ row: 1, column: 0 });
|
||||
});
|
||||
|
||||
it("preserves merge metadata through normalization and matrix conversion", () => {
|
||||
const merged = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
||||
expect(normalizeExtraSheetRichText(merged).merges).toEqual(merged.merges);
|
||||
expect(matrixToExtraSheet(merged, extraSheetToMatrix(merged), idFactory()).merges).toEqual(merged.merges);
|
||||
expect(extraSheetSchema.parse(JSON.parse(JSON.stringify(merged))).merges).toEqual(merged.merges);
|
||||
expect(extraSheetSchema.parse(createExtraSheet(idFactory())).merges).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unmerges only affected regions on structural edits", () => {
|
||||
const merged = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
||||
const operations = [
|
||||
insertExtraSheetRow(merged, 1, "inserted-row"),
|
||||
insertExtraSheetColumn(merged, 1, "Inserted", "inserted-column"),
|
||||
deleteExtraSheetRow(merged, 1), deleteExtraSheetColumn(merged, 1),
|
||||
moveExtraSheetRow(merged, 0, 3), moveExtraSheetColumn(merged, 0, 4),
|
||||
moveExtraSheetRow(merged, 3, 0), moveExtraSheetColumn(merged, 5, 0),
|
||||
];
|
||||
for (const sheet of operations) {
|
||||
expect(sheet.merges).toEqual([]);
|
||||
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
||||
}
|
||||
for (const sheet of [
|
||||
insertExtraSheetRow(merged, 0, "before-row"), insertExtraSheetRow(merged, 2, "after-row"),
|
||||
insertExtraSheetColumn(merged, 0, "Before", "before-column"),
|
||||
insertExtraSheetColumn(merged, 2, "After", "after-column"),
|
||||
moveExtraSheetRow(merged, 2, 3), moveExtraSheetColumn(merged, 3, 5),
|
||||
deleteExtraSheetRow(merged, 3), deleteExtraSheetColumn(merged, 5),
|
||||
]) {
|
||||
expect(sheet.merges).toEqual(merged.merges);
|
||||
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("formats and edits merged anchors once, including vertical alignment", () => {
|
||||
let sheet = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
||||
const column = sheet.columns[0].id;
|
||||
sheet = updateExtraSheetCells(sheet, [rectangle.start, rectangle.end], {
|
||||
value: "<p>Text</p>", verticalAlignment: "middle", alignment: "center", backgroundColor: "#123456",
|
||||
});
|
||||
expect(sheet.rows[1].cells[sheet.columns[1].id]).toBe("");
|
||||
expect(sheet.rows[0].verticalAlignments[column]).toBe("middle");
|
||||
expect(extraSheetToMatrix(sheet)[0][0]?.verticalAlignment).toBe("middle");
|
||||
expect(matrixToExtraSheet(sheet, extraSheetToMatrix(sheet), idFactory()).rows[0].verticalAlignments[column]).toBe("middle");
|
||||
sheet = styleExtraSheetCells(sheet, [rectangle.start, rectangle.end], { bold: true });
|
||||
expect(sheet.rows[0].cells[column]).toBe("<p><strong>Text</strong></p>");
|
||||
const cleared = clearExtraSheetCellFormatting(sheet, [rectangle.end]);
|
||||
expect(cleared.rows[0].cells[column]).toBe("<p>Text</p>");
|
||||
expect(cleared.rows[0].verticalAlignments).toEqual({});
|
||||
expect(cleared.merges).toEqual(sheet.merges);
|
||||
});
|
||||
|
||||
it("copies anchor text and blanks, edits single-cell paste, and unmerges multi-cell paste", () => {
|
||||
const createId = idFactory();
|
||||
let sheet = mergeExtraSheetRange(createExtraSheet(createId), rectangle);
|
||||
sheet = updateExtraSheetCell(sheet, 1, 1, "<p>Text</p>");
|
||||
expect(extraSheetRangeToTsv(sheet, rectangle.end, rectangle.end)).toBe("Text\t\n\t");
|
||||
const single = pasteExtraSheetText(sheet, rectangle.end, "New", createId);
|
||||
expect(single.rows[0].cells[single.columns[0].id]).toBe("<p>New</p>");
|
||||
expect(single.merges).toEqual(sheet.merges);
|
||||
const multi = pasteExtraSheetText(sheet, rectangle.start, "A\tB", createId);
|
||||
expect(multi.merges).toEqual([]);
|
||||
expect(multi.rows[0].cells[multi.columns[1].id]).toBe("<p>B</p>");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("skips merge coverage during directional navigation and Tab wrapping", () => {
|
||||
let sheet = createExtraSheet(idFactory());
|
||||
sheet = mergeExtraSheetRange(sheet, { start: { row: 0, column: 0 }, end: { row: 1, column: 5 } });
|
||||
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 0, column: 1 }, true)).toEqual({ row: 2, column: 0 });
|
||||
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 1, column: 0 })).toEqual({ row: 2, column: 0 });
|
||||
expect(nextExtraSheetPoint(sheet, { row: 2, column: 3 }, { row: -1, column: 0 })).toEqual({ row: 0, column: 0 });
|
||||
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 0, column: -1 }, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
+236
-12
@@ -14,6 +14,8 @@ 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 extraSheetVerticalAlignmentSchema = z.enum(["top", "middle", "bottom"]);
|
||||
export type ExtraSheetVerticalAlignment = z.infer<typeof extraSheetVerticalAlignmentSchema>;
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
columns: z.array(z.strictObject({
|
||||
@@ -22,11 +24,16 @@ export const extraSheetSchema = z.strictObject({
|
||||
width: z.number().int().min(MIN_EXTRA_SHEET_COLUMN_WIDTH).max(MAX_EXTRA_SHEET_COLUMN_WIDTH)
|
||||
.default(DEFAULT_EXTRA_SHEET_COLUMN_WIDTH),
|
||||
})).min(1),
|
||||
merges: z.array(z.strictObject({
|
||||
rowIds: z.array(extraSheetId).min(1),
|
||||
columnIds: z.array(extraSheetId).min(1),
|
||||
})).optional(),
|
||||
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({}),
|
||||
verticalAlignments: z.record(z.string(), extraSheetVerticalAlignmentSchema).default({}),
|
||||
})).min(1),
|
||||
}).superRefine((sheet, context) => {
|
||||
const columnIds = new Set(sheet.columns.map((column) => column.id));
|
||||
@@ -41,11 +48,33 @@ export const extraSheetSchema = z.strictObject({
|
||||
if (rowIds.size !== sheet.rows.length) {
|
||||
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
const occupied = new Set<string>();
|
||||
sheet.merges?.forEach((merge, index) => {
|
||||
const consecutive = (ids: string[], axis: { id: string }[]) => {
|
||||
const start = axis.findIndex((item) => item.id === ids[0]);
|
||||
return start >= 0 && ids.every((id, offset) => axis[start + offset]?.id === id);
|
||||
};
|
||||
if (merge.rowIds.length * merge.columnIds.length < 2
|
||||
|| !consecutive(merge.rowIds, sheet.rows)
|
||||
|| !consecutive(merge.columnIds, sheet.columns)) {
|
||||
context.addIssue({ code: "custom", path: ["merges", index], message: "ช่วงเซลล์ที่ผสานต้องเป็นสี่เหลี่ยมต่อเนื่องและมีอย่างน้อยสองเซลล์" });
|
||||
}
|
||||
for (const rowId of merge.rowIds) {
|
||||
for (const columnId of merge.columnIds) {
|
||||
const key = JSON.stringify([rowId, columnId]);
|
||||
if (occupied.has(key)) {
|
||||
context.addIssue({ code: "custom", path: ["merges", index], message: "ช่วงเซลล์ที่ผสานต้องไม่ซ้อนกัน" });
|
||||
}
|
||||
occupied.add(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
sheet.rows.forEach((row, rowIndex) => {
|
||||
[
|
||||
...Object.keys(row.cells),
|
||||
...Object.keys(row.backgrounds ?? {}),
|
||||
...Object.keys(row.alignments ?? {}),
|
||||
...Object.keys(row.verticalAlignments ?? {}),
|
||||
].forEach((columnId) => {
|
||||
if (!columnIds.has(columnId)) {
|
||||
context.addIssue({
|
||||
@@ -64,19 +93,179 @@ export type ExtraSheetCell = {
|
||||
value: string;
|
||||
backgroundColor?: string;
|
||||
alignment?: ExtraSheetAlignment;
|
||||
verticalAlignment?: ExtraSheetVerticalAlignment;
|
||||
};
|
||||
export type ExtraSheetMatrix = Array<Array<ExtraSheetCell | undefined>>;
|
||||
type SheetPoint = { row: number; column: number };
|
||||
|
||||
export type ExtraSheetRange = { start: SheetPoint; end: SheetPoint };
|
||||
export type ExtraSheetMerge = NonNullable<ExtraSheet["merges"]>[number];
|
||||
|
||||
export function extraSheetMergeRange(sheet: ExtraSheet, merge: ExtraSheetMerge): ExtraSheetRange {
|
||||
const row = sheet.rows.findIndex((item) => item.id === merge.rowIds[0]);
|
||||
const column = sheet.columns.findIndex((item) => item.id === merge.columnIds[0]);
|
||||
return {
|
||||
start: { row, column },
|
||||
end: { row: row + merge.rowIds.length - 1, column: column + merge.columnIds.length - 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function contains(range: ExtraSheetRange, point: SheetPoint): boolean {
|
||||
return point.row >= range.start.row && point.row <= range.end.row
|
||||
&& point.column >= range.start.column && point.column <= range.end.column;
|
||||
}
|
||||
|
||||
function intersects(a: ExtraSheetRange, b: ExtraSheetRange): boolean {
|
||||
return a.start.row <= b.end.row && a.end.row >= b.start.row
|
||||
&& a.start.column <= b.end.column && a.end.column >= b.start.column;
|
||||
}
|
||||
|
||||
export function extraSheetMergeAt(sheet: ExtraSheet, point: SheetPoint): ExtraSheetRange | undefined {
|
||||
const merge = sheet.merges?.find((item) => contains(extraSheetMergeRange(sheet, item), point));
|
||||
return merge ? extraSheetMergeRange(sheet, merge) : undefined;
|
||||
}
|
||||
|
||||
export function extraSheetMergeMap(sheet: ExtraSheet): Map<string, ExtraSheetRange> {
|
||||
const map = new Map<string, ExtraSheetRange>();
|
||||
for (const merge of sheet.merges ?? []) {
|
||||
const range = extraSheetMergeRange(sheet, merge);
|
||||
for (const point of extraSheetRangePoints(range)) map.set(`${point.row}:${point.column}`, range);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function extraSheetAnchor(sheet: ExtraSheet, point: SheetPoint): SheetPoint {
|
||||
return extraSheetMergeAt(sheet, point)?.start ?? point;
|
||||
}
|
||||
|
||||
export function nextExtraSheetPoint(
|
||||
sheet: ExtraSheet, point: SheetPoint, direction: SheetPoint, wrap = false,
|
||||
): SheetPoint | undefined {
|
||||
const merge = extraSheetMergeAt(sheet, point);
|
||||
const anchor = merge?.start ?? point;
|
||||
let next = {
|
||||
row: direction.row > 0 && merge ? merge.end.row + 1 : point.row + direction.row,
|
||||
column: direction.column > 0 && merge ? merge.end.column + 1 : point.column + direction.column,
|
||||
};
|
||||
while (true) {
|
||||
if (wrap && next.column >= sheet.columns.length) next = { row: next.row + 1, column: 0 };
|
||||
if (wrap && next.column < 0) next = { row: next.row - 1, column: sheet.columns.length - 1 };
|
||||
if (!sheet.rows[next.row] || !sheet.columns[next.column]) return undefined;
|
||||
const target = extraSheetAnchor(sheet, next);
|
||||
if (target.row !== anchor.row || target.column !== anchor.column) return target;
|
||||
if (!direction.row && !direction.column) return undefined;
|
||||
next = { row: next.row + direction.row, column: next.column + direction.column };
|
||||
}
|
||||
}
|
||||
|
||||
export function extraSheetRangePoints(range: ExtraSheetRange): SheetPoint[] {
|
||||
return Array.from({ length: range.end.row - range.start.row + 1 }, (_, row) =>
|
||||
Array.from({ length: range.end.column - range.start.column + 1 }, (_, column) => ({
|
||||
row: range.start.row + row, column: range.start.column + column,
|
||||
})),
|
||||
).flat();
|
||||
}
|
||||
|
||||
export function expandExtraSheetRange(sheet: ExtraSheet, start: SheetPoint, end: SheetPoint): ExtraSheetRange {
|
||||
const range = {
|
||||
start: { row: Math.min(start.row, end.row), column: Math.min(start.column, end.column) },
|
||||
end: { row: Math.max(start.row, end.row), column: Math.max(start.column, end.column) },
|
||||
};
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const merge of sheet.merges ?? []) {
|
||||
const bounds = extraSheetMergeRange(sheet, merge);
|
||||
if (!intersects(range, bounds)) continue;
|
||||
if (contains(range, bounds.start) && contains(range, bounds.end)) continue;
|
||||
range.start.row = Math.min(range.start.row, bounds.start.row);
|
||||
range.start.column = Math.min(range.start.column, bounds.start.column);
|
||||
range.end.row = Math.max(range.end.row, bounds.end.row);
|
||||
range.end.column = Math.max(range.end.column, bounds.end.column);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export function canMergeExtraSheetRange(sheet: ExtraSheet, range: ExtraSheetRange): boolean {
|
||||
if (!sheet.rows[range.start.row] || !sheet.rows[range.end.row]
|
||||
|| !sheet.columns[range.start.column] || !sheet.columns[range.end.column]) return false;
|
||||
if (range.end.row < range.start.row || range.end.column < range.start.column
|
||||
|| (range.start.row === range.end.row && range.start.column === range.end.column)) return false;
|
||||
return !(sheet.merges ?? []).some((merge) => {
|
||||
const bounds = extraSheetMergeRange(sheet, merge);
|
||||
const identical = bounds.start.row === range.start.row && bounds.start.column === range.start.column
|
||||
&& bounds.end.row === range.end.row && bounds.end.column === range.end.column;
|
||||
return identical || (intersects(range, bounds)
|
||||
&& !(contains(range, bounds.start) && contains(range, bounds.end)));
|
||||
});
|
||||
}
|
||||
|
||||
export function unmergeExtraSheetRange(sheet: ExtraSheet, range: ExtraSheetRange): ExtraSheet {
|
||||
if (!sheet.merges?.some((merge) => intersects(range, extraSheetMergeRange(sheet, merge)))) return sheet;
|
||||
return { ...sheet, merges: sheet.merges.filter((merge) => !intersects(range, extraSheetMergeRange(sheet, merge))) };
|
||||
}
|
||||
|
||||
export function mergeExtraSheetRange(sheet: ExtraSheet, range: ExtraSheetRange): ExtraSheet {
|
||||
if (!canMergeExtraSheetRange(sheet, range)) return sheet;
|
||||
const points = extraSheetRangePoints(range);
|
||||
const text = points.flatMap(({ row, column }) => {
|
||||
const value = sheet.rows[row].cells[sheet.columns[column].id] ?? "";
|
||||
return extraRichTextToPlainText(value).trim() ? [normalizeExtraRichText(value)] : [];
|
||||
}).join("");
|
||||
if (text.length > 10_000) throw new Error("ข้อความที่ผสานต้องไม่เกิน 10,000 ตัวอักษร");
|
||||
const unmerged = unmergeExtraSheetRange(sheet, range);
|
||||
const cleared = updateExtraSheetCells(unmerged, points, { value: "" });
|
||||
const updated = updateExtraSheetCell(cleared, range.start.row, range.start.column, text);
|
||||
return {
|
||||
...updated,
|
||||
merges: [...(updated.merges ?? []), {
|
||||
rowIds: sheet.rows.slice(range.start.row, range.end.row + 1).map((row) => row.id),
|
||||
columnIds: sheet.columns.slice(range.start.column, range.end.column + 1).map((column) => column.id),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function anchorKeys(sheet: ExtraSheet, points: Iterable<SheetPoint>): Set<string> {
|
||||
const merges = extraSheetMergeMap(sheet);
|
||||
return new Set(Array.from(points, (point) => {
|
||||
const { row, column } = merges.get(`${point.row}:${point.column}`)?.start ?? point;
|
||||
return `${row}:${column}`;
|
||||
}));
|
||||
}
|
||||
|
||||
// Remove only merges touched by a structural edit. Insertion at a boundary is outside a merge.
|
||||
function unmergeExtraSheetAxis(
|
||||
sheet: ExtraSheet, axis: "row" | "column", from: number, to = from, insertion = false,
|
||||
): ExtraSheet {
|
||||
if (!sheet.merges?.length) return sheet;
|
||||
return {
|
||||
...sheet,
|
||||
merges: sheet.merges.filter((merge) => {
|
||||
const range = extraSheetMergeRange(sheet, merge);
|
||||
const start = range.start[axis];
|
||||
const end = range.end[axis];
|
||||
return insertion ? !(from > start && from <= end)
|
||||
: !(Math.min(from, to) <= end && Math.max(from, to) >= start);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function extraSheetUsedRange(sheet: ExtraSheet): ExtraSheet | null {
|
||||
const usedPoints = sheet.rows.flatMap((row, rowIndex) =>
|
||||
sheet.columns.flatMap((column, columnIndex) => {
|
||||
const used = extraRichTextToPlainText(row.cells[column.id] ?? "").trim()
|
||||
|| row.backgrounds?.[column.id]
|
||||
|| row.alignments?.[column.id];
|
||||
|| row.alignments?.[column.id]
|
||||
|| row.verticalAlignments?.[column.id];
|
||||
return used ? [{ row: rowIndex, column: columnIndex }] : [];
|
||||
}),
|
||||
);
|
||||
for (const merge of sheet.merges ?? []) {
|
||||
const range = extraSheetMergeRange(sheet, merge);
|
||||
usedPoints.push(range.start, range.end);
|
||||
}
|
||||
if (!usedPoints.length) return null;
|
||||
|
||||
const rowIndexes = usedPoints.map((point) => point.row);
|
||||
@@ -88,6 +277,7 @@ export function extraSheetUsedRange(sheet: ExtraSheet): ExtraSheet | null {
|
||||
|
||||
const columns = sheet.columns.slice(firstColumn, lastColumn + 1);
|
||||
return {
|
||||
...sheet,
|
||||
columns,
|
||||
rows: sheet.rows.slice(firstRow, lastRow + 1).map((row) => ({
|
||||
...row,
|
||||
@@ -98,6 +288,9 @@ export function extraSheetUsedRange(sheet: ExtraSheet): ExtraSheet | null {
|
||||
alignments: Object.fromEntries(columns.flatMap((column) =>
|
||||
row.alignments?.[column.id] ? [[column.id, row.alignments[column.id]]] : [],
|
||||
)),
|
||||
verticalAlignments: Object.fromEntries(columns.flatMap((column) =>
|
||||
row.verticalAlignments?.[column.id] ? [[column.id, row.verticalAlignments[column.id]]] : [],
|
||||
)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -122,6 +315,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
|
||||
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
verticalAlignments: {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -132,6 +326,7 @@ export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
|
||||
value: row.cells[column.id] ?? "",
|
||||
backgroundColor: row.backgrounds?.[column.id],
|
||||
alignment: row.alignments?.[column.id],
|
||||
verticalAlignment: row.verticalAlignments?.[column.id],
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -170,8 +365,13 @@ export function matrixToExtraSheet(
|
||||
?? sheet.rows[rowIndex]?.alignments?.[column.id];
|
||||
return alignment ? [[column.id, alignment]] : [];
|
||||
})),
|
||||
verticalAlignments: Object.fromEntries(columns.flatMap((column, columnIndex) => {
|
||||
const alignment = matrix[rowIndex]?.[columnIndex]?.verticalAlignment
|
||||
?? sheet.rows[rowIndex]?.verticalAlignments?.[column.id];
|
||||
return alignment ? [[column.id, alignment]] : [];
|
||||
})),
|
||||
}));
|
||||
return { columns, rows };
|
||||
return { ...sheet, columns, rows };
|
||||
}
|
||||
|
||||
export function addExtraSheetColumn(
|
||||
@@ -189,6 +389,7 @@ export function insertExtraSheetColumn(
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.columns.length));
|
||||
sheet = unmergeExtraSheetAxis(sheet, "column", position, position, true);
|
||||
const columns = [...sheet.columns];
|
||||
columns.splice(position, 0, {
|
||||
id,
|
||||
@@ -196,6 +397,7 @@ export function insertExtraSheetColumn(
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
});
|
||||
return {
|
||||
...sheet,
|
||||
columns,
|
||||
rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
|
||||
};
|
||||
@@ -211,12 +413,14 @@ export function insertExtraSheetRow(
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.rows.length));
|
||||
sheet = unmergeExtraSheetAxis(sheet, "row", position, position, true);
|
||||
const rows = [...sheet.rows];
|
||||
rows.splice(position, 0, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
verticalAlignments: {},
|
||||
});
|
||||
return {
|
||||
...sheet,
|
||||
@@ -247,6 +451,7 @@ export function moveExtraSheetColumn(
|
||||
to: number,
|
||||
): ExtraSheet {
|
||||
if (to < 0 || to >= sheet.columns.length || from === to) return sheet;
|
||||
sheet = unmergeExtraSheetAxis(sheet, "column", from, to);
|
||||
const columns = [...sheet.columns];
|
||||
const [column] = columns.splice(from, 1);
|
||||
columns.splice(to, 0, column);
|
||||
@@ -259,6 +464,7 @@ export function moveExtraSheetRow(
|
||||
to: number,
|
||||
): ExtraSheet {
|
||||
if (to < 0 || to >= sheet.rows.length || from === to) return sheet;
|
||||
sheet = unmergeExtraSheetAxis(sheet, "row", from, to);
|
||||
const rows = [...sheet.rows];
|
||||
const [row] = rows.splice(from, 1);
|
||||
rows.splice(to, 0, row);
|
||||
@@ -269,19 +475,23 @@ export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraS
|
||||
if (sheet.columns.length === 1) return sheet;
|
||||
const columnId = sheet.columns[index]?.id;
|
||||
if (!columnId) return sheet;
|
||||
sheet = unmergeExtraSheetAxis(sheet, "column", index);
|
||||
return {
|
||||
...sheet,
|
||||
columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index),
|
||||
rows: sheet.rows.map((row) => ({
|
||||
...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)),
|
||||
verticalAlignments: Object.fromEntries(Object.entries(row.verticalAlignments ?? {}).filter(([key]) => key !== columnId)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet {
|
||||
if (sheet.rows.length === 1) return sheet;
|
||||
sheet = unmergeExtraSheetAxis(sheet, "row", index);
|
||||
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
|
||||
}
|
||||
|
||||
@@ -291,6 +501,9 @@ export function updateExtraSheetCell(
|
||||
columnIndex: number,
|
||||
value: string,
|
||||
): ExtraSheet {
|
||||
const anchor = extraSheetAnchor(sheet, { row: rowIndex, column: columnIndex });
|
||||
rowIndex = anchor.row;
|
||||
columnIndex = anchor.column;
|
||||
const row = sheet.rows[rowIndex];
|
||||
const column = sheet.columns[columnIndex];
|
||||
if (!row || !column) return sheet;
|
||||
@@ -309,15 +522,17 @@ export function updateExtraSheetCells(
|
||||
value?: string;
|
||||
backgroundColor?: string | null;
|
||||
alignment?: ExtraSheetAlignment | null;
|
||||
verticalAlignment?: ExtraSheetVerticalAlignment | null;
|
||||
},
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
const selected = anchorKeys(sheet, points);
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
const verticalAlignments = { ...(row.verticalAlignments ?? {}) };
|
||||
sheet.columns.forEach((column, columnIndex) => {
|
||||
if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
|
||||
if (update.value !== undefined) cells[column.id] = update.value;
|
||||
@@ -325,8 +540,10 @@ export function updateExtraSheetCells(
|
||||
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;
|
||||
if (update.verticalAlignment === null) delete verticalAlignments[column.id];
|
||||
else if (update.verticalAlignment !== undefined) verticalAlignments[column.id] = update.verticalAlignment;
|
||||
});
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
return { ...row, cells, backgrounds, alignments, verticalAlignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -335,20 +552,22 @@ export function clearExtraSheetCellFormatting(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
const selected = anchorKeys(sheet, points);
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
const verticalAlignments = { ...(row.verticalAlignments ?? {}) };
|
||||
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];
|
||||
delete verticalAlignments[column.id];
|
||||
});
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
return { ...row, cells, backgrounds, alignments, verticalAlignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -362,10 +581,9 @@ export function extraSheetRangeToTsv(
|
||||
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);
|
||||
const range = expandExtraSheetRange(sheet, start, end);
|
||||
const { row: top, column: left } = range.start;
|
||||
const { row: bottom, column: right } = range.end;
|
||||
return sheet.rows.slice(top, bottom + 1).map((row) =>
|
||||
sheet.columns.slice(left, right + 1).map((column) =>
|
||||
quoteTsvValue(extraRichTextToPlainText(row.cells[column.id] ?? "")),
|
||||
@@ -408,7 +626,13 @@ export function pasteExtraSheetText(
|
||||
): ExtraSheet {
|
||||
const pasted = parseExtraSheetTsv(text);
|
||||
if (!pasted.length) return sheet;
|
||||
let next = sheet;
|
||||
if (pasted.length === 1 && pasted[0].length === 1) {
|
||||
return updateExtraSheetCell(sheet, start.row, start.column, normalizeExtraRichText(pasted[0][0]));
|
||||
}
|
||||
let next = unmergeExtraSheetRange(sheet, {
|
||||
start,
|
||||
end: { row: start.row + pasted.length - 1, column: start.column + Math.max(...pasted.map((row) => row.length)) - 1 },
|
||||
});
|
||||
const requiredRows = start.row + pasted.length;
|
||||
const requiredColumns = start.column + Math.max(...pasted.map((row) => row.length));
|
||||
while (next.rows.length < requiredRows) {
|
||||
@@ -441,7 +665,7 @@ export function styleExtraSheetCells(
|
||||
points: Iterable<SheetPoint>,
|
||||
patch: RichTextStylePatch,
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
const selected = anchorKeys(sheet, points);
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => ({
|
||||
|
||||
@@ -314,6 +314,8 @@ describe("structured guide validation", () => {
|
||||
let id = 0;
|
||||
const sheet = createExtraSheet((prefix) => `${prefix}-${++id}`);
|
||||
sheet.rows[0].cells[sheet.columns[0].id] = "=1+1";
|
||||
sheet.merges = [{ rowIds: [sheet.rows[0].id, sheet.rows[1].id], columnIds: [sheet.columns[0].id, sheet.columns[1].id] }];
|
||||
sheet.rows[0].verticalAlignments[sheet.columns[0].id] = "middle";
|
||||
const row = {
|
||||
mediaId: null,
|
||||
heading: "Stats",
|
||||
@@ -331,6 +333,8 @@ describe("structured guide validation", () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("<p>=1+1</p>");
|
||||
expect(result.data.sections[0].rows[0].sheet?.merges).toEqual(sheet.merges);
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].verticalAlignments[sheet.columns[0].id]).toBe("middle");
|
||||
}
|
||||
|
||||
const invalidSheet = structuredClone(sheet);
|
||||
|
||||
Reference in New Issue
Block a user