Skip to content

Commit a40e538

Browse files
committed
🐛(frontend) fix callout block arrow navigation
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 <[email protected]>
1 parent 47c29a8 commit a40e538

File tree

4 files changed

+251
-15
lines changed

4 files changed

+251
-15
lines changed

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import { Box, TextErrors } from '@/components';
1818
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
1919
import { useAuth } from '@/features/auth';
2020

21-
import { useHeadings, useUploadFile, useUploadStatus } from '../hook/';
21+
import {
22+
useCalloutBlock,
23+
useHeadings,
24+
useUploadFile,
25+
useUploadStatus,
26+
} from '../hook/';
2227
import useSaveDoc from '../hook/useSaveDoc';
2328
import { useEditorStore } from '../stores';
2429
import { cssEditor } from '../styles';
@@ -131,6 +136,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
131136

132137
useHeadings(editor);
133138
useUploadStatus(editor);
139+
useCalloutBlock(editor);
134140

135141
useEffect(() => {
136142
setEditor(editor);

src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,15 @@ export const CalloutBlock = createReactBlockSpec(
8787
onEmojiSelect={onEmojiSelect}
8888
/>
8989
)}
90-
<Box as="p" className="inline-content" ref={contentRef}
90+
<Box
91+
as="p"
92+
className="inline-content"
93+
ref={contentRef}
9194
$css={css`
9295
& > div {
9396
padding-top: 2px;
9497
}
95-
`}
98+
`}
9699
/>
97100
</Box>
98101
);
@@ -105,19 +108,19 @@ export const getCalloutReactSlashMenuItems = (
105108
t: TFunction<'translation', undefined>,
106109
group: string,
107110
) => [
108-
{
109-
title: t('Callout'),
110-
onItemClick: () => {
111-
insertOrUpdateBlock(editor, {
112-
type: 'callout',
113-
});
114-
},
115-
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
116-
group,
117-
icon: <Icon iconName="lightbulb" $size="18px" />,
118-
subtext: t('Add a callout block'),
111+
{
112+
title: t('Callout'),
113+
onItemClick: () => {
114+
insertOrUpdateBlock(editor, {
115+
type: 'callout',
116+
});
119117
},
120-
];
118+
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
119+
group,
120+
icon: <Icon iconName="lightbulb" $size="18px" />,
121+
subtext: t('Add a callout block'),
122+
},
123+
];
121124

122125
export const getCalloutFormattingToolbarItems = (
123126
t: TFunction<'translation', undefined>,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './useHeadings';
22
export * from './useSaveDoc';
33
export * from './useUploadFile';
4+
export * from './useCalloutBlock';
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { ResolvedPos } from 'prosemirror-model';
2+
import { EditorState, Selection } from 'prosemirror-state';
3+
import { EditorView } from 'prosemirror-view';
4+
import { useEffect, useState } from 'react';
5+
6+
import { DocsBlockNoteEditor } from '../types';
7+
8+
const UP = 'ArrowUp',
9+
RIGHT = 'ArrowRight',
10+
DOWN = 'ArrowDown',
11+
LEFT = 'ArrowLeft';
12+
13+
const lastLine = (
14+
state: EditorState,
15+
view: EditorView,
16+
resolved: ResolvedPos,
17+
): { lStart: number; lLen: number } => {
18+
// returns the starting position and length of a callout's last
19+
// line.
20+
const start = resolved.start(resolved.depth);
21+
22+
const { doc, selection } = state;
23+
let pos = selection.anchor - 3;
24+
25+
const { top } = view.coordsAtPos(pos);
26+
let l = 0;
27+
28+
while (view.coordsAtPos(--pos).top == top && pos > start) {
29+
l++;
30+
}
31+
32+
return {
33+
lStart: start + doc.resolve(pos).parent.textContent.length - l,
34+
lLen: l,
35+
};
36+
};
37+
38+
const lastLineOffset = (
39+
view: EditorView,
40+
selection: Selection | null,
41+
): number => {
42+
// returns the selection's offset relatively to the
43+
// last line's start of a callout block.
44+
45+
if (!selection) {
46+
return 0;
47+
}
48+
49+
let i = 0;
50+
let { anchor } = selection;
51+
const { top } = view.coordsAtPos(anchor);
52+
53+
while (view.coordsAtPos(--anchor).top == top && anchor > 0) {
54+
i++;
55+
}
56+
57+
return i;
58+
};
59+
60+
type InputState = {
61+
lastKeyCode: number;
62+
};
63+
64+
interface PmEditorView extends EditorView {
65+
input: InputState;
66+
}
67+
68+
export const useCalloutBlock = (editor: DocsBlockNoteEditor) => {
69+
// Hacks to fix cursor behavior between and around callout blocks.
70+
//
71+
// Navigating backwards (arrow up or arrow left at the start of
72+
// a callout) will create a GapCursor (prosemirror-gapcursor) instance
73+
// on top of the block when it is wether the first block of the
74+
// document or preceded by another callout block. Same behavior to be
75+
// expected when navigating forwards (arrow down or arrow right).
76+
//
77+
// This hook defines where the cursor should go (setting the
78+
// selection) by looking for the next valid text node.
79+
80+
const [prevSelection, setPrevSelection] = useState<Selection | null>(null);
81+
82+
useEffect(() => {
83+
const handleSelectionChange = () => {
84+
const view = editor.prosemirrorView as PmEditorView;
85+
const lastKeyCode = view.input?.lastKeyCode;
86+
87+
if (![38, 40].includes(lastKeyCode)) {
88+
setPrevSelection(editor.prosemirrorState.selection);
89+
}
90+
};
91+
92+
editor.onSelectionChange(handleSelectionChange);
93+
}, [prevSelection, editor]);
94+
95+
useEffect(() => {
96+
const handle = (e: KeyboardEvent) => {
97+
const { code } = e;
98+
if (![UP, DOWN, LEFT, RIGHT].includes(code)) {
99+
return;
100+
}
101+
102+
editor.exec((state, dispatch, view) => {
103+
if (!view) {
104+
return false;
105+
}
106+
107+
const { doc, selection, tr } = state;
108+
const { $anchor } = selection;
109+
let { pos } = $anchor;
110+
111+
const start = $anchor.start($anchor.depth);
112+
const end = $anchor.end($anchor.depth);
113+
114+
switch (code) {
115+
case UP:
116+
if (pos > start && pos < end) {
117+
return false;
118+
}
119+
120+
if (!editor.getTextCursorPosition().prevBlock && dispatch) {
121+
tr.setSelection(Selection.near(doc.resolve(start)));
122+
dispatch(tr);
123+
return true;
124+
}
125+
126+
while (pos-- > 0) {
127+
const $resolved = doc.resolve(pos);
128+
129+
if (
130+
!$anchor.parent.eq($resolved.parent) &&
131+
$resolved.parent.type.name === 'callout' &&
132+
$resolved.depth == 3 &&
133+
dispatch
134+
) {
135+
const { lStart, lLen } = lastLine(state, view, $resolved);
136+
const start =
137+
lStart +
138+
Math.min(lLen, lastLineOffset(view, prevSelection) - 1);
139+
140+
tr.setSelection(Selection.near(doc.resolve(start)));
141+
dispatch(tr);
142+
return true;
143+
}
144+
}
145+
break;
146+
147+
case DOWN:
148+
if (pos < Selection.atEnd($anchor.parent).anchor) {
149+
return false;
150+
}
151+
152+
while (pos++ < doc.content.size) {
153+
const $resolved = doc.resolve(pos);
154+
155+
if (
156+
!$anchor.parent.eq($resolved.parent) &&
157+
$resolved.parent.type.name === 'callout' &&
158+
$resolved.depth == 3 &&
159+
dispatch
160+
) {
161+
const start =
162+
pos +
163+
Math.min(
164+
lastLineOffset(view, prevSelection),
165+
doc.resolve(pos).parent.textContent.length,
166+
);
167+
tr.setSelection(Selection.near(doc.resolve(start)));
168+
dispatch(tr);
169+
return true;
170+
}
171+
}
172+
break;
173+
174+
case RIGHT:
175+
if ($anchor.depth < 3) {
176+
while (pos++ < doc.content.size) {
177+
const $resolved = doc.resolve(pos);
178+
if (
179+
$resolved.parent.type.name === 'callout' &&
180+
$resolved.depth === 3 &&
181+
dispatch
182+
) {
183+
tr.setSelection(Selection.near($resolved));
184+
dispatch(tr);
185+
return true;
186+
}
187+
}
188+
}
189+
break;
190+
191+
case LEFT:
192+
if ($anchor.depth < 3) {
193+
while (pos-- > 0) {
194+
const $resolved = doc.resolve(pos);
195+
if (
196+
$resolved.parent.type.name === 'callout' &&
197+
$resolved.depth === 3 &&
198+
dispatch
199+
) {
200+
tr.setSelection(Selection.near($resolved));
201+
dispatch(tr);
202+
return true;
203+
}
204+
}
205+
}
206+
207+
if (pos > start) {
208+
return false;
209+
}
210+
211+
if (!editor.getTextCursorPosition().prevBlock && dispatch) {
212+
tr.setSelection(Selection.near(doc.resolve(start)));
213+
dispatch(tr);
214+
return true;
215+
}
216+
217+
break;
218+
}
219+
return false;
220+
});
221+
};
222+
223+
document.addEventListener('keydown', handle);
224+
return () => document.removeEventListener('keydown', handle);
225+
}, [prevSelection, editor]);
226+
};

0 commit comments

Comments
 (0)