diff --git a/CHANGELOG.md b/CHANGELOG.md index 526298eaa..494c81f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx index a883d9529..13e612578 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx +++ b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx @@ -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, @@ -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 = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 } + function findBlockWithLinkMention( blocks: any[], url: string @@ -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(null) + const dismissedBlocksRef = useRef(new Set()) + const knownTaskBlockIdsRef = useRef>(new Set()) const editorContainerRef = useRef(null) const containerRef = useRef(null) const noteIdRef = useRef(noteId) @@ -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 (
{ void handleChange() + + const currentTaskIds = new Set() + 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} @@ -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( diff --git a/apps/desktop/src/renderer/src/components/note/content-area/editor-schema.ts b/apps/desktop/src/renderer/src/components/note/content-area/editor-schema.ts index 199a2287f..37ddfce41 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/editor-schema.ts +++ b/apps/desktop/src/renderer/src/components/note/content-area/editor-schema.ts @@ -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' @@ -18,7 +19,8 @@ export const editorSchema = BlockNoteSchema.create({ codeBlock: createCodeBlockSpec(codeBlockOptions), file: createFileBlock(), callout: createCalloutBlock(), - youtubeEmbed: createYoutubeEmbedBlock() + youtubeEmbed: createYoutubeEmbedBlock(), + taskBlock: createTaskBlock() }, inlineContentSpecs: { ...defaultInlineContentSpecs, diff --git a/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts b/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts index e9609df6b..b733a5745 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts +++ b/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts @@ -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' @@ -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())) @@ -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())) diff --git a/apps/desktop/src/renderer/src/components/note/content-area/markdown-utils.ts b/apps/desktop/src/renderer/src/components/note/content-area/markdown-utils.ts index ef916ae13..e04dec915 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/markdown-utils.ts +++ b/apps/desktop/src/renderer/src/components/note/content-area/markdown-utils.ts @@ -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 @@ -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 diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/__tests__/task-block-utils.test.ts b/apps/desktop/src/renderer/src/components/note/content-area/task-block/__tests__/task-block-utils.test.ts new file mode 100644 index 000000000..197d7795c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/__tests__/task-block-utils.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest' +import { + isLikelyTask, + serializeTaskBlock, + parseTaskBlockSuffix, + normalizeTaskBlocks +} from '../task-block-utils' + +describe('isLikelyTask', () => { + it('returns true for text starting with action verbs', () => { + expect(isLikelyTask('Buy groceries')).toBe(true) + expect(isLikelyTask('fix the login bug')).toBe(true) + expect(isLikelyTask('Send email to team')).toBe(true) + expect(isLikelyTask('Review PR #123')).toBe(true) + expect(isLikelyTask('Schedule meeting with design')).toBe(true) + expect(isLikelyTask('Deploy to staging')).toBe(true) + }) + + it('returns false for non-action text', () => { + expect(isLikelyTask('Milk')).toBe(false) + expect(isLikelyTask('Item 1')).toBe(false) + expect(isLikelyTask('Notes from standup')).toBe(false) + expect(isLikelyTask('a')).toBe(false) + expect(isLikelyTask('')).toBe(false) + }) + + it('is case-insensitive', () => { + expect(isLikelyTask('BUY groceries')).toBe(true) + expect(isLikelyTask('Fix Bug')).toBe(true) + }) + + it('handles leading whitespace', () => { + expect(isLikelyTask(' Buy groceries')).toBe(true) + }) + + it('rejects very short or very long text', () => { + expect(isLikelyTask('Go')).toBe(false) + expect(isLikelyTask('x'.repeat(300))).toBe(false) + }) +}) + +describe('serializeTaskBlock', () => { + it('serializes unchecked task', () => { + expect(serializeTaskBlock({ taskId: 'abc-123', title: 'Buy groceries', checked: false })).toBe( + '- [ ] Buy groceries {task:abc-123}' + ) + }) + + it('serializes checked task', () => { + expect(serializeTaskBlock({ taskId: 'def-456', title: 'Send email', checked: true })).toBe( + '- [x] Send email {task:def-456}' + ) + }) +}) + +describe('parseTaskBlockSuffix', () => { + it('parses task reference from text', () => { + expect(parseTaskBlockSuffix('Buy groceries {task:abc-123}')).toEqual({ + taskId: 'abc-123', + title: 'Buy groceries' + }) + }) + + it('returns null for text without task ref', () => { + expect(parseTaskBlockSuffix('Just a regular item')).toBeNull() + }) + + it('handles task ref at end of longer text', () => { + expect(parseTaskBlockSuffix('Send email {task:def-456}')).toEqual({ + taskId: 'def-456', + title: 'Send email' + }) + }) +}) + +describe('normalizeTaskBlocks', () => { + it('converts checkListItem with {task:id} to taskBlock', () => { + const blocks = [ + { + id: 'b1', + type: 'checkListItem', + props: { isChecked: false }, + content: [{ type: 'text', text: 'Buy groceries {task:abc-123}', styles: {} }], + children: [] + } + ] as any[] + + const { blocks: result, didChange } = normalizeTaskBlocks(blocks) + expect(didChange).toBe(true) + expect(result[0].type).toBe('taskBlock') + expect((result[0].props as any).taskId).toBe('abc-123') + expect((result[0].props as any).title).toBe('Buy groceries') + expect((result[0].props as any).checked).toBe(false) + }) + + it('preserves checked state from checkListItem', () => { + const blocks = [ + { + id: 'b2', + type: 'checkListItem', + props: { isChecked: true }, + content: [{ type: 'text', text: 'Done task {task:def-456}', styles: {} }], + children: [] + } + ] as any[] + + const { blocks: result } = normalizeTaskBlocks(blocks) + expect((result[0].props as any).checked).toBe(true) + }) + + it('leaves regular checkListItem untouched', () => { + const blocks = [ + { + id: 'b3', + type: 'checkListItem', + props: { isChecked: false }, + content: [{ type: 'text', text: 'Just a checkbox', styles: {} }], + children: [] + } + ] as any[] + + const { blocks: result, didChange } = normalizeTaskBlocks(blocks) + expect(didChange).toBe(false) + expect(result).toBe(blocks) + }) + + it('leaves non-checkListItem blocks unchanged', () => { + const blocks = [ + { + id: 'b4', + type: 'paragraph', + props: {}, + content: [{ type: 'text', text: 'Some text {task:xyz}', styles: {} }], + children: [] + } + ] as any[] + + const { didChange } = normalizeTaskBlocks(blocks) + expect(didChange).toBe(false) + }) + + it('returns same reference when no {task: found', () => { + const blocks = [ + { + id: 'b5', + type: 'checkListItem', + props: { isChecked: false }, + content: [{ type: 'text', text: 'No task here', styles: {} }], + children: [] + } + ] as any[] + + const { blocks: result } = normalizeTaskBlocks(blocks) + expect(result).toBe(blocks) + }) +}) diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/index.tsx b/apps/desktop/src/renderer/src/components/note/content-area/task-block/index.tsx new file mode 100644 index 000000000..1c5870ffb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/index.tsx @@ -0,0 +1,68 @@ +import { createReactBlockSpec } from '@blocknote/react' +import { TaskBlockRenderer } from './task-block-renderer' +import { tasksService } from '@/services/tasks-service' +import { parseQuickAdd } from '@/lib/quick-add-parser' +import { formatDateKey } from '@/lib/task-utils' + +const PRIORITY_REVERSE: Record = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 } + +export const createTaskBlock = createReactBlockSpec( + { + type: 'taskBlock' as const, + propSchema: { + taskId: { default: '' }, + title: { default: '' }, + checked: { default: false } + }, + content: 'none' + }, + { + render: (props) => ( + + ) + } +) + +export function getTaskSlashMenuItem(editor: any) { + return { + title: 'Task', + onItemClick: async () => { + const currentBlock = editor.getTextCursorPosition().block + const content = currentBlock.content as any[] + const text = + content + ?.map((c: any) => (typeof c === 'string' ? c : (c.text ?? ''))) + .join('') + .trim() || '' + + const res = await tasksService.listProjects() + const 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' as const, projectId: null, dueDate: null } + + 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 + }) + if (result.success && result.task) { + editor.updateBlock(currentBlock, { + type: 'taskBlock' as any, + props: { taskId: result.task.id, title: parsed.title, checked: false } + }) + } + }, + aliases: ['task', 'todo', 'action'], + group: 'Basic blocks', + subtext: 'Create a linked task' + } +} diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-renderer.tsx b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-renderer.tsx new file mode 100644 index 000000000..1cd0000c0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-renderer.tsx @@ -0,0 +1,383 @@ +import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, ArrowUpRight, Loader2, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useTaskBlockData } from './use-task-block-data' +import { serviceTaskToDisplayTask, PRIORITY_REVERSE } from './task-block-utils' +import { useTasksOptional } from '@/contexts/tasks' +import { useTabActions } from '@/contexts/tabs' +import { tasksService } from '@/services/tasks-service' +import type { Task as DisplayTask } from '@/data/sample-tasks' +import { defaultStatuses, type Status } from '@/data/tasks-data' +import { TaskRow } from '@/components/tasks/task-row' + +interface TaskBlockRendererProps { + block: { id: string; props: { taskId: string; title: string; checked: boolean } } + editor: any + contentRef: React.Ref +} + +const BLOCKNOTE_OVERRIDES = ` + .bn-formatting-toolbar:empty { display: none !important; } + .bn-block-content[data-content-type="taskBlock"] { cursor: default; } + .bn-block[data-id]:has([data-content-type="taskBlock"]) { border: none !important; outline: none !important; box-shadow: none !important; } + .bn-block[data-id]:has([data-content-type="taskBlock"]):focus-within { border: none !important; outline: none !important; box-shadow: none !important; } + .bn-block-content[data-content-type="taskBlock"]:focus { outline: none !important; border: none !important; } + [data-content-type="taskBlock"] * { outline: none !important; } +` + +export const TaskBlockRenderer: FC = ({ block, editor, contentRef }) => { + const { taskId, title, checked } = block.props + const { task, isLoading, isDeleted } = useTaskBlockData(taskId) + const tasksCtx = useTasksOptional() + const { openTab } = useTabActions() + const syncingRef = useRef(false) + + const isNewBlockRef = useRef(true) + const wasDraftRef = useRef(!taskId) + const [isEditingTitle, setIsEditingTitle] = useState(!taskId) + const [editTitle, setEditTitle] = useState(title) + const titleInputRef = useRef(null) + const titleSaveTimeoutRef = useRef | null>(null) + const skipBlurRef = useRef(false) + + const projects = tasksCtx?.projects ?? [] + const defaultProject = projects.find((p: any) => p.isDefault || p.isInbox) ?? projects[0] + const project = projects.find((p) => p.id === task?.projectId) ?? defaultProject + const statuses: Status[] = (project?.statuses as Status[]) ?? defaultStatuses + const isCompleted = task ? !!task.completedAt : checked + + const placeholderTask: import('@/data/sample-tasks').Task = useMemo( + () => ({ + id: '', + title, + description: '', + projectId: project?.id ?? '', + statusId: statuses[0]?.id ?? '', + priority: 'none' as const, + dueDate: null, + dueTime: null, + isRepeating: false, + repeatConfig: null, + linkedNoteIds: [], + sourceNoteId: null, + parentId: null, + subtaskIds: [], + createdAt: new Date(), + completedAt: null, + archivedAt: null + }), + [project?.id, statuses, title] + ) + + const displayTask = useMemo( + () => (task ? serviceTaskToDisplayTask(task, statuses[0]?.id ?? '') : null), + [task, statuses] + ) + + // Auto-enter edit mode for newly created blocks + useEffect(() => { + if (isNewBlockRef.current && taskId && !task) { + setIsEditingTitle(true) + if (!wasDraftRef.current) setEditTitle(title) + } + if (task) { + isNewBlockRef.current = false + if (wasDraftRef.current) { + wasDraftRef.current = false + if (editTitle.trim() && task.title !== editTitle.trim()) { + void tasksService.update({ id: taskId, title: editTitle.trim() }) + editor.updateBlock(block, { + props: { ...block.props, title: editTitle.trim() } + }) + } + } + } + }, [taskId, task, title, editTitle, block, editor]) + + // Focus title input when editing starts (double-rAF to beat ProseMirror focus restoration) + useEffect(() => { + if (!isEditingTitle) return + let cancelled = false + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!cancelled && titleInputRef.current) { + titleInputRef.current.focus() + titleInputRef.current.setSelectionRange( + titleInputRef.current.value.length, + titleInputRef.current.value.length + ) + } + }) + }) + return () => { + cancelled = true + cancelAnimationFrame(rafId) + } + }, [isEditingTitle]) + + // Sync block props with DB state (for markdown serialization) + useEffect(() => { + if (!task || syncingRef.current) return + const needsUpdate = + task.title !== block.props.title || !!task.completedAt !== block.props.checked + if (needsUpdate) { + syncingRef.current = true + editor.updateBlock(block, { + props: { ...block.props, title: task.title, checked: !!task.completedAt } + }) + if (!isEditingTitle) setEditTitle(task.title) + syncingRef.current = false + } + }, [task, block, editor, isEditingTitle]) + + // Cleanup debounce timer + useEffect(() => { + return () => { + if (titleSaveTimeoutRef.current) clearTimeout(titleSaveTimeoutRef.current) + } + }, []) + + // --- Title editing handlers --- + + const saveTitleToDb = useCallback( + async (newTitle: string) => { + if (!newTitle.trim()) return + syncingRef.current = true + editor.updateBlock(block, { props: { ...block.props, title: newTitle.trim() } }) + if (taskId) { + try { + await tasksService.update({ id: taskId, title: newTitle.trim() }) + } finally { + syncingRef.current = false + } + } else { + syncingRef.current = false + } + }, + [taskId, block, editor] + ) + + const handleTitleChange = useCallback( + (value: string) => { + setEditTitle(value) + if (titleSaveTimeoutRef.current) clearTimeout(titleSaveTimeoutRef.current) + titleSaveTimeoutRef.current = setTimeout(() => void saveTitleToDb(value), 600) + }, + [saveTitleToDb] + ) + + const handleTitleBlur = useCallback(() => { + if (skipBlurRef.current) { + skipBlurRef.current = false + return + } + if (titleSaveTimeoutRef.current) clearTimeout(titleSaveTimeoutRef.current) + if (editTitle.trim()) void saveTitleToDb(editTitle) + setIsEditingTitle(false) + }, [editTitle, saveTitleToDb]) + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + skipBlurRef.current = true + if (titleSaveTimeoutRef.current) clearTimeout(titleSaveTimeoutRef.current) + if (editTitle.trim()) void saveTitleToDb(editTitle) + setIsEditingTitle(false) + return + } + + if (e.key === 'Enter') { + e.preventDefault() + skipBlurRef.current = true + if (titleSaveTimeoutRef.current) clearTimeout(titleSaveTimeoutRef.current) + + const trimmed = editTitle.trim() + if (trimmed) { + isNewBlockRef.current = false + void saveTitleToDb(trimmed) + setIsEditingTitle(false) + editor.insertBlocks( + [{ type: 'taskBlock' as any, props: { taskId: '', title: '', checked: false } }], + block, + 'after' + ) + } else { + isNewBlockRef.current = false + setIsEditingTitle(false) + if (taskId) void tasksService.delete(taskId) + const doc = editor.document as any[] + const blockIdx = doc.findIndex((b: any) => b.id === block.id) + const anchor = blockIdx > 0 ? doc[blockIdx - 1] : null + editor.removeBlocks([block]) + const updatedDoc = editor.document as any[] + if (anchor) { + editor.insertBlocks([{ type: 'paragraph' as any }], anchor, 'after') + } else if (updatedDoc.length > 0) { + editor.insertBlocks([{ type: 'paragraph' as any }], updatedDoc[0], 'before') + } + requestAnimationFrame(() => { + const finalDoc = editor.document as any[] + const para = finalDoc[blockIdx] ?? finalDoc[finalDoc.length - 1] + if (para) { + editor.setTextCursorPosition(para.id, 'start') + editor.focus() + } + }) + } + } + }, + [editor, block, taskId, editTitle, saveTitleToDb] + ) + + // --- Task action handlers --- + + const handleToggleComplete = useCallback( + async (taskIdArg: string) => { + if (!taskIdArg) return + const newChecked = !isCompleted + editor.updateBlock(block, { props: { ...block.props, checked: newChecked } }) + if (newChecked) { + await tasksService.complete({ id: taskIdArg }) + } else { + await tasksService.uncomplete(taskIdArg) + } + }, + [isCompleted, block, editor] + ) + + const handleUpdateTask = useCallback( + async (_taskId: string, updates: Partial) => { + if (!taskId) return + await tasksService.update({ + id: taskId, + ...(updates.statusId !== undefined && { statusId: updates.statusId }), + ...(updates.priority !== undefined && { + priority: PRIORITY_REVERSE[updates.priority] ?? 0 + }) + }) + }, + [taskId] + ) + + const handleProjectChange = useCallback( + async (projectId: string) => { + if (!taskId) return + await tasksService.update({ id: taskId, projectId }) + }, + [taskId] + ) + + const handleRemoveGhost = useCallback(() => { + editor.removeBlocks([block]) + }, [block, editor]) + + const navigateArrow = useMemo( + () => ( + + ), + [openTab, taskId, task?.projectId] + ) + + const titleInput = useCallback( + () => ( + handleTitleChange(e.target.value)} + onBlur={handleTitleBlur} + onKeyDown={handleTitleKeyDown} + className="grow shrink min-w-0 bg-transparent text-[13px] font-medium outline-none text-foreground/90 placeholder:text-muted-foreground" + placeholder="Task name..." + /> + ), + [editTitle, handleTitleChange, handleTitleBlur, handleTitleKeyDown] + ) + + // --- Render states --- + + if (isDeleted) { + return ( +
+ + {task?.title ?? title} + Task deleted + +
+ ) + } + + // Render TaskRow — use real task if loaded, placeholder otherwise + const rowTask = displayTask ?? placeholderTask + const rowProject = project ?? defaultProject + + if (!rowProject) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-utils.ts b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-utils.ts new file mode 100644 index 000000000..36cfe6045 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-block-utils.ts @@ -0,0 +1,243 @@ +import type { Block } from '@blocknote/core' +import type { + Task as DisplayTask, + Priority, + RepeatConfig as DisplayRepeatConfig +} from '@/data/sample-tasks' +import type { Task as ServiceTask } from '@/services/tasks-service' + +const DB_PRIORITY_MAP: Record = { + 0: 'none', + 1: 'low', + 2: 'medium', + 3: 'high', + 4: 'urgent' +} + +export const PRIORITY_REVERSE: Record = { + none: 0, + low: 1, + medium: 2, + high: 3, + urgent: 4 +} + +export function serviceTaskToDisplayTask(task: ServiceTask, fallbackStatusId: string): DisplayTask { + let repeatConfig: DisplayRepeatConfig | null = null + if (task.repeatConfig) { + const rc = task.repeatConfig + repeatConfig = { + ...rc, + endDate: rc.endDate ? new Date(rc.endDate) : null, + createdAt: new Date(rc.createdAt) + } + } + + return { + id: task.id, + title: task.title, + description: task.description ?? '', + projectId: task.projectId, + statusId: task.statusId ?? fallbackStatusId, + priority: DB_PRIORITY_MAP[task.priority] ?? 'none', + dueDate: task.dueDate ? new Date(task.dueDate) : null, + dueTime: task.dueTime ?? null, + isRepeating: task.repeatConfig !== null, + repeatConfig, + linkedNoteIds: task.linkedNoteIds ?? [], + sourceNoteId: task.sourceNoteId, + parentId: task.parentId, + subtaskIds: [], + createdAt: new Date(task.createdAt), + completedAt: task.completedAt ? new Date(task.completedAt) : null, + archivedAt: task.archivedAt ? new Date(task.archivedAt) : null + } +} + +const ACTION_VERBS = new Set([ + 'add', + 'announce', + 'approve', + 'arrange', + 'ask', + 'assign', + 'backup', + 'book', + 'build', + 'buy', + 'call', + 'cancel', + 'check', + 'clean', + 'clear', + 'close', + 'configure', + 'confirm', + 'connect', + 'copy', + 'create', + 'debug', + 'deploy', + 'design', + 'discuss', + 'do', + 'download', + 'draft', + 'drop', + 'edit', + 'email', + 'export', + 'file', + 'fill', + 'find', + 'finish', + 'fix', + 'flush', + 'follow', + 'get', + 'go', + 'implement', + 'import', + 'install', + 'investigate', + 'link', + 'look', + 'make', + 'meet', + 'merge', + 'migrate', + 'move', + 'notify', + 'open', + 'order', + 'organize', + 'pack', + 'patch', + 'pay', + 'pick', + 'pin', + 'plan', + 'post', + 'prepare', + 'print', + 'publish', + 'push', + 'read', + 'refactor', + 'release', + 'remind', + 'remove', + 'renew', + 'replace', + 'research', + 'resolve', + 'respond', + 'restore', + 'return', + 'review', + 'run', + 'scan', + 'schedule', + 'send', + 'set', + 'share', + 'ship', + 'sign', + 'sort', + 'start', + 'stop', + 'submit', + 'swap', + 'sync', + 'tag', + 'talk', + 'test', + 'try', + 'update', + 'upgrade', + 'upload', + 'validate', + 'verify', + 'watch', + 'write' +]) + +export function isLikelyTask(text: string): boolean { + const trimmed = text.trim() + if (trimmed.length < 3 || trimmed.length > 200) return false + const firstWord = trimmed.split(/\s+/)[0].toLowerCase() + return ACTION_VERBS.has(firstWord) +} + +const TASK_BLOCK_SUFFIX_REGEX = /\{task:([^}]+)\}\s*$/ + +export interface TaskBlockProps { + taskId: string + title: string + checked: boolean +} + +export function serializeTaskBlock(props: TaskBlockProps): string { + const check = props.checked ? 'x' : ' ' + return `- [${check}] ${props.title} {task:${props.taskId}}` +} + +export function parseTaskBlockSuffix(text: string): { taskId: string; title: string } | null { + const match = text.match(TASK_BLOCK_SUFFIX_REGEX) + if (!match) return null + return { + taskId: match[1], + title: text.replace(TASK_BLOCK_SUFFIX_REGEX, '').trim() + } +} + +function extractInlineText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map((item: unknown) => { + if (typeof item === 'string') return item + if ( + item && + typeof item === 'object' && + 'type' in item && + (item as Record).type === 'text' + ) { + return ((item as Record).text as string) || '' + } + return '' + }) + .join('') +} + +export function normalizeTaskBlocks(blocks: Block[]): { blocks: Block[]; didChange: boolean } { + const blockStr = JSON.stringify(blocks) + if (!blockStr.includes('{task:')) { + return { blocks, didChange: false } + } + + let didChange = false + + const nextBlocks = blocks.map((block) => { + if (block.type !== 'checkListItem') return block + + const text = extractInlineText(block.content) + const parsed = parseTaskBlockSuffix(text) + if (!parsed) return block + + didChange = true + return { + type: 'taskBlock', + props: { + taskId: parsed.taskId, + title: parsed.title, + checked: (block.props as Record).isChecked ?? false + }, + content: undefined, + children: [], + id: block.id + } as unknown as Block + }) + + return { blocks: didChange ? nextBlocks : blocks, didChange } +} diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-creation-popover.tsx b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-creation-popover.tsx new file mode 100644 index 000000000..e4907cdd6 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/task-creation-popover.tsx @@ -0,0 +1,187 @@ +import { type FC, useState, useCallback, useEffect, useRef, type RefObject } from 'react' +import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover' +import { Button } from '@/components/ui/button' +import { useTasksOptional } from '@/contexts/tasks' +import { tasksService, type TaskCreateInput } from '@/services/tasks-service' +import { extractErrorMessage } from '@/lib/ipc-error' +import { cn } from '@/lib/utils' + +interface TaskCreationPopoverProps { + isOpen: boolean + anchorRef: RefObject + title: string + noteId?: string + onCreated: (taskId: string, title: string) => void + onCancel: () => void +} + +const PRIORITY_OPTIONS = [ + { value: 0, label: 'None', color: 'bg-stone-300 dark:bg-stone-600' }, + { value: 1, label: 'Low', color: 'bg-sky-400' }, + { value: 2, label: 'Medium', color: 'bg-amber-400' }, + { value: 3, label: 'High', color: 'bg-orange-500' }, + { value: 4, label: 'Urgent', color: 'bg-red-500' } +] as const + +export const TaskCreationPopover: FC = ({ + isOpen, + anchorRef, + title, + noteId, + onCreated, + onCancel +}) => { + const tasksCtx = useTasksOptional() + const projects = tasksCtx?.projects?.filter((p) => !p.isArchived) ?? [] + const inboxProject = projects.find((p) => p.isDefault) + + const [projectId, setProjectId] = useState('') + const [priority, setPriority] = useState(0) + const [dueDate, setDueDate] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const createBtnRef = useRef(null) + const prevOpenRef = useRef(false) + + useEffect(() => { + const justOpened = isOpen && !prevOpenRef.current + prevOpenRef.current = isOpen + if (justOpened) { + setProjectId(inboxProject?.id ?? projects[0]?.id ?? '') + setPriority(0) + setDueDate('') + setError(null) + setIsSubmitting(false) + } + }, [isOpen, inboxProject?.id, projects]) + + const handleCreate = useCallback(async () => { + if (!projectId || isSubmitting) return + setIsSubmitting(true) + setError(null) + + const input: TaskCreateInput = { + projectId, + title, + priority, + dueDate: dueDate || null, + linkedNoteIds: noteId ? [noteId] : [] + } + + try { + const res = await tasksService.create(input) + if (res.success && res.task) { + onCreated(res.task.id, res.task.title) + } else { + setError(res.error ?? 'Failed to create task') + setIsSubmitting(false) + } + } catch (err) { + setError(extractErrorMessage(err, 'Failed to create task')) + setIsSubmitting(false) + } + }, [projectId, title, priority, dueDate, noteId, isSubmitting, onCreated]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleCreate() + } + if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }, + [handleCreate, onCancel] + ) + + return ( + !open && onCancel()}> + } /> + { + e.preventDefault() + createBtnRef.current?.focus() + }} + > +
+
{title}
+ +
+ + +
+ +
+ +
+ {PRIORITY_OPTIONS.map((opt) => ( +
+
+ +
+ + setDueDate(e.target.value)} + className={cn( + 'w-full rounded-md border bg-transparent px-2 py-1.5 text-sm', + 'focus:outline-none focus:ring-1 focus:ring-ring' + )} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/note/content-area/task-block/use-task-block-data.ts b/apps/desktop/src/renderer/src/components/note/content-area/task-block/use-task-block-data.ts new file mode 100644 index 000000000..d07697454 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/content-area/task-block/use-task-block-data.ts @@ -0,0 +1,73 @@ +import { useState, useEffect, useCallback } from 'react' +import { + tasksService, + onTaskUpdated, + onTaskDeleted, + onTaskCompleted, + type Task +} from '@/services/tasks-service' + +interface UseTaskBlockDataResult { + task: Task | null + isLoading: boolean + isDeleted: boolean +} + +export function useTaskBlockData(taskId: string): UseTaskBlockDataResult { + const [task, setTask] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isDeleted, setIsDeleted] = useState(false) + + const loadTask = useCallback(async (id: string): Promise => { + setIsLoading(true) + try { + const result = await tasksService.get(id) + if (result) { + setTask(result) + setIsDeleted(false) + } else { + setIsDeleted(true) + } + } catch { + setIsDeleted(true) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + if (!taskId) return + loadTask(taskId) + }, [taskId, loadTask]) + + useEffect(() => { + if (!taskId) return + + const unsubUpdated = onTaskUpdated((event) => { + if (event.id === taskId) { + setTask(event.task) + } + }) + + const unsubCompleted = onTaskCompleted((event) => { + if (event.id === taskId) { + setTask(event.task) + } + }) + + const unsubDeleted = onTaskDeleted((event) => { + if (event.id === taskId) { + setIsDeleted(true) + setTask(null) + } + }) + + return () => { + unsubUpdated() + unsubCompleted() + unsubDeleted() + } + }, [taskId]) + + return { task, isLoading, isDeleted } +} diff --git a/apps/desktop/src/renderer/src/components/tasks/task-row.tsx b/apps/desktop/src/renderer/src/components/tasks/task-row.tsx index efc089aa8..6a7836b1f 100644 --- a/apps/desktop/src/renderer/src/components/tasks/task-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/task-row.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils' import { formatDueDate, formatDateShort, formatTime } from '@/lib/task-utils' import { InlineStatusPopover } from '@/components/tasks/inline-status-popover' import { InlinePriorityPopover } from '@/components/tasks/inline-priority-popover' +import { InteractiveProjectBadge } from '@/components/tasks/interactive-project-badge' import { SelectionCheckbox } from '@/components/tasks/bulk-actions' import { RepeatIndicator } from '@/components/tasks/repeat-indicator' import type { Task } from '@/data/sample-tasks' @@ -26,6 +27,9 @@ interface TaskRowProps { isCheckedForSelection?: boolean onToggleSelect?: (taskId: string) => void onShiftSelect?: (taskId: string) => void + onProjectChange?: (projectId: string) => void + actions?: React.ReactNode + renderTitle?: () => React.ReactNode } // ============================================================================ @@ -50,7 +54,7 @@ const resolveStatus = ( export const TaskRow = ({ task, project, - projects: _projects, + projects, isCompleted, isSelected = false, showProjectBadge = false, @@ -61,7 +65,10 @@ export const TaskRow = ({ isSelectionMode = false, isCheckedForSelection = false, onToggleSelect, - onShiftSelect + onShiftSelect, + onProjectChange, + actions, + renderTitle }: TaskRowProps): React.JSX.Element => { const formattedDate = formatDueDate(task.dueDate, task.dueTime) const isOverdue = formattedDate?.status === 'overdue' && !isCompleted @@ -147,29 +154,41 @@ export const TaskRow = ({ onPriorityChange={(priority) => onUpdateTask?.(task.id, { priority })} /> - - {task.title} - + {renderTitle ? ( + renderTitle() + ) : ( + + {task.title} + + )} {task.isRepeating && task.repeatConfig && ( )} - {showProjectBadge && ( + {showProjectBadge && onProjectChange ? ( +
e.stopPropagation()}> + +
+ ) : showProjectBadge ? (
{project.name}
- )} + ) : null} {dueDateDisplay && (
)} + + {actions}
) }