diff --git a/src/frontend/package.json b/src/frontend/package.json index d68c2be..98f8653 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-3", + "@atyrode/excalidraw": "^0.18.0-4", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index f53c98e..0fcb1c4 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -120,7 +120,32 @@ let isScrolling = false; // Create a custom event for scrolling state changes const scrollStateChangeEvent = new CustomEvent('scrollStateChange', { detail: { isScrolling: false } }); -export const lockEmbeddables = () => { +// Memoized debounced function factory +const getDebouncedScrollEnd = (() => { + let lastDebounceTime = 0; + let debouncedFn: ReturnType | null = null; + + return (currentDebounceTime: number) => { + // Only recreate if the time has changed + if (currentDebounceTime !== lastDebounceTime || !debouncedFn) { + lastDebounceTime = currentDebounceTime; + debouncedFn = debounce(() => { + isScrolling = false; + // Set pointer-events back to all when not scrolling + document.documentElement.style.setProperty('--embeddable-pointer-events', 'all'); + // Dispatch event with updated scrolling state + scrollStateChangeEvent.detail.isScrolling = false; + document.dispatchEvent(scrollStateChangeEvent); + }, currentDebounceTime); + } + return debouncedFn; + }; +})(); + +export const lockEmbeddables = (appState?: AppState) => { + // Get the debounce time from settings, with fallback to default + const debounceTime = appState?.pad?.userSettings?.embedLockDebounceTime || 350; + if (!isScrolling) { isScrolling = true; // Set pointer-events to none during scrolling @@ -130,16 +155,7 @@ export const lockEmbeddables = () => { document.dispatchEvent(scrollStateChangeEvent); } - // Reset the pointer-events after scrolling stops + // Get the current debounced function and call it + const debouncedScrollEnd = getDebouncedScrollEnd(debounceTime); debouncedScrollEnd(); }; - -// Create a debounced function to detect when scrolling ends -const debouncedScrollEnd = debounce(() => { - isScrolling = false; - // Set pointer-events back to all when not scrolling - document.documentElement.style.setProperty('--embeddable-pointer-events', 'all'); - // Dispatch event with updated scrolling state - scrollStateChangeEvent.detail.isScrolling = false; - document.dispatchEvent(scrollStateChangeEvent); -}, 350); \ No newline at end of file diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx index b2095e1..99f51cc 100644 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ b/src/frontend/src/ExcalidrawWrapper.tsx @@ -8,6 +8,7 @@ import { MainMenuConfig } from './ui/MainMenu'; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; import BackupsModal from './ui/BackupsDialog'; +import SettingsDialog from './ui/SettingsDialog'; const defaultInitialData = { elements: [], @@ -47,8 +48,9 @@ export const ExcalidrawWrapper: React.FC = ({ // Add state for modal animation const [isExiting, setIsExiting] = useState(false); - // State for BackupsModal + // State for modals const [showBackupsModal, setShowBackupsModal] = useState(false); + const [showSettingsModal, setShowSettingsModal] = useState(false); // Handle auth state changes useEffect(() => { @@ -57,11 +59,16 @@ export const ExcalidrawWrapper: React.FC = ({ } }, [isAuthenticated]); - // Handler for closing the backups modal + + // Handlers for closing modals const handleCloseBackupsModal = () => { setShowBackupsModal(false); }; + const handleCloseSettingsModal = () => { + setShowSettingsModal(false); + }; + const renderExcalidraw = (children: React.ReactNode) => { const Excalidraw = Children.toArray(children).find( (child: any) => @@ -82,7 +89,10 @@ export const ExcalidrawWrapper: React.FC = ({ initialData: initialData ?? defaultInitialData, onChange: onChange, name: "Pad.ws", - onScrollChange: lockEmbeddables, + onScrollChange: (scrollX, scrollY) => { + lockEmbeddables(excalidrawAPI?.getAppState()); + if (onScrollChange) onScrollChange(scrollX, scrollY); + }, validateEmbeddable: true, renderEmbeddable: (element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI), renderTopRightUI: renderTopRightUI ?? (() => ( @@ -98,6 +108,8 @@ export const ExcalidrawWrapper: React.FC = ({ excalidrawAPI={excalidrawAPI} showBackupsModal={showBackupsModal} setShowBackupsModal={setShowBackupsModal} + showSettingsModal={showSettingsModal} + setShowSettingsModal={setShowSettingsModal} /> {!isAuthLoading && isAuthenticated === false && ( = ({ onClose={handleCloseBackupsModal} /> )} + + {showSettingsModal && ( + + )} ); }; diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts new file mode 100644 index 0000000..35e7400 --- /dev/null +++ b/src/frontend/src/types/settings.ts @@ -0,0 +1,16 @@ +/** + * Types for user settings + */ + +export interface UserSettings { + /** + * The debounce time in milliseconds for the embed lock + * Range: 150ms to 5000ms (5 seconds) + * Default: 350ms + */ + embedLockDebounceTime?: number; +} + +export const DEFAULT_SETTINGS: UserSettings = { + embedLockDebounceTime: 350, // Default value from CustomEmbeddableRenderer.tsx +}; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index da211ac..ba6f1b3 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -3,17 +3,20 @@ import React, { useState } from 'react'; import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; import type { MainMenu as MainMenuType } from '@atyrode/excalidraw'; -import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore } from 'lucide-react'; +import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore, Settings } from 'lucide-react'; import { capture } from '../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; import { useUserProfile } from "../api/hooks"; import { queryClient } from "../api/queryClient"; +import SettingsDialog from "./SettingsDialog"; import "./MainMenu.scss"; interface MainMenuConfigProps { MainMenu: typeof MainMenuType; excalidrawAPI: ExcalidrawImperativeAPI | null; showBackupsModal: boolean; setShowBackupsModal: (show: boolean) => void; + showSettingsModal?: boolean; + setShowSettingsModal?: (show: boolean) => void; } export const MainMenuConfig: React.FC = ({ @@ -21,6 +24,8 @@ export const MainMenuConfig: React.FC = ({ excalidrawAPI, showBackupsModal, setShowBackupsModal, + showSettingsModal = false, + setShowSettingsModal = (show: boolean) => {}, }) => { const { data, isLoading, isError } = useUserProfile(); @@ -100,6 +105,10 @@ export const MainMenuConfig: React.FC = ({ setShowBackupsModal(true); }; + const handleSettingsClick = () => { + setShowSettingsModal(true); + }; + const handleGridToggle = () => { if (!excalidrawAPI) return; const appState = excalidrawAPI.getAppState(); @@ -207,6 +216,13 @@ export const MainMenuConfig: React.FC = ({ + } + onClick={handleSettingsClick} + > + Settings + + } onClick={async () => { diff --git a/src/frontend/src/ui/Range.scss b/src/frontend/src/ui/Range.scss new file mode 100644 index 0000000..a8748c3 --- /dev/null +++ b/src/frontend/src/ui/Range.scss @@ -0,0 +1,79 @@ +:root { + --slider-thumb-size: 16px; + --range-track-filled: #cc6d24; + --range-track-unfilled: #525252; + --range-thumb-color: #a4571b; /* Slightly lighter than track-filled for contrast */ +} + +.range { + &__control-label { + display: flex; + flex-direction: column; + width: 100%; + font-size: 0.9rem; + color: var(--text-primary-color); + } + + &__wrapper { + position: relative; + padding-top: 10px; + padding-bottom: 25px; + width: 100%; + } + + &__input { + width: 100%; + height: 4px; + -webkit-appearance: none; + background: var(--range-track-filled); + border-radius: 2px; + outline: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: var(--slider-thumb-size); + height: var(--slider-thumb-size); + background: var(--range-thumb-color); + border-radius: 50%; + cursor: pointer; + border: none; + } + + &::-moz-range-thumb { + width: var(--slider-thumb-size); + height: var(--slider-thumb-size); + background: var(--range-thumb-color); + border-radius: 50%; + cursor: pointer; + border: none; + } + } + + &__value-bubble { + position: absolute; + bottom: 0; + transform: translateX(-50%); + font-size: 12px; + color: var(--text-primary-color); + } + + &__label { + position: absolute; + bottom: 0; + font-size: 12px; + color: var(--text-primary-color); + + &--min { + left: 4px; + } + + &--zero { + left: 4px; + } + + &--max { + right: 4px; + } + } +} diff --git a/src/frontend/src/ui/Range.tsx b/src/frontend/src/ui/Range.tsx new file mode 100644 index 0000000..a95dbb5 --- /dev/null +++ b/src/frontend/src/ui/Range.tsx @@ -0,0 +1,78 @@ +import React, { useEffect } from "react"; + +import "./Range.scss"; + +export type RangeProps = { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + minLabel?: string; + maxLabel?: string; + showValueBubble?: boolean; +}; + +export const Range = ({ + value, + onChange, + min = 0, + max = 100, + step = 1, + minLabel, + maxLabel, + showValueBubble = true, +}: RangeProps) => { + const rangeRef = React.useRef(null); + const valueRef = React.useRef(null); + + useEffect(() => { + if (rangeRef.current) { + const rangeElement = rangeRef.current; + + // Update value bubble position if it exists + if (showValueBubble && valueRef.current) { + const valueElement = valueRef.current; + const inputWidth = rangeElement.offsetWidth; + const thumbWidth = 16; // Match the --slider-thumb-size CSS variable + const position = + ((value - min) / (max - min)) * (inputWidth - thumbWidth) + thumbWidth / 2; + valueElement.style.left = `${position}px`; + } + + // Calculate percentage for gradient + const percentage = ((value - min) / (max - min)) * 100; + rangeElement.style.background = `linear-gradient(to right, var(--range-track-filled) 0%, var(--range-track-filled) ${percentage}%, var(--range-track-unfilled) ${percentage}%, var(--range-track-unfilled) 100%)`; + } + }, [value, min, max, showValueBubble]); + + return ( + + ); +}; diff --git a/src/frontend/src/ui/SettingsDialog.scss b/src/frontend/src/ui/SettingsDialog.scss new file mode 100644 index 0000000..aff6ccb --- /dev/null +++ b/src/frontend/src/ui/SettingsDialog.scss @@ -0,0 +1,62 @@ +.settings-dialog { + + &__wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background-color: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(1px); + } + + &__title-container { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__title { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + } + + &__content { + padding: 1rem; + min-height: 200px; + display: flex; + flex-direction: column; + } + + &__section { + margin-bottom: 1.5rem; + } + + &__section-title { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary-color); + } + + &__setting { + margin-bottom: 1rem; + padding: 0.5rem; + border-radius: 4px; + background-color: var(--dialog-bg-color); + } + + &__label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.9rem; + color: var(--text-primary-color); + } + + &__range-container { + margin: 1rem 0; + } + +} diff --git a/src/frontend/src/ui/SettingsDialog.tsx b/src/frontend/src/ui/SettingsDialog.tsx new file mode 100644 index 0000000..fe86076 --- /dev/null +++ b/src/frontend/src/ui/SettingsDialog.tsx @@ -0,0 +1,120 @@ +import React, { useState, useCallback, useEffect } from "react"; +import { Dialog } from "@atyrode/excalidraw"; +import { Range } from "./Range"; +import { UserSettings, DEFAULT_SETTINGS } from "../types/settings"; +import "./SettingsDialog.scss"; + +interface SettingsDialogProps { + excalidrawAPI?: any; + onClose?: () => void; +} + +const SettingsDialog: React.FC = ({ + excalidrawAPI, + onClose, +}) => { + const [modalIsShown, setModalIsShown] = useState(true); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + + // Get current settings from excalidrawAPI when component mounts + useEffect(() => { + if (excalidrawAPI) { + const appState = excalidrawAPI.getAppState(); + const userSettings = appState?.pad?.userSettings || {}; + setSettings({ + ...DEFAULT_SETTINGS, + ...userSettings + }); + } + }, [excalidrawAPI]); + + const handleClose = useCallback(() => { + setModalIsShown(false); + if (onClose) { + onClose(); + } + }, [onClose]); + + /** + * Updates a specific setting and syncs it with the excalidraw app state + * @param key The setting key to update + * @param value The new value for the setting + */ + const updateSetting = (key: K, value: UserSettings[K]) => { + if (!excalidrawAPI) return; + + const newSettings = { + ...settings, + [key]: value + }; + + setSettings(newSettings); + + // Update the appState + const appState = excalidrawAPI.getAppState(); + const updatedAppState = { + ...appState, + pad: { + ...appState.pad, + userSettings: newSettings + } + }; + + excalidrawAPI.updateScene({ + appState: updatedAppState + }); + }; + + // Dialog content + const dialogContent = ( +
+
+

Embed Settings

+
+ +
+ updateSetting( + 'embedLockDebounceTime', + // Map 0-100 range to 150-5000ms, rounded to nearest multiple of 50 + Math.round((150 + (value / 100) * 4850) / 50) * 50 + )} + min={0} + max={100} + step={1} + minLabel="150ms" + maxLabel="5000ms" + showValueBubble={false} + /> +
+
+
+
+ ); + + return ( + <> + {modalIsShown && ( +
+ +

Settings

+
+ } + closeOnClickOutside={true} + children={dialogContent} + /> + + )} + + ); +}; + +export default SettingsDialog; diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index 41b8ad9..79d2c02 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -1,9 +1,6 @@ +import { DEFAULT_SETTINGS } from '../types/settings'; + /** - * Normalizes canvas data by removing width and height properties from appState - * and resetting collaborators to an empty Map. - * - * This is necessary when loading canvas data to ensure it fits properly in the current viewport - * and doesn't carry over collaborator information that might be stale. * * @param data The canvas data to normalize * @returns Normalized canvas data @@ -21,6 +18,11 @@ export function normalizeCanvasData(data: any) { delete appState.height; } + // Preserve existing pad settings if they exist, otherwise create new ones + const existingPad = appState.pad || {}; + const existingUserSettings = existingPad.userSettings || {}; + + // Merge existing user settings with default settings appState.pad = { moduleBorderOffset: { left: 10, @@ -28,6 +30,11 @@ export function normalizeCanvasData(data: any) { top: 40, bottom: 10, }, + // Merge existing user settings with default settings + userSettings: { + ...DEFAULT_SETTINGS, + ...existingUserSettings + } }; // Reset collaborators (https://github.com/excalidraw/excalidraw/issues/8637) diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 723d6a2..b069aa3 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-3": - version "0.18.0-3" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-3.tgz#445176d5b9828f033205a46f227fd76e722019ec" - integrity sha512-iWLyYMZNV9VQCcWAtrLmg52NkfCw1CDrigXXRrxa3H2KW6nyrlbFm3KZAF5Y0mOJDYzAtxclPvHoTvcRruMjGg== +"@atyrode/excalidraw@^0.18.0-4": + version "0.18.0-4" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-4.tgz#3350bd09533cb424105fa615ec0e2e4e243f798e" + integrity sha512-MDYAXT34cNmhoc49eC7iuoweGHsirKKH0VnwDT9CJPWTjUfOrXp/NsL1PHyFIyhBu14NI1osSpGTD0qi1FPegQ== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1"