diff --git a/background.ts b/background.ts index 52124d4..a92e64b 100644 --- a/background.ts +++ b/background.ts @@ -1,4 +1,8 @@ import { authService } from './core/auth/auth-service'; +import { + createReadingProgressDisabledResponse, + isReadingProgressEnabled, +} from './core/config/feature-gates'; import { DEFAULT_CONFIG, getDefaultConfig } from './core/config/llm-config'; import { i18n } from './core/i18n'; import { collectService } from './core/services/collect-service'; @@ -13,9 +17,75 @@ import { collectDB } from './core/storage/collect-db'; import { dictionaryDB } from './core/storage/dictionary-db'; import { StorageMigration } from './core/storage/migration'; import { secureStorage } from './core/storage/secure-storage'; +import { + createTabSelectionContext, + isSelectionContextFresh, + mergeSelectedTextIntoSnapshot, + type TabSelectionContext, +} from './core/tab-context/selection-context'; +import { + TabAssistantSidePanelController, + type TabAssistantSidePanelApi, +} from './core/tab-context/side-panel-toggle'; import { createLogger } from './utils/logger'; const logger = createLogger('Background'); +const TAB_ASSISTANT_SIDE_PANEL_PATH = 'tabs/tab-assistant.html'; +const tabAssistantSidePanelController = new TabAssistantSidePanelController( + TAB_ASSISTANT_SIDE_PANEL_PATH +); +const pendingTabSelections = new Map(); + +type TabAssistantSidePanelEvents = TabAssistantSidePanelApi & { + onOpened?: { + addListener(callback: (info: { tabId?: number; path: string }) => void): void; + }; + onClosed?: { + addListener(callback: (info: { tabId?: number; path: string }) => void): void; + }; +}; + +const getTabAssistantSidePanel = (): TabAssistantSidePanelEvents | null => + (chrome.sidePanel as unknown as TabAssistantSidePanelEvents | undefined) ?? null; + +const enableTabAssistantSidePanel = (tabId: number) => { + const sidePanel = getTabAssistantSidePanel(); + if (!sidePanel?.setOptions) { + return Promise.reject(new Error('Side panel options are not available in this browser')); + } + return tabAssistantSidePanelController.prepare(sidePanel, tabId); +}; + +const savePendingTabSelection = (tabId: number, selectedText: unknown) => { + const selection = createTabSelectionContext(tabId, selectedText); + if (!selection) return null; + + pendingTabSelections.set(tabId, selection); + return selection; +}; + +const getPendingTabSelection = (tabId: number): TabSelectionContext | null => { + const selection = pendingTabSelections.get(tabId); + if (!selection) return null; + + if (!isSelectionContextFresh(selection)) { + pendingTabSelections.delete(tabId); + return null; + } + + return selection; +}; + +const broadcastPendingTabSelection = async (selection: TabSelectionContext) => { + try { + await chrome.runtime.sendMessage({ + action: 'tabContext:selectedTextUpdated', + selection, + }); + } catch { + // The side panel may not be mounted yet; it will consume the pending selection on capture. + } +}; // Initialize extension on installation chrome.runtime.onInstalled.addListener(async (details) => { @@ -95,6 +165,23 @@ chrome.runtime.onStartup.addListener(async () => { } }); +chrome.tabs.onRemoved.addListener((tabId) => { + tabAssistantSidePanelController.markClosed(tabId); + pendingTabSelections.delete(tabId); +}); + +const sidePanelEvents = getTabAssistantSidePanel(); +sidePanelEvents?.onOpened?.addListener((info) => { + if (info.path === TAB_ASSISTANT_SIDE_PANEL_PATH && info.tabId != null) { + tabAssistantSidePanelController.markOpened(info.tabId); + } +}); +sidePanelEvents?.onClosed?.addListener((info) => { + if (info.path === TAB_ASSISTANT_SIDE_PANEL_PATH && info.tabId != null) { + tabAssistantSidePanelController.markClosed(info.tabId); + } +}); + // Also run migration when extension is enabled/reloaded (async () => { logger.info('Extension loaded'); @@ -358,6 +445,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } case 'readingProgress:get': { + if (!isReadingProgressEnabled()) { + sendResponse(createReadingProgressDisabledResponse()); + return true; + } + (async () => { try { const url = request.url as string; @@ -376,6 +468,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } case 'readingProgress:save': { + if (!isReadingProgressEnabled()) { + sendResponse(createReadingProgressDisabledResponse()); + return true; + } + (async () => { try { const url = request.url as string; @@ -400,6 +497,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } case 'readingProgress:delete': { + if (!isReadingProgressEnabled()) { + sendResponse(createReadingProgressDisabledResponse()); + return true; + } + (async () => { try { const url = request.url as string; @@ -416,6 +518,153 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { })(); return true; } + case 'tabContext:openSidePanel': + case 'tabContext:toggleSidePanel': { + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ success: false, error: 'Missing tab id' }); + return true; + } + + const sidePanel = getTabAssistantSidePanel(); + if (!sidePanel?.open) { + sendResponse({ success: false, error: 'Side panel is not available in this browser' }); + return true; + } + if (!sidePanel.setOptions) { + sendResponse({ + success: false, + error: 'Side panel options are not available in this browser', + }); + return true; + } + + const selection = savePendingTabSelection(tabId, request.selectedText); + const sidePanelAction = + request.action === 'tabContext:toggleSidePanel' + ? tabAssistantSidePanelController.toggle(sidePanel, tabId) + : tabAssistantSidePanelController.open(sidePanel, tabId); + + Promise.resolve(sidePanelAction) + .then((result) => { + if (selection) { + void broadcastPendingTabSelection(selection); + } + sendResponse({ success: true, tabId, action: result.action }); + }) + .catch((err: any) => { + logger.error('Failed to toggle side panel:', err); + sendResponse({ success: false, error: err?.message || 'Failed to toggle side panel' }); + }); + return true; + } + case 'tabContext:prepareSidePanel': { + (async () => { + try { + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ success: false, error: 'Missing tab id' }); + return; + } + + await enableTabAssistantSidePanel(tabId); + sendResponse({ success: true, tabId }); + } catch (err: any) { + logger.warn('Failed to prepare tab assistant side panel:', err); + sendResponse({ success: false, error: err?.message || 'Failed to prepare side panel' }); + } + })(); + return true; + } + case 'tabContext:getActiveTab': { + (async () => { + const lastSidePanelTabId = tabAssistantSidePanelController.getLastKnownTabId(); + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + sendResponse({ + success: true, + tab: tab + ? { + id: tab.id, + title: tab.title, + url: tab.url, + windowId: tab.windowId, + } + : lastSidePanelTabId + ? { id: lastSidePanelTabId } + : null, + }); + } catch (err: any) { + logger.warn('Failed to get active tab:', err); + sendResponse({ + success: false, + error: err?.message || 'Failed to get active tab', + tab: lastSidePanelTabId ? { id: lastSidePanelTabId } : null, + }); + } + })(); + return true; + } + case 'tabContext:getPendingSelection': { + const tabId = request.tabId as number | undefined; + if (!tabId) { + sendResponse({ success: false, error: 'Missing tab id', selection: null }); + return true; + } + + sendResponse({ + success: true, + selection: getPendingTabSelection(tabId), + }); + return true; + } + case 'tabContext:clearPendingSelection': { + const tabId = request.tabId as number | undefined; + if (!tabId) { + sendResponse({ success: false, error: 'Missing tab id' }); + return true; + } + + pendingTabSelections.delete(tabId); + sendResponse({ success: true }); + return true; + } + case 'tabContext:capture': { + (async () => { + try { + const tabId = request.tabId as number | undefined; + if (!tabId) { + sendResponse({ success: false, error: 'Missing tab id' }); + return; + } + + const res = await chrome.tabs.sendMessage( + tabId, + { + action: 'tabContext:capturePage', + budget: request.budget, + }, + { frameId: 0 } + ); + const selection = getPendingTabSelection(tabId); + sendResponse({ + ...res, + snapshot: + res?.success && res.snapshot + ? mergeSelectedTextIntoSnapshot(res.snapshot, selection) + : res?.snapshot, + }); + } catch (err: any) { + logger.warn('Failed to capture tab context:', err); + sendResponse({ + success: false, + error: err?.message || 'This page cannot be read', + }); + } + })(); + return true; + } case 'open': chrome.tabs.create({ url: request.url }); sendResponse({ success: true }); diff --git a/components/content/GlobalActionBar.test.tsx b/components/content/GlobalActionBar.test.tsx new file mode 100644 index 0000000..6a29430 --- /dev/null +++ b/components/content/GlobalActionBar.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { GlobalActionBar } from './GlobalActionBar'; + +const labels = { + askPage: 'Ask this page', + saveProgress: 'Save progress', + progressSaved: 'Progress saved', +}; + +describe('GlobalActionBar', () => { + it('groups multiple available actions behind a single trigger', () => { + const html = renderToStaticMarkup( + {}} + onSaveProgress={async () => {}} + /> + ); + + expect(html).toContain('selectly-global-action-cluster'); + expect(html).toContain('selectly-global-action-trigger'); + expect(html).toContain('is-expand-up'); + expect(html.match(/selectly-global-action-drag-target/g)).toHaveLength(1); + expect(html.match(/selectly-global-action-btn/g)).toHaveLength(3); + }); + + it('keeps a single available action as a direct button', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + + expect(html).not.toContain('selectly-global-action-cluster'); + expect(html).not.toContain('selectly-global-action-trigger'); + expect(html.match(/selectly-global-action-drag-target/g)).toHaveLength(1); + expect(html.match(/selectly-global-action-btn/g)).toHaveLength(1); + }); +}); diff --git a/components/content/GlobalActionBar.tsx b/components/content/GlobalActionBar.tsx index d141c94..e79000a 100644 --- a/components/content/GlobalActionBar.tsx +++ b/components/content/GlobalActionBar.tsx @@ -1,19 +1,32 @@ -import { Bookmark } from 'lucide-react'; -import { useState } from 'react'; +import { Bookmark, MessageCircle, Sparkles } from 'lucide-react'; +import React, { useState } from 'react'; + +import { useGlobalActionBarPosition } from './useGlobalActionBarPosition'; interface GlobalActionBarProps { - onSaveProgress: () => Promise; + onOpenTabAssistant?: () => Promise; + onSaveProgress?: () => Promise; + showTabAssistant?: boolean; + showSaveProgress?: boolean; labels: { + askPage: string; saveProgress: string; progressSaved: string; }; } -export const GlobalActionBar = ({ onSaveProgress, labels }: GlobalActionBarProps) => { +export const GlobalActionBar = ({ + onOpenTabAssistant, + onSaveProgress, + showTabAssistant = true, + showSaveProgress = false, + labels, +}: GlobalActionBarProps) => { const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [expanded, setExpanded] = useState(false); const handleSave = async () => { - if (status === 'saving') return; + if (status === 'saving' || !onSaveProgress) return; setStatus('saving'); try { await onSaveProgress(); @@ -24,16 +37,112 @@ export const GlobalActionBar = ({ onSaveProgress, labels }: GlobalActionBarProps } }; + const actions = [ + showTabAssistant + ? { + id: 'tab-assistant', + label: labels.askPage, + icon: , + onClick: () => onOpenTabAssistant?.(), + } + : null, + showSaveProgress + ? { + id: 'save-progress', + label: status === 'saved' ? labels.progressSaved : labels.saveProgress, + icon: , + onClick: handleSave, + className: status === 'saved' ? 'is-saved' : '', + } + : null, + ].filter(Boolean) as Array<{ + id: string; + label: string; + icon: JSX.Element; + onClick: () => void; + className?: string; + }>; + const { rootRef, point, expandDirection, isDragging, dragTargetProps } = + useGlobalActionBarPosition(actions.length); + + if (actions.length === 0) return null; + + const rootClassName = [ + 'selectly-global-actions', + `is-expand-${expandDirection}`, + isDragging ? 'is-dragging' : '', + ] + .filter(Boolean) + .join(' '); + const rootStyle: React.CSSProperties = { + left: `${point?.x || 0}px`, + top: `${point?.y || 0}px`, + right: 'auto', + bottom: 'auto', + visibility: point ? 'visible' : 'hidden', + }; + + if (actions.length === 1) { + const [action] = actions; + return ( +
+ +
+ ); + } + + const menuLabel = actions.map((action) => action.label).join(' / '); + return ( -
- +
+ {actions.map((action) => ( + + ))} +
+ +
); }; diff --git a/components/content/useGlobalActionBarPosition.ts b/components/content/useGlobalActionBarPosition.ts new file mode 100644 index 0000000..4df1784 --- /dev/null +++ b/components/content/useGlobalActionBarPosition.ts @@ -0,0 +1,239 @@ +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + getGlobalActionDragPoint, + isGlobalActionDragGesture, +} from '../../core/content/global-action-drag'; +import { + getDefaultGlobalActionPosition, + getGlobalActionExpandDirection, + pointToGlobalActionPosition, + positionToPoint, + type GlobalActionExpandDirection, + type GlobalActionPoint, + type GlobalActionPosition, + type GlobalActionSize, + type GlobalActionViewport, +} from '../../core/content/global-action-position'; +import { + loadGlobalActionPosition, + saveGlobalActionPosition, +} from '../../core/storage/global-action-position-storage'; + +interface GlobalActionLayout { + viewport: GlobalActionViewport; + size: GlobalActionSize; +} + +interface ActiveDrag { + pointerId: number; + startPointer: GlobalActionPoint; + startPoint: GlobalActionPoint; + lastPoint: GlobalActionPoint; + layout: GlobalActionLayout; + dragging: boolean; +} + +interface UseGlobalActionBarPositionResult { + rootRef: React.RefObject; + point: GlobalActionPoint | null; + expandDirection: GlobalActionExpandDirection; + isDragging: boolean; + dragTargetProps: { + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + onClickCapture: (event: React.MouseEvent) => void; + }; +} + +export function useGlobalActionBarPosition(layoutKey: unknown): UseGlobalActionBarPositionResult { + const rootRef = useRef(null); + const dragRef = useRef(null); + const savedPositionRef = useRef(getDefaultGlobalActionPosition()); + const suppressClickRef = useRef(false); + const [savedPosition, setSavedPosition] = useState( + getDefaultGlobalActionPosition + ); + const [point, setPoint] = useState(null); + const [expandDirection, setExpandDirection] = useState( + getGlobalActionExpandDirection(savedPosition) + ); + const [isDragging, setIsDragging] = useState(false); + + const getLayout = useCallback((): GlobalActionLayout | null => { + if (typeof window === 'undefined') return null; + const element = rootRef.current; + if (!element) return null; + + const rect = element.getBoundingClientRect(); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + size: { + width: rect.width || 44, + height: rect.height || 44, + }, + }; + }, []); + + const applyPosition = useCallback( + (position: GlobalActionPosition) => { + const layout = getLayout(); + if (!layout) return; + + setPoint(positionToPoint(position, layout.viewport, layout.size)); + setExpandDirection(getGlobalActionExpandDirection(position)); + }, + [getLayout] + ); + + useEffect(() => { + savedPositionRef.current = savedPosition; + }, [savedPosition]); + + useEffect(() => { + let active = true; + + loadGlobalActionPosition() + .then((position) => { + if (!active) return; + setSavedPosition(position); + }) + .catch(() => { + if (!active) return; + setSavedPosition(getDefaultGlobalActionPosition()); + }); + + return () => { + active = false; + }; + }, []); + + useEffect(() => { + applyPosition(savedPosition); + }, [applyPosition, layoutKey, savedPosition]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleResize = () => applyPosition(savedPositionRef.current); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [applyPosition]); + + const updateDragPoint = useCallback( + (event: React.PointerEvent, activeDrag: ActiveDrag): ActiveDrag => { + const currentPointer = { x: event.clientX, y: event.clientY }; + const layout = getLayout() || activeDrag.layout; + const nextPoint = getGlobalActionDragPoint({ + startPointer: activeDrag.startPointer, + currentPointer, + startPoint: activeDrag.startPoint, + viewport: layout.viewport, + size: layout.size, + }); + const nextPosition = pointToGlobalActionPosition(nextPoint, layout.viewport, layout.size); + + setPoint(nextPoint); + setExpandDirection(getGlobalActionExpandDirection(nextPosition)); + + return { + ...activeDrag, + layout, + lastPoint: nextPoint, + dragging: true, + }; + }, + [getLayout] + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0 || !point) return; + const layout = getLayout(); + if (!layout) return; + + dragRef.current = { + pointerId: event.pointerId, + startPointer: { x: event.clientX, y: event.clientY }, + startPoint: point, + lastPoint: point, + layout, + dragging: false, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [getLayout, point] + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + const activeDrag = dragRef.current; + if (!activeDrag || activeDrag.pointerId !== event.pointerId) return; + + const currentPointer = { x: event.clientX, y: event.clientY }; + if ( + !activeDrag.dragging && + !isGlobalActionDragGesture(activeDrag.startPointer, currentPointer) + ) { + return; + } + + dragRef.current = updateDragPoint(event, activeDrag); + setIsDragging(true); + event.preventDefault(); + }, + [updateDragPoint] + ); + + const finishPointerGesture = useCallback((event: React.PointerEvent) => { + const activeDrag = dragRef.current; + if (!activeDrag || activeDrag.pointerId !== event.pointerId) return; + + event.currentTarget.releasePointerCapture?.(event.pointerId); + dragRef.current = null; + setIsDragging(false); + + if (!activeDrag.dragging) return; + + const nextPosition = pointToGlobalActionPosition( + activeDrag.lastPoint, + activeDrag.layout.viewport, + activeDrag.layout.size + ); + savedPositionRef.current = nextPosition; + setSavedPosition(nextPosition); + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 150); + void saveGlobalActionPosition(nextPosition).catch(() => {}); + event.preventDefault(); + }, []); + + const handleClickCapture = useCallback((event: React.MouseEvent) => { + if (!suppressClickRef.current) return; + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, []); + + return { + rootRef, + point, + expandDirection, + isDragging, + dragTargetProps: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: finishPointerGesture, + onPointerCancel: finishPointerGesture, + onClickCapture: handleClickCapture, + }, + }; +} diff --git a/components/options/GeneralPage.test.tsx b/components/options/GeneralPage.test.tsx new file mode 100644 index 0000000..ca89cff --- /dev/null +++ b/components/options/GeneralPage.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_CONFIG } from '../../core/config/llm-config'; +import { GeneralPage } from './GeneralPage'; + +describe('GeneralPage', () => { + it('hides reading progress settings while the feature is disabled', () => { + const html = renderToStaticMarkup( + {}} + userConfig={DEFAULT_CONFIG} + onChange={vi.fn()} + /> + ); + + expect(html).not.toContain('Reading Progress'); + expect(html).not.toContain('Show progress bar'); + }); +}); diff --git a/components/options/GeneralPage.tsx b/components/options/GeneralPage.tsx index 0ad1042..aa487a9 100644 --- a/components/options/GeneralPage.tsx +++ b/components/options/GeneralPage.tsx @@ -6,12 +6,11 @@ import { Download, Upload } from 'lucide-react'; import React from 'react'; +import { isReadingProgressEnabled } from '../../core/config/feature-gates'; import type { GeneralConfig, UserConfig } from '../../core/config/llm-config'; import { secureStorage } from '../../core/storage/secure-storage'; import { createLogger } from '../../utils/logger'; import { COLOR_PRESETS } from './color-presets'; - -const logger = createLogger('GeneralPage'); import { PALETTE } from './constants'; import { parseColorToRgba, @@ -20,6 +19,8 @@ import { type RgbaColor, } from './forms/highlight-color-utils'; +const logger = createLogger('GeneralPage'); + interface GeneralPageProps { t: any; // i18n translations onReload: () => Promise; @@ -28,6 +29,7 @@ interface GeneralPageProps { } export const GeneralPage: React.FC = ({ t, onReload, userConfig, onChange }) => { + const showReadingProgressSettings = isReadingProgressEnabled(); const progressFallback: RgbaColor = { r: 96, g: 165, b: 250, a: 1 }; const progressColorValue = userConfig.general?.readingProgressBarColor || '#60a5fa'; const currentProgress = parseColorToRgba(progressColorValue, progressFallback); @@ -90,211 +92,224 @@ export const GeneralPage: React.FC = ({ t, onReload, userConfi return (
-
-
-

- {t.popup?.general?.readingProgressTitle || 'Reading Progress'} -

-
-
-
- - - -
-
- {t.popup?.general?.readingProgressRetention || 'Progress retention (days)'} -
- - onChange('readingProgressRetentionDays', Math.max(1, Number(e.target.value || 0))) - } - /> -
- {t.popup?.general?.readingProgressRetentionDesc || - 'Progress older than this will be removed automatically.'} -
-
-
- - {t.popup?.general?.readingProgressBarColor || 'Progress bar color'} - -
- {progressCustomLabel} + {showReadingProgressSettings ? ( +
+
+

+ {t.popup?.general?.readingProgressTitle || 'Reading Progress'} +

+
+
+
+
-
-
-
{progressPresetLabel}
-
- {progressPresets.map((preset) => { - const isSelected = - rgbaToString(parseColorToRgba(preset.color, currentProgress)) === - rgbaToString(currentProgress); - return ( -
-
-
-
- {t.popup?.general?.readingProgressMode || 'Domain list mode'} -
- -
- - - {listMode === 'blacklist' ? ( -
+ + {t.popup?.general?.showReadingProgressBar || 'Show progress bar'} + + + + +
- {t.popup?.general?.readingProgressBlacklist || - 'Blacklisted Domains (One per line)'} + {t.popup?.general?.readingProgressRetention || 'Progress retention (days)'}
-