feat: add single player mode
This commit is contained in:
61
packages/lib/client-only/hooks/use-analytics.ts
Normal file
61
packages/lib/client-only/hooks/use-analytics.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { posthog } from 'posthog-js';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import {
|
||||
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
|
||||
extractPostHogConfig,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function useAnalytics() {
|
||||
const featureFlags = useFeatureFlags();
|
||||
const isPostHogEnabled = extractPostHogConfig();
|
||||
|
||||
/**
|
||||
* Capture an analytic event.
|
||||
*
|
||||
* @param event The event name.
|
||||
* @param properties Properties to attach to the event.
|
||||
*/
|
||||
const capture = (event: string, properties?: Record<string, unknown>) => {
|
||||
if (!isPostHogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture(event, properties);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the session recording.
|
||||
*
|
||||
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
|
||||
*/
|
||||
const startSessionRecording = (eventFlag?: string) => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.startSessionRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the current session recording.
|
||||
*/
|
||||
const stopSessionRecording = () => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.stopSessionRecording();
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
startSessionRecording,
|
||||
stopSessionRecording,
|
||||
};
|
||||
}
|
||||
18
packages/lib/client-only/hooks/use-debounced-value.ts
Normal file
18
packages/lib/client-only/hooks/use-debounced-value.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal file
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate the width and height of a text element.
|
||||
*
|
||||
* @param text The text to calculate the width and height of.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns the width and height of the text.
|
||||
*/
|
||||
function calculateTextDimensions(
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
): { width: number; height: number } {
|
||||
// Reuse old canvas if available.
|
||||
let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas;
|
||||
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
(calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
context.font = `${fontSize} ${fontFamily}`;
|
||||
const metrics = context.measureText(text);
|
||||
|
||||
return {
|
||||
width: metrics.width,
|
||||
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scaling size to apply to a text to fit it within a container.
|
||||
*
|
||||
* @param container The container dimensions to fit the text within.
|
||||
* @param text The text to fit within the container.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text.
|
||||
*/
|
||||
export const calculateTextScaleSize = (
|
||||
container: { width: number; height: number },
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
) => {
|
||||
const { width, height } = calculateTextDimensions(text, fontSize, fontFamily);
|
||||
return Math.min(container.width / width, container.height / height, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a container and child element, calculate the scaling size to apply to the child.
|
||||
*/
|
||||
export function useElementScaleSize(
|
||||
container: { width: number; height: number },
|
||||
child: RefObject<HTMLElement | null>,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
) {
|
||||
const [scalingFactor, setScalingFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!child.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleSize = calculateTextScaleSize(
|
||||
container,
|
||||
child.current.innerText,
|
||||
`${fontSize}px`,
|
||||
fontFamily,
|
||||
);
|
||||
|
||||
setScalingFactor(scaleSize);
|
||||
}, [child, container, fontFamily, fontSize]);
|
||||
|
||||
return scalingFactor;
|
||||
}
|
||||
78
packages/lib/client-only/hooks/use-field-page-coords.ts
Normal file
78
packages/lib/client-only/hooks/use-field-page-coords.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const fieldX = (Number(field.positionX) / 100) * width + left;
|
||||
const fieldY = (Number(field.positionY) / 100) * height + top;
|
||||
|
||||
const fieldHeight = (Number(field.height) / 100) * height;
|
||||
const fieldWidth = (Number(field.width) / 100) * width;
|
||||
|
||||
setCoords({
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
});
|
||||
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useIsMounted = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return isMounted;
|
||||
};
|
||||
27
packages/lib/client-only/hooks/use-window-size.ts
Normal file
27
packages/lib/client-only/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
95
packages/lib/client-only/providers/feature-flag.tsx
Normal file
95
packages/lib/client-only/providers/feature-flag.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
export type FeatureFlagContextValue = {
|
||||
getFlag: (_key: string) => TFeatureFlagValue;
|
||||
};
|
||||
|
||||
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
children,
|
||||
initialFlags,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialFlags: Record<string, TFeatureFlagValue>;
|
||||
}) {
|
||||
const [flags, setFlags] = useState(initialFlags);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flag: string) => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
return flags[flag] ?? false;
|
||||
},
|
||||
[flags],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
}
|
||||
}, FEATURE_FLAG_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh the flags when the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider
|
||||
value={{
|
||||
getFlag,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
10
packages/lib/client-only/providers/feature-flag.types.ts
Normal file
10
packages/lib/client-only/providers/feature-flag.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
Reference in New Issue
Block a user