Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 28 additions & 12 deletions src/frontend/src/CustomEmbeddableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof debounce> | 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
Expand All @@ -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);
25 changes: 22 additions & 3 deletions src/frontend/src/ExcalidrawWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -47,8 +48,9 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
// 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(() => {
Expand All @@ -57,11 +59,16 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
}
}, [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) =>
Expand All @@ -82,7 +89,10 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
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 ?? (() => (
Expand All @@ -98,6 +108,8 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
excalidrawAPI={excalidrawAPI}
showBackupsModal={showBackupsModal}
setShowBackupsModal={setShowBackupsModal}
showSettingsModal={showSettingsModal}
setShowSettingsModal={setShowSettingsModal}
/>
{!isAuthLoading && isAuthenticated === false && (
<AuthDialog
Expand All @@ -111,6 +123,13 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
onClose={handleCloseBackupsModal}
/>
)}

{showSettingsModal && (
<SettingsDialog
excalidrawAPI={excalidrawAPI}
onClose={handleCloseSettingsModal}
/>
)}
</>
);
};
Expand Down
16 changes: 16 additions & 0 deletions src/frontend/src/types/settings.ts
Original file line number Diff line number Diff line change
@@ -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
};
18 changes: 17 additions & 1 deletion src/frontend/src/ui/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@ 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<MainMenuConfigProps> = ({
MainMenu,
excalidrawAPI,
showBackupsModal,
setShowBackupsModal,
showSettingsModal = false,
setShowSettingsModal = (show: boolean) => {},
}) => {
const { data, isLoading, isError } = useUserProfile();

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

const handleSettingsClick = () => {
setShowSettingsModal(true);
};

const handleGridToggle = () => {
if (!excalidrawAPI) return;
const appState = excalidrawAPI.getAppState();
Expand Down Expand Up @@ -207,6 +216,13 @@ export const MainMenuConfig: React.FC<MainMenuConfigProps> = ({

<MainMenu.Separator />

<MainMenu.Item
icon={<Settings />}
onClick={handleSettingsClick}
>
Settings
</MainMenu.Item>

<MainMenu.Item
icon={<LogOut />}
onClick={async () => {
Expand Down
79 changes: 79 additions & 0 deletions src/frontend/src/ui/Range.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
78 changes: 78 additions & 0 deletions src/frontend/src/ui/Range.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(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 (
<label className="range range__control-label">
<div className="range__wrapper">
<input
ref={rangeRef}
type="range"
min={min}
max={max}
step={step}
onChange={(event) => {
onChange(+event.target.value);
}}
value={value}
className="range__input"
/>
{showValueBubble && (
<div className="range__value-bubble" ref={valueRef}>
{value !== min ? value : null}
</div>
)}
{min === 0 ? (
<div className="range__label range__label--zero">{minLabel || min}</div>
) : (
<div className="range__label range__label--min">{minLabel || min}</div>
)}
<div className="range__label range__label--max">{maxLabel || max}</div>
</div>
</label>
);
};
Loading