feat : i dont even know what am i doing right now
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user