CSV view (#73)

This commit is contained in:
Red J Adaya 2024-06-25 01:17:35 +08:00 committed by GitHub
parent 4714b88be7
commit edb8eb25b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 367 additions and 2 deletions

View File

@ -0,0 +1,99 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.csv-view {
opacity: 0; /* Start with an opacity of 0, meaning it's invisible */
.ellipsis() {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
overflow-x: auto;
overflow-y: hidden;
.cursor-pointer {
cursor: pointer;
}
.select-none {
user-select: none;
}
table.probe {
position: absolute;
visibility: hidden;
}
table {
border-collapse: collapse;
overflow-x: auto;
border: 1px solid var(--scrollbar-thumb-hover-color);
thead {
position: relative;
display: block;
width: 100%;
overflow-y: scroll;
tr {
border-bottom: 1px solid var(--scrollbar-thumb-hover-color);
th {
color: var(--app-text-color);
border-right: 1px solid var(--scrollbar-thumb-hover-color);
border-bottom: none;
padding: 2px 10px;
flex-basis: 100%;
flex-grow: 2;
display: block;
text-align: left;
position: relative;
.inner {
text-align: left;
padding-right: 15px;
position: relative;
.ellipsis();
.sort-icon {
position: absolute;
right: 0px;
top: 2px;
width: 9px;
}
}
}
}
}
tbody {
display: block;
position: relative;
overflow-y: scroll;
overscroll-behavior: contain;
}
tr {
width: 100%;
display: flex;
td {
border-right: 1px solid var(--scrollbar-thumb-hover-color);
border-left: 1px solid var(--scrollbar-thumb-hover-color);
padding: 3px 10px;
flex-basis: 100%;
flex-grow: 2;
display: block;
text-align: left;
.ellipsis();
}
}
}
}
.csv-view.show {
opacity: 1;
}

View File

@ -0,0 +1,205 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { useTableNav } from "@table-nav/react";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { clsx } from "clsx";
import Papa from "papaparse";
import { useEffect, useMemo, useRef, useState } from "react";
import "./csvview.less";
const MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB in bytes
type CSVRow = {
[key: string]: string | number;
};
interface CSVViewProps {
parentRef: React.MutableRefObject<HTMLDivElement>;
content: string;
filename: string;
readonly: boolean;
}
interface State {
content: string | null;
showReadonly: boolean;
tbodyHeight: number;
}
const columnHelper = createColumnHelper<any>();
const CSVView = ({ parentRef, filename, content }: CSVViewProps) => {
const csvCacheRef = useRef(new Map<string, string>());
const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);
const headerRef = useRef<HTMLTableRowElement | null>(null);
const probeRef = useRef<HTMLTableRowElement | null>(null);
const tbodyRef = useRef<HTMLTableSectionElement | null>(null);
const [state, setState] = useState<State>({
content,
showReadonly: true,
tbodyHeight: 0,
});
const [tableLoaded, setTableLoaded] = useState(false);
const [maxHeight, setMaxHeight] = useState(0);
const { listeners } = useTableNav();
const cacheKey = `${filename}`;
csvCacheRef.current.set(cacheKey, content);
// Parse the CSV data
const parsedData = useMemo<CSVRow[]>(() => {
if (!state.content) return [];
// Trim the content and then check for headers based on the first row's content.
const trimmedContent = state.content.trim();
const firstRow = trimmedContent.split("\n")[0];
// This checks if the first row starts with a letter or a quote
const hasHeaders = !!firstRow.match(/^[a-zA-Z"]/);
const results = Papa.parse(trimmedContent, { header: hasHeaders });
// Check for non-header CSVs
if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) {
const dataArray = results.data as string[][]; // Asserting the type
const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`);
results.data = dataArray.map((row) => {
const newRow: CSVRow = {};
row.forEach((value, index) => {
newRow[headers[index]] = value;
});
return newRow;
});
}
return results.data.map((row) => {
return Object.fromEntries(
Object.entries(row as CSVRow).map(([key, value]) => {
if (typeof value === "string") {
const numberValue = parseFloat(value);
if (!isNaN(numberValue) && String(numberValue) === value) {
return [key, numberValue];
}
}
return [key, value];
})
) as CSVRow;
});
}, [state.content]);
// Column Definitions
const columns = useMemo(() => {
if (parsedData.length === 0) {
return [];
}
const headers = Object.keys(parsedData[0]);
return headers.map((header) =>
columnHelper.accessor(header, {
header: () => header,
cell: (info) => info.renderValue(),
})
);
}, [parsedData]);
useEffect(() => {
if (probeRef.current && headerRef.current && parsedData.length && parentRef.current) {
const rowHeight = probeRef.current.offsetHeight;
const fullTBodyHeight = rowHeight * parsedData.length;
const headerHeight = headerRef.current.offsetHeight;
const maxHeight = parentRef.current.getBoundingClientRect().height - 32; // 32 is the border plus the breadcrumb height
const maxHeightLessHeader = maxHeight - headerHeight;
const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight);
setState((prevState) => ({ ...prevState, tbodyHeight }));
}
}, [maxHeight, parsedData]);
// Makes sure rows are rendered before setting the renderer as loaded
useEffect(() => {
let tid: NodeJS.Timeout;
if (rowRef.current.length === parsedData.length) {
tid = setTimeout(() => {
setTableLoaded(true);
}, 50); // Delay a bit to make sure the rows are rendered
}
return () => clearTimeout(tid);
}, [rowRef, parsedData]);
const table = useReactTable({
manualPagination: true,
data: parsedData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<div className={clsx("csv-view", { show: tableLoaded })} style={{ height: "auto" }}>
<table className="probe">
<tbody>
<tr ref={probeRef}>
<td>dummy data</td>
</tr>
</tbody>
</table>
<table {...listeners}>
<thead>
{table.getHeaderGroups().map((headerGroup, index) => (
<tr key={headerGroup.id} ref={headerRef} id={headerGroup.id} tabIndex={index}>
{headerGroup.headers.map((header, index) => (
<th
key={header.id}
colSpan={header.colSpan}
id={header.id}
tabIndex={index}
style={{ width: header.getSize() }}
>
{header.isPlaceholder ? null : (
<div
{...{
className: header.column.getCanSort()
? "inner cursor-pointer select-none"
: "",
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" ? (
<i className="sort-icon fa-sharp fa-solid fa-sort-up"></i>
) : header.column.getIsSorted() === "desc" ? (
<i className="sort-icon fa-sharp fa-solid fa-sort-down"></i>
) : null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody style={{ height: `${state.tbodyHeight}px` }} ref={tbodyRef}>
{table.getRowModel().rows.map((row, index) => (
<tr key={row.id} ref={(el) => (rowRef.current[index] = el)} id={row.id} tabIndex={index}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} id={cell.id} tabIndex={index}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
export { CSVView };

View File

@ -8,8 +8,10 @@ import * as WOS from "@/store/wos";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
import { useRef } from "react";
import { CenteredDiv } from "../element/quickelems";
import { CodeEdit } from "./codeedit";
import { CSVView } from "./csvview";
import { DirectoryPreview } from "./directorypreview";
import "./view.less";
@ -121,6 +123,21 @@ function CodeEditPreview({
return <CodeEdit readonly={true} text={fileContent} filename={filename} />;
}
function CSVViewPreview({
parentRef,
contentAtom,
filename,
readonly,
}: {
parentRef: React.MutableRefObject<HTMLDivElement>;
contentAtom: jotai.Atom<Promise<string>>;
filename: string;
readonly: boolean;
}) {
const fileContent = jotai.useAtomValue(contentAtom);
return <CSVView parentRef={parentRef} readonly={true} content={fileContent} filename={filename} />;
}
function iconForFile(mimeType: string, fileName: string): string {
if (mimeType == "application/pdf") {
return "file-pdf";
@ -149,6 +166,7 @@ function iconForFile(mimeType: string, fileName: string): string {
}
function PreviewView({ blockId }: { blockId: string }) {
const ref = useRef<HTMLDivElement>(null);
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
const fileNameAtom: jotai.WritableAtom<string, [string], void> = useBlockCache(blockId, "preview:filename", () =>
jotai.atom<string, [string], void>(
@ -220,6 +238,10 @@ function PreviewView({ blockId }: { blockId: string }) {
specializedView = <CenteredDiv>File Too Large to Preview</CenteredDiv>;
} else if (mimeType === "text/markdown") {
specializedView = <MarkdownPreview contentAtom={fileContentAtom} />;
} else if (mimeType === "text/csv") {
specializedView = (
<CSVViewPreview parentRef={ref} contentAtom={fileContentAtom} filename={fileName} readonly={true} />
);
} else if (
mimeType.startsWith("text/") ||
(mimeType.startsWith("application/") &&
@ -243,7 +265,7 @@ function PreviewView({ blockId }: { blockId: string }) {
}, 10);
return (
<div className="full-preview">
<div ref={ref} className="full-preview">
<DirNav cwdAtom={fileNameAtom} />
{specializedView}
</div>

View File

@ -98,7 +98,6 @@ function WorkspaceElem() {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabId = windowData?.activetabid;
const ws = jotai.useAtomValue(atoms.workspace);
console.log("ws", ws);
return (
<div className="workspace">
<TabBar workspace={ws} />

View File

@ -25,6 +25,7 @@
"@storybook/react-vite": "^8.1.9",
"@storybook/test": "^8.1.9",
"@types/node": "^20.12.12",
"@types/papaparse": "^5",
"@types/react": "^18.3.2",
"@types/throttle-debounce": "^5",
"@types/uuid": "^9.0.8",
@ -53,6 +54,8 @@
"@monaco-editor/react": "^4.6.0",
"@observablehq/plot": "^0.6.14",
"@react-hook/resize-observer": "^2.0.1",
"@table-nav/core": "^0.0.7",
"@table-nav/react": "^0.0.7",
"@tanstack/react-table": "^8.17.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
@ -66,6 +69,7 @@
"jotai": "^2.8.0",
"monaco-editor": "^0.49.0",
"overlayscrollbars": "^2.8.3",
"papaparse": "^5.4.1",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",

View File

@ -3889,6 +3889,22 @@ __metadata:
languageName: node
linkType: hard
"@table-nav/core@npm:^0.0.7":
version: 0.0.7
resolution: "@table-nav/core@npm:0.0.7"
checksum: 10c0/75955f8ed2c0beef56bfe9dcf9ee0f24d593a49eaa364edbf0d32023242cb39172855d390838c8e818dbed5aa3bf0891151d973bc203fd2f18b3e5072daf97aa
languageName: node
linkType: hard
"@table-nav/react@npm:^0.0.7":
version: 0.0.7
resolution: "@table-nav/react@npm:0.0.7"
peerDependencies:
"@table-nav/core": ^0.0.7
checksum: 10c0/a03baf6fb38bd92260823f15f8309f31fdffec72d2ef43e4d8c808c0aa2081e9e4147f675e961270fa676bcc8361388c08d9dfbaad14458d5cd2b22e76de39f0
languageName: node
linkType: hard
"@tanstack/react-table@npm:^8.17.3":
version: 8.17.3
resolution: "@tanstack/react-table@npm:8.17.3"
@ -4323,6 +4339,15 @@ __metadata:
languageName: node
linkType: hard
"@types/papaparse@npm:^5":
version: 5.3.14
resolution: "@types/papaparse@npm:5.3.14"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/feb4d215903b67442feaa9836a6a5771e78dc6a9da24781e399c6f891622fa82245cd783ab2613c5be43e4a2d6a94da52325538e4485af258166864576ecd0d8
languageName: node
linkType: hard
"@types/pretty-hrtime@npm:^1.0.0":
version: 1.0.3
resolution: "@types/pretty-hrtime@npm:1.0.3"
@ -10413,6 +10438,13 @@ __metadata:
languageName: node
linkType: hard
"papaparse@npm:^5.4.1":
version: 5.4.1
resolution: "papaparse@npm:5.4.1"
checksum: 10c0/201f37c4813453fed5bfb4c01816696b099d2db9ff1e8fb610acc4771fdde91d2a22b6094721edb0fedb21ca3c46f04263f68be4beb3e35b8c72278f0cedc7b7
languageName: node
linkType: hard
"parent-module@npm:^1.0.0":
version: 1.0.1
resolution: "parent-module@npm:1.0.1"
@ -12288,8 +12320,11 @@ __metadata:
"@storybook/react": "npm:^8.1.9"
"@storybook/react-vite": "npm:^8.1.9"
"@storybook/test": "npm:^8.1.9"
"@table-nav/core": "npm:^0.0.7"
"@table-nav/react": "npm:^0.0.7"
"@tanstack/react-table": "npm:^8.17.3"
"@types/node": "npm:^20.12.12"
"@types/papaparse": "npm:^5"
"@types/react": "npm:^18.3.2"
"@types/throttle-debounce": "npm:^5"
"@types/uuid": "npm:^9.0.8"
@ -12311,6 +12346,7 @@ __metadata:
less: "npm:^4.2.0"
monaco-editor: "npm:^0.49.0"
overlayscrollbars: "npm:^2.8.3"
papaparse: "npm:^5.4.1"
prettier: "npm:^3.2.5"
prettier-plugin-jsdoc: "npm:^1.3.0"
prettier-plugin-organize-imports: "npm:^3.2.4"