diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index d5cbe1071..299013151 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -95,7 +95,7 @@ function switchTab(offset: number) { services.ObjectService.SetActiveTab(newActiveTabId); } -var transformRegexp = /translate\(\s*([0-9.]+)px\s*,\s*([0-9.]+)px\)/; +var transformRegexp = /translate3d\(\s*([0-9.]+)px\s*,\s*([0-9.]+)px,\s*0\)/; function parseFloatFromCSS(s: string | number): number { if (typeof s == "number") { diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 5f2028f82..e0f53a8c8 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -205,6 +205,7 @@ interface BlockFrameProps { blockId: string; onClose?: () => void; onClick?: () => void; + onFocusCapture?: React.FocusEventHandler; preview: boolean; children?: React.ReactNode; blockRef?: React.RefObject; @@ -215,6 +216,7 @@ const BlockFrame_Tech_Component = ({ blockId, onClose, onClick, + onFocusCapture, preview, blockRef, dragHandleRef, @@ -249,6 +251,7 @@ const BlockFrame_Tech_Component = ({ preview ? "block-preview" : null )} onClick={onClick} + onFocusCapture={onFocusCapture} ref={blockRef} style={style} > @@ -425,6 +428,18 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { const blockRef = React.useRef(null); const [blockClicked, setBlockClicked] = React.useState(false); const [blockData, blockDataLoading] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const [focusedChild, setFocusedChild] = React.useState(null); + const isFocusedAtom = useBlockAtom(blockId, "isFocused", () => { + return jotai.atom((get) => { + const winData = get(atoms.waveWindow); + return winData.activeblockid === blockId; + }); + }); + let isFocused = jotai.useAtomValue(isFocusedAtom); + + React.useLayoutEffect(() => { + setBlockClicked(isFocused); + }, [isFocused]); React.useLayoutEffect(() => { if (!blockClicked) { @@ -433,15 +448,50 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { setBlockClicked(false); const focusWithin = blockRef.current?.contains(document.activeElement); if (!focusWithin) { - focusElemRef.current?.focus(); + setFocusTarget(); } setBlockFocus(blockId); }, [blockClicked]); + React.useLayoutEffect(() => { + if (focusedChild == null) { + return; + } + setBlockFocus(blockId); + }, [focusedChild, blockId]); + + // treat the block as clicked on creation const setBlockClickedTrue = React.useCallback(() => { setBlockClicked(true); }, []); + const determineFocusedChild = React.useCallback( + (event: React.FocusEvent) => { + setFocusedChild(event.target); + }, + [setFocusedChild] + ); + + const getFocusableChildren = React.useCallback(() => { + if (blockRef.current == null) { + return []; + } + return Array.from( + blockRef.current.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex="0"]' + ) + ).filter((elem) => elem.id != `${blockId}-dummy-focus`); + }, [blockRef.current]); + + const setFocusTarget = React.useCallback(() => { + const focusableChildren = getFocusableChildren(); + if (focusableChildren.length == 0) { + focusElemRef.current.focus({ preventScroll: true }); + } else { + (focusableChildren[0] as HTMLElement).focus({ preventScroll: true }); + } + }, [focusElemRef.current, getFocusableChildren]); + if (!blockId || !blockData) return null; if (blockDataLoading) { blockElem = Loading...; @@ -458,6 +508,7 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { } else if (blockData.view === "waveai") { blockElem = ; } + return ( { onClick={setBlockClickedTrue} blockRef={blockRef} dragHandleRef={dragHandleRef} + onFocusCapture={(e) => determineFocusedChild(e)} >
- {}} /> + {}} + disabled={getFocusableChildren().length > 0} + />
diff --git a/frontend/app/view/directorypreview.less b/frontend/app/view/directorypreview.less index 2896e08a0..00e50c41a 100644 --- a/frontend/app/view/directorypreview.less +++ b/frontend/app/view/directorypreview.less @@ -1,122 +1,179 @@ -.dir-table { - --col-size-size: 0.2rem; - overflow: auto; +.dir-table-container { width: 100%; - border-radius: 3px; - .dir-table-head { - .dir-table-head-row { - display: flex; - border-bottom: 2px solid var(--border-color); - padding: 4px 0; - background-color: var(--panel-bg-color); - - .dir-table-head-cell:not(:first-child) { - position: relative; - padding: 2px 4px; - font-weight: bold; - display: flex; - justify-content: space-between; - overflow: hidden; - .dir-table-head-resize { - position: absolute; - top: 0; - right: 0; - height: 100%; - cursor: col-resize; - user-select: none; - -webkit-user-select: none; - touch-action: none; - width: 2px; - background: linear-gradient(var(--border-color), var(--border-color)) no-repeat center/2px 100%; - } - - .dir-table-head-direction { - color: var(--grey-text-color); - margin-right: 0.2rem; - margin-top: 0.2rem; - } - - &:last-child { - .dir-table-head-resize { - width: 0; - } - } - } - } - } - - .dir-table-body { - .dir-table-body-row { - display: flex; - border-radius: 3px; - - &.focused { - background-color: rgb(from var(--accent-color) r g b / 0.7); - color: var(--main-text-color); - - .dir-table-body-cell { - .dir-table-lastmod, - .dir-table-modestr, - .dir-table-size, - .dir-table-type { - color: var(--main-text-color); - } - } - } - - &:focus { - background-color: rgb(from var(--accent-color) r g b / 0.7); - color: var(--main-text-color); - - .dir-table-body-cell { - .dir-table-lastmod, - .dir-table-modestr, - .dir-table-size, - .dir-table-type { - color: var(--main-text-color); - } - } - } - - &:hover:not(:focus):not(.focused) { - background-color: var(--highlight-bg-color); - } - - .dir-table-body-cell { - overflow: hidden; - white-space: nowrap; - padding: 0.25rem; - cursor: default; - - &.col-size { - text-align: right; - } - - .dir-table-lastmod, - .dir-table-modestr, - .dir-table-size, - .dir-table-type { - color: var(--secondary-text-color); - } - } - } - } -} - -.dir-table-search-line { display: flex; - gap: 0.7rem; + flex-direction: column; + height: 100%; + .dir-table { + height: 100%; + --col-size-size: 0.2rem; + border-radius: 3px; + display: flex; + flex-direction: column; + .dir-table-head { + .dir-table-head-row { + display: flex; + border-bottom: 2px solid var(--border-color); + padding: 4px 0; + background-color: var(--panel-bg-color); - .dir-table-search-box { - background-color: var(--panel-bg-color); - margin-bottom: 0.5rem; - border: none; - width: 15rem; - color: var(--main-text-color); - border-radius: 4px; + .dir-table-head-cell:not(:first-child) { + position: relative; + padding: 2px 4px; + font-weight: bold; + display: flex; + justify-content: space-between; + overflow: hidden; + .dir-table-head-resize { + position: absolute; + top: 0; + right: 0; + height: 100%; + cursor: col-resize; + user-select: none; + -webkit-user-select: none; + touch-action: none; + width: 4px; + background: linear-gradient(var(--border-color), var(--border-color)) no-repeat center/2px 100%; + } - &:focus { - outline-color: var(--accent-color); + .dir-table-head-direction { + color: var(--grey-text-color); + margin-right: 0.2rem; + margin-top: 0.2rem; + } + + &:last-child { + .dir-table-head-resize { + width: 0; + } + } + } + } + } + + .dir-table-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + .dir-table-body-search-display { + display: flex; + border-radius: 3px; + padding: 0.25rem 0.5rem; + background-color: var(--warning-color); + + .search-display-close-button { + margin-left: auto; + } + } + + .dir-table-body-scroll-box { + position: relative; + overflow-y: auto; + .dummy { + position: absolute; + visibility: hidden; + } + .dir-table-body-row { + display: flex; + border-radius: 3px; + + &.focused { + background-color: rgb(from var(--accent-color) r g b / 0.7); + color: var(--main-text-color); + + .dir-table-body-cell { + .dir-table-lastmod, + .dir-table-modestr, + .dir-table-size, + .dir-table-type { + color: var(--main-text-color); + } + } + } + + &:focus { + background-color: rgb(from var(--accent-color) r g b / 0.7); + color: var(--main-text-color); + + .dir-table-body-cell { + .dir-table-lastmod, + .dir-table-modestr, + .dir-table-size, + .dir-table-type { + color: var(--main-text-color); + } + } + } + + &:hover:not(:focus):not(.focused) { + background-color: var(--highlight-bg-color); + } + + .dir-table-body-cell { + overflow: hidden; + white-space: nowrap; + padding: 0.25rem; + cursor: default; + + &.col-size { + text-align: right; + } + + .dir-table-lastmod, + .dir-table-modestr, + .dir-table-size, + .dir-table-type { + color: var(--secondary-text-color); + } + } + } + } + } + } + + .dir-table-search-line { + display: flex; + justify-content: flex-end; + gap: 0.7rem; + + .dir-table-search-box { + width: 0; + height: 0; + opacity: 0; + padding: 0; + border: none; + pointer-events: none; } } } + +.dir-table-button { + background-color: transparent; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 0.2rem; + border-radius: 6px; + + input { + width: 0; + height: 0; + opacity: 0; + padding: 0; + border: none; + pointer-events: none; + } + + &:hover { + background-color: var(--highlight-bg-color); + } + + &:focus { + background-color: var(--highlight-bg-color); + } + + &:focus-within { + background-color: var(--highlight-bg-color); + } +} diff --git a/frontend/app/view/directorypreview.tsx b/frontend/app/view/directorypreview.tsx index a72cb1834..76224b137 100644 --- a/frontend/app/view/directorypreview.tsx +++ b/frontend/app/view/directorypreview.tsx @@ -1,10 +1,11 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { Button } from "@/element/button"; import * as services from "@/store/services"; +import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; import { + Row, Table, createColumnHelper, flexRender, @@ -23,6 +24,7 @@ import "./directorypreview.less"; interface DirectoryTableProps { data: FileInfo[]; + search: string; focusIndex: number; setFocusIndex: (_: number) => void; setFileName: (_: string) => void; @@ -94,7 +96,7 @@ function getLastModifiedTime(unixMillis: number): string { } else if (nowDatetime.month() != fileDatetime.month()) { return dayjs(fileDatetime).format("MMM D"); } else { - return dayjs(fileDatetime).format("h:mm A"); + return dayjs(fileDatetime).format("MMM D h:mm A"); } } @@ -135,6 +137,7 @@ function cleanMimetype(input: string): string { function DirectoryTable({ data, + search, focusIndex, setFocusIndex, setFileName, @@ -227,11 +230,28 @@ function DirectoryTable({ columnVisibility: { path: false, }, + rowPinning: { + top: [], + bottom: [], + }, }, enableMultiSort: false, enableSortingRemoval: false, }); + React.useEffect(() => { + setSelectedPath((table.getSortedRowModel()?.flatRows[focusIndex]?.getValue("path") as string) ?? null); + }, [table, focusIndex, data]); + + React.useEffect(() => { + let rows = table.getRowModel()?.flatRows; + for (const row of rows) { + if (row.getValue("name") == "..") { + row.pin("top"); + return; + } + } + }, [data]); const columnSizeVars = React.useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; @@ -271,7 +291,9 @@ function DirectoryTable({
{table.getState().columnSizingInfo.isResizingColumn ? ( ) : ( ; table: Table; + search: string; focusIndex: number; setFocusIndex: (_: number) => void; setFileName: (_: string) => void; @@ -305,7 +331,9 @@ interface TableBodyProps { } function TableBody({ + data, table, + search, focusIndex, setFocusIndex, setFileName, @@ -313,9 +341,36 @@ function TableBody({ setSelectedPath, setRefresh, }: TableBodyProps) { + const dummyLineRef = React.useRef(null); + const parentRef = React.useRef(null); + const warningBoxRef = React.useRef(null); + const [bodyHeight, setBodyHeight] = React.useState(0); + const [containerHeight, setContainerHeight] = React.useState(0); + React.useEffect(() => { - setSelectedPath((table.getSortedRowModel()?.flatRows[focusIndex]?.getValue("path") as string) ?? null); - }, [table, focusIndex]); + if (parentRef.current == null) { + return; + } + const resizeObserver = new ResizeObserver(() => { + setContainerHeight(parentRef.current.getBoundingClientRect().height); // 17 is height of breadcrumb + }); + resizeObserver.observe(parentRef.current); + + return () => resizeObserver.disconnect(); + }, []); + + React.useEffect(() => { + if (dummyLineRef.current && data && parentRef.current) { + const rowHeight = dummyLineRef.current.offsetHeight; + const fullTBodyHeight = rowHeight * data.length; + const warningBoxHeight = warningBoxRef.current?.offsetHeight ?? 0; + const maxHeight = containerHeight - 1; // i don't know why, but the -1 makes the resize work + const maxHeightLessHeader = maxHeight - warningBoxHeight; + const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight); + + setBodyHeight(tbodyHeight); + } + }, [data, containerHeight]); const handleFileContextMenu = React.useCallback( (e: React.MouseEvent, path: string) => { @@ -350,33 +405,53 @@ function TableBody({ [setRefresh] ); + const displayRow = React.useCallback( + (row: Row, idx: number) => ( +
{ + const newFileName = row.getValue("path") as string; + setFileName(newFileName); + setSearch(""); + }} + onClick={() => setFocusIndex(idx)} + onContextMenu={(e) => handleFileContextMenu(e, row.getValue("path") as string)} + > + {row.getVisibleCells().map((cell) => { + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+ ), + [setSearch, setFileName, handleFileContextMenu, setFocusIndex, focusIndex] + ); + return ( -
- {table.getRowModel().rows.map((row, idx) => ( -
{ - const newFileName = row.getValue("path") as string; - setFileName(newFileName); - setSearch(""); - }} - onClick={() => setFocusIndex(idx)} - onContextMenu={(e) => handleFileContextMenu(e, row.getValue("path") as string)} - > - {row.getVisibleCells().map((cell) => { - return ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ); - })} +
+ {search == "" || ( +
+ Searching for "{search}" +
setSearch("")}> + + {}}> +
- ))} + )} +
+
+
dummy-data
+
+ {table.getTopRows().map(displayRow)} + {table.getCenterRows().map((row, idx) => displayRow(row, idx + table.getTopRows().length))} +
); } @@ -405,7 +480,7 @@ function DirectoryPreview({ fileNameAtom }: DirectoryPreviewProps) { const serializedContent = util.base64ToString(file?.data64); let content: FileInfo[] = JSON.parse(serializedContent); let filtered = content.filter((fileInfo) => { - if (hideHiddenFiles && fileInfo.name.startsWith(".")) { + if (hideHiddenFiles && fileInfo.name.startsWith(".") && fileInfo.name != "..") { return false; } return fileInfo.name.toLowerCase().includes(searchText); @@ -416,62 +491,60 @@ function DirectoryPreview({ fileNameAtom }: DirectoryPreviewProps) { }, [fileName, searchText, hideHiddenFiles, refresh]); const handleKeyDown = React.useCallback( - (e) => { - switch (e.key) { - case "Escape": - //todo: escape block focus - break; - case "ArrowUp": - e.preventDefault(); - setFocusIndex((idx) => Math.max(idx - 1, 0)); - break; - case "ArrowDown": - e.preventDefault(); - setFocusIndex((idx) => Math.min(idx + 1, content.length - 1)); - break; - case "Enter": - e.preventDefault(); - setFileName(selectedPath); - setSearchText(""); - break; - default: + (waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + setSearchText(""); + return; + } + if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) { + setFocusIndex((idx) => Math.max(idx - 1, 0)); + return true; + } + if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { + setFocusIndex((idx) => Math.min(idx + 1, content.length - 1)); + return true; + } + if (keyutil.checkKeyPressed(waveEvent, "Enter")) { + setFileName(selectedPath); + setSearchText(""); + return true; } }, [content, focusIndex, selectedPath] ); - React.useEffect(() => { - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [handleKeyDown]); - return ( - <> +
{ + const event = e as React.ChangeEvent; + setSearchText(event.target.value.toLowerCase()); + }} + onKeyDownCapture={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} + onFocusCapture={() => document.getSelection().collapseToEnd()} + >
- - setSearchText(e.target.value.toLowerCase())} + onChange={() => {}} //for nuisance warnings maxLength={400} autoFocus={true} value={searchText} /> - -
+
setRefresh((current) => !current)} className="dir-table-button"> - + {}}> +
- +
); }