дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(database): keep line breaks when editing multi-line cells (#1311)
The inline cell editor is a single-line input that drops a value's line breaks on the first change, and it committed on every Enter; the Record view's fields had the same problem. Multi-line values now open in the multi-line editor, Shift+Enter opens it for any cell (from the grid or the inline editor) and Cmd/Ctrl+Enter applies it; CRLF values keep CRLF; the grid marks line breaks with a muted ↵; the Record view uses a text area for multi-line values.
Цей коміт міститься в:
1 джерело
7cdf690b4b
коміт
834b8d4e3b
5 files changed
+141
-21
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
type LocalSort,
|
||||
} from "./resultView";
|
||||
import { openDialog } from "./store";
|
||||
import { hasLineBreak } from "./cellPreview";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -346,17 +347,30 @@ function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean;
|
||||
{c.name}
|
||||
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
/>
|
||||
{hasLineBreak(props.cell ?? "") ? (
|
||||
// A single-line field would drop the line breaks on the first edit.
|
||||
<textarea
|
||||
className={styles.recordText}
|
||||
rows={Math.min(8, (props.cell ?? "").split(/\r\n|\r|\n/).length)}
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
mono
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.lineBreak {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.cellEditor {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
@@ -959,10 +962,23 @@
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.valueText:focus {
|
||||
.valueText:focus,
|
||||
.recordText:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
.recordText {
|
||||
resize: vertical;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg-strong);
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-size: var(--fs-code);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.aggregates {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -130,3 +130,48 @@ describe("large database cells", () => {
|
||||
expect(screen.getByRole("gridcell").textContent).toBe("<null>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-line database cells", () => {
|
||||
const columns = [{ name: "note", type: "text" }];
|
||||
const multi = "first line\nsecond line\r\nthird";
|
||||
|
||||
it("shows line breaks as markers on one line", () => {
|
||||
render(<Grid columns={columns} rowCount={1} value={() => multi} />);
|
||||
expect(screen.getByRole("gridcell").textContent).toBe("first line↵second line↵third");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["LF", "a\nb", "a\nb\nc"],
|
||||
["CRLF, kept although the textarea uses LF", "a\r\nb", "a\r\nb\r\nc"],
|
||||
])("edits a multi-line value (%s) in the multi-line editor and keeps its line breaks", (_, value, saved) => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={columns} rowCount={1} value={() => value} editable onEdit={onEdit} />);
|
||||
fireEvent.doubleClick(screen.getByRole("gridcell"));
|
||||
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(editor.tagName).toBe("TEXTAREA");
|
||||
fireEvent.change(editor, { target: { value: "a\nb\nc" } });
|
||||
fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, saved);
|
||||
});
|
||||
|
||||
it("opens the multi-line editor with Shift+Enter, from the grid and from the inline editor", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={columns} rowCount={1} value={() => "one"} editable onEdit={onEdit} />);
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell"), { button: 0 });
|
||||
fireEvent.mouseUp(window);
|
||||
fireEvent.keyDown(screen.getByRole("grid"), { key: "Enter", shiftKey: true });
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).tagName).toBe("TEXTAREA");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.doubleClick(screen.getByRole("gridcell"));
|
||||
const inline = screen.getByRole("textbox");
|
||||
expect(inline.tagName).toBe("INPUT");
|
||||
fireEvent.change(inline, { target: { value: "one two" } });
|
||||
fireEvent.keyDown(inline, { key: "Enter", shiftKey: true });
|
||||
fireEvent.blur(inline);
|
||||
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(editor.tagName).toBe("TEXTAREA");
|
||||
expect(editor.value).toBe("one two");
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { Dialog, Icon, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { parseDelimited, toCsv, toTsv, type Cell } from "./model";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview, hasLineBreak } from "./cellPreview";
|
||||
|
||||
export interface GridColumn {
|
||||
name: string;
|
||||
@@ -83,6 +83,22 @@ function initialWidth(c: GridColumn, sample: Cell[], decorations: number): numbe
|
||||
/** SQLite renders whole REALs as `3108.0`; IDEA shows `3108`. */
|
||||
const displayed = (v: string, numeric: boolean) => (numeric && /^-?\d+\.0$/.test(v) ? v.slice(0, -2) : v);
|
||||
|
||||
/** A cell's text on one line: each line break shows as a muted `↵`. */
|
||||
function cellText(value: string) {
|
||||
const parts = cellPreview(value).split(/\r\n|\r|\n/);
|
||||
if (parts.length === 1) return parts[0];
|
||||
return parts.flatMap((part, i) =>
|
||||
i === 0
|
||||
? [part]
|
||||
: [
|
||||
<span key={i} className={styles.lineBreak}>
|
||||
↵
|
||||
</span>,
|
||||
part,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function norm(s: Sel) {
|
||||
return {
|
||||
top: Math.min(s.r0, s.r1),
|
||||
@@ -105,6 +121,11 @@ export function Grid(props: GridProps) {
|
||||
return () => window.removeEventListener("mouseup", stop);
|
||||
}, []);
|
||||
const [editing, setEditing] = useState<{ row: number; col: number; text: string; dialog: boolean } | null>(null);
|
||||
/** The inline editor is being swapped for the multi-line one: its blur must not commit. */
|
||||
const maximizing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!editing?.dialog) maximizing.current = false;
|
||||
}, [editing?.dialog]);
|
||||
const columnKey = columns.map((c) => c.name).join("\u0000");
|
||||
const [widths, setWidths] = useState<number[]>([]);
|
||||
const [numeric, setNumeric] = useState<boolean[]>([]);
|
||||
@@ -163,17 +184,23 @@ export function Grid(props: GridProps) {
|
||||
const commit = () => {
|
||||
if (!editing) return;
|
||||
const prev = value(editing.row, editing.col);
|
||||
if (editing.text !== (prev ?? "") || prev === null) {
|
||||
if (!(prev === null && editing.text === "")) onEdit?.(editing.row, editing.col, editing.text);
|
||||
// A textarea turns CRLF into LF; a value that used CRLF throughout keeps it.
|
||||
const text =
|
||||
prev && prev.includes("\r\n") && !/(^|[^\r])\n/.test(prev)
|
||||
? editing.text.replace(/\r?\n/g, "\r\n")
|
||||
: editing.text;
|
||||
if (text !== (prev ?? "") || prev === null) {
|
||||
if (!(prev === null && text === "")) onEdit?.(editing.row, editing.col, text);
|
||||
}
|
||||
setEditing(null);
|
||||
scrollRef.current?.focus();
|
||||
};
|
||||
|
||||
const startEdit = (row: number, col: number) => {
|
||||
/** `maximized` (⇧Enter): the multi-line editor, which multi-line and long values always use. */
|
||||
const startEdit = (row: number, col: number, maximized = false) => {
|
||||
if (!editable || props.rowState?.(row) === "deleted") return;
|
||||
const text = value(row, col) ?? "";
|
||||
setEditing({ row, col, text, dialog: text.length > CELL_PREVIEW_LIMIT });
|
||||
setEditing({ row, col, text, dialog: maximized || text.length > CELL_PREVIEW_LIMIT || hasLineBreak(text) });
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -192,7 +219,7 @@ export function Grid(props: GridProps) {
|
||||
if (!sel) return;
|
||||
if ((e.key === "Enter" || e.key === "F2") && editable) {
|
||||
e.preventDefault();
|
||||
startEdit(sel.r1, sel.c1);
|
||||
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
|
||||
return;
|
||||
}
|
||||
const moves: Record<string, [number, number]> = {
|
||||
@@ -325,7 +352,13 @@ export function Grid(props: GridProps) {
|
||||
wrap="soft"
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
@@ -404,9 +437,18 @@ export function Grid(props: GridProps) {
|
||||
aria-label={`Edit ${columns[c]!.name}`}
|
||||
value={editing.text}
|
||||
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
if (!maximizing.current) commit();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
// A line break can't be typed in a single-line field: continue in the multi-line editor.
|
||||
e.preventDefault();
|
||||
maximizing.current = true;
|
||||
setEditing({ ...editing, dialog: true });
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setEditing(null);
|
||||
@@ -437,7 +479,7 @@ export function Grid(props: GridProps) {
|
||||
{val === null ? (
|
||||
<span className={styles.null}><null></span>
|
||||
) : (
|
||||
cellPreview(displayed(val, !!numeric[c]))
|
||||
cellText(displayed(val, !!numeric[c]))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,3 +8,6 @@ export function cellPreview(value: string): string {
|
||||
if (last >= 0xd800 && last <= 0xdbff) end--;
|
||||
return value.slice(0, end) + "\u2026";
|
||||
}
|
||||
|
||||
/** Whether the value spans several lines (a single-line field would drop its line breaks). */
|
||||
export const hasLineBreak = (value: string) => /[\r\n]/.test(value);
|
||||
Reference in new issue
Заблокувати користувача