Compare commits

...

12 Commits

Author SHA1 Message Date
eliottreich cdd08bbf58 ci: pin third-party GitHub Actions to commit SHAs (#12662)
Pins all 12 third-party action references currently on mutable tags
to the commit each tag resolves to, across 4 workflow files, keeping
a # tag comment. Ref-only, no behavior change.
2026-06-12 22:01:26 -04:00
Tom Moor bda95e4952 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>
2026-06-12 21:42:16 -04:00
Tom Moor 394c6e3b03 fix: Duplicate paths in export ZIP (#12674) 2026-06-12 20:04:18 -04:00
Tom Moor 9113501906 Add PROXY_HEADERS_TRUSTED env (#12676)
* Add PROXY_HEADERS_TRUSTED env

* Don't trust X-Forwarded-Proto for HTTPS redirect when proxy headers untrusted

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:58:16 -04:00
Tom Moor 92168c3641 fix: Toggling a nested list no longer converts parent lists (#12670)
* fix: Toggling a nested list no longer converts parent lists

When the selection was inside a nested list, toggling the list type from
the toolbar or keyboard shortcut converted every list in the tree,
including ancestors of the selected list. This was caused by
doc.nodesBetween visiting ancestor nodes whose range overlaps the
selected list - these are now skipped so only the closest list and its
children are converted. Also guards against converting nested lists with
incompatible content such as checkbox lists.

Closes #12653

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

* test: Throw when selection text is not found in toggleList test helper

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-12 19:37:38 -04:00
Tom Moor 5ea63aa1a2 fix: Editor math block parsing and NaN media dimensions (#12668)
* fix: Block math not closed by trailing $$ on a content line

The closing delimiter check compared a 3-character slice against the
2-character "$$" delimiter, so block math closed on the same line as
content (e.g. "c = d$$") was never detected and the block swallowed the
rest of the document. Use the delimiter length rather than a hardcoded
slice. Also fix the indexOf sentinel comparison (!== 1 instead of
!== -1) in inline math parsing, which terminated correctly only by
coincidence.

Adds tests for the math markdown rules and moves the findNodes test
helper into shared/test/editor for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: NaN width and height parsed for video and image nodes

Video parseDOM and parseMarkdown used parseInt on a missing attribute,
storing NaN instead of null and persisting it to markdown as NaNxNaN.
Image size syntax with a missing dimension (e.g. "=x100") hit the same
issue through optional regex groups. Parse dimensions only when
present, matching the existing guard in Image parseDOM, and correct the
video getAttrs element type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Normalize non-numeric video dimensions, avoid serializing nullxnull

Review feedback: parseInt could still produce NaN when the attribute
exists but is not numeric (e.g. width="auto"), and toMarkdown wrote
null dimensions as "nullxnull". Parse dimensions through a helper that
normalizes non-finite values to null, and serialize nullish dimensions
as empty strings, which still round-trips as a video node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:29:29 -04:00
Tom Moor b1bf7c488b chore: Drop dead collaborativeEditing column from teams (#12669)
The collaborativeEditing toggle has been unused since collaborative
editing became always-on. The column is no longer defined in the Team
model nor referenced anywhere in the codebase, so this drops it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:08 -04:00
Tom Moor 9811ab6aea feat: Emoji reaction shorthand (#12650)
* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 21:51:11 -04:00
Tom Moor f0899f614b fix: Improve markdown serialization speed (#12667) 2026-06-11 21:50:47 -04:00
Tom Moor c65b020655 fix: Reject collections.update requests that include both description and data (#12648) 2026-06-11 21:30:47 -04:00
Tom Moor 9791ff1170 fix: Prevent selecting word-joiner characters around multiplayer cursor (#12660)
* Possible fix for word-joiner characters copied on Chrome+Windows

* simplify
2026-06-11 09:04:38 -04:00
dependabot[bot] a25f334bb1 chore(deps): bump shell-quote from 1.8.3 to 1.8.4 (#12659)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 17:50:41 -04:00
49 changed files with 1624 additions and 222 deletions
+5
View File
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
# and "X-Client-IP".
# PROXY_IP_HEADER=
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
# running behind a proxy in production.
# PROXY_HEADERS_TRUSTED=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v5
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
@@ -54,7 +54,7 @@ jobs:
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
id: filter
with:
filters: |
@@ -126,7 +126,7 @@ jobs:
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
+7 -7
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -38,7 +38,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -60,7 +60,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -93,7 +93,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -113,7 +113,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -135,7 +135,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -182,7 +182,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker meta
id: meta
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
+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
) {
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { Second } from "@shared/utils/time";
type UserAwareness = {
@@ -107,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
return {
style: `background-color: ${u.color}${opacity}`,
class: "ProseMirror-yjs-selection",
class: EditorStyleHelper.multiplayerSelection,
};
};
+1 -1
View File
@@ -46,7 +46,7 @@ export default class Suggestion<
: `(?:${triggers.map(escapeRegExp).join("|")})`;
this.openRegex = new RegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
+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}
@@ -8,6 +8,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
import type { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
@@ -157,6 +158,30 @@ function CommentForm({
return;
}
// "+:emoji:" shorthand: react to the comment above instead of replying.
if (thread && !thread.isNew) {
const emoji = parseReactionShorthand(draft);
if (emoji) {
const target = comments
.inThread(thread.id)
.filter((comment) => !comment.isNew)
.pop();
if (target) {
onSaveDraft(undefined);
setForceRender((s) => ++s);
void target.addReaction({ emoji, user });
onSubmit?.();
// re-focus the comment editor
setTimeout(() => {
editorRef.current?.focusAtStart();
}, 0);
return;
}
}
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
+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",
+10
View File
@@ -372,6 +372,16 @@ export class Environment {
@IsOptional()
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
/**
* Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
* X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
* true for backwards compat. Set to false if not running behind a proxy in production.
*/
@IsBoolean()
public PROXY_HEADERS_TRUSTED = this.toBoolean(
environment.PROXY_HEADERS_TRUSTED ?? "true"
);
/**
* Should the installation send anonymized statistics to the maintainers.
* Defaults to true.
@@ -0,0 +1,14 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.removeColumn("teams", "collaborativeEditing");
},
async down(queryInterface, Sequelize) {
await queryInterface.addColumn("teams", "collaborativeEditing", {
type: Sequelize.BOOLEAN,
});
},
};
+47 -31
View File
@@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
includeAttachments = true
) {
const pathMap = this.createPathMap(collections, format);
Logger.debug(
"task",
`Start adding ${Object.values(pathMap).length} documents to archive`
);
for (const path of pathMap) {
const documentId = path[0].replace("/doc/", "");
const pathInZip = path[1];
await this.processDocument({
zip,
pathInZip,
documentId,
includeAttachments,
format,
pathMap,
});
}
Logger.debug("task", "Completed adding documents to archive");
await this.addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments,
});
return await ZipHelper.toTmpFile(zip);
}
@@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
format
);
Logger.debug(
"task",
`Start adding ${Object.values(pathMap).length} documents to archive`
);
await this.addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments: true,
});
for (const entry of pathMap) {
const documentId = entry[0].replace("/doc/", "");
const pathInZip = entry[1];
return await ZipHelper.toTmpFile(zip);
}
/**
* Processes each unique document in the path map and adds it to the zip.
*
* @param zip The yazl ZipFile to add files to
* @param pathMap Map of document urls to their path in the zip
* @param format The format to export in
* @param includeAttachments Whether to include attachments in the export
*/
private async addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments,
}: {
zip: ZipFile;
pathMap: Map<string, string>;
format: FileOperationFormat;
includeAttachments: boolean;
}) {
const processedPaths = new Set<string>();
Logger.debug("task", `Start adding documents to archive`);
for (const [url, pathInZip] of pathMap) {
// A document may be keyed by multiple urls in the path map, only
// process each file in the zip once.
if (processedPaths.has(pathInZip)) {
continue;
}
processedPaths.add(pathInZip);
await this.processDocument({
zip,
pathInZip,
documentId,
includeAttachments: true,
documentId: url.replace("/doc/", ""),
includeAttachments,
format,
pathMap,
});
}
Logger.debug("task", "Completed adding documents to archive");
return await ZipHelper.toTmpFile(zip);
}
/**
@@ -0,0 +1,61 @@
import fs from "fs-extra";
import ZipHelper from "@server/utils/ZipHelper";
import {
buildCollection,
buildDocument,
buildFileOperation,
buildTeam,
buildUser,
} from "@server/test/factories";
import ExportMarkdownZipTask from "./ExportMarkdownZipTask";
describe("ExportMarkdownZipTask", () => {
it("should not duplicate documents in the zip file", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
createdById: user.id,
});
const documents = await Promise.all([
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "Test1",
}),
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "Test2",
}),
]);
for (const document of documents) {
await collection.addDocumentToStructure(document);
}
const fileOperation = await buildFileOperation({
teamId: team.id,
userId: user.id,
});
const task = new ExportMarkdownZipTask();
const filePath = await task.exportCollections([collection], fileOperation);
try {
const fileNames: string[] = [];
await ZipHelper.walk(filePath, (entry) => {
if (!entry.isDirectory) {
fileNames.push(entry.fileName);
}
});
expect(fileNames.sort()).toEqual([
`${collection.name}/Test1.md`,
`${collection.name}/Test2.md`,
]);
} finally {
await fs.remove(filePath);
}
});
});
@@ -1257,6 +1257,23 @@ describe("#collections.create", () => {
expect(res.status).toEqual(400);
});
it("rejects providing both description and data", async () => {
const user = await buildUser();
const res = await server.post("/api/collections.create", user, {
body: {
name: "Test",
description: "Test",
data: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
],
},
},
});
expect(res.status).toEqual(400);
});
it("should allow setting sharing to false", async () => {
const user = await buildUser();
const res = await server.post("/api/collections.create", user, {
@@ -1448,6 +1465,50 @@ describe("#collections.update", () => {
expect(collection.content).toBeTruthy();
});
it("replaces rendered content when description is updated post-create", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const createRes = await server.post("/api/collections.create", admin, {
headers: { "x-api-version": "3" },
body: { name: "Foo", description: "Original" },
});
const { id } = (await createRes.json()).data;
const updateRes = await server.post("/api/collections.update", admin, {
headers: { "x-api-version": "3" },
body: { id, description: "Replaced" },
});
expect(updateRes.status).toEqual(200);
const infoRes = await server.post("/api/collections.info", admin, {
headers: { "x-api-version": "3" },
body: { id },
});
const content = JSON.stringify((await infoRes.json()).data.data);
expect(content).toContain("Replaced");
expect(content).not.toContain("Original");
});
it("rejects providing both description and data", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const res = await server.post("/api/collections.update", admin, {
body: {
id: collection.id,
description: "Test",
data: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
],
},
},
});
expect(res.status).toEqual(400);
});
it("allows editing data", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
+44 -33
View File
@@ -15,39 +15,50 @@ const BaseIdSchema = z.object({
id: zodIdType(),
});
/** The landing page can be set from description (markdown) or data (rich content), but not both. */
const refineBodyContent = <T extends { description?: unknown; data?: unknown }>(
body: T
) => isUndefined(body.description) || isUndefined(body.data);
const bodyContentError = {
error: "Only one of description or data may be provided",
};
export const CollectionsCreateSchema = BaseSchema.extend({
body: z.object({
name: z.string(),
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
description: z.string().nullish(),
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
permission: z
.enum(CollectionPermission)
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().prefault(true),
icon: zodIconType().optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
direction: z.union([z.literal("asc"), z.literal("desc")]),
})
.prefault(Collection.DEFAULT_SORT),
index: z
.string()
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
.max(ValidateIndex.maxLength, {
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
})
.optional(),
commenting: z.boolean().nullish(),
templateManagement: z
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
.prefault(CollectionPermission.Admin),
}),
body: z
.object({
name: z.string(),
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
description: z.string().nullish(),
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
permission: z
.enum(CollectionPermission)
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().prefault(true),
icon: zodIconType().optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
direction: z.union([z.literal("asc"), z.literal("desc")]),
})
.prefault(Collection.DEFAULT_SORT),
index: z
.string()
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
.max(ValidateIndex.maxLength, {
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
})
.optional(),
commenting: z.boolean().nullish(),
templateManagement: z
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
.prefault(CollectionPermission.Admin),
})
.refine(refineBodyContent, bodyContentError),
});
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
@@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
templateManagement: z
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
.optional(),
}),
}).refine(refineBodyContent, bodyContentError),
});
export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
+14 -7
View File
@@ -29,6 +29,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
void initI18n();
if (env.isProduction) {
// Trust the X-Forwarded-* headers set by an upstream proxy, eg
// X-Forwarded-For. Defaults to true, but can be disabled with
// PROXY_HEADERS_TRUSTED when the app is reachable directly.
if (env.PROXY_HEADERS_TRUSTED) {
app.proxy = true;
if (env.PROXY_IP_HEADER) {
app.proxyIpHeader = env.PROXY_IP_HEADER;
}
}
// Force redirect to HTTPS protocol unless explicitly disabled
if (env.FORCE_HTTPS) {
app.use(
@@ -37,19 +47,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
if (httpsResolver(ctx)) {
return true;
}
return xForwardedProtoResolver(ctx);
// Only honor X-Forwarded-Proto when proxy headers are trusted
return env.PROXY_HEADERS_TRUSTED
? xForwardedProtoResolver(ctx)
: false;
},
})
);
} else {
Logger.warn("Enforced https was disabled with FORCE_HTTPS env variable");
}
// trust header fields set by our proxy. eg X-Forwarded-For
app.proxy = true;
if (env.PROXY_IP_HEADER) {
app.proxyIpHeader = env.PROXY_IP_HEADER;
}
}
// Make `ctx.userAgent` available
+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>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Node } from "prosemirror-model";
import type { Command } from "prosemirror-state";
import {
createEditorStateWithSelection,
doc,
p,
schema,
} from "@shared/test/editor";
import toggleList from "./toggleList";
const { bullet_list, ordered_list, list_item } = schema.nodes;
/**
* Creates a list item node with the given block content.
*/
function li(content: Node[]) {
return list_item.create(null, content);
}
/**
* Returns a position inside the first text node matching the given text.
*
* @throws if no matching text node exists in the document.
*/
function posOfText(node: Node, text: string) {
let found = -1;
node.descendants((child, pos) => {
if (found === -1 && child.isText && child.text === text) {
found = pos;
}
return found === -1;
});
if (found === -1) {
throw new Error(`Text "${text}" not found in document`);
}
return found + 1;
}
/**
* Runs a command with the selection placed inside the given text and returns
* the resulting document.
*/
function run(testDoc: Node, selectionText: string, command: Command) {
let state = createEditorStateWithSelection(
testDoc,
posOfText(testDoc, selectionText)
);
command(state, (tr) => {
state = state.apply(tr);
});
return state.doc;
}
describe("toggleList", () => {
it("converts a nested ordered list to bullet without changing the parent list", () => {
const testDoc = doc([
ordered_list.create(null, [
li([p("one")]),
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
]),
]);
const result = run(testDoc, "nested", toggleList(bullet_list, list_item));
const outer = result.firstChild;
expect(outer?.type.name).toBe("ordered_list");
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
});
it("converts a nested bullet list to ordered without changing the parent list", () => {
const testDoc = doc([
bullet_list.create(null, [
li([p("one")]),
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
]),
]);
const result = run(testDoc, "nested", toggleList(ordered_list, list_item));
const outer = result.firstChild;
expect(outer?.type.name).toBe("bullet_list");
expect(outer?.child(1).child(1).type.name).toBe("ordered_list");
});
it("converts the list and its children when the selection is in the parent list", () => {
const testDoc = doc([
ordered_list.create(null, [
li([p("one")]),
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
]),
]);
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
const outer = result.firstChild;
expect(outer?.type.name).toBe("bullet_list");
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
});
it("lifts the item out of the list when toggling the same list type", () => {
const testDoc = doc([
bullet_list.create(null, [li([p("one")]), li([p("two")])]),
]);
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
expect(result.childCount).toBe(2);
expect(result.child(0).type.name).toBe("bullet_list");
expect(result.child(1).type.name).toBe("paragraph");
expect(result.child(1).textContent).toBe("two");
});
});
+8 -1
View File
@@ -54,7 +54,14 @@ export default function toggleList(
parentList.pos,
parentList.pos + parentList.node.nodeSize,
(node, pos) => {
if (isList(node, schema)) {
// nodesBetween also visits the ancestors of the given range, these
// must be skipped so that toggling a nested list does not convert
// the lists it is nested within.
if (
pos >= parentList.pos &&
isList(node, schema) &&
listType.validContent(node.content)
) {
tr.setNodeMarkup(pos, listType, listStyle ? { listStyle } : {});
}
}
@@ -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;
`;
+12 -5
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: "@";
}
@@ -596,7 +602,7 @@ width: 100%;
padding: ${props.editorStyle?.padding ?? "initial"};
margin: ${props.editorStyle?.margin ?? "initial"};
& > .ProseMirror-yjs-cursor {
& > .${EditorStyleHelper.multiplayerCursor} {
display: none;
}
@@ -670,11 +676,11 @@ width: 100%;
h5 { font-size: var(--font-size-h5); }
h6 { font-size: var(--font-size-h6); }
.ProseMirror-yjs-selection {
.${EditorStyleHelper.multiplayerSelection} {
transition: background-color 500ms ease-in-out;
}
.ProseMirror-yjs-cursor {
.${EditorStyleHelper.multiplayerCursor} {
position: relative;
margin-left: -1px;
margin-right: -1px;
@@ -682,6 +688,7 @@ width: 100%;
border-right: 1px solid black;
height: 1em;
word-break: normal;
user-select: none;
&::after {
content: "";
@@ -719,7 +726,7 @@ width: 100%;
}
}
&.show-cursor-names .ProseMirror-yjs-cursor > div {
&.show-cursor-names .${EditorStyleHelper.multiplayerCursor} > div {
opacity: 1;
}
@@ -998,7 +1005,7 @@ img.ProseMirror-separator {
.${EditorStyleHelper.headingPositionAnchor}:first-child,
// Edge case where multiplayer cursor is between start of cell and heading
.${EditorStyleHelper.headingPositionAnchor}:first-child + .ProseMirror-yjs-cursor,
.${EditorStyleHelper.headingPositionAnchor}:first-child + .${EditorStyleHelper.multiplayerCursor},
// Edge case where table grips are between start of cell and heading
.${EditorStyleHelper.headingPositionAnchor}:first-child + [role=button] + [role=button] {
& + h1,
+95 -1
View File
@@ -1,4 +1,10 @@
import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
import type { ProsemirrorData } from "../../types";
import {
getNameFromEmoji,
getEmojiFromName,
loadEmojiData,
parseReactionShorthand,
} from "./emoji";
beforeAll(async () => {
await loadEmojiData();
@@ -15,3 +21,91 @@ describe("getEmojiFromName", () => {
expect(getEmojiFromName("thinking_face")).toBe("🤔");
});
});
describe("parseReactionShorthand", () => {
const doc = (content: ProsemirrorData[]): ProsemirrorData => ({
type: "doc",
content,
});
const paragraph = (content: ProsemirrorData[]): ProsemirrorData => ({
type: "paragraph",
content,
});
const text = (value: string): ProsemirrorData => ({
type: "text",
text: value,
});
const emoji = (name: string): ProsemirrorData => ({
type: "emoji",
attrs: { "data-name": name },
});
it("resolves a '+' followed by an emoji node", () => {
expect(
parseReactionShorthand(doc([paragraph([text("+"), emoji("thumbs_up")])]))
).toBe("👍");
});
it("ignores whitespace between the '+' and the emoji node", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+"), text(" "), emoji("thinking_face")])])
)
).toBe("🤔");
});
it("resolves a custom emoji UUID to its UUID", () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(
parseReactionShorthand(doc([paragraph([text("+"), emoji(uuid)])]))
).toBe(uuid);
});
it("resolves literal '+:shortcode:' text", () => {
expect(
parseReactionShorthand(doc([paragraph([text("+:thinking_face:")])]))
).toBe("🤔");
});
it("returns undefined for an unknown shortcode", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+"), emoji("not_an_emoji")])])
)
).toBeUndefined();
});
it("returns undefined when there is text alongside the emoji", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+ nice "), emoji("thumbs_up")])])
)
).toBeUndefined();
});
it("returns undefined for a regular comment", () => {
expect(
parseReactionShorthand(doc([paragraph([text("Looks good to me")])]))
).toBeUndefined();
});
it("returns undefined when the '+' prefix is missing", () => {
expect(
parseReactionShorthand(doc([paragraph([emoji("thumbs_up")])]))
).toBeUndefined();
});
it("returns undefined for multiple paragraphs", () => {
expect(
parseReactionShorthand(
doc([
paragraph([text("+"), emoji("thumbs_up")]),
paragraph([text("more")]),
])
)
).toBeUndefined();
});
});
+76
View File
@@ -1,4 +1,6 @@
import type { EmojiMartData } from "@emoji-mart/data";
import { isUUID } from "validator";
import type { ProsemirrorData } from "../../types";
export const emojiMartToGemoji: Record<string, string> = {
"+1": "thumbs_up",
@@ -74,3 +76,77 @@ export const getEmojiFromName = (name: string) =>
*/
export const getNameFromEmoji = (emoji: string) =>
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
/**
* Resolve an emoji node name to the value used to react with.
*
* @param name The emoji shortcode, or a UUID for a custom emoji.
* @returns the native emoji character, the UUID of a custom emoji, or undefined
* when the name does not resolve to a known emoji.
*/
function getReactionFromName(name: unknown): string | undefined {
if (typeof name !== "string") {
return undefined;
}
// Custom emojis are stored as UUIDs and reacted with directly.
if (isUUID(name)) {
return name;
}
const character = getEmojiFromName(name);
return character === "?" ? undefined : character;
}
/**
* Detect the "+:emoji:" reaction shorthand within a comment's document. When a
* comment consists solely of a leading "+" immediately followed by a single
* emoji it is treated as a request to react to the comment above rather than as
* a new comment, mirroring the Slack shorthand.
*
* @param data The Prosemirror document of the draft comment.
* @returns the emoji to react with — a native emoji character, or a UUID for a
* custom emoji — or undefined when the document is not a reaction shorthand.
*/
export function parseReactionShorthand(
data: ProsemirrorData
): string | undefined {
const blocks = data.content ?? [];
if (blocks.length !== 1) {
return undefined;
}
const paragraph = blocks[0];
if (paragraph.type !== "paragraph") {
return undefined;
}
// Ignore whitespace-only text nodes so that "+ :emoji:" still matches.
const inline = (paragraph.content ?? []).filter(
(node) => !(node.type === "text" && !node.text?.trim())
);
// The common case: a "+" text node followed by an emoji node inserted via
// the emoji menu.
if (inline.length === 2) {
const [prefix, emoji] = inline;
if (
prefix.type === "text" &&
prefix.text?.trim() === "+" &&
emoji.type === "emoji"
) {
return getReactionFromName(emoji.attrs?.["data-name"]);
}
return undefined;
}
// Fallback: literal "+:shortcode:" text that was never converted to a node.
if (inline.length === 1 && inline[0].type === "text") {
const match = inline[0].text?.trim().match(/^\+\s*:([\w-]+):$/);
if (match) {
return getReactionFromName(match[1]);
}
}
return undefined;
}
+60 -21
View File
@@ -83,6 +83,10 @@ export class MarkdownSerializer {
}
}
// Tracks whether we have already warned about direct assignment to `out`,
// so a hot loop cannot flood the console.
let warnedDirectOutAssignment = false;
export interface BlockMapEntry {
/** Start position in the ProseMirror document (offset within parent content). */
pmFrom: number;
@@ -103,14 +107,36 @@ export class MarkdownSerializerState {
inTightList = false;
closed = false;
delim = "";
out = "";
_out = "";
lastChar = "";
options: Options;
blockMap = null;
// The serialized output so far. Use `append` to add to it — direct
// assignment still works but reads the last character back out of the
// string, which forces V8 to flatten the internal rope and is slow when
// done repeatedly on large documents.
get out() {
return this._out;
}
set out(value) {
if (!warnedDirectOutAssignment) {
warnedDirectOutAssignment = true;
// eslint-disable-next-line no-console
console.warn(
"MarkdownSerializerState: assigning `out` directly is slow on large documents, use append() instead."
);
}
this._out = value;
this.lastChar = value === "" ? "" : value.charAt(value.length - 1);
}
constructor(nodes, marks, options) {
this.nodes = nodes;
this.marks = marks;
this.delim = this.out = "";
this.delim = this._out = "";
this.lastChar = "";
this.closed = false;
this.inTightList = false;
this.inTable = false;
@@ -126,10 +152,21 @@ export class MarkdownSerializerState {
}
}
// :: (string)
// Append a string to the output, tracking `lastChar` without reading
// characters back out of `out` — that would force V8 to flatten the
// internal rope, which is quadratic on large documents.
append(content) {
if (content) {
this._out += content;
this.lastChar = content.charAt(content.length - 1);
}
}
flushClose(size) {
if (this.closed) {
if (!this.atBlank()) {
this.out += "\n";
this.append("\n");
}
if (size === null || size === undefined) {
size = 2;
@@ -141,7 +178,7 @@ export class MarkdownSerializerState {
delimMin = delimMin.slice(0, delimMin.length - trim[0].length);
}
for (let i = 1; i < size; i++) {
this.out += delimMin + "\n";
this.append(delimMin + "\n");
}
}
this.closed = false;
@@ -163,14 +200,14 @@ export class MarkdownSerializerState {
}
atBlank() {
return /(^|\n)$/.test(this.out);
return this.lastChar === "" || this.lastChar === "\n";
}
// :: ()
// Ensure the current content ends with a newline.
ensureNewLine() {
if (!this.atBlank()) {
this.out += "\n";
this.append("\n");
}
}
@@ -181,10 +218,10 @@ export class MarkdownSerializerState {
write(content) {
this.flushClose();
if (this.delim && this.atBlank()) {
this.out += this.delim;
this.append(this.delim);
}
if (content) {
this.out += content;
this.append(content);
}
}
@@ -202,9 +239,11 @@ export class MarkdownSerializerState {
for (let i = 0; i < lines.length; i++) {
const startOfLine = this.atBlank() || this.closed;
this.write();
this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i];
this.append(
escape !== false ? this.esc(lines[i], startOfLine) : lines[i]
);
if (i !== lines.length - 1) {
this.out += "\n";
this.append("\n");
}
}
}
@@ -389,9 +428,9 @@ export class MarkdownSerializerState {
if (this.inTable) {
node.forEach((child, _, i) => {
if (i > 0) {
this.out += " <br> ";
this.append(" <br> ");
}
this.out += firstDelim(i).trim() + " ";
this.append(firstDelim(i).trim() + " ");
this.render(child, node, i);
});
return;
@@ -438,12 +477,12 @@ export class MarkdownSerializerState {
});
// Ensure there is an empty newline above all tables
this.out += "\n";
this.append("\n");
// Render rows
node.forEach((row, _, i) => {
row.forEach((cell, _, j) => {
this.out += j === 0 ? "| " : " | ";
this.append(j === 0 ? "| " : " | ");
const startPos = this.out.length;
@@ -463,26 +502,26 @@ export class MarkdownSerializerState {
// Pad to column width
const contentLength = this.out.length - startPos;
const padding = Math.max(0, columnWidths[j] - contentLength);
this.out += " ".repeat(padding);
this.append(" ".repeat(padding));
});
this.out += " |\n";
this.append(" |\n");
// Header separator after first row
if (i === 0) {
headerRow.forEach((cell, _, j) => {
const width = columnWidths[j];
if (cell.attrs.alignment === "center") {
this.out += "|:" + "-".repeat(width) + ":";
this.append("|:" + "-".repeat(width) + ":");
} else if (cell.attrs.alignment === "left") {
this.out += "|:" + "-".repeat(width + 1);
this.append("|:" + "-".repeat(width + 1));
} else if (cell.attrs.alignment === "right") {
this.out += "|" + "-".repeat(width + 1) + ":";
this.append("|" + "-".repeat(width + 1) + ":");
} else {
this.out += "|" + "-".repeat(width + 2);
this.append("|" + "-".repeat(width + 2));
}
});
this.out += "|\n";
this.append("|\n");
}
});
+2 -2
View File
@@ -116,11 +116,11 @@ export default class CheckboxItem extends Node {
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.out += node.attrs.checked ? "[x] " : "[ ] ";
state.append(node.attrs.checked ? "[x] " : "[ ] ");
if (state.inTable) {
node.forEach((block, _, i) => {
if (i > 0) {
state.out += " ";
state.append(" ");
}
state.renderInline(block);
});
+2 -2
View File
@@ -52,8 +52,8 @@ const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
const match = tokenTitle.match(imageSizeRegex);
if (match) {
attributes.width = parseInt(match[1], 10);
attributes.height = parseInt(match[2], 10);
attributes.width = match[1] ? parseInt(match[1], 10) : undefined;
attributes.height = match[2] ? parseInt(match[2], 10) : undefined;
tokenTitle = tokenTitle.replace(imageSizeRegex, "");
}
+1 -1
View File
@@ -291,7 +291,7 @@ export default class ListItem extends Node {
if (state.inTable) {
node.forEach((block, _, i) => {
if (i > 0) {
state.out += " ";
state.append(" ");
}
state.renderInline(block);
});
+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]) => {
+13 -6
View File
@@ -17,6 +17,11 @@ import attachmentsRule from "../rules/links";
import type { ComponentProps } from "../types";
import Node from "./Node";
const parseDimension = (value: string | null): number | null => {
const parsed = parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : null;
};
export default class Video extends Node {
get name() {
return "video";
@@ -56,12 +61,12 @@ export default class Video extends Node {
{
priority: 100,
tag: "video",
getAttrs: (dom: HTMLAnchorElement) => ({
getAttrs: (dom: HTMLVideoElement) => ({
id: dom.id,
title: dom.getAttribute("title"),
src: dom.getAttribute("src"),
width: parseInt(dom.getAttribute("width") ?? "", 10),
height: parseInt(dom.getAttribute("height") ?? "", 10),
width: parseDimension(dom.getAttribute("width")),
height: parseDimension(dom.getAttribute("height")),
}),
},
],
@@ -184,7 +189,9 @@ export default class Video extends Node {
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.ensureNewLine();
state.write(
`[${node.attrs.title} ${node.attrs.width}x${node.attrs.height}](${node.attrs.src})\n\n`
`[${node.attrs.title} ${node.attrs.width ?? ""}x${
node.attrs.height ?? ""
}](${node.attrs.src})\n\n`
);
state.ensureNewLine();
}
@@ -195,8 +202,8 @@ export default class Video extends Node {
getAttrs: (tok: Token) => ({
src: tok.attrGet("src"),
title: tok.attrGet("title"),
width: parseInt(tok.attrGet("width") ?? "", 10),
height: parseInt(tok.attrGet("height") ?? "", 10),
width: parseDimension(tok.attrGet("width")),
height: parseDimension(tok.attrGet("height")),
}),
};
}
+9 -26
View File
@@ -1,4 +1,4 @@
import { extensionManager, schema } from "../../test/editor";
import { extensionManager, findNodes, schema } from "../../test/editor";
const serializer = extensionManager.serializer();
const parser = extensionManager.parser({
@@ -6,29 +6,19 @@ const parser = extensionManager.parser({
plugins: extensionManager.rulePlugins,
});
interface ProsemirrorNode {
type: string;
content?: ProsemirrorNode[];
attrs?: Record<string, unknown>;
}
it("preserves mixed checkbox and regular items in a list", () => {
const markdown = `- [x] Checked item
- Regular item
- [ ] Unchecked item`;
const ast = parser.parse(markdown);
const json = ast?.toJSON();
const checkboxList = json?.content?.find(
(node: ProsemirrorNode) => node.type === "checkbox_list"
);
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
expect(checkboxList).toBeDefined();
expect(checkboxList?.content).toHaveLength(3);
expect(checkboxList?.content[0].type).toBe("checkbox_item");
expect(checkboxList?.content[1].type).toBe("checkbox_item");
expect(checkboxList?.content[2].type).toBe("checkbox_item");
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
expect(checkboxList?.content?.[2].type).toBe("checkbox_item");
});
it("round-trips mixed checkbox lists through serializer", () => {
@@ -52,22 +42,15 @@ it("does not convert nested bullet list items inside checkbox lists", () => {
- [ ] Second checkbox`;
const ast = parser.parse(markdown);
const json = ast?.toJSON();
const checkboxList = json?.content?.find(
(node: ProsemirrorNode) => node.type === "checkbox_list"
);
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
expect(checkboxList).toBeDefined();
expect(checkboxList?.content).toHaveLength(2);
expect(checkboxList?.content[0].type).toBe("checkbox_item");
expect(checkboxList?.content[1].type).toBe("checkbox_item");
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
// Nested list should remain a bullet_list, not a checkbox_list
const nestedContent = checkboxList?.content[0].content;
const nestedList = nestedContent?.find(
(node: ProsemirrorNode) => node.type === "bullet_list"
);
const [nestedList] = findNodes(checkboxList?.content?.[0], "bullet_list");
expect(nestedList).toBeDefined();
expect(nestedList?.content?.[0].type).toBe("list_item");
});
+50
View File
@@ -0,0 +1,50 @@
import type { JSONNode } from "../../test/editor";
import { extensionManager, findNodes, schema } from "../../test/editor";
const parser = extensionManager.parser({
schema,
plugins: extensionManager.rulePlugins,
});
const parseToJSON = (markdown: string): JSONNode | undefined =>
parser.parse(markdown)?.toJSON();
describe("math markdown rules", () => {
it("parses inline math", () => {
const doc = parseToJSON("before $x + y$ after");
const nodes = findNodes(doc, "math_inline");
expect(nodes).toHaveLength(1);
expect(nodes[0].content?.[0].text).toBe("x + y");
});
it("parses block math with closing delimiter on its own line", () => {
const doc = parseToJSON("$$\na = b\n$$\n\nparagraph after");
const nodes = findNodes(doc, "math_block");
expect(nodes).toHaveLength(1);
expect(nodes[0].content?.[0].text).toContain("a = b");
expect(findNodes(doc, "paragraph")).toHaveLength(1);
});
it("parses block math with closing delimiter at the end of a content line", () => {
const doc = parseToJSON("$$\na = b\nc = d$$\n\nparagraph after");
const blocks = findNodes(doc, "math_block");
expect(blocks).toHaveLength(1);
expect(blocks[0].content?.[0].text).toContain("a = b");
expect(blocks[0].content?.[0].text).toContain("c = d");
// The paragraph following the block must not be swallowed into the math
const paragraphs = findNodes(doc, "paragraph");
expect(paragraphs).toHaveLength(1);
expect(blocks[0].content?.[0].text).not.toContain("paragraph after");
});
it("leaves unclosed inline math as plain text", () => {
const doc = parseToJSON("price is $5 and rising");
expect(findNodes(doc, "math_inline")).toHaveLength(0);
expect(findNodes(doc, "math_block")).toHaveLength(0);
});
});
+5 -2
View File
@@ -61,7 +61,7 @@ function mathInline(state: StateInline, silent: boolean): boolean {
// we have found an opening delimiter already
const start = state.pos + inlineMathDelimiter.length;
match = start;
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== 1) {
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== -1) {
// found potential delimeter, look for escapes, pos will point to
// first non escape when complete
pos = match - 1;
@@ -166,7 +166,10 @@ function mathDisplay(
break;
}
if (state.src.slice(pos, max).trim().slice(-3) === blockMathDelimiter) {
if (
state.src.slice(pos, max).trim().slice(-blockMathDelimiter.length) ===
blockMathDelimiter
) {
lastPos = state.src.slice(0, max).lastIndexOf(blockMathDelimiter);
lastLine = state.src.slice(pos, lastPos);
found = true;
+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(
@@ -22,6 +22,14 @@ export class EditorStyleHelper {
static readonly comment = "comment-marker";
// Multiplayer
/** Remote collaborator's cursor */
static readonly multiplayerCursor = "ProseMirror-yjs-cursor";
/** Remote collaborator's selection */
static readonly multiplayerSelection = "ProseMirror-yjs-selection";
// Code
static readonly codeBlock = "code-block";
+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"
}
+32
View File
@@ -238,3 +238,35 @@ export function doc(
) {
return schema.nodes.doc.create(null, content);
}
/**
* A plain-object representation of a ProseMirror node, as returned by
* `Node.toJSON()`.
*/
export interface JSONNode {
type: string;
content?: JSONNode[];
attrs?: Record<string, unknown>;
text?: string;
}
/**
* Recursively collects all nodes of the given type from a `Node.toJSON()`
* tree, including the root node itself.
*
* @param node - the JSON node to search, may be undefined for convenience.
* @param type - the node type name to match.
* @returns array of matching nodes in document order.
*/
export function findNodes(
node: JSONNode | undefined,
type: string
): JSONNode[] {
if (!node) {
return [];
}
return [
...(node.type === type ? [node] : []),
...(node.content ?? []).flatMap((child) => findNodes(child, type)),
];
}
+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());
}
+11 -3
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"
@@ -18097,9 +18105,9 @@ __metadata:
linkType: hard
"shell-quote@npm:^1.8.1":
version: 1.8.3
resolution: "shell-quote@npm:1.8.3"
checksum: 10c0/bee87c34e1e986cfb4c30846b8e6327d18874f10b535699866f368ade11ea4ee45433d97bf5eada22c4320c27df79c3a6a7eb1bf3ecfc47f2c997d9e5e2672fd
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
checksum: 10c0/86c93678bc394cb81f5ddcdc87df9c95d279ef9652775cd1cd1eed361404169a8d8cbaacaeed232ab09919e36ee1e5363863570390d78571f8c22b7f6312fb40
languageName: node
linkType: hard