Compare commits

..

7 Commits

Author SHA1 Message Date
Anthony LC fb2850d4fb working chrome 2025-06-03 19:04:50 +02:00
Anthony LC 87597b7701 save6 2025-06-03 17:06:26 +02:00
Anthony LC ae64b7764d save5-works-almost 2025-06-03 16:48:18 +02:00
Anthony LC ed58fa228e save4 2025-06-03 16:10:23 +02:00
Anthony LC 8ceed77081 Save3 2025-06-03 16:06:23 +02:00
Anthony LC d2ba54ec3a save2 2025-06-03 15:53:22 +02:00
Anthony LC 3416e2bdf1 save1 2025-06-03 15:35:03 +02:00
9 changed files with 1286 additions and 318 deletions
+1
View File
@@ -52,6 +52,7 @@
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"styled-components": "6.1.18",
"tldraw": "3.13.1",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "*",
@@ -28,6 +28,7 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, DividerBlock } from './custom-blocks';
import { DrawBlock } from './custom-blocks/DrawBlock';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
@@ -35,6 +36,7 @@ export const blockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
draw: DrawBlock,
},
}),
);
@@ -15,6 +15,7 @@ import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
import { getDrawReactSlashMenuItems } from './custom-blocks/DrawBlock';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -30,6 +31,7 @@ export const BlockNoteSuggestionMenu = () => {
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
getDrawReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
),
@@ -0,0 +1,341 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
ReactRendererProps,
defaultProps,
insertOrUpdateBlock,
} from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Editor,
TLEventMapHandler,
TLStore,
Tldraw,
getSnapshot,
loadSnapshot,
} from 'tldraw';
import 'tldraw/tldraw.css';
import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import _ from 'lodash';
import { clear } from 'console';
/**
* ----------------------------------------------------------------------------------
* Collaborative **Draw** block backed by a Yjs document synced through
* `@hocuspocus/provider` (see `useProviderStore`).
* ----------------------------------------------------------------------------------
*
* Each Draw block owns its own YDoc, identified by `roomId` (persisted in `propSchema`).
* The block serialises a base64encoded Yjs update (`drawingData`) so newcomers see
* the latest snapshot _immediately_, without waiting for the websocket connection.
*/
export const DrawBlock = createReactBlockSpec(
{
type: 'draw',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
roomId: { default: `drawing-${Date.now()}` },
drawingData: { default: '' },
changeHistory: { default: '' },
lastChange: { default: '' },
increment: { default: 0 }, // Increment to force re-rendering
},
content: 'inline',
},
{
render: ({ block, editor: editorBN }) => {
const [editor, setEditor] = useState<Editor>();
const setAppToState = useCallback((editor: Editor) => {
setEditor(editor);
}, []);
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const [storeEvents, setStoreEvents] = useState<string[]>([]);
console.log('Loading saved drawing data');
useEffect(() => {
if (block.props.drawingData && editor) {
console.log('DDData');
try {
const drawingData = JSON.parse(block.props.drawingData);
//const drawingData = block.props.drawingData;
// Update: Using the non-deprecated method to load the snapshot
// Instead of editor.store.loadSnapshot(drawingData)
loadSnapshot(editor.store, drawingData);
console.log('Successfully loaded drawing data');
} catch (error) {
console.error('Failed to load drawing data:', error);
}
}
}, [block.props.drawingData, editor]);
useEffect(() => {
if (!editor) {
return;
}
// Load saved drawing data if available
function logChangeEvent(eventInfo: string, changeData: any = null) {
console.log(eventInfo);
// Get current properties
// const currentProps = { ...block.props };
// // Get current changeHistory or initialize empty array
// const currentHistory = Array.isArray(currentProps.changeHistory)
// ? currentProps.changeHistory
// : [];
// // Create a change record with timestamp
// const changeRecord = {
// timestamp: new Date().toISOString(),
// event: eventInfo,
// data: changeData,
// };
// // Create new props object with all updated values
// const updatedProps = {
// ...currentProps,
// changeHistory: [...currentHistory, changeRecord],
// lastChange: changeRecord,
// };
// // Add drawingData if available
// if (changeData?.drawingData) {
// updatedProps.drawingData = changeData.drawingData;
// }
// Update the block with the new props
// console.log('Updating block with props:', updatedProps);
// //editorBN.updateBlock(block, { props: updatedProps });
// if (timeoutId.current) {
// clearTimeout(timeoutId.current);
// }
// timeoutId.current = setTimeout(() => {
// editorBN.updateBlock(block, {
// props: updatedProps,
// });
// }, 300);
if (!editor) {
return;
}
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = setTimeout(() => {
const snapshot = getSnapshot(editor.store);
//const snapshot = JSON.stringify(editor.store.serialize());
console.log('Captured drawing snapshot:', snapshot);
// Only update drawingData property to avoid multiple updates
const currentProps = { ...block.props };
editorBN.updateBlock(block, {
props: {
drawingData: JSON.stringify(snapshot),
increment: currentProps.increment + 1, // Increment to force re-rendering
},
});
}, 300);
//setStoreEvents((events) => [...events, eventInfo]);
}
//[1]
const handleChangeEvent: TLEventMapHandler<'change'> = (change) => {
// Added
for (const record of Object.values(change.changes.added)) {
if (record.typeName === 'shape') {
logChangeEvent(`created shape (${record.type})`, {
action: 'created',
shapeType: record.type,
shape: record,
});
}
}
// Updated
for (const [from, to] of Object.values(change.changes.updated)) {
if (
from.typeName === 'instance' &&
to.typeName === 'instance' &&
from.currentPageId !== to.currentPageId
) {
logChangeEvent(
`changed page (${from.currentPageId}, ${to.currentPageId})`,
{
action: 'changedPage',
fromPageId: from.currentPageId,
toPageId: to.currentPageId,
},
);
} else if (
from.id.startsWith('shape') &&
to.id.startsWith('shape')
) {
let diff = _.reduce(
from,
(result: any[], value, key: string) =>
_.isEqual(value, to[key])
? result
: result.concat([key, to[key]]),
[],
);
const diffObj = {};
if (diff?.[0] === 'props') {
diff = _.reduce(
from.props,
(result: any[], value, key) =>
_.isEqual(value, to.props[key])
? result
: result.concat([key, to.props[key]]),
[],
);
// Convert diff array to object for better storage
for (let i = 0; i < diff.length; i += 2) {
diffObj[diff[i]] = diff[i + 1];
}
}
logChangeEvent(`updated shape (${JSON.stringify(diff)})`, {
action: 'updated',
shapeId: from.id,
changes: diffObj,
from: from,
to: to,
});
}
}
// Removed
for (const record of Object.values(change.changes.removed)) {
if (record.typeName === 'shape') {
logChangeEvent(`deleted shape (${record.type})`, {
action: 'deleted',
shapeType: record.type,
shape: record,
});
}
}
// Store the entire drawing state periodically when changes occur
// if (
// Object.keys(change.changes.added).length > 0 ||
// Object.keys(change.changes.updated).length > 0 ||
// Object.keys(change.changes.removed).length > 0
// ) {
// // Capture the current drawing state if available
// if (editor.store) {
// try {
// // Update: Using the non-deprecated method to get the snapshot
// // Instead of
// //
// const snapshot = getSnapshot(editor.store);
// //const snapshot = JSON.stringify(editor.store.serialize());
// console.log('Captured drawing snapshot:', snapshot);
// // Only update drawingData property to avoid multiple updates
// const currentProps = { ...block.props };
// if (timeoutId.current) {
// clearTimeout(timeoutId.current);
// }
// timeoutId.current = setTimeout(() => {
// editorBN.updateBlock(block, {
// props: {
// ...currentProps,
// drawingData: snapshot,
// },
// });
// }, 300);
// console.log('Drawing snapshot updated');
// } catch (error) {
// console.error('Failed to capture drawing snapshot:', error);
// }
// }
// }
};
// [2]
const cleanupFunction = editor.store.listen(handleChangeEvent, {
source: 'user',
scope: 'all',
});
return () => {
cleanupFunction();
};
}, [block, editor, editorBN]);
return (
<Box style={{ width: '100%', height: 300 }}>
{/*
* We deliberately pass the TLstore directly. TLDraw will observe the
* changes including those coming over the wire and rerender.
*/}
<Tldraw onMount={setAppToState} />
</Box>
);
},
},
);
/**
* Slashmenu helper → inserts a new collaborative draw block with a unique `roomId`.
*/
export const getDrawReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Draw'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'draw',
props: {
roomId: `drawing-${Date.now()}`,
drawingData: null,
changeHistory: [],
lastChange: null,
},
});
},
aliases: ['draw'],
group,
icon: <Icon iconName="draw" $size="18px" />,
subtext: t('Add a collaborative canvas'),
},
];
/**
* Formattingtoolbar item so users can transform an existing block into a Draw block.
*/
export const getDrawFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Draw'),
type: 'draw',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'draw',
});
@@ -1,72 +0,0 @@
import {
BlockNoteSchema,
PartialBlock,
defaultBlockSpecs,
} from '@blocknote/core';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { Request, Response } from 'express';
import * as Y from 'yjs';
import { logger, toBase64 } from '@/utils';
import { CalloutBlock, DividerBlock } from './custom-blocks';
const blockNoteSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
},
});
type DocsBlockSchema = typeof blockNoteSchema.blockSchema;
type DocsInlineContentSchema = typeof blockNoteSchema.inlineContentSchema;
type DocsStyleSchema = typeof blockNoteSchema.styleSchema;
interface ConversionRequest {
blocks: PartialBlock<DocsBlockSchema>[];
}
interface ConversionResponse {
content: string;
}
interface ErrorResponse {
error: string;
}
export const convertBlocksHandler = (
req: Request<
object,
ConversionResponse | ErrorResponse,
ConversionRequest,
object
>,
res: Response<ConversionResponse | ErrorResponse>,
) => {
const blocks = req.body?.blocks;
if (!blocks) {
res.status(400).json({ error: 'Invalid request: missing content' });
return;
}
try {
// Create a server editor with custom block schema
const editor = ServerBlockNoteEditor.create<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>({
schema: blockNoteSchema,
});
// Create a Yjs Document from blocks, and encode it as a base64 string
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const content = toBase64(Y.encodeStateAsUpdate(yDocument));
res.status(200).json({ content });
} catch (e) {
logger('conversion failed:', e);
res.status(500).json({ error: String(e) });
}
};
@@ -1,166 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const toggleEmojiPicker = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
},
);
export const getCalloutReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'callout',
});
},
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
group,
icon: <Icon iconName="lightbulb" $size="18px" />,
subtext: t('Add a callout block'),
},
];
export const getCalloutFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});
@@ -1,51 +0,0 @@
import { insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
export const DividerBlock = createReactBlockSpec(
{
type: 'divider',
propSchema: {},
content: 'none',
},
{
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<Box
as="hr"
$width="100%"
$background={colorsTokens['greyscale-300']}
$margin="1rem 0"
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
/>
);
},
},
);
export const getDividerReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Divider'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'divider',
});
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group,
icon: <Icon iconName="remove" $size="18px" />,
subtext: t('Add a horizontal line'),
},
];
@@ -1,2 +0,0 @@
export * from './CalloutBlock';
export * from './DividerBlock';
+940 -27
View File
File diff suppressed because it is too large Load Diff