mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:48:29 +00:00
Feat: browser extension (#27)
This commit is contained in:
parent
7bf1bd9128
commit
a60dc20bf2
37
.github/workflows/submit.yml
vendored
Normal file
37
.github/workflows/submit.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: "Submit to Web Store"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/extension
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Cache pnpm modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: latest
|
||||
run_install: true
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: 16.x
|
||||
cache: "pnpm"
|
||||
- name: Build the extension
|
||||
run: pnpm build
|
||||
- name: Package the extension into a zip artifact
|
||||
run: pnpm package
|
||||
- name: Browser Platform Publish
|
||||
uses: PlasmoHQ/bpp@v3
|
||||
with:
|
||||
keys: ${{ secrets.SUBMIT_KEYS }}
|
||||
artifact: build/chrome-mv3-prod.zip
|
||||
33
apps/extension/.gitignore
vendored
Normal file
33
apps/extension/.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# plasmo
|
||||
.plasmo
|
||||
|
||||
# typescript
|
||||
.tsbuildinfo
|
||||
26
apps/extension/.prettierrc.mjs
Normal file
26
apps/extension/.prettierrc.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @type {import('prettier').Options}
|
||||
*/
|
||||
export default {
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: false,
|
||||
singleQuote: false,
|
||||
trailingComma: "none",
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: true,
|
||||
plugins: ["@ianvs/prettier-plugin-sort-imports"],
|
||||
importOrder: [
|
||||
"<BUILTIN_MODULES>", // Node.js built-in modules
|
||||
"<THIRD_PARTY_MODULES>", // Imports not matched by other special words or groups.
|
||||
"", // Empty line
|
||||
"^@plasmo/(.*)$",
|
||||
"",
|
||||
"^@plasmohq/(.*)$",
|
||||
"",
|
||||
"^~(.*)$",
|
||||
"",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
33
apps/extension/README.md
Normal file
33
apps/extension/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
This is a [Plasmo extension](https://docs.plasmo.com/) project bootstrapped with [`plasmo init`](https://www.npmjs.com/package/plasmo).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open your browser and load the appropriate development build. For example, if you are developing for the chrome browser, using manifest v3, use: `build/chrome-mv3-dev`.
|
||||
|
||||
You can start editing the popup by modifying `popup.tsx`. It should auto-update as you make changes. To add an options page, simply add a `options.tsx` file to the root of the project, with a react component default exported. Likewise to add a content page, add a `content.ts` file to the root of the project, importing some module and do some logic, then reload the extension on your browser.
|
||||
|
||||
For further guidance, [visit our Documentation](https://docs.plasmo.com/)
|
||||
|
||||
## Making production build
|
||||
|
||||
Run the following:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# or
|
||||
npm run build
|
||||
```
|
||||
|
||||
This should create a production bundle for your extension, ready to be zipped and published to the stores.
|
||||
|
||||
## Submit to the webstores
|
||||
|
||||
The easiest way to deploy your Plasmo extension is to use the built-in [bpp](https://bpp.browser.market) GitHub action. Prior to using this action however, make sure to build your extension and upload the first version to the store to establish the basic credentials. Then, simply follow [this setup instruction](https://docs.plasmo.com/framework/workflows/submit) and you should be on your way for automated submission!
|
||||
BIN
apps/extension/assets/icon.png
Normal file
BIN
apps/extension/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
129
apps/extension/components/InputSuggestionsPopup.tsx
Normal file
129
apps/extension/components/InputSuggestionsPopup.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
interface InputSuggestionsPopupProps {
|
||||
facts: string[]
|
||||
isLoading: boolean
|
||||
onFactSelect: (fact: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function InputSuggestionsPopup({
|
||||
facts,
|
||||
isLoading,
|
||||
onFactSelect,
|
||||
onClose
|
||||
}: InputSuggestionsPopupProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
|
||||
// Auto-hide after 10 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, 10000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
|
||||
fontSize: "14px",
|
||||
maxWidth: "400px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto"
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
borderBottom: "1px solid #eee",
|
||||
paddingBottom: "8px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}>
|
||||
<span>AI Suggestions</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#6B7280",
|
||||
cursor: "pointer",
|
||||
fontSize: "20px",
|
||||
padding: "0",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
padding: "20px"
|
||||
}}>
|
||||
Searching for relevant facts...
|
||||
</div>
|
||||
) : facts.length > 0 ? (
|
||||
facts.slice(0, 5).map((fact, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onFactSelect(fact)}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
margin: "4px 0",
|
||||
background: hoveredIndex === index ? "#e9ecef" : "#f8f9fa",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
border: "1px solid #e9ecef",
|
||||
transition: "background-color 0.2s"
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "#333",
|
||||
lineHeight: "1.4"
|
||||
}}>
|
||||
{fact}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
padding: "20px"
|
||||
}}>
|
||||
No relevant suggestions found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
apps/extension/components/TextSelectionPopup.tsx
Normal file
166
apps/extension/components/TextSelectionPopup.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
interface TextSelectionPopupProps {
|
||||
selectedText: string
|
||||
onSave: (text: string) => Promise<void>
|
||||
onClose: () => void
|
||||
isSaving: boolean
|
||||
saveStatus: "idle" | "saving" | "success" | "error" | "empty"
|
||||
}
|
||||
|
||||
export default function TextSelectionPopup({
|
||||
selectedText,
|
||||
onSave,
|
||||
onClose,
|
||||
isSaving,
|
||||
saveStatus
|
||||
}: TextSelectionPopupProps) {
|
||||
const [text, setText] = useState(selectedText)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Focus textarea on mount
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
const textToSave = text.trim()
|
||||
if (!textToSave) {
|
||||
return
|
||||
}
|
||||
await onSave(textToSave)
|
||||
}
|
||||
|
||||
const getButtonText = () => {
|
||||
switch (saveStatus) {
|
||||
case "saving":
|
||||
return "Saving..."
|
||||
case "success":
|
||||
return "Saved!"
|
||||
case "error":
|
||||
return "Error"
|
||||
case "empty":
|
||||
return "No text to save"
|
||||
default:
|
||||
return "Add to Memory"
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonColor = () => {
|
||||
switch (saveStatus) {
|
||||
case "success":
|
||||
return "#10B981"
|
||||
case "error":
|
||||
case "empty":
|
||||
return "#EF4444"
|
||||
default:
|
||||
return "#c15e50"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
|
||||
fontSize: "14px",
|
||||
width: "350px"
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
borderBottom: "1px solid #eee",
|
||||
paddingBottom: "8px"
|
||||
}}>
|
||||
Add to Core Memory
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder="Edit the text you want to save to memory..."
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "80px",
|
||||
maxHeight: "150px",
|
||||
resize: "vertical",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "#f8f9fa",
|
||||
borderRadius: "4px",
|
||||
padding: "8px",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.4",
|
||||
color: "#333",
|
||||
marginBottom: "12px",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !text.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: getButtonColor(),
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "4px",
|
||||
cursor: isSaving || !text.trim() ? "not-allowed" : "pointer",
|
||||
fontSize: "13px",
|
||||
opacity: isSaving || !text.trim() ? 0.6 : 1
|
||||
}}>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "#6B7280",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px"
|
||||
}}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
260
apps/extension/components/logo.tsx
Normal file
260
apps/extension/components/logo.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
export interface LogoProps {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function StaticLogo({ width, height }: LogoProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 282 282"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="95.4357"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.591888 0.80602 -0.783494 0.6214 77.3574 37.2551)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<path
|
||||
d="M49.1309 84.7972L136.212 176.505"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="90.8224"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.814972 0.5795 -0.549892 0.835235 32.5566 143.53)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="146.141"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.686415 -0.72721 0.700262 0.713886 35.4785 139.482)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="77.4689"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.528012 -0.849237 0.830187 0.557485 49.1133 196.162)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="111.328"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.979793 0.200014 -0.185721 -0.982602 135.791 117.215)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="58.5744"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.532065 -0.846704 0.827428 0.561572 81.252 246.769)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="64.5426"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.503861 -0.863784 0.846088 -0.533044 137.443 252.884)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="45.7129"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.0852204 0.996362 -0.99576 -0.0919863 110.471 150.615)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="87.7453"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.998935 -0.0461398 0.0427267 0.999087 49.1133 198.186)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="64.6589"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.165686 0.986178 -0.983932 -0.178541 100.73 66.6073)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle cx="103.955" cy="66.3968" r="11.0444" fill="#C15E50" />
|
||||
<circle cx="90.4996" cy="129.403" r="11.5465" fill="#C15E50" />
|
||||
<circle cx="103.955" cy="197.449" r="11.0444" fill="#C15E50" />
|
||||
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="#C15E50" />
|
||||
<path
|
||||
d="M86.0294 34.6113C86.0294 38.7701 82.6579 42.1416 78.4991 42.1416C74.3402 42.1416 70.9688 38.7701 70.9688 34.6113C70.9688 30.4524 74.3402 27.081 78.4991 27.081C82.6579 27.081 86.0294 30.4524 86.0294 34.6113Z"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle cx="29.0525" cy="140.996" r="13.0525" fill="#C15E50" />
|
||||
<circle cx="79.0009" cy="246.875" r="7.02828" fill="#C15E50" />
|
||||
<path
|
||||
d="M53.0314 195.433C53.0314 199.869 49.4352 203.466 44.9991 203.466C40.563 203.466 36.9668 199.869 36.9668 195.433C36.9668 190.997 40.563 187.401 44.9991 187.401C49.4352 187.401 53.0314 190.997 53.0314 195.433Z"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<path
|
||||
d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="95.4357"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.591888 -0.80602 0.783494 -0.6214 205.531 244.745)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<path
|
||||
d="M233.758 197.203L146.677 105.495"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="90.8224"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.814972 -0.5795 0.549892 -0.835235 250.332 138.47)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="146.141"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.686415 0.72721 -0.700262 -0.713886 247.41 142.518)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="77.4689"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.528012 0.849237 -0.830187 -0.557485 233.775 85.838)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="111.328"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.979793 -0.200014 0.185721 0.982602 147.098 164.785)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="58.5744"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.532065 0.846704 -0.827428 -0.561572 201.637 35.2307)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="64.5426"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.503861 0.863784 -0.846088 0.533044 145.445 29.1161)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="45.7129"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.0852204 -0.996362 0.99576 0.0919863 172.418 131.385)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="87.7453"
|
||||
y2="-2.5"
|
||||
transform="matrix(-0.998935 0.0461398 -0.0427267 -0.999087 233.775 83.8137)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<line
|
||||
y1="-2.5"
|
||||
x2="64.6589"
|
||||
y2="-2.5"
|
||||
transform="matrix(0.165686 -0.986178 0.983932 0.178541 182.158 215.393)"
|
||||
stroke="#C15E50"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle
|
||||
cx="178.934"
|
||||
cy="215.603"
|
||||
r="11.0444"
|
||||
transform="rotate(180 178.934 215.603)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle
|
||||
cx="192.389"
|
||||
cy="152.597"
|
||||
r="11.5465"
|
||||
transform="rotate(180 192.389 152.597)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle
|
||||
cx="178.934"
|
||||
cy="84.5506"
|
||||
r="11.0444"
|
||||
transform="rotate(180 178.934 84.5506)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle
|
||||
cx="234.389"
|
||||
cy="195.945"
|
||||
r="8.53434"
|
||||
transform="rotate(180 234.389 195.945)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<path
|
||||
d="M196.859 247.389C196.859 243.23 200.231 239.858 204.39 239.858C208.548 239.858 211.92 243.23 211.92 247.389C211.92 251.548 208.548 254.919 204.39 254.919C200.231 254.919 196.859 251.548 196.859 247.389Z"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle
|
||||
cx="253.836"
|
||||
cy="141.004"
|
||||
r="13.0525"
|
||||
transform="rotate(180 253.836 141.004)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<circle
|
||||
cx="203.888"
|
||||
cy="35.1255"
|
||||
r="7.02828"
|
||||
transform="rotate(180 203.888 35.1255)"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
<path
|
||||
d="M229.857 86.5668C229.857 82.1307 233.453 78.5345 237.89 78.5345C242.326 78.5345 245.922 82.1307 245.922 86.5668C245.922 91.0029 242.326 94.5991 237.89 94.5991C233.453 94.5991 229.857 91.0029 229.857 86.5668Z"
|
||||
fill="#C15E50"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
309
apps/extension/contents/input-suggestions.ts
Normal file
309
apps/extension/contents/input-suggestions.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { createElement } from "react"
|
||||
import { createRoot, type Root } from "react-dom/client"
|
||||
|
||||
import InputSuggestionsPopup from "~components/InputSuggestionsPopup"
|
||||
import { searchFacts } from "~utils/api"
|
||||
import { isExtensionContextValid } from "~utils/context"
|
||||
|
||||
let inputDots: Map<HTMLElement, HTMLDivElement> = new Map()
|
||||
let inputPopups: Map<HTMLElement, HTMLDivElement> = new Map()
|
||||
let reactRoots: Map<HTMLElement, Root> = new Map()
|
||||
let currentUrl = window.location.href
|
||||
|
||||
// Track page navigation and reset popups
|
||||
function checkPageNavigation() {
|
||||
if (window.location.href !== currentUrl) {
|
||||
console.log("Page navigation detected, resetting input popups")
|
||||
currentUrl = window.location.href
|
||||
clearAllInputElements()
|
||||
}
|
||||
}
|
||||
|
||||
// Input field monitoring
|
||||
function createInputDot(input: HTMLElement, rect: DOMRect) {
|
||||
if (inputDots.has(input)) return
|
||||
|
||||
const dot = document.createElement("div")
|
||||
dot.style.cssText = `
|
||||
position: absolute;
|
||||
width: 54px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
background: #c15e50;
|
||||
border-radius: 4px;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
// Add the logo SVG with white color and Core text - smaller version for input fields
|
||||
dot.innerHTML = `
|
||||
<div style="display: flex; flex-direction: row; align-items: center; gap: 5px;">
|
||||
<svg width="16" height="16" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z" stroke="white" stroke-width="5"/>
|
||||
<circle cx="103.955" cy="66.3968" r="11.0444" fill="white"/>
|
||||
<circle cx="90.4996" cy="129.403" r="11.5465" fill="white"/>
|
||||
<circle cx="103.955" cy="197.449" r="11.0444" fill="white"/>
|
||||
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="white"/>
|
||||
<path d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z" stroke="white" stroke-width="5"/>
|
||||
<circle cx="178.934" cy="215.603" r="11.0444" transform="rotate(180 178.934 215.603)" fill="white"/>
|
||||
<circle cx="192.389" cy="152.597" r="11.5465" transform="rotate(180 192.389 152.597)" fill="white"/>
|
||||
<circle cx="178.934" cy="84.5506" r="11.0444" transform="rotate(180 178.934 84.5506)" fill="white"/>
|
||||
<circle cx="234.389" cy="195.945" r="8.53434" transform="rotate(180 234.389 195.945)" fill="white"/>
|
||||
</svg>
|
||||
<div style="
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
">Core</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
dot.style.top = `${rect.top + window.scrollY + 2}px`
|
||||
dot.style.left = `${rect.right + window.scrollX - 24}px`
|
||||
|
||||
dot.addEventListener("click", () => showInputPopup(input))
|
||||
dot.addEventListener("mouseenter", () => showInputPopup(input))
|
||||
|
||||
document.body.appendChild(dot)
|
||||
inputDots.set(input, dot)
|
||||
|
||||
setTimeout(() => {
|
||||
dot.style.opacity = "1"
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Facts popup for input fields with React
|
||||
async function showInputPopup(input: HTMLElement) {
|
||||
if (!isExtensionContextValid()) {
|
||||
console.log("Extension context invalid, skipping input popup")
|
||||
return
|
||||
}
|
||||
|
||||
// Get value from input element, handling both input/textarea and contenteditable
|
||||
let query = ""
|
||||
if ("value" in input && (input as HTMLInputElement).value !== undefined) {
|
||||
query = (input as HTMLInputElement).value.trim()
|
||||
} else {
|
||||
query = input.textContent?.trim() || ""
|
||||
}
|
||||
|
||||
if (!query || inputPopups.has(input)) return
|
||||
|
||||
// Create popup container
|
||||
const popup = document.createElement("div")
|
||||
popup.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 10002;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
`
|
||||
|
||||
// Position popup
|
||||
const rect = input.getBoundingClientRect()
|
||||
let top = rect.bottom + 5
|
||||
let left = rect.left
|
||||
|
||||
if (left + 400 > window.innerWidth) {
|
||||
left = window.innerWidth - 410
|
||||
}
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
if (top + 300 > window.innerHeight) {
|
||||
top = rect.top - 310
|
||||
}
|
||||
|
||||
popup.style.top = `${top}px`
|
||||
popup.style.left = `${left}px`
|
||||
|
||||
document.body.appendChild(popup)
|
||||
inputPopups.set(input, popup)
|
||||
|
||||
// Create React root
|
||||
const root = createRoot(popup)
|
||||
reactRoots.set(input, root)
|
||||
|
||||
// Handle fact selection
|
||||
const handleFactSelect = (factText: string) => {
|
||||
if (input) {
|
||||
// Handle both input/textarea and contenteditable elements
|
||||
if ("value" in input && (input as HTMLInputElement).value !== undefined) {
|
||||
const inputEl = input as HTMLInputElement
|
||||
inputEl.value += (inputEl.value ? " " : "") + factText
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
} else {
|
||||
// For contenteditable elements
|
||||
const currentText = input.textContent || ""
|
||||
input.textContent = currentText + (currentText ? " " : "") + factText
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
}
|
||||
removeInputPopup(input)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle popup close
|
||||
const handleClose = () => {
|
||||
removeInputPopup(input)
|
||||
}
|
||||
|
||||
// Render loading state initially
|
||||
root.render(
|
||||
createElement(InputSuggestionsPopup, {
|
||||
facts: [],
|
||||
isLoading: true,
|
||||
onFactSelect: handleFactSelect,
|
||||
onClose: handleClose
|
||||
})
|
||||
)
|
||||
|
||||
// Show popup
|
||||
setTimeout(() => {
|
||||
popup.style.opacity = "1"
|
||||
}, 10)
|
||||
|
||||
// Search for facts
|
||||
try {
|
||||
const searchResult = await searchFacts(query)
|
||||
const facts = searchResult?.facts || []
|
||||
|
||||
// Update with search results
|
||||
root.render(
|
||||
createElement(InputSuggestionsPopup, {
|
||||
facts,
|
||||
isLoading: false,
|
||||
onFactSelect: handleFactSelect,
|
||||
onClose: handleClose
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error searching facts:", error)
|
||||
// Show error state
|
||||
root.render(
|
||||
createElement(InputSuggestionsPopup, {
|
||||
facts: [],
|
||||
isLoading: false,
|
||||
onFactSelect: handleFactSelect,
|
||||
onClose: handleClose
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Input field monitoring
|
||||
export function checkInputFields() {
|
||||
// Check for page navigation first
|
||||
checkPageNavigation()
|
||||
|
||||
const inputs = document.querySelectorAll(
|
||||
"input[type='text'], input[type='search'], input[type='email'], input[type='url'], textarea, [contenteditable='true']"
|
||||
)
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const element = input as HTMLElement
|
||||
const inputElement = element as HTMLInputElement | HTMLTextAreaElement
|
||||
|
||||
// Skip Core extension popup textarea
|
||||
if (
|
||||
element.id === "memory-text" ||
|
||||
element.closest('[style*="z-index: 10002"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let value = ""
|
||||
if (inputElement.value !== undefined) {
|
||||
value = inputElement.value.trim()
|
||||
} else {
|
||||
value = element.textContent?.trim() || ""
|
||||
}
|
||||
|
||||
const words = value.split(/\s+/).filter((word) => word.length > 0)
|
||||
|
||||
if (words.length > 1) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
createInputDot(element, rect)
|
||||
}
|
||||
} else {
|
||||
removeInputDot(element)
|
||||
removeInputPopup(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Cleanup functions
|
||||
function removeInputDot(input: HTMLElement) {
|
||||
const dot = inputDots.get(input)
|
||||
if (dot) {
|
||||
dot.remove()
|
||||
inputDots.delete(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeInputPopup(input: HTMLElement) {
|
||||
// Clean up React root first
|
||||
const root = reactRoots.get(input)
|
||||
if (root) {
|
||||
root.unmount()
|
||||
reactRoots.delete(input)
|
||||
}
|
||||
|
||||
// Remove DOM element
|
||||
const popup = inputPopups.get(input)
|
||||
if (popup) {
|
||||
popup.remove()
|
||||
inputPopups.delete(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllInputElements() {
|
||||
// Clean up React roots
|
||||
reactRoots.forEach((root) => root.unmount())
|
||||
reactRoots.clear()
|
||||
|
||||
// Clean up DOM elements
|
||||
inputDots.forEach((dot) => dot.remove())
|
||||
inputPopups.forEach((popup) => popup.remove())
|
||||
inputDots.clear()
|
||||
inputPopups.clear()
|
||||
}
|
||||
|
||||
// Check if click is on input elements
|
||||
export function isInputElement(target: HTMLElement): boolean {
|
||||
// Check if target is within any popup or dot
|
||||
for (const popup of inputPopups.values()) {
|
||||
if (popup.contains(target)) return true
|
||||
}
|
||||
for (const dot of inputDots.values()) {
|
||||
if (dot.contains(target)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle input popup click outside
|
||||
export function handleInputClickOutside(target: HTMLElement) {
|
||||
inputPopups.forEach((popup, input) => {
|
||||
if (!popup.contains(target) && target !== input) {
|
||||
removeInputPopup(input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Close all input popups
|
||||
export function closeAllInputPopups() {
|
||||
inputPopups.forEach((popup, input) => {
|
||||
removeInputPopup(input)
|
||||
})
|
||||
}
|
||||
83
apps/extension/contents/main.ts
Normal file
83
apps/extension/contents/main.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { PlasmoCSConfig } from "plasmo"
|
||||
|
||||
import {
|
||||
checkInputFields,
|
||||
clearAllInputElements,
|
||||
closeAllInputPopups,
|
||||
handleInputClickOutside,
|
||||
isInputElement
|
||||
} from "~contents/input-suggestions"
|
||||
import {
|
||||
handleSelectionClickOutside,
|
||||
handleTextSelection,
|
||||
isSelectionElement,
|
||||
removeSelectionElements
|
||||
} from "~contents/text-selection"
|
||||
import { setupContextMonitoring } from "~utils/context"
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"]
|
||||
}
|
||||
|
||||
// Global cleanup function
|
||||
function globalCleanup() {
|
||||
console.log("Performing global cleanup...")
|
||||
removeSelectionElements()
|
||||
clearAllInputElements()
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function initializeExtension() {
|
||||
try {
|
||||
console.log("Core extension initialized")
|
||||
|
||||
// Setup context monitoring with cleanup
|
||||
const stopMonitoring = setupContextMonitoring(globalCleanup)
|
||||
|
||||
// Text selection handler
|
||||
document.addEventListener("mouseup", handleTextSelection)
|
||||
|
||||
// Keyboard event handlers
|
||||
document.addEventListener("keyup", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
removeSelectionElements()
|
||||
closeAllInputPopups()
|
||||
}
|
||||
})
|
||||
|
||||
// Monitor input fields
|
||||
setInterval(checkInputFields, 1000)
|
||||
|
||||
// Initial check
|
||||
setTimeout(checkInputFields, 1000)
|
||||
|
||||
// Click outside to close popups
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// Don't close if clicking on extension elements
|
||||
if (isSelectionElement(target) || isInputElement(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle click outside for both features
|
||||
handleSelectionClickOutside(target)
|
||||
handleInputClickOutside(target)
|
||||
})
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopMonitoring()
|
||||
globalCleanup()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Extension initialization error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initializeExtension)
|
||||
} else {
|
||||
initializeExtension()
|
||||
}
|
||||
340
apps/extension/contents/text-selection.ts
Normal file
340
apps/extension/contents/text-selection.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import { addEpisode } from "~utils/api"
|
||||
import { isExtensionContextValid } from "~utils/context"
|
||||
import { createRoot, type Root } from "react-dom/client"
|
||||
import { createElement } from "react"
|
||||
import TextSelectionPopup from "~components/TextSelectionPopup"
|
||||
|
||||
let selectionDot: HTMLDivElement | null = null
|
||||
let selectionPopup: HTMLDivElement | null = null
|
||||
let selectionRoot: Root | null = null
|
||||
let autoHideTimer: NodeJS.Timeout | null = null
|
||||
let isHoveringElements = false
|
||||
|
||||
// Logo dot for text selection
|
||||
function createSelectionDot(rect: DOMRect) {
|
||||
removeSelectionElements()
|
||||
|
||||
selectionDot = document.createElement("div")
|
||||
selectionDot.style.cssText = `
|
||||
position: absolute;
|
||||
width: 54px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
background: #c15e50;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
// Add the logo SVG with white color and Core text
|
||||
selectionDot.innerHTML = `
|
||||
<div style="display: flex; flex-direction: row; align-items: center; gap: 5px;">
|
||||
<svg width="16" height="16" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z" stroke="white" stroke-width="5"/>
|
||||
<circle cx="103.955" cy="66.3968" r="11.0444" fill="white"/>
|
||||
<circle cx="90.4996" cy="129.403" r="11.5465" fill="white"/>
|
||||
<circle cx="103.955" cy="197.449" r="11.0444" fill="white"/>
|
||||
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="white"/>
|
||||
<path d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z" stroke="white" stroke-width="5"/>
|
||||
<circle cx="178.934" cy="215.603" r="11.0444" transform="rotate(180 178.934 215.603)" fill="white"/>
|
||||
<circle cx="192.389" cy="152.597" r="11.5465" transform="rotate(180 192.389 152.597)" fill="white"/>
|
||||
<circle cx="178.934" cy="84.5506" r="11.0444" transform="rotate(180 178.934 84.5506)" fill="white"/>
|
||||
<circle cx="234.389" cy="195.945" r="8.53434" transform="rotate(180 234.389 195.945)" fill="white"/>
|
||||
</svg>
|
||||
<div style="
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
">Core</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const top = rect.bottom + window.scrollY + 5
|
||||
const left = Math.min(
|
||||
rect.right + window.scrollX - 16,
|
||||
window.innerWidth - 40
|
||||
)
|
||||
|
||||
selectionDot.style.top = `${top}px`
|
||||
selectionDot.style.left = `${left}px`
|
||||
|
||||
selectionDot.addEventListener("click", showSelectionPopup)
|
||||
selectionDot.addEventListener("mouseenter", showSelectionPopup)
|
||||
|
||||
document.body.appendChild(selectionDot)
|
||||
|
||||
setTimeout(() => {
|
||||
if (selectionDot) {
|
||||
selectionDot.style.opacity = "1"
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Start auto-hide timer
|
||||
startAutoHideTimer(8000) // Longer initial timer
|
||||
|
||||
// Handle hover events
|
||||
selectionDot.addEventListener("mouseenter", () => {
|
||||
isHoveringElements = true
|
||||
clearAutoHideTimer()
|
||||
})
|
||||
|
||||
selectionDot.addEventListener("mouseleave", () => {
|
||||
isHoveringElements = false
|
||||
if (!selectionPopup) {
|
||||
startAutoHideTimer(3000) // Shorter timer when leaving dot
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Timer management functions
|
||||
function startAutoHideTimer(delay: number) {
|
||||
clearAutoHideTimer()
|
||||
autoHideTimer = setTimeout(() => {
|
||||
if (!isHoveringElements) {
|
||||
removeSelectionElements()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function clearAutoHideTimer() {
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer)
|
||||
autoHideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Popup for text selection with React
|
||||
function showSelectionPopup() {
|
||||
const selectedText = window.getSelection()?.toString().trim()
|
||||
if (!selectedText || !selectionDot) return
|
||||
|
||||
removeSelectionPopup()
|
||||
|
||||
// Create popup container
|
||||
selectionPopup = document.createElement("div")
|
||||
selectionPopup.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 10002;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
`
|
||||
|
||||
// Position popup
|
||||
const dotRect = selectionDot.getBoundingClientRect()
|
||||
let top = dotRect.bottom + 5
|
||||
let left = dotRect.left
|
||||
|
||||
// Keep popup within viewport bounds
|
||||
if (left + 350 > window.innerWidth) {
|
||||
left = window.innerWidth - 360
|
||||
}
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
// If popup would go below viewport, show above the dot
|
||||
if (top + 250 > window.innerHeight) {
|
||||
top = dotRect.top - 255
|
||||
}
|
||||
|
||||
// Ensure popup doesn't go above viewport
|
||||
if (top < 10) {
|
||||
top = 10
|
||||
}
|
||||
|
||||
selectionPopup.style.top = `${top}px`
|
||||
selectionPopup.style.left = `${left}px`
|
||||
|
||||
document.body.appendChild(selectionPopup)
|
||||
|
||||
// Handle popup hover events
|
||||
selectionPopup.addEventListener("mouseenter", () => {
|
||||
isHoveringElements = true
|
||||
clearAutoHideTimer()
|
||||
})
|
||||
|
||||
selectionPopup.addEventListener("mouseleave", () => {
|
||||
isHoveringElements = false
|
||||
startAutoHideTimer(5000) // Auto-hide after leaving popup
|
||||
})
|
||||
|
||||
// Prevent popup from closing when clicking inside it
|
||||
selectionPopup.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
// Create React root
|
||||
selectionRoot = createRoot(selectionPopup)
|
||||
|
||||
// State management for the component
|
||||
let saveStatus: 'idle' | 'saving' | 'success' | 'error' | 'empty' = 'idle'
|
||||
let isSaving = false
|
||||
|
||||
// Handle save
|
||||
const handleSave = async (text: string) => {
|
||||
if (!isExtensionContextValid()) {
|
||||
saveStatus = 'error'
|
||||
renderPopup()
|
||||
return
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
saveStatus = 'empty'
|
||||
renderPopup()
|
||||
setTimeout(() => {
|
||||
saveStatus = 'idle'
|
||||
renderPopup()
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
saveStatus = 'saving'
|
||||
renderPopup()
|
||||
|
||||
try {
|
||||
const success = await addEpisode(text)
|
||||
|
||||
if (success) {
|
||||
saveStatus = 'success'
|
||||
renderPopup()
|
||||
setTimeout(() => removeSelectionElements(), 1500)
|
||||
} else {
|
||||
saveStatus = 'error'
|
||||
renderPopup()
|
||||
setTimeout(() => {
|
||||
saveStatus = 'idle'
|
||||
isSaving = false
|
||||
renderPopup()
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
saveStatus = 'error'
|
||||
renderPopup()
|
||||
setTimeout(() => {
|
||||
saveStatus = 'idle'
|
||||
isSaving = false
|
||||
renderPopup()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close
|
||||
const handleClose = () => {
|
||||
removeSelectionElements()
|
||||
}
|
||||
|
||||
// Render function
|
||||
const renderPopup = () => {
|
||||
if (selectionRoot) {
|
||||
selectionRoot.render(
|
||||
createElement(TextSelectionPopup, {
|
||||
selectedText,
|
||||
onSave: handleSave,
|
||||
onClose: handleClose,
|
||||
isSaving,
|
||||
saveStatus
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderPopup()
|
||||
|
||||
// Show popup
|
||||
setTimeout(() => {
|
||||
if (selectionPopup) {
|
||||
selectionPopup.style.opacity = "1"
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Text selection handler
|
||||
export function handleTextSelection() {
|
||||
const selection = window.getSelection()
|
||||
const selectedText = selection?.toString().trim()
|
||||
|
||||
if (
|
||||
selectedText &&
|
||||
selectedText.length > 0 &&
|
||||
selection &&
|
||||
selection.rangeCount > 0
|
||||
) {
|
||||
const range = selection.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect()
|
||||
createSelectionDot(rect)
|
||||
} else {
|
||||
removeSelectionElements()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup functions
|
||||
export function removeSelectionElements() {
|
||||
clearAutoHideTimer()
|
||||
isHoveringElements = false
|
||||
|
||||
if (selectionDot) {
|
||||
selectionDot.remove()
|
||||
selectionDot = null
|
||||
}
|
||||
removeSelectionPopup()
|
||||
}
|
||||
|
||||
function removeSelectionPopup() {
|
||||
// Clean up React root first
|
||||
if (selectionRoot) {
|
||||
selectionRoot.unmount()
|
||||
selectionRoot = null
|
||||
}
|
||||
|
||||
// Remove DOM element
|
||||
if (selectionPopup) {
|
||||
selectionPopup.remove()
|
||||
selectionPopup = null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if click is on selection elements
|
||||
export function isSelectionElement(target: HTMLElement): boolean {
|
||||
// Check if clicking on the dot
|
||||
if (selectionDot && selectionDot.contains(target)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if clicking on the popup (including textarea and buttons)
|
||||
if (selectionPopup && selectionPopup.contains(target)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if clicking on specific popup elements by ID
|
||||
if (target.id === 'memory-text' || target.id === 'save-btn' || target.id === 'close-btn') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if target is within any element with our popup z-index
|
||||
if (target.closest('[style*="z-index: 10002"]')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Close selection popup if clicking elsewhere
|
||||
export function handleSelectionClickOutside(target: HTMLElement) {
|
||||
// Only close if we're not clicking on any selection elements
|
||||
if (selectionPopup && !isSelectionElement(target)) {
|
||||
removeSelectionElements()
|
||||
}
|
||||
}
|
||||
35
apps/extension/package.json
Normal file
35
apps/extension/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "core-extension",
|
||||
"displayName": "Core extension",
|
||||
"version": "0.0.1",
|
||||
"description": "Core memory extension ",
|
||||
"author": "Redplanethq (harshith@tegon.ai)",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
"plasmo": "0.90.5",
|
||||
"@plasmohq/storage": "1.15.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
|
||||
"@types/chrome": "0.0.258",
|
||||
"@types/node": "20.11.5",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"prettier": "3.2.4",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"manifest": {
|
||||
"host_permissions": [
|
||||
"https://*/*"
|
||||
],
|
||||
"permissions": [
|
||||
"storage"
|
||||
]
|
||||
}
|
||||
}
|
||||
6331
apps/extension/pnpm-lock.yaml
generated
Normal file
6331
apps/extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
151
apps/extension/popup.tsx
Normal file
151
apps/extension/popup.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { useStorage } from "@plasmohq/storage/hook"
|
||||
|
||||
function IndexPopup() {
|
||||
// useStorage returns [value, setValue, { remove }]
|
||||
const [apiKey, setApiKey, { remove }] = useStorage<string>("core_api_key")
|
||||
const [inputValue, setInputValue] = useState(apiKey ?? "")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
// Keep inputValue in sync with apiKey if apiKey changes externally
|
||||
// (e.g. from another tab)
|
||||
// This is optional, but helps UX
|
||||
if (apiKey !== undefined && inputValue !== apiKey) {
|
||||
setInputValue(apiKey)
|
||||
}
|
||||
|
||||
const saveApiKey = async () => {
|
||||
if (!inputValue.trim()) {
|
||||
setMessage("Please enter an API key")
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await setApiKey(inputValue.trim())
|
||||
setMessage("API key saved successfully!")
|
||||
setTimeout(() => setMessage(""), 3000)
|
||||
} catch (error) {
|
||||
setMessage("Failed to save API key")
|
||||
console.error("Save error:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearApiKey = async () => {
|
||||
try {
|
||||
await remove()
|
||||
setInputValue("")
|
||||
setMessage("API key cleared")
|
||||
setTimeout(() => setMessage(""), 3000)
|
||||
} catch (error) {
|
||||
setMessage("Failed to clear API key")
|
||||
console.error("Clear error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, minWidth: 300 }}>
|
||||
<h2 style={{ margin: "0 0 16px 0", fontSize: 18, color: "#333" }}>
|
||||
Core Memory Extension
|
||||
</h2>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
fontSize: 14,
|
||||
color: "#555"
|
||||
}}>
|
||||
Core API Key:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Enter your Core API key"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={saveApiKey}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 16px",
|
||||
background: "#c15e50",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: isLoading ? "not-allowed" : "pointer",
|
||||
fontSize: 14,
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
}}>
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
|
||||
{apiKey && (
|
||||
<button
|
||||
onClick={clearApiKey}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "#EF4444",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 14
|
||||
}}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
backgroundColor: message.includes("success")
|
||||
? "#10B981"
|
||||
: "#EF4444",
|
||||
color: "white",
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKey && (
|
||||
<div style={{ fontSize: 12, color: "#10B981", marginBottom: 16 }}>
|
||||
✓ API key configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: "#666", lineHeight: 1.4 }}>
|
||||
<p style={{ margin: "0 0 8px 0" }}>
|
||||
<strong>How to use:</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
<li>Select text on any page to save to Core</li>
|
||||
<li>Look for red dots in input fields for AI suggestions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexPopup
|
||||
19
apps/extension/tsconfig.json
Normal file
19
apps/extension/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
96
apps/extension/utils/api.ts
Normal file
96
apps/extension/utils/api.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
|
||||
import { isExtensionContextValid, withValidContext } from "./context"
|
||||
|
||||
const storage = new Storage()
|
||||
const API_BASE_URL = "https://core.heysol.ai/api/v1"
|
||||
|
||||
export interface CoreEpisode {
|
||||
episodeBody: string
|
||||
referenceTime: Date
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface CoreSearchQuery {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface CoreSearchResponse {
|
||||
facts: string[]
|
||||
episodes: CoreEpisode[]
|
||||
}
|
||||
|
||||
async function getApiKey(): Promise<string | null> {
|
||||
if (!isExtensionContextValid()) {
|
||||
console.log("Extension context invalid, cannot get API key")
|
||||
return null
|
||||
}
|
||||
|
||||
return withValidContext(async () => {
|
||||
return await storage.get("core_api_key")
|
||||
}, null)
|
||||
}
|
||||
|
||||
export async function addEpisode(episodeBody: string): Promise<boolean> {
|
||||
const apiKey = await getApiKey()
|
||||
if (!apiKey) {
|
||||
console.error("No API key found")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/add`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
episodeBody,
|
||||
referenceTime: new Date(),
|
||||
source: "Core extension"
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to add episode:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchFacts(
|
||||
query: string
|
||||
): Promise<CoreSearchResponse | null> {
|
||||
const apiKey = await getApiKey()
|
||||
if (!apiKey) {
|
||||
console.error("No API key found")
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ query })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const responseJson = await response.json()
|
||||
|
||||
return responseJson
|
||||
} catch (error) {
|
||||
console.error("Failed to search facts:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
59
apps/extension/utils/context.ts
Normal file
59
apps/extension/utils/context.ts
Normal file
@ -0,0 +1,59 @@
|
||||
// Utility to check if extension context is still valid
|
||||
export function isExtensionContextValid(): boolean {
|
||||
try {
|
||||
// Try to access chrome.runtime - this will throw if context is invalidated
|
||||
return !!chrome?.runtime?.id
|
||||
} catch (error) {
|
||||
console.log("Extension context invalidated")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for operations that require valid extension context
|
||||
export async function withValidContext<T>(
|
||||
operation: () => Promise<T>,
|
||||
fallback?: T
|
||||
): Promise<T | undefined> {
|
||||
if (!isExtensionContextValid()) {
|
||||
console.log("Skipping operation - extension context invalid")
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
if (error.message?.includes("Extension context invalidated")) {
|
||||
console.log("Extension context invalidated during operation")
|
||||
return fallback
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful cleanup when context becomes invalid
|
||||
export function setupContextMonitoring(cleanupCallback: () => void) {
|
||||
// Check context periodically
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isExtensionContextValid()) {
|
||||
console.log("Extension context lost, cleaning up...")
|
||||
cleanupCallback()
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Listen for disconnect events
|
||||
try {
|
||||
chrome.runtime?.onConnect?.addListener((port) => {
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.log("Port disconnected, cleaning up...")
|
||||
cleanupCallback()
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// Ignore if chrome.runtime is not available
|
||||
}
|
||||
|
||||
return () => clearInterval(checkInterval)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user