From 6de09411a10a8656b6c9acea7416a823294358fa Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Sat, 28 Feb 2026 17:11:33 -0600 Subject: [PATCH] Significant tiptap updates --- bun.lock | 40 ++++++- package.json | 12 +++ src/components/Layout.tsx | 12 ++- src/components/MentionList.tsx | 80 ++++++++++++++ src/components/SlashMenu.tsx | 55 +++++++++- src/components/TaskDetail.tsx | 61 +++++++++++ src/components/TaskEditor.tsx | 189 ++++++++++++++++++++++++++++++++- src/lib/mention-suggestion.tsx | 102 ++++++++++++++++++ 8 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 src/components/MentionList.tsx create mode 100644 src/lib/mention-suggestion.tsx diff --git a/bun.lock b/bun.lock index 18e5930..b608d7f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,22 @@ "@kobalte/core": "^0.13.11", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "^3.18.0", + "@tiptap/extension-bubble-menu": "^3.20.0", + "@tiptap/extension-dropcursor": "^3.20.0", + "@tiptap/extension-floating-menu": "^3.20.0", + "@tiptap/extension-highlight": "^3.20.0", "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-mention": "^3.20.0", "@tiptap/extension-placeholder": "^3.18.0", + "@tiptap/extension-table": "^3.20.0", + "@tiptap/extension-table-cell": "^3.20.0", + "@tiptap/extension-table-header": "^3.20.0", + "@tiptap/extension-table-row": "^3.20.0", "@tiptap/extension-task-item": "^3.18.0", "@tiptap/extension-task-list": "^3.18.0", + "@tiptap/extension-text-align": "^3.20.0", + "@tiptap/extension-typography": "^3.20.0", "@tiptap/extension-underline": "^3.20.0", "@tiptap/starter-kit": "^3.18.0", "@tiptap/suggestion": "^3.18.0", @@ -451,6 +463,8 @@ "@tiptap/extension-bold": ["@tiptap/extension-bold@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-xUgOvHCdGXh9Lfxd7DtgsSr0T/egIwBllWHIBWDjQEQQ0b+ICn+0+i703btHMB4hjdduZtgVDrhK8jAW3U6swA=="], + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.20.0", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw=="], + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-8sEpY0nxAGGFDYlF+WVFPKX00X2dAAjmoi0+2eWvK990PdQqwXrQsRs7pkUbpE2mDtATV8+GlDXk9KDkK/ZXhA=="], "@tiptap/extension-code": ["@tiptap/extension-code@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-0SU53O0NRmdtRM2Hgzm372dVoHjs2F40o/dtB7ls4kocf4W89FyWeC2R6ZsFQqcXisNh9RTzLtYfbNyizGuZIw=="], @@ -459,7 +473,9 @@ "@tiptap/extension-document": ["@tiptap/extension-document@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-e0hOGrjTMpCns8IC5p+c5CEiE1BBmFBFL+RpIxU/fjT2SaZ7q2xsFguBu94lQDT0cD6fdZokFRpGwEMxZNVGCg=="], - "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-pIW/K9fGth221dkfA5SInHcqfnCr0aG9LGkRiEh4gwM4cf6ceUBrvcD+QlemSZ4q9oktNGJmXT+sEXVOQ8QoeQ=="], + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.20.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig=="], "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-covioXPPHX3SnlTwC/1rcHUHAc7/JFd4vN0kZQmZmvGHlxqq2dPmtrPh8D7TuDuhG0k/3Z6i8dJFP0phfRAhuA=="], @@ -467,13 +483,15 @@ "@tiptap/extension-heading": ["@tiptap/extension-heading@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-MTamVnYsFWVndLSq5PRQ7ZmbF6AExsFS9uIvGtUAwuhzvR4of/WHh6wpvWYjA+BLXTWRrfuGHaZTl7UXBN13fg=="], + "@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA=="], + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-fEq7DwwQZ496RHNbMQypBVNqoWnhDEERbzWMBqlmfCfc/0FvJrHtsQkk3k4lgqMYqmBwym3Wp0SrRYiyKCPGTw=="], "@tiptap/extension-image": ["@tiptap/extension-image@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g=="], "@tiptap/extension-italic": ["@tiptap/extension-italic@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ=="], - "@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="], + "@tiptap/extension-link": ["@tiptap/extension-link@3.20.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA=="], "@tiptap/extension-list": ["@tiptap/extension-list@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-9lQBo45HNqIFcLEHAk+CY3W51eMMxIJjWbthm2CwEWr4PB3+922YELlvq8JcLH1nVFkBVpmBFmQe/GxgnCkzwQ=="], @@ -481,6 +499,8 @@ "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-ZzO5r/cW7G0zpL/eM69WPnMpzb0YsSjtI60CYGA0iQDRJnK9INvxu0RU0ewM2faqqwASmtjuNJac+Fjk6scdXg=="], + "@tiptap/extension-mention": ["@tiptap/extension-mention@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0", "@tiptap/suggestion": "^3.20.0" } }, "sha512-wUjsq7Za0JJdJzrGNG+g8nrCpek/85GQ0Rm9bka3PynIVRwus+xQqW6IyWVPBdl1BSkrbgMAUqtrfoh1ymznbg=="], + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-5bUAfklYLS5o6qvLLfreGyGvD1JKXqOQF0YntLyPuCGrXv7+XjPWQL2BmEf59fOn2UPT2syXLQ1WN5MHTArRzg=="], "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-uvFhdwiur4NhhUdBmDsajxjGAIlg5qga55fYag2DzOXxIQE2M7/aVMRkRpuJzb88GY4EHSh8rY34HgMK2FJt2Q=="], @@ -489,12 +509,24 @@ "@tiptap/extension-strike": ["@tiptap/extension-strike@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-kl/fa68LZg8NWUqTkRTfgyCx+IGqozBmzJxQDc1zxurrIU+VFptDV9UuZim587sbM2KGjCi/PNPjPGk1Uu0PVg=="], + "@tiptap/extension-table": ["@tiptap/extension-table@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ=="], + + "@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A=="], + + "@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw=="], + + "@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A=="], + "@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-XWrprk9YN2sc6coDypLxFGnFN51ZW+nKwz+9CHefINK6ndmUOeG1QNDGkQBnU5IB1fZBSEpyJga/shmJ7RfOAg=="], "@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-/znJ9/B4dBa4PizWtKFbVyuR7ROnlQA2HngL9Z5IvZQUlru1djpVfU4nuUImOkRWg4H7CRV5i2xL21JkcS57UA=="], "@tiptap/extension-text": ["@tiptap/extension-text@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-9TvctdnBCwK/zyTi9kS7nGFNl5OvGM8xE0u38ZmQw5t79JOqJHgOroyqMjw8LHK/1PWrozfNCmsZbpq4IZuKXw=="], + "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-4s0r+bovtH6yeGDUD+Ui8j5WOV5koB5P6AuzOMqoLwaFGRSkKf64ly6DXjjmjIgnYCLZN/XO6llaQKVVdvad2g=="], + + "@tiptap/extension-typography": ["@tiptap/extension-typography@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-XpstIJ/hCK6+N87DdzjaYie1iH1gunbDK0UCsqsLemd8rMR/WAlF04M/yeJ6F2QtNzNh83bkoTxyNAHLDdUm2A=="], + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ=="], "@tiptap/extensions": ["@tiptap/extensions@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-uSRIE9HGshBN6NRFR3LX2lZqBLvX92SgU5A9AvUbJD4MqU63E+HdruJnRjsVlX3kPrmbIDowxrzXlUcg3K0USQ=="], @@ -1393,6 +1425,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tiptap/starter-kit/@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-pIW/K9fGth221dkfA5SInHcqfnCr0aG9LGkRiEh4gwM4cf6ceUBrvcD+QlemSZ4q9oktNGJmXT+sEXVOQ8QoeQ=="], + + "@tiptap/starter-kit/@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="], + "@tiptap/starter-kit/@tiptap/extension-underline": ["@tiptap/extension-underline@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-009IeXURNJ/sm1pBqbj+2YQgjQaBtNlJR3dbl6xu49C+qExqCmI7klhKQuwsVVGLR7ahsYlp7d9RlftnhCXIcQ=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/package.json b/package.json index 03ebd5a..553b68b 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,22 @@ "@kobalte/core": "^0.13.11", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "^3.18.0", + "@tiptap/extension-bubble-menu": "^3.20.0", + "@tiptap/extension-dropcursor": "^3.20.0", + "@tiptap/extension-floating-menu": "^3.20.0", + "@tiptap/extension-highlight": "^3.20.0", "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-mention": "^3.20.0", "@tiptap/extension-placeholder": "^3.18.0", + "@tiptap/extension-table": "^3.20.0", + "@tiptap/extension-table-cell": "^3.20.0", + "@tiptap/extension-table-header": "^3.20.0", + "@tiptap/extension-table-row": "^3.20.0", "@tiptap/extension-task-item": "^3.18.0", "@tiptap/extension-task-list": "^3.18.0", + "@tiptap/extension-text-align": "^3.20.0", + "@tiptap/extension-typography": "^3.20.0", "@tiptap/extension-underline": "^3.20.0", "@tiptap/starter-kit": "^3.18.0", "@tiptap/suggestion": "^3.18.0", diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a0d6d4f..4a821a0 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount } from "solid-js"; +import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js"; import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation"; import { store, setStore, activeTaskId, setActiveTaskId } from "@/store"; import { PanelLeftOpen, PanelLeftClose } from "lucide-solid"; @@ -30,6 +30,16 @@ export const Layout: Component = (props) => { }); onMount(() => { + const handleOpenNote = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail && detail.noteId) { + setStore('isNotepadMode', true); + setSelectedNoteId(detail.noteId); + } + }; + window.addEventListener('open-note', handleOpenNote); + onCleanup(() => window.removeEventListener('open-note', handleOpenNote)); + // Preload heavy components in background when idle const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 2000)); idleCallback(() => { diff --git a/src/components/MentionList.tsx b/src/components/MentionList.tsx new file mode 100644 index 0000000..77bd4fa --- /dev/null +++ b/src/components/MentionList.tsx @@ -0,0 +1,80 @@ +import { type Component, createSignal, For, createEffect } from "solid-js"; +import { cn } from "@/lib/utils"; + +export interface MentionItem { + id: string; + label: string; + sublabel?: string; + type: 'user' | 'note'; +} + +interface MentionListProps { + items: MentionItem[]; + command: (item: MentionItem) => void; + ref: (handlers: { onKeyDown: (e: KeyboardEvent) => boolean }) => void; +} + +export const MentionList: Component = (props) => { + const [selectedIndex, setSelectedIndex] = createSignal(0); + + createEffect(() => { + props.items; + setSelectedIndex(0); + }); + + const selectItem = (index: number) => { + const item = props.items[index]; + if (item) { + props.command(item); + } + }; + + props.ref({ + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "ArrowUp") { + setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length); + return true; + } + if (e.key === "ArrowDown") { + setSelectedIndex((prev) => (prev + 1) % props.items.length); + return true; + } + if (e.key === "Enter") { + selectItem(selectedIndex()); + return true; + } + return false; + } + }); + + return ( +
+
+ + {(item, index) => ( + + )} + + {props.items.length === 0 && ( +
No results found
+ )} +
+
+ ); +}; diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 7d52251..a5379ac 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -4,7 +4,10 @@ import { List, ListTodo, Type, Code, Quote, Image as ImageIcon, Minus, - Bold, Italic, Underline + Bold, Italic, Underline, + Grid3X3, Highlighter, + AlignLeft, AlignCenter, AlignRight, + Trash2 } from "lucide-solid"; import { cn, convertImageToWebpFile } from "@/lib/utils"; import { activeTaskId, uploadTaskAttachment } from "@/store"; @@ -170,6 +173,56 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, + { + title: "Table", + description: "Insert a 3x3 table.", + aliases: ["grid", "table"], + icon: Grid3X3, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }, + }, + { + title: "Highlight", + description: "Highlight selected text.", + aliases: ["mark"], + icon: Highlighter, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).toggleHighlight().run(); + }, + }, + { + title: "Align Left", + description: "Align text to the left.", + icon: AlignLeft, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).setTextAlign("left").run(); + }, + }, + { + title: "Align Center", + description: "Align text to the center.", + icon: AlignCenter, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).setTextAlign("center").run(); + }, + }, + { + title: "Align Right", + description: "Align text to the right.", + icon: AlignRight, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).setTextAlign("right").run(); + }, + }, + { + title: "Delete Table", + description: "Delete the entire table.", + icon: Trash2, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).deleteTable().run(); + }, + }, ].filter(item => { const lowerQuery = query.toLowerCase(); return item.title.toLowerCase().startsWith(lowerQuery) || diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index e93f753..4e4335a 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -48,6 +48,21 @@ export const TaskDetail: Component = (props) => { } }); + const [trackedMentions, setTrackedMentions] = createSignal>(new Set()); + + createEffect(() => { + if (props.isOpen && props.task.content) { + const html = props.task.content; + const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g; + let match; + const initial = new Set(); + while ((match = mentionRegex.exec(html)) !== null) { + initial.add(match[1]); + } + setTrackedMentions(initial); + } + }); + let debounceTimer: number | undefined; let pendingContent: string | null = null; @@ -57,6 +72,39 @@ export const TaskDetail: Component = (props) => { debounceTimer = setTimeout(() => { if (pendingContent !== null) { updateTask(props.task.id, { content: pendingContent }); + + // Sync mentions deletion + const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g; + let match; + const currentMentions = new Set(); + while ((match = mentionRegex.exec(pendingContent)) !== null) { + currentMentions.add(match[1].toLowerCase()); + } + + const currentTags = props.task.tags || []; + let tagsChanged = false; + const newTags = currentTags.filter(t => { + const tagLower = t.toLowerCase(); + const isTracked = [...trackedMentions()].some(tm => tm.toLowerCase() === tagLower); + if (isTracked && !currentMentions.has(tagLower)) { + tagsChanged = true; + // Also remove from tracked + setTrackedMentions(prev => { + const next = new Set(prev); + for (let p of next) { + if (p.toLowerCase() === tagLower) next.delete(p); + } + return next; + }); + return false; // remove from tags + } + return true; + }); + + if (tagsChanged) { + updateTags(newTags); + } + pendingContent = null; } }, 1000); @@ -353,6 +401,19 @@ export const TaskDetail: Component = (props) => { content={props.task.content} onUpdate={updateTaskContent} onEditorReady={setEditorInstance} + onNoteOpen={(noteId) => { + props.onClose(); + window.dispatchEvent(new CustomEvent('open-note', { detail: { noteId } })); + }} + onUserMention={(name) => { + const tagName = '@' + name; + setTrackedMentions(prev => new Set([...prev, tagName])); + const currentTags = props.task.tags || []; + if (!currentTags.some(t => t.toLowerCase() === tagName.toLowerCase())) { + updateTags([...currentTags, tagName]); + toast.success(`Added tag ${tagName} to share task.`); + } + }} /> diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 8dc16ac..f13abd7 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -6,28 +6,47 @@ import TaskList from "@tiptap/extension-task-list"; import { CustomTaskItem } from "./CustomTaskItem"; import Image from "@tiptap/extension-image"; import Underline from "@tiptap/extension-underline"; -import { X } from "lucide-solid"; +import { X, Trash2, ArrowUpToLine, ArrowDownToLine, ArrowLeftToLine, ArrowRightToLine } from "lucide-solid"; import { cn } from "@/lib/utils"; +import { store } from "@/store"; import { SlashCommands } from "@/lib/slash-command"; import { suggestion } from "@/lib/slash-renderer"; import { MobileIndent } from "@/lib/mobile-indent"; +// New Extensions +import { Link } from "@tiptap/extension-link"; +import { Highlight } from "@tiptap/extension-highlight"; +import { TextAlign } from "@tiptap/extension-text-align"; +import { Table } from "@tiptap/extension-table"; +import { TableRow } from "@tiptap/extension-table-row"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { Typography } from "@tiptap/extension-typography"; +import { Dropcursor } from "@tiptap/extension-dropcursor"; +import { BubbleMenu } from "@tiptap/extension-bubble-menu"; +import { Mention } from "@tiptap/extension-mention"; +import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion"; + interface TaskEditorProps { content?: string; onUpdate: (html: string) => void; onEditorReady?: (editor: any) => void; editable?: boolean; + onUserMention?: (userName: string) => void; + onNoteOpen?: (noteId: string) => void; class?: string; } export const TaskEditor: Component = (props) => { let editorRef: HTMLDivElement | undefined; + let tableMenuRef: HTMLDivElement | undefined; const [fullscreenImage, setFullscreenImage] = createSignal(null); const [zoomScale, setZoomScale] = createSignal(1); const [zoomOffset, setZoomOffset] = createSignal({ x: 0, y: 0 }); const [isDragging, setIsDragging] = createSignal(false); const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 }); + const [previewNote, setPreviewNote] = createSignal<{ noteId: string, x: number, y: number } | null>(null); const resetZoom = () => { setZoomScale(1); @@ -73,6 +92,81 @@ export const TaskEditor: Component = (props) => { }), Underline.configure({}), MobileIndent.configure({}), + Link.configure({ + autolink: true, + openOnClick: false, + HTMLAttributes: { + class: "text-primary underline underline-offset-4 cursor-pointer hover:text-primary/80 transition-colors", + }, + }), + Highlight.configure({ + multicolor: true, + }), + TextAlign.configure({ + types: ["heading", "paragraph"], + }), + Table.configure({ + resizable: true, + allowTableNodeSelection: true, + }), + TableRow, + TableHeader, + TableCell, + Typography.configure({ + openDoubleQuote: false, + closeDoubleQuote: false, + openSingleQuote: false, + closeSingleQuote: false, + }), + Mention.configure({ + HTMLAttributes: { + class: "mention bg-primary/10 text-primary font-medium py-0.5 px-1 rounded-md border border-primary/20", + }, + suggestion: { + ...userSuggestion, + command: ({ editor, range, props: mentionProps }: any) => { + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "mention", + attrs: mentionProps, + }, + { + type: "text", + text: " ", + }, + ]) + .run(); + + if (mentionProps.label) { + props.onUserMention?.(mentionProps.label); + } + } + }, + }), + Mention.extend({ + name: "noteMention", + }).configure({ + suggestion: { + ...noteSuggestion, + char: "#", + }, + HTMLAttributes: { + class: "note-mention bg-muted text-muted-foreground font-medium py-0.5 px-1 rounded-md border border-border italic cursor-pointer hover:bg-muted/80", + }, + }), + BubbleMenu.configure({ + element: tableMenuRef!, + shouldShow: ({ editor }) => editor.isActive("table"), + // @ts-ignore - Solid-tiptap types might be outdated + tippyOptions: { placement: "bottom", duration: 100 }, + }), + Dropcursor.configure({ + color: "var(--primary)", + width: 2, + }), ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", @@ -88,6 +182,14 @@ export const TaskEditor: Component = (props) => { "prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1", "prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4", "prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:cursor-zoom-in", + // New Extension styles + "prose-a:text-primary prose-a:underline prose-a:underline-offset-4", + "prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic", + "[&_table]:border-collapse [&_table]:w-full [&_table]:my-4", + "[&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:p-2 [&_th]:text-left [&_th]:font-bold", + "[&_td]:border [&_td]:border-border [&_td]:p-2 [&_td]:align-top", + "[&_mark]:bg-primary/20 dark:[&_mark]:bg-primary/40 [&_mark]:text-foreground dark:[&_mark]:text-primary-foreground [&_mark]:px-0.5 [&_mark]:rounded-sm", + "[&_.mention]:mx-0.5 [&_.note-mention]:mx-0.5", // Custom Task List Styling "[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0", "[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start", @@ -118,6 +220,32 @@ export const TaskEditor: Component = (props) => { } }); + // Handle note mention clicks + createEffect(() => { + const e = editor(); + if (!e) return; + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.classList.contains('note-mention')) { + const noteId = target.getAttribute('data-id'); + if (noteId) { + const rect = target.getBoundingClientRect(); + setPreviewNote({ noteId, x: rect.left, y: rect.bottom + window.scrollY }); + return; + } + } + // Close preview if clicking elsewhere + setPreviewNote(null); + }; + + const element = e.view.dom; + if (element) { + element.addEventListener('click', handleClick); + } + }); + + // Handle full screen images as before return (
= (props) => { } }} /> + + {/* Table Bubble Menu */} +