Compare commits

..

6 Commits

Author SHA1 Message Date
Anthony LC 9f05c1b3c2 upgrade blocknote v3 2025-02-06 09:43:35 +01:00
Anthony LC c1e07d5eb1 upgrade blocknote 2025-02-05 10:31:53 +01:00
Anthony LC d235b32a2c deploy-docs-ai 2025-02-05 09:56:17 +01:00
Anthony LC b277ca70eb fix model 2025-02-05 09:55:04 +01:00
Anthony LC a0c5a911d7 fix lint 2025-02-05 09:44:19 +01:00
yousefed 6181e147ac blocknote ai 2025-02-04 21:47:44 +01:00
20 changed files with 955 additions and 505 deletions
+1
View File
@@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'feature/blocknote-ai'
tags:
- 'v*'
pull_request:
-2
View File
@@ -11,8 +11,6 @@ and this project adheres to
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add Alert, Quote, and Divider blocks to the editor #566
## [2.1.0] - 2025-01-29
@@ -368,84 +368,4 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
test('it checks the divider block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Divider', { exact: true }).click();
await expect(
editor.locator('.bn-block-content[data-content-type="divider"]'),
).toBeVisible();
});
test('it checks the quote block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Quote', { exact: true }).click();
await expect(
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await editor.fill('Hello World');
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
'italic',
);
});
test('it checks the alert block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Alert', { exact: true }).click();
const alertBlock = editor.locator(
'.bn-block-content[data-content-type="alert"]',
);
await expect(
alertBlock.locator('div[data-alert-type="warning"]'),
).toBeVisible();
await editor.fill('My alert');
await expect(alertBlock.getByText('My alert')).toBeVisible();
await alertBlock.getByText('warning').click();
await expect(
alertBlock.getByRole('menuitem', { name: 'warning Warning' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'error Error' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'info Info' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'check_circle Success' }),
).toBeVisible();
await alertBlock
.getByRole('menuitem', { name: 'check_circle Success' })
.click();
await expect(
alertBlock.locator('div[data-alert-type="success"]'),
).toBeVisible();
});
});
+8 -3
View File
@@ -15,9 +15,14 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"ai": "^4.1.18",
"zod": "^3.24.1",
"@ai-sdk/openai": "^1.1.9",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@blocknote/xl-ai": "*",
"vitest": "^2.0.3",
"@blocknote/xl-docx-exporter": "0.21.0",
"@blocknote/xl-pdf-exporter": "0.21.0",
"@gouvfr-lasuite/integration": "1.0.2",
@@ -95,7 +95,7 @@ export function AIGroupButton() {
return (
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Trigger>
<Components.FormattingToolbar.Button
<Components.Toolbar.Button
className="bn-button bn-menu-item"
data-test="ai-actions"
label="AI"
@@ -1,14 +1,31 @@
import { createOpenAI } from '@ai-sdk/openai';
import {
BlockNoteEditor as BlockNoteEditorCore,
BlockNoteSchema,
BlockNoteEditor as BNEditor,
BlockConfig,
Dictionary,
defaultBlockSpecs,
InlineContentSchema,
StyleSchema,
filterSuggestionItems,
locales,
} from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
useCreateBlockNote,
} from '@blocknote/react';
import {
AIShowSelectionPlugin,
BlockNoteAIContextProvider,
BlockNoteAIUI,
locales as aiLocales,
createBlockNoteAIClient,
getAISlashMenuItems,
useBlockNoteAIContext,
} from '@blocknote/xl-ai';
import '@blocknote/xl-ai/style.css';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -25,23 +42,34 @@ import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { AlertBlock, DividerBlock, QuoteBlock } from './custom-blocks';
export const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
alert: AlertBlock,
quote: QuoteBlock,
divider: DividerBlock,
},
const blocknoteAIClient = createBlockNoteAIClient({
apiKey: 'BLOCKNOTE-API-KEY-CURRENTLY-NOT-NEEDED',
baseURL: 'https://blocknote-esy4.onrender.com/ai',
});
export type DocsBlockNoteEditor = BlockNoteEditorCore<
typeof schema.blockSchema,
typeof schema.inlineContentSchema,
typeof schema.styleSchema
const model = createOpenAI({
baseURL: 'https://albert.api.staging.etalab.gouv.fr/v1',
...blocknoteAIClient.getProviderSettings('albert-etalab'),
compatibility: 'compatible',
})('neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8');
// We call the model via a proxy server (see above) that has the API key,
// but we could also call the model directly from the frontend.
// i.e., this should work as well (but it would leak your albert key to the frontend):
/*
return createOpenAI({
baseURL: 'https://albert.api.staging.etalab.gouv.fr/v1',
apiKey: 'ALBERT-API-KEY',
compatibility: 'compatible',
})('albert-etalab/neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8');
*/
export type DocsBlockNoteEditor = BNEditor<
Record<string, BlockConfig>,
InlineContentSchema,
StyleSchema
>;
interface BlockNoteEditorProps {
@@ -67,6 +95,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor = useCreateBlockNote(
{
_extensions: {
aiSelection: new AIShowSelectionPlugin(),
},
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
@@ -100,8 +132,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
schema,
dictionary: {
...(locales[lang as keyof typeof locales] as Dictionary),
ai: aiLocales['en'] as unknown as Dictionary,
},
uploadFile,
},
[collabName, lang, provider, uploadFile],
@@ -136,16 +170,43 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
editable={!readOnly}
slashMenu={false}
theme="light"
slashMenu={false}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
<BlockNoteAIContextProvider
model={model}
dataFormat="markdown"
stream={false}
>
<BlockNoteAIUI />
<BlockNoteToolbar />
<SuggestionMenu editor={editor as unknown as DocsBlockNoteEditor} />
</BlockNoteAIContextProvider>
</BlockNoteView>
</Box>
);
};
function SuggestionMenu(props: { editor: DocsBlockNoteEditor }) {
const ctx = useBlockNoteAIContext();
return (
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query) =>
Promise.resolve(
filterSuggestionItems(
[
...getDefaultReactSlashMenuItems(props.editor),
...getAISlashMenuItems(props.editor, ctx),
],
query,
),
)
}
/>
);
}
interface BlockNoteEditorVersionProps {
initialContent: Y.XmlFragment;
}
@@ -165,7 +226,6 @@ export const BlockNoteEditorVersion = ({
},
provider: undefined,
},
schema,
},
[initialContent],
);
@@ -1,39 +0,0 @@
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
import '@blocknote/mantine/style.css';
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
useBlockNoteEditor,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockNoteEditor } from './BlockNoteEditor';
import { insertAlert, insertDivider, insertQuote } from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const { t } = useTranslation();
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
Promise.resolve(
filterSuggestionItems(
combineByGroup(
getDefaultReactSlashMenuItems(editor),
[insertAlert(editor, t)],
[insertQuote(editor, t)],
[insertDivider(editor, t)],
),
query,
),
);
}, [editor, t]);
return (
<SuggestionMenuController
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
);
};
@@ -5,9 +5,9 @@ import {
FormattingToolbarProps,
getFormattingToolbarItems,
} from '@blocknote/react';
import React, { useCallback } from 'react';
import { AIToolbarButton } from '@blocknote/xl-ai';
import { useCallback } from 'react';
import { AIGroupButton } from './AIButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
@@ -17,7 +17,8 @@ export const BlockNoteToolbar = () => {
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* <AIGroupButton key="AIButton" /> */}
<AIToolbarButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
@@ -5,7 +5,7 @@ import {
useSelectedBlocks,
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Block = {
@@ -80,11 +80,11 @@ export function MarkdownButton() {
}
return (
<Components.FormattingToolbar.Button
<Components.Toolbar.Button
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
>
M
</Components.FormattingToolbar.Button>
</Components.Toolbar.Button>
);
}
@@ -1,179 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { Menu } from '@mantine/core';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
// The types of alerts that users can choose from.
export const alertTypes = [
{
title: 'Warning',
value: 'warning',
icon: 'warning',
color: 'warning-500',
backgroundColor: 'warning-300',
},
{
title: 'Error',
value: 'danger',
icon: 'error',
color: 'danger-500',
backgroundColor: 'danger-300',
},
{
title: 'Info',
value: 'info',
icon: 'info',
color: 'info-500',
backgroundColor: 'info-300',
},
{
title: 'Success',
value: 'success',
icon: 'check_circle',
color: 'success-500',
backgroundColor: 'success-100',
},
] as const;
// The Alert block.
export const AlertBlock = createReactBlockSpec(
{
type: 'alert',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
type: {
default: 'warning',
values: ['warning', 'danger', 'info', 'success'],
},
},
content: 'inline',
},
{
render: (props) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
let alertType = alertTypes.find(
(a) => a.value === props.block.props.type,
);
if (!alertType) {
alertType = alertTypes[0];
}
return (
<Box
className="alert"
data-alert-type={props.block.props.type}
$direction="row"
$justify="center"
$align="center"
$radius="4px"
$padding="4px"
$background={colorsTokens()[alertType.backgroundColor]}
$minHeight="48px"
$css={css`
flex-grow: 1;
`}
>
<Menu withinPortal={false}>
<Menu.Target>
<Box
className="alert-icon-wrapper"
$margin={{ horizontal: '12px' }}
$radius="16px"
$justify="center"
$align="center"
$height="24px"
$width="24px"
contentEditable={false}
$css="user-select: none; cursor: pointer;"
>
<Text
$isMaterialIcon
$theme={alertType.value}
$variation="500"
$size="20px"
>
{alertType.icon}
</Text>
</Box>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: 9999 }}>
<Menu.Label>{t('Alert Type')}</Menu.Label>
<Menu.Divider />
{alertTypes.map((type) => (
<Menu.Item
key={type.value}
leftSection={
<Text
$isMaterialIcon
$color={colorsTokens()[type.color]}
$size="16px"
>
{type.icon}
</Text>
}
onClick={() =>
props.editor.updateBlock(props.block, {
type: 'alert',
props: { type: type.value },
})
}
>
{t(type.title)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<Box
className="inline-content"
$css={css`
flex-grow: 1;
& * {
color: ${colorsTokens()[alertType.color]};
}
`}
ref={props.contentRef}
/>
</Box>
);
},
},
);
export const insertAlert = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Alert'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'alert',
});
},
aliases: [
'alert',
'notification',
'emphasize',
'warning',
'error',
'info',
'success',
],
group: t('Others'),
icon: (
<Text $isMaterialIcon $size="18px">
warning
</Text>
),
subtext: t('Add a colored alert box'),
});
@@ -1,55 +0,0 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
export const DividerBlock = createReactBlockSpec(
{
type: 'divider',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
},
content: 'none',
},
{
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<div
style={{
width: '100%',
height: '2px',
backgroundColor: colorsTokens()['greyscale-300'],
margin: '1rem 0',
}}
/>
);
},
},
);
export const insertDivider = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Divider'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'divider',
});
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group: t('Others'),
icon: (
<span className="material-icons" style={{ fontSize: '18px' }}>
remove
</span>
),
subtext: t('Add a horizontal line'),
});
@@ -1,63 +0,0 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
export const QuoteBlock = createReactBlockSpec(
{
type: 'quote',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
},
content: 'inline',
},
{
render: (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<div
className="inline-content"
style={{
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
margin: '0 0 1rem 0',
padding: '0.5rem 1rem',
color: colorsTokens()['greyscale-600'],
fontStyle: 'italic',
flexGrow: 1,
}}
ref={props.contentRef}
/>
);
},
parse: () => {
return undefined;
},
},
);
export const insertQuote = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => ({
title: t('Quote'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'quote',
});
},
aliases: ['quote', 'blockquote', 'citation'],
group: t('Others'),
icon: (
<span className="material-icons" style={{ fontSize: '18px' }}>
format_quote
</span>
),
subtext: t('Add a quote block'),
});
@@ -1,3 +0,0 @@
export * from './AlertBlock';
export * from './DividerBlock';
export * from './QuoteBlock';
@@ -1,9 +1,9 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useEffect } from 'react';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
import { useHeadingStore } from '../stores';
export const useHeadings = (editor: DocsBlockNoteEditor) => {
export const useHeadings = (editor: BlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
@@ -1,10 +1,9 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
export interface UseEditorstore {
editor?: DocsBlockNoteEditor;
setEditor: (editor: DocsBlockNoteEditor | undefined) => void;
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
@@ -1,6 +1,6 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
import { HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
@@ -21,7 +21,7 @@ const recursiveTextContent = (content: HeadingBlock['content']): string => {
export interface UseHeadingStore {
headings: HeadingBlock[];
setHeadings: (editor: DocsBlockNoteEditor) => void;
setHeadings: (editor: BlockNoteEditor) => void;
resetHeadings: () => void;
}
@@ -6,15 +6,6 @@ export const cssEditor = (readonly: boolean) => css`
& .ProseMirror {
height: 100%;
.bn-side-menu[data-block-type='alert'] {
height: 55px;
}
.bn-side-menu[data-block-type='divider'] {
height: 40px;
}
.bn-side-menu[data-block-type='quote'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
@@ -1,11 +1,10 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useState } from 'react';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { DocsBlockNoteEditor } from '../../doc-editor/components/BlockNoteEditor';
const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem',
2: '0.9rem',
@@ -18,7 +17,7 @@ export type HeadingsHighlight = {
}[];
interface HeadingProps {
editor: DocsBlockNoteEditor;
editor: BlockNoteEditor;
level: number;
text: string;
headingId: string;
+5 -1
View File
@@ -35,6 +35,10 @@
"eslint": "8.57.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.7.3"
"typescript": "5.7.3",
"@blocknote/core": "https://gitpkg.vercel.app/typecellOS/blocknote/packages/core?ai-block-built&v=3",
"@blocknote/mantine": "https://gitpkg.vercel.app/typecellOS/blocknote/packages/mantine?ai-block-built&v=3",
"@blocknote/react": "https://gitpkg.vercel.app/typecellOS/blocknote/packages/react?ai-block-built&v=3",
"@blocknote/xl-ai": "https://gitpkg.vercel.app/typecellOS/blocknote/packages/xl-ai?ai-block-built&v=3"
}
}
+841 -30
View File
File diff suppressed because it is too large Load Diff