Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9e0dcd7
feat: add task block utility functions with tests
h4yfans Apr 5, 2026
31c384c
feat: add TaskBlock renderer with live data and useTaskBlockData hook
h4yfans Apr 5, 2026
c6a6f57
feat: integrate taskBlock into editor schema and markdown pipeline
h4yfans Apr 5, 2026
7b98736
feat: add TaskCreationPopover for inline task property assignment
h4yfans Apr 5, 2026
1404f4a
feat: add /task slash command, bracket trigger, and right-click promote
h4yfans Apr 5, 2026
64c3bb9
fix: resolve type errors in taskBlock spec and popover anchor ref
h4yfans Apr 5, 2026
b56d4d9
refactor: auto-create tasks from brackets, inline editable controls
h4yfans Apr 5, 2026
0298d96
fix: auto-create tasks silently, remove broken popover dialog
h4yfans Apr 5, 2026
3613ce5
fix: remove action verb gate, make task title editable inline
h4yfans Apr 5, 2026
8c50d43
fix: scan all blocks for checkListItems instead of relying on cursor
h4yfans Apr 5, 2026
af9eb93
refactor: match task list row styling, reuse InlineStatusPopover and …
h4yfans Apr 5, 2026
52f0a1c
fix: hide toolbar, add project badge, remove border, add navigate icon
h4yfans Apr 5, 2026
2518798
feat: add quick-add parsing and task delete sync
h4yfans Apr 5, 2026
64e7240
fix: resolve ProjectWithStats type mismatch in convertCheckboxToTask
h4yfans Apr 5, 2026
203756b
fix: debounce task deletion scan to prevent race condition
h4yfans Apr 5, 2026
c5219d0
fix: stop hiding block content — only hide empty formatting toolbar
h4yfans Apr 5, 2026
e46265f
fix: remove focus border on taskBlock, keep cursor in title input
h4yfans Apr 5, 2026
3c5fd4f
fix: only auto-focus title input on freshly created taskBlocks
h4yfans Apr 5, 2026
4d1809e
refactor: add renderTitle, actions, and project change props to TaskRow
h4yfans Apr 6, 2026
3ebf629
feat: add Enter/Escape key handling and focus management for task blocks
h4yfans Apr 6, 2026
03b6848
fix: apply activeTab from viewState when navigating from task block
h4yfans Apr 6, 2026
b0abff4
docs: add inline task blocks changelog entry
h4yfans Apr 6, 2026
d9b9306
chore: regenerate IPC invoke map
h4yfans Apr 6, 2026
4c0a9e1
fix: resolve TS narrowing error in draft task block detection
h4yfans Apr 6, 2026
02d8ce1
Merge remote-tracking branch 'origin/main' into feat/inline-task-blocks
h4yfans Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ Format: weekly entries grouped by feature area.

---

## 2026-04-07 — Inline Task Blocks

### Added
- Add custom task block for BlockNote editor with live DB sync and inline editing
- Add `[] ` bracket shortcut and `/task` slash command to create task blocks
- Add right-click context menu to promote checkboxes to linked tasks
- Add quick-add syntax parsing in task blocks (project, priority, due date)
- Add Enter key continuation: create new task block after typing title
- Add Enter on empty task block to exit to paragraph with cursor focus
- Add Escape key to exit task editing without creating new block
- Add draft task block detection in editor onChange for DB task creation
- Add task block markdown serialization with `{task:id}` suffix round-trip

### Fixed
- Fix focus jumping to previous task block when pressing Enter before async task creation completes
- Fix sync effect reverting title to stale DB value during save (hold syncingRef across async boundary)
- Fix paragraph conversion from task block not receiving browser focus (add editor.focus() after cursor placement)
- Fix task deletion scan race condition with debounced block scanning

### Changed
- Add `renderTitle` prop to TaskRow for inline title editing in task blocks

---

## 2026-04-06 — Vertical Sidebar Navigation

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { TagSuggestionPopover } from './tag-suggestion-popover'
import { WikiLinkPreviewCard } from './wiki-link-preview-card'
import { BlockDropIndicator, EmptyDocumentDropIndicator } from './block-drop-indicator'
import { getCalloutSlashMenuItem } from './callout-block'
import { getTaskSlashMenuItem } from './task-block'
import { tasksService } from '@/services/tasks-service'
import { useTasksOptional } from '@/contexts/tasks'
import { parseQuickAdd } from '@/lib/quick-add-parser'
import { formatDateKey } from '@/lib/task-utils'
import { editorSchema } from './editor-schema'
import {
HighlightReminderPopover,
Expand All @@ -53,6 +58,8 @@ import { extractDomain, fetchLinkPreview } from '@/lib/url-metadata'
import { createLinkMentionContent } from './link-mention'
import type { PasteLinkOption } from './hooks/use-paste-link-menu'

const PRIORITY_REVERSE: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 }

function findBlockWithLinkMention(
blocks: any[],
url: string
Expand Down Expand Up @@ -105,7 +112,10 @@ const ContentAreaEditor = memo(function ContentAreaEditor({
const { openTag } = useSidebarDrillDown()
const { port: aiPort, error: aiError, retry: retryAI } = useAIInlineContext()

const tasksCtx = useTasksOptional()
const [highlightSelection, setHighlightSelection] = useState<HighlightSelection | null>(null)
const dismissedBlocksRef = useRef(new Set<string>())
const knownTaskBlockIdsRef = useRef<Set<string>>(new Set())
const editorContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const noteIdRef = useRef<string | undefined>(noteId)
Expand Down Expand Up @@ -277,6 +287,131 @@ const ContentAreaEditor = memo(function ContentAreaEditor({
window.getSelection()?.removeAllRanges()
}, [])

const convertCheckboxToTask = useCallback(
(blockId: string) => {
dismissedBlocksRef.current.add(blockId)

const block = editor.getBlock(blockId)
if (!block) return

const content = block.content as any[] | undefined
const text =
content
?.map((c: any) => (typeof c === 'string' ? c : (c.text ?? '')))
.join('')
.trim() ?? ''

editor.updateBlock(block, {
type: 'taskBlock' as any,
props: { taskId: '', title: text, checked: false }
})

void (async () => {
let projects: any[] = tasksCtx?.projects ?? []
if (projects.length === 0) {
const res = await tasksService.listProjects()
projects = res.projects ?? []
}

const defaultProject = projects.find((p: any) => p.isDefault || p.isInbox) ?? projects[0]
if (!defaultProject) return

const parsed = text
? parseQuickAdd(text, projects as any[])
: { title: '', priority: 'none', projectId: null, dueDate: null }

try {
const result = await tasksService.create({
projectId: parsed.projectId ?? defaultProject.id,
title: parsed.title,
priority: PRIORITY_REVERSE[parsed.priority] ?? 0,
dueDate: parsed.dueDate ? formatDateKey(parsed.dueDate) : null,
linkedNoteIds: noteId ? [noteId] : []
})
if (result.success && result.task) {
const freshBlock = editor.getBlock(blockId)
if (freshBlock) {
const currentTitle = (freshBlock.props as any).title || parsed.title
editor.updateBlock(freshBlock, {
props: { taskId: result.task.id, title: currentTitle, checked: false }
})
if (currentTitle && currentTitle !== result.task.title) {
void tasksService.update({ id: result.task.id, title: currentTitle })
}
}
}
} catch {
dismissedBlocksRef.current.delete(blockId)
}
})()
},
[editor, noteId, tasksCtx]
)

const createTaskForDraftBlock = useCallback(
(blockId: string, title: string) => {
dismissedBlocksRef.current.add(blockId)

void (async () => {
let projects: any[] = tasksCtx?.projects ?? []
if (projects.length === 0) {
const res = await tasksService.listProjects()
projects = res.projects ?? []
}

const defaultProject = projects.find((p: any) => p.isDefault || p.isInbox) ?? projects[0]
if (!defaultProject) return

const parsed = title
? parseQuickAdd(title, projects as any[])
: { title: '', priority: 'none', projectId: null, dueDate: null }

try {
const result = await tasksService.create({
projectId: parsed.projectId ?? defaultProject.id,
title: parsed.title,
priority: PRIORITY_REVERSE[parsed.priority] ?? 0,
dueDate: parsed.dueDate ? formatDateKey(parsed.dueDate) : null,
linkedNoteIds: noteId ? [noteId] : []
})
if (result.success && result.task) {
const freshBlock = editor.getBlock(blockId)
if (freshBlock) {
const currentTitle = (freshBlock.props as any).title || parsed.title
editor.updateBlock(freshBlock, {
props: { taskId: result.task.id, title: currentTitle, checked: false }
})
if (currentTitle && currentTitle !== result.task.title) {
void tasksService.update({ id: result.task.id, title: currentTitle })
}
}
}
} catch {
dismissedBlocksRef.current.delete(blockId)
}
})()
},
[editor, noteId, tasksCtx]
)

const handleEditorContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
const checkListBlock = target.closest('[data-content-type="checkListItem"]')
if (!checkListBlock) return

const blockId = checkListBlock.getAttribute('data-id')
if (!blockId) return

const block = editor.getBlock(blockId)
if (!block || block.type !== 'checkListItem') return

e.preventDefault()
convertCheckboxToTask(blockId)
},
[editor, convertCheckboxToTask]
)

return (
<div
ref={containerRef}
Expand Down Expand Up @@ -309,12 +444,59 @@ const ContentAreaEditor = memo(function ContentAreaEditor({
)}
role="application"
aria-label="Rich text editor"
onContextMenu={handleEditorContextMenu}
>
<BlockNoteView
editor={editor}
editable={editable}
onChange={(): void => {
void handleChange()

const currentTaskIds = new Set<string>()
let firstUndismissedCheckbox: string | null = null
let firstDraftTaskBlock: { id: string; title: string } | null = null

const scanBlocks = (blocks: any[]): void => {
for (const b of blocks) {
if (b.type === 'taskBlock' && b.props?.taskId) {
currentTaskIds.add(b.props.taskId as string)
}
if (
b.type === 'taskBlock' &&
!b.props?.taskId &&
b.props?.title?.trim() &&
!firstDraftTaskBlock &&
!dismissedBlocksRef.current.has(b.id)
) {
firstDraftTaskBlock = { id: b.id, title: b.props.title as string }
}
if (
b.type === 'checkListItem' &&
!firstUndismissedCheckbox &&
!dismissedBlocksRef.current.has(b.id)
) {
firstUndismissedCheckbox = b.id
}
if (b.children?.length) scanBlocks(b.children)
}
}
scanBlocks(editor.document as any[])

if (firstUndismissedCheckbox) {
convertCheckboxToTask(firstUndismissedCheckbox)
}

const draft = firstDraftTaskBlock as { id: string; title: string } | null
if (draft) {
createTaskForDraftBlock(draft.id, draft.title)
}

for (const prevId of knownTaskBlockIdsRef.current) {
if (!currentTaskIds.has(prevId)) {
void tasksService.delete(prevId)
}
}
knownTaskBlockIdsRef.current = currentTaskIds
}}
theme={editorTheme}
formattingToolbar={!stickyToolbar}
Expand All @@ -328,7 +510,8 @@ const ContentAreaEditor = memo(function ContentAreaEditor({
const defaults = getDefaultReactSlashMenuItems(editor)
const aiItems = aiReady ? getAISlashMenuItems(editor) : []
const calloutItem = getCalloutSlashMenuItem(editor)
const all = [...defaults, calloutItem, ...aiItems]
const taskItem = getTaskSlashMenuItem(editor)
const all = [...defaults, calloutItem, taskItem, ...aiItems]
if (!query) return all
const lower = query.toLowerCase()
return all.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { codeBlockOptions } from '@blocknote/code-block'
import { createFileBlock } from './file-block'
import { createCalloutBlock } from './callout-block'
import { createYoutubeEmbedBlock } from './youtube-embed-block'
import { createTaskBlock } from './task-block'
import { WikiLink } from './wiki-link'
import { HashTag } from './hash-tag'
import { LinkMention } from './link-mention'
Expand All @@ -18,7 +19,8 @@ export const editorSchema = BlockNoteSchema.create({
codeBlock: createCodeBlockSpec(codeBlockOptions),
file: createFileBlock(),
callout: createCalloutBlock(),
youtubeEmbed: createYoutubeEmbedBlock()
youtubeEmbed: createYoutubeEmbedBlock(),
taskBlock: createTaskBlock()
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
normalizeMarkdownHardBreaks
} from '../wiki-link-utils'
import { normalizeHashTags, extractInlineTags } from '../hash-tag'
import { normalizeTaskBlocks } from '../task-block/task-block-utils'
import { FILE_BLOCK_REGEX, createFileBlockContent, serializeFileBlock } from '../file-block'
import { parseMarkdownPreservingBlanks, serializeBlocksPreservingBlanks } from '../markdown-utils'
import { createLinkMentionContent } from '../link-mention'
Expand Down Expand Up @@ -162,6 +163,8 @@ export function useEditorSync({
}

let normalizedBlocks = normalizeWikiLinks(blocks).blocks
const taskNormalized = normalizeTaskBlocks(normalizedBlocks)
normalizedBlocks = taskNormalized.blocks

if (noteTags?.length && tagColorMap) {
const tagSet = new Set(noteTags.map((t) => t.toLowerCase()))
Expand All @@ -177,6 +180,8 @@ export function useEditorSync({
}
} else if (Array.isArray(initialContent) && initialContent.length > 0) {
let normalizedBlocks = normalizeWikiLinks(initialContent).blocks
const taskNormalized = normalizeTaskBlocks(normalizedBlocks)
normalizedBlocks = taskNormalized.blocks

if (noteTags?.length && tagColorMap) {
const tagSet = new Set(noteTags.map((t) => t.toLowerCase()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { splitMarkdownByCallouts, serializeCalloutBlock } from './callout-block'
import { extractYouTubeVideoId } from '@/lib/youtube-utils'
import { serializeYoutubeEmbed } from './youtube-embed-block'
import { serializeTaskBlock } from './task-block/task-block-utils'

export function isEmptyParagraph(block: Block): boolean {
if (block.type !== 'paragraph') return false
Expand Down Expand Up @@ -88,7 +89,12 @@ export async function serializeBlocksPreservingBlanks(
}

for (const block of blocks) {
if ((block.type as string) === 'youtubeEmbed') {
if ((block.type as string) === 'taskBlock') {
await flushContent()
flushGap()
const props = block.props as { taskId: string; title: string; checked: boolean }
segments.push({ type: 'content', text: serializeTaskBlock(props) })
} else if ((block.type as string) === 'youtubeEmbed') {
await flushContent()
flushGap()
const videoUrl = (block.props as any).videoUrl as string
Expand Down
Loading
Loading