Compare commits

...

3 Commits

Author SHA1 Message Date
Anthony LC 64ce2d449a 📱(frontend) add touch detection
We rely on the size of the screen to determine
if we are on a touch device, but this is not
always accurate, for example on laptops
when we resize the window to a smaller size,
it could be misinterpreted as a touch device.
To improve this, we add detection for touch input
so we can more accurately determine if the user
is using touch or mouse input.
2026-01-07 12:03:14 +01:00
Anthony LC 2be5cda88e 📱(frontend) toolbar to the bottom when mobile
On mobile devices, the mobile toolbar is often
positioned above the Blocknote toolbar, making
it hard to access.
This commit adjusts the toolbar position to
the bottom of the screen for better accessibility
when using mobile devices.
2026-01-07 12:02:16 +01:00
Anthony LC 1211e04a45 ️(frontend) improve visual focus on some elements
Improve visual focus indication on
MainLayout content and PdfBlock editor.
2026-01-07 11:13:40 +01:00
8 changed files with 135 additions and 31 deletions
+1
View File
@@ -17,6 +17,7 @@ and this project adheres to
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
- 📱(frontend) toolbar to the bottom when mobile #1774
### Fixed
@@ -52,14 +52,17 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { replace } = useRouter();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
);
const { initializeResizeListener, initializeInputDetection } =
useResponsiveStore();
useEffect(() => {
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {
return initializeInputDetection();
}, [initializeInputDetection]);
/**
* Update the global router replace function
* This allows us to use the router replace function globally
@@ -1,15 +1,26 @@
import { FormattingToolbarExtension } from '@blocknote/core/extensions';
import {
ExperimentalMobileFormattingToolbarController,
FormattingToolbar,
FormattingToolbarController,
blockTypeSelectItems,
getFormattingToolbarItems,
useBlockNoteEditor,
useDictionary,
useExtensionState,
} from '@blocknote/react';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useConfig } from '@/core/config/api';
import { useResponsiveStore } from '@/stores';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
import { CommentToolbarButton } from '../comments/CommentToolbarButton';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
@@ -24,6 +35,7 @@ export const BlockNoteToolbar = () => {
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const { isTablet, isInputTouch } = useResponsiveStore();
const toolbarItems = useMemo(() => {
let toolbarItems = getFormattingToolbarItems([
@@ -84,7 +96,13 @@ export const BlockNoteToolbar = () => {
return (
<>
<FormattingToolbarController formattingToolbar={formattingToolbar} />
{isInputTouch && isTablet ? (
<MobileFormattingToolbarController
formattingToolbar={formattingToolbar}
/>
) : (
<FormattingToolbarController formattingToolbar={formattingToolbar} />
)}
{confirmOpen && (
<ModalConfirmDownloadUnsafe
onClose={() => setIsConfirmOpen(false)}
@@ -94,3 +112,38 @@ export const BlockNoteToolbar = () => {
</>
);
};
const MobileFormattingToolbarController = ({
formattingToolbar,
}: {
formattingToolbar: () => React.ReactNode;
}) => {
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
const show = useExtensionState(FormattingToolbarExtension, {
editor,
});
if (!show) {
return null;
}
return (
<Box
$position="absolute"
$css={`
& > div {
left: 50%;
transform: translate(0px, 0px) scale(1) translateX(-50%)!important;
}
`}
>
<ExperimentalMobileFormattingToolbarController
formattingToolbar={formattingToolbar}
/>
</Box>
);
};
@@ -74,7 +74,9 @@ export function MarkdownButton() {
};
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
return (
selectedBlocks.filter((block) => block.content !== undefined).length !== 0
);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
@@ -59,11 +59,7 @@ interface PdfBlockComponentProps {
>;
}
const PdfBlockComponent = ({
editor,
block,
contentRef,
}: PdfBlockComponentProps) => {
const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
@@ -114,27 +110,34 @@ const PdfBlockComponent = ({
void validatePDFContent();
}, [pdfUrl]);
if (isPDFContentLoading) {
return <Loading />;
}
if (!isPDFContentLoading && isPDFContent !== null && !isPDFContent) {
return (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
$width="100%"
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
);
}
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<>
<PDFBlockStyle />
{isPDFContentLoading && <Loading />}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
)}
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
@@ -158,7 +161,7 @@ const PdfBlockComponent = ({
/>
)}
</ResizableFileBlockWrapper>
</Box>
</>
);
};
@@ -180,6 +180,9 @@ export const cssEditor = css`
& .bn-editor {
padding-right: 36px;
}
& .bn-toolbar {
max-width: 100vw;
}
}
@media screen and (width <= 560px) {
@@ -120,7 +120,7 @@ const MainContent = ({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus {
&:focus-visible {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
}
@@ -1,6 +1,7 @@
import { create } from 'zustand';
export type ScreenSize = 'small-mobile' | 'mobile' | 'tablet' | 'desktop';
export type InputMethod = 'touch' | 'mouse' | 'unknown';
export interface UseResponsiveStore {
isMobile: boolean;
@@ -10,7 +11,11 @@ export interface UseResponsiveStore {
screenWidth: number;
setScreenSize: (size: ScreenSize) => void;
isDesktop: boolean;
isTouchCapable: boolean;
isInputTouch: boolean;
inputMethod: InputMethod;
initializeResizeListener: () => () => void;
initializeInputDetection: () => () => void;
}
const initialState = {
@@ -20,6 +25,9 @@ const initialState = {
isDesktop: false,
screenSize: 'desktop' as ScreenSize,
screenWidth: 0,
isTouchCapable: false,
isInputTouch: false,
inputMethod: 'unknown' as InputMethod,
};
export const useResponsiveStore = create<UseResponsiveStore>((set) => ({
@@ -29,6 +37,9 @@ export const useResponsiveStore = create<UseResponsiveStore>((set) => ({
isTablet: initialState.isTablet,
screenSize: initialState.screenSize,
screenWidth: initialState.screenWidth,
isTouchCapable: initialState.isTouchCapable,
isInputTouch: initialState.isInputTouch,
inputMethod: initialState.inputMethod,
setScreenSize: (size: ScreenSize) => set(() => ({ screenSize: size })),
initializeResizeListener: () => {
const resizeHandler = () => {
@@ -84,4 +95,32 @@ export const useResponsiveStore = create<UseResponsiveStore>((set) => ({
window.removeEventListener('resize', debouncedResizeHandler);
};
},
initializeInputDetection: () => {
// Detect if device has touch capability
const isTouchCapable =
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore - for older browsers
navigator.msMaxTouchPoints > 0;
set({ isTouchCapable });
// Track actual input method being used
const handleTouchStart = () => {
set({ inputMethod: 'touch', isInputTouch: true });
};
const handleMouseMove = () => {
set({ inputMethod: 'mouse', isInputTouch: false });
};
// Listen for first interaction to determine input method
window.addEventListener('touchstart', handleTouchStart, { once: false });
window.addEventListener('mousemove', handleMouseMove, { once: false });
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('mousemove', handleMouseMove);
};
},
}));