🐛(frontend) keep editor mounted when resize window

When resizing the window and crossing the desktop
breakpoint, the editor was unmounted. It could
lead to loss of data if there were unsaved changes,
and tiptap crash if the toolbar was used while the
editor was unmounted.
It was caused by the ResizableLeftPanel component
which was rerendering the editor.
We now keep the editor mounted when resizing
the window, by keeping the ResizableLeftPanel
component rendered but setting its size to 0
and disabling the resize handle.
This commit is contained in:
Anthony LC
2025-12-10 11:48:09 +01:00
parent 99131dc917
commit af15e77713
6 changed files with 129 additions and 65 deletions

View File

@@ -21,6 +21,7 @@ and this project adheres to
### Fixed
- 🐛(nginx) fix / location to handle new static pages
- 🐛(frontend) rerendering during resize window #1715
## [4.0.0] - 2025-12-01

View File

@@ -996,4 +996,44 @@ test.describe('Doc Editor', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
test('it preserves text when switching between mobile and desktop views', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-viewport-test',
browserName,
1,
);
await verifyDocName(page, docTitle);
const editor = await writeInEditor({
page,
text: 'Hello World - Desktop Text',
});
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await page.waitForTimeout(500);
// Switch to mobile viewport
await page.setViewportSize({ width: 500, height: 1200 });
await page.waitForTimeout(500);
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await writeInEditor({
page,
text: 'Mobile Text',
});
await page.waitForTimeout(500);
// Switch back to desktop viewport
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForTimeout(500);
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
});

View File

@@ -12,7 +12,7 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const { closePanel } = useLeftPanelStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -25,7 +25,7 @@ export const LeftPanelHeaderButton = () => {
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
togglePanel();
closePanel();
})
.catch(() => {
// In case of navigation error, disable the skeleton

View File

@@ -6,6 +6,8 @@ import {
PanelResizeHandle,
} from 'react-resizable-panels';
import { useResponsiveStore } from '@/stores';
// Convert a target pixel width to a percentage of the current viewport width.
const pxToPercent = (px: number) => {
return (px / window.innerWidth) * 100;
@@ -24,18 +26,27 @@ export const ResizableLeftPanel = ({
minPanelSizePx = 300,
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { isDesktop } = useResponsiveStore();
const ref = useRef<ImperativePanelHandle>(null);
const savedWidthPxRef = useRef<number>(minPanelSizePx);
const [panelSizePercent, setPanelSizePercent] = useState(() =>
pxToPercent(minPanelSizePx),
);
const minPanelSizePercent = pxToPercent(minPanelSizePx);
const maxPanelSizePercent = Math.min(pxToPercent(maxPanelSizePx), 40);
const [panelSizePercent, setPanelSizePercent] = useState(() => {
const initialSize = pxToPercent(minPanelSizePx);
return Math.max(
minPanelSizePercent,
Math.min(initialSize, maxPanelSizePercent),
);
});
// Keep pixel width constant on window resize
useEffect(() => {
if (!isDesktop) {
return;
}
const handleResize = () => {
const newPercent = pxToPercent(savedWidthPxRef.current);
setPanelSizePercent(newPercent);
@@ -48,7 +59,7 @@ export const ResizableLeftPanel = ({
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
}, [isDesktop]);
const handleResize = (sizePercent: number) => {
const widthPx = (sizePercent / 100) * window.innerWidth;
@@ -57,29 +68,29 @@ export const ResizableLeftPanel = ({
};
return (
<>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={panelSizePercent}
minSize={minPanelSizePercent}
maxSize={maxPanelSizePercent}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
</>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={isDesktop ? panelSizePercent : 0}
minSize={isDesktop ? minPanelSizePercent : 0}
maxSize={isDesktop ? maxPanelSizePercent : 0}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
disabled={!isDesktop}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
);
};

View File

@@ -3,6 +3,7 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
togglePanel: (value?: boolean) => void;
closePanel: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
@@ -15,4 +16,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
set({ isPanelOpen: sanitizedValue });
},
closePanel: () => {
set({ isPanelOpen: false });
},
}));

View File

@@ -53,11 +53,51 @@ export function MainLayoutContent({
enableResizablePanel = false,
}: PropsWithChildren<MainLayoutContentProps>) {
const { isDesktop } = useResponsiveStore();
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</ResizableLeftPanel>
);
}
if (!isDesktop) {
return (
<>
<LeftPanel />
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
const MainContent = ({
children,
backgroundColor,
}: PropsWithChildren<MainLayoutContentProps>) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const mainContent = (
return (
<Box
as="main"
role="main"
@@ -92,36 +132,4 @@ export function MainLayoutContent({
{children}
</Box>
);
if (!isDesktop) {
return (
<>
<LeftPanel />
{mainContent}
</>
);
}
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
{mainContent}
</ResizableLeftPanel>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
{mainContent}
</>
);
}
};