diff --git a/app/globals.css b/app/globals.css index 28794c9..c48014b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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"], diff --git a/bun.lock b/bun.lock index c32e3af..c8f6b87 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx index 46c598f..a10b486 100644 --- a/components/admin/extra-sheet-editor.tsx +++ b/components/admin/extra-sheet-editor.tsx @@ -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(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(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 (
- 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(); + }} />
); @@ -285,15 +307,12 @@ export function ExtraSheetEditor({ }), ); const spreadsheetRef = useRef(null); + const initialEditTextRef = useRef(null); const [active, setActive] = useState({ 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({}); const [selectedCount, setSelectedCount] = useState(1); const [history, setHistory] = useState({ 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) => ( { + initialEditTextRef.current = null; + }, []); + const dataEditor = useCallback( + (props: DataEditorProps<{ value: string }>) => ( + + ), + [clearInitialEditText], + ); return (
เพิ่มคอลัมน์ -
-
{ - 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); - } - }} - > +
{ + 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(".Spreadsheet__active-cell--view") + ?.click(); + }} onActivate={(point) => { setActive(point); setSelectedCount(1); @@ -705,67 +708,6 @@ export function ExtraSheetEditor({
- - - - แก้ไขเซลล์ - - {selectedCount > 1 - ? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์` - : `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`} - - - 1} - onFormatChange={(patch) => - setStylePatch((current) => ({ ...current, ...patch })) - } - /> - - - - - - diff --git a/lib/rich-text.test.ts b/lib/rich-text.test.ts index 96ee608..7a1e5f1 100644 --- a/lib/rich-text.test.ts +++ b/lib/rich-text.test.ts @@ -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( + "

First
line

Second & safe

", + )).toBe("First\nline\nSecond & safe"); + expect(normalizeExtraRichText(extraRichTextToPlainText("

A
B

"))) + .toBe("

A
B

"); + }); + it("escapes plain text, preserves newlines, and converts phrase colors", () => { expect(normalizeExtraRichText("\nMASSIVE damage", [ { text: "MASSIVE", color: "#ff0000" }, diff --git a/lib/rich-text.ts b/lib/rich-text.ts index e30f9f2..da2cf9f 100644 --- a/lib/rich-text.ts +++ b/lib/rich-text.ts @@ -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 ? `

${content}

` : ""; } +/** Converts stored Extra Sheet rich text into editable plain text. */ +export function extraRichTextToPlainText(value: string): string { + const withLineBreaks = normalizeExtraRichText(value) + .replace(//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", diff --git a/package.json b/package.json index 1f0322d..5743217 100644 --- a/package.json +++ b/package.json @@ -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",