diff --git a/src/homeScreen/HomeScreen.tsx b/src/homeScreen/HomeScreen.tsx index 7c04f30..fd2d1bb 100644 --- a/src/homeScreen/HomeScreen.tsx +++ b/src/homeScreen/HomeScreen.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import styles from './HomeScreen.module.css'; -import { init } from "./interactions/initialization"; +import { deinit, init } from "./interactions/initialization"; import ToastPane from "@/components/toasts/ToastPane"; import SheetPane from "./SheetPane"; import ImportSheetDialog from "./dialogs/ImportSheetDialog"; @@ -24,6 +24,7 @@ import KeepPartialDataDialog from "./dialogs/KeepPartialDataDialog"; import ResumeJobDialog from "./dialogs/ResumeJobDialog"; import ExportOptionsDialog from "./dialogs/ExportOptionsDialog"; import ImportOptionsDialog from "./dialogs/ImportOptionsDialog"; +import ConfirmSheetPasteDialog from "./dialogs/ConfirmSheetPasteDialog"; function HomeScreen() { const [sheet, setSheet] = useState(null); @@ -34,8 +35,9 @@ function HomeScreen() { const [promptTemplate, setPromptTemplate] = useState(''); useEffect(() => { - init().then(() => { }); - }); + init(setAvailableSheets, setModalDialog).then(() => { }); + return deinit; + }, []); const promptPaneContent = sheet ? setModalDialog(null)} /> + onSelectSheet(pastedSheet, setAvailableSheets, setSheet, setModalDialog)} + onCancel={() => setModalDialog(null)} + /> + ); diff --git a/src/homeScreen/SheetView.tsx b/src/homeScreen/SheetView.tsx index 68e62b5..60ea180 100644 --- a/src/homeScreen/SheetView.tsx +++ b/src/homeScreen/SheetView.tsx @@ -1,11 +1,12 @@ -import { useState, useEffect } from "react"; +import { useMemo } from "react"; import styles from './SheetView.module.css'; import HoneSheet from "@/sheets/types/HoneSheet"; import HoneColumn from "@/sheets/types/HoneColumn"; -import { getSheetRows } from "@/sheets/sheetUtil"; +import { getSheetRows, HTML_NBSP } from "@/sheets/sheetUtil"; import GeneratedText from "@/components/generatedText/GeneratedText"; import Rowset from "@/sheets/types/Rowset"; +import { plural } from "@/common/englishGrammarUtil"; type Props = { sheet: HoneSheet, @@ -20,9 +21,16 @@ function _tableHeaderContent(columns:HoneColumn[]) { return #{columns.map((column, i) => {column.name})}; } -function _tableBodyContent(rows:Rowset, generatingColumnI:number, selectedRowNo?:number, onRowSelect?:(rowNo:number)=>void) { +function _paddedRowContent(rowI:number, columnCount:number) { + const cells = Array(columnCount).fill(null).map((_, i) => ); + return ({HTML_NBSP}{cells}); +} + +function _tableBodyContent(rows:Rowset, sheetRowCount:number, generatingColumnI:number, selectedRowNo?:number, onRowSelect?:(rowNo:number)=>void) { return rows.map( (row:any, rowI:number) => { + if (rowI >= sheetRowCount) return _paddedRowContent(rowI, row.length); + const isSelected = (selectedRowNo === rowI+1); const rowStyle = isSelected ? styles.selectedRow : ''; const cells = row.map((cell:any, columnI:number) => { @@ -38,29 +46,21 @@ function _tableBodyContent(rows:Rowset, generatingColumnI:number, selectedRowNo? } function SheetView({sheet, maxRows, padToMax, generatingColumnName, selectedRowNo, onRowSelect}:Props) { - const [rows, setRows] = useState(null); - const [generatingColumnI, setGeneratingColumnI] = useState(-1); - - useEffect(() => { - let nextRows = getSheetRows(sheet, 0, maxRows, padToMax); - setRows(nextRows); - }, [sheet, maxRows]); - - useEffect(() => { - if (!sheet) return; - if (!generatingColumnName) { setGeneratingColumnI(-1); return; } - const nextI = sheet.columns.findIndex((column) => column.name === generatingColumnName); - setGeneratingColumnI(nextI); + const rows = useMemo(() => getSheetRows(sheet, 0, maxRows, padToMax), [sheet, maxRows, padToMax]); + const generatingColumnI = useMemo(() => { + if (!generatingColumnName) return -1; + return sheet.columns.findIndex((column) => column.name === generatingColumnName); }, [sheet, generatingColumnName]); if (!rows) return
Loading...
; + const sheetRowCount = sheet.rows.length; return (
{_tableHeaderContent(sheet.columns)} - {_tableBodyContent(rows, generatingColumnI, selectedRowNo, onRowSelect)} - + {_tableBodyContent(rows, sheetRowCount, generatingColumnI, selectedRowNo, onRowSelect)} +
{sheet.rows.length} rows
{sheetRowCount} {plural('row', sheetRowCount)}
); diff --git a/src/homeScreen/dialogs/ConfirmSheetPasteDialog.tsx b/src/homeScreen/dialogs/ConfirmSheetPasteDialog.tsx new file mode 100644 index 0000000..b2fb8f5 --- /dev/null +++ b/src/homeScreen/dialogs/ConfirmSheetPasteDialog.tsx @@ -0,0 +1,42 @@ +import { useState, useEffect, useMemo } from 'react'; + +import ModalDialog from '@/components/modalDialogs/ModalDialog'; +import DialogFooter from '@/components/modalDialogs/DialogFooter'; +import DialogButton from '@/components/modalDialogs/DialogButton'; +import SheetView from '../SheetView'; +import HoneSheet from '@/sheets/types/HoneSheet'; +import { doesSheetHaveWritableColumns } from '@/sheets/sheetUtil'; + +type Props = { + pastedSheet:HoneSheet|null, + existingSheet:HoneSheet|null, + isOpen:boolean, + onConfirm(sheet:HoneSheet):void, + onCancel():void +} + +function _getUiTextAffectedByExistingSheet(existingSheet:HoneSheet|null):{confirmButtonText:string, description:string} { + if (!existingSheet) return {confirmButtonText:'Import', description:'Import this sheet into Hone?'}; + let description = `Replace "${existingSheet.name}" sheet with this new pasted sheet?`; + if (doesSheetHaveWritableColumns(existingSheet)) description += ' You will discard any added columns.'; + return {confirmButtonText:'Replace', description}; +} + +function ConfirmSheetPasteDialog({pastedSheet, existingSheet, isOpen, onConfirm, onCancel}:Props) { + if (!isOpen || !pastedSheet) return null; + + const {confirmButtonText, description} = _getUiTextAffectedByExistingSheet(existingSheet); + + return ( + +

{description}

+ + + + { if (pastedSheet) onConfirm(pastedSheet)}} isPrimary/> + +
+ ); +} + +export default ConfirmSheetPasteDialog; \ No newline at end of file diff --git a/src/homeScreen/interactions/import.ts b/src/homeScreen/interactions/import.ts index 0c03bd7..fd721e4 100644 --- a/src/homeScreen/interactions/import.ts +++ b/src/homeScreen/interactions/import.ts @@ -5,8 +5,9 @@ import ImportSheetDialog from '@/homeScreen/dialogs/ImportSheetDialog'; import HoneSheet from '@/sheets/types/HoneSheet'; import ImportOptions from '@/homeScreen/types/ImportOptions'; import ImportType from '@/homeScreen/types/ImportType'; -import { importSheetFromClipboard, importSheetFromCsvFile, importSheetsFromXlsBytes, importSheetsFromXlsFile, SheetErrorType } from '@/sheets/sheetUtil'; +import { importSheetFromClipboard, importSheetFromClipboardData, importSheetFromCsvFile, importSheetsFromXlsBytes, importSheetsFromXlsFile, SheetErrorType } from '@/sheets/sheetUtil'; import { CvsImportErrorType, MAX_FIELD_COUNT } from '@/csv/csvImportUtil'; +import ConfirmSheetPasteDialog from "../dialogs/ConfirmSheetPasteDialog"; async function _selectExcelFileHandle():Promise { const openFileOptions = { @@ -84,6 +85,35 @@ async function _importFromClipboard(importOptions:ImportOptions, setSheet:Functi } } +export async function importFromPasteEvent(event:ClipboardEvent, setAvailableSheets:Function, setModalDialog:Function) { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + try { + const honeSheet = await importSheetFromClipboardData(clipboardData, 'Pasted'); + setAvailableSheets([honeSheet]); + setModalDialog(ConfirmSheetPasteDialog.name); + } catch(e:any) { + setModalDialog(null); + switch(e.name) { + // Most errors will be quietly ignored because the user might have accidentally tried to paste something that wasn't a table. + // They can use the "import" feature to express their intent more clearly and get more feedback on import problems. + case SheetErrorType.CLIPBOARD_NO_ROWS: case CvsImportErrorType.NO_DATA: + case SheetErrorType.UNEXPECTED_CLIPBOARD_ERROR: + case CvsImportErrorType.FIELD_COUNT_MISMATCH: case CvsImportErrorType.UNSTRUCTURED_DATA: + return; + + case CvsImportErrorType.TOO_MANY_FIELDS: + errorToast(`The pasted data had too many columns. (Max supported is ${MAX_FIELD_COUNT}). Maybe try copying a smaller set of columns?`); + return; + + default: + console.error(e); // Debug error probably. + return; + } + } +} + async function _importFromCsv(importOptions:ImportOptions, setSheet:Function, setModalDialog:Function) { try { const fileHandle = await _selectCsvFileHandle(); diff --git a/src/homeScreen/interactions/initialization.ts b/src/homeScreen/interactions/initialization.ts index fa9044e..0baf81b 100644 --- a/src/homeScreen/interactions/initialization.ts +++ b/src/homeScreen/interactions/initialization.ts @@ -1,6 +1,36 @@ import { setSystemMessage } from "@/llm/llmUtil"; import { SYSTEM_MESSAGE } from "./prompt"; +import { importFromPasteEvent } from "./import"; -export async function init() { +type PasteHandlerFunction = (event:ClipboardEvent) => void; + +let pasteHandler:PasteHandlerFunction|null = null; + +function _handlePaste(clipboardEvent:ClipboardEvent, setAvailableSheets:Function, setModalDialog:Function) { + const target = clipboardEvent.target as HTMLElement|null; + + if (target) { + // If an editable DOM element is focused, I just want the browser's default handling of the paste. + const targetName = target.tagName; + if (targetName === 'INPUT' || targetName === 'TEXTAREA' || target.isContentEditable) return; + } + + // Try to import a sheet from the clipboard. + clipboardEvent.preventDefault(); + importFromPasteEvent(clipboardEvent, setAvailableSheets, setModalDialog); +} + +export async function init(setAvailableSheets:Function, setModalDialog:Function) { setSystemMessage(SYSTEM_MESSAGE); + + if (pasteHandler) document.removeEventListener('paste', pasteHandler); + pasteHandler = clipboardEvent => _handlePaste(clipboardEvent, setAvailableSheets, setModalDialog); + document.addEventListener('paste', pasteHandler); +} + +export function deinit() { + if (pasteHandler) { + document.removeEventListener('paste', pasteHandler); + pasteHandler = null; + } } \ No newline at end of file diff --git a/src/sheets/sheetUtil.ts b/src/sheets/sheetUtil.ts index a3d0969..f1e7278 100644 --- a/src/sheets/sheetUtil.ts +++ b/src/sheets/sheetUtil.ts @@ -4,7 +4,7 @@ import StringMap from '@/common/types/StringMap'; import HoneSheet from './types/HoneSheet'; import HoneColumn from './types/HoneColumn'; import { rowArrayToCsvUtf8 } from '@/csv/csvExportUtil'; -import { csvUnicodeToRowArray, csvUtf8ToRowArray } from '@/csv/csvImportUtil'; +import { csvUnicodeToRowArray, csvUtf8ToRowArray, MAX_FIELDNAME_LENGTH } from '@/csv/csvImportUtil'; import AppException from '@/common/types/AppException'; import Rowset from './types/Rowset'; import { generateColumnNames } from './columnUtil'; @@ -146,12 +146,21 @@ async function _readClipboardText():Promise { } } +async function _readDataTransferText(dataTransfer:DataTransfer):Promise { + try { + const text = await dataTransfer.getData('text/plain'); + return text; + } catch(e:any) { + throw new AppException(SheetErrorType.UNEXPECTED_CLIPBOARD_ERROR, e.message); + } +} + function _fileNameToSheetName(filename:string):string { let dotI = filename.lastIndexOf('.'); return dotI === -1 ? filename : filename.substring(0, dotI); } -function _firstRowToColumns(firstRow:string[]):HoneColumn[] { +function _rowToColumns(firstRow:string[]):HoneColumn[] { return firstRow.map(name => ({ name, isWritable:false })); } @@ -192,7 +201,7 @@ export async function importSheetFromCsvFile(fileHandle:FileSystemFileHandle, us const csvUtf8 = await readFileAsUint8Array(fileHandle); const sheetName = _fileNameToSheetName(fileHandle.name); let rows = csvUtf8ToRowArray(csvUtf8, useFirstRowColumnNames); - const columns = _firstRowToColumns(rows[0]); + const columns = _rowToColumns(rows[0]); rows = rows.slice(1); return { name:sheetName, columns, rows }; } @@ -202,12 +211,32 @@ export async function importSheetFromCsvFile(fileHandle:FileSystemFileHandle, us export async function importSheetFromClipboard(useFirstRowColumnNames:boolean, sheetName:string):Promise { const text = await _readClipboardText(); let rows = csvUnicodeToRowArray(text, useFirstRowColumnNames); - const columns = _firstRowToColumns(rows[0]); + const columns = _rowToColumns(rows[0]); rows = rows.slice(1); if (!rows.length) throw new AppException(SheetErrorType.CLIPBOARD_NO_ROWS, 'No rows found in clipboard data.'); return { name:sheetName, columns, rows }; } +function _doesRowLookLikeColumnNames(row:string[]):boolean { + if (!row.length) return false; + return !row.some( + fieldValue => typeof fieldValue !== 'string' + || fieldValue.length > MAX_FIELDNAME_LENGTH + || fieldValue === '' + ); +} + +export async function importSheetFromClipboardData(clipboardData:DataTransfer, sheetName:string):Promise { + const text = await _readDataTransferText(clipboardData); + let rows = csvUnicodeToRowArray(text, false); + if (rows.length === 1) throw new AppException(SheetErrorType.CLIPBOARD_NO_ROWS, 'No rows found in clipboard data.'); + const headerRowI = (_doesRowLookLikeColumnNames(rows[1])) ? 1 : 0; + if (rows.length === 2 && headerRowI === 1) throw new AppException(SheetErrorType.CLIPBOARD_NO_ROWS, 'No data rows found in clipboard data.'); + const columns = _rowToColumns(rows[headerRowI]); + rows = rows.slice(headerRowI + 1); + return { name:sheetName, columns, rows }; +} + export async function exportSheetToClipboard(sheet:HoneSheet, includeHeaders:boolean) { const fieldNames = getColumnNames(sheet); const rows = sheet.rows;