diff --git a/plugins/global-search/src/App.test.tsx b/plugins/global-search/src/App.test.tsx index 39f9b1a3b..4e40918ec 100644 --- a/plugins/global-search/src/App.test.tsx +++ b/plugins/global-search/src/App.test.tsx @@ -4,8 +4,9 @@ import { describe, expect, it } from "vitest" import { App } from "./App" describe("App", () => { - it("should render", () => { + it("should render search interface", () => { render() - expect(screen.getByText(/Welcome!/gi)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/Search/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Search for anything in your Framer project/i)).toBeInTheDocument() }) }) diff --git a/plugins/global-search/src/App.tsx b/plugins/global-search/src/App.tsx index bca005d8f..cb12e73ba 100644 --- a/plugins/global-search/src/App.tsx +++ b/plugins/global-search/src/App.tsx @@ -2,13 +2,13 @@ import { framer } from "framer-plugin" import { useEffect, useState } from "react" import { ErrorBoundary } from "react-error-boundary" import { DevToolsScene } from "./components/DevToolsScene" +import { SearchScene } from "./components/SearchScene" import { IndexerProvider } from "./utils/indexer/IndexerProvider" framer.showUI({ position: "top right", - width: 400, - height: 600, - resizable: true, + width: 280, + height: 64, }) export function App() { @@ -34,11 +34,8 @@ export function App() { )} > - {activeScene === "dev-tools" ? ( - - ) : ( -
Welcome! This is under development. Select "Open Dev Tools" to get started.
- )} + {activeScene === "dev-tools" && } + {activeScene === "search" && }
) diff --git a/plugins/global-search/src/components/DevToolsScene.tsx b/plugins/global-search/src/components/DevToolsScene.tsx index 5ed8c1a53..f684e7a9f 100644 --- a/plugins/global-search/src/components/DevToolsScene.tsx +++ b/plugins/global-search/src/components/DevToolsScene.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react" +import { framer } from "framer-plugin" +import { useEffect, useMemo, useState } from "react" import { cn } from "../utils/className" import { type IndexEntry } from "../utils/indexer/types" import { useIndexer } from "../utils/indexer/useIndexer" @@ -28,6 +29,14 @@ export function DevToolsScene() { [entries, filterQuery] ) + useEffect(() => { + framer.showUI({ + height: Infinity, + width: Infinity, + resizable: true, + }) + }, []) + const stats = useMemo( () => ({ total: entries.length, diff --git a/plugins/global-search/src/components/SearchInput.tsx b/plugins/global-search/src/components/SearchInput.tsx new file mode 100644 index 000000000..fde42c3d0 --- /dev/null +++ b/plugins/global-search/src/components/SearchInput.tsx @@ -0,0 +1,23 @@ +import type { DetailedHTMLProps } from "react" +import { cn } from "../utils/className" +import { IconSearch } from "./ui/IconSearch" + +type SearchInputProps = DetailedHTMLProps, HTMLInputElement> + +export function SearchInput({ className, ...props }: SearchInputProps) { + return ( + + ) +} diff --git a/plugins/global-search/src/components/SearchScene.tsx b/plugins/global-search/src/components/SearchScene.tsx new file mode 100644 index 000000000..abb5ce232 --- /dev/null +++ b/plugins/global-search/src/components/SearchScene.tsx @@ -0,0 +1,203 @@ +import { framer, type MenuItem } from "framer-plugin" +import { startTransition, useCallback, useEffect, useMemo, useState } from "react" +import { assertNever } from "../utils/assert" +import { type ReadonlyGroupedResults } from "../utils/filter/group-results" +import type { Range } from "../utils/filter/ranges" +import { type CollectionItemResult, type NodeResult, type Result } from "../utils/filter/types" +import { useFilter } from "../utils/filter/useFilter" +import type { RootNodeType } from "../utils/indexer/types" +import { useIndexer } from "../utils/indexer/useIndexer" +import { entries } from "../utils/object" +import { SearchInput } from "./SearchInput" +import { IconEllipsis } from "./ui/IconEllipsis" +import { Menu } from "./ui/Menu" + +export function SearchScene() { + const { index } = useIndexer() + const [query, setQuery] = useState("") + const { searchOptions, optionsMenuItems } = useOptionsMenuItems() + const { results } = useFilter(query, searchOptions, index) + + const handleQueryChange = useCallback((event: React.ChangeEvent) => { + startTransition(() => { + setQuery(event.target.value) + }) + }, []) + + const hasResults = useMemo(() => { + for (const [, resultForRootNodeType] of entries(results)) { + if (!resultForRootNodeType) continue + + for (const resultsForRootId of Object.values(resultForRootNodeType)) { + if (resultsForRootId.length > 0) return true + } + } + return false + }, [results]) + + useEffect(() => { + if (query && hasResults) { + framer.showUI({ + height: 320, + }) + } else if (query && !hasResults) { + framer.showUI({ + height: 140, + }) + } else { + framer.showUI({ + height: 64, + }) + } + }, [query, hasResults]) + + return ( +
+
+ + + + +
+
+ {query && hasResults ? : } +
+
+ ) +} + +// All components below this line are temporary and will be removed when the search results are implemented +// Having them ensures it's easier to verify the indexer and filterer are working as expected + +function NoResults() { + return ( +
+
No results found.
+
+ ) +} + +function SearchResultsByRootType({ results }: { results: ReadonlyGroupedResults }) { + return Object.entries(results).map(([rootNodeType, resultsByRootId]) => ( + + )) +} + +function RootNodeTypeSection({ resultsByRootId }: { resultsByRootId: { readonly [id: string]: readonly Result[] } }) { + return ( +
+ {Object.entries(resultsByRootId).map(([rootNodeId, results]) => ( + + ))} +
+ ) +} + +function SearchResultGroup({ results }: { results: readonly Result[] }) { + const [first] = results + + if (!first) return null + + return ( +
+
+ {first.entry.rootNodeName} ({first.entry.rootNodeType} {first.entry.rootNode.id}) +
+
    + {results.map(result => ( + + ))} +
+
+ ) +} + +function SearchResult({ result }: { result: Result }) { + if (result.type === "CollectionItem") { + return + } else if (result.type === "Node") { + return + } + + assertNever(result) +} + +function NodeSearchResult({ result }: { result: NodeResult }) { + if (!result.entry.text) return null + + return +} + +function CollectionItemSearchResult({ result }: { result: CollectionItemResult }) { + if (!result.text) return null + + return +} + +function SearchResultRanges({ text, ranges, resultId }: { text: string; ranges: readonly Range[]; resultId: string }) { + return ranges.map(range => ( +
  • + ({resultId}) +
  • + )) +} + +function HighlightedTextWithContext({ text, range }: { text: string; range: Range }) { + const [start, end] = range + const before = text.slice(0, start) + const match = text.slice(start, end) + const after = text.slice(end) + + return ( + <> + {before} + {match} + {after} + + ) +} + +/** + * Contains if you can filter by a root node type. + * + * During current state of the plugin, not all types are indexed yet. + */ +const optionsEnabled = { + ComponentNode: true, + WebPageNode: true, + Collection: true, +} as const satisfies Record + +const defaultSearchOptions = entries(optionsEnabled) + .filter(([, enabled]) => enabled) + .map(([rootNode]) => rootNode) + +const optionsMenuLabels = { + ComponentNode: "Components", + WebPageNode: "Pages", + Collection: "Collections", +} as const satisfies Record + +function useOptionsMenuItems() { + const [searchOptions, setSearchOptions] = useState(defaultSearchOptions) + + const optionsMenuItems = useMemo((): MenuItem[] => { + return entries(optionsEnabled).map(([rootNode, enabled]) => ({ + id: rootNode, + label: optionsMenuLabels[rootNode], + enabled, + checked: searchOptions.includes(rootNode), + onAction: () => { + setSearchOptions(prev => { + if (prev.includes(rootNode)) { + return prev.filter(option => option !== rootNode) + } + + return [...prev, rootNode] + }) + }, + })) + }, [searchOptions]) + + return { searchOptions, optionsMenuItems } +} diff --git a/plugins/global-search/src/components/ui/IconEllipsis.tsx b/plugins/global-search/src/components/ui/IconEllipsis.tsx new file mode 100644 index 000000000..20e580010 --- /dev/null +++ b/plugins/global-search/src/components/ui/IconEllipsis.tsx @@ -0,0 +1,18 @@ +export function IconEllipsis() { + return ( + + Ellipsis + + + ) +} diff --git a/plugins/global-search/src/components/ui/IconSearch.tsx b/plugins/global-search/src/components/ui/IconSearch.tsx new file mode 100644 index 000000000..549923e17 --- /dev/null +++ b/plugins/global-search/src/components/ui/IconSearch.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react" + +export function IconSearch(props: SVGProps) { + return ( + + + + ) +} diff --git a/plugins/global-search/src/components/ui/Menu.tsx b/plugins/global-search/src/components/ui/Menu.tsx new file mode 100644 index 000000000..b8e18f0e2 --- /dev/null +++ b/plugins/global-search/src/components/ui/Menu.tsx @@ -0,0 +1,44 @@ +import { framer, type MenuItem } from "framer-plugin" +import { memo, type ReactNode, useCallback, useRef } from "react" + +interface MenuProps { + items: MenuItem[] + children: ReactNode +} + +export const Menu = memo(function Menu({ items, children }: MenuProps) { + const buttonRef = useRef(null) + + const toggleMenu = useCallback( + async (event: React.MouseEvent | React.KeyboardEvent) => { + if (!buttonRef.current) return + if ("key" in event && event.key !== "Enter" && event.key !== " ") return + + const buttonBounds = buttonRef.current.getBoundingClientRect() + + await framer.showContextMenu(items, { + location: { x: buttonBounds.right - 5, y: buttonBounds.bottom }, + placement: "bottom-left", + width: 200, + }) + }, + [items] + ) + + return ( +
    + +
    + ) +}) diff --git a/plugins/global-search/src/utils/assert.ts b/plugins/global-search/src/utils/assert.ts new file mode 100644 index 000000000..26e10a075 --- /dev/null +++ b/plugins/global-search/src/utils/assert.ts @@ -0,0 +1,8 @@ +export function assert(condition: unknown, ...message: unknown[]): asserts condition { + if (condition) return + throw Error(`Assertion error: ${message.join(", ")}`) +} + +export function assertNever(x: never): never { + throw new Error(`Unexpected value: ${String(x)}`) +} diff --git a/plugins/global-search/src/utils/filter/execute-filter.ts b/plugins/global-search/src/utils/filter/execute-filter.ts new file mode 100644 index 000000000..d05a989e7 --- /dev/null +++ b/plugins/global-search/src/utils/filter/execute-filter.ts @@ -0,0 +1,97 @@ +import type { IndexCollectionItemEntry, IndexEntry, IndexNodeEntry } from "../indexer/types" +import { findRanges } from "./ranges" +import { type Filter, type Matcher, type Result, ResultType, type RootNodesFilter, type TextMatcher } from "./types" + +/** Execute a list of filters on a list of entries and return the results. */ +export function executeFilters( + /** The matchers to execute on the index. */ + matchers: readonly Matcher[], + /** A filter to narrow down the results. */ + filters: readonly Filter[], + /** The index to search on */ + index: readonly IndexEntry[] +): Result[] { + const results: Result[] = [] + + for (const entry of index) { + let include = true + let result: Result | undefined + + for (const matcher of matchers) { + const matchResult = executeMatcher(matcher, entry) + + if (matchResult === undefined) { + include = false + break + } + + if (filters.some(filter => executeFilter(filter, matchResult))) { + result = matchResult + } + } + + if (include && result) { + results.push(result) + } + } + + return results +} + +/** Execute a matcher on a single entry and routes to the appropriate matcher function. */ +function executeMatcher(matcher: Matcher, entry: IndexEntry): Result | undefined { + // When more matchers are added, we can add more matcher functions here and use this as a router + if (entry.type === "CollectionItem") { + return executeTextMatcherForCollectionItems(matcher, entry) + } + return executeTextMatcherForNodes(matcher, entry) +} + +function executeTextMatcherForNodes(matcher: TextMatcher, entry: IndexNodeEntry): Result | undefined { + const text = entry.text ?? entry.name + if (!text) return undefined + + const ranges = findRanges(text, matcher.query, matcher.caseSensitive) + if (!ranges.length) return undefined + + return { + id: entry.id, + text: text, + ranges, + entry, + type: ResultType.Node, + } +} + +function executeTextMatcherForCollectionItems( + matcher: TextMatcher, + entry: IndexCollectionItemEntry +): Result | undefined { + // FIXME: This only returns the first matching field + // Instead of having multiple fields in the index, we should have a single entry per field in the index + for (const [field, text] of Object.entries(entry.fields)) { + const ranges = findRanges(text, matcher.query, matcher.caseSensitive) + if (ranges.length) { + return { + id: `${entry.id}-${field}`, + field, + text, + ranges, + entry, + type: ResultType.CollectionItem, + } + } + } + + return undefined +} + +/** Execute a filter on a result and return true if the result should be included. */ +function executeFilter(filter: Filter, result: Result): boolean { + // When more filters are added, we can add more filter functions here and use this as a router + return executeRootNodesFilter(filter, result) +} + +function executeRootNodesFilter(filter: RootNodesFilter, result: Result): boolean { + return filter.rootNodes.includes(result.entry.rootNodeType) +} diff --git a/plugins/global-search/src/utils/filter/group-results.ts b/plugins/global-search/src/utils/filter/group-results.ts new file mode 100644 index 000000000..77631f33c --- /dev/null +++ b/plugins/global-search/src/utils/filter/group-results.ts @@ -0,0 +1,30 @@ +import type { IndexEntry, RootNodeType } from "../indexer/types" +import type { ReadonlyRecord } from "../object" +import type { Result } from "./types" + +export type GroupedResults = { [type in RootNodeType]?: Record } +export type ReadonlyGroupedResults = { + readonly [type in RootNodeType]?: ReadonlyRecord +} + +/** Groups the results by root node type and the result's entry id. */ +export function groupResults(items: readonly Result[]): ReadonlyGroupedResults { + const grouped: GroupedResults = {} + for (const item of items) { + let rootNodeGroup = grouped[item.entry.rootNodeType] + if (!rootNodeGroup) { + rootNodeGroup = {} + grouped[item.entry.rootNodeType] = rootNodeGroup + } + + let rootNode = rootNodeGroup[item.entry.rootNode.id] + if (!rootNode) { + rootNode = [] + rootNodeGroup[item.entry.rootNode.id] = rootNode + } + + rootNode.push(item) + } + + return grouped +} diff --git a/plugins/global-search/src/utils/filter/ranges.test.ts b/plugins/global-search/src/utils/filter/ranges.test.ts new file mode 100644 index 000000000..70bc2e7bd --- /dev/null +++ b/plugins/global-search/src/utils/filter/ranges.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" +import { findRanges } from "./ranges" + +describe("findRanges", () => { + it("should return an empty array if the text is empty", () => { + expect(findRanges("", "test", false)).toEqual([]) + }) + + it("should have a match of the query in the text", () => { + expect(findRanges("I'm not sure all these people understand", "people", false)).toEqual([[23, 29]]) + }) + + it("should return multiple matches of the query in the text", () => { + expect(findRanges("Nightswimming deserves a quiet night\nDeserves a quiet night", "quiet", false)).toEqual([ + [25, 30], + [48, 53], + ]) + }) + + it("should not return a match if the (case-sensitive) query is not in the text", () => { + expect(findRanges("September's coming soon", "COMING", true)).toEqual([]) + }) + + it("should return a match if the (case-insensitive) query is in the text", () => { + expect(findRanges("I'm pining for the moon", "MOON", false)).toEqual([[19, 23]]) + }) +}) diff --git a/plugins/global-search/src/utils/filter/ranges.ts b/plugins/global-search/src/utils/filter/ranges.ts new file mode 100644 index 000000000..5f93b03ec --- /dev/null +++ b/plugins/global-search/src/utils/filter/ranges.ts @@ -0,0 +1,28 @@ +export type Range = readonly [start: number, end: number] + +function indicesOf(haystack: string, needle: string): number[] { + if (haystack.length === 0 || needle.length === 0) return [] + + const indices: number[] = [] + + let index = haystack.indexOf(needle) + + while (index !== -1) { + indices.push(index) + index = haystack.indexOf(needle, index + needle.length) + } + + return indices +} + +export function findRanges(text: string, query: string, isCaseSensitive: boolean): readonly Range[] { + if (!text || !query) return [] + + const haystack = isCaseSensitive ? text : text.toLowerCase() + const needle = isCaseSensitive ? query : query.toLowerCase() + + const indices = indicesOf(haystack, needle) + if (indices.length === 0) return [] + + return indices.map((start): Range => [start, start + needle.length]) +} diff --git a/plugins/global-search/src/utils/filter/types.ts b/plugins/global-search/src/utils/filter/types.ts new file mode 100644 index 000000000..6881ecf4e --- /dev/null +++ b/plugins/global-search/src/utils/filter/types.ts @@ -0,0 +1,52 @@ +import type { IndexCollectionItemEntry, IndexEntry, IndexNodeEntry, RootNodeType } from "../indexer/types" +import type { Range } from "./ranges" + +export const enum FilterType { + RootNodes = "rootNodes", +} + +export const enum MatcherType { + Text = "text", +} + +// A matcher produces a result, while a filter can narrows down the results +export interface TextMatcher { + readonly type: MatcherType.Text + readonly query: string + readonly caseSensitive: boolean +} + +export interface RootNodesFilter { + readonly type: FilterType.RootNodes + readonly rootNodes: readonly RootNodeType[] +} + +export type Matcher = TextMatcher +export type Filter = RootNodesFilter + +export enum ResultType { + CollectionItem = "CollectionItem", + Node = "Node", +} + +export interface BaseResult { + readonly type: ResultType + readonly id: string + readonly text: string + readonly ranges: readonly Range[] + readonly entry: IndexEntry +} + +export interface CollectionItemResult extends BaseResult { + readonly type: ResultType.CollectionItem + readonly field: keyof IndexCollectionItemEntry["fields"] + readonly text: string + readonly entry: IndexCollectionItemEntry +} + +export interface NodeResult extends BaseResult { + readonly type: ResultType.Node + readonly entry: IndexNodeEntry +} + +export type Result = CollectionItemResult | NodeResult diff --git a/plugins/global-search/src/utils/filter/useFilter.ts b/plugins/global-search/src/utils/filter/useFilter.ts new file mode 100644 index 000000000..12fe02d24 --- /dev/null +++ b/plugins/global-search/src/utils/filter/useFilter.ts @@ -0,0 +1,34 @@ +import { useDeferredValue, useMemo } from "react" +import type { IndexEntry, RootNodeType } from "../indexer/types" +import { executeFilters } from "./execute-filter" +import type { ReadonlyGroupedResults } from "./group-results" +import { groupResults } from "./group-results" +import { type Filter, FilterType, type Matcher, MatcherType } from "./types" + +export function useFilter( + query: string, + searchOptions: readonly RootNodeType[], + index: readonly IndexEntry[] +): { + results: ReadonlyGroupedResults +} { + const deferredQuery = useDeferredValue(query) + + const matchers = useMemo((): readonly Matcher[] => { + return [{ type: MatcherType.Text, query: deferredQuery, caseSensitive: false }] + }, [deferredQuery]) + + const filters = useMemo((): readonly Filter[] => { + return [{ type: FilterType.RootNodes, rootNodes: searchOptions }] + }, [searchOptions]) + + const results = useMemo(() => { + const items = executeFilters(matchers, filters, index) + + return groupResults(items) + }, [matchers, filters, index]) + + return { + results, + } +} diff --git a/plugins/global-search/src/utils/indexer/indexer.ts b/plugins/global-search/src/utils/indexer/indexer.ts index 137dde3bc..8cb459d8c 100644 --- a/plugins/global-search/src/utils/indexer/indexer.ts +++ b/plugins/global-search/src/utils/indexer/indexer.ts @@ -9,7 +9,7 @@ import { } from "framer-plugin" import { type EventMap, TypedEventEmitter } from "../event-emitter" import { stripMarkup } from "./strip-markup" -import { type IndexEntry, includedAttributes, type RootNode, shouldIndexNode } from "./types" +import { type IndexEntry, type IndexNodeRootNode, includedAttributes, shouldIndexNode } from "./types" async function getNodeName(node: AnyNode): Promise { if (isWebPageNode(node)) { @@ -66,7 +66,7 @@ export class GlobalSearchIndexer { return null } - private async *crawlNodes(rootNodes: readonly RootNode[]): AsyncGenerator { + private async *crawlNodes(rootNodes: readonly IndexNodeRootNode[]): AsyncGenerator { let batch: IndexEntry[] = [] for (const rootNode of rootNodes) { diff --git a/plugins/global-search/src/utils/indexer/types.ts b/plugins/global-search/src/utils/indexer/types.ts index d31a040a5..32f5b5c5a 100644 --- a/plugins/global-search/src/utils/indexer/types.ts +++ b/plugins/global-search/src/utils/indexer/types.ts @@ -12,38 +12,41 @@ import { WebPageNode, } from "framer-plugin" -export type RootNode = ComponentNode | WebPageNode -export type RootNodeType = RootNode["__class"] +export type IndexNodeRootNode = ComponentNode | WebPageNode +export type IndexNodeRootNodeType = IndexNodeRootNode["__class"] export type IndexEntryType = AnyNode["__class"] interface IndexEntryBase { - id: string - type: string + readonly id: string + readonly type: string + readonly rootNode: IndexNodeRootNode | Collection } export interface IndexNodeEntry extends IndexEntryBase { - type: IndexEntryType - node: AnyNode - rootNodeName: string | null - rootNode: RootNode - rootNodeType: RootNodeType - text: string | null - name: string | null + readonly type: IndexEntryType + readonly node: AnyNode + readonly rootNodeName: string | null + readonly rootNode: IndexNodeRootNode + readonly rootNodeType: IndexNodeRootNodeType + readonly text: string | null + readonly name: string | null } export interface IndexCollectionItemEntry extends IndexEntryBase { - type: "CollectionItem" - collectionItem: CollectionItem - rootNodeName: string - rootNode: Collection - rootNodeType: "Collection" - slug: string - fields: Record + readonly type: "CollectionItem" + readonly collectionItem: CollectionItem + readonly rootNodeName: string + readonly rootNode: Collection + readonly rootNodeType: "Collection" + readonly slug: string + readonly fields: Record } export type IndexEntry = IndexNodeEntry | IndexCollectionItemEntry +export type RootNodeType = IndexEntry["rootNodeType"] + export const includedAttributes = ["text"] as const export type IncludedAttribute = (typeof includedAttributes)[number] diff --git a/plugins/global-search/src/utils/object.ts b/plugins/global-search/src/utils/object.ts new file mode 100644 index 000000000..c5a82841a --- /dev/null +++ b/plugins/global-search/src/utils/object.ts @@ -0,0 +1,6 @@ +/** Type-respecting `Object.entries` */ +export function entries(object: T): [keyof T, T[keyof T]][] { + return Object.entries(object) as [keyof T, T[keyof T]][] +} + +export type ReadonlyRecord = { readonly [key in K]: V }