Compare commits

...

8 Commits

Author SHA1 Message Date
Cyril
0144044c55 (frontend) add pdf export regression test with embedded image fixtures
adds base64 png/jpg/svg image fixtures to stabilize pdf export snapshots

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:43 +01:00
Cyril
a6da37e231 (frontend) add test to compare generated PDF against reference template
ensures no regression by validating PDF content matches the expected pattern

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:31 +01:00
Anthony LC
9aeedd1d03 ️(frontend) improve UploadFile process
We notices that `context.getChanges` was very
greedy, on a large document with multiple
users collaborating, it caused performance issues.
We change the way that we track a upload by
listening onUploadEnd event instead of tracking
all changes in the document.
When a doc opens, we check if there are any ongoing
uploads and resume them.
We fix as well a race condition that could happen
when multiple collaborators were on a document
during an upload.
2025-12-01 10:31:46 +01:00
Anthony LC
f7d4e6810b ️(frontend) enhance Table of Contents
- the Table of Contents stickiness now covers the
full height of the viewport, before it was limited to
100vh
- we listen the scroll to highlight the heading
in the Table of Contents only when the Table of Contents
is open
- We debounce the editor change to avoid excessive updates
to the Table of Contents
2025-12-01 10:31:45 +01:00
Anthony LC
b740ffa52c 📌(frontend) Bump body-parser from 2.2.0 to 2.2.1
Bumps [body-parser](https://github.com/expressjs/body-parser)
from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:52:23 +01:00
Anthony LC
f555e36e98 ⬆️(dependencies) Jump js-yaml from 3.14.1 to 3.14.2
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:51:11 +01:00
Anthony LC
de11ab508f ⬆️(dependencies) Bump glob from 10.4.5 to 10.5.0 in /src/mail
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:11:53 +01:00
Anthony LC
dc2fe4905b ⬆️(frontend) upgrade some gitHub actions
Some github actions were using outdated versions.
This commit upgrades them to use a common versions
for all our workflows.
2025-11-28 17:36:16 +01:00
15 changed files with 500 additions and 254 deletions

View File

@@ -19,7 +19,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -46,7 +46,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
@@ -92,7 +92,7 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:

View File

@@ -15,6 +15,7 @@ and this project adheres to
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2
- ⚡️(frontend) improve perf on upload and table of contents #1662
### Fixed
@@ -31,6 +32,7 @@ and this project adheres to
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
- ✅(e2e) add test to compare generated PDF against reference template #1648
### Changed

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

View File

@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -13,6 +14,35 @@ import {
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
const REGRESSION_FIXTURE_CONTENT = fs.readFileSync(
path.join(__dirname, 'assets/content.txt'),
'utf-8',
);
const REGRESSION_SNAPSHOT_NAME = 'doc-export-regression.pdf';
const REGRESSION_DOC_TITLE = 'doc-export-regression-reference';
/**
* Playwright snapshots store the raw PDF bytes. However, each export embeds
* dynamic metadata (timestamps, font-subset identifiers, etc.) that would make
* the snapshot differ at every run. To ensure deterministic comparisons we
* strip/neutralize those fields before matching against the reference PDF.
*/
const sanitizePdfBuffer = (buffer: Buffer) => {
const pdfText = buffer.toString('latin1');
const neutralized = pdfText
// Remove per-export timestamps
.replace(/\/CreationDate\s*\(.*?\)/g, '/CreationDate ()')
.replace(/\/ModDate\s*\(.*?\)/g, '/ModDate ()')
// Remove file identifiers
.replace(/\/ID\s*\[<[^>]+>\s*<[^>]+>\]/g, '/ID [<0><0>]')
.replace(/D:\d{14}Z/g, 'D:00000000000000Z')
// Remove subset font prefixes generated by PDF renderer
.replace(/\b[A-Z]{6}\+(Inter18pt-[A-Za-z]+)\b/g, 'STATIC+$1')
.replace(/\b[A-Z]{6}\+(GeistMono-[A-Za-z]+)\b/g, 'STATIC+$1');
return Buffer.from(neutralized, 'latin1');
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -551,4 +581,118 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
/**
* Regression guard for the full PDF export pipeline.
*
* Usage reminder:
* 1. `npx playwright test __tests__/app-impress/doc-export.spec.ts --update-snapshots -g "full document" --project=chromium`
* -> refresh the reference PDF whenever we intentionally change the export output.
* 2. `npx playwright test __tests__/app-impress/doc-export.spec.ts -g "full document" --project=chromium`
* -> CI (and local runs without --update-snapshots) will compare the PDF to the reference
* and fail on any byte-level difference once the dynamic metadata has been sanitized.
*/
test('it keeps the full document PDF export identical to the reference snapshot', async ({
page,
browserName,
}, testInfo) => {
// PDF generation for a large, image-heavy document can be slow in CI.
// Give this regression test a higher timeout budget than the default.
testInfo.setTimeout(120000);
const snapshotPath = testInfo.snapshotPath(REGRESSION_SNAPSHOT_NAME);
test.skip(
!fs.existsSync(snapshotPath) &&
testInfo.config.updateSnapshots === 'none',
`Missing PDF snapshot at ${snapshotPath}. Run Playwright with --update-snapshots to record it.`,
);
// We must use a deterministic title so that block content (and thus the
// exported PDF) stays identical between runs.
await createDoc(page, 'doc-export-regression', browserName, 1);
const titleInput = page.getByRole('textbox', { name: 'Document title' });
await expect(titleInput).toBeVisible();
await titleInput.fill(REGRESSION_DOC_TITLE);
await titleInput.blur();
await verifyDocName(page, REGRESSION_DOC_TITLE);
const regressionDoc = REGRESSION_DOC_TITLE;
const docId = page
.url()
.split('/docs/')[1]
?.split('/')
.filter(Boolean)
.shift();
expect(docId).toBeTruthy();
// Inject the pre-crafted blocknote document via the REST API to avoid
// rebuilding it through the UI (which would be slow and flaky).
const cookies = await page.context().cookies();
const csrfToken = cookies.find(
(cookie) => cookie.name === 'csrftoken',
)?.value;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const updateResponse = await page.request.patch(
`http://localhost:8071/api/v1.0/documents/${docId}/`,
{
headers,
data: {
content: REGRESSION_FIXTURE_CONTENT,
websocket: true,
},
},
);
if (!updateResponse.ok()) {
throw new Error(
`Failed to seed document content. Status: ${updateResponse.status()}, body: ${await updateResponse.text()}`,
);
}
await page.reload();
// After reloading, just ensure the editor container is present before exporting.
await expect(page.locator('.--docs--editor-container')).toBeVisible({
timeout: 15000,
});
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeEnabled({
timeout: 15000,
});
// Export to PDF and confirm the generated bytes match the reference file.
const downloadPromise = page.waitForEvent('download', {
timeout: 60000,
predicate: (download) =>
download.suggestedFilename().includes(`${regressionDoc}.pdf`),
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${regressionDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const normalizedPdfBuffer = sanitizePdfBuffer(pdfBuffer);
expect(normalizedPdfBuffer).toMatchSnapshot(REGRESSION_SNAPSHOT_NAME);
});
});

View File

@@ -0,0 +1,29 @@
//utilitary script to print the datauris of the targeted assets
const fs = require('fs');
const path = require('path');
const ASSETS_ROOT = path.resolve(__dirname, '__tests__/app-impress/assets');
function saveDataUrl(file, mime, outName) {
const abs = path.join(ASSETS_ROOT, file);
const base64 = fs.readFileSync(abs).toString('base64');
const dataUrl = `data:${mime};base64,${base64}`;
const outPath = path.join(ASSETS_ROOT, outName);
fs.writeFileSync(outPath, dataUrl, 'utf8');
console.log(`Wrote ${outName}`);
}
// PNG
saveDataUrl('panopng.png', 'image/png', 'pano-png-dataurl.txt');
// JPG
saveDataUrl('panojpg.jpeg', 'image/jpeg', 'pano-jpg-dataurl.txt');
// SVG
const svgPath = path.join(ASSETS_ROOT, 'test.svg');
const svgText = fs.readFileSync(svgPath, 'utf8');
const svgDataUrl =
'data:image/svg+xml;base64,' + Buffer.from(svgText).toString('base64');
fs.writeFileSync(path.join(ASSETS_ROOT, 'test-svg-dataurl.txt'), svgDataUrl, 'utf8');
console.log('Wrote test-svg-dataurl.txt');

View File

@@ -1,6 +1,5 @@
import clsx from 'clsx';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
@@ -97,18 +96,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return (
<>
{isDesktop && (
<Box
$height="100vh"
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent />
</Box>
)}
{isDesktop && <TableContent />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={

View File

@@ -75,27 +75,31 @@ const UploadLoaderBlockComponent = ({
loopCheckDocMediaStatus(url)
.then((response) => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
// Add random delay to reduce collision probability during collaboration
const randomDelay = Math.random() * 800;
setTimeout(() => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
}, randomDelay);
})
.catch((error) => {
console.error('Error analyzing file:', error);

View File

@@ -9,11 +9,33 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
const unsubscribe = editor?.onChange(() => {
setHeadings(editor);
let timeoutId: NodeJS.Timeout;
const DEBOUNCE_DELAY = 500;
const unsubscribe = editor?.onChange((_, context) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
if (
blockChanges.type !== 'update' ||
blockChanges.block.type !== 'heading'
) {
return;
}
setHeadings(editor);
}, DEBOUNCE_DELAY);
});
return () => {
clearTimeout(timeoutId);
resetHeadings();
unsubscribe();
};

View File

@@ -1,3 +1,4 @@
import { Block } from '@blocknote/core';
import { captureException } from '@sentry/nextjs';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -36,73 +37,93 @@ export const useUploadFile = (docId: string) => {
};
};
/**
* When we upload a file it can takes some time to analyze it (e.g. virus scan).
* This hook listen to upload end and replace the uploaded block by a uploadLoader
* block to show analyzing status.
* The uploadLoader block will then handle the status display until the analysis is done
* then replaced by the final block (e.g. image, pdf, etc.).
* @param editor
*/
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const ANALYZE_URL = 'media-check';
const { t } = useTranslation();
useEffect(() => {
const unsubscribe = editor.onChange((_, context) => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
/**
* Replace the resource block by a uploadLoader block to show analyzing status
*/
const replaceBlockWithUploadLoader = useCallback(
(block: Block) => {
if (
blockChanges.source.type !== 'local' ||
blockChanges.type !== 'update' ||
!('url' in blockChanges.block.props) ||
('url' in blockChanges.block.props &&
!blockChanges.block.props.url.includes(ANALYZE_URL))
!block ||
!('url' in block.props) ||
('url' in block.props && !block.props.url.includes(ANALYZE_URL))
) {
return;
}
const blockUploadUrl = blockChanges.block.props.url;
const blockUploadType = blockChanges.block.type;
const blockUploadName = blockChanges.block.props.name;
const blockUploadUrl = block.props.url;
const blockUploadType = block.type;
const blockUploadName = block.props.name;
const blockUploadShowPreview =
('showPreview' in blockChanges.block.props &&
blockChanges.block.props.showPreview) ||
false;
('showPreview' in block.props && block.props.showPreview) || false;
const timeoutId = setTimeout(() => {
// Replace the resource block by a uploadLoader block
// to show analyzing status
try {
editor.replaceBlocks(
[blockChanges.block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
try {
editor.replaceBlocks(
[block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
}, 250);
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
},
[editor, t],
);
useEffect(() => {
const imagesBlocks = editor?.document.filter(
(block) =>
block.type === 'image' && block.props.url.includes(ANALYZE_URL),
);
imagesBlocks.forEach((block) => {
replaceBlockWithUploadLoader(block as Block);
});
}, [editor, replaceBlockWithUploadLoader]);
/**
* Handle upload end to replace the upload block by a uploadLoader
* block to show analyzing status
*/
useEffect(() => {
editor.onUploadEnd((blockId) => {
if (!blockId) {
return;
}
const innerTimeoutId = setTimeout(() => {
const block = editor.getBlock({ id: blockId });
replaceBlockWithUploadLoader(block as Block);
}, 300);
return () => {
clearTimeout(timeoutId);
unsubscribe();
clearTimeout(innerTimeoutId);
};
});
return () => {
unsubscribe();
};
}, [editor, t]);
}, [editor, replaceBlockWithUploadLoader]);
};

View File

@@ -10,15 +10,112 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';
export const TableContent = () => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [containerHeight, setContainerHeight] = useState('100vh');
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
/**
* Calculate container height based on the scrollable content
*/
useEffect(() => {
const mainLayout = document.getElementById(MAIN_LAYOUT_ID);
if (mainLayout) {
setContainerHeight(`${mainLayout.scrollHeight}px`);
}
}, []);
const onOpen = () => {
setIsOpen(true);
};
return (
<Box
$height={containerHeight}
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<Box
as="nav"
id="summaryContainer"
$width={!isOpen ? '40px' : '200px'}
$height={!isOpen ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={1000}
$align="center"
$padding={isOpen ? 'xs' : '0'}
$justify="center"
$position="sticky"
aria-label={t('Summary')}
$css={css`
top: var(--c--globals--spacings--0);
border: 1px solid ${colorsTokens['brand-100']};
overflow: hidden;
border-radius: ${spacingsTokens['3xs']};
background: ${colorsTokens['gray-000']};
${isOpen &&
css`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: ${spacingsTokens['2xs']};
`}
`}
className="--docs--table-content"
>
{!isOpen && (
<BoxButton
onClick={onOpen}
$width="100%"
$height="100%"
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isOpen}
aria-controls="toc-list"
$css={css`
&:focus-visible {
outline: none;
box-shadow: 0 0 0 4px ${colorsTokens['brand-400']};
background: ${colorsTokens['brand-100']};
width: 90%;
height: 90%;
}
`}
>
<Icon
$theme="brand"
$variation="tertiary"
iconName="list"
variant="symbols-outlined"
/>
</BoxButton>
)}
{isOpen && <TableContentOpened setIsOpen={setIsOpen} />}
</Box>
</Box>
);
};
const TableContentOpened = ({
setIsOpen,
}: {
setIsOpen: (isOpen: boolean) => void;
}) => {
const { headings } = useHeadingStore();
const { editor } = useEditorStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
const { t } = useTranslation();
const [isHover, setIsHover] = useState(false);
/**
* Handle scroll to highlight the current heading in the table of content
*/
useEffect(() => {
const handleScroll = () => {
if (!headings) {
@@ -69,23 +166,10 @@ export const TableContent = () => {
.getElementById(MAIN_LAYOUT_ID)
?.removeEventListener('scroll', scrollFn);
};
}, [headings, setHeadingIdHighlight]);
const onOpen = () => {
setIsHover(true);
setTimeout(() => {
const element = document.getElementById(`heading-${headingIdHighlight}`);
element?.scrollIntoView({
behavior: 'instant',
inline: 'center',
block: 'center',
});
}, 0); // 300ms is the transition time of the box
};
}, [headings]);
const onClose = () => {
setIsHover(false);
setIsOpen(false);
};
if (
@@ -99,129 +183,69 @@ export const TableContent = () => {
return (
<Box
as="nav"
id="summaryContainer"
$width={!isHover ? '40px' : '200px'}
$height={!isHover ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={1000}
$align="center"
$padding={isHover ? 'xs' : '0'}
$justify="center"
$position="sticky"
aria-label={t('Summary')}
$width="100%"
$overflow="hidden"
$css={css`
top: var(--c--globals--spacings--0);
border: 1px solid ${colorsTokens['brand-100']};
overflow: hidden;
border-radius: ${spacingsTokens['3xs']};
background: ${colorsTokens['gray-000']};
${isHover &&
css`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: ${spacingsTokens['2xs']};
`}
user-select: none;
padding: ${spacingsTokens['4xs']};
`}
className="--docs--table-content"
>
{!isHover && (
<Box
$margin={{ bottom: spacingsTokens.xs }}
$direction="row"
$justify="space-between"
$align="center"
>
<Text $weight="500" $size="sm">
{t('Summary')}
</Text>
<BoxButton
onClick={onOpen}
$width="100%"
$height="100%"
onClick={onClose}
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isHover}
aria-expanded="true"
aria-controls="toc-list"
$css={css`
transition: none !important;
transform: rotate(180deg);
&:focus-visible {
outline: none;
box-shadow: 0 0 0 4px ${colorsTokens['brand-400']};
background: ${colorsTokens['brand-100']};
width: 90%;
height: 90%;
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']};
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon
$theme="brand"
$variation="tertiary"
iconName="list"
variant="symbols-outlined"
/>
<Icon iconName="menu_open" $theme="brand" $variation="tertiary" />
</BoxButton>
)}
{isHover && (
<Box
$width="100%"
$overflow="hidden"
$css={css`
user-select: none;
padding: ${spacingsTokens['4xs']};
`}
>
<Box
$margin={{ bottom: spacingsTokens.xs }}
$direction="row"
$justify="space-between"
$align="center"
>
<Text $weight="500" $size="sm">
{t('Summary')}
</Text>
<BoxButton
onClick={onClose}
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isHover}
aria-controls="toc-list"
$css={css`
transition: none !important;
transform: rotate(180deg);
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']};
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon iconName="menu_open" $theme="brand" $variation="tertiary" />
</BoxButton>
</Box>
<Box
as="ul"
id="toc-list"
role="list"
$gap={spacingsTokens['3xs']}
$css={css`
overflow-y: auto;
list-style: none;
padding: ${spacingsTokens['3xs']};
margin: 0;
`}
>
{headings?.map(
(heading) =>
heading.contentText && (
<Box as="li" role="listitem" key={heading.id}>
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
isHighlight={headingIdHighlight === heading.id}
/>
</Box>
),
)}
</Box>
</Box>
)}
</Box>
<Box
as="ul"
id="toc-list"
role="list"
$gap={spacingsTokens['3xs']}
$css={css`
overflow-y: auto;
list-style: none;
padding: ${spacingsTokens['3xs']};
margin: 0;
`}
>
{headings?.map(
(heading) =>
heading.contentText && (
<Box as="li" role="listitem" key={heading.id}>
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
isHighlight={headingIdHighlight === heading.id}
/>
</Box>
),
)}
</Box>
</Box>
);
};

View File

@@ -7134,19 +7134,19 @@ bluebird@^3.4.1:
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
body-parser@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa"
integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==
version "2.2.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.1.tgz#6df606b0eb0a6e3f783dde91dde182c24c82438c"
integrity sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==
dependencies:
bytes "^3.1.2"
content-type "^1.0.5"
debug "^4.4.0"
debug "^4.4.3"
http-errors "^2.0.0"
iconv-lite "^0.6.3"
iconv-lite "^0.7.0"
on-finished "^2.4.1"
qs "^6.14.0"
raw-body "^3.0.0"
type-is "^2.0.0"
raw-body "^3.0.1"
type-is "^2.0.1"
boolbase@^1.0.0:
version "1.0.0"
@@ -7259,7 +7259,7 @@ buffer@^6.0.3:
base64-js "^1.3.1"
ieee754 "^1.2.1"
bytes@3.1.2, bytes@^3.1.2:
bytes@^3.1.2, bytes@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
@@ -7975,7 +7975,7 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
depd@2.0.0, depd@^2.0.0:
depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
@@ -9747,7 +9747,7 @@ htmlparser2@^10.0.0:
domutils "^3.2.1"
entities "^6.0.0"
http-errors@2.0.0, http-errors@^2.0.0:
http-errors@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
@@ -9758,6 +9758,17 @@ http-errors@2.0.0, http-errors@^2.0.0:
statuses "2.0.1"
toidentifier "1.0.1"
http-errors@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
dependencies:
depd "~2.0.0"
inherits "~2.0.4"
setprototypeof "~1.2.0"
statuses "~2.0.2"
toidentifier "~1.0.1"
http-proxy-agent@^7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
@@ -9843,7 +9854,7 @@ iconv-lite@0.6.3, iconv-lite@^0.6.3:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
iconv-lite@0.7.0:
iconv-lite@^0.7.0, iconv-lite@~0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e"
integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==
@@ -9944,7 +9955,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -10732,17 +10743,17 @@ jest@30.2.0:
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^3.13.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
version "3.14.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0"
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
dependencies:
argparse "^1.0.7"
esprima "^4.0.0"
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies:
argparse "^2.0.1"
@@ -12696,15 +12707,15 @@ range-parser@^1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.1.tgz#ced5cd79a77bbb0496d707f2a0f9e1ae3aecdcb1"
integrity sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==
raw-body@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51"
integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
iconv-lite "0.7.0"
unpipe "1.0.0"
bytes "~3.1.2"
http-errors "~2.0.1"
iconv-lite "~0.7.0"
unpipe "~1.0.0"
react-arborist@3.4.3:
version "3.4.3"
@@ -13694,7 +13705,7 @@ setimmediate@^1.0.5:
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
setprototypeof@1.2.0:
setprototypeof@1.2.0, setprototypeof@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
@@ -13953,7 +13964,7 @@ statuses@2.0.1:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
statuses@^2.0.1:
statuses@^2.0.1, statuses@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
@@ -14544,7 +14555,7 @@ to-through@^3.0.0:
dependencies:
streamx "^2.12.5"
toidentifier@1.0.1:
toidentifier@1.0.1, toidentifier@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
@@ -14723,7 +14734,7 @@ type-fest@^4.41.0:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
type-is@^2.0.0, type-is@^2.0.1:
type-is@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97"
integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==
@@ -14940,7 +14951,7 @@ universalify@^2.0.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
unpipe@1.0.0:
unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==

View File

@@ -398,9 +398,9 @@ glob-parent@~5.1.2:
is-glob "^4.0.1"
glob@^10.3.10, glob@^10.3.3:
version "10.4.5"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
version "10.5.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
dependencies:
foreground-child "^3.1.0"
jackspeak "^3.1.2"