feat: Date mentions (#12621)

* Add date mentions to the editor

Introduce a new "date" mention type alongside the existing user,
document and collection mentions. Typing @ with a natural language date
(e.g. "tomorrow", "next friday", "jan 2") surfaces a date suggestion,
parsed via chrono-node. Dates are stored as date-only ISO strings and
displayed with increasing granularity (Today / Tomorrow / January 2nd /
February 3rd, 2024), recomputed dynamically so relative labels stay
fresh. Clicking a date mention opens a Radix popover calendar to change
it.

* Load chrono-node lazily to keep it out of the main bundle

Convert parseNaturalLanguageDate to dynamically import chrono-node on
first use so the bundler splits it into a separate chunk fetched only
when a date is actually parsed. The mention menu now resolves the parse
asynchronously in an effect.

* Add DynamicCalendarIcon

* Lock page scroll while the date mention picker is open

Wrap the date picker popover content in RemoveScroll (via a Slot, with
the Radix content asChild), mirroring the inline editor menu, so the
page can't scroll behind the open calendar.

* Restyle the date mention calendar picker

The react-day-picker base stylesheet isn't loaded in the editor, so day
cells fell back to default browser button styling. Style the calendar
from scratch to match the rest of the app: reset button chrome, show
outside (previous/next month) days clearly de-emphasised, render the
selected day as a solid accent-filled circle, and emphasise today with
the accent colour. Enable showOutsideDays and fixedWeeks for a stable
6-week grid.

* Share one themed Calendar between the date mention and API key pickers

Extract the custom react-day-picker styling into a reusable Calendar
component and use it in both the date mention picker and the API key
expiry picker, so they look identical. The calendar owns its own padding
and the API key scene no longer needs the library's base stylesheet.

* Make the ISO date the single source of truth for date mentions

Date mentions no longer persist a human-readable label in the ProseMirror
data. Instead the displayed text, plaintext, DOM text and markdown link
text are all derived from the ISO modelId, so the saved data can never
drift or go stale. parseDOM/parseMarkdown no longer capture rendered text
as a label for dates, and the mention menu/picker stop writing one.

* tweaks

* Make DynamicCalendarIcon day text contrast with its fill

The day number is rendered white with mix-blend-mode difference, which
produces the exact inverse of the icon's (currentColor) fill, so it stays
legible whatever colour the icon takes. The SVG is isolated so the blend
only considers the icon's own fill.

* Lazy-load the date picker to keep Radix out of the editor schema graph

Importing @radix-ui/react-popover and react-day-picker at the top of
Mentions.tsx pulled them into the editor schema's static import graph,
which is also loaded on the server. Radix's prebuilt ESM does a bare
"react/jsx-runtime" import that the node/shared test resolvers can't
resolve, breaking all server and shared editor test suites.

Move the popover + calendar into DateMentionPicker, loaded via
React.lazy, so the browser-only dependencies are code-split out of the
schema graph and only fetched when an editable date mention renders.

* Deprecate block menu date/time commands in favor of date mention

Replace the block menu "Current date" entry so it inserts a date mention
for today instead of a static string/template token, and remove the
"Current time" and "Current date and time" entries. The underlying
DateTime extension and its {date}/{time}/{datetime} template placeholders
are left intact so existing documents and templates keep working.

* Omit the year from dateToReadable within the current year

dateToReadable now formats current-year dates without the year (e.g.
"June 8th") and includes it otherwise (e.g. "February 3rd, 2024"). This
keeps the mention menu subtitle compact while the relative title shows
"Today"/"Tomorrow".

* Let date mentions inherit surrounding font weight

The .mention style fixes font-weight to 500, which prevented a date
mention placed inside a heading from rendering bold like the rest of the
heading. Date mentions are plain text, so they now inherit the font
weight of their context.

* Address review feedback on date mentions

- MentionMenu: catch rejected date-parse promises so a chunk-load failure
  clears the suggestion instead of leaving stale state / an unhandled rejection.
- parseNaturalLanguageDate: don't cache a rejected chrono import so a later
  parse can retry after a transient failure.
- parseISODate: reject strings with a time component to honor the date-only
  contract and keep day-granular comparisons correct.
- DynamicCalendarIcon: mark the decorative SVG aria-hidden / focusable=false.

* tweaks

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-06-12 21:42:16 -04:00
committed by GitHub
parent 394c6e3b03
commit bda95e4952
20 changed files with 846 additions and 67 deletions
+4
View File
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DateSection = ({ t }: ActionContext) => t("Date");
DateSection.priority = 1;
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
+75 -3
View File
@@ -1,6 +1,7 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { runInAction } from "mobx";
import {
DocumentIcon,
PlusIcon,
@@ -14,11 +15,20 @@ import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import {
dateToReadable,
dateToRelativeReadable,
parseISODate,
toISODate,
} from "@shared/utils/date";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
import Flex from "~/components/Flex";
import {
DateSection,
DocumentsSection,
UserSection,
CollectionsSection,
@@ -26,18 +36,20 @@ import {
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { client } from "~/utils/ApiClient";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
id: string;
type: MentionType;
modelId: string;
label: string;
// Date mentions intentionally omit a label — their text is derived from
// the ISO `modelId` so nothing human-readable is persisted.
label?: string;
actorId?: string;
};
}
@@ -54,8 +66,65 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const userLocale = useUserLocale();
const maxResultsInSection = search ? 25 : 5;
// Surface a date suggestion when the search query parses as a natural
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
// asynchronous as chrono-node is loaded lazily, so the result is held in
// state and applied once resolved.
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
useEffect(() => {
if (!search) {
setParsedISODate(undefined);
return;
}
let cancelled = false;
void parseNaturalLanguageDate(search)
.then((date) => {
if (!cancelled) {
setParsedISODate(date ? toISODate(date) : undefined);
}
})
.catch(() => {
// Parsing failed (e.g. the chrono chunk failed to load); drop the
// suggestion rather than leaving a stale one.
if (!cancelled) {
setParsedISODate(undefined);
}
});
return () => {
cancelled = true;
};
}, [search]);
let dateItems: MentionItem[] = [];
if (actorId && parsedISODate) {
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
const subtitle = dateToReadable(parsedISODate, userLocale);
dateItems = [
{
name: "mention",
icon: (
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
),
title,
subtitle: title !== subtitle ? subtitle : undefined,
section: DateSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Date,
modelId: parsedISODate,
actorId,
},
} as MentionItem,
];
}
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", {
@@ -87,7 +156,7 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] = actorId
const mentionItems: MentionItem[] = actorId
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
@@ -253,9 +322,12 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
])
: [];
const items: MentionItem[] = [...dateItems, ...mentionItems];
const handleSelect = useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Date ||
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
+13 -15
View File
@@ -17,7 +17,6 @@ import {
WarningIcon,
InfoIcon,
AttachmentIcon,
ClockIcon,
CalendarIcon,
MathIcon,
DoneIcon,
@@ -26,9 +25,12 @@ import {
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { v4 as uuidv4 } from "uuid";
import type { TFunction } from "i18next";
import Image from "@shared/editor/components/Img";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { toISODate } from "@shared/utils/date";
import { metaDisplay } from "@shared/utils/keyboard";
import Desktop from "~/utils/Desktop";
@@ -184,22 +186,18 @@ export default function blockMenuItems(
attrs: { markup: "***" },
},
{
name: "date",
// Inserts a date mention for today. Supersedes the deprecated "Current
// date/time" commands that inserted a static string or template token.
name: "mention",
title: t("Current date"),
keywords: "clock today",
icon: <CalendarIcon />,
},
{
name: "time",
title: t("Current time"),
keywords: "clock now",
icon: <ClockIcon />,
},
{
name: "datetime",
title: t("Current date and time"),
keywords: "clock today date",
keywords: "clock today date time now",
icon: <CalendarIcon />,
appendSpace: true,
attrs: () => ({
id: uuidv4(),
type: MentionType.Date,
modelId: toISODate(new Date()),
}),
},
{
name: "separator",
@@ -1,9 +1,8 @@
import { format as formatDate } from "date-fns";
import { CalendarIcon } from "outline-icons";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import { Calendar } from "@shared/components/Calendar";
import { dateLocale } from "@shared/utils/date";
import Button from "~/components/Button";
import {
@@ -21,25 +20,10 @@ type Props = {
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const theme = useTheme();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const styles = React.useMemo(
() =>
({
"--rdp-caption-font-size": "16px",
"--rdp-cell-size": "34px",
"--rdp-selected-text": theme.accentText,
"--rdp-accent-color": theme.accent,
"--rdp-accent-color-dark": theme.accent,
"--rdp-background-color": theme.listItemHoverBackground,
"--rdp-background-color-dark": theme.listItemHoverBackground,
}) as React.CSSProperties,
[theme]
);
const handleSelect = React.useCallback(
(date: Date) => {
setOpen(false);
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledPopoverButton icon={<Icon />} neutral>
<StyledPopoverButton neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
side="right"
shrink
>
<DayPicker
<Calendar
required
mode="single"
selected={selectedDate}
onSelect={handleSelect}
style={styles}
locale={locale}
disabled={{ before: new Date() }}
/>
</PopoverContent>
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
);
};
const Icon = () => (
<IconWrapper>
<CalendarIcon />
</IconWrapper>
);
const StyledPopoverButton = styled(Button)`
margin-top: 12px;
width: 150px;
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
`;
export default ExpiryDatePicker;
+1 -2
View File
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { dateToExpiry } from "~/utils/date";
import "react-day-picker/dist/style.css";
import ExpiryDatePicker from "./components/ExpiryDatePicker";
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
)}
.
</Text>
<Flex align="center" gap={16}>
<Flex align="center" gap={8}>
<StyledExpirySelect
options={expiryOptions}
value={expiryType}
+1
View File
@@ -115,6 +115,7 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"bull": "^4.16.5",
"chrono-node": "^2.9.1",
"class-validator": "^0.15.1",
"command-score": "^0.1.2",
"compressorjs": "^1.3.0",
+167
View File
@@ -0,0 +1,167 @@
import * as React from "react";
import { DayPicker } from "react-day-picker";
import styled from "styled-components";
import { s } from "../styles";
type Props = React.ComponentProps<typeof DayPicker>;
/**
* A themed calendar built on react-day-picker. It is styled from scratch (the
* library's base stylesheet is intentionally not relied upon) so that it looks
* consistent everywhere it is used. Outside (previous/next month) days are
* shown de-emphasised, the selected day is a solid accent-filled circle, and
* today is highlighted with the accent colour.
*
* @param props the underlying react-day-picker props; `showOutsideDays` and
* `fixedWeeks` default to true but may be overridden.
* @returns the rendered calendar.
*/
export function Calendar(props: Props) {
return (
<Wrapper>
<DayPicker showOutsideDays fixedWeeks {...props} />
</Wrapper>
);
}
const Wrapper = styled.div`
padding: 12px;
color: ${s("text")};
.rdp {
margin: 0;
}
/* Visually-hidden accessibility labels (would otherwise show without the
base stylesheet). */
.rdp-vhidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
appearance: none;
}
.rdp-month {
width: 100%;
}
.rdp-caption {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px 8px;
}
.rdp-caption_label {
font-size: 14px;
font-weight: 600;
color: ${s("text")};
}
.rdp-nav {
display: flex;
gap: 2px;
}
.rdp-nav_button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: none;
border-radius: 4px;
color: ${s("textSecondary")};
cursor: pointer;
transition: background 100ms ease;
&:hover {
background: ${s("listItemHoverBackground")};
}
}
.rdp-nav_icon {
width: 16px;
height: 16px;
}
.rdp-table {
border-collapse: collapse;
width: 100%;
}
.rdp-head_cell {
font-size: 12px;
font-weight: 500;
text-transform: none;
color: ${s("textTertiary")};
padding: 4px 0;
text-align: center;
}
.rdp-cell {
padding: 1px;
text-align: center;
}
.rdp-day {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 0;
background: none;
border-radius: 50%;
font-family: inherit;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: ${s("text")};
cursor: pointer;
transition: background 100ms ease;
&:hover:not([disabled]):not(.rdp-day_selected) {
background: ${s("listItemHoverBackground")};
}
&:focus-visible:not([disabled]) {
outline: 2px solid ${s("accent")};
outline-offset: -2px;
}
}
/* Today, when not selected, is emphasised with the accent colour. */
.rdp-day_today:not(.rdp-day_selected) {
font-weight: 700;
color: ${s("accent")};
}
/* Days belonging to the previous/next month are clearly de-emphasised. */
.rdp-day_outside {
color: ${s("textTertiary")};
opacity: 0.5;
}
.rdp-day[disabled] {
color: ${s("textTertiary")};
opacity: 0.4;
cursor: default;
}
/* The selected day is a solid accent-filled circle. */
.rdp-day_selected,
.rdp-day_selected:hover,
.rdp-day_selected:focus-visible {
background: ${s("accent")};
color: ${s("accentText")};
font-weight: 500;
opacity: 1;
}
`;
+47
View File
@@ -0,0 +1,47 @@
import { useTheme } from "styled-components";
type Props = { day?: number; className?: string };
export function DynamicCalendarIcon({ day, className }: Props) {
const theme = useTheme();
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
// Decorative icon: hide from assistive tech so the day digit isn't
// announced out of context.
aria-hidden
focusable={false}
// Isolate so the day text only blends against the icon's own fill below
// it, not whatever is behind the icon on the page.
style={{ isolation: "isolate" }}
>
<path
d="M10 5.01953C10.3319 5.00624 10.6846 5 11.0596 5H12.9404C13.3154 5 13.6681 5.00624 14 5.01953V4H16V5.24609C18.3996 5.78241 19 7.32118 19 11.0596V12.9404C19 17.9302 17.9302 19 12.9404 19H11.0596C6.06982 19 5 17.9302 5 12.9404V11.0596C5 7.32118 5.60035 5.78241 8 5.24609V4H10V5.01953Z"
fill="currentColor"
/>
<text
// White blended with "difference" against the fill below produces the
// exact inverse of the fill colour, so the day is always legible
// regardless of the icon's (currentColor) fill.
fill="white"
style={{ mixBlendMode: "difference" }}
fontFamily={theme.fontFamily}
fontSize="8"
fontWeight="600"
textAnchor="middle"
dominantBaseline="middle"
letterSpacing="0em"
>
<tspan x="12" y="13.5">
{day}
</tspan>
</text>
</svg>
);
}
@@ -0,0 +1,100 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { RemoveScroll } from "react-remove-scroll";
import styled from "styled-components";
import { Calendar } from "../../components/Calendar";
import { depths, s } from "../../styles";
import { dateLocale, toISODate } from "../../utils/date";
type Props = {
/** The currently selected date, if any. */
selectedDate?: Date;
/** The user's language, used to localise the calendar. */
language?: Parameters<typeof dateLocale>[0];
/** Called with the new date-only ISO string when a day is picked. */
onChange: (modelId: string) => void;
/** The trigger element the calendar popover is anchored to. */
children: React.ReactNode;
};
/**
* The interactive calendar popover for a date mention. It lives in its own
* module so that its browser-only dependencies (Radix, react-day-picker) are
* loaded lazily and stay out of the editor schema graph, which is also imported
* on the server.
*
* @returns the popover wrapping the provided trigger.
*/
export default function DateMentionPicker({
selectedDate,
language,
onChange,
children,
}: Props) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const handleSelect = React.useCallback(
(date: Date) => {
setOpen(false);
onChange(toISODate(date));
},
[onChange]
);
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger
asChild
onMouseDown={(e) => e.stopPropagation()}
>
{children}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
asChild
sideOffset={4}
align="start"
aria-label={t("Choose a date")}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<RemoveScroll as={Slot} allowPinchZoom>
<DatePopoverContent>
<Calendar
required
mode="single"
selected={selectedDate}
defaultMonth={selectedDate}
onSelect={handleSelect}
locale={dateLocale(language)}
/>
</DatePopoverContent>
</RemoveScroll>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
const DatePopoverContent = styled.div`
z-index: ${depths.modal};
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 8px;
outline: none;
&[data-state="open"] {
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
+55
View File
@@ -10,6 +10,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { dateToRelativeReadable, parseISODate } from "../../utils/date";
import { Backticks } from "../../components/Backticks";
import Flex from "../../components/Flex";
import Icon from "../../components/Icon";
@@ -510,6 +511,55 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
);
});
type DateProps = ComponentProps & {
onChangeDate: (modelId: string) => void;
};
// Loaded lazily so its browser-only dependencies (Radix, react-day-picker)
// don't enter the editor schema's static import graph, which is also used on
// the server.
const DateMentionPicker = React.lazy(() => import("./DateMentionPicker"));
export const MentionDate = observer(function MentionDate_(props: DateProps) {
const { isSelected, isEditable, node, onChangeDate } = props;
const { t } = useTranslation();
const { auth } = useStores();
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
const language = auth.user?.language;
const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : "";
const display = dateToRelativeReadable(iso, t, language);
const selectedDate = parseISODate(iso) ?? undefined;
const content = (
<DateMention
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
$editable={isEditable}
>
{display}
</DateMention>
);
if (!isEditable) {
return content;
}
return (
<React.Suspense fallback={content}>
<DateMentionPicker
selectedDate={selectedDate}
language={language}
onChange={onChangeDate}
>
{content}
</DateMentionPicker>
</React.Suspense>
);
});
const MentionLoading = ({ className }: { className: string }) => {
const { t } = useTranslation();
@@ -532,6 +582,11 @@ const MentionError = ({ className }: { className: string }) => {
);
};
const DateMention = styled.span<{ $editable: boolean }>`
cursor: ${(props) => (props.$editable ? "pointer" : "default")};
user-select: none;
`;
const StyledWarningIcon = styled(WarningIcon)`
margin: 0 -2px;
`;
+6
View File
@@ -570,6 +570,12 @@ width: 100%;
gap: 0;
}
/* Date mentions are plain text, so they inherit the surrounding font weight
(e.g. bold when placed inside a heading). */
&[data-type="date"] {
font-weight: inherit;
}
&.mention-user::before {
content: "@";
}
+58 -9
View File
@@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import type { UnfurlResponse } from "../../types";
import { MentionType, UnfurlResourceType } from "../../types";
import { dateToReadable } from "../../utils/date";
import {
MentionCollection,
MentionDocument,
@@ -21,6 +22,7 @@ import {
MentionIssue,
MentionProject,
MentionPullRequest,
MentionDate,
MentionURL,
MentionUser,
} from "../components/Mentions";
@@ -39,17 +41,25 @@ export default class Mention extends Node {
}
get schema(): NodeSpec {
const toPlainText = (node: ProsemirrorNode) =>
node.attrs.type === MentionType.User
// Date mentions derive their text from the ISO `modelId`, which is the
// single source of truth — no human-readable label is persisted for them.
const toPlainText = (node: ProsemirrorNode) => {
if (node.attrs.type === MentionType.Date) {
return dateToReadable(node.attrs.modelId);
}
return node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label;
};
return {
attrs: {
type: {
default: MentionType.User,
},
label: {},
label: {
default: undefined,
},
modelId: {},
actorId: {
default: undefined,
@@ -84,7 +94,9 @@ export default class Mention extends Node {
type,
modelId,
actorId: dom.dataset.actorid,
label: dom.innerText,
// Date mentions derive their text from `modelId`; never capture
// the rendered text as a persisted label.
label: type === MentionType.Date ? undefined : dom.innerText,
id: dom.id,
href: dom.getAttribute("href"),
unfurl: dom.dataset.unfurl
@@ -95,12 +107,21 @@ export default class Mention extends Node {
},
],
toDOM: (node) => [
node.attrs.type === MentionType.User ? "span" : "a",
node.attrs.type === MentionType.User ||
node.attrs.type === MentionType.Date
? "span"
: "a",
{
class: `${node.type.name} use-hover-preview`,
// Date mentions are self-contained and have nothing to unfurl, so
// they opt out of the hover preview behaviour.
class:
node.attrs.type === MentionType.Date
? node.type.name
: `${node.type.name} use-hover-preview`,
id: node.attrs.id,
href:
node.attrs.type === MentionType.User
node.attrs.type === MentionType.User ||
node.attrs.type === MentionType.Date
? undefined
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
@@ -162,6 +183,10 @@ export default class Mention extends Node {
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.Date:
return (
<MentionDate {...props} onChangeDate={this.handleChangeDate(props)} />
);
default:
return null;
}
@@ -315,7 +340,10 @@ export default class Mention extends Node {
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const mType = node.attrs.type;
const mId = node.attrs.modelId;
const label = node.attrs.label;
// Date mentions have no stored label; the readable text is derived from
// the ISO `modelId` so it can never drift from the source of truth.
const label =
mType === MentionType.Date ? dateToReadable(mId) : node.attrs.label;
const id = node.attrs.id;
// Use regular links for document and collection mentions
@@ -336,11 +364,32 @@ export default class Mention extends Node {
id: tok.attrGet("id"),
type: tok.attrGet("type"),
modelId: tok.attrGet("modelId"),
label: tok.content,
// Date mentions derive their text from `modelId`; the link text is not
// persisted as a label.
label:
tok.attrGet("type") === MentionType.Date ? undefined : tok.content,
}),
};
}
handleChangeDate =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(modelId: string) => {
const { view } = this.editor;
const { tr } = view.state;
const pos = getPos();
if (node.attrs.modelId === modelId) {
return;
}
const transaction = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
modelId,
});
view.dispatch(transaction);
};
handleChangeUnfurl =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
+15
View File
@@ -98,6 +98,21 @@ describe("mention rule", () => {
});
});
describe("date format", () => {
it("should parse a date mention with an ISO date modelId", () => {
const result = md.parse(
"@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)",
{}
);
const mentions = findMentionTokens(result);
expect(mentions).toHaveLength(1);
expect(mentions[0].type).toBe("date");
expect(mentions[0].modelId).toBe("2024-02-03");
expect(mentions[0].label).toBe("February 3rd, 2024");
});
});
describe("mixed content", () => {
it("should parse mention within text", () => {
const result = md.parse(
+3 -3
View File
@@ -188,6 +188,7 @@
"Collection": "Collection",
"Collections": "Collections",
"Debug": "Debug",
"Date": "Date",
"Document": "Document",
"Search results": "Search results",
"Documents": "Documents",
@@ -616,8 +617,6 @@
"Divider": "Divider",
"Page break": "Page break",
"Current date": "Current date",
"Current time": "Current time",
"Current date and time": "Current date and time",
"Info notice": "Info notice",
"Success notice": "Success notice",
"Warning notice": "Warning notice",
@@ -1781,5 +1780,6 @@
"Hide completed": "Hide completed",
"Write a caption": "Write a caption",
"Add title": "Add title",
"Add content": "Add content"
"Add content": "Add content",
"Tomorrow": "Tomorrow"
}
+1
View File
@@ -121,6 +121,7 @@ export enum MentionType {
PullRequest = "pull_request",
Project = "project",
URL = "url",
Date = "date",
}
export type PublicEnv = {
+80
View File
@@ -0,0 +1,80 @@
import {
dateToReadable,
dateToRelativeReadable,
parseISODate,
toISODate,
} from "./date";
describe("toISODate / parseISODate", () => {
it("round-trips a date through its ISO representation", () => {
const date = new Date(2024, 1, 3); // Feb 3, 2024
const iso = toISODate(date);
expect(iso).toBe("2024-02-03");
expect(parseISODate(iso)).toEqual(date);
});
it("returns null for an invalid ISO string", () => {
expect(parseISODate("not-a-date")).toBeNull();
});
it("rejects strings carrying a time component", () => {
expect(parseISODate("2024-02-03T10:00:00Z")).toBeNull();
});
it("parses a date-only string to local midnight", () => {
const date = parseISODate("2024-02-03");
expect(date?.getHours()).toBe(0);
expect(date?.getMinutes()).toBe(0);
});
});
describe("dateToReadable", () => {
it("includes the year outside the current year", () => {
expect(dateToReadable("2020-02-03")).toBe("February 3rd, 2020");
});
it("omits the year within the current year", () => {
const date = new Date();
date.setMonth(date.getMonth() === 0 ? 6 : 0);
date.setDate(15);
const result = dateToReadable(toISODate(date));
expect(result).not.toContain(`${date.getFullYear()}`);
});
it("returns the original string when invalid", () => {
expect(dateToReadable("nonsense")).toBe("nonsense");
});
});
describe("dateToRelativeReadable", () => {
const t = (key: string) => key;
it("returns Today for the current date", () => {
const iso = toISODate(new Date());
expect(dateToRelativeReadable(iso, t)).toBe("Today");
});
it("returns Tomorrow for the next day", () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
expect(dateToRelativeReadable(toISODate(tomorrow), t)).toBe("Tomorrow");
});
it("returns Yesterday for the previous day", () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(dateToRelativeReadable(toISODate(yesterday), t)).toBe("Yesterday");
});
it("omits the year within the current year", () => {
const date = new Date();
date.setMonth(date.getMonth() === 0 ? 6 : 0); // a different month, same year
date.setDate(15);
const result = dateToRelativeReadable(toISODate(date), t);
expect(result).not.toContain(`${date.getFullYear()}`);
});
it("includes the year for a date in a different year", () => {
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
});
});
+97
View File
@@ -2,7 +2,13 @@
import type { Locale } from "date-fns";
import {
addSeconds,
format,
formatDistanceToNow,
isSameYear,
isToday,
isTomorrow,
isYesterday,
parseISO,
subDays,
subMonths,
subWeeks,
@@ -297,3 +303,94 @@ export function dateLocale(language: keyof typeof locales | undefined | null) {
}
export { locales };
/**
* Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local
* timezone. Used as the stored value for date mentions.
*
* @param date The date to format.
* @returns the date-only ISO string.
*/
export function toISODate(date: Date): string {
return format(date, "yyyy-MM-dd");
}
/**
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
* Strings carrying a time component are rejected so the date-only contract
* (and the day-granular comparisons that depend on it) cannot be violated.
*
* @param iso The date-only ISO string.
* @returns the parsed Date at local midnight, or null when the string is not a
* valid date-only value.
*/
export function parseISODate(iso: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
return null;
}
const date = parseISO(iso);
return isValid(date) ? date : null;
}
/**
* Formats a date mention's stored ISO value into an absolute, localized,
* human-readable label. The year is omitted within the current year (e.g.
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). Suitable
* for plaintext and markdown serialization.
*
* @param iso The date-only ISO string.
* @param language The user's language preference.
* @returns the absolute human-readable date, or the original string when invalid.
*/
export function dateToReadable(
iso: string,
language?: keyof typeof locales | null
): string {
const date = parseISODate(iso);
if (!date) {
return iso;
}
const locale = dateLocale(language);
if (isSameYear(date, new Date())) {
return format(date, "MMMM do", { locale });
}
return format(date, "MMMM do, yyyy", { locale });
}
/**
* Formats a date mention's stored ISO value into a relative, localized,
* human-readable label with increasing granularity. Returns "Today",
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
* current year, and "February 3rd, 2024" otherwise.
*
* @param iso The date-only ISO string.
* @param t The translation function.
* @param language The user's language preference.
* @returns the relative human-readable date, or the original string when invalid.
*/
export function dateToRelativeReadable(
iso: string,
t: (key: string) => string,
language?: keyof typeof locales | null
): string {
const date = parseISODate(iso);
if (!date) {
return iso;
}
if (isToday(date)) {
return t("Today");
}
if (isTomorrow(date)) {
return t("Tomorrow");
}
if (isYesterday(date)) {
return t("Yesterday");
}
const locale = dateLocale(language);
if (isSameYear(date, new Date())) {
return format(date, "MMMM do", { locale });
}
return format(date, "MMMM do, yyyy", { locale });
}
@@ -0,0 +1,45 @@
import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate";
describe("parseNaturalLanguageDate", () => {
const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024
it("returns null for empty input", async () => {
expect(await parseNaturalLanguageDate("", reference)).toBeNull();
expect(await parseNaturalLanguageDate(" ", reference)).toBeNull();
});
it("returns null for non-date input", async () => {
expect(await parseNaturalLanguageDate("hello world", reference)).toBeNull();
});
it("parses 'today'", async () => {
const result = await parseNaturalLanguageDate("today", reference);
expect(result).toEqual(new Date(2024, 0, 1));
});
it("parses 'tomorrow'", async () => {
const result = await parseNaturalLanguageDate("tomorrow", reference);
expect(result).toEqual(new Date(2024, 0, 2));
});
it("parses 'yesterday'", async () => {
const result = await parseNaturalLanguageDate("yesterday", reference);
expect(result).toEqual(new Date(2023, 11, 31));
});
it("parses 'in 3 days'", async () => {
const result = await parseNaturalLanguageDate("in 3 days", reference);
expect(result).toEqual(new Date(2024, 0, 4));
});
it("parses an explicit month and day", async () => {
const result = await parseNaturalLanguageDate("February 3", reference);
expect(result).toEqual(new Date(2024, 1, 3));
});
it("normalizes the time component to local midnight", async () => {
const result = await parseNaturalLanguageDate("tomorrow at 5pm", reference);
expect(result?.getHours()).toBe(0);
expect(result?.getMinutes()).toBe(0);
});
});
+65
View File
@@ -0,0 +1,65 @@
// Type-only import is fully erased at compile time, so it does not pull
// chrono-node into the bundle.
import type * as Chrono from "chrono-node";
/**
* chrono-node is a sizeable dependency, so it is loaded lazily on first use
* via a dynamic import. The bundler splits it into its own chunk that is only
* fetched when a date actually needs to be parsed (i.e. when the user types in
* the mention menu), keeping it out of the main bundle.
*/
let chronoPromise: Promise<typeof Chrono> | undefined;
function loadChrono(): Promise<typeof Chrono> {
if (!chronoPromise) {
chronoPromise = import("chrono-node").catch((err) => {
// Don't cache a rejected import (e.g. a transient chunk-load failure),
// otherwise every subsequent parse would reuse the failure. Clearing it
// lets the next call retry.
chronoPromise = undefined;
throw err;
});
}
return chronoPromise;
}
/**
* Parse a natural language string such as "tomorrow", "next friday",
* "jan 2" or "in 3 days" into a calendar date.
*
* The time component is intentionally discarded as date mentions are
* day-granular; only the year, month and day of the matched date are
* returned. chrono-node is loaded asynchronously the first time this is
* called.
*
* @param input the natural language string to parse.
* @param referenceDate the date relative to which terms like "tomorrow"
* are resolved, defaults to now.
* @returns a promise resolving to the matched date with the time set to
* local midnight, or null when no date could be confidently parsed.
*/
export async function parseNaturalLanguageDate(
input: string,
referenceDate: Date = new Date()
): Promise<Date | null> {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const chrono = await loadChrono();
const results = chrono.parse(trimmed, referenceDate, { forwardDate: true });
const result = results[0];
if (!result) {
return null;
}
// Only accept matches that span (roughly) the whole input so that
// unrelated text typed after "@" does not accidentally resolve to a date.
if (result.text.trim().length < trimmed.length) {
return null;
}
const date = result.start.date();
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
+8
View File
@@ -8682,6 +8682,13 @@ __metadata:
languageName: node
linkType: hard
"chrono-node@npm:^2.9.1":
version: 2.9.1
resolution: "chrono-node@npm:2.9.1"
checksum: 10c0/2408e4a404ea3e7e8226daae75cdbc4621bc66065d596574440d0f81a7c456c166ca212c9e1c329e653c716300289498794fece2102f9365bfd33bd1a4b24473
languageName: node
linkType: hard
"ci-info@npm:^3.7.0":
version: 3.9.0
resolution: "ci-info@npm:3.9.0"
@@ -15178,6 +15185,7 @@ __metadata:
babel-plugin-transform-typescript-metadata: "npm:^0.4.0"
browserslist-to-esbuild: "npm:^1.2.0"
bull: "npm:^4.16.5"
chrono-node: "npm:^2.9.1"
class-validator: "npm:^0.15.1"
command-score: "npm:^0.1.2"
compressorjs: "npm:^1.3.0"