Skip to content

Commit e2fd50d

Browse files
committed
feat(global-search): adding indexer
1 parent b0cad3f commit e2fd50d

14 files changed

Lines changed: 614 additions & 34 deletions

File tree

plugins/global-search/src/App.tsx

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,45 @@
1-
import { type CanvasNode, framer } from "framer-plugin"
1+
import { framer } from "framer-plugin"
22
import { useEffect, useState } from "react"
3-
import "./styles.css"
3+
import { ErrorBoundary } from "react-error-boundary"
4+
import { DevToolsScene } from "./components/DevToolsScene"
5+
import { IndexerProvider } from "./utils/indexer/IndexerProvider"
46

57
framer.showUI({
68
position: "top right",
7-
width: 240,
8-
height: 95,
9+
width: 400,
10+
height: 600,
11+
resizable: true,
912
})
1013

11-
function useSelection() {
12-
const [selection, setSelection] = useState<CanvasNode[]>([])
13-
14-
useEffect(() => {
15-
return framer.subscribeToSelection(setSelection)
16-
}, [])
17-
18-
return selection
19-
}
20-
2114
export function App() {
22-
const selection = useSelection()
23-
const layer = selection.length === 1 ? "layer" : "layers"
15+
const [activeScene, setActiveScene] = useState<"search" | "dev-tools">("search")
2416

25-
const handleAddSvg = async () => {
26-
await framer.addSVG({
27-
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="#999" d="M20 0v8h-8L4 0ZM4 8h8l8 8h-8v8l-8-8Z"/></svg>`,
28-
name: "Logo.svg",
29-
})
30-
}
17+
useEffect(() => {
18+
framer.setMenu([
19+
{
20+
label: "Open Dev Tools",
21+
onAction: () => setActiveScene(state => (state === "dev-tools" ? "search" : "dev-tools")),
22+
checked: activeScene === "dev-tools",
23+
},
24+
])
25+
})
3126

3227
return (
33-
<main className="mx-4 space-y-2">
34-
<p>
35-
Welcome! Check out the{" "}
36-
<a href="https://framer.com/developers/plugins/introduction" target="_blank">
37-
Docs
38-
</a>{" "}
39-
to start. You have {selection.length} {layer} selected.
40-
</p>
41-
<button className="framer-button-primary" onClick={handleAddSvg}>
42-
Insert Logo
43-
</button>
44-
</main>
28+
<ErrorBoundary
29+
fallbackRender={({ error, resetErrorBoundary }) => (
30+
<div>
31+
{error.message}
32+
<button onClick={resetErrorBoundary}>Reset</button>
33+
</div>
34+
)}
35+
>
36+
<IndexerProvider>
37+
{activeScene === "dev-tools" ? (
38+
<DevToolsScene />
39+
) : (
40+
<div>Welcome! This is under development. Select "Open Dev Tools" to get started.</div>
41+
)}
42+
</IndexerProvider>
43+
</ErrorBoundary>
4544
)
4645
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { useMemo, useState } from "react"
2+
import { cn } from "../utils/className"
3+
import { type IndexEntry } from "../utils/indexer/types"
4+
import { useIndexer } from "../utils/indexer/useIndexer"
5+
6+
export function DevToolsScene() {
7+
const { index, isIndexing, indexerInstance } = useIndexer()
8+
9+
const [selectedEntry, setSelectedEntry] = useState<IndexEntry | null>(null)
10+
11+
const [filterQuery, setFilterQuery] = useState("")
12+
13+
const entries = useMemo(() => Object.values(index), [index])
14+
15+
const filteredEntries = useMemo(
16+
() =>
17+
entries.filter(entry => {
18+
if (entry.node.id === filterQuery) return true
19+
return entry.name?.toLowerCase().includes(filterQuery.toLowerCase())
20+
}),
21+
[entries, filterQuery]
22+
)
23+
24+
const stats = useMemo(
25+
() => ({
26+
total: entries.length,
27+
byType: entries.reduce(
28+
(acc, entry) => {
29+
acc[entry.type] = (acc[entry.type] ?? 0) + 1
30+
return acc
31+
},
32+
{} as Record<string, number>
33+
),
34+
withText: entries.filter(e => e.text).length,
35+
}),
36+
[entries]
37+
)
38+
39+
return (
40+
<div className="flex flex-col h-full">
41+
<div className="flex flex-col gap-2 p-2">
42+
<div className=" flex gap-1">
43+
<input
44+
type="text"
45+
placeholder="Filter by name or id"
46+
value={filterQuery}
47+
onChange={e => setFilterQuery(e.target.value)}
48+
className="w-full p-2 border border-gray-300 rounded flex-1"
49+
/>
50+
<button
51+
onClick={() => indexerInstance.restart()}
52+
disabled={isIndexing}
53+
className="px-3 py-1 bg-blue-500 text-white text-sm rounded disabled:opacity-50 w-auto"
54+
>
55+
{isIndexing ? "Indexing..." : "Re-index"}
56+
</button>
57+
</div>
58+
<div className="text-xs text-gray-600 gap-x-1 flex space-between">
59+
{Object.entries(stats.byType).map(([type, count]) => (
60+
<span key={type} className="bg-gray-100 px-2 py-1 rounded block">
61+
{type}: {count}
62+
</span>
63+
))}
64+
</div>
65+
</div>
66+
67+
<div className="flex-1 flex overflow-hidden border-t border-t-framer-divider">
68+
<div className="w-1/3 max-w-sm border-r border-r-framer-divider overflow-auto">
69+
<div className="divide-y">
70+
{filteredEntries.map(entry => (
71+
<div
72+
key={`${entry.id}-${entry.type}`}
73+
className={cn(
74+
"p-3 cursor-pointer hover:bg-gray-50",
75+
selectedEntry === entry && "bg-blue-50"
76+
)}
77+
onClick={() => setSelectedEntry(entry)}
78+
>
79+
<div className="flex items-start justify-between">
80+
<div className="flex-1 min-w-0">
81+
<p className="text-sm font-medium text-gray-900 truncate">
82+
{entry.name || "Unnamed"}
83+
</p>
84+
<p className="text-xs text-gray-500">{entry.type}</p>
85+
{entry.text && (
86+
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{entry.text}</p>
87+
)}
88+
</div>
89+
</div>
90+
</div>
91+
))}
92+
</div>
93+
</div>
94+
95+
{/* Detail Panel */}
96+
<div className="flex-1 overflow-auto">
97+
{selectedEntry ? (
98+
<div className="p-4 space-y-4">
99+
<h3 className="font-semibold">Entry Details</h3>
100+
101+
<div className="space-y-3">
102+
<div>
103+
<label className="block text-xs font-medium text-gray-700 mb-1">ID</label>
104+
<code className="text-xs bg-gray-100 p-2 rounded block font-mono">
105+
{selectedEntry.id}
106+
</code>
107+
</div>
108+
109+
<div>
110+
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
111+
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
112+
{selectedEntry.type}
113+
</span>
114+
</div>
115+
116+
<div>
117+
<label className="block text-xs font-medium text-gray-700 mb-1">Name</label>
118+
<p className="text-sm bg-gray-50 p-2 rounded">
119+
{selectedEntry.name || "(no name)"}
120+
</p>
121+
</div>
122+
123+
{selectedEntry.text && (
124+
<div>
125+
<label className="block text-xs font-medium text-gray-700 mb-1">
126+
Text Content
127+
</label>
128+
<p className="text-sm bg-gray-50 p-2 rounded whitespace-pre-wrap">
129+
{selectedEntry.text}
130+
</p>
131+
</div>
132+
)}
133+
134+
<div>
135+
<label className="block text-xs font-medium text-gray-700 mb-1">Root Node</label>
136+
<p className="text-sm bg-gray-50 p-2 rounded">
137+
{selectedEntry.rootNodeName} ({selectedEntry.rootNodeType})
138+
</p>
139+
</div>
140+
141+
<div>
142+
<label className="block text-xs font-medium text-gray-700 mb-1">
143+
Raw Node Data
144+
</label>
145+
<details className="text-xs">
146+
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">
147+
Show raw node object
148+
</summary>
149+
<pre className="mt-2 bg-gray-100 p-2 rounded overflow-auto text-xs">
150+
{JSON.stringify(
151+
{
152+
...selectedEntry.node,
153+
// Limit some potentially large properties
154+
...(selectedEntry.node.__class && {
155+
__class: selectedEntry.node.__class,
156+
}),
157+
...(selectedEntry.node.id && { id: selectedEntry.node.id }),
158+
...("name" in selectedEntry.node && {
159+
name: selectedEntry.node.name,
160+
}),
161+
},
162+
null,
163+
2
164+
)}
165+
</pre>
166+
</details>
167+
</div>
168+
</div>
169+
</div>
170+
) : (
171+
<div className="p-4 text-center text-gray-500">Select an entry to view details</div>
172+
)}
173+
</div>
174+
</div>
175+
</div>
176+
)
177+
}

plugins/global-search/src/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { StrictMode } from "react"
22
import { createRoot } from "react-dom/client"
33
import { App } from "./App"
4+
import "./styles.css"
45

56
const root = document.getElementById("root")
67
if (!root) throw new Error("Root element not found")

plugins/global-search/src/styles.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
textarea,
4848
select {
4949
padding: initial;
50+
background-color: initial;
51+
border: initial;
52+
outline: initial;
5053
}
5154

5255
::selection {

plugins/global-search/src/test-setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ vi.mock("framer-plugin", () => ({
88
framer: {
99
showUI: vi.fn(),
1010
subscribeToSelection: vi.fn(),
11+
setMenu: vi.fn(),
1112
},
1213
}))
1314

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { type ClassValue, clsx } from "clsx"
2+
import { twMerge } from "tailwind-merge"
3+
4+
export function cn(...inputs: ClassValue[]) {
5+
return twMerge(clsx(inputs))
6+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { startTransition, useEffect, useMemo, useRef, useState } from "react"
2+
import { IndexerContext } from "./context"
3+
import type { IndexerEvents } from "./indexer"
4+
import { GlobalSearchIndexer } from "./indexer"
5+
import type { IndexEntry } from "./types"
6+
7+
/**
8+
* Creates an indexer instance and provides it to the children.
9+
* Manages the index in a React state, so that it can use reactivity to show the results
10+
*/
11+
export function IndexerProvider({ children }: { children: React.ReactNode }) {
12+
const indexerRef = useRef<GlobalSearchIndexer>()
13+
if (!indexerRef.current) {
14+
indexerRef.current = new GlobalSearchIndexer()
15+
}
16+
const indexer = indexerRef.current
17+
const [isIndexing, setIsIndexing] = useState(false)
18+
const [index, setIndex] = useState<Record<string, IndexEntry>>({})
19+
20+
useEffect(() => {
21+
indexer.start()
22+
23+
const onUpsert = ({ entry }: IndexerEvents["upsert"]) =>
24+
startTransition(() => {
25+
setIndex(prev => ({ ...prev, [entry.id]: entry }))
26+
})
27+
28+
const onStarted = () =>
29+
startTransition(() => {
30+
setIndex({})
31+
setIsIndexing(true)
32+
})
33+
34+
const onCompleted = () =>
35+
startTransition(() => {
36+
setIsIndexing(false)
37+
})
38+
39+
const onAborted = () =>
40+
startTransition(() => {
41+
setIsIndexing(false)
42+
})
43+
44+
const onError = ({ error }: IndexerEvents["error"]) =>
45+
startTransition(() => {
46+
setIsIndexing(false)
47+
setIndex({})
48+
console.error(error)
49+
})
50+
51+
const onRestarted = () =>
52+
startTransition(() => {
53+
setIsIndexing(true)
54+
})
55+
56+
const unsubscribes = [
57+
indexer.on("upsert", onUpsert),
58+
indexer.on("restarted", onRestarted),
59+
indexer.on("aborted", onAborted),
60+
indexer.on("started", onStarted),
61+
indexer.on("completed", onCompleted),
62+
indexer.on("error", onError),
63+
]
64+
65+
return () => {
66+
for (const unsubscribe of unsubscribes) unsubscribe()
67+
}
68+
}, [indexer])
69+
70+
const data = useMemo(
71+
() => ({ isIndexing, index: Object.values(index), indexerInstance: indexer }),
72+
[isIndexing, index, indexer]
73+
)
74+
75+
return <IndexerContext.Provider value={data}>{children}</IndexerContext.Provider>
76+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createContext } from "react"
2+
import { GlobalSearchIndexer } from "./indexer"
3+
import type { IndexEntry } from "./types"
4+
5+
export const IndexerContext = createContext<{
6+
isIndexing: boolean
7+
index: readonly IndexEntry[]
8+
indexerInstance: GlobalSearchIndexer
9+
} | null>(null)

0 commit comments

Comments
 (0)