feat(extra-sheet) : add inline cell editing

This commit is contained in:
2026-09-25 16:08:40 +07:00 Unverified
parent 6127682d5d
commit 37b7cb39f6
6 changed files with 119 additions and 140 deletions
+8
View File
@@ -164,6 +164,14 @@
color: #000 !important; color: #000 !important;
} }
.extra-sheet-grid .Spreadsheet__data-editor textarea {
box-sizing: border-box;
min-height: 100%;
min-width: 100%;
resize: both;
white-space: pre-wrap;
}
@media (pointer: coarse) { @media (pointer: coarse) {
[data-slot="button"], [data-slot="button"],
[data-slot="toggle-group-item"], [data-slot="toggle-group-item"],
+1
View File
@@ -27,6 +27,7 @@
"discord.js": "^14.27.0", "discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"entities": "7.0.1",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"lucide-react": "^1.35.0", "lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0", "modern-screenshot": "^4.7.0",
+82 -140
View File
@@ -4,7 +4,6 @@ import {
GripHorizontalIcon, GripHorizontalIcon,
GripVerticalIcon, GripVerticalIcon,
PlusIcon, PlusIcon,
PencilIcon,
PaletteIcon, PaletteIcon,
Redo2Icon, Redo2Icon,
Trash2Icon, Trash2Icon,
@@ -47,7 +46,6 @@ import { CSS } from "@dnd-kit/utilities";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { RichTextContent } from "@/components/rich-text-content"; import { RichTextContent } from "@/components/rich-text-content";
import { RichTextEditor } from "@/components/rich-text-editor";
import { SimpleTooltip } from "@/components/tooltip"; import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -69,16 +67,13 @@ import {
moveExtraSheetColumn, moveExtraSheetColumn,
moveExtraSheetRow, moveExtraSheetRow,
nextExtraSheetColumnLabel, nextExtraSheetColumnLabel,
styleExtraSheetCells,
updateExtraSheetCell,
updateExtraSheetCells, updateExtraSheetCells,
type ExtraSheet, type ExtraSheet,
type ExtraSheetMatrix, type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet"; } from "@/lib/guides/extra-sheet";
import { import {
extraRichTextToPlainText,
normalizeExtraRichText, normalizeExtraRichText,
richTextContentSignature,
type RichTextStylePatch,
} from "@/lib/rich-text"; } from "@/lib/rich-text";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -134,30 +129,57 @@ function LiteralDataViewer({
function InlineDataEditor({ function InlineDataEditor({
cell, cell,
onChange, onChange,
}: DataEditorProps<{ value: string }>) { exitEditMode,
const inputRef = useRef<HTMLInputElement>(null); initialText,
const text = useMemo(() => { onReady,
const document = new DOMParser().parseFromString( }: DataEditorProps<{ value: string }> & {
normalizeExtraRichText(String(cell?.value ?? "")), initialText?: string;
"text/html", onReady?: () => void;
); }) {
document.querySelectorAll("br").forEach((br) => br.replaceWith("\n")); const inputRef = useRef<HTMLTextAreaElement>(null);
document.querySelectorAll("p + p").forEach((p) => p.prepend("\n")); const originalText = useRef(
return document.body.textContent ?? ""; extraRichTextToPlainText(String(cell?.value ?? "")),
}, [cell?.value]); );
const [text, setText] = useState(
() => initialText ?? extraRichTextToPlainText(String(cell?.value ?? "")),
);
const committed = useRef(false);
const commit = useCallback(() => {
if (committed.current) return;
committed.current = true;
const nextValue = normalizeExtraRichText(text);
if (text !== originalText.current) {
onChange({ ...cell, value: nextValue });
}
}, [cell, onChange, text]);
useEffect(() => { useEffect(() => {
onReady?.();
const input = inputRef.current; const input = inputRef.current;
input?.focus(); input?.focus();
input?.setSelectionRange(input.value.length, input.value.length); input?.setSelectionRange(input.value.length, input.value.length);
}, []); }, [onReady]);
return ( return (
<div className="Spreadsheet__data-editor"> <div className="Spreadsheet__data-editor">
<input <textarea
ref={inputRef} ref={inputRef}
type="text"
aria-label="แก้ไขข้อความเซลล์" aria-label="แก้ไขข้อความเซลล์"
value={text} value={text}
onChange={(event) => onChange({ ...cell, value: event.target.value })} onChange={(event) => setText(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Escape") {
committed.current = true;
event.preventDefault();
event.stopPropagation();
exitEditMode();
return;
}
if (event.key === "Enter" && (event.shiftKey || event.altKey)) {
event.stopPropagation();
return;
}
if (event.key === "Enter" || event.key === "Tab") commit();
}}
/> />
</div> </div>
); );
@@ -285,15 +307,12 @@ export function ExtraSheetEditor({
}), }),
); );
const spreadsheetRef = useRef<SpreadsheetRef>(null); const spreadsheetRef = useRef<SpreadsheetRef>(null);
const initialEditTextRef = useRef<string | null>(null);
const [active, setActive] = useState<Point>({ row: 0, column: 0 }); const [active, setActive] = useState<Point>({ row: 0, column: 0 });
const [labelDialogOpen, setLabelDialogOpen] = useState(false); const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [columnLabel, setColumnLabel] = useState(""); const [columnLabel, setColumnLabel] = useState("");
const [removeOpen, setRemoveOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false);
const [cellDialogOpen, setCellDialogOpen] = useState(false);
const [cellDraft, setCellDraft] = useState("");
const [originalCellDraft, setOriginalCellDraft] = useState("");
const [stylePatch, setStylePatch] = useState<RichTextStylePatch>({});
const [selectedCount, setSelectedCount] = useState(1); const [selectedCount, setSelectedCount] = useState(1);
const [history, setHistory] = useState<SheetHistory>({ const [history, setHistory] = useState<SheetHistory>({
items: [value], items: [value],
@@ -477,27 +496,6 @@ export function ExtraSheetEditor({
} }
setLabelDialogOpen(false); setLabelDialogOpen(false);
}; };
const openCellEditorAt = useCallback(
(point: Point, content?: string) => {
setActive(point);
const column = value.columns[point.column];
const row = value.rows[point.row];
setCellDraft(
normalizeExtraRichText(
content ?? (column && row ? (row.cells[column.id] ?? "") : ""),
),
);
setOriginalCellDraft(
normalizeExtraRichText(
content ?? (column && row ? (row.cells[column.id] ?? "") : ""),
),
);
setStylePatch({});
setCellDialogOpen(true);
},
[value.columns, value.rows],
);
const openActiveCellEditor = () => openCellEditorAt(active);
const columnIndicator = useCallback( const columnIndicator = useCallback(
(props: ColumnIndicatorProps) => ( (props: ColumnIndicatorProps) => (
<EditableColumnIndicator <EditableColumnIndicator
@@ -517,6 +515,19 @@ export function ExtraSheetEditor({
), ),
[value.rows], [value.rows],
); );
const clearInitialEditText = useCallback(() => {
initialEditTextRef.current = null;
}, []);
const dataEditor = useCallback(
(props: DataEditorProps<{ value: string }>) => (
<InlineDataEditor
{...props}
initialText={initialEditTextRef.current ?? undefined}
onReady={clearInitialEditText}
/>
),
[clearInitialEditText],
);
return ( return (
<div <div
@@ -583,15 +594,6 @@ export function ExtraSheetEditor({
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
เพิ่มคอลัมน์ เพิ่มคอลัมน์
</Button> </Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={openActiveCellEditor}
>
<PencilIcon data-icon="inline-start" />
แก้ไข {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
</Button>
<label className="relative"> <label className="relative">
<Button <Button
type="button" type="button"
@@ -645,29 +647,7 @@ export function ExtraSheetEditor({
</Button> </Button>
</div> </div>
<div <div className="overflow-x-auto rounded-lg border">
className="overflow-x-auto rounded-lg border"
onClickCapture={(event) => {
if (
(event.target as HTMLElement).closest(
".Spreadsheet__active-cell--view",
)
) {
event.stopPropagation();
}
}}
onDoubleClickCapture={(event) => {
if (
(event.target as HTMLElement).closest(
".Spreadsheet__cell, .Spreadsheet__active-cell",
)
) {
event.preventDefault();
event.stopPropagation();
openCellEditorAt(active);
}
}}
>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={sameAxisCollision} collisionDetection={sameAxisCollision}
@@ -682,7 +662,30 @@ export function ExtraSheetEditor({
ColumnIndicator={columnIndicator} ColumnIndicator={columnIndicator}
RowIndicator={rowIndicator} RowIndicator={rowIndicator}
DataViewer={LiteralDataViewer} DataViewer={LiteralDataViewer}
DataEditor={InlineDataEditor} DataEditor={dataEditor}
onKeyDown={(event) => {
if (
(event.target as HTMLElement).closest(
".Spreadsheet__data-editor",
)
) {
return;
}
const startsTyping =
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey;
if (event.key !== "F2" && !startsTyping) return;
event.preventDefault();
initialEditTextRef.current = startsTyping
? event.key
: null;
const root = event.currentTarget as HTMLElement;
root
.querySelector<HTMLElement>(".Spreadsheet__active-cell--view")
?.click();
}}
onActivate={(point) => { onActivate={(point) => {
setActive(point); setActive(point);
setSelectedCount(1); setSelectedCount(1);
@@ -705,67 +708,6 @@ export function ExtraSheetEditor({
</DndContext> </DndContext>
</div> </div>
<Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขเซลล์</DialogTitle>
<DialogDescription>
{selectedCount > 1
? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์`
: `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`}
</DialogDescription>
</DialogHeader>
<RichTextEditor
value={cellDraft}
onChange={setCellDraft}
showBubbleMenu={false}
formatWholeContent={selectedCount > 1}
onFormatChange={(patch) =>
setStylePatch((current) => ({ ...current, ...patch }))
}
/>
<DialogFooter showCloseButton>
<Button
type="button"
onClick={() => {
const contentUnchanged =
richTextContentSignature(cellDraft) ===
richTextContentSignature(originalCellDraft);
if (selectedCount > 1 && contentUnchanged) {
if (Object.keys(stylePatch).length > 0) {
commitSheet(
styleExtraSheetCells(
value,
selectedPointsRef.current,
stylePatch,
),
);
}
} else {
commitSheet(
selectedCount > 1
? updateExtraSheetCells(
value,
selectedPointsRef.current,
{ value: cellDraft },
)
: updateExtraSheetCell(
value,
active.row,
active.column,
cellDraft,
),
);
}
setCellDialogOpen(false);
}}
>
บันทึกเซลล์
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}> <Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
extraRichTextToPlainText,
applyRichTextStylePatch, applyRichTextStylePatch,
normalizeExtraRichText, normalizeExtraRichText,
richTextContentSignature, richTextContentSignature,
@@ -34,6 +35,14 @@ describe("rich text sanitization", () => {
}); });
describe("legacy Extra rich text conversion", () => { describe("legacy Extra rich text conversion", () => {
it("converts stored markup into safe multiline text for cell editing", () => {
expect(extraRichTextToPlainText(
"<p><strong>First</strong><br>line</p><p>Second &amp; safe<script>bad()</script></p>",
)).toBe("First\nline\nSecond & safe");
expect(normalizeExtraRichText(extraRichTextToPlainText("<p>A<br>B</p>")))
.toBe("<p>A<br>B</p>");
});
it("escapes plain text, preserves newlines, and converts phrase colors", () => { it("escapes plain text, preserves newlines, and converts phrase colors", () => {
expect(normalizeExtraRichText("<unsafe>\nMASSIVE damage", [ expect(normalizeExtraRichText("<unsafe>\nMASSIVE damage", [
{ text: "MASSIVE", color: "#ff0000" }, { text: "MASSIVE", color: "#ff0000" },
+18
View File
@@ -1,4 +1,5 @@
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { decodeHTML } from "entities";
import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights"; import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
@@ -112,6 +113,23 @@ export function normalizeExtraRichText(
return content ? `<p>${content}</p>` : ""; return content ? `<p>${content}</p>` : "";
} }
/** Converts stored Extra Sheet rich text into editable plain text. */
export function extraRichTextToPlainText(value: string): string {
const withLineBreaks = normalizeExtraRichText(value)
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(?:blockquote|li|ol|p|pre|ul)>/gi, "\n");
let text = "";
sanitizeHtml(withLineBreaks, {
allowedTags: [],
allowedAttributes: {},
textFilter: (chunk) => {
text += chunk;
return "";
},
});
return decodeHTML(text).replace(/\n$/, "");
}
const RICH_TEXT_MARK_TAGS = { const RICH_TEXT_MARK_TAGS = {
bold: "strong", bold: "strong",
italic: "em", italic: "em",
+1
View File
@@ -43,6 +43,7 @@
"discord.js": "^14.27.0", "discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"entities": "7.0.1",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"lucide-react": "^1.35.0", "lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0", "modern-screenshot": "^4.7.0",