feat : i dont even know what am i doing right now
CI / Verify (push) Successful in 2m20s
CI / Build immutable images and deploy (push) Successful in 2m57s

This commit is contained in:
2026-10-10 04:33:08 +07:00 Unverified
parent e723471707
commit 35f9dc40ba
10 changed files with 994 additions and 63 deletions
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { createContext, useContext, useLayoutEffect, useRef, useState } from "react";
import type { CellComponentProps } from "react-spreadsheet";
import {
type ExtraSheetCell,
type ExtraSheetRange,
} from "@/lib/guides/extra-sheet";
import { cn } from "@/lib/utils";
export const ExtraSheetCellContext = createContext<{ merges: Map<string, ExtraSheetRange>; layoutVersion: number } | null>(null);
export function sheetCellElement(table: HTMLTableElement, row: number, column: number): HTMLTableCellElement | null {
return table.querySelector(`[data-sheet-row="${row}"][data-sheet-column="${column}"]`);
}
export function sheetRangeRect(table: HTMLTableElement, range: ExtraSheetRange) {
const first = sheetCellElement(table, range.start.row, range.start.column);
const last = sheetCellElement(table, range.end.row, range.end.column);
if (!first || !last) return null;
return {
top: first.offsetTop,
left: first.offsetLeft,
width: last.offsetLeft + last.offsetWidth - first.offsetLeft,
height: last.offsetTop + last.offsetHeight - first.offsetTop,
};
}
// Keep physical cells for react-spreadsheet's per-axis measurements. Only the
// anchor draws the merged content; covered cells remain empty layout slots.
export function ExtraSheetCellView(props: CellComponentProps<ExtraSheetCell>) {
const context = useContext(ExtraSheetCellContext)!;
const { row, column, active, selected, dragging, mode, data, DataViewer, setCellDimensions } = props;
const rootRef = useRef<HTMLTableCellElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const merge = context.merges.get(`${row}:${column}`);
const covered = !!merge && (merge.start.row !== row || merge.start.column !== column);
const [size, setSize] = useState({ width: 0, height: 0, spacer: 36 });
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const table = root.closest("table");
if (merge && !covered && table) {
const rect = sheetRangeRect(table, merge);
if (rect) {
const contentHeight = contentRef.current?.scrollHeight ?? 36;
const spacer = Math.max(36, contentHeight - (rect.height - root.offsetHeight));
setSize((previous) => previous.width === rect.width && previous.height === rect.height && previous.spacer === spacer
? previous : { width: rect.width, height: rect.height, spacer });
}
}
if (selected || active) {
setCellDimensions({ row, column }, {
top: root.offsetTop, left: root.offsetLeft,
width: root.offsetWidth, height: root.offsetHeight,
});
}
if (active && mode === "view" && !covered) root.focus();
}, [row, column, active, selected, mode, covered, merge, data, context, setCellDimensions, size.spacer]);
const target = merge?.start ?? { row, column };
return (
<td
ref={rootRef}
className={cn("Spreadsheet__cell", merge && "extra-sheet-merge-slot")}
data-sheet-row={row}
data-sheet-column={column}
style={{ verticalAlign: data?.verticalAlignment ?? "top" }}
tabIndex={covered ? -1 : 0}
aria-hidden={covered || undefined}
aria-rowspan={!covered && merge ? merge.end.row - row + 1 : undefined}
aria-colspan={!covered && merge ? merge.end.column - column + 1 : undefined}
onMouseDown={(event) => {
// Activation focuses the anchor. Browser default focus can otherwise
// blur the grid after the previous editor unmounts.
event.preventDefault();
if (mode !== "view") return;
if (event.shiftKey) props.select(target);
else props.activate(target);
}}
onMouseOver={() => { if (dragging) props.select(target); }}
>
{merge ? (
<>
<div aria-hidden="true" style={{ minHeight: covered ? 36 : size.spacer }} />
{!covered ? (
<div
className="extra-sheet-merged-cell"
style={{
width: size.width || undefined, minHeight: size.height || 36,
backgroundColor: data?.backgroundColor,
justifyContent: data?.verticalAlignment === "middle" ? "center" : data?.verticalAlignment === "bottom" ? "flex-end" : "flex-start",
}}
>
<div ref={contentRef}>
<DataViewer row={row} column={column} cell={data} evaluatedCell={props.evaluatedData} setCellData={props.setCellData} />
</div>
</div>
) : null}
</>
) : (
<DataViewer row={row} column={column} cell={data} evaluatedCell={props.evaluatedData} setCellData={props.setCellData} />
)}
</td>
);
}
@@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import { act, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ExtraSheetEditor } from "./extra-sheet-editor";
import { createExtraSheet, mergeExtraSheetRange, type ExtraSheet } from "@/lib/guides/extra-sheet";
let root: Root;
let container: HTMLDivElement;
let latest: ExtraSheet;
let sequence = 0;
const createId = (prefix: string) => `${prefix}-${++sequence}`;
function Harness({ initial }: { initial: ExtraSheet }) {
const [sheet, setSheet] = useState(initial);
useEffect(() => { latest = sheet; }, [sheet]);
return <ExtraSheetEditor value={sheet} createId={createId} onChange={setSheet} onRemove={() => {}} />;
}
async function mouseDown(element: Element, shiftKey = false) {
await act(async () => {
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, shiftKey }));
});
}
async function click(element: Element) {
await mouseDown(element);
await act(async () => { element.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
}
function cell(row: number, column: number) {
return container.querySelector<HTMLTableCellElement>(`[data-sheet-row="${row}"][data-sheet-column="${column}"]`)!;
}
function button(label: string) {
return Array.from(container.querySelectorAll("button")).find((item) => item.getAttribute("aria-label") === label || item.textContent?.trim() === label)!;
}
async function typeText(element: HTMLTextAreaElement, text: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(element, text);
element.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function mount(initial = createExtraSheet(createId)) {
await act(async () => { root.render(<Harness initial={initial} />); });
}
beforeEach(() => {
sequence = 0;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
// Supply grid measurements; happy-dom does not run browser layout.
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) { return this.tagName === "TD" ? 140 : 1000; });
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (this: HTMLElement) { return this.tagName === "TD" ? 36 : 180; });
vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function (this: HTMLElement) { return (Number(this.dataset.sheetRow ?? -1) + 1) * 36; });
vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) { return (Number(this.dataset.sheetColumn ?? -1) + 1) * 140; });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("extra spreadsheet interactions", () => {
it("saves inline text when another cell is clicked without Enter", async () => {
await mount();
await mouseDown(cell(0, 0));
await click(container.querySelector(".Spreadsheet__active-cell--view")!);
const input = container.querySelector<HTMLTextAreaElement>(".Spreadsheet__data-editor textarea")!;
expect(input).not.toBeNull();
await typeText(input, "Keep this text");
await mouseDown(cell(1, 1));
expect(latest.rows[0].cells[latest.columns[0].id]).toBe("<p>Keep this text</p>");
expect(latest.rows[1].cells[latest.columns[1].id]).toBe("");
});
it("saves inline text on blur and cancels Escape", async () => {
await mount();
await mouseDown(cell(0, 0));
await click(container.querySelector(".Spreadsheet__active-cell--view")!);
let input = container.querySelector<HTMLTextAreaElement>(".Spreadsheet__data-editor textarea")!;
await typeText(input, "Saved on blur");
await act(async () => { input.blur(); });
expect(latest.rows[0].cells[latest.columns[0].id]).toBe("<p>Saved on blur</p>");
await mouseDown(cell(0, 1));
await click(container.querySelector(".Spreadsheet__active-cell--view")!);
input = container.querySelector<HTMLTextAreaElement>(".Spreadsheet__data-editor textarea")!;
await typeText(input, "Discard");
await act(async () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); });
expect(latest.rows[0].cells[latest.columns[1].id]).toBe("");
});
it.each(["เลือกสีพื้นหลังเซลล์", "เลือกสีตัวอักษร"])("keeps %s on the selection where its picker opened", async (label) => {
const sheet = createExtraSheet(createId);
sheet.rows[0].cells[sheet.columns[0].id] = "<p>First</p>";
sheet.rows[1].cells[sheet.columns[1].id] = "<p>Second</p>";
await mount(sheet);
await mouseDown(cell(0, 0));
const picker = container.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`)!;
await act(async () => { picker.dispatchEvent(new Event("pointerdown", { bubbles: true })); });
await mouseDown(cell(1, 1));
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(picker, "#123456");
picker.dispatchEvent(new Event("change", { bubbles: true }));
});
if (label === "เลือกสีพื้นหลังเซลล์") {
expect(latest.rows[0].backgrounds[latest.columns[0].id]).toBe("#123456");
expect(latest.rows[1].backgrounds).toEqual({});
} else {
expect(latest.rows[0].cells[latest.columns[0].id]).toContain("#123456");
expect(latest.rows[1].cells[latest.columns[1].id]).toBe("<p>Second</p>");
}
});
it("merges a selection, supports undo/redo, and unmerges", async () => {
const sheet = createExtraSheet(createId);
sheet.rows[0].cells[sheet.columns[0].id] = "<p>First</p>";
sheet.rows[1].cells[sheet.columns[1].id] = "<p>Last</p>";
await mount(sheet);
await mouseDown(cell(0, 0));
await mouseDown(cell(1, 1), true);
expect(button("ผสานเซลล์").disabled).toBe(false);
await click(button("ผสานเซลล์"));
expect(latest.merges).toHaveLength(1);
expect(latest.rows[0].cells[latest.columns[0].id]).toBe("<p>First</p><p>Last</p>");
expect(cell(0, 0).getAttribute("aria-rowspan")).toBe("2");
expect(cell(1, 1).getAttribute("aria-hidden")).toBe("true");
await click(button("ย้อนกลับการแก้ไขตาราง"));
expect(latest.merges).toBeUndefined();
expect(latest.rows[1].cells[latest.columns[1].id]).toBe("<p>Last</p>");
await click(button("ทำซ้ำการแก้ไขตาราง"));
expect(latest.merges).toHaveLength(1);
await click(button("ยกเลิกผสาน"));
expect(latest.merges).toEqual([]);
});
it.each(["Enter", "Tab"])("commits merged inline edits and skips coverage with %s", async (key) => {
const merged = mergeExtraSheetRange(createExtraSheet(createId), { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } });
await mount(merged);
await mouseDown(cell(0, 0));
await click(container.querySelector(".Spreadsheet__active-cell--view")!);
const input = container.querySelector<HTMLTextAreaElement>(".Spreadsheet__data-editor textarea")!;
await typeText(input, "Committed with key");
await act(async () => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(latest.rows[0].cells[latest.columns[0].id]).toBe("<p>Committed with key</p>");
const expected = key === "Enter" ? cell(2, 0) : cell(0, 2);
const overlay = container.querySelector<HTMLElement>(".Spreadsheet__active-cell--view")!;
expect(overlay.style.top).toBe(`${expected.offsetTop}px`);
expect(overlay.style.left).toBe(`${expected.offsetLeft}px`);
expect(document.activeElement).toBe(overlay);
});
it("activates merge anchors, skips covered cells, and persists middle alignment", async () => {
const merged = mergeExtraSheetRange(createExtraSheet(createId), { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } });
await mount(merged);
await mouseDown(cell(1, 1));
await click(button("กึ่งกลางแนวตั้ง"));
expect(latest.rows[0].verticalAlignments[latest.columns[0].id]).toBe("middle");
expect(cell(0, 0).querySelector<HTMLElement>(".extra-sheet-merged-cell")?.style.justifyContent).toBe("center");
await act(async () => {
cell(0, 0).dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(container.querySelector(".Spreadsheet__active-cell")?.getAttribute("style")).toContain("left: 420px");
});
});
+212 -34
View File
@@ -2,6 +2,9 @@
import {
AlignCenterIcon,
AlignVerticalJustifyStartIcon,
AlignVerticalJustifyCenterIcon,
AlignVerticalJustifyEndIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
@@ -11,6 +14,8 @@ import {
GripHorizontalIcon,
GripVerticalIcon,
ItalicIcon,
MergeIcon,
SplitIcon,
PlusIcon,
PaletteIcon,
Redo2Icon,
@@ -25,6 +30,8 @@ import Spreadsheet, {
type DataEditorProps,
EntireColumnsSelection,
EntireRowsSelection,
PointRange,
RangeSelection,
type Point,
type RowIndicatorProps,
type Selection,
@@ -34,6 +41,7 @@ import Spreadsheet, {
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -60,6 +68,14 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { flushSync } from "react-dom";
import { toast } from "sonner";
import {
ExtraSheetCellContext,
ExtraSheetCellView,
sheetRangeRect,
} from "@/components/admin/extra-sheet-cell";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { RichTextContent } from "@/components/rich-text-content";
import { SimpleTooltip } from "@/components/tooltip";
@@ -94,6 +110,15 @@ import {
addExtraSheetColumn,
addExtraSheetRow,
clearExtraSheetCellFormatting,
canMergeExtraSheetRange,
expandExtraSheetRange,
extraSheetAnchor,
extraSheetMergeAt,
extraSheetMergeMap,
extraSheetRangePoints,
mergeExtraSheetRange,
unmergeExtraSheetRange,
updateExtraSheetCell,
DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
MAX_EXTRA_SHEET_COLUMN_WIDTH,
MIN_EXTRA_SHEET_COLUMN_WIDTH,
@@ -107,12 +132,14 @@ import {
moveExtraSheetColumn,
moveExtraSheetRow,
nextExtraSheetColumnLabel,
nextExtraSheetPoint,
parseExtraSheetTsv,
pasteExtraSheetText,
resizeExtraSheetColumn,
styleExtraSheetCells,
updateExtraSheetCells,
type ExtraSheetAlignment,
type ExtraSheetVerticalAlignment,
type ExtraSheet,
type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet";
@@ -193,13 +220,18 @@ function LiteralDataViewer({
function InlineDataEditor({
cell,
onChange,
exitEditMode,
initialText,
onReady,
onCommit,
registerCommit,
onNavigate,
}: DataEditorProps<{ value: string }> & {
initialText?: string;
onReady?: () => void;
onCommit: (value: string) => void;
registerCommit: (commit: (() => void) | null) => void;
onNavigate: (key: "Enter" | "Tab", shift: boolean) => void;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const originalText = useRef(
@@ -214,9 +246,13 @@ function InlineDataEditor({
committed.current = true;
const nextValue = normalizeExtraRichText(text);
if (text !== originalText.current) {
onChange({ ...cell, value: nextValue });
onCommit(nextValue);
}
}, [cell, onChange, text]);
}, [onCommit, text]);
useLayoutEffect(() => {
registerCommit(commit);
return () => registerCommit(null);
}, [commit, registerCommit]);
useEffect(() => {
onReady?.();
const input = inputRef.current;
@@ -243,7 +279,13 @@ function InlineDataEditor({
event.stopPropagation();
return;
}
if (event.key === "Enter" || event.key === "Tab") commit();
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
event.stopPropagation();
commit();
exitEditMode();
onNavigate(event.key, event.shiftKey);
}
}}
/>
</div>
@@ -412,6 +454,7 @@ export function ExtraSheetEditor({
);
const sheetRootRef = useRef<HTMLDivElement>(null);
const spreadsheetRef = useRef<SpreadsheetRef>(null);
const libraryActiveRef = useRef<Point | null>(null);
const initialEditTextRef = useRef<string | null>(null);
const [active, setActive] = useState<Point>({ row: 0, column: 0 });
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
@@ -425,6 +468,12 @@ export function ExtraSheetEditor({
index: 0,
value,
});
const pendingInlineCommitRef = useRef<(() => void) | null>(null);
const colorTargetsRef = useRef<{ text?: Point[]; background?: Point[] }>({});
const [sheetSelection, setSheetSelection] = useState<Selection>(
() => new RangeSelection(new PointRange(active, active)),
);
const [layoutVersion, setLayoutVersion] = useState(0);
const selectedPointsRef = useRef<Point[]>([active]);
const selectedRangeRef = useRef({ start: active, end: active });
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
@@ -436,6 +485,13 @@ export function ExtraSheetEditor({
const activeAlignment = activeColumn && activeRow
? activeRow.alignments?.[activeColumn.id] ?? "left"
: "left";
const activeVerticalAlignment = activeColumn && activeRow
? activeRow.verticalAlignments?.[activeColumn.id] ?? "top" : "top";
const selectedRange = sheetSelection.toRange(matrix) ?? { start: active, end: active };
const canMerge = canMergeExtraSheetRange(value, selectedRange);
const canUnmerge = extraSheetRangePoints(selectedRange).some((point) => !!extraSheetMergeAt(value, point));
const mergeMap = useMemo(() => extraSheetMergeMap(value), [value]);
const cellContext = useMemo(() => ({ merges: mergeMap, layoutVersion }), [mergeMap, layoutVersion]);
const canUndo = history.index > 0;
const canRedo = history.index < history.items.length - 1;
const sortableIds = useMemo(
@@ -476,28 +532,38 @@ export function ExtraSheetEditor({
if (!next) return;
setHistory((current) => ({ ...current, index, value: next }));
onChange(next);
const point = {
const point = extraSheetAnchor(next, {
row: Math.min(active.row, next.rows.length - 1),
column: Math.min(active.column, next.columns.length - 1),
};
});
const range = expandExtraSheetRange(next, point, point);
setActive(point);
selectedPointsRef.current = [point];
setSelectedCount(1);
window.requestAnimationFrame(() =>
spreadsheetRef.current?.activate(point),
);
selectedPointsRef.current = extraSheetRangePoints(range);
selectedRangeRef.current = range;
setSheetSelection(new RangeSelection(new PointRange(range.start, range.end)));
setSelectedCount(selectedPointsRef.current.length);
const previous = libraryActiveRef.current;
if (!previous || previous.row !== point.row || previous.column !== point.column) {
spreadsheetRef.current?.activate(point);
}
},
[active, history, onChange],
);
const activateOnly = useCallback((point: Point) => {
const range = expandExtraSheetRange(value, point, point);
point = extraSheetAnchor(value, point);
setActive(point);
selectedPointsRef.current = [point];
selectedRangeRef.current = { start: point, end: point };
setSelectedCount(1);
selectedPointsRef.current = extraSheetRangePoints(range);
selectedRangeRef.current = range;
setSheetSelection(new RangeSelection(new PointRange(range.start, range.end)));
setSelectedCount(selectedPointsRef.current.length);
setSelectionKind("cells");
window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point));
}, []);
const previous = libraryActiveRef.current;
if (!previous || previous.row !== point.row || previous.column !== point.column) {
spreadsheetRef.current?.activate(point);
}
}, [value]);
const moveRow = useCallback(
(from: number, to: number) => {
@@ -548,7 +614,7 @@ export function ExtraSheetEditor({
const handleSheetKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("input, textarea, [contenteditable='true']")) return;
if (event.defaultPrevented || target.closest("input, textarea, [contenteditable='true']")) return;
const key = event.key.toLowerCase();
const commandKey = event.metaKey || event.ctrlKey;
const undo =
@@ -748,15 +814,31 @@ export function ExtraSheetEditor({
const clearInitialEditText = useCallback(() => {
initialEditTextRef.current = null;
}, []);
const navigateInlineRef = useRef<(point: Point, key: "Enter" | "Tab", shift: boolean) => void>(() => {});
const commitInlineRef = useRef<(row: number, column: number, text: string) => void>(() => {});
useLayoutEffect(() => {
commitInlineRef.current = (row, column, text) => commitSheet(updateExtraSheetCell(value, row, column, text));
navigateInlineRef.current = (point, key, shift) => {
const direction = key === "Enter" ? { row: 1, column: 0 } : { row: 0, column: shift ? -1 : 1 };
const next = nextExtraSheetPoint(value, point, direction, key === "Tab");
if (next) activateOnly(next);
};
}, [value, commitSheet, activateOnly]);
const registerInlineCommit = useCallback((commit: (() => void) | null) => {
pendingInlineCommitRef.current = commit;
}, []);
const dataEditor = useCallback(
(props: DataEditorProps<{ value: string }>) => (
<InlineDataEditor
{...props}
initialText={initialEditTextRef.current ?? undefined}
onReady={clearInitialEditText}
onCommit={(text) => commitInlineRef.current(props.row, props.column, text)}
registerCommit={registerInlineCommit}
onNavigate={(key, shift) => navigateInlineRef.current({ row: props.row, column: props.column }, key, shift)}
/>
),
[clearInitialEditText],
[clearInitialEditText, registerInlineCommit],
);
const sheetTable = useCallback(
({ children, columns, hideColumnIndicators }: TableProps) => (
@@ -779,6 +861,46 @@ export function ExtraSheetEditor({
),
[value.columns],
);
useEffect(() => {
const table = sheetRootRef.current?.querySelector("table");
if (!table) return;
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => setLayoutVersion((version) => version + 1));
});
observer.observe(table);
return () => { observer.disconnect(); cancelAnimationFrame(frame); };
}, [sheetTable]);
useLayoutEffect(() => {
const grid = sheetRootRef.current?.querySelector<HTMLElement>(".extra-sheet-grid");
const table = grid?.querySelector("table");
if (!grid || !table) return;
const merge = extraSheetMergeAt(value, active);
const rect = merge && sheetRangeRect(table, merge);
grid.classList.toggle("extra-sheet-has-active-merge", !!rect);
if (rect) {
grid.style.setProperty("--merged-active-width", `${rect.width}px`);
grid.style.setProperty("--merged-active-height", `${rect.height}px`);
}
}, [value, active, layoutVersion]);
const mergeSelection = () => {
try {
const next = mergeExtraSheetRange(value, selectedRangeRef.current);
if (next === value) return;
commitSheet(next);
activateOnly(selectedRangeRef.current.start);
} catch (error) {
toast.error(error instanceof Error ? error.message : "ไม่สามารถผสานเซลล์ได้");
}
};
const unmergeSelection = () => commitSheet(unmergeExtraSheetRange(value, selectedRangeRef.current));
const captureColorTargets = (kind: "text" | "background") => {
colorTargetsRef.current[kind] = [...selectedPointsRef.current];
};
const activeFormats = [
/<strong\b/i.test(activeValue) ? "bold" : null,
/<em\b/i.test(activeValue) ? "italic" : null,
@@ -794,6 +916,11 @@ export function ExtraSheetEditor({
className="flex min-w-0 flex-col gap-3"
aria-label="ตารางข้อมูลเสริม"
onKeyDown={handleSheetKeyDown}
onMouseDownCapture={(event) => {
if (!(event.target as HTMLElement).closest(".Spreadsheet__data-editor")) {
flushSync(() => pendingInlineCommitRef.current?.());
}
}}
onCopyCapture={handleCopy}
onCutCapture={handleCut}
onPasteCapture={handlePaste}
@@ -863,7 +990,9 @@ export function ExtraSheetEditor({
aria-label="เลือกสีตัวอักษร"
className="absolute inset-0 cursor-pointer opacity-0"
value={activeTextColor}
onChange={(event) => applyStyle({ color: event.target.value })}
onPointerDown={() => captureColorTargets("text")}
onFocus={() => captureColorTargets("text")}
onChange={(event) => commitSheet(styleExtraSheetCells(value, colorTargetsRef.current.text ?? selectedPointsRef.current, { color: event.target.value }))}
/>
</label>
<SimpleTooltip
@@ -920,6 +1049,8 @@ export function ExtraSheetEditor({
<input
type="color"
aria-label="เลือกสีพื้นหลังเซลล์"
onPointerDown={() => captureColorTargets("background")}
onFocus={() => captureColorTargets("background")}
className="absolute inset-0 cursor-pointer opacity-0"
value={
activeRow?.backgrounds?.[activeColumn?.id ?? ""] ??
@@ -927,7 +1058,7 @@ export function ExtraSheetEditor({
}
onChange={(event) => {
commitSheet(
updateExtraSheetCells(value, selectedPointsRef.current, {
updateExtraSheetCells(value, colorTargetsRef.current.background ?? selectedPointsRef.current, {
backgroundColor: event.target.value,
}),
);
@@ -951,11 +1082,32 @@ export function ExtraSheetEditor({
<ToggleGroupItem value="center" aria-label="กึ่งกลาง"><AlignCenterIcon /></ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="ชิดขวา"><AlignRightIcon /></ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
value={[activeVerticalAlignment]}
variant="outline"
size="sm"
spacing={0}
aria-label="จัดแนวข้อความแนวตั้ง"
onValueChange={(alignments) => {
const verticalAlignment = alignments[0] as ExtraSheetVerticalAlignment | undefined;
if (verticalAlignment) commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { verticalAlignment }));
}}
>
<ToggleGroupItem value="top" aria-label="ชิดบน"><AlignVerticalJustifyStartIcon /></ToggleGroupItem>
<ToggleGroupItem value="middle" aria-label="กึ่งกลางแนวตั้ง"><AlignVerticalJustifyCenterIcon /></ToggleGroupItem>
<ToggleGroupItem value="bottom" aria-label="ชิดล่าง"><AlignVerticalJustifyEndIcon /></ToggleGroupItem>
</ToggleGroup>
<SimpleTooltip text="ล้างรูปแบบ">
<Button type="button" variant="ghost" size="icon-sm" aria-label="ล้างรูปแบบ" onClick={clearFormatting}>
<EraserIcon />
</Button>
</SimpleTooltip>
<Button type="button" variant="outline" size="sm" disabled={!canMerge} onClick={mergeSelection}>
<MergeIcon data-icon="inline-start" /> ผสานเซลล์
</Button>
<Button type="button" variant="outline" size="sm" disabled={!canUnmerge} onClick={unmergeSelection}>
<SplitIcon data-icon="inline-start" /> ยกเลิกผสาน
</Button>
<Button
type="button"
variant="ghost"
@@ -1002,10 +1154,13 @@ export function ExtraSheetEditor({
onDragEnd={handleDragEnd}
>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<ExtraSheetCellContext.Provider value={cellContext}>
<Spreadsheet
ref={spreadsheetRef}
className="extra-sheet-grid"
data={matrix}
Cell={ExtraSheetCellView}
selected={sheetSelection}
columnLabels={value.columns.map((column) => column.label)}
ColumnIndicator={columnIndicator}
RowIndicator={rowIndicator}
@@ -1020,6 +1175,23 @@ export function ExtraSheetEditor({
) {
return;
}
if (["Delete", "Backspace"].includes(event.key)) {
event.preventDefault();
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { value: "" }));
return;
}
const directions: Record<string, Point> = {
ArrowUp: { row: -1, column: 0 }, ArrowDown: { row: 1, column: 0 },
ArrowLeft: { row: 0, column: -1 }, ArrowRight: { row: 0, column: 1 },
Tab: { row: 0, column: event.shiftKey ? -1 : 1 },
};
const direction = directions[event.key];
if (direction && (!event.shiftKey || event.key === "Tab") && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
const point = nextExtraSheetPoint(value, active, direction, event.key === "Tab");
if (point) activateOnly(point);
return;
}
const startsTyping =
event.key.length === 1 &&
!event.ctrlKey &&
@@ -1035,26 +1207,29 @@ export function ExtraSheetEditor({
.querySelector<HTMLElement>(".Spreadsheet__active-cell--view")
?.click();
}}
onBlur={() => { libraryActiveRef.current = null; }}
onActivate={(point) => {
setActive(point);
setSelectedCount(1);
selectedPointsRef.current = [point];
selectedRangeRef.current = { start: point, end: point };
setSelectionKind("cells");
libraryActiveRef.current = point;
const anchor = extraSheetAnchor(value, point);
if (anchor.row !== point.row || anchor.column !== point.column) {
spreadsheetRef.current?.activate(anchor);
return;
}
setActive(anchor);
}}
onSelect={(nextSelection: Selection) => {
const range = nextSelection.toRange(matrix);
if (range) {
setActive(range.start);
selectedPointsRef.current = Array.from(range);
selectedRangeRef.current = { start: range.start, end: range.end };
setSelectedCount(range.size());
const expanded = expandExtraSheetRange(value, range.start, range.end);
selectedPointsRef.current = extraSheetRangePoints(expanded);
selectedRangeRef.current = expanded;
setSelectedCount(selectedPointsRef.current.length);
const changed = expanded.start.row !== range.start.row || expanded.start.column !== range.start.column
|| expanded.end.row !== range.end.row || expanded.end.column !== range.end.column;
setSheetSelection(changed ? new RangeSelection(new PointRange(expanded.start, expanded.end)) : nextSelection);
setSelectionKind(
nextSelection instanceof EntireRowsSelection
? "rows"
: nextSelection instanceof EntireColumnsSelection
? "columns"
: "cells",
nextSelection instanceof EntireRowsSelection ? "rows"
: nextSelection instanceof EntireColumnsSelection ? "columns" : "cells",
);
}
}}
@@ -1064,6 +1239,7 @@ export function ExtraSheetEditor({
)
}
/>
</ExtraSheetCellContext.Provider>
</SortableContext>
</DndContext>
</ContextMenuTrigger>
@@ -1099,6 +1275,8 @@ export function ExtraSheetEditor({
</ContextMenuGroup>
) : (
<ContextMenuGroup>
<ContextMenuItem disabled={!canMerge} onClick={mergeSelection}><MergeIcon /> ผสานเซลล์</ContextMenuItem>
<ContextMenuItem disabled={!canUnmerge} onClick={unmergeSelection}><SplitIcon /> ยกเลิกผสาน</ContextMenuItem>
<ContextMenuItem onClick={clearContents}>ล้างเนื้อหา</ContextMenuItem>
<ContextMenuItem onClick={clearFormatting}><EraserIcon /> ล้างรูปแบบ</ContextMenuItem>
</ContextMenuGroup>
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { createExtraSheet, mergeExtraSheetRange, updateExtraSheetCells } from "@/lib/guides/extra-sheet";
import type { PublicGuide } from "@/lib/guides/queries";
vi.mock("next/font/local", () => ({ default: () => ({ className: "font" }) }));
vi.mock("./guide-page-shell", () => ({
GuidePageShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
publicSectionTitle: (title: string) => title,
}));
vi.mock("./guide-image-export", () => ({
GuideImageExport: ({ children }: { children: ReactNode }) => <aside data-export>{children}</aside>,
}));
vi.mock("./guide-export-branding", () => ({ GuideExportBranding: () => null }));
const { StructuredGuideShell } = await import("./structured-guide");
describe("extra sheet public rendering", () => {
it("renders full merged bounds and vertical alignment in both the guide and export", () => {
let sequence = 0;
let sheet = createExtraSheet((prefix) => `${prefix}-${++sequence}`);
sheet = mergeExtraSheetRange(sheet, { start: { row: 1, column: 1 }, end: { row: 2, column: 3 } });
sheet = updateExtraSheetCells(sheet, [{ row: 1, column: 1 }], {
value: "<p><strong>Merged text</strong></p>", alignment: "center", verticalAlignment: "middle", backgroundColor: "#123456",
});
const guide = {
id: "guide", slug: "guide", coverUrl: "/cover.png", character: { splashImageUrl: null },
availableSections: [{ id: "extra", slug: "notes", title: "Notes" }],
extras: [{ id: "extra", slug: "notes", title: "Notes", rows: [{
id: "row", heading: "Details", body: "", percent: null, highlights: [], catalogItems: [],
imageUrl: null, backgroundColor: null, sheet,
}] }],
} as unknown as PublicGuide;
const container = document.createElement("div");
container.innerHTML = renderToStaticMarkup(<StructuredGuideShell guide={guide} section="notes" />);
const tables = container.querySelectorAll("table");
expect(tables).toHaveLength(2);
for (const table of tables) {
expect(table.querySelectorAll("col")).toHaveLength(3);
expect(table.querySelectorAll("tbody tr")).toHaveLength(2);
expect(table.querySelectorAll("td")).toHaveLength(1);
const cell = table.querySelector("td")!;
expect(cell.rowSpan).toBe(2);
expect(cell.colSpan).toBe(3);
expect(cell.style.verticalAlign).toBe("middle");
expect(cell.style.textAlign).toBe("center");
expect(cell.querySelector("strong")?.textContent).toBe("Merged text");
}
});
});
+25 -16
View File
@@ -57,6 +57,7 @@ import { normalizeExtraRichText } from "@/lib/rich-text";
import {
DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
extraSheetUsedRange,
extraSheetMergeMap,
} from "@/lib/guides/extra-sheet";
import { publicGuideSections } from "@/lib/guides/public-sections";
import { cn } from "@/lib/utils";
@@ -1231,6 +1232,7 @@ function ExtraSectionContent({
<div className={cn("relative z-10 grid gap-5 md:grid-cols-2", exporting && "grid-cols-2")}>
{extra.rows.map((row) => {
const displaySheet = row.sheet ? extraSheetUsedRange(row.sheet) : null;
const merges = displaySheet ? extraSheetMergeMap(displaySheet) : null;
return (
<Card
key={row.id}
@@ -1325,23 +1327,30 @@ function ExtraSectionContent({
</TableRow>
</TableHeader>
<TableBody>
{displaySheet.rows.map((sheetRow) => (
{displaySheet.rows.map((sheetRow, rowIndex) => (
<TableRow key={sheetRow.id}>
{displaySheet.columns.map((column) => (
<TableCell
key={column.id}
className="border-l first:border-l-0 whitespace-normal"
style={{
backgroundColor: sheetRow.backgrounds?.[column.id],
textAlign: sheetRow.alignments?.[column.id] ?? "left",
}}
>
<RichTextContent
html={normalizeExtraRichText(sheetRow.cells[column.id] ?? "")}
className="extra-sheet-text extra-sheet-text--public"
/>
</TableCell>
))}
{displaySheet.columns.map((column, columnIndex) => {
const merge = merges?.get(`${rowIndex}:${columnIndex}`);
if (merge && (merge.start.row !== rowIndex || merge.start.column !== columnIndex)) return null;
return (
<TableCell
rowSpan={merge ? merge.end.row - rowIndex + 1 : undefined}
colSpan={merge ? merge.end.column - columnIndex + 1 : undefined}
key={column.id}
className="border-l first:border-l-0 whitespace-normal"
style={{
backgroundColor: sheetRow.backgrounds?.[column.id],
textAlign: sheetRow.alignments?.[column.id] ?? "left",
verticalAlign: sheetRow.verticalAlignments?.[column.id] ?? "top",
}}
>
<RichTextContent
html={normalizeExtraRichText(sheetRow.cells[column.id] ?? "")}
className="extra-sheet-text extra-sheet-text--public"
/>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>