UNPKG

@preprio/prepr-nextjs

Version:

Next.js package for Prepr CMS preview functionality with advanced debugging and visual editing capabilities

1 lines 145 kB
{"version":3,"sources":["../../src/react/index.tsx","../../src/react/components/toolbar/toolbar-provider.tsx","../../src/react/components/error-boundary.tsx","../../src/react/components/store/prepr-store-initializer.tsx","../../src/stores/prepr-store.ts","../../src/utils/index.ts","../../src/utils/errors.ts","../../src/utils/dom.ts","../../src/utils/debug.ts","../../src/utils/performance.ts","../../src/react/hooks/use-scroll-position.tsx","../../src/react/components/toolbar/prepr-toolbar.tsx","../../src/react/hooks/use-stega-scan.tsx","../../src/react/hooks/use-stega-overlay.tsx","../../src/react/hooks/use-stega-proximity.tsx","../../src/react/hooks/use-stega-elements.tsx","../../src/react/components/toolbar/toolbar-wrapper.tsx","../../src/react/components/toolbar/toolbar.tsx","../../src/react/hooks/use-modal.ts","../../src/react/components/toolbar/toolbar-content.tsx","../../src/react/components/ui/status-indicator-pill.tsx","../../src/react/components/icons/xmark.tsx","../../src/react/hooks/use-i18n.ts","../../src/i18n/locales/en.json","../../src/i18n/locales/nl.json","../../src/i18n/index.ts","../../src/react/components/ui/close-edit-mode-pill.tsx","../../src/react/components/ui/reset-button.tsx","../../src/react/components/icons/rotate.tsx","../../src/react/components/ui/icon.tsx","../../src/react/components/ui/logo.tsx","../../src/react/components/ui/prepr-tracking-pixel.tsx","../../src/react/components/selectors/segment-selector.tsx","../../src/react/components/icons/sort-down.tsx","../../src/react/components/selectors/variant-selector.tsx","../../src/react/components/selectors/radio-selector.tsx","../../src/react/components/ui/tooltip.tsx","../../src/react/components/selectors/edit-mode-selector.tsx","../../src/react/components/selectors/preview-mode-selector.tsx","../../src/react/components/toolbar/toolbar-button.tsx","../../src/react/components/toolbar/toolbar-indicator-wrapper.tsx"],"sourcesContent":["'use client';\n\nexport {\n PreprToolbarProvider,\n usePreprToolbar,\n} from './components/toolbar/toolbar-provider';\n\nexport { default as PreprToolbar } from './components/toolbar/prepr-toolbar';\nexport { default as PreprTrackingPixel } from './components/ui/prepr-tracking-pixel';\nexport { useTranslations } from './hooks/use-i18n';\n","'use client';\n\nimport React, { ReactNode, useEffect, useCallback, useMemo } from 'react';\nimport { PreprToolbarOptions, PreprToolbarProps } from '../../../types';\nimport { StegaErrorBoundary } from '../error-boundary';\nimport { PreprStoreInitializer } from '../store/prepr-store-initializer';\nimport { initDebugLogger } from '../../../utils/debug';\nimport useScrollPosition from '../../hooks/use-scroll-position';\nimport { usePreprStore, usePreviewMode } from '../../../stores/prepr-store';\n\ninterface PreprToolbarProviderProps {\n children: ReactNode;\n props: PreprToolbarProps;\n options?: PreprToolbarOptions;\n}\n\nexport const PreprToolbarProvider: React.FC<PreprToolbarProviderProps> = ({\n children,\n props,\n options,\n}) => {\n // Initialize debug logger with options\n useEffect(() => {\n const debugEnabled = options?.debug ?? false;\n initDebugLogger(debugEnabled);\n }, [options?.debug]);\n\n // Initialize scroll position handling for iframe communication\n useScrollPosition();\n\n // Initialize locale from options\n const setLocale = usePreprStore(s => s.setLocale);\n useEffect(() => {\n if (options?.locale) {\n setLocale(options.locale);\n }\n }, [options?.locale, setLocale]);\n\n // Fallback: auto-detect browser language when no locale is provided\n useEffect(() => {\n if (options?.locale) return; // Respect explicitly provided locale\n\n if (typeof navigator !== 'undefined') {\n const candidates =\n Array.isArray(navigator.languages) && navigator.languages.length\n ? navigator.languages\n : [navigator.language];\n\n const normalized = candidates\n .filter(Boolean)\n .map(l => l.toLowerCase())\n .map(l => l.split('-')[0]);\n\n // Restrict to supported locales; default to 'en'\n const supported: Array<'en' | 'nl'> = ['en', 'nl'];\n const match = normalized.find(l =>\n supported.includes(l as 'en' | 'nl')\n ) as 'en' | 'nl' | undefined;\n\n setLocale(match ?? 'en');\n }\n }, [options?.locale, setLocale]);\n\n return (\n <StegaErrorBoundary>\n <PreprStoreInitializer props={props}>{children}</PreprStoreInitializer>\n </StegaErrorBoundary>\n );\n};\n\n// Legacy hook for backward compatibility\nexport const usePreprToolbar = () => {\n // Convenience hook backed by Zustand store\n const selectedSegment = usePreprStore(s => s.selectedSegment);\n const segments = usePreprStore(s => s.segments);\n const emptySegment = usePreprStore(s => s.emptySegment);\n const setSelectedSegment = usePreprStore(s => s.setSelectedSegment);\n\n const selectedVariant = usePreprStore(s => s.selectedVariant);\n const emptyVariant = usePreprStore(s => s.emptyVariant);\n const setSelectedVariant = usePreprStore(s => s.setSelectedVariant);\n\n const editMode = usePreprStore(s => s.editMode);\n const setEditMode = usePreprStore(s => s.setEditMode);\n const isIframe = usePreprStore(s => s.isIframe);\n\n const resetPersonalizationStore = usePreprStore(s => s.resetPersonalization);\n const resetAllStore = usePreprStore(s => s.resetAll);\n const previewMode = usePreviewMode();\n\n const resetPersonalization = useCallback(() => {\n resetPersonalizationStore();\n }, [resetPersonalizationStore]);\n\n const resetAll = useCallback(() => {\n resetAllStore();\n }, [resetAllStore]);\n\n return useMemo(\n () => ({\n isPreviewMode: previewMode,\n activeSegment: selectedSegment._id,\n activeVariant: selectedVariant,\n data: segments,\n emptySegment,\n segmentList: segments,\n selectedSegment,\n setSelectedSegment,\n emptyVariant,\n selectedVariant,\n setSelectedVariant,\n editMode,\n setEditMode,\n isIframe,\n resetPersonalization,\n resetAll,\n }),\n [\n previewMode,\n selectedSegment,\n segments,\n emptySegment,\n setSelectedSegment,\n selectedVariant,\n emptyVariant,\n setSelectedVariant,\n editMode,\n setEditMode,\n isIframe,\n resetPersonalization,\n resetAll,\n ]\n );\n};\n","'use client';\n\nimport React, { Component, ReactNode } from 'react';\n\ninterface Props {\n children: ReactNode;\n fallback?: ReactNode;\n}\n\ninterface State {\n hasError: boolean;\n error: Error | null;\n}\n\nexport class StegaErrorBoundary extends Component<Props, State> {\n constructor(props: Props) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): State {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n console.error('Stega Error Boundary caught an error:', error, errorInfo);\n\n // In production, you might want to send this to an error tracking service\n if (process.env.NODE_ENV === 'production') {\n // sendToErrorTrackingService({ error, errorInfo });\n }\n }\n\n render() {\n if (this.state.hasError) {\n if (this.props.fallback) {\n return this.props.fallback;\n }\n\n return (\n <div className=\"p-rounded-lg p-border p-border-red-200 p-bg-red-50 p-p-4 p-text-sm p-text-red-800\">\n <div className=\"p-mb-2 p-font-semibold\">Preview mode unavailable</div>\n <div className=\"p-text-red-600\">\n {this.state.error?.message || 'An unexpected error occurred'}\n </div>\n </div>\n );\n }\n\n return this.props.children;\n }\n}\n","'use client';\n\nimport React, { ReactNode, useEffect } from 'react';\nimport { usePreprStore } from '../../../stores/prepr-store';\nimport { PreprToolbarProps } from '../../../types';\n\ninterface PreprStoreInitializerProps {\n children: ReactNode;\n props: PreprToolbarProps;\n}\n\nexport function PreprStoreInitializer({\n children,\n props,\n}: PreprStoreInitializerProps) {\n const initialize = usePreprStore(state => state.initialize);\n const setIsIframe = usePreprStore(state => state.setIsIframe);\n const editMode = usePreprStore(state => state.editMode);\n const setEditMode = usePreprStore(state => state.setEditMode);\n const setToolbarOpen = usePreprStore(state => state.setToolbarOpen);\n\n // Initialize store with server data\n useEffect(() => {\n initialize({\n initialSegments: props.data,\n activeSegment: props.activeSegment,\n activeVariant: props.activeVariant,\n });\n }, [initialize, props.data, props.activeSegment, props.activeVariant]);\n\n // Handle iframe detection\n useEffect(() => {\n if (typeof window !== 'undefined' && window.parent !== window) {\n setIsIframe(true);\n }\n }, [setIsIframe]);\n\n // Handle escape key for edit mode\n useEffect(() => {\n const handleEscapeKey = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && editMode) {\n setEditMode(false);\n }\n };\n\n if (editMode) {\n document.addEventListener('keydown', handleEscapeKey);\n }\n\n return () => {\n document.removeEventListener('keydown', handleEscapeKey);\n };\n }, [editMode, setEditMode]);\n\n // Initialize preview mode from cookie\n useEffect(() => {\n if (typeof window !== 'undefined') {\n const getCookie = (name: string): string | null => {\n if (typeof document === 'undefined') return null;\n const value = `; ${document.cookie}`;\n const parts = value.split(`; ${name}=`);\n if (parts.length === 2) return parts.pop()?.split(';').shift() || null;\n return null;\n };\n\n const cookieValue = getCookie('Prepr-Preview-Mode');\n if (cookieValue !== null) {\n // Don't trigger events on initial load\n usePreprStore.setState({ previewMode: cookieValue === 'true' });\n }\n\n const toolbarOpenCookie = getCookie('Prepr-Toolbar-Open');\n if (toolbarOpenCookie !== null) {\n setToolbarOpen(toolbarOpenCookie === 'true');\n }\n }\n }, []);\n\n return <>{children}</>;\n}\n","'use client';\n\nimport { create } from 'zustand';\nimport { subscribeWithSelector } from 'zustand/middleware';\nimport { PreprSegment } from '../types';\nimport { sendPreprEvent } from '../utils';\n\ninterface PreprStore {\n // Locale slice\n locale: string;\n setLocale: (locale: string) => void;\n\n // Segment slice\n segments: PreprSegment[];\n selectedSegment: PreprSegment;\n emptySegment: PreprSegment;\n setSelectedSegment: (segment: PreprSegment) => void;\n initializeSegments: (\n segments: readonly PreprSegment[],\n activeSegment?: string | null\n ) => void;\n\n // Variant slice\n selectedVariant: string | null;\n emptyVariant: string;\n setSelectedVariant: (variant: string | null) => void;\n initializeVariant: (activeVariant?: string | null) => void;\n\n // Edit mode slice\n editMode: boolean;\n isIframe: boolean;\n setEditMode: (mode: boolean) => void;\n setIsIframe: (isIframe: boolean) => void;\n\n // Preview mode slice\n previewMode: boolean;\n setPreviewMode: (mode: boolean) => void;\n\n // Toolbar visibility slice\n toolbarOpen: boolean;\n setToolbarOpen: (open: boolean) => void;\n\n // Reset actions\n resetPersonalization: () => void;\n resetAll: () => void;\n\n // Store initialization\n initialize: (props: {\n initialSegments: readonly PreprSegment[];\n activeSegment?: string | null;\n activeVariant?: string | null;\n }) => void;\n}\n\nexport const usePreprStore = create<PreprStore>()(\n subscribeWithSelector((set, get) => ({\n // Initial i18n state\n locale: 'en',\n setLocale: (locale: string) => set({ locale }),\n\n // Initial segment state\n segments: [],\n selectedSegment: {\n name: 'Choose segment',\n _id: 'null',\n },\n emptySegment: {\n name: 'Choose segment',\n _id: 'null',\n },\n setSelectedSegment: (segment: PreprSegment) => {\n set({ selectedSegment: segment });\n sendPreprEvent('segment_changed', { segment: segment._id });\n },\n initializeSegments: (\n initialSegments: readonly PreprSegment[],\n activeSegment?: string | null\n ) => {\n const segmentList: PreprSegment[] = [\n {\n _id: 'all_other_users',\n name: 'All other users',\n },\n ...initialSegments,\n ];\n\n const emptySegment: PreprSegment = {\n name: 'Choose segment',\n _id: 'null',\n };\n\n const selectedSegment =\n (segmentList &&\n segmentList.filter(\n (segmentData: PreprSegment) => segmentData._id === activeSegment\n )[0]) ||\n emptySegment;\n\n set({\n segments: segmentList,\n selectedSegment,\n emptySegment,\n });\n },\n\n // Initial variant state\n selectedVariant: 'A',\n emptyVariant: 'A',\n setSelectedVariant: (variant: string | null) => {\n set({ selectedVariant: variant });\n sendPreprEvent('variant_changed', { variant: variant ?? undefined });\n },\n initializeVariant: (activeVariant?: string | null) => {\n set({ selectedVariant: activeVariant || 'A' });\n },\n\n // Initial edit mode state\n editMode: false,\n isIframe: false,\n setEditMode: (mode: boolean) => {\n set({ editMode: mode });\n sendPreprEvent('edit_mode_toggled', { editMode: mode });\n },\n setIsIframe: (isIframe: boolean) => {\n set({ isIframe });\n },\n\n // Initial preview mode state\n previewMode: true,\n setPreviewMode: (mode: boolean) => {\n set({ previewMode: mode });\n // Ensure edit mode is off when toolbar is disabled\n if (!mode) {\n const { setEditMode } = get();\n setEditMode(false);\n }\n // Manage toolbar open state and cookie to restore after reload\n const { setToolbarOpen } = get();\n // Auto-close toolbar when toggling preview mode\n setToolbarOpen(false);\n // Cookie handling\n if (typeof document !== 'undefined') {\n const expires = new Date();\n expires.setTime(expires.getTime() + 365 * 24 * 60 * 60 * 1000);\n document.cookie = `Prepr-Preview-Mode=${mode.toString()};expires=${expires.toUTCString()};path=/`;\n document.cookie = `Prepr-Toolbar-Open=false;expires=${expires.toUTCString()};path=/`;\n }\n sendPreprEvent('preview_mode_toggled', { previewMode: mode });\n // Refresh the page to apply the new preview mode state\n if (typeof window !== 'undefined') {\n window.location.reload();\n }\n },\n\n // Toolbar visibility\n toolbarOpen: false,\n setToolbarOpen: (open: boolean) => {\n set({ toolbarOpen: open });\n if (typeof document !== 'undefined') {\n const expires = new Date();\n expires.setTime(expires.getTime() + 365 * 24 * 60 * 60 * 1000);\n document.cookie = `Prepr-Toolbar-Open=${open.toString()};expires=${expires.toUTCString()};path=/`;\n }\n },\n\n // Reset actions\n resetPersonalization: () => {\n const {\n emptySegment,\n emptyVariant,\n setSelectedSegment,\n setSelectedVariant,\n } = get();\n setSelectedSegment(emptySegment);\n setSelectedVariant(emptyVariant);\n },\n resetAll: () => {\n const {\n emptySegment,\n emptyVariant,\n setSelectedSegment,\n setSelectedVariant,\n setEditMode,\n } = get();\n setSelectedSegment(emptySegment);\n setSelectedVariant(emptyVariant);\n setEditMode(false);\n },\n\n // Master initialization\n initialize: props => {\n const { initializeSegments, initializeVariant } = get();\n initializeSegments(props.initialSegments, props.activeSegment);\n initializeVariant(props.activeVariant);\n },\n }))\n);\n\n// Selectors for performance optimization\nexport const useSegments = () => usePreprStore(state => state.segments);\nexport const useSelectedSegment = () =>\n usePreprStore(state => state.selectedSegment);\nexport const useSelectedVariant = () =>\n usePreprStore(state => state.selectedVariant);\nexport const useEditMode = () => usePreprStore(state => state.editMode);\nexport const useIsIframe = () => usePreprStore(state => state.isIframe);\nexport const usePreviewMode = () => usePreprStore(state => state.previewMode);\nexport const useLocale = () => usePreprStore(state => state.locale);\n","import { ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\nimport { PreprEventType } from '../types';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\n// Define specific types for Prepr events\nexport interface PreprEventData {\n readonly segment?: string;\n readonly variant?: string;\n readonly editMode?: boolean;\n readonly [key: string]: string | boolean | number | undefined;\n}\n\n/**\n * Sends a Prepr event to both the current window and parent window\n * @param event - The event type to send\n * @param data - Optional event data\n */\nexport function sendPreprEvent(\n event: PreprEventType,\n data?: PreprEventData\n): void {\n if (typeof window !== 'undefined') {\n const message = {\n name: 'prepr_preview_bar',\n event,\n ...data,\n };\n\n // Send to current window for local event handling\n window.dispatchEvent(\n new CustomEvent('prepr_preview_bar', { detail: message })\n );\n\n // Send to parent window if available\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n }\n}\n\n// Export error handling utilities\nexport * from './errors';\n\n// Export DOM service\nexport * from './dom';\n\n// Export debug utilities\nexport * from './debug';\n\n// Export performance utilities\nexport * from './performance';\n","export const StegaError = {\n DECODE_FAILED: 'STEGA_DECODE_FAILED',\n INVALID_FORMAT: 'STEGA_INVALID_FORMAT',\n DOM_MANIPULATION_FAILED: 'DOM_MANIPULATION_FAILED',\n CONTEXT_NOT_FOUND: 'CONTEXT_NOT_FOUND',\n} as const;\n\nexport type StegaErrorType = (typeof StegaError)[keyof typeof StegaError];\n\n// Define specific types for error additional data\nexport interface ErrorAdditionalData {\n input?: string;\n element?: HTMLElement;\n context?: string;\n [key: string]: string | HTMLElement | undefined;\n}\n\nexport interface ErrorInfo {\n type: StegaErrorType;\n context: string;\n message: string;\n timestamp: string;\n stack?: string;\n additionalData?: ErrorAdditionalData;\n}\n\nexport function createErrorInfo(\n type: StegaErrorType,\n context: string,\n error: Error,\n additionalData?: ErrorAdditionalData\n): ErrorInfo {\n return {\n type,\n context,\n message: error.message,\n timestamp: new Date().toISOString(),\n stack: error.stack,\n additionalData,\n };\n}\n\nexport function handleStegaError(\n error: Error,\n context: string,\n additionalData?: ErrorAdditionalData\n) {\n const errorInfo = createErrorInfo(\n StegaError.DECODE_FAILED,\n context,\n error,\n additionalData\n );\n\n console.error('Stega Error:', errorInfo);\n\n // In production, you might want to send this to an error tracking service\n if (process.env.NODE_ENV === 'production') {\n // sendToErrorTrackingService(errorInfo);\n }\n\n return errorInfo;\n}\n\nexport function handleDOMError(error: Error, context: string) {\n const errorInfo = createErrorInfo(\n StegaError.DOM_MANIPULATION_FAILED,\n context,\n error\n );\n\n console.error('DOM Error:', errorInfo);\n return errorInfo;\n}\n\nexport function handleContextError(contextName: string) {\n const error = new Error(`${contextName} must be used within its provider`);\n const errorInfo = createErrorInfo(\n StegaError.CONTEXT_NOT_FOUND,\n contextName,\n error\n );\n\n console.error('Context Error:', errorInfo);\n throw error;\n}\n","import { handleDOMError } from './errors';\n\nexport class DOMService {\n /**\n * Creates an HTML element with specified tag and class name\n */\n static createElement(tag: string, className: string): HTMLElement {\n try {\n const element = document.createElement(tag);\n element.className = className;\n return element;\n } catch (error) {\n handleDOMError(error as Error, 'createElement');\n throw error;\n }\n }\n\n /**\n * Appends an element to the document body\n */\n static appendToBody(element: HTMLElement): void {\n try {\n document.body.appendChild(element);\n } catch (error) {\n handleDOMError(error as Error, 'appendToBody');\n throw error;\n }\n }\n\n /**\n * Removes an element from the document body\n */\n static removeFromBody(element: HTMLElement): void {\n try {\n if (element.parentNode) {\n element.parentNode.removeChild(element);\n }\n } catch (error) {\n handleDOMError(error as Error, 'removeFromBody');\n throw error;\n }\n }\n\n /**\n * Sets multiple CSS properties on an element\n */\n static setElementStyles(\n element: HTMLElement,\n styles: Record<string, string>\n ): void {\n try {\n Object.entries(styles).forEach(([property, value]) => {\n element.style.setProperty(property, value);\n });\n } catch (error) {\n handleDOMError(error as Error, 'setElementStyles');\n throw error;\n }\n }\n\n /**\n * Gets the bounding rectangle of an element\n */\n static getElementRect(element: HTMLElement): DOMRect {\n try {\n return element.getBoundingClientRect();\n } catch (error) {\n handleDOMError(error as Error, 'getElementRect');\n throw error;\n }\n }\n\n /**\n * Checks if an element is in the viewport\n */\n static isElementInViewport(element: HTMLElement): boolean {\n try {\n const rect = this.getElementRect(element);\n return (\n rect.top >= 0 &&\n rect.left >= 0 &&\n rect.bottom <=\n (window.innerHeight || document.documentElement.clientHeight) &&\n rect.right <=\n (window.innerWidth || document.documentElement.clientWidth)\n );\n } catch (error) {\n handleDOMError(error as Error, 'isElementInViewport');\n return false;\n }\n }\n\n /**\n * Calculates distance between two points\n */\n static calculateDistance(\n x1: number,\n y1: number,\n x2: number,\n y2: number\n ): number {\n return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));\n }\n\n /**\n * Finds the closest element to a point from a list of elements\n */\n static findClosestElement(\n pointX: number,\n pointY: number,\n elements: NodeListOf<Element>\n ): HTMLElement | null {\n try {\n let closestElement: HTMLElement | null = null;\n let minDistance = Infinity;\n\n elements.forEach(element => {\n const rect = this.getElementRect(element as HTMLElement);\n const distance = this.calculateDistance(\n pointX,\n pointY,\n rect.left + rect.width / 2,\n rect.top + rect.height / 2\n );\n\n if (distance < minDistance) {\n minDistance = distance;\n closestElement = element as HTMLElement;\n }\n });\n\n return closestElement;\n } catch (error) {\n handleDOMError(error as Error, 'findClosestElement');\n return null;\n }\n }\n\n /**\n * Safely adds event listeners\n */\n static addEventListener(\n element: EventTarget,\n event: string,\n handler: EventListener,\n options?: AddEventListenerOptions\n ): void {\n try {\n element.addEventListener(event, handler, options);\n } catch (error) {\n handleDOMError(error as Error, 'addEventListener');\n throw error;\n }\n }\n\n /**\n * Safely removes event listeners\n */\n static removeEventListener(\n element: EventTarget,\n event: string,\n handler: EventListener,\n options?: EventListenerOptions\n ): void {\n try {\n element.removeEventListener(event, handler, options);\n } catch (error) {\n handleDOMError(error as Error, 'removeEventListener');\n throw error;\n }\n }\n}\n","/**\n * Debug utility for Prepr Next.js package\n * Provides centralized debug logging with performance optimizations\n */\n\n// Define specific types for debug arguments\nexport type DebugArg = string | number | boolean | null | undefined | object;\n\ninterface DebugOptions {\n enabled?: boolean;\n prefix?: string;\n}\n\nclass DebugLogger {\n private options: DebugOptions;\n\n constructor(options: DebugOptions) {\n this.options = {\n prefix: '[Prepr]',\n ...options,\n };\n }\n\n /**\n * Check if debug is enabled - checks both local and global state\n */\n private isEnabled(): boolean {\n // If this logger has a local enabled state, use it\n if (this.options.enabled !== undefined) {\n return this.options.enabled;\n }\n\n // Otherwise, check the global logger state\n return globalDebugLogger?.options?.enabled ?? false;\n }\n\n /**\n * Log a debug message if debug is enabled\n */\n log(message: string, ...args: DebugArg[]): void {\n if (!this.isEnabled()) return;\n\n const prefix = this.options.prefix;\n console.log(`${prefix} ${message}`, ...args);\n }\n\n /**\n * Log a debug warning if debug is enabled\n */\n warn(message: string, ...args: DebugArg[]): void {\n if (!this.isEnabled()) return;\n\n const prefix = this.options.prefix;\n console.warn(`${prefix} ${message}`, ...args);\n }\n\n /**\n * Log a debug error if debug is enabled\n */\n error(message: string, ...args: DebugArg[]): void {\n if (!this.isEnabled()) return;\n\n const prefix = this.options.prefix;\n console.error(`${prefix} ${message}`, ...args);\n }\n\n /**\n * Create a scoped logger with additional context\n */\n scope(scopeName: string): DebugLogger {\n return new DebugLogger({\n ...this.options,\n prefix: `${this.options.prefix}[${scopeName}]`,\n });\n }\n}\n\n// Global debug instance\nlet globalDebugLogger: DebugLogger | null = null;\n\n/**\n * Initialize the debug logger\n */\nexport function initDebugLogger(enabled: boolean = false): void {\n globalDebugLogger = new DebugLogger({ enabled });\n}\n\n/**\n * Get the debug logger instance\n */\nexport function getDebugLogger(): DebugLogger {\n if (!globalDebugLogger) {\n // Fallback to disabled logger if not initialized\n globalDebugLogger = new DebugLogger({ enabled: false });\n }\n return globalDebugLogger;\n}\n\n/**\n * Convenience function for logging\n */\nexport function debugLog(message: string, ...args: DebugArg[]): void {\n getDebugLogger().log(message, ...args);\n}\n\n/**\n * Convenience function for warning\n */\nexport function debugWarn(message: string, ...args: DebugArg[]): void {\n getDebugLogger().warn(message, ...args);\n}\n\n/**\n * Convenience function for errors\n */\nexport function debugError(message: string, ...args: DebugArg[]): void {\n getDebugLogger().error(message, ...args);\n}\n\n/**\n * Create a scoped debug logger that dynamically checks global debug state\n */\nexport function createScopedLogger(scopeName: string): DebugLogger {\n // Create a scoped logger without its own enabled state\n // This allows it to dynamically check the global logger state\n return new DebugLogger({\n prefix: `[Prepr][${scopeName}]`,\n });\n}\n","// Performance utilities\n\n/**\n * Throttled function with cancellation support\n */\nexport interface ThrottledFunction<T extends (...args: any[]) => any> {\n (...args: Parameters<T>): void;\n cancel(): void;\n}\n\n/**\n * Improved throttle function with better memory management and cancellation\n * @param func - The function to throttle\n * @param delay - The delay in milliseconds\n * @returns Throttled function with cancel method\n */\nexport function throttle<T extends (...args: any[]) => any>(\n func: T,\n delay: number\n): ThrottledFunction<T> {\n let timeoutId: NodeJS.Timeout | null = null;\n let lastExecTime = 0;\n\n const throttledFunc = ((...args: Parameters<T>) => {\n const currentTime = Date.now();\n const timeSinceLastExec = currentTime - lastExecTime;\n\n if (timeSinceLastExec >= delay) {\n func(...args);\n lastExecTime = currentTime;\n } else {\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n timeoutId = setTimeout(() => {\n func(...args);\n lastExecTime = Date.now();\n timeoutId = null;\n }, delay - timeSinceLastExec);\n }\n }) as ThrottledFunction<T>;\n\n throttledFunc.cancel = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n return throttledFunc;\n}\n\n/**\n * Debounce function with cancellation support\n * @param func - The function to debounce\n * @param delay - The delay in milliseconds\n * @returns Debounced function with cancel method\n */\nexport function debounce<T extends (...args: any[]) => any>(\n func: T,\n delay: number\n): ThrottledFunction<T> {\n let timeoutId: NodeJS.Timeout | null = null;\n\n const debouncedFunc = ((...args: Parameters<T>) => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n timeoutId = setTimeout(() => {\n func(...args);\n timeoutId = null;\n }, delay);\n }) as ThrottledFunction<T>;\n\n debouncedFunc.cancel = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n return debouncedFunc;\n}\n\n// Simple DOM element cache for querySelectorAll\nexport function createElementCache<T extends Element = Element>(\n query: string,\n ttl: number = 1000\n) {\n let cache: NodeListOf<T> | null = null;\n let lastCacheTime = 0;\n return () => {\n const now = Date.now();\n if (!cache || now - lastCacheTime > ttl) {\n cache = document.querySelectorAll<T>(query);\n lastCacheTime = now;\n }\n return cache;\n };\n}\n","import { useEffect } from 'react';\nimport { sendPreprEvent } from '../../utils';\nimport { createScopedLogger } from '../../utils';\n\n// Mark this hook as having side effects to prevent tree shaking\n\nexport default function useScrollPosition() {\n const debug = createScopedLogger('useScrollPosition');\n\n useEffect(() => {\n sendPreprEvent('getScrollPosition', {\n value: 0,\n });\n\n if (window.parent !== self) {\n let parentOrigin: string | null = null; //Get origin of parent outside iframe\n sendPreprEvent('loaded');\n\n const handleMessage = (evt: MessageEvent) => {\n debug.log('received message:', evt.data);\n\n if (evt?.data?.event === 'prepr:initVE' && !parentOrigin) {\n parentOrigin = evt.origin;\n\n if (evt.data?.scrollPosition) {\n debug.log('scrolling to position:', evt.data.scrollPosition);\n //Timeout needed in order to scroll to position\n setTimeout(() => {\n window.scrollTo(0, evt.data?.scrollPosition);\n }, 1);\n }\n }\n if (evt.origin !== parentOrigin) return;\n\n if (evt?.data?.event === 'prepr:getScrollPosition') {\n const currentScrollY =\n window.scrollY || document.documentElement.scrollTop;\n debug.log('sending scroll position:', currentScrollY);\n sendPreprEvent('getScrollPosition', {\n value: currentScrollY,\n });\n }\n };\n\n window.addEventListener('message', handleMessage);\n\n debug.log('set up iframe message listener');\n\n return () => {\n window.removeEventListener('message', handleMessage);\n debug.log('cleaned up iframe message listener');\n };\n } else {\n debug.log('not in iframe, skipping iframe setup');\n return undefined;\n }\n }, [debug]);\n\n // Return something to prevent tree shaking\n return true;\n}\n","'use client';\n\nimport React from 'react';\nimport { useEditMode } from '../../../stores/prepr-store';\nimport useStegaScan from '../../hooks/use-stega-scan';\nimport ToolbarWrapper from './toolbar-wrapper';\nimport ToolbarIndicatorWrapper from './toolbar-indicator-wrapper';\n\nexport default function PreprToolbar() {\n const editMode = useEditMode();\n\n useStegaScan(editMode);\n\n return (\n <>\n <ToolbarWrapper />\n <ToolbarIndicatorWrapper />\n </>\n );\n}\n","import { useEffect, useRef, useCallback, useMemo } from 'react';\nimport { DOMService } from '../../utils/dom';\nimport { createScopedLogger } from '../../utils/debug';\nimport { throttle } from '../../utils/performance';\nimport { useStegaOverlay } from './use-stega-overlay';\nimport { useStegaProximity } from './use-stega-proximity';\nimport { useStegaElements } from './use-stega-elements';\n\nexport default function useStegaScan(editMode: boolean): void {\n const debug = createScopedLogger('useStegaScan');\n const isInitializedRef = useRef(false);\n\n const {\n currentElementRef,\n hideTimeoutRef,\n createOverlay,\n showOverlay,\n hideOverlay,\n cleanup: cleanupOverlay,\n decode,\n } = useStegaOverlay();\n\n const {\n updateElementGradients,\n clearAllHighlights,\n refreshObserving,\n stopObserving,\n } = useStegaProximity();\n\n const {\n getElements,\n scanDocument,\n setupMutationObserver,\n cleanup: cleanupElements,\n } = useStegaElements();\n\n // Memoize the throttled mouse move handler\n const throttledMouseMove = useMemo(\n () =>\n throttle((e: Event) => {\n const mouseEvent = e as MouseEvent;\n const target = mouseEvent.target as HTMLElement;\n // Early return if hovering over tooltip\n if (target.closest('.prepr-tooltip')) {\n return;\n }\n updateElementGradients(mouseEvent.clientX, mouseEvent.clientY);\n const encodedElement = target.closest('[data-prepr-encoded]');\n if (encodedElement) {\n showOverlay(encodedElement as HTMLElement);\n } else {\n hideOverlay();\n }\n }, 16),\n [updateElementGradients, showOverlay, hideOverlay]\n );\n\n // Memoize tooltip handlers\n const handleTooltipMouseEnter = useCallback(() => {\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n }, [hideTimeoutRef]);\n\n const handleTooltipMouseLeave = useCallback(() => {\n if (!currentElementRef.current) {\n hideOverlay();\n }\n }, [currentElementRef, hideOverlay]);\n\n useEffect(() => {\n debug.log('editMode changed to', editMode);\n if (!editMode) {\n debug.log('editMode is false, cleaning up');\n if (isInitializedRef.current) {\n DOMService.removeEventListener(\n document,\n 'mousemove',\n throttledMouseMove\n );\n cleanupOverlay();\n clearAllHighlights();\n cleanupElements();\n isInitializedRef.current = false;\n }\n return;\n }\n if (isInitializedRef.current) {\n debug.log('already initialized, skipping setup');\n return;\n }\n debug.log('editMode is true, setting up scanning');\n // Create overlay and tooltip elements\n const { tooltip } = createOverlay();\n debug.log('created overlay and tooltip');\n DOMService.addEventListener(tooltip, 'mouseenter', handleTooltipMouseEnter);\n DOMService.addEventListener(tooltip, 'mouseleave', handleTooltipMouseLeave);\n debug.log('starting document scan');\n scanDocument(decode);\n const elements = getElements();\n debug.log('found', elements.length, 'encoded elements after scan');\n // Start observing visible candidates\n refreshObserving();\n setupMutationObserver(decode, () => {\n // Refresh visible candidates on DOM changes\n refreshObserving();\n });\n debug.log('set up mutation observer');\n DOMService.addEventListener(document, 'mousemove', throttledMouseMove);\n debug.log('added throttled mousemove handler');\n isInitializedRef.current = true;\n return () => {\n debug.log('cleaning up');\n DOMService.removeEventListener(document, 'mousemove', throttledMouseMove);\n DOMService.removeEventListener(\n tooltip,\n 'mouseenter',\n handleTooltipMouseEnter\n );\n DOMService.removeEventListener(\n tooltip,\n 'mouseleave',\n handleTooltipMouseLeave\n );\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n }\n cleanupOverlay();\n clearAllHighlights();\n stopObserving();\n cleanupElements();\n isInitializedRef.current = false;\n };\n }, [editMode]);\n}\n","import { useRef, useCallback } from 'react';\nimport { DOMService } from '../../utils/dom';\nimport { handleStegaError } from '../../utils/errors';\nimport { createScopedLogger } from '../../utils/debug';\nimport { vercelStegaDecode } from '@vercel/stega';\n\ninterface DecodedData {\n origin: string;\n href: string;\n}\n\nconst decodeLogger = createScopedLogger('decode');\n\nfunction decode(str: string | null): DecodedData | null {\n if (!str) return null;\n\n try {\n // First, try to decode the string directly\n decodeLogger.log('attempting to decode stega data');\n\n const decoded = vercelStegaDecode(str) as DecodedData;\n decodeLogger.log('vercelStegaDecode result:', decoded);\n\n if (decoded?.href) {\n decodeLogger.log('successfully decoded', decoded);\n return decoded;\n }\n } catch (e) {\n decodeLogger.log('error decoding stega data:', e as Error);\n // If it fails, it might be because of trailing characters.\n // Regex to find the JSON string\n const regex = /{\"origin.*?}/;\n const match = str.match(regex);\n\n if (match) {\n try {\n // Now, try to decode the matched JSON string\n const decodedMatch = vercelStegaDecode(match[0]) as DecodedData;\n if (decodedMatch?.href) {\n decodeLogger.log(\n 'successfully decoded with regex match',\n decodedMatch\n );\n return decodedMatch;\n }\n } catch (e) {\n handleStegaError(e as Error, 'decode', { input: str });\n }\n }\n }\n\n return null;\n}\n\nexport function useStegaOverlay() {\n const debug = createScopedLogger('useStegaOverlay');\n const overlayRef = useRef<HTMLDivElement | null>(null);\n const tooltipRef = useRef<HTMLDivElement | null>(null);\n const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n const currentElementRef = useRef<HTMLElement | null>(null);\n\n const createOverlay = useCallback(() => {\n const overlay = DOMService.createElement(\n 'div',\n 'prepr-overlay'\n ) as HTMLDivElement;\n overlay.style.display = 'none';\n\n const tooltip = DOMService.createElement(\n 'div',\n 'prepr-tooltip'\n ) as HTMLDivElement;\n tooltip.style.display = 'none';\n\n DOMService.appendToBody(overlay);\n DOMService.appendToBody(tooltip);\n\n overlayRef.current = overlay;\n tooltipRef.current = tooltip;\n\n debug.log('created overlay and tooltip elements');\n\n return { overlay, tooltip };\n }, [debug]);\n\n const showOverlay = useCallback(\n (element: HTMLElement) => {\n if (!overlayRef.current || !tooltipRef.current) return;\n\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n // Update active class on elements\n if (currentElementRef.current && currentElementRef.current !== element) {\n currentElementRef.current.classList.remove('prepr-overlay-active');\n }\n\n const rect = DOMService.getElementRect(element);\n const href = element.getAttribute('data-prepr-href');\n const origin = element.getAttribute('data-prepr-origin');\n\n debug.log('showing overlay for element:', { href, origin, rect });\n\n // Position overlay\n const overlay = overlayRef.current;\n overlay.style.display = 'block';\n overlay.style.top = `${rect.top + window.scrollY - 2}px`;\n overlay.style.left = `${rect.left + window.scrollX - 4}px`;\n overlay.style.width = `${rect.width + 8}px`;\n overlay.style.height = `${rect.height + 4}px`;\n\n // Position and show tooltip\n const tooltip = tooltipRef.current;\n if (tooltip && href && origin) {\n const MIN_WIDTH_FOR_TEXT = 80;\n const isCompact = rect.width < MIN_WIDTH_FOR_TEXT;\n tooltip.textContent = isCompact ? '↗' : `${origin} ↗`;\n tooltip.style.display = 'block';\n\n // Remove min-width constraint for compact tooltips\n if (isCompact) {\n tooltip.style.minWidth = 'auto';\n } else {\n tooltip.style.minWidth = '80px';\n }\n\n // Use requestAnimationFrame to ensure the DOM has updated before calculating position\n requestAnimationFrame(() => {\n if (tooltip) {\n // Compute desired positions\n let top = rect.top + window.scrollY - tooltip.clientHeight - 2;\n let left = rect.right + 4 - tooltip.clientWidth;\n\n // Clamp within viewport bounds\n const minTop = window.scrollY + 4;\n const maxTop =\n window.scrollY + window.innerHeight - tooltip.clientHeight - 4;\n const minLeft = window.scrollX + 4;\n const maxLeft =\n window.scrollX + window.innerWidth - tooltip.clientWidth - 4;\n\n if (top < minTop) {\n // If above viewport, place below the element\n top = rect.bottom + window.scrollY + 2;\n }\n top = Math.max(minTop, Math.min(top, maxTop));\n left = Math.max(minLeft, Math.min(left, maxLeft));\n\n tooltip.style.top = `${top}px`;\n tooltip.style.left = `${left}px`;\n }\n });\n\n tooltip.onclick = () =>\n window.open(href, '_blank', 'noopener,noreferrer');\n }\n\n currentElementRef.current = element;\n element.classList.add('prepr-overlay-active');\n },\n [debug]\n );\n\n const hideOverlay = useCallback(() => {\n if (!overlayRef.current || !tooltipRef.current) return;\n\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n }\n\n hideTimeoutRef.current = setTimeout(() => {\n if (overlayRef.current) overlayRef.current.style.display = 'none';\n if (tooltipRef.current) tooltipRef.current.style.display = 'none';\n if (currentElementRef.current) {\n currentElementRef.current.classList.remove('prepr-overlay-active');\n }\n currentElementRef.current = null;\n debug.log('hidden overlay and tooltip');\n }, 100);\n }, [debug]);\n\n const cleanup = useCallback(() => {\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n }\n\n if (overlayRef.current) {\n DOMService.removeFromBody(overlayRef.current);\n }\n if (tooltipRef.current) {\n DOMService.removeFromBody(tooltipRef.current);\n }\n\n debug.log('cleaned up overlay and tooltip');\n }, [debug]);\n\n return {\n overlayRef,\n tooltipRef,\n currentElementRef,\n hideTimeoutRef,\n createOverlay,\n showOverlay,\n hideOverlay,\n cleanup,\n decode,\n };\n}\n","import { useRef, useCallback } from 'react';\nimport { createScopedLogger } from '../../utils/debug';\nimport { createElementCache } from '../../utils/performance';\n\nexport function useStegaProximity() {\n const debug = createScopedLogger('useStegaProximity');\n const highlightedElementsRef = useRef<Set<HTMLElement>>(new Set());\n const getEncodedElements = createElementCache<HTMLElement>(\n '[data-prepr-encoded]',\n 200\n );\n\n // Track on-screen candidates via IntersectionObserver\n const visibleElementsRef = useRef<Set<HTMLElement>>(new Set());\n const observerRef = useRef<IntersectionObserver | null>(null);\n\n const refreshObserving = useCallback(() => {\n try {\n if (!('IntersectionObserver' in window)) return; // Fallback handled later\n\n // Reset observer\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n\n const visible = new Set<HTMLElement>();\n visibleElementsRef.current = visible;\n\n observerRef.current = new IntersectionObserver(\n entries => {\n entries.forEach(entry => {\n const el = entry.target as HTMLElement;\n if (entry.isIntersecting) {\n visible.add(el);\n } else {\n visible.delete(el);\n }\n });\n },\n { root: null, rootMargin: '0px', threshold: 0 }\n );\n\n const nodes = getEncodedElements();\n nodes.forEach(el => observerRef.current!.observe(el));\n debug.log('observing', nodes.length, 'encoded elements');\n } catch (e) {\n debug.log('error setting up IntersectionObserver:', e as Error);\n }\n }, [debug, getEncodedElements]);\n\n const stopObserving = useCallback(() => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n visibleElementsRef.current.clear();\n }\n }, []);\n\n const updateElementGradients = useCallback(\n (cursorX: number, cursorY: number) => {\n // Use visible candidates when available; fall back to all\n const candidates =\n visibleElementsRef.current.size > 0\n ? Array.from(visibleElementsRef.current)\n : Array.from(getEncodedElements());\n const newHighlightedElements = new Set<HTMLElement>();\n let highlightedCount = 0;\n\n candidates.forEach(element => {\n const rect = element.getBoundingClientRect();\n\n // Calculate shortest distance from cursor to element edges\n const distanceLeft = Math.abs(cursorX - rect.left);\n const distanceRight = Math.abs(cursorX - rect.right);\n const distanceTop = Math.abs(cursorY - rect.top);\n const distanceBottom = Math.abs(cursorY - rect.bottom);\n\n // Use minimum distance to any edge\n const distance = Math.min(\n distanceLeft,\n distanceRight,\n distanceTop,\n distanceBottom\n );\n\n const el = element as HTMLElement;\n if (distance < 150) {\n // Calculate relative cursor position within the element\n const relativeX = cursorX - rect.left;\n const relativeY = cursorY - rect.top;\n\n el.style.setProperty('--cursor-x', `${relativeX}px`);\n el.style.setProperty('--cursor-y', `${relativeY}px`);\n\n // Set gradient size based on element dimensions\n // Calculate base gradient size based on element dimensions\n const baseGradientSize = Math.max(\n 150,\n Math.max(rect.width, rect.height) * 1.1\n );\n // Scale gradient size based on distance (400 is max distance, closer = larger gradient)\n const distanceScale = Math.max(0, (400 - distance) / 400);\n const gradientSize = baseGradientSize * distanceScale;\n\n el.style.setProperty('--gradient-size', `${gradientSize}px`);\n el.classList.add('prepr-proximity-highlight');\n newHighlightedElements.add(el);\n highlightedCount++;\n } else {\n el.classList.remove('prepr-proximity-highlight');\n }\n });\n\n // Update the highlighted elements reference\n highlightedElementsRef.current = newHighlightedElements;\n\n if (highlightedCount > 0) {\n debug.log('highlighted', highlightedCount, 'elements near cursor');\n }\n },\n [debug, getEncodedElements]\n );\n\n const clearAllHighlights = useCallback(() => {\n const highlightedElements = highlightedElementsRef.current;\n let clearedCount = 0;\n\n highlightedElements.forEach(element => {\n element.classList.remove('prepr-proximity-highlight');\n clearedCount++;\n });\n\n highlightedElementsRef.current.clear();\n\n if (clearedCount > 0) {\n debug.log('cleared highlights from', clearedCount, 'elements');\n }\n }, [debug]);\n\n return {\n updateElementGradients,\n clearAllHighlights,\n refreshObserving,\n stopObserving,\n highlightedElementsRef,\n };\n}\n","import { useRef, useCallback } from 'react';\nimport { createScopedLogger } from '../../utils/debug';\n\n// Define the expected structure for decoded data\ninterface DecodedData {\n href: string;\n origin: string;\n}\n\nexport function useStegaElements() {\n const debug = createScopedLogger('useStegaElements');\n const elementsRef = useRef<NodeListOf<Element> | undefined>(undefined);\n const observerRef = useRef<MutationObserver | null>(null);\n\n const getElements = useCallback(() => {\n if (!elementsRef.current) {\n elementsRef.current = document.querySelectorAll(\n '[data-prepr-encoded], [data-prepr-edit-target][data-prepr-encoded]'\n );\n }\n return elementsRef.current;\n }, []);\n\n const scanNode = useCallback(\n (node: Node, decode: (str: string | null) => DecodedData | null) => {\n if (node.nodeType === Node.TEXT_NODE) {\n if (!node.textContent?.trim()) return;\n if (node.parentElement?.closest('script, style, noscript')) return;\n const decoded = decode(node.textContent);\n if (decoded?.href) {\n const target = node.parentElement