Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/FloatingToolbar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cloneElement, useCallback, useEffect, useState } from 'react'
import { unmountComponentAtNode } from 'react-dom'
import ConversationCard from '../ConversationCard'
import PropTypes from 'prop-types'
import { config as toolsConfig } from '../../content-script/selection-tools'
Expand Down Expand Up @@ -70,6 +71,7 @@ function FloatingToolbar(props) {
}

const onClose = useCallback(() => {
unmountComponentAtNode(props.container)
props.container.remove()
}, [])
Comment thread
PeterDaveHello marked this conversation as resolved.

Expand Down
40 changes: 33 additions & 7 deletions src/content-script/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,13 @@ async function getInput(inputQuery) {
}

let toolbarContainer
let toolbarCreationVersion = 0
const deleteToolbar = () => {
toolbarCreationVersion += 1
try {
if (toolbarContainer && toolbarContainer.className === 'chatgptbox-toolbar-container') {
console.debug('[content] Deleting toolbar:', toolbarContainer)
unmountComponentAtNode(toolbarContainer)
toolbarContainer.remove()
toolbarContainer = null
}
Expand All @@ -237,7 +240,7 @@ const deleteToolbar = () => {
}
}

const createSelectionTools = async (toolbarContainerElement, selection) => {
const createSelectionTools = async (toolbarContainerElement, selection, creationVersion) => {
console.debug(
'[content] createSelectionTools called with selection:',
selection,
Expand All @@ -247,6 +250,14 @@ const createSelectionTools = async (toolbarContainerElement, selection) => {
try {
toolbarContainerElement.className = 'chatgptbox-toolbar-container'
const userConfig = await getUserConfig()
if (
creationVersion !== toolbarCreationVersion ||
toolbarContainerElement !== toolbarContainer ||
!toolbarContainerElement.isConnected
) {
console.debug('[content] Selection tools creation was superseded, skipping render.')
return
}
render(
<FloatingToolbar
session={initSession({
Expand Down Expand Up @@ -290,8 +301,10 @@ async function prepareForSelectionTools() {
}

deleteToolbar()
const creationVersion = toolbarCreationVersion
setTimeout(async () => {
try {
if (creationVersion !== toolbarCreationVersion) return
const selection = window
.getSelection()
?.toString()
Expand All @@ -302,6 +315,7 @@ async function prepareForSelectionTools() {
let position

const config = await getUserConfig()
if (creationVersion !== toolbarCreationVersion) return
if (!config.selectionToolsNextToInputBox) {
position = { x: e.pageX + 20, y: e.pageY + 20 }
} else {
Expand All @@ -325,8 +339,9 @@ async function prepareForSelectionTools() {
}
}
console.debug('[content] Toolbar position:', position)
toolbarContainer = createElementAtPosition(position.x, position.y)
await createSelectionTools(toolbarContainer, selection)
const container = createElementAtPosition(position.x, position.y)
toolbarContainer = container
await createSelectionTools(container, selection, creationVersion)
} else {
console.debug('[content] No text selected on mouseup.')
}
Expand All @@ -346,7 +361,11 @@ async function prepareForSelectionTools() {
return
}
console.debug('[content] Mousedown outside toolbar, removing existing toolbars.')
document.querySelectorAll('.chatgptbox-toolbar-container').forEach((el) => el.remove())
toolbarCreationVersion += 1
document.querySelectorAll('.chatgptbox-toolbar-container').forEach((el) => {
unmountComponentAtNode(el)
el.remove()
})
toolbarContainer = null
} catch (error) {
console.error('[content] Error in mousedown listener for selection tools:', error)
Expand Down Expand Up @@ -402,8 +421,10 @@ async function prepareForSelectionToolsTouch() {
}

deleteToolbar()
const creationVersion = toolbarCreationVersion
setTimeout(async () => {
try {
if (creationVersion !== toolbarCreationVersion) return
const selection = window
.getSelection()
?.toString()
Expand All @@ -412,8 +433,9 @@ async function prepareForSelectionToolsTouch() {
if (selection) {
console.debug('[content] Text selected via touch:', selection)
const touch = e.changedTouches[0]
toolbarContainer = createElementAtPosition(touch.pageX + 20, touch.pageY + 20)
await createSelectionTools(toolbarContainer, selection)
const container = createElementAtPosition(touch.pageX + 20, touch.pageY + 20)
toolbarContainer = container
await createSelectionTools(container, selection, creationVersion)
} else {
console.debug('[content] No text selected on touchend.')
}
Expand All @@ -436,7 +458,11 @@ async function prepareForSelectionToolsTouch() {
return
}
console.debug('[content] Touchstart outside toolbar, removing existing toolbars.')
document.querySelectorAll('.chatgptbox-toolbar-container').forEach((el) => el.remove())
toolbarCreationVersion += 1
document.querySelectorAll('.chatgptbox-toolbar-container').forEach((el) => {
unmountComponentAtNode(el)
el.remove()
Comment thread
PeterDaveHello marked this conversation as resolved.
})
toolbarContainer = null
} catch (error) {
console.error('[content] Error in touchstart listener for touch selection tools:', error)
Expand Down
169 changes: 169 additions & 0 deletions tests/setup/content-script-selection-toolbar-loader-hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'

const contentScriptStubs = new Map([
['./styles.scss', 'test:styles'],
['../components/DecisionCard', 'test:decision-card'],
['./site-adapters', 'test:site-adapters'],
['./selection-tools', 'test:selection-tools'],
['./menu-tools', 'test:menu-tools'],
['../config/index.mjs', 'test:config'],
['../utils', 'test:utils'],
['../components/FloatingToolbar', 'test:floating-toolbar'],
['webextension-polyfill', 'test:browser'],
['../config/language.mjs', 'test:language'],
['../_locales/i18n-react', 'test:i18n-react'],
['i18next', 'test:i18next'],
['../services/init-session.mjs', 'test:init-session'],
['../services/wrappers.mjs', 'test:wrappers'],
['../services/apis/chatgpt-web.mjs', 'test:chatgpt-api'],
['../components/WebJumpBackNotification', 'test:jump-back'],
['./port-error.mjs', 'test:port-error'],
])

const floatingToolbarStubs = new Map([
['../ConversationCard', 'test:floating-conversation-card'],
['../../content-script/selection-tools', 'test:floating-selection-tools'],
['../../utils', 'test:floating-utils'],
['react-draggable', 'test:floating-draggable'],
['../../hooks/use-clamp-window-size', 'test:floating-window-size'],
['react-i18next', 'test:floating-i18n'],
['../../hooks/use-config.mjs', 'test:floating-config'],
])

const sources = {
'test:styles': '',
'test:decision-card': 'export default function DecisionCard() { return null }',
'test:site-adapters': 'export const config = {}',
'test:selection-tools': 'export const config = {}',
'test:menu-tools': 'export const config = {}',
'test:config': `
export const chatgptWebModelKeys = []
export const getPreferredLanguageKey = async () => 'en'
export const getUserConfig = () => globalThis.__SELECTION_TOOLBAR_TEST__.getUserConfig()
export const isUsingChatgptWebModel = () => false
export const setAccessToken = async () => {}
export const setUserConfig = async () => {}
`,
'test:utils': `
export const createElementAtPosition = () => {
const element = document.createElement('div')
document.documentElement.append(element)
globalThis.__SELECTION_TOOLBAR_TEST__.createdContainers.push(element)
return element
}
export const cropText = async (text) => text
export const endsWithQuestionMark = () => false
export const getApiModesStringArrayFromConfig = () => []
export const getClientPosition = () => ({ x: 0, y: 0 })
export const getPossibleElementByQuerySelector = () => null
`,
'test:floating-toolbar': `
export default function FloatingToolbar() {
globalThis.__SELECTION_TOOLBAR_TEST__.renderCount += 1
return null
}
`,
'test:browser': `
const event = { addListener() {}, removeListener() {} }
export default {
runtime: { onMessage: event, sendMessage: async () => {} },
storage: { onChanged: event },
}
`,
'test:language': `export const getPreferredLanguage = async () => 'English'`,
'test:i18n-react': '',
'test:i18next': 'export const changeLanguage = async () => {}',
'test:init-session': 'export const initSession = () => ({})',
'test:wrappers': `
export const getChatGptAccessToken = async () => null
export const registerPortListener = () => {}
`,
'test:chatgpt-api': 'export const generateAnswersWithChatgptWebApi = async () => {}',
'test:jump-back': 'export default function WebJumpBackNotification() { return null }',
'test:port-error': `
export const getPortErrorMessage = (error) => String(error)
export const shouldDelegatePortError = () => false
`,
'test:floating-conversation-card': `
import { useLayoutEffect } from 'preact/hooks'
export default function ConversationCard(props) {
const state = globalThis.__FLOATING_TOOLBAR_TEST__
state.onClose = props.onClose
useLayoutEffect(() => () => {
state.cleanupCount += 1
state.cleanupSawConnectedContainer = state.container.isConnected
}, [])
return null
}
`,
'test:floating-selection-tools': 'export const config = {}',
'test:floating-utils': `
Comment thread
pullfrog[bot] marked this conversation as resolved.
export const getClientPosition = () => ({ x: 0, y: 0 })
export const isMobile = () => false
export const setElementPositionInViewport = (_container, x, y) => ({ x, y })
`,
'test:floating-draggable': `
export default function Draggable(props) {
return props.children
}
`,
'test:floating-window-size': 'export const useClampWindowSize = () => [1000, 1000]',
'test:floating-i18n': 'export const useTranslation = () => ({ t: (value) => value })',
'test:floating-config': `
import { useLayoutEffect } from 'preact/hooks'
const config = {
alwaysPinWindow: false,
themeMode: 'light',
activeSelectionTools: [],
customSelectionTools: [],
}
export const useConfig = (onLoad) => {
useLayoutEffect(() => {
onLoad()
}, [])
return config
}
`,
}

export async function resolve(specifier, context, nextResolve) {
if (context.parentURL?.startsWith('test:') && specifier === 'preact/hooks') {
return nextResolve(specifier, { ...context, parentURL: import.meta.url })
}

if (context.parentURL?.endsWith('/src/content-script/index.jsx')) {
const stubUrl = contentScriptStubs.get(specifier)
if (stubUrl) return { url: stubUrl, shortCircuit: true }
}

if (context.parentURL?.endsWith('/src/components/FloatingToolbar/index.jsx')) {
const stubUrl = floatingToolbarStubs.get(specifier)
if (stubUrl) return { url: stubUrl, shortCircuit: true }
}

return nextResolve(specifier, context)
Comment thread
pullfrog[bot] marked this conversation as resolved.
}

export async function load(url, context, nextLoad) {
if (url.startsWith('test:')) {
return {
shortCircuit: true,
format: 'module',
source: sources[url],
}
}

if (url.startsWith('file://') && url.endsWith('.jsx') && !url.includes('node_modules')) {
const source = await readFile(fileURLToPath(url), 'utf8')
const esbuild = await import('esbuild')
const result = await esbuild.transform(source, {
loader: 'jsx',
jsx: 'automatic',
jsxImportSource: 'preact',
})
return { shortCircuit: true, format: 'module', source: result.code }
}

return nextLoad(url, context)
}
Loading