Skip to content

Commit 8055112

Browse files
authored
feat: add settings menu (#40)
* feat: add SettingsDialog and integrate into ExcalidrawWrapper and MainMenu - Introduced a new SettingsDialog component for managing application settings. - Updated ExcalidrawWrapper to handle the visibility of the SettingsDialog. - Enhanced MainMenu to include a Settings option that triggers the SettingsDialog. - Added corresponding styles for the SettingsDialog to ensure a cohesive UI experience. * chore: update @atyrode/excalidraw dependency to version 0.18.0-4 * feat: enhance SettingsDialog with user settings management - Added UserSettings interface and default settings for embed lock debounce time. - Integrated settings management into SettingsDialog, allowing users to adjust embed lock debounce time via a range input. - Updated normalizeCanvasData to preserve user settings when normalizing canvas data. - Improved styles for SettingsDialog to enhance user experience and organization. * feat: enhance lockEmbeddables function to read from settings - Introduced a memoized debounced function to manage scrolling state more efficiently. - Updated lockEmbeddables to accept appState for customizable debounce timing. - Modified ExcalidrawWrapper to integrate the new scrolling behavior, ensuring smoother user experience during scrolling events. * style: update SettingsDialog styles for improved UI - Introduced background color and backdrop filter to the dialog wrapper for better visual appeal. * feat: add Range component for adjustable settings - Introduced a new Range component for user input, allowing adjustable values with labels and a value bubble. - Integrated the Range component into SettingsDialog for managing embed lock debounce time, enhancing user experience with visual feedback. - Added corresponding styles in Range.scss for improved UI consistency. * refactor: apply BEM naming convention to Range component styles - Updated Range.scss and Range.tsx to implement BEM naming conventions for improved readability and maintainability. - Introduced new CSS variables for track colors to enhance customization. - Adjusted styles for input and labels to align with the new structure, ensuring consistent UI presentation. * refactor: simplify SettingsDialog state update logic - Replaced the specific embedLockDebounceTime handler with a more generic updateSetting function to streamline state updates for various settings. - Removed unused styles from SettingsDialog.scss to enhance code clarity and maintainability. * fix: adjust embedLockDebounceTime mapping for improved accuracy - Updated the calculation for embedLockDebounceTime in SettingsDialog to round the mapped value to the nearest multiple of 50, enhancing precision in user settings adjustments. * refactor: rename embedLockDebounceTime label for clarity - Updated the label in SettingsDialog to "Embed Lock Time" for improved clarity and user understanding of the setting.
1 parent 8d7d145 commit 8055112

File tree

11 files changed

+439
-26
lines changed

11 files changed

+439
-26
lines changed

src/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "1.0.0",
44
"private": true,
55
"dependencies": {
6-
"@atyrode/excalidraw": "^0.18.0-3",
6+
"@atyrode/excalidraw": "^0.18.0-4",
77
"@monaco-editor/react": "^4.7.0",
88
"@tanstack/react-query": "^5.74.3",
99
"@tanstack/react-query-devtools": "^5.74.3",

src/frontend/src/CustomEmbeddableRenderer.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,32 @@ let isScrolling = false;
120120
// Create a custom event for scrolling state changes
121121
const scrollStateChangeEvent = new CustomEvent('scrollStateChange', { detail: { isScrolling: false } });
122122

123-
export const lockEmbeddables = () => {
123+
// Memoized debounced function factory
124+
const getDebouncedScrollEnd = (() => {
125+
let lastDebounceTime = 0;
126+
let debouncedFn: ReturnType<typeof debounce> | null = null;
127+
128+
return (currentDebounceTime: number) => {
129+
// Only recreate if the time has changed
130+
if (currentDebounceTime !== lastDebounceTime || !debouncedFn) {
131+
lastDebounceTime = currentDebounceTime;
132+
debouncedFn = debounce(() => {
133+
isScrolling = false;
134+
// Set pointer-events back to all when not scrolling
135+
document.documentElement.style.setProperty('--embeddable-pointer-events', 'all');
136+
// Dispatch event with updated scrolling state
137+
scrollStateChangeEvent.detail.isScrolling = false;
138+
document.dispatchEvent(scrollStateChangeEvent);
139+
}, currentDebounceTime);
140+
}
141+
return debouncedFn;
142+
};
143+
})();
144+
145+
export const lockEmbeddables = (appState?: AppState) => {
146+
// Get the debounce time from settings, with fallback to default
147+
const debounceTime = appState?.pad?.userSettings?.embedLockDebounceTime || 350;
148+
124149
if (!isScrolling) {
125150
isScrolling = true;
126151
// Set pointer-events to none during scrolling
@@ -130,16 +155,7 @@ export const lockEmbeddables = () => {
130155
document.dispatchEvent(scrollStateChangeEvent);
131156
}
132157

133-
// Reset the pointer-events after scrolling stops
158+
// Get the current debounced function and call it
159+
const debouncedScrollEnd = getDebouncedScrollEnd(debounceTime);
134160
debouncedScrollEnd();
135161
};
136-
137-
// Create a debounced function to detect when scrolling ends
138-
const debouncedScrollEnd = debounce(() => {
139-
isScrolling = false;
140-
// Set pointer-events back to all when not scrolling
141-
document.documentElement.style.setProperty('--embeddable-pointer-events', 'all');
142-
// Dispatch event with updated scrolling state
143-
scrollStateChangeEvent.detail.isScrolling = false;
144-
document.dispatchEvent(scrollStateChangeEvent);
145-
}, 350);

src/frontend/src/ExcalidrawWrapper.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { MainMenuConfig } from './ui/MainMenu';
88
import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer';
99
import AuthDialog from './ui/AuthDialog';
1010
import BackupsModal from './ui/BackupsDialog';
11+
import SettingsDialog from './ui/SettingsDialog';
1112

1213
const defaultInitialData = {
1314
elements: [],
@@ -47,8 +48,9 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
4748
// Add state for modal animation
4849
const [isExiting, setIsExiting] = useState(false);
4950

50-
// State for BackupsModal
51+
// State for modals
5152
const [showBackupsModal, setShowBackupsModal] = useState(false);
53+
const [showSettingsModal, setShowSettingsModal] = useState(false);
5254

5355
// Handle auth state changes
5456
useEffect(() => {
@@ -57,11 +59,16 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
5759
}
5860
}, [isAuthenticated]);
5961

60-
// Handler for closing the backups modal
62+
63+
// Handlers for closing modals
6164
const handleCloseBackupsModal = () => {
6265
setShowBackupsModal(false);
6366
};
6467

68+
const handleCloseSettingsModal = () => {
69+
setShowSettingsModal(false);
70+
};
71+
6572
const renderExcalidraw = (children: React.ReactNode) => {
6673
const Excalidraw = Children.toArray(children).find(
6774
(child: any) =>
@@ -82,7 +89,10 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
8289
initialData: initialData ?? defaultInitialData,
8390
onChange: onChange,
8491
name: "Pad.ws",
85-
onScrollChange: lockEmbeddables,
92+
onScrollChange: (scrollX, scrollY) => {
93+
lockEmbeddables(excalidrawAPI?.getAppState());
94+
if (onScrollChange) onScrollChange(scrollX, scrollY);
95+
},
8696
validateEmbeddable: true,
8797
renderEmbeddable: (element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI),
8898
renderTopRightUI: renderTopRightUI ?? (() => (
@@ -98,6 +108,8 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
98108
excalidrawAPI={excalidrawAPI}
99109
showBackupsModal={showBackupsModal}
100110
setShowBackupsModal={setShowBackupsModal}
111+
showSettingsModal={showSettingsModal}
112+
setShowSettingsModal={setShowSettingsModal}
101113
/>
102114
{!isAuthLoading && isAuthenticated === false && (
103115
<AuthDialog
@@ -111,6 +123,13 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
111123
onClose={handleCloseBackupsModal}
112124
/>
113125
)}
126+
127+
{showSettingsModal && (
128+
<SettingsDialog
129+
excalidrawAPI={excalidrawAPI}
130+
onClose={handleCloseSettingsModal}
131+
/>
132+
)}
114133
</>
115134
);
116135
};

src/frontend/src/types/settings.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Types for user settings
3+
*/
4+
5+
export interface UserSettings {
6+
/**
7+
* The debounce time in milliseconds for the embed lock
8+
* Range: 150ms to 5000ms (5 seconds)
9+
* Default: 350ms
10+
*/
11+
embedLockDebounceTime?: number;
12+
}
13+
14+
export const DEFAULT_SETTINGS: UserSettings = {
15+
embedLockDebounceTime: 350, // Default value from CustomEmbeddableRenderer.tsx
16+
};

src/frontend/src/ui/MainMenu.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,29 @@ import React, { useState } from 'react';
33
import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types';
44
import type { MainMenu as MainMenuType } from '@atyrode/excalidraw';
55

6-
import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore } from 'lucide-react';
6+
import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore, Settings } from 'lucide-react';
77
import { capture } from '../utils/posthog';
88
import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory';
99
import { useUserProfile } from "../api/hooks";
1010
import { queryClient } from "../api/queryClient";
11+
import SettingsDialog from "./SettingsDialog";
1112
import "./MainMenu.scss";
1213
interface MainMenuConfigProps {
1314
MainMenu: typeof MainMenuType;
1415
excalidrawAPI: ExcalidrawImperativeAPI | null;
1516
showBackupsModal: boolean;
1617
setShowBackupsModal: (show: boolean) => void;
18+
showSettingsModal?: boolean;
19+
setShowSettingsModal?: (show: boolean) => void;
1720
}
1821

1922
export const MainMenuConfig: React.FC<MainMenuConfigProps> = ({
2023
MainMenu,
2124
excalidrawAPI,
2225
showBackupsModal,
2326
setShowBackupsModal,
27+
showSettingsModal = false,
28+
setShowSettingsModal = (show: boolean) => {},
2429
}) => {
2530
const { data, isLoading, isError } = useUserProfile();
2631

@@ -100,6 +105,10 @@ export const MainMenuConfig: React.FC<MainMenuConfigProps> = ({
100105
setShowBackupsModal(true);
101106
};
102107

108+
const handleSettingsClick = () => {
109+
setShowSettingsModal(true);
110+
};
111+
103112
const handleGridToggle = () => {
104113
if (!excalidrawAPI) return;
105114
const appState = excalidrawAPI.getAppState();
@@ -207,6 +216,13 @@ export const MainMenuConfig: React.FC<MainMenuConfigProps> = ({
207216

208217
<MainMenu.Separator />
209218

219+
<MainMenu.Item
220+
icon={<Settings />}
221+
onClick={handleSettingsClick}
222+
>
223+
Settings
224+
</MainMenu.Item>
225+
210226
<MainMenu.Item
211227
icon={<LogOut />}
212228
onClick={async () => {

src/frontend/src/ui/Range.scss

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
:root {
2+
--slider-thumb-size: 16px;
3+
--range-track-filled: #cc6d24;
4+
--range-track-unfilled: #525252;
5+
--range-thumb-color: #a4571b; /* Slightly lighter than track-filled for contrast */
6+
}
7+
8+
.range {
9+
&__control-label {
10+
display: flex;
11+
flex-direction: column;
12+
width: 100%;
13+
font-size: 0.9rem;
14+
color: var(--text-primary-color);
15+
}
16+
17+
&__wrapper {
18+
position: relative;
19+
padding-top: 10px;
20+
padding-bottom: 25px;
21+
width: 100%;
22+
}
23+
24+
&__input {
25+
width: 100%;
26+
height: 4px;
27+
-webkit-appearance: none;
28+
background: var(--range-track-filled);
29+
border-radius: 2px;
30+
outline: none;
31+
32+
&::-webkit-slider-thumb {
33+
-webkit-appearance: none;
34+
appearance: none;
35+
width: var(--slider-thumb-size);
36+
height: var(--slider-thumb-size);
37+
background: var(--range-thumb-color);
38+
border-radius: 50%;
39+
cursor: pointer;
40+
border: none;
41+
}
42+
43+
&::-moz-range-thumb {
44+
width: var(--slider-thumb-size);
45+
height: var(--slider-thumb-size);
46+
background: var(--range-thumb-color);
47+
border-radius: 50%;
48+
cursor: pointer;
49+
border: none;
50+
}
51+
}
52+
53+
&__value-bubble {
54+
position: absolute;
55+
bottom: 0;
56+
transform: translateX(-50%);
57+
font-size: 12px;
58+
color: var(--text-primary-color);
59+
}
60+
61+
&__label {
62+
position: absolute;
63+
bottom: 0;
64+
font-size: 12px;
65+
color: var(--text-primary-color);
66+
67+
&--min {
68+
left: 4px;
69+
}
70+
71+
&--zero {
72+
left: 4px;
73+
}
74+
75+
&--max {
76+
right: 4px;
77+
}
78+
}
79+
}

src/frontend/src/ui/Range.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useEffect } from "react";
2+
3+
import "./Range.scss";
4+
5+
export type RangeProps = {
6+
value: number;
7+
onChange: (value: number) => void;
8+
min?: number;
9+
max?: number;
10+
step?: number;
11+
minLabel?: string;
12+
maxLabel?: string;
13+
showValueBubble?: boolean;
14+
};
15+
16+
export const Range = ({
17+
value,
18+
onChange,
19+
min = 0,
20+
max = 100,
21+
step = 1,
22+
minLabel,
23+
maxLabel,
24+
showValueBubble = true,
25+
}: RangeProps) => {
26+
const rangeRef = React.useRef<HTMLInputElement>(null);
27+
const valueRef = React.useRef<HTMLDivElement>(null);
28+
29+
useEffect(() => {
30+
if (rangeRef.current) {
31+
const rangeElement = rangeRef.current;
32+
33+
// Update value bubble position if it exists
34+
if (showValueBubble && valueRef.current) {
35+
const valueElement = valueRef.current;
36+
const inputWidth = rangeElement.offsetWidth;
37+
const thumbWidth = 16; // Match the --slider-thumb-size CSS variable
38+
const position =
39+
((value - min) / (max - min)) * (inputWidth - thumbWidth) + thumbWidth / 2;
40+
valueElement.style.left = `${position}px`;
41+
}
42+
43+
// Calculate percentage for gradient
44+
const percentage = ((value - min) / (max - min)) * 100;
45+
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%)`;
46+
}
47+
}, [value, min, max, showValueBubble]);
48+
49+
return (
50+
<label className="range range__control-label">
51+
<div className="range__wrapper">
52+
<input
53+
ref={rangeRef}
54+
type="range"
55+
min={min}
56+
max={max}
57+
step={step}
58+
onChange={(event) => {
59+
onChange(+event.target.value);
60+
}}
61+
value={value}
62+
className="range__input"
63+
/>
64+
{showValueBubble && (
65+
<div className="range__value-bubble" ref={valueRef}>
66+
{value !== min ? value : null}
67+
</div>
68+
)}
69+
{min === 0 ? (
70+
<div className="range__label range__label--zero">{minLabel || min}</div>
71+
) : (
72+
<div className="range__label range__label--min">{minLabel || min}</div>
73+
)}
74+
<div className="range__label range__label--max">{maxLabel || max}</div>
75+
</div>
76+
</label>
77+
);
78+
};

0 commit comments

Comments
 (0)