diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts index 47a1c96..67e7f95 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -45,6 +45,7 @@ export { exportToMarkdown, fixBlockNoteTableExport, } from './lib/markdown-export' +export { checklistSplitFixExtension } from './lib/checklistSplitFix' export { rangeCheckToggleExtension } from './lib/rangeCheckToggle' export { searchExtension } from './lib/searchExtension' export { slashMenuEmacsKeysExtension } from './lib/slashMenuEmacsKeys' diff --git a/src/features/editor/lib/checklistSplitFix.ts b/src/features/editor/lib/checklistSplitFix.ts new file mode 100644 index 0000000..190d946 --- /dev/null +++ b/src/features/editor/lib/checklistSplitFix.ts @@ -0,0 +1,60 @@ +import { createExtension } from '@blocknote/core' + +/** + * BlockNote extension that fixes the checked state when splitting a + * checkListItem block by pressing Enter at the text start. + * + * BlockNote's `splitBlockTr` passes `keepProps=undefined` (falsy) to + * `tr.split()`, which gives the new (lower) node `attrs: {}` and resets + * its `checked` to `false`. The original (upper) node retains its + * `checked: true`. After a line-start split the result is: + * + * Upper: checked=true (wrong — should be false) + * Lower: checked=false (wrong — should be true) + * + * This extension intercepts Enter on checked checkListItem blocks via + * `runsBefore: ['check-list-item-shortcuts']` and performs the split + * with corrected checked states using `editor.transact()`. + */ +export const checklistSplitFixExtension = createExtension(({ editor }) => ({ + key: 'checklistSplitFix', + runsBefore: ['check-list-item-shortcuts'], + keyboardShortcuts: { + Enter: () => { + const pos = editor.getTextCursorPosition() + if (pos.block.type !== 'checkListItem') return false + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const block = editor.getBlock(pos.block.id) as any + if (!block) return false + + // Check if cursor is at content start (line-head split) by inspecting + // the TipTap selection. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tiptap = (editor as any)._tiptapEditor + const { $from } = tiptap.state.selection + // $from.parent is the checkListItem node; parentOffset === 0 means + // the cursor is at the very start of its inline content. + if ($from.parentOffset !== 0) return false + + // Block must have content (empty block → paragraph conversion is + // handled by BlockNote's own handler). + if (block.content?.length === 0) return false + + // checked=true, non-empty, cursor at content start: perform split + // with corrected checked states. + editor.transact(() => { + const inserted = editor.insertBlocks( + [{ type: 'checkListItem', props: { checked: false } }], + pos.block.id, + 'before' + ) + if (inserted.length > 0) { + editor.setTextCursorPosition(inserted[0].id, 'start') + } + }) + + return true + }, + }, +})) diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index b93b72c..4986672 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -28,6 +28,7 @@ import { useTheme } from '@/app/providers/theme-provider' import { useToolbarConfig } from '@/app/providers/toolbar-config-provider' import type { SaveStatus } from '..' import { + checklistSplitFixExtension, cursorCenteringExtension, cursorVimKeysExtension, resolveImageUrl, @@ -275,6 +276,7 @@ export const Editor = forwardRef(function Editor( extensions: [ cursorCenteringExtension, searchExtension, + checklistSplitFixExtension(), rangeCheckToggleExtension(), slashMenuEmacsKeysExtension(), cursorVimKeysExtension(), @@ -521,43 +523,6 @@ export const Editor = forwardRef(function Editor( scheduleSave(JSON.stringify(editor.document)) }, [editor, scheduleSave, backfillImageCaptions]) - /** - * Handles clicks on the editor wrapper's padding area. - * - * The editor wrapper has generous bottom padding (`pb-[60vh]`) so that - * users can scroll content above the virtual keyboard on mobile devices. - * Clicking in this padding zone does not naturally focus the editor - * because the click target is outside the `.bn-editor` contenteditable - * region. This callback detects such clicks and programmatically focuses - * the editor with the cursor placed at the end of the last block. - * - * If the click originated inside `.bn-editor` (including any of its - * descendant elements), the callback returns early and lets the default - * browser behaviour handle focus normally. - * - * @param e - The mouse event from the wrapper `
`. - * - * @example - * ```tsx - *
- * - *
- * ``` - */ - const handleWrapperClick = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement - if (target.closest('.bn-editor')) return - - const lastBlock = editor.document[editor.document.length - 1] - if (lastBlock) { - editor.setTextCursorPosition(lastBlock, 'end') - } - editor.focus() - }, - [editor] - ) - return ( <> {/* Editor wrapper — starts invisible (`opacity-0`) and transitions to @@ -566,7 +531,6 @@ export const Editor = forwardRef(function Editor(