commit09bed34946Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 8 14:03:50 2022 +0100 Fixed the layout of a search list item commit90126d43adAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 17:25:13 2022 +0100 Insanely complicated path search results highlighitng and truncating commita4bbd5521eAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 14:05:01 2022 +0100 Improved the title editing in the header commit5814053968Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:38:33 2022 +0100 Improved text highlight colour commit110de37242Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:36:57 2022 +0100 Icon hover state now uses isHighlighted commitfb6da943d9Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:31:11 2022 +0100 Light mode styling commit2c378d5158Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 12:24:43 2022 +0100 Spacing between search items no longer a hack commit85d90bb0aeAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:19:10 2022 +0100 Added ellipsis to search matches if they are windowed commit91f3e70676Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:08:00 2022 +0100 Fixed search item icons commite384c87c6fAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:52:58 2022 +0100 Improved hover state commitf4681edc07Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:47:33 2022 +0100 Removed focus state on button commitfaa2d0663cAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:42 2022 +0100 made the search palette a little bigger commitdf8bd521feAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:14 2022 +0100 Remove onOverlayClick prop commitecd046b3b2Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:18:46 2022 +0100 Close search when the overlay is clicked commitfba330b5d9Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:10:35 2022 +0100 hitting esc when search input is focused and empty should close the search commitfc2fe8ae6fAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:00:08 2022 +0100 Highlight the window of the best search match in the search results commit45193ec92cAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 10:45:01 2022 +0100 Now you can arrow down to the last item in the list commit0de5fef7e9Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 10:37:20 2022 +0100 Added a hacky margin between items and remove the transition to make it feel snappier commit167b548343Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 09:30:33 2022 +0100 WIP implementing search commit5655631f12Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Mar 25 15:28:19 2022 +0000 Build and styled search modal commite368db81c8Author: Eric Allam <eallam@icloud.com> Date: Fri Mar 25 14:42:03 2022 +0000 Implement JSON search through fuse.js + web worker + react hook commit8caa11ba1cAuthor: Eric Allam <eallam@icloud.com> Date: Wed Mar 23 10:21:33 2022 +0000 Start of the Command Palette using radix-ui Dialog commit904b64d781Author: Eric Allam <eallam@icloud.com> Date: Wed Mar 23 09:58:52 2022 +0000 Uncommented out the search bar field
614 lines
14 KiB
TypeScript
614 lines
14 KiB
TypeScript
import { omit } from "lodash-es";
|
|
import React, {
|
|
ReactNode,
|
|
Reducer,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useReducer,
|
|
} from "react";
|
|
import { useMemoCompare } from "~/hooks/useMemoCompare";
|
|
|
|
export type IconComponent = (
|
|
props: React.SVGProps<SVGSVGElement>
|
|
) => JSX.Element;
|
|
|
|
export interface ColumnViewNode {
|
|
id: string;
|
|
name: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
longTitle?: string;
|
|
icon?: IconComponent;
|
|
children: ColumnViewNode[];
|
|
}
|
|
|
|
export type ColumnViewOptions = {
|
|
rootNode: ColumnViewNode;
|
|
initialState?: ColumnViewState | string;
|
|
stateReducer?: ColumnViewStateReducerHook;
|
|
};
|
|
|
|
export type ColumnDefinition = {
|
|
id: string;
|
|
title: string;
|
|
icon?: IconComponent;
|
|
items: ColumnViewNode[];
|
|
};
|
|
|
|
export type ColumnViewInstanceState = {
|
|
columns: Array<ColumnDefinition>;
|
|
getColumnViewProps: () => ColumnViewProps;
|
|
selectedNodeId?: string;
|
|
selectedNodeSource?: string;
|
|
selectedPath: string[];
|
|
highlightedNodeId?: string;
|
|
highlightedPath: string[];
|
|
selectedNodes: ColumnViewNode[];
|
|
canGoBack: boolean;
|
|
canGoForward: boolean;
|
|
};
|
|
|
|
export type ColumnViewAPIOptions = {
|
|
source?: KeyboardEvent | MouseEvent;
|
|
};
|
|
|
|
export type ColumnViewAPI = {
|
|
goBack: () => void;
|
|
goForward: () => void;
|
|
goToNodeId: (nodeId: string, source: string) => void;
|
|
goToParent: (options?: ColumnViewAPIOptions) => void;
|
|
goToChildren: () => void;
|
|
goToNextSibling: () => void;
|
|
goToPreviousSibling: () => void;
|
|
resetSelection: () => void;
|
|
};
|
|
|
|
export type ColumnViewInstance = {
|
|
state: ColumnViewInstanceState;
|
|
api: ColumnViewAPI;
|
|
};
|
|
|
|
export type ColumnViewProps = {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
tabIndex?: number;
|
|
};
|
|
|
|
type ColumnDefinitionCache = Map<string, ColumnDefinition>;
|
|
|
|
export function useColumnView({
|
|
rootNode,
|
|
initialState,
|
|
stateReducer,
|
|
}: ColumnViewOptions): ColumnViewInstance {
|
|
const columnCache = React.useRef<ColumnDefinitionCache>(new Map());
|
|
|
|
useEffect(() => {
|
|
columnCache.current = new Map();
|
|
}, [rootNode]);
|
|
|
|
const nodeTable = useMemo<NodeTable>(
|
|
() => generateNodeTable(rootNode),
|
|
[rootNode]
|
|
);
|
|
|
|
const enhancedReducer: Reducer<ColumnViewState, ColumnViewAction> =
|
|
useCallback(
|
|
(state: ColumnViewState, action: ColumnViewAction): ColumnViewState => {
|
|
let changes = columnViewReducer(state, action);
|
|
|
|
// Don't allow the client modify history actions
|
|
if (action.type === "GO") {
|
|
return changes;
|
|
}
|
|
|
|
if (stateReducer) {
|
|
changes = stateReducer(state, action, changes);
|
|
}
|
|
|
|
//we need to get rid of any history items further forward because the user has forked by navigating
|
|
let updatedHistory = changes.history.slice(
|
|
0,
|
|
changes.historyCurrentIndex + 1
|
|
);
|
|
let historyIndex = changes.historyCurrentIndex;
|
|
|
|
//add new entry
|
|
const newHistoryEntry = omit(changes, "history", "historyCurrentIndex");
|
|
updatedHistory.push(newHistoryEntry);
|
|
historyIndex = updatedHistory.length - 1;
|
|
|
|
return {
|
|
...changes,
|
|
history: updatedHistory,
|
|
historyCurrentIndex: historyIndex,
|
|
};
|
|
},
|
|
[stateReducer]
|
|
);
|
|
|
|
const [state, dispatch] = useReducer<
|
|
Reducer<ColumnViewState, ColumnViewAction>
|
|
>(
|
|
enhancedReducer,
|
|
typeof initialState === "string"
|
|
? {
|
|
selectedNodeId: initialState,
|
|
highlightedNodeId: initialState,
|
|
history: [],
|
|
historyCurrentIndex: 0,
|
|
nodeTable,
|
|
rootNodeId: rootNode.id,
|
|
}
|
|
: initialState ?? {
|
|
selectedNodeId: "$",
|
|
highlightedNodeId: "$",
|
|
history: [],
|
|
historyCurrentIndex: 0,
|
|
nodeTable,
|
|
rootNodeId: rootNode.id,
|
|
}
|
|
);
|
|
|
|
const api = useMemo<ColumnViewAPI>(() => {
|
|
return {
|
|
goBack: () => {
|
|
dispatch(goBackAction());
|
|
},
|
|
goForward: () => {
|
|
dispatch(goForwardAction());
|
|
},
|
|
goToNodeId: (nodeId: string, source: string) => {
|
|
dispatch(goToNodeIdAction(nodeId, source));
|
|
},
|
|
goToParent: (options?: ColumnViewAPIOptions) => {
|
|
dispatch(goToParentAction(options));
|
|
},
|
|
goToChildren: () => {
|
|
dispatch(goToChildrenAction());
|
|
},
|
|
goToPreviousSibling: () => {
|
|
dispatch(goToPreviousSibling());
|
|
},
|
|
goToNextSibling: () => {
|
|
dispatch(goToNextSibling());
|
|
},
|
|
resetSelection: () => {
|
|
dispatch(resetSelectionAction());
|
|
},
|
|
};
|
|
}, [dispatch]);
|
|
|
|
const {
|
|
selectedNodeId,
|
|
highlightedNodeId,
|
|
selectedNodeSource,
|
|
history,
|
|
historyCurrentIndex,
|
|
} = state;
|
|
|
|
const selectedPath = getPathToNode(nodeTable, selectedNodeId);
|
|
const highlightedPath = getPathToNode(nodeTable, highlightedNodeId);
|
|
|
|
const columns = useMemoCompare(
|
|
generateColumns(nodeTable, selectedPath, columnCache.current),
|
|
(previous, next) => {
|
|
if (!previous || !next) return false;
|
|
if (previous.length !== next.length) return false;
|
|
const isEqual =
|
|
previous.map(({ id }) => id).join("") ===
|
|
next.map(({ id }) => id).join("");
|
|
|
|
return isEqual;
|
|
}
|
|
);
|
|
const selectedNodes = selectedPath.map((id) => nodeTable[id].node);
|
|
|
|
const getColumnViewProps = useCallback(() => {
|
|
return {};
|
|
}, []);
|
|
|
|
const canGoBack = historyCurrentIndex > 0;
|
|
const canGoForward = historyCurrentIndex < history.length - 1;
|
|
|
|
return {
|
|
state: {
|
|
selectedNodeId,
|
|
selectedNodeSource,
|
|
selectedPath,
|
|
selectedNodes,
|
|
highlightedNodeId,
|
|
highlightedPath,
|
|
columns: columns ?? [],
|
|
getColumnViewProps,
|
|
canGoBack,
|
|
canGoForward,
|
|
},
|
|
api,
|
|
};
|
|
}
|
|
|
|
export type ColumnViewState = {
|
|
selectedNodeId?: string;
|
|
highlightedNodeId?: string;
|
|
selectedNodeSource?: string;
|
|
history: Array<Omit<ColumnViewState, "history" | "historyCurrentIndex">>;
|
|
historyCurrentIndex: number;
|
|
nodeTable: NodeTable;
|
|
rootNodeId: string;
|
|
};
|
|
|
|
export type SetSelectedNodeIdAction = {
|
|
type: "SET_SELECTED_NODE_ID";
|
|
id: string;
|
|
source: string;
|
|
};
|
|
|
|
export type MoveSelectedNodeAction = {
|
|
type: "MOVE_UP" | "MOVE_DOWN" | "MOVE_TO_PARENT" | "MOVE_TO_CHILDREN";
|
|
source?: KeyboardEvent | MouseEvent;
|
|
};
|
|
|
|
export type ResetSelectionNodeAction = {
|
|
type: "RESET_SELECTION";
|
|
};
|
|
|
|
export type GoAction = {
|
|
type: "GO";
|
|
direction: -1 | 1;
|
|
};
|
|
|
|
export type ColumnViewAction =
|
|
| SetSelectedNodeIdAction
|
|
| MoveSelectedNodeAction
|
|
| ResetSelectionNodeAction
|
|
| GoAction;
|
|
|
|
function goBackAction(): GoAction {
|
|
return {
|
|
type: "GO",
|
|
direction: -1,
|
|
};
|
|
}
|
|
|
|
function goForwardAction(): GoAction {
|
|
return {
|
|
type: "GO",
|
|
direction: 1,
|
|
};
|
|
}
|
|
|
|
function resetSelectionAction(): ResetSelectionNodeAction {
|
|
return {
|
|
type: "RESET_SELECTION",
|
|
};
|
|
}
|
|
|
|
function goToNodeIdAction(
|
|
nodeId: string,
|
|
source: string
|
|
): SetSelectedNodeIdAction {
|
|
return {
|
|
type: "SET_SELECTED_NODE_ID",
|
|
id: nodeId,
|
|
source,
|
|
};
|
|
}
|
|
|
|
function goToParentAction(
|
|
options?: ColumnViewAPIOptions
|
|
): MoveSelectedNodeAction {
|
|
return {
|
|
type: "MOVE_TO_PARENT",
|
|
source: options?.source,
|
|
};
|
|
}
|
|
|
|
function goToChildrenAction(): MoveSelectedNodeAction {
|
|
return {
|
|
type: "MOVE_TO_CHILDREN",
|
|
};
|
|
}
|
|
|
|
function goToPreviousSibling(): MoveSelectedNodeAction {
|
|
return {
|
|
type: "MOVE_UP",
|
|
};
|
|
}
|
|
|
|
function goToNextSibling(): MoveSelectedNodeAction {
|
|
return {
|
|
type: "MOVE_DOWN",
|
|
};
|
|
}
|
|
|
|
export type ColumnViewStateReducerHook = (
|
|
state: ColumnViewState,
|
|
action: ColumnViewAction,
|
|
changes: ColumnViewState
|
|
) => ColumnViewState;
|
|
|
|
/*
|
|
Needs to support the following selection actions:
|
|
1. Select a node by id
|
|
2. Highlight a node by id
|
|
3. Move to a specific child of of the current node
|
|
4. Move to the next sibling of the current node
|
|
5. Move to the previous sibling of the current node
|
|
5. Move to the parent of the current node
|
|
6. Move to the root of the current node
|
|
7. Move to the first child of the current node
|
|
8. Move back to the previous state
|
|
9. Move forward to the next state
|
|
*/
|
|
function columnViewReducer(
|
|
state: ColumnViewState,
|
|
action: ColumnViewAction
|
|
): ColumnViewState {
|
|
switch (action.type) {
|
|
case "SET_SELECTED_NODE_ID":
|
|
return {
|
|
...state,
|
|
selectedNodeId: action.id,
|
|
highlightedNodeId: action.id,
|
|
selectedNodeSource: action.source,
|
|
};
|
|
case "MOVE_DOWN": {
|
|
if (state.highlightedNodeId === state.rootNodeId) {
|
|
return moveToChildren(state);
|
|
}
|
|
|
|
const id = getHighlightedSibling(state, state.nodeTable, 1);
|
|
|
|
if (!id) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
selectedNodeId: id,
|
|
highlightedNodeId: id,
|
|
};
|
|
}
|
|
case "MOVE_UP": {
|
|
const id = getHighlightedSibling(state, state.nodeTable, -1);
|
|
|
|
if (!id) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
selectedNodeId: id,
|
|
highlightedNodeId: id,
|
|
};
|
|
}
|
|
case "MOVE_TO_CHILDREN": {
|
|
return moveToChildren(state);
|
|
}
|
|
case "MOVE_TO_PARENT": {
|
|
const { highlightedNodeId } = state;
|
|
|
|
if (!highlightedNodeId) {
|
|
return state;
|
|
}
|
|
|
|
const highlightedNode = state.nodeTable[highlightedNodeId];
|
|
|
|
if (!highlightedNode || !highlightedNode.parentId) {
|
|
return state;
|
|
}
|
|
|
|
const id = highlightedNode.parentId;
|
|
|
|
return {
|
|
...state,
|
|
selectedNodeId: id,
|
|
highlightedNodeId: id,
|
|
};
|
|
}
|
|
case "RESET_SELECTION": {
|
|
if (state.selectedNodeId !== state.highlightedNodeId) {
|
|
return {
|
|
...state,
|
|
selectedNodeId: state.highlightedNodeId,
|
|
};
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "GO": {
|
|
const { history, historyCurrentIndex } = state;
|
|
|
|
if (action.direction === -1 && historyCurrentIndex > 0) {
|
|
const newHistoryCurrentIndex = historyCurrentIndex - 1;
|
|
const nextState = history[newHistoryCurrentIndex];
|
|
|
|
return {
|
|
...nextState,
|
|
historyCurrentIndex: newHistoryCurrentIndex,
|
|
history,
|
|
};
|
|
}
|
|
|
|
if (action.direction === 1 && historyCurrentIndex < history.length - 1) {
|
|
const newHistoryCurrentIndex = historyCurrentIndex + 1;
|
|
const nextState = history[newHistoryCurrentIndex];
|
|
|
|
return {
|
|
...nextState,
|
|
historyCurrentIndex: newHistoryCurrentIndex,
|
|
history,
|
|
};
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function moveToChildren(state: ColumnViewState): ColumnViewState {
|
|
const { highlightedNodeId } = state;
|
|
|
|
if (!highlightedNodeId) {
|
|
return state;
|
|
}
|
|
|
|
const highlightedNode = state.nodeTable[highlightedNodeId];
|
|
|
|
if (!highlightedNode || highlightedNode.children.length === 0) {
|
|
return state;
|
|
}
|
|
|
|
const id = highlightedNode.children[0];
|
|
|
|
return {
|
|
...state,
|
|
selectedNodeId: id,
|
|
highlightedNodeId: id,
|
|
};
|
|
}
|
|
|
|
// TODO: CACHE THIS
|
|
function generateColumns(
|
|
nodeTable: NodeTable,
|
|
path: string[],
|
|
columnCache: ColumnDefinitionCache
|
|
): Array<ColumnDefinition> {
|
|
const columns: Array<ColumnDefinition> = [];
|
|
|
|
function addColumn(nodeRecord: NodeRecord) {
|
|
const cachedColumn = columnCache.get(nodeRecord.id);
|
|
|
|
if (cachedColumn) {
|
|
columns.push(cachedColumn);
|
|
return;
|
|
}
|
|
|
|
const column: ColumnDefinition = {
|
|
id: nodeRecord.id,
|
|
title: nodeRecord.node.longTitle ?? nodeRecord.node.title,
|
|
icon: nodeRecord.node.icon,
|
|
items: nodeRecord.node.children || [],
|
|
};
|
|
|
|
columns.push(column);
|
|
|
|
columnCache.set(nodeRecord.id, column);
|
|
}
|
|
|
|
path.forEach((nodeId) => {
|
|
const nodeRecord = nodeTable[nodeId];
|
|
|
|
if (nodeRecord && nodeRecord.node.children.length > 0) {
|
|
addColumn(nodeRecord);
|
|
}
|
|
});
|
|
|
|
return columns;
|
|
}
|
|
|
|
function getPathToNode(nodeTable: NodeTable, nodeId?: string): string[] {
|
|
return getNodeAncestorPath(nodeTable, nodeId).reverse();
|
|
}
|
|
|
|
function getNodeAncestorPath(
|
|
nodeTable: NodeTable,
|
|
nodeId?: string,
|
|
path: string[] = []
|
|
): string[] {
|
|
if (!nodeId) {
|
|
return path;
|
|
}
|
|
|
|
const nodeRecord = nodeTable[nodeId];
|
|
|
|
if (!nodeRecord) {
|
|
return path;
|
|
}
|
|
|
|
return getNodeAncestorPath(
|
|
nodeTable,
|
|
nodeRecord.parentId,
|
|
path.concat(nodeId)
|
|
);
|
|
}
|
|
|
|
type NodeRecord = {
|
|
id: string;
|
|
node: ColumnViewNode;
|
|
parentId?: string;
|
|
children: string[];
|
|
};
|
|
|
|
type NodeTable = {
|
|
[id: string]: NodeRecord;
|
|
};
|
|
|
|
function generateNodeTable(rootNode: ColumnViewNode): NodeTable {
|
|
const nodesById: { [id: string]: NodeRecord } = {};
|
|
|
|
function addNode(node: ColumnViewNode, parentId?: string) {
|
|
const nodeRecord: NodeRecord = {
|
|
id: node.id,
|
|
node,
|
|
parentId,
|
|
children: [],
|
|
};
|
|
|
|
nodesById[node.id] = nodeRecord;
|
|
|
|
if (node.children) {
|
|
node.children.forEach((child) => {
|
|
addNode(child, node.id);
|
|
});
|
|
|
|
nodeRecord.children = node.children.map((child) => child.id);
|
|
}
|
|
}
|
|
|
|
addNode(rootNode);
|
|
|
|
return nodesById;
|
|
}
|
|
|
|
function getHighlightedSibling(
|
|
state: ColumnViewState,
|
|
nodeTable: NodeTable,
|
|
direction: -1 | 1
|
|
): string | undefined {
|
|
const { highlightedNodeId } = state;
|
|
|
|
if (!highlightedNodeId) {
|
|
return;
|
|
}
|
|
|
|
const highlightedNode = nodeTable[highlightedNodeId];
|
|
|
|
if (!highlightedNode || !highlightedNode.parentId) {
|
|
return;
|
|
}
|
|
|
|
const parentNode = nodeTable[highlightedNode.parentId];
|
|
|
|
if (!parentNode) {
|
|
return;
|
|
}
|
|
|
|
const highlightedIndex = parentNode.children.indexOf(highlightedNodeId);
|
|
const nextIndex = highlightedIndex + direction;
|
|
|
|
if (parentNode.children.length <= nextIndex || nextIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const nextNodeId = parentNode.children[nextIndex];
|
|
|
|
return nextNodeId;
|
|
}
|