♻️(frontent) improve summary feature
- Change Summary to Table of content - No dash before the title - Change font-size depend the type of heading - If more than 2 headings the panel is open by default - improve sticky - highligth the title where you are in the page
This commit is contained in:
@@ -20,6 +20,7 @@ and this project adheres to
|
|||||||
- ♻️ Allow null titles on documents for easier creation #234
|
- ♻️ Allow null titles on documents for easier creation #234
|
||||||
- 🛂(backend) stop to list public doc to everyone #234
|
- 🛂(backend) stop to list public doc to everyone #234
|
||||||
- 🚚(frontend) change visibility in share modal #235
|
- 🚚(frontend) change visibility in share modal #235
|
||||||
|
- ⚡️(frontend) Improve summary #244
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test.describe('Doc Summary', () => {
|
|||||||
await page.getByLabel('Open the document options').click();
|
await page.getByLabel('Open the document options').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Summary',
|
name: 'Table of content',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ test.describe('Doc Summary', () => {
|
|||||||
await page.locator('.bn-block-outer').last().click();
|
await page.locator('.bn-block-outer').last().click();
|
||||||
|
|
||||||
// Create space to fill the viewport
|
// Create space to fill the viewport
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ test.describe('Doc Summary', () => {
|
|||||||
await page.locator('.bn-block-outer').last().click();
|
await page.locator('.bn-block-outer').last().click();
|
||||||
|
|
||||||
// Create space to fill the viewport
|
// Create space to fill the viewport
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,17 +48,41 @@ test.describe('Doc Summary', () => {
|
|||||||
await page.getByText('Heading 3').click();
|
await page.getByText('Heading 3').click();
|
||||||
await page.keyboard.type('Another World');
|
await page.keyboard.type('Another World');
|
||||||
|
|
||||||
await expect(panel.getByText('Hello World')).toBeVisible();
|
const hello = panel.getByText('Hello World');
|
||||||
await expect(panel.getByText('Super World')).toBeVisible();
|
const superW = panel.getByText('Super World');
|
||||||
|
const another = panel.getByText('Another World');
|
||||||
|
|
||||||
await panel.getByText('Another World').click();
|
await expect(hello).toBeVisible();
|
||||||
|
await expect(hello).toHaveCSS('font-size', '19.2px');
|
||||||
|
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
await expect(superW).toBeVisible();
|
||||||
|
await expect(superW).toHaveCSS('font-size', '16px');
|
||||||
|
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||||
|
|
||||||
|
await expect(another).toBeVisible();
|
||||||
|
await expect(another).toHaveCSS('font-size', '12.8px');
|
||||||
|
await expect(another).toHaveAttribute('aria-selected', 'false');
|
||||||
|
|
||||||
|
await hello.click();
|
||||||
|
|
||||||
|
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||||
|
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||||
|
|
||||||
|
await another.click();
|
||||||
|
|
||||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||||
|
await expect(hello).toHaveAttribute('aria-selected', 'false');
|
||||||
|
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
await panel.getByText('Back to top').click();
|
await panel.getByText('Back to top').click();
|
||||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||||
|
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||||
|
|
||||||
await panel.getByText('Go to bottom').click();
|
await panel.getByText('Go to bottom').click();
|
||||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||||
|
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Box, Card, IconBG, Text } from '@/components';
|
|||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
title: string;
|
title?: string;
|
||||||
setIsPanelOpen: (isOpen: boolean) => void;
|
setIsPanelOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +53,14 @@ export const Panel = ({
|
|||||||
{...closedOverridingStyles}
|
{...closedOverridingStyles}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
$overflow="hidden"
|
$overflow="inherit"
|
||||||
|
$position="sticky"
|
||||||
$css={`
|
$css={`
|
||||||
|
top: 0;
|
||||||
opacity: ${isOpen ? '1' : '0'};
|
opacity: ${isOpen ? '1' : '0'};
|
||||||
transition: ${transition};
|
transition: ${transition};
|
||||||
`}
|
`}
|
||||||
|
$maxHeight="100%"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
$padding={{ all: 'small' }}
|
$padding={{ all: 'small' }}
|
||||||
@@ -90,9 +93,11 @@ export const Panel = ({
|
|||||||
}}
|
}}
|
||||||
$radius="2px"
|
$radius="2px"
|
||||||
/>
|
/>
|
||||||
<Text $weight="bold" $size="l" $theme="primary">
|
{title && (
|
||||||
{title}
|
<Text $weight="bold" $size="l" $theme="primary">
|
||||||
</Text>
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Panel } from '@/components/Panel';
|
|||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { DocHeader } from '@/features/docs/doc-header';
|
import { DocHeader } from '@/features/docs/doc-header';
|
||||||
import { Doc } from '@/features/docs/doc-management';
|
import { Doc } from '@/features/docs/doc-management';
|
||||||
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
|
import { Summary } from '@/features/docs/doc-summary';
|
||||||
import {
|
import {
|
||||||
VersionList,
|
VersionList,
|
||||||
Versions,
|
Versions,
|
||||||
@@ -28,8 +28,6 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
|||||||
query: { versionId },
|
query: { versionId },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
|
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
|
||||||
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isVersion = versionId && typeof versionId === 'string';
|
const isVersion = versionId && typeof versionId === 'string';
|
||||||
@@ -72,11 +70,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
|||||||
<VersionList doc={doc} />
|
<VersionList doc={doc} />
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
{isPanelSummaryOpen && (
|
<Summary doc={doc} />
|
||||||
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
|
|
||||||
<Summary doc={doc} />
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
icon={<span className="material-icons">summarize</span>}
|
icon={<span className="material-icons">summarize</span>}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<Text $theme="primary">{t('Summary')}</Text>
|
<Text $theme="primary">{t('Table of content')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { BlockNoteEditor } from '@blocknote/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { BoxButton, Text } from '@/components';
|
||||||
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
|
||||||
|
const sizeMap: { [key: number]: string } = {
|
||||||
|
1: '1.2rem',
|
||||||
|
2: '1rem',
|
||||||
|
3: '0.8rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeadingsHighlight = {
|
||||||
|
headingId: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
interface HeadingProps {
|
||||||
|
editor: BlockNoteEditor;
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
headingId: string;
|
||||||
|
isHighlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Heading = ({
|
||||||
|
headingId,
|
||||||
|
editor,
|
||||||
|
isHighlight,
|
||||||
|
level,
|
||||||
|
text,
|
||||||
|
}: HeadingProps) => {
|
||||||
|
const [isHover, setIsHover] = useState(isHighlight);
|
||||||
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxButton
|
||||||
|
key={headingId}
|
||||||
|
onMouseOver={() => setIsHover(true)}
|
||||||
|
onMouseLeave={() => setIsHover(false)}
|
||||||
|
onClick={() => {
|
||||||
|
editor.focus();
|
||||||
|
editor.setTextCursorPosition(headingId, 'end');
|
||||||
|
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
$theme="primary"
|
||||||
|
$padding={{ vertical: 'xtiny', left: 'tiny' }}
|
||||||
|
$size={sizeMap[level]}
|
||||||
|
$hasTransition
|
||||||
|
$css={
|
||||||
|
isHover || isHighlight
|
||||||
|
? `box-shadow: -2px 0px 0px ${colorsTokens()[isHighlight ? 'primary-500' : 'primary-400']};`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
aria-selected={isHighlight}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</BoxButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,11 +2,24 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box, BoxButton, Text } from '@/components';
|
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 { useDocStore } from '../../doc-editor';
|
|
||||||
import { Doc } from '../../doc-management';
|
|
||||||
import { useDocSummaryStore } from '../stores';
|
import { useDocSummaryStore } from '../stores';
|
||||||
|
|
||||||
|
import { Heading } from './Heading';
|
||||||
|
|
||||||
|
type HeadingBlock = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
content: HeadingBlock[];
|
||||||
|
props: {
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface SummaryProps {
|
interface SummaryProps {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
}
|
}
|
||||||
@@ -17,88 +30,150 @@ export const Summary = ({ doc }: SummaryProps) => {
|
|||||||
|
|
||||||
const editor = docsStore?.[doc.id]?.editor;
|
const editor = docsStore?.[doc.id]?.editor;
|
||||||
const headingFiltering = useCallback(
|
const headingFiltering = useCallback(
|
||||||
() => editor?.document.filter((block) => block.type === 'heading'),
|
() =>
|
||||||
|
editor?.document.filter(
|
||||||
|
(block) => block.type === 'heading',
|
||||||
|
) as unknown as HeadingBlock[],
|
||||||
[editor?.document],
|
[editor?.document],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [headings, setHeadings] = useState(headingFiltering());
|
const [headings, setHeadings] = useState<HeadingBlock[]>();
|
||||||
const { setIsPanelSummaryOpen } = useDocSummaryStore();
|
const { setIsPanelSummaryOpen, isPanelSummaryOpen } = useDocSummaryStore();
|
||||||
|
const [hasBeenClose, setHasBeenClose] = useState(false);
|
||||||
|
const setClosePanel = () => {
|
||||||
|
setHasBeenClose(true);
|
||||||
|
setIsPanelSummaryOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
|
||||||
|
|
||||||
|
// Open the panel if there are more than 1 heading
|
||||||
|
useEffect(() => {
|
||||||
|
if (headings?.length && headings.length > 1 && !hasBeenClose) {
|
||||||
|
setIsPanelSummaryOpen(true);
|
||||||
|
}
|
||||||
|
}, [setIsPanelSummaryOpen, headings, hasBeenClose]);
|
||||||
|
|
||||||
|
// Close the panel unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setIsPanelSummaryOpen(false);
|
setIsPanelSummaryOpen(false);
|
||||||
};
|
};
|
||||||
}, [setIsPanelSummaryOpen]);
|
}, [setIsPanelSummaryOpen]);
|
||||||
|
|
||||||
|
// To highlight the first heading in the viewport
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!headings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const heading of headings) {
|
||||||
|
const elHeading = document.body.querySelector(
|
||||||
|
`.bn-block-outer[data-id="${heading.id}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!elHeading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = elHeading.getBoundingClientRect();
|
||||||
|
const isVisible =
|
||||||
|
rect.top + rect.height >= 1 &&
|
||||||
|
rect.bottom <=
|
||||||
|
(window.innerHeight || document.documentElement.clientHeight);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
setHeadingIdHighlight(heading.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
handleScroll();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [headings, setHeadingIdHighlight]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.onEditorContentChange(() => {
|
// Update the headings when the editor content changes
|
||||||
|
editor?.onEditorContentChange(() => {
|
||||||
setHeadings(headingFiltering());
|
setHeadings(headingFiltering());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isPanelSummaryOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $overflow="auto" $padding="small">
|
<Panel setIsPanelOpen={setClosePanel}>
|
||||||
{headings?.map((heading) => (
|
<Box $padding="small" $maxHeight="95%">
|
||||||
|
<Box $overflow="auto">
|
||||||
|
{headings?.map((heading) => {
|
||||||
|
const content = heading.content?.[0];
|
||||||
|
const text = content?.type === 'text' ? content.text : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Heading
|
||||||
|
editor={editor}
|
||||||
|
headingId={heading.id}
|
||||||
|
level={heading.props.level}
|
||||||
|
text={text}
|
||||||
|
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
|
<BoxButton
|
||||||
key={heading.id}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
editor?.setTextCursorPosition(heading.id, 'end');
|
|
||||||
document
|
document
|
||||||
.querySelector(`[data-id="${heading.id}"]`)
|
.querySelector(
|
||||||
|
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
|
||||||
|
)
|
||||||
?.scrollIntoView({
|
?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
style={{ textAlign: 'left' }}
|
|
||||||
>
|
>
|
||||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||||
{heading.content?.[0]?.type === 'text' && heading.content?.[0]?.text
|
{t('Go to bottom')}
|
||||||
? `- ${heading.content[0].text}`
|
|
||||||
: ''}
|
|
||||||
</Text>
|
</Text>
|
||||||
</BoxButton>
|
</BoxButton>
|
||||||
))}
|
</Box>
|
||||||
<Box
|
</Panel>
|
||||||
$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('Go to bottom')}
|
|
||||||
</Text>
|
|
||||||
</BoxButton>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user