feat(extra-sheet) : add inline cell editing
This commit is contained in:
@@ -164,6 +164,14 @@
|
||||
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) {
|
||||
[data-slot="button"],
|
||||
[data-slot="toggle-group-item"],
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"discord.js": "^14.27.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"entities": "7.0.1",
|
||||
"ioredis": "^6.0.0",
|
||||
"lucide-react": "^1.35.0",
|
||||
"modern-screenshot": "^4.7.0",
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
GripHorizontalIcon,
|
||||
GripVerticalIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
PaletteIcon,
|
||||
Redo2Icon,
|
||||
Trash2Icon,
|
||||
@@ -47,7 +46,6 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||
import { RichTextContent } from "@/components/rich-text-content";
|
||||
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -69,16 +67,13 @@ import {
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
nextExtraSheetColumnLabel,
|
||||
styleExtraSheetCells,
|
||||
updateExtraSheetCell,
|
||||
updateExtraSheetCells,
|
||||
type ExtraSheet,
|
||||
type ExtraSheetMatrix,
|
||||
} from "@/lib/guides/extra-sheet";
|
||||
import {
|
||||
extraRichTextToPlainText,
|
||||
normalizeExtraRichText,
|
||||
richTextContentSignature,
|
||||
type RichTextStylePatch,
|
||||
} from "@/lib/rich-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -134,30 +129,57 @@ function LiteralDataViewer({
|
||||
function InlineDataEditor({
|
||||
cell,
|
||||
onChange,
|
||||
}: DataEditorProps<{ value: string }>) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const text = useMemo(() => {
|
||||
const document = new DOMParser().parseFromString(
|
||||
normalizeExtraRichText(String(cell?.value ?? "")),
|
||||
"text/html",
|
||||
);
|
||||
document.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
|
||||
document.querySelectorAll("p + p").forEach((p) => p.prepend("\n"));
|
||||
return document.body.textContent ?? "";
|
||||
}, [cell?.value]);
|
||||
exitEditMode,
|
||||
initialText,
|
||||
onReady,
|
||||
}: DataEditorProps<{ value: string }> & {
|
||||
initialText?: string;
|
||||
onReady?: () => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const originalText = useRef(
|
||||
extraRichTextToPlainText(String(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(() => {
|
||||
onReady?.();
|
||||
const input = inputRef.current;
|
||||
input?.focus();
|
||||
input?.setSelectionRange(input.value.length, input.value.length);
|
||||
}, []);
|
||||
}, [onReady]);
|
||||
return (
|
||||
<div className="Spreadsheet__data-editor">
|
||||
<input
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
aria-label="แก้ไขข้อความเซลล์"
|
||||
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>
|
||||
);
|
||||
@@ -285,15 +307,12 @@ export function ExtraSheetEditor({
|
||||
}),
|
||||
);
|
||||
const spreadsheetRef = useRef<SpreadsheetRef>(null);
|
||||
const initialEditTextRef = useRef<string | null>(null);
|
||||
const [active, setActive] = useState<Point>({ row: 0, column: 0 });
|
||||
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [columnLabel, setColumnLabel] = useState("");
|
||||
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 [history, setHistory] = useState<SheetHistory>({
|
||||
items: [value],
|
||||
@@ -477,27 +496,6 @@ export function ExtraSheetEditor({
|
||||
}
|
||||
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(
|
||||
(props: ColumnIndicatorProps) => (
|
||||
<EditableColumnIndicator
|
||||
@@ -517,6 +515,19 @@ export function ExtraSheetEditor({
|
||||
),
|
||||
[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 (
|
||||
<div
|
||||
@@ -583,15 +594,6 @@ export function ExtraSheetEditor({
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่มคอลัมน์
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openActiveCellEditor}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
แก้ไข {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
|
||||
</Button>
|
||||
<label className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -645,29 +647,7 @@ export function ExtraSheetEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={sameAxisCollision}
|
||||
@@ -682,7 +662,30 @@ export function ExtraSheetEditor({
|
||||
ColumnIndicator={columnIndicator}
|
||||
RowIndicator={rowIndicator}
|
||||
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) => {
|
||||
setActive(point);
|
||||
setSelectedCount(1);
|
||||
@@ -705,67 +708,6 @@ export function ExtraSheetEditor({
|
||||
</DndContext>
|
||||
</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}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extraRichTextToPlainText,
|
||||
applyRichTextStylePatch,
|
||||
normalizeExtraRichText,
|
||||
richTextContentSignature,
|
||||
@@ -34,6 +35,14 @@ describe("rich text sanitization", () => {
|
||||
});
|
||||
|
||||
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 & 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", () => {
|
||||
expect(normalizeExtraRichText("<unsafe>\nMASSIVE damage", [
|
||||
{ text: "MASSIVE", color: "#ff0000" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { decodeHTML } from "entities";
|
||||
|
||||
import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
|
||||
|
||||
@@ -112,6 +113,23 @@ export function normalizeExtraRichText(
|
||||
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 = {
|
||||
bold: "strong",
|
||||
italic: "em",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"discord.js": "^14.27.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"entities": "7.0.1",
|
||||
"ioredis": "^6.0.0",
|
||||
"lucide-react": "^1.35.0",
|
||||
"modern-screenshot": "^4.7.0",
|
||||
|
||||
Reference in New Issue
Block a user