From 47c29a8f6ebcf05fbcddf39de3399b5bbcb91261 Mon Sep 17 00:00:00 2001 From: ZouicheOmar Date: Thu, 10 Jul 2025 09:56:16 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=92=84(frontend)=20update=20callout?= =?UTF-8?q?=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the callout block styles, centered the emoji and aligned the text to be in the center of the block. Signed-off-by: ZouicheOmar --- .../components/custom-blocks/CalloutBlock.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx index 6e77d5a05..d991e4162 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -70,8 +70,10 @@ export const CalloutBlock = createReactBlockSpec( } `} $align="center" + $justify="center" $height="28px" $width="28px" + $minWidth="28px" $radius="4px" > {block.props.emoji} @@ -85,7 +87,13 @@ export const CalloutBlock = createReactBlockSpec( onEmojiSelect={onEmojiSelect} /> )} - + div { + padding-top: 2px; + } + `} + /> ); }, @@ -97,19 +105,19 @@ export const getCalloutReactSlashMenuItems = ( t: TFunction<'translation', undefined>, group: string, ) => [ - { - title: t('Callout'), - onItemClick: () => { - insertOrUpdateBlock(editor, { - type: 'callout', - }); + { + title: t('Callout'), + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: 'callout', + }); + }, + aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'], + group, + icon: , + subtext: t('Add a callout block'), }, - aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'], - group, - icon: , - subtext: t('Add a callout block'), - }, -]; + ]; export const getCalloutFormattingToolbarItems = ( t: TFunction<'translation', undefined>, From a40e538c4f9d98963f7f077cf1e9ee4c2f379f4a Mon Sep 17 00:00:00 2001 From: ZouicheOmar Date: Thu, 10 Jul 2025 15:56:10 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20callout=20bl?= =?UTF-8?q?ock=20arrow=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the behavior of arrow navigation around and between callout blocks. When navigating backwards (arrow up or left) from a callout that is either document's first block or comes right after a callout, the cursor doesn't go into a text selection, which it should. Similar behavior if when going forward. This bug is trigged by any of the four arrow keys. Added a command to set the cursor position as it is expected. Signed-off-by: ZouicheOmar --- .../doc-editor/components/BlockNoteEditor.tsx | 8 +- .../components/custom-blocks/CalloutBlock.tsx | 31 +-- .../features/docs/doc-editor/hook/index.ts | 1 + .../docs/doc-editor/hook/useCalloutBlock.tsx | 226 ++++++++++++++++++ 4 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCalloutBlock.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index fe419b809..ba9740445 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -18,7 +18,12 @@ import { Box, TextErrors } from '@/components'; import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; -import { useHeadings, useUploadFile, useUploadStatus } from '../hook/'; +import { + useCalloutBlock, + useHeadings, + useUploadFile, + useUploadStatus, +} from '../hook/'; import useSaveDoc from '../hook/useSaveDoc'; import { useEditorStore } from '../stores'; import { cssEditor } from '../styles'; @@ -131,6 +136,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { useHeadings(editor); useUploadStatus(editor); + useCalloutBlock(editor); useEffect(() => { setEditor(editor); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx index d991e4162..92957327a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -87,12 +87,15 @@ export const CalloutBlock = createReactBlockSpec( onEmojiSelect={onEmojiSelect} /> )} - div { padding-top: 2px; } - `} + `} /> ); @@ -105,19 +108,19 @@ export const getCalloutReactSlashMenuItems = ( t: TFunction<'translation', undefined>, group: string, ) => [ - { - title: t('Callout'), - onItemClick: () => { - insertOrUpdateBlock(editor, { - type: 'callout', - }); - }, - aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'], - group, - icon: , - subtext: t('Add a callout block'), + { + title: t('Callout'), + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: 'callout', + }); }, - ]; + aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'], + group, + icon: , + subtext: t('Add a callout block'), + }, +]; export const getCalloutFormattingToolbarItems = ( t: TFunction<'translation', undefined>, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts index 3934dfa2d..32ed5a8d6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -1,3 +1,4 @@ export * from './useHeadings'; export * from './useSaveDoc'; export * from './useUploadFile'; +export * from './useCalloutBlock'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCalloutBlock.tsx new file mode 100644 index 000000000..e9ea84ffc --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCalloutBlock.tsx @@ -0,0 +1,226 @@ +import { ResolvedPos } from 'prosemirror-model'; +import { EditorState, Selection } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { useEffect, useState } from 'react'; + +import { DocsBlockNoteEditor } from '../types'; + +const UP = 'ArrowUp', + RIGHT = 'ArrowRight', + DOWN = 'ArrowDown', + LEFT = 'ArrowLeft'; + +const lastLine = ( + state: EditorState, + view: EditorView, + resolved: ResolvedPos, +): { lStart: number; lLen: number } => { + // returns the starting position and length of a callout's last + // line. + const start = resolved.start(resolved.depth); + + const { doc, selection } = state; + let pos = selection.anchor - 3; + + const { top } = view.coordsAtPos(pos); + let l = 0; + + while (view.coordsAtPos(--pos).top == top && pos > start) { + l++; + } + + return { + lStart: start + doc.resolve(pos).parent.textContent.length - l, + lLen: l, + }; +}; + +const lastLineOffset = ( + view: EditorView, + selection: Selection | null, +): number => { + // returns the selection's offset relatively to the + // last line's start of a callout block. + + if (!selection) { + return 0; + } + + let i = 0; + let { anchor } = selection; + const { top } = view.coordsAtPos(anchor); + + while (view.coordsAtPos(--anchor).top == top && anchor > 0) { + i++; + } + + return i; +}; + +type InputState = { + lastKeyCode: number; +}; + +interface PmEditorView extends EditorView { + input: InputState; +} + +export const useCalloutBlock = (editor: DocsBlockNoteEditor) => { + // Hacks to fix cursor behavior between and around callout blocks. + // + // Navigating backwards (arrow up or arrow left at the start of + // a callout) will create a GapCursor (prosemirror-gapcursor) instance + // on top of the block when it is wether the first block of the + // document or preceded by another callout block. Same behavior to be + // expected when navigating forwards (arrow down or arrow right). + // + // This hook defines where the cursor should go (setting the + // selection) by looking for the next valid text node. + + const [prevSelection, setPrevSelection] = useState(null); + + useEffect(() => { + const handleSelectionChange = () => { + const view = editor.prosemirrorView as PmEditorView; + const lastKeyCode = view.input?.lastKeyCode; + + if (![38, 40].includes(lastKeyCode)) { + setPrevSelection(editor.prosemirrorState.selection); + } + }; + + editor.onSelectionChange(handleSelectionChange); + }, [prevSelection, editor]); + + useEffect(() => { + const handle = (e: KeyboardEvent) => { + const { code } = e; + if (![UP, DOWN, LEFT, RIGHT].includes(code)) { + return; + } + + editor.exec((state, dispatch, view) => { + if (!view) { + return false; + } + + const { doc, selection, tr } = state; + const { $anchor } = selection; + let { pos } = $anchor; + + const start = $anchor.start($anchor.depth); + const end = $anchor.end($anchor.depth); + + switch (code) { + case UP: + if (pos > start && pos < end) { + return false; + } + + if (!editor.getTextCursorPosition().prevBlock && dispatch) { + tr.setSelection(Selection.near(doc.resolve(start))); + dispatch(tr); + return true; + } + + while (pos-- > 0) { + const $resolved = doc.resolve(pos); + + if ( + !$anchor.parent.eq($resolved.parent) && + $resolved.parent.type.name === 'callout' && + $resolved.depth == 3 && + dispatch + ) { + const { lStart, lLen } = lastLine(state, view, $resolved); + const start = + lStart + + Math.min(lLen, lastLineOffset(view, prevSelection) - 1); + + tr.setSelection(Selection.near(doc.resolve(start))); + dispatch(tr); + return true; + } + } + break; + + case DOWN: + if (pos < Selection.atEnd($anchor.parent).anchor) { + return false; + } + + while (pos++ < doc.content.size) { + const $resolved = doc.resolve(pos); + + if ( + !$anchor.parent.eq($resolved.parent) && + $resolved.parent.type.name === 'callout' && + $resolved.depth == 3 && + dispatch + ) { + const start = + pos + + Math.min( + lastLineOffset(view, prevSelection), + doc.resolve(pos).parent.textContent.length, + ); + tr.setSelection(Selection.near(doc.resolve(start))); + dispatch(tr); + return true; + } + } + break; + + case RIGHT: + if ($anchor.depth < 3) { + while (pos++ < doc.content.size) { + const $resolved = doc.resolve(pos); + if ( + $resolved.parent.type.name === 'callout' && + $resolved.depth === 3 && + dispatch + ) { + tr.setSelection(Selection.near($resolved)); + dispatch(tr); + return true; + } + } + } + break; + + case LEFT: + if ($anchor.depth < 3) { + while (pos-- > 0) { + const $resolved = doc.resolve(pos); + if ( + $resolved.parent.type.name === 'callout' && + $resolved.depth === 3 && + dispatch + ) { + tr.setSelection(Selection.near($resolved)); + dispatch(tr); + return true; + } + } + } + + if (pos > start) { + return false; + } + + if (!editor.getTextCursorPosition().prevBlock && dispatch) { + tr.setSelection(Selection.near(doc.resolve(start))); + dispatch(tr); + return true; + } + + break; + } + return false; + }); + }; + + document.addEventListener('keydown', handle); + return () => document.removeEventListener('keydown', handle); + }, [prevSelection, editor]); +};