feat : update excel
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
normalizeExtraSheetRichText,
|
||||
styleExtraSheetCells,
|
||||
updateExtraSheetCell,
|
||||
updateExtraSheetCells,
|
||||
type ExtraSheet,
|
||||
@@ -124,6 +125,23 @@ describe("Extra sheet", () => {
|
||||
.rows[0].backgrounds).toEqual({});
|
||||
});
|
||||
|
||||
it("styles selected cells without replacing their individual text", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const firstColumn = sheet.columns[0].id;
|
||||
const secondColumn = sheet.columns[1].id;
|
||||
sheet.rows[0].cells[firstColumn] = "<p>Alpha</p>";
|
||||
sheet.rows[1].cells[secondColumn] = "<p><em>Beta</em></p>";
|
||||
|
||||
const styled = styleExtraSheetCells(sheet, [
|
||||
{ row: 0, column: 0 },
|
||||
{ row: 1, column: 1 },
|
||||
], { bold: true });
|
||||
|
||||
expect(styled.rows[0].cells[firstColumn]).toBe("<p><strong>Alpha</strong></p>");
|
||||
expect(styled.rows[1].cells[secondColumn]).toBe("<p><em><strong>Beta</strong></em></p>");
|
||||
expect(styled.rows[0].cells[secondColumn]).toBe("");
|
||||
});
|
||||
|
||||
it("renders legacy sheets that do not have background metadata yet", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const legacy = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import {
|
||||
applyRichTextStylePatch,
|
||||
normalizeExtraRichText,
|
||||
type RichTextStylePatch,
|
||||
} from "@/lib/rich-text";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
|
||||
@@ -213,6 +217,29 @@ export function updateExtraSheetCells(
|
||||
};
|
||||
}
|
||||
|
||||
export function styleExtraSheetCells(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
patch: RichTextStylePatch,
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => ({
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => {
|
||||
const columnIndex = sheet.columns.findIndex((column) => column.id === columnId);
|
||||
return [
|
||||
columnId,
|
||||
selected.has(`${rowIndex}:${columnIndex}`)
|
||||
? applyRichTextStylePatch(value, patch)
|
||||
: value,
|
||||
];
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
|
||||
return {
|
||||
...sheet,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyRichTextStylePatch,
|
||||
normalizeExtraRichText,
|
||||
richTextContentSignature,
|
||||
sanitizeRichTextHtml,
|
||||
sanitizeRichTextUrl,
|
||||
} from "@/lib/rich-text";
|
||||
@@ -46,3 +48,35 @@ describe("legacy Extra rich text conversion", () => {
|
||||
])).toBe("<p><em>Ready</em></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk rich-text styles", () => {
|
||||
it("applies a style to every text node without replacing structure or links", () => {
|
||||
expect(applyRichTextStylePatch(
|
||||
'<p>First <a href="https://example.com">link</a></p><ul><li>Second</li></ul>',
|
||||
{ bold: true },
|
||||
)).toBe(
|
||||
'<p><strong>First </strong><a href="https://example.com" target="_blank" rel="noopener noreferrer"><strong>link</strong></a></p><ul><li><strong>Second</strong></li></ul>',
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only requested styles and applies normalized colors", () => {
|
||||
expect(applyRichTextStylePatch(
|
||||
'<p><strong>Bold <em>and italic</em></strong></p>',
|
||||
{ bold: false },
|
||||
)).toBe("<p>Bold <em>and italic</em></p>");
|
||||
expect(applyRichTextStylePatch("<p>Red</p>", { color: "#00ff00" }))
|
||||
.toBe('<p><span style="color: #00FF00">Red</span></p>');
|
||||
expect(applyRichTextStylePatch(
|
||||
'<p><span style="color: #00FF00">Green</span></p>',
|
||||
{ color: null },
|
||||
)).toBe("<p>Green</p>");
|
||||
});
|
||||
|
||||
it("compares content independently from visual formatting", () => {
|
||||
expect(richTextContentSignature(
|
||||
'<p><strong>Same</strong> <span style="color: #FF0000">text</span></p>',
|
||||
)).toBe("<p>Same text</p>");
|
||||
expect(richTextContentSignature("<p>Changed text</p>"))
|
||||
.not.toBe(richTextContentSignature("<p>Original text</p>"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,14 @@ const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
|
||||
|
||||
export interface RichTextStylePatch {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strike?: boolean;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
function normalizeRichTextColor(value: string): string | null {
|
||||
const color = value.trim();
|
||||
if (HEX_COLOR.test(color)) return color.toUpperCase();
|
||||
@@ -120,3 +128,58 @@ export function normalizeExtraRichText(
|
||||
|
||||
return content ? `<p>${content}</p>` : "";
|
||||
}
|
||||
|
||||
const RICH_TEXT_MARK_TAGS = {
|
||||
bold: "strong",
|
||||
italic: "em",
|
||||
underline: "u",
|
||||
strike: "s",
|
||||
} as const;
|
||||
|
||||
function removeRichTextTag(value: string, tag: string): string {
|
||||
return value.replace(new RegExp(`</?${tag}(?:\\s[^>]*)?>`, "gi"), "");
|
||||
}
|
||||
|
||||
function wrapRichTextNodes(value: string, tag: string, attributes = ""): string {
|
||||
return value
|
||||
.split(/(<[^>]+>)/g)
|
||||
.map((part) => part && !part.startsWith("<")
|
||||
? `<${tag}${attributes}>${part}</${tag}>`
|
||||
: part)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Returns rich-text structure with visual marks removed for content comparisons. */
|
||||
export function richTextContentSignature(value: string): string {
|
||||
let result = normalizeExtraRichText(value);
|
||||
Object.values(RICH_TEXT_MARK_TAGS).forEach((tag) => {
|
||||
result = removeRichTextTag(result, tag);
|
||||
});
|
||||
return removeRichTextTag(result, "span");
|
||||
}
|
||||
|
||||
/** Applies uniform visual formatting without replacing a cell's own content. */
|
||||
export function applyRichTextStylePatch(
|
||||
value: string,
|
||||
patch: RichTextStylePatch,
|
||||
): string {
|
||||
let result = normalizeExtraRichText(value);
|
||||
|
||||
(Object.entries(RICH_TEXT_MARK_TAGS) as Array<
|
||||
[keyof Omit<RichTextStylePatch, "color">, string]
|
||||
>).forEach(([format, tag]) => {
|
||||
if (patch[format] === undefined) return;
|
||||
result = removeRichTextTag(result, tag);
|
||||
if (patch[format]) result = wrapRichTextNodes(result, tag);
|
||||
});
|
||||
|
||||
if (patch.color !== undefined) {
|
||||
result = removeRichTextTag(result, "span");
|
||||
const color = patch.color ? normalizeRichTextColor(patch.color) : null;
|
||||
if (color) {
|
||||
result = wrapRichTextNodes(result, "span", ` style="color: ${color}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeRichTextHtml(result);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user