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

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

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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>
</>
);
};

View File

@@ -180,6 +180,9 @@ export const cssEditor = css`
& .bn-editor {
padding-right: 36px;
}
& .bn-toolbar {
max-width: 100vw;
}
}
@media screen and (width <= 560px) {

View File

@@ -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;
}

View File

@@ -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);
};
},
}));