Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.

Commit 735fe33

Browse files
author
ComputelessComputer
committed
add mention chips and date suggestions
1 parent e865f8e commit 735fe33

6 files changed

Lines changed: 597 additions & 21 deletions

File tree

‎apps/desktop/src/components/editor/Editor.css‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,79 @@
1313
display: none;
1414
}
1515

16+
.tiptap .mention-chip {
17+
display: inline-flex;
18+
align-items: center;
19+
max-width: 16rem;
20+
margin: 0 0.125rem;
21+
padding: 0.1rem 0.5rem;
22+
border-radius: 999px;
23+
border: 1px solid #dbeafe;
24+
background: #eff6ff;
25+
color: #1d4ed8;
26+
font-size: 0.92em;
27+
font-weight: 600;
28+
line-height: 1.5;
29+
white-space: nowrap;
30+
vertical-align: baseline;
31+
}
32+
33+
.tiptap .mention-chip-recurring {
34+
border-color: #fed7aa;
35+
background: #fff7ed;
36+
color: #c2410c;
37+
}
38+
39+
.tiptap .mention-chip-tag {
40+
border-color: #d1d5db;
41+
background: #f9fafb;
42+
color: #374151;
43+
}
44+
45+
.mention-menu {
46+
min-width: 12rem;
47+
padding: 0.4rem;
48+
border: 1px solid #e5e7eb;
49+
border-radius: 0.9rem;
50+
background: rgba(255, 255, 255, 0.98);
51+
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
52+
backdrop-filter: blur(12px);
53+
}
54+
55+
.mention-menu-divider {
56+
height: 1px;
57+
margin: 0.35rem 0;
58+
background: #e5e7eb;
59+
}
60+
61+
.mention-menu-item {
62+
display: flex;
63+
align-items: center;
64+
width: 100%;
65+
gap: 0.55rem;
66+
padding: 0.45rem 0.6rem;
67+
border: 0;
68+
border-radius: 0.7rem;
69+
background: transparent;
70+
color: #111827;
71+
text-align: left;
72+
cursor: pointer;
73+
}
74+
75+
.mention-menu-item.is-selected {
76+
background: #eff6ff;
77+
color: #1d4ed8;
78+
}
79+
80+
.mention-menu-icon {
81+
flex-shrink: 0;
82+
}
83+
84+
.mention-menu-label {
85+
font-size: 0.92rem;
86+
font-weight: 500;
87+
}
88+
1689
/* ---- Bubble Menu ---- */
1790

1891
.bubble-menu {
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { autoUpdate, computePosition, flip, limitShift, offset, shift, type VirtualElement, } from "@floating-ui/dom";
2+
import Mention from "@tiptap/extension-mention";
3+
import { PluginKey, } from "@tiptap/pm/state";
4+
import { ReactRenderer, } from "@tiptap/react";
5+
import type { SuggestionOptions, } from "@tiptap/suggestion";
6+
import { CalendarDays, Repeat2, } from "lucide-react";
7+
import { forwardRef, useEffect, useImperativeHandle, useState, } from "react";
8+
import { getMentionSuggestions, type MentionSuggestion, } from "../../../../services/mentions";
9+
10+
const MentionMenu = forwardRef<
11+
{ onKeyDown: (props: { event: KeyboardEvent; },) => boolean; },
12+
{ items: MentionSuggestion[]; command: (item: MentionSuggestion,) => void; }
13+
>(function MentionMenu({ items, command, }, ref,) {
14+
const [selectedIndex, setSelectedIndex,] = useState(0,);
15+
16+
useEffect(() => {
17+
setSelectedIndex(0,);
18+
}, [items,],);
19+
20+
useImperativeHandle(ref, () => ({
21+
onKeyDown: ({ event, },) => {
22+
if (items.length === 0) return false;
23+
24+
if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter") {
25+
event.preventDefault();
26+
}
27+
28+
if (event.key === "ArrowUp") {
29+
setSelectedIndex((current,) => (current + items.length - 1) % items.length);
30+
return true;
31+
}
32+
33+
if (event.key === "ArrowDown") {
34+
setSelectedIndex((current,) => (current + 1) % items.length);
35+
return true;
36+
}
37+
38+
if (event.key === "Enter") {
39+
const item = items[selectedIndex];
40+
if (item) command(item,);
41+
return true;
42+
}
43+
44+
return false;
45+
},
46+
}), [items, selectedIndex, command,],);
47+
48+
if (items.length === 0) return null;
49+
50+
let sawRecurring = false;
51+
52+
return (
53+
<div className="mention-menu">
54+
{items.map((item, index,) => {
55+
const showRecurringDivider = item.group === "recurring" && !sawRecurring;
56+
if (item.group === "recurring") sawRecurring = true;
57+
const Icon = item.kind === "recurring" ? Repeat2 : CalendarDays;
58+
59+
return (
60+
<div key={item.id}>
61+
{showRecurringDivider && <div className="mention-menu-divider" />}
62+
<button
63+
className={`mention-menu-item ${index === selectedIndex ? "is-selected" : ""}`}
64+
onClick={() => command(item,)}
65+
type="button"
66+
>
67+
<Icon className="mention-menu-icon" size={14} />
68+
<span className="mention-menu-label">{item.label}</span>
69+
</button>
70+
</div>
71+
);
72+
},)}
73+
</div>
74+
);
75+
},);
76+
77+
export function buildMentionChipSuggestion(referenceDate?: string,): Omit<SuggestionOptions, "editor"> {
78+
return {
79+
char: "@",
80+
allowSpaces: true,
81+
pluginKey: new PluginKey("mention-chip-suggestion",),
82+
items: ({ query, },) => getMentionSuggestions(query, referenceDate,),
83+
command: ({ editor, range, props, },) => {
84+
const item = props as MentionSuggestion;
85+
editor
86+
.chain()
87+
.focus()
88+
.insertContentAt(range, [
89+
{
90+
type: "mentionChip",
91+
attrs: {
92+
id: item.id,
93+
label: item.label,
94+
kind: item.kind,
95+
},
96+
},
97+
{ type: "text", text: " ", },
98+
],)
99+
.run();
100+
},
101+
render: () => {
102+
let renderer: ReactRenderer;
103+
let cleanup: (() => void) | undefined;
104+
let floatingEl: HTMLElement;
105+
let referenceEl: VirtualElement | null = null;
106+
107+
const update = () => {
108+
if (!referenceEl) return;
109+
void computePosition(referenceEl, floatingEl, {
110+
placement: "bottom-start",
111+
middleware: [offset(6,), flip(), shift({ limiter: limitShift(), },),],
112+
},).then(({ x, y, },) => {
113+
Object.assign(floatingEl.style, {
114+
left: `${x}px`,
115+
top: `${y}px`,
116+
},);
117+
},);
118+
};
119+
120+
return {
121+
onStart: (props,) => {
122+
renderer = new ReactRenderer(MentionMenu, {
123+
props: {
124+
items: props.items as MentionSuggestion[],
125+
command: (item: MentionSuggestion,) => props.command(item,),
126+
},
127+
editor: props.editor,
128+
},);
129+
130+
floatingEl = renderer.element as HTMLElement;
131+
Object.assign(floatingEl.style, {
132+
position: "absolute",
133+
top: "0",
134+
left: "0",
135+
zIndex: "40",
136+
},);
137+
document.body.appendChild(floatingEl,);
138+
139+
if (!props.clientRect) return;
140+
141+
referenceEl = {
142+
getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect(),
143+
};
144+
145+
cleanup = autoUpdate(referenceEl, floatingEl, update,);
146+
update();
147+
},
148+
onUpdate: (props,) => {
149+
renderer.updateProps({
150+
items: props.items as MentionSuggestion[],
151+
command: (item: MentionSuggestion,) => props.command(item,),
152+
},);
153+
154+
if (props.clientRect && referenceEl) {
155+
referenceEl.getBoundingClientRect = () => props.clientRect?.() ?? new DOMRect();
156+
}
157+
update();
158+
},
159+
onKeyDown: (props,) => {
160+
if (props.event.key === "Escape") {
161+
cleanup?.();
162+
floatingEl.remove();
163+
return true;
164+
}
165+
166+
return ((renderer.ref as { onKeyDown?: (input: { event: KeyboardEvent; },) => boolean; } | null)
167+
?.onKeyDown?.({ event: props.event, },) ?? false);
168+
},
169+
onExit: () => {
170+
cleanup?.();
171+
floatingEl.remove();
172+
renderer.destroy();
173+
},
174+
};
175+
},
176+
};
177+
}
178+
179+
export const MentionChipExtension = Mention.extend({
180+
name: "mentionChip",
181+
182+
addOptions() {
183+
return {
184+
HTMLAttributes: {},
185+
renderText: ({ node, },) => String(node.attrs.label ?? "",),
186+
deleteTriggerWithBackspace: true,
187+
renderHTML: ({ node, },) => [
188+
"span",
189+
{
190+
"data-mention-chip": "",
191+
class: `mention-chip mention-chip-${String(node.attrs.kind ?? "tag",)}`,
192+
},
193+
String(node.attrs.label ?? "",),
194+
],
195+
suggestions: [],
196+
suggestion: buildMentionChipSuggestion(),
197+
};
198+
},
199+
200+
addAttributes() {
201+
return {
202+
id: {
203+
default: "",
204+
parseHTML: (element: Element,) => element.getAttribute("data-id",) ?? "",
205+
renderHTML: (attributes: { id: string; },) => ({ "data-id": attributes.id, }),
206+
},
207+
label: {
208+
default: "",
209+
parseHTML: (element: Element,) => element.getAttribute("data-label",) ?? "",
210+
renderHTML: (attributes: { label: string; },) => ({ "data-label": attributes.label, }),
211+
},
212+
kind: {
213+
default: "tag",
214+
parseHTML: (element: Element,) => element.getAttribute("data-kind",) ?? "tag",
215+
renderHTML: (attributes: { kind: string; },) => ({ "data-kind": attributes.kind, }),
216+
},
217+
};
218+
},
219+
220+
parseHTML() {
221+
return [{ tag: "span[data-mention-chip]", },];
222+
},
223+
224+
renderMarkdown(node,) {
225+
const id = String(node.attrs?.id ?? "",);
226+
const label = String(node.attrs?.label ?? "",);
227+
if (!id) return "";
228+
if (!label || label === id) return `[[${id}]]`;
229+
return `[[${id}|${label}]]`;
230+
},
231+
},);

‎apps/desktop/src/components/journal/EditableNote.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ClipboardTextSerializer, } from "../editor/extensions/clipboard";
2424
import { ExcalidrawExtension, } from "../editor/extensions/excalidraw/ExcalidrawExtension";
2525
import { HashtagExtension, } from "../editor/extensions/hashtag/HashtagExtension";
2626
import { CustomListKeymap, } from "../editor/extensions/list-keymap";
27+
import { buildMentionChipSuggestion, MentionChipExtension, } from "../editor/extensions/mention/MentionChipExtension";
2728
import { CustomParagraph, } from "../editor/extensions/paragraph/ParagraphExtension";
2829
import { CustomTaskItem, } from "../editor/extensions/task-item/TaskItemNode";
2930
import { WidgetExtension, } from "../editor/extensions/widget/WidgetExtension";
@@ -142,6 +143,7 @@ const EditableNote = forwardRef<EditableNoteHandle, EditableNoteProps>(
142143
TableHeader,
143144
TableCell,
144145
Highlight,
146+
MentionChipExtension.configure({ suggestion: buildMentionChipSuggestion(note.date,), },),
145147
HashtagExtension,
146148
ExcalidrawExtension,
147149
WidgetExtension,

‎apps/desktop/src/lib/markdown.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import StarterKit from "@tiptap/starter-kit";
1010

1111
import { ExcalidrawExtension, } from "../components/editor/extensions/excalidraw/ExcalidrawExtension";
1212
import { HashtagExtension, } from "../components/editor/extensions/hashtag/HashtagExtension";
13+
import { MentionChipExtension, } from "../components/editor/extensions/mention/MentionChipExtension";
1314
import { CustomParagraph, } from "../components/editor/extensions/paragraph/ParagraphExtension";
1415
import { CustomTaskItem, } from "../components/editor/extensions/task-item/TaskItemNode";
1516
import { WidgetExtension, } from "../components/editor/extensions/widget/WidgetExtension";
@@ -43,6 +44,7 @@ function getExtensions() {
4344
TableHeader,
4445
TableCell,
4546
Highlight,
47+
MentionChipExtension,
4648
HashtagExtension,
4749
ExcalidrawExtension,
4850
WidgetExtension,

0 commit comments

Comments
 (0)