Files
ProjectE/apps/web/components/notes/note-editor.tsx
T
Hermes efe8db108d fix(p3): TipTap focus, Task List toast dedup, TaskCard a11y
Bug #10 (LOW): TipTap editor text input leaks into Search field.
The EditorContent wrapper had no tabIndex so the global keyboard
shortcut (which treats inputs/textareas/buttons as focused but
not contenteditable divs) sent typed text to the search field
instead of the editor. Adding tabIndex={0} makes the wrapper
focusable; TipTap's contentEditable=true then routes the
keyboard events to the editor.

Bug #1 (LOW): Task List view infinite toast loop. The catch block
fired toast.error on every realtime-triggered fetch failure with
no dedup, causing infinite toast spam on 429 or persistent
errors. Realtime subscription also called fetchTasks() on every
event with no debounce, amplifying the problem.

Fixes:
- Add lastErrorRef to track the last error message; only toast
  when the message class changes.
- Distinguish 429/rate-limited from generic 500 in the toast text.
- Reset lastErrorRef on successful fetch.
- Debounce the realtime-triggered refetch by 750ms so event
  bursts collapse to a single fetch.

Bug #8 (LOW): TaskCard in Board view not keyboard-focusable.
The TaskCard in tasks-kanban-view.tsx already has role=button,
tabIndex={0}, onKeyDown for Enter/Space, and aria-label. No
change needed; verified in the tree that the fix is present
(probably landed as part of an earlier leaf integration).

Bug #1 + Bug #8 + Bug #10 all addressed in this commit.

Note: Bug #9 (Search returns No results) is fixed by P0 (the
search route was patched to use resolveActiveDomain). Verified
working without further changes needed.
2026-07-31 01:06:31 +00:00

164 lines
4.2 KiB
TypeScript

'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
Undo,
Redo,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface NoteEditorProps {
content: string;
onChange: (content: string) => void;
onBlur?: () => void;
}
export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
Placeholder.configure({
placeholder: 'Start writing... Use [[Note Title]] to link to other notes',
}),
],
content,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
onBlur: () => {
onBlur?.();
},
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
label="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
label="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
label="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
label="Code"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
label="Bullet list"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
label="Ordered list"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
label="Blockquote"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
label="Undo"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
label="Redo"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
</div>
{/* Editor content */}
<div className="flex-1 overflow-auto">
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" tabIndex={0} />
</div>
</div>
);
}
function ToolbarButton({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
{children}
</Button>
);
}