♻️(frontend) add versions in the panel editor

We add the features version to the panel editor.
We had to refactor the panel to be able to
have the version with the table of content in
the same panel.
This commit is contained in:
Anthony LC
2024-09-30 17:02:16 +02:00
committed by Anthony LC
parent b2480eea74
commit ac74db2fde
20 changed files with 599 additions and 288 deletions

View File

@@ -1,106 +0,0 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelProps {
title?: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
export const Panel = ({
children,
title,
setIsPanelOpen,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="99vh"
$height="100%"
$css={`
top: 0vh;
transition: ${transition};
${
!isOpen &&
`
box-shadow: none;
border: none;
`
}
`}
aria-label={t('Document panel')}
{...closedOverridingStyles}
>
<Box
$overflow="inherit"
$position="sticky"
$css={`
top: 0;
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
$maxHeight="100%"
>
<Box
$padding={{ all: 'small' }}
$direction="row"
$align="center"
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<IconBG
iconName="menu_open"
aria-label={isOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: 0rem;
top: 0.1rem;
transition: ${transition};
transform: rotate(180deg);
opacity: ${isOpen ? '1' : '0'};
user-select: none;
`}
$position="absolute"
onClick={() => {
setIsOpen(false);
setTimeout(() => {
setIsPanelOpen(false);
}, 400);
}}
$radius="2px"
/>
{title && (
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
)}
</Box>
{children}
</Box>
</Card>
);
};

View File

@@ -5,19 +5,14 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import {
VersionList,
Versions,
useDocVersion,
useDocVersionStore,
} from '@/features/docs/doc-versioning/';
import { useHeading } from '@/features/docs/doc-table-content';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { BlockNoteEditor } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
@@ -27,8 +22,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { t } = useTranslation();
const headings = useHeading(doc.id);
const isVersion = versionId && typeof versionId === 'string';
@@ -56,21 +51,22 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
$height="100%"
$direction="row"
$margin={{ all: 'small', top: 'none' }}
$gap="1rem"
$css="overflow-x: clip;"
>
<Card $padding="big" $css="flex:1;" $overflow="auto">
<Card
$padding="big"
$css="flex:1;"
$overflow="auto"
$position="relative"
>
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
<IconOpenPanelEditor headings={headings} />
</Card>
{doc.abilities.versions_list && isPanelVersionOpen && (
<Panel title={t('VERSIONS')} setIsPanelOpen={setIsPanelVersionOpen}>
<VersionList doc={doc} />
</Panel>
)}
<TableContent doc={doc} />
<PanelEditor doc={doc} headings={headings} />
</Box>
</>
);

View File

@@ -0,0 +1,187 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs//doc-management';
import { HeadingBlock, TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { usePanelEditorStore } from '../stores/usePanelEditorStore';
interface PanelProps {
doc: Doc;
headings: HeadingBlock[];
}
export const PanelEditor = ({
doc,
headings,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="99vh"
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
transform: translateX(0%);
overflow: hidden;
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="100%"
>
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
${isPanelTableContentOpen ? 'transform: translateX(0);' : 'transform: translateX(100%);'}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
interface IconOpenPanelEditorProps {
headings: HeadingBlock[];
}
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [headings, setIsPanelTableContentOpen, setIsPanelOpen, hasBeenOpen]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={1}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};

View File

@@ -1 +1,2 @@
export * from './useDocStore';
export * from './usePanelEditorStore';

View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
export interface UsePanelEditorStore {
isPanelOpen: boolean;
setIsPanelOpen: (isOpen: boolean) => void;
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const usePanelEditorStore = create<UsePanelEditorStore>((set) => ({
isPanelOpen: false,
isPanelTableContentOpen: true,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
setIsPanelOpen: (isPanelOpen) => {
set(() => ({ isPanelOpen }));
},
}));

View File

@@ -3,14 +3,13 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, Text } from '@/components';
import { usePanelEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { useDocTableContentStore } from '@/features/docs/doc-table-content';
import { useDocVersionStore } from '@/features/docs/doc-versioning';
import { ModalPDF } from './ModalExport';
@@ -25,8 +24,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelVersionOpen } = useDocVersionStore();
const { setIsPanelTableContentOpen } = useDocTableContentStore();
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
return (
<Box
@@ -81,10 +79,24 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
<Text $theme="primary">{t('Delete document')}</Text>
</Button>
)}
{doc.abilities.versions_list && (
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">history</span>}
size="small"
>
<Text $theme="primary">{t('Version history')}</Text>
</Button>
)}
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
setIsPanelVersionOpen(false);
setIsDropOpen(false);
}}
color="primary-text"

View File

@@ -1,89 +1,25 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { Panel } from '@/components/Panel';
import { useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useDocTableContentStore } from '../stores';
import { HeadingBlock } from '../types';
import { Heading } from './Heading';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
return '';
}
return content.reduce((acc, content) => {
if (content.type === 'text') {
return acc + content.text;
} else if (content.type === 'link') {
return acc + recursiveTextContent(content.content);
}
return acc;
}, '');
};
type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
contentText: string;
props: {
level: number;
};
};
interface TableContentProps {
doc: Doc;
headings: HeadingBlock[];
}
export const TableContent = ({ doc }: TableContentProps) => {
export const TableContent = ({ doc, headings }: TableContentProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() =>
editor?.document
.filter((block) => block.type === 'heading')
.map((block) => ({
...block,
contentText: recursiveTextContent(
block.content as unknown as HeadingBlock['content'],
),
})) as unknown as HeadingBlock[],
[editor?.document],
);
const [headings, setHeadings] = useState<HeadingBlock[]>();
const { setIsPanelTableContentOpen, isPanelTableContentOpen } =
useDocTableContentStore();
const [hasBeenClose, setHasBeenClose] = useState(false);
const setClosePanel = () => {
setHasBeenClose(true);
setIsPanelTableContentOpen(false);
};
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenClose) {
setIsPanelTableContentOpen(true);
}
}, [setIsPanelTableContentOpen, headings, hasBeenClose]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelTableContentOpen(false);
};
}, [setIsPanelTableContentOpen]);
// To highlight the first heading in the viewport
useEffect(() => {
const handleScroll = () => {
@@ -130,68 +66,57 @@ export const TableContent = ({ doc }: TableContentProps) => {
return null;
}
// Update the headings when the editor content changes
editor?.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
if (!isPanelTableContentOpen) {
return null;
}
return (
<Panel setIsPanelOpen={setClosePanel}>
<Box $padding="small" $maxHeight="95%">
<Box $overflow="auto">
{headings?.map((heading) => (
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
key={heading.id}
isHighlight={headingIdHighlight === heading.id}
/>
))}
</Box>
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
<Box $padding={{ all: 'small', right: 'none' }} $maxHeight="95%">
<Box $overflow="auto">
{headings?.map((heading) => (
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
key={heading.id}
isHighlight={headingIdHighlight === heading.id}
/>
))}
</Box>
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
</Panel>
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
);
};

View File

@@ -0,0 +1 @@
export * from './useHeading';

View File

@@ -0,0 +1,46 @@
import { useCallback, useState } from 'react';
import { useDocStore } from '../../doc-editor';
import { HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
return '';
}
return content.reduce((acc, content) => {
if (content.type === 'text') {
return acc + content.text;
} else if (content.type === 'link') {
return acc + recursiveTextContent(content.content);
}
return acc;
}, '');
};
export const useHeading = (docId: string) => {
const { docsStore } = useDocStore();
const editor = docsStore?.[docId]?.editor;
const headingFiltering = useCallback(
() =>
editor?.document
.filter((block) => block.type === 'heading')
.map((block) => ({
...block,
contentText: recursiveTextContent(
block.content as unknown as HeadingBlock['content'],
),
})) as unknown as HeadingBlock[],
[editor?.document],
);
const [headings, setHeadings] = useState<HeadingBlock[]>(headingFiltering());
editor?.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
return headings;
};

View File

@@ -1,2 +1,3 @@
export * from './components';
export * from './stores';
export * from './hooks';
export * from './types';

View File

@@ -1 +0,0 @@
export * from './useDocTableContentStore';

View File

@@ -1,15 +0,0 @@
import { create } from 'zustand';
export interface UseDocTableContentStore {
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const useDocTableContentStore = create<UseDocTableContentStore>(
(set) => ({
isPanelTableContentOpen: false,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
}),
);

View File

@@ -0,0 +1,10 @@
export type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
contentText: string;
props: {
level: number;
};
};

View File

@@ -90,6 +90,8 @@ export const ModalVersion = ({
id: docId,
content: newDoc,
});
onClose();
}}
>
{t('Restore')}

View File

@@ -1,4 +1,3 @@
export * from './api';
export * from './components';
export * from './stores';
export * from './types';

View File

@@ -1 +0,0 @@
export * from './useDocVersionStore';

View File

@@ -1,13 +0,0 @@
import { create } from 'zustand';
export interface UseDocVersionStore {
isPanelVersionOpen: boolean;
setIsPanelVersionOpen: (isOpen: boolean) => void;
}
export const useDocVersionStore = create<UseDocVersionStore>((set) => ({
isPanelVersionOpen: false,
setIsPanelVersionOpen: (isPanelVersionOpen) => {
set(() => ({ isPanelVersionOpen }));
},
}));