diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index badf3a8b6..6193e28cd 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -192,6 +192,7 @@ export const ReactGrabRenderer: Component = (props) => { onStateChange={props.onToolbarStateChange} onSubscribeToStateChanges={props.onSubscribeToToolbarStateChanges} onSelectHoverChange={props.onToolbarSelectHoverChange} + persistToolbarState={props.persistToolbarState} /> diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 984871ac4..b408de59a 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -57,6 +57,7 @@ interface ToolbarProps { callback: (state: ToolbarState) => void, ) => () => void; onSelectHoverChange?: (isHovered: boolean) => void; + persistToolbarState?: boolean; } export const Toolbar: Component = (props) => { @@ -757,12 +758,12 @@ export const Toolbar: Component = (props) => { }; const saveAndNotify = (state: ToolbarState) => { - saveToolbarState(state); + saveToolbarState(state, props.persistToolbarState); props.onStateChange?.(state); }; onMount(() => { - const savedState = loadToolbarState(); + const savedState = loadToolbarState(props.persistToolbarState); const rect = containerRef?.getBoundingClientRect(); const viewport = getVisualViewport(); diff --git a/packages/react-grab/src/components/toolbar/state.ts b/packages/react-grab/src/components/toolbar/state.ts index e02b9615f..571c5ce22 100644 --- a/packages/react-grab/src/components/toolbar/state.ts +++ b/packages/react-grab/src/components/toolbar/state.ts @@ -5,7 +5,10 @@ export type SnapEdge = "top" | "bottom" | "left" | "right"; const STORAGE_KEY = "react-grab-toolbar-state"; -export const loadToolbarState = (): ToolbarState | null => { +export const loadToolbarState = ( + persistToolbarState = true, +): ToolbarState | null => { + if (!persistToolbarState) return null; try { const serializedToolbarState = localStorage.getItem(STORAGE_KEY); if (!serializedToolbarState) return null; @@ -28,7 +31,11 @@ export const loadToolbarState = (): ToolbarState | null => { return null; }; -export const saveToolbarState = (state: ToolbarState): void => { +export const saveToolbarState = ( + state: ToolbarState, + persistToolbarState = true, +): void => { + if (!persistToolbarState) return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (error) { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 6fac4e7ec..f31a8dcd0 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -247,7 +247,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { store.current.isPendingDismiss, ); - const savedToolbarState = loadToolbarState(); + const shouldPersistToolbarState = () => + pluginRegistry.store.options.persistToolbarState !== false; + const resolveToolbarState = (): ToolbarState | null => + loadToolbarState(shouldPersistToolbarState()) ?? currentToolbarState(); + const savedToolbarState = loadToolbarState( + initialOptions.persistToolbarState !== false, + ); const [isEnabled, setIsEnabled] = createSignal( savedToolbarState?.enabled ?? true, ); @@ -1514,14 +1520,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleToggleEnabled = () => { const newEnabled = !isEnabled(); setIsEnabled(newEnabled); - const currentState = loadToolbarState(); + const currentState = resolveToolbarState(); const newState = { edge: currentState?.edge ?? "bottom", ratio: currentState?.ratio ?? 0.5, collapsed: currentState?.collapsed ?? false, enabled: newEnabled, }; - saveToolbarState(newState); + saveToolbarState(newState, shouldPersistToolbarState()); setCurrentToolbarState(newState); toolbarStateChangeCallbacks.forEach((cb) => cb(newState)); if (!newEnabled) { @@ -3395,6 +3401,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; }} onToolbarSelectHoverChange={setIsToolbarSelectHovered} + persistToolbarState={shouldPersistToolbarState()} contextMenuPosition={contextMenuPosition()} contextMenuBounds={contextMenuBounds()} contextMenuTagName={contextMenuTagName()} @@ -3505,16 +3512,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { inToggleFeedbackPeriod = false; } }, - getToolbarState: () => loadToolbarState(), + getToolbarState: () => resolveToolbarState(), setToolbarState: (state: Partial) => { - const currentState = loadToolbarState(); + const currentState = resolveToolbarState(); const newState = { edge: state.edge ?? currentState?.edge ?? "bottom", ratio: state.ratio ?? currentState?.ratio ?? 0.5, collapsed: state.collapsed ?? currentState?.collapsed ?? false, enabled: state.enabled ?? currentState?.enabled ?? true, }; - saveToolbarState(newState); + saveToolbarState(newState, shouldPersistToolbarState()); setCurrentToolbarState(newState); if (state.enabled !== undefined && state.enabled !== isEnabled()) { setIsEnabled(state.enabled); diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 2f01cddec..97c4f583f 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -35,6 +35,7 @@ interface OptionsState { activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; freezeReactUpdates: boolean; + persistToolbarState: boolean; } const DEFAULT_OPTIONS: OptionsState = { @@ -45,6 +46,7 @@ const DEFAULT_OPTIONS: OptionsState = { activationKey: undefined, getContent: undefined, freezeReactUpdates: true, + persistToolbarState: true, }; interface PluginStoreState { diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 5ae5df600..216ebe6da 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -366,6 +366,12 @@ export interface Options { * @default true */ freezeReactUpdates?: boolean; + /** + * Whether to persist toolbar state (position, collapsed, enabled) to localStorage. + * Set to false when an external consumer (e.g. web extension) handles persistence. + * @default true + */ + persistToolbarState?: boolean; } export interface SettableOptions extends Options { @@ -505,6 +511,7 @@ export interface ReactGrabRendererProps { callback: (state: ToolbarState) => void, ) => () => void; onToolbarSelectHoverChange?: (isHovered: boolean) => void; + persistToolbarState?: boolean; contextMenuPosition?: { x: number; y: number } | null; contextMenuBounds?: OverlayBounds | null; contextMenuTagName?: string; diff --git a/packages/web-extension/package.json b/packages/web-extension/package.json index be26b048d..3fb93721b 100644 --- a/packages/web-extension/package.json +++ b/packages/web-extension/package.json @@ -13,6 +13,7 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-grab": "workspace:*", + "sinking": "^0.0.6", "turndown": "^7.2.0", "webextension-polyfill": "^0.12.0" }, diff --git a/packages/web-extension/src/content/bridge.ts b/packages/web-extension/src/content/bridge.ts index f5c10efd6..0647c661a 100644 --- a/packages/web-extension/src/content/bridge.ts +++ b/packages/web-extension/src/content/bridge.ts @@ -8,16 +8,6 @@ chrome.storage.onChanged.addListener((changes) => { "*", ); } - - if (changes.react_grab_toolbar_state) { - const newState = changes.react_grab_toolbar_state.newValue; - if (newState) { - window.postMessage( - { type: "__REACT_GRAB_TOOLBAR_STATE_CHANGE__", state: newState }, - "*", - ); - } - } }); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { @@ -38,25 +28,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { window.addEventListener("message", (event) => { if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") { - chrome.storage.local.get( - ["react_grab_enabled", "react_grab_toolbar_state"], - (result) => { - const enabled = result.react_grab_enabled ?? true; - const toolbarState = result.react_grab_toolbar_state ?? null; + chrome.storage.local.get(["react_grab_enabled"], (result) => { + const enabled = result.react_grab_enabled ?? true; - window.postMessage( - { - type: "__REACT_GRAB_STATE_RESPONSE__", - enabled, - toolbarState, - }, - "*", - ); - }, - ); + window.postMessage( + { + type: "__REACT_GRAB_STATE_RESPONSE__", + enabled, + }, + "*", + ); + }); } - if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") { - chrome.storage.local.set({ react_grab_toolbar_state: event.data.state }); + if (event.data?.type === "__REACT_GRAB_GET_WORKER_URL__") { + const workerUrl = chrome.runtime.getURL("src/worker.ts"); + window.postMessage({ type: "__REACT_GRAB_WORKER_URL__", workerUrl }, "*"); } }); diff --git a/packages/web-extension/src/content/react-grab.ts b/packages/web-extension/src/content/react-grab.ts index 5b81615ff..ebe0b1c8b 100644 --- a/packages/web-extension/src/content/react-grab.ts +++ b/packages/web-extension/src/content/react-grab.ts @@ -1,10 +1,17 @@ import { init } from "react-grab/core"; -import type { Options, ReactGrabAPI } from "react-grab"; +import type { Options, ReactGrabAPI, ToolbarState } from "react-grab"; import TurndownService from "turndown"; import { LOCALHOST_INIT_DELAY_MS, STATE_QUERY_TIMEOUT_MS, } from "../constants.js"; +import { + initSinkingClient, + loadToolbarStateFromSinking, + saveToolbarStateToSinking, + subscribeToToolbarState, + getCachedToolbarState, +} from "../storage/client.js"; declare global { interface Window { @@ -19,48 +26,62 @@ const isLocalhost = const turndownService = new TurndownService(); -interface ToolbarState { - edge: "top" | "bottom" | "left" | "right"; - ratio: number; - collapsed: boolean; - enabled: boolean; -} - let extensionApi: ReactGrabAPI | null = null; let lastToolbarState: ToolbarState | null = null; let isApplyingExternalState = false; let stateChangeUnsubscribe: (() => void) | null = null; +let sinkingUnsubscribe: (() => void) | null = null; + +const isToolbarStateEqual = ( + stateA: ToolbarState | null, + stateB: ToolbarState | null, +): boolean => { + if (stateA === stateB) return true; + if (!stateA || !stateB) return false; + return ( + stateA.edge === stateB.edge && + stateA.ratio === stateB.ratio && + stateA.collapsed === stateB.collapsed && + stateA.enabled === stateB.enabled + ); +}; const handleToolbarStateFromApi = (toolbarState: ToolbarState | null): void => { if (isApplyingExternalState) return; if (!toolbarState) return; - if ( - lastToolbarState && - lastToolbarState.edge === toolbarState.edge && - lastToolbarState.ratio === toolbarState.ratio && - lastToolbarState.collapsed === toolbarState.collapsed && - lastToolbarState.enabled === toolbarState.enabled - ) { - return; - } + if (isToolbarStateEqual(lastToolbarState, toolbarState)) return; lastToolbarState = toolbarState; - window.postMessage( - { type: "__REACT_GRAB_TOOLBAR_STATE_SAVE__", state: toolbarState }, - "*", - ); + void saveToolbarStateToSinking(toolbarState); }; -const subscribeToStateChanges = (api: ReactGrabAPI): void => { - if (stateChangeUnsubscribe) { - stateChangeUnsubscribe(); +const handleSinkingChange = (): void => { + const cachedState = getCachedToolbarState(); + if (!cachedState) return; + if (isToolbarStateEqual(lastToolbarState, cachedState)) return; + + lastToolbarState = cachedState; + const api = getActiveApi(); + if (api) { + isApplyingExternalState = true; + api.setToolbarState(cachedState); + isApplyingExternalState = false; } - stateChangeUnsubscribe = api.onToolbarStateChange((state) => { - handleToolbarStateFromApi(state); - }); +}; + +const subscribeToStateChanges = (api: ReactGrabAPI): void => { + stateChangeUnsubscribe?.(); + stateChangeUnsubscribe = api.onToolbarStateChange(handleToolbarStateFromApi); + + sinkingUnsubscribe?.(); + sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange); +}; + +const disableCorePersistence = (api: ReactGrabAPI): void => { + api.setOptions({ persistToolbarState: false }); }; const createExtensionApi = (): ReactGrabAPI => { - const options: Options = { enabled: true }; + const options: Options = { enabled: true, persistToolbarState: false }; if (!isLocalhost) { options.getContent = (elements) => { @@ -95,6 +116,8 @@ const initializeReactGrab = (): Promise => { const delayedApi = getActiveApi(); if (delayedApi) { extensionApi = delayedApi; + disableCorePersistence(delayedApi); + subscribeToStateChanges(delayedApi); resolve(delayedApi); return; } @@ -116,6 +139,7 @@ window.addEventListener("react-grab:init", (event) => { } extensionApi = pageApi; window.__REACT_GRAB__ = pageApi; + disableCorePersistence(pageApi); subscribeToStateChanges(pageApi); }); @@ -128,37 +152,20 @@ const handleToggle = async (enabled: boolean): Promise => { } }; -const handleToolbarStateChange = async (state: ToolbarState): Promise => { - if (isApplyingExternalState) return; - - await initializeReactGrab(); - const api = getActiveApi(); - if (api) { - isApplyingExternalState = true; - api.setToolbarState(state); - isApplyingExternalState = false; - } -}; - window.addEventListener("message", (event: MessageEvent) => { if (event.data?.type === "__REACT_GRAB_EXTENSION_TOGGLE__") { void handleToggle(event.data.enabled); } - - if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_CHANGE__") { - void handleToolbarStateChange(event.data.state); - } }); interface InitialState { enabled: boolean; - toolbarState: ToolbarState | null; } const queryInitialState = (): Promise => { return new Promise((resolve) => { const timeout = setTimeout(() => { - resolve({ enabled: true, toolbarState: null }); + resolve({ enabled: true }); }, STATE_QUERY_TIMEOUT_MS); const handler = (event: MessageEvent) => { @@ -167,7 +174,6 @@ const queryInitialState = (): Promise => { window.removeEventListener("message", handler); resolve({ enabled: event.data.enabled ?? true, - toolbarState: event.data.toolbarState ?? null, }); } }; @@ -177,16 +183,52 @@ const queryInitialState = (): Promise => { }); }; +const requestWorkerUrl = (): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(null); + }, STATE_QUERY_TIMEOUT_MS); + + const handler = (event: MessageEvent) => { + if (event.data?.type === "__REACT_GRAB_WORKER_URL__") { + clearTimeout(timeout); + window.removeEventListener("message", handler); + resolve(event.data.workerUrl ?? null); + } + }; + + window.addEventListener("message", handler); + window.postMessage({ type: "__REACT_GRAB_GET_WORKER_URL__" }, "*"); + }); +}; + const startup = async (): Promise => { - const initialState = await queryInitialState(); + const [initialState, workerUrl] = await Promise.all([ + queryInitialState(), + requestWorkerUrl(), + ]); + + if (workerUrl) { + initSinkingClient(workerUrl); + + const existingApi = getActiveApi(); + if (existingApi) { + sinkingUnsubscribe?.(); + sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange); + } + } + const api = await initializeReactGrab(); if (api) { - if (initialState.toolbarState) { + const sinkingToolbarState = await loadToolbarStateFromSinking(); + if (sinkingToolbarState) { isApplyingExternalState = true; - api.setToolbarState(initialState.toolbarState); + api.setToolbarState(sinkingToolbarState); isApplyingExternalState = false; - } else if (!initialState.enabled) { + } + + if (!initialState.enabled) { api.setEnabled(false); } } diff --git a/packages/web-extension/src/manifest.json b/packages/web-extension/src/manifest.json index d421d9422..810fd69ac 100644 --- a/packages/web-extension/src/manifest.json +++ b/packages/web-extension/src/manifest.json @@ -37,6 +37,12 @@ "world": "MAIN" } ], + "web_accessible_resources": [ + { + "resources": ["src/worker.ts"], + "matches": [""] + } + ], "host_permissions": [""], "permissions": ["storage"] } diff --git a/packages/web-extension/src/storage/client.ts b/packages/web-extension/src/storage/client.ts new file mode 100644 index 000000000..43f998b86 --- /dev/null +++ b/packages/web-extension/src/storage/client.ts @@ -0,0 +1,68 @@ +import type { ToolbarState } from "react-grab"; +import { Sinking, type DatabaseSchema } from "sinking/core"; + +interface ToolbarStateRecord extends ToolbarState { + id: string; +} + +const TOOLBAR_STATE_STORE = "toolbar-state"; +const TOOLBAR_STATE_KEY = "default"; + +const schema: DatabaseSchema = { + name: "react-grab", + version: 1, + stores: { + [TOOLBAR_STATE_STORE]: { keyPath: "id" }, + }, +}; + +let sinkingClient: Sinking | null = null; + +const toToolbarState = (record: ToolbarStateRecord): ToolbarState => ({ + edge: record.edge, + ratio: record.ratio, + collapsed: record.collapsed, + enabled: record.enabled, +}); + +const getToolbarStateQuery = () => + sinkingClient!.get( + TOOLBAR_STATE_STORE, + TOOLBAR_STATE_KEY, + ); + +export const initSinkingClient = (workerUrl: string | URL): Sinking => { + if (sinkingClient) return sinkingClient; + sinkingClient = new Sinking({ workerUrl, schema }); + return sinkingClient; +}; + +export const loadToolbarStateFromSinking = + async (): Promise => { + if (!sinkingClient) return null; + const record = await getToolbarStateQuery(); + if (!record) return null; + return toToolbarState(record); + }; + +export const saveToolbarStateToSinking = async ( + state: ToolbarState, +): Promise => { + if (!sinkingClient) return; + const record: ToolbarStateRecord = { ...state, id: TOOLBAR_STATE_KEY }; + await sinkingClient.put(TOOLBAR_STATE_STORE, TOOLBAR_STATE_KEY, record); +}; + +export const subscribeToToolbarState = (listener: () => void): (() => void) => { + if (!sinkingClient) return () => {}; + return sinkingClient.subscribe(getToolbarStateQuery().description, listener); +}; + +export const getCachedToolbarState = (): ToolbarState | null => { + if (!sinkingClient) return null; + const record = sinkingClient.getCached( + getToolbarStateQuery().description, + ); + if (!record) return null; + return toToolbarState(record); +}; diff --git a/packages/web-extension/src/worker.ts b/packages/web-extension/src/worker.ts new file mode 100644 index 000000000..ce187dba6 --- /dev/null +++ b/packages/web-extension/src/worker.ts @@ -0,0 +1 @@ +import "sinking/worker"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c49a3050..803696390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: react-grab: specifier: workspace:* version: link:../react-grab + sinking: + specifier: ^0.0.6 + version: 0.0.6(react@19.1.2) turndown: specifier: ^7.2.0 version: 7.2.2 @@ -3051,8 +3054,8 @@ packages: engines: {node: '>=20'} hasBin: true - '@sourcegraph/amp@0.0.1770231444-g53361d': - resolution: {integrity: sha512-TuwM7W9QSkJ7J3ZouQBGu7G3snjtsWCBl61+e2zdmF8YjHwYFhNbUjeK7PHDu7NSSqOvR7Wu04Chmgpx50HIWA==} + '@sourcegraph/amp@0.0.1770352274-gd36e02': + resolution: {integrity: sha512-Gs7m7nomhJ79jGneQcHYoG8jWvb1aLfxVci3KgzCeIOya19662nEi/Y+T8LsnZa5eI3tSJWa2mDRFeojECVahg==} engines: {node: '>=20'} hasBin: true @@ -6341,6 +6344,11 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sinking@0.0.6: + resolution: {integrity: sha512-VzNzbrkZdsirp6QfjqysKgv223uB8QhP15WwYoQi+DPCi9lwjLBDFBFq280jP8AuXqbySlHZ/ZogoJqtMJHuJg==} + peerDependencies: + react: '>=19' + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -9033,14 +9041,14 @@ snapshots: '@sourcegraph/amp-sdk@0.1.0-20251210081226-g90e3892': dependencies: - '@sourcegraph/amp': 0.0.1770231444-g53361d + '@sourcegraph/amp': 0.0.1770352274-gd36e02 zod: 3.25.76 '@sourcegraph/amp@0.0.1767830505-ga62310': dependencies: '@napi-rs/keyring': 1.1.9 - '@sourcegraph/amp@0.0.1770231444-g53361d': + '@sourcegraph/amp@0.0.1770352274-gd36e02': dependencies: '@napi-rs/keyring': 1.1.9 @@ -12574,6 +12582,10 @@ snapshots: signal-exit@4.1.0: {} + sinking@0.0.6(react@19.1.2): + dependencies: + react: 19.1.2 + sisteransi@1.0.5: {} slash@3.0.0: {}