feat(extra-sheet) : add inline cell editing
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const originalText = useRef(
|
||||||
|
extraRichTextToPlainText(String(cell?.value ?? "")),
|
||||||
);
|
);
|
||||||
document.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
|
const [text, setText] = useState(
|
||||||
document.querySelectorAll("p + p").forEach((p) => p.prepend("\n"));
|
() => initialText ?? extraRichTextToPlainText(String(cell?.value ?? "")),
|
||||||
return document.body.textContent ?? "";
|
);
|
||||||
}, [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>
|
||||||
|
|||||||
@@ -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 & 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" },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user