Skip to content

Commit 40ec284

Browse files
authored
Merge pull request framer#370 from framer/global-search/init-indexer
Global search: Indexer
2 parents b0cad3f + 815e0ea commit 40ec284

21 files changed

Lines changed: 694 additions & 85 deletions
-11.8 KB
Binary file not shown.
-151 KB
Binary file not shown.
-1.12 MB
Binary file not shown.
-49.6 KB
Binary file not shown.
-22.3 KB
Binary file not shown.

plugins/global-search/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@
1616
"dependencies": {
1717
"clsx": "^2.1.1",
1818
"framer-plugin": "^3.5.2",
19-
"react": "^19.1.1",
20-
"react-dom": "^19.1.1",
19+
"react": "^18.3.1",
20+
"react-dom": "^18.3.1",
21+
"react-error-boundary": "^6.0.0",
2122
"tailwind-merge": "^3.3.1"
2223
},
2324
"devDependencies": {
2425
"@testing-library/dom": "^10.4.0",
2526
"@testing-library/jest-dom": "^6.6.3",
2627
"@testing-library/react": "^16.3.0",
2728
"@testing-library/user-event": "^14.6.1",
28-
"@types/react": "^19.1.9",
29-
"@types/react-dom": "^19.1.7",
29+
"@types/react": "^18.3.23",
30+
"@types/react-dom": "^18.3.7",
3031
"@vitest/ui": "^3.2.4",
3132
"happy-dom": "^18.0.1",
3233
"vitest": "^3.2.4"

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: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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.id === filterQuery) return true
19+
switch (entry.type) {
20+
case "CollectionItem":
21+
return Object.values(entry.fields).some(field =>
22+
field.toLowerCase().includes(filterQuery.toLowerCase())
23+
)
24+
default:
25+
return entry.name?.toLowerCase().includes(filterQuery.toLowerCase())
26+
}
27+
}),
28+
[entries, filterQuery]
29+
)
30+
31+
const stats = useMemo(
32+
() => ({
33+
total: entries.length,
34+
byType: entries.reduce(
35+
(acc, entry) => {
36+
acc[entry.type] = (acc[entry.type] ?? 0) + 1
37+
return acc
38+
},
39+
{} as Record<string, number>
40+
),
41+
}),
42+
[entries]
43+
)
44+
45+
return (
46+
<div className="flex flex-col h-full">
47+
<div className="flex flex-col gap-2 p-2">
48+
<div className=" flex gap-1">
49+
<input
50+
type="text"
51+
placeholder="Filter by name or id"
52+
value={filterQuery}
53+
onChange={e => setFilterQuery(e.target.value)}
54+
className="w-full p-2 border border-gray-300 rounded flex-1"
55+
/>
56+
<button
57+
onClick={() => indexerInstance.restart()}
58+
disabled={isIndexing}
59+
className="px-3 py-1 bg-blue-500 text-white text-sm rounded disabled:opacity-50 w-auto"
60+
>
61+
{isIndexing ? "Indexing..." : "Re-index"}
62+
</button>
63+
</div>
64+
<div className="text-xs text-gray-600 gap-x-1 flex space-between">
65+
{Object.entries(stats.byType).map(([type, count]) => (
66+
<span key={type} className="bg-gray-100 px-2 py-1 rounded block">
67+
{type}: {count}
68+
</span>
69+
))}
70+
</div>
71+
</div>
72+
73+
<div className="flex-1 flex overflow-hidden border-t border-t-framer-divider">
74+
<div className="w-1/3 max-w-sm border-r border-r-framer-divider overflow-auto">
75+
<div className="divide-y">
76+
{filteredEntries.map(entry => (
77+
<div
78+
key={`${entry.id}-${entry.type}`}
79+
className={cn(
80+
"p-3 cursor-pointer hover:bg-gray-50",
81+
selectedEntry === entry && "bg-blue-50"
82+
)}
83+
onClick={() => setSelectedEntry(entry)}
84+
>
85+
<div className="flex items-start justify-between">
86+
<div className="flex-1 min-w-0">
87+
<p className="text-sm font-medium text-gray-900 truncate">
88+
{entry.type === "CollectionItem"
89+
? `${entry.rootNodeName} - ${entry.slug}`
90+
: entry.name || "Unnamed"}
91+
</p>
92+
<p className="text-xs text-gray-500">{entry.type}</p>
93+
</div>
94+
</div>
95+
</div>
96+
))}
97+
</div>
98+
</div>
99+
100+
{/* Detail Panel */}
101+
<div className="flex-1 overflow-auto">
102+
{selectedEntry ? (
103+
<div className="p-4 space-y-4">
104+
<h3 className="font-semibold">Entry Details</h3>
105+
106+
<div className="space-y-3">
107+
<div>
108+
<label className="block text-xs font-medium text-gray-700 mb-1">ID</label>
109+
<code className="text-xs bg-gray-100 p-2 rounded block font-mono">
110+
{selectedEntry.id}
111+
</code>
112+
</div>
113+
114+
<div>
115+
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
116+
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
117+
{selectedEntry.type}
118+
</span>
119+
</div>
120+
121+
<div>
122+
<label className="block text-xs font-medium text-gray-700 mb-1">Name</label>
123+
<p className="text-sm bg-gray-50 p-2 rounded">
124+
{selectedEntry.type === "CollectionItem"
125+
? "Collection Item"
126+
: selectedEntry.name || "(no name)"}
127+
</p>
128+
</div>
129+
130+
{selectedEntry.type !== "CollectionItem" && (
131+
<div>
132+
<label className="block text-xs font-medium text-gray-700 mb-1">
133+
Text Content
134+
</label>
135+
<p className="text-sm bg-gray-50 p-2 rounded whitespace-pre-wrap">
136+
{selectedEntry.text}
137+
</p>
138+
</div>
139+
)}
140+
141+
{selectedEntry.type === "CollectionItem" && (
142+
<div>
143+
<label className="block text-xs font-medium text-gray-700 mb-1">Fields</label>
144+
<pre className="text-sm bg-gray-50 p-2 rounded whitespace-pre-wrap">
145+
{JSON.stringify(selectedEntry.fields, null, 2)}
146+
</pre>
147+
</div>
148+
)}
149+
150+
<div>
151+
<label className="block text-xs font-medium text-gray-700 mb-1">Root Node</label>
152+
<p className="text-sm bg-gray-50 p-2 rounded">
153+
{selectedEntry.rootNodeName} ({selectedEntry.rootNodeType})
154+
</p>
155+
</div>
156+
157+
<div>
158+
<label className="block text-xs font-medium text-gray-700 mb-1">
159+
Raw Node Data
160+
</label>
161+
<details className="text-xs">
162+
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">
163+
Show raw node object
164+
</summary>
165+
<pre className="mt-2 bg-gray-100 p-2 rounded overflow-auto text-xs">
166+
{JSON.stringify(
167+
{
168+
...(selectedEntry.type === "CollectionItem"
169+
? selectedEntry.collectionItem
170+
: selectedEntry.node),
171+
},
172+
null,
173+
2
174+
)}
175+
</pre>
176+
</details>
177+
</div>
178+
</div>
179+
</div>
180+
) : (
181+
<div className="p-4 text-center text-gray-500">Select an entry to view details</div>
182+
)}
183+
</div>
184+
</div>
185+
</div>
186+
)
187+
}

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 {

0 commit comments

Comments
 (0)