Compare commits

...

37 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
Tom Moor e1b2993bca Reduce debounce 2026-06-09 23:01:33 -04:00
Tom Moor b3d4563730 perf: Improve performance of in-page search (#12649)
* Fix editor find freezing on long documents

In-document search (Ctrl+F) blocked the UI for several seconds while
typing in long documents. Two compounding causes:

- The find command ran a full-document search and highlight rebuild on
  every keystroke. Debounce it so typing stays responsive; the input
  value still updates immediately and pending searches are flushed when
  navigating between matches.

- search() de-duplicated matches with an O(n) scan of all prior results
  per match, making a common term that matches many times quadratic.
  Track seen positions in a Set for constant-time lookups.

* Skip redundant search highlight rebuilds, lower debounce to 100ms

The highlight plugin rebuilt every match's DOM range via domAtPos on
every editor view update while a search was active, forcing synchronous
layout on cursor moves, selection changes, and collaboration cursors.

Track the built ranges and, when the result set is unchanged, only
rebuild when they are actually stale — a referenced node has detached or
some matches were not yet resolved to ranges. isConnected checks are
cheap property reads with no layout, versus domAtPos which forces
reflow, so this is strictly less work than before and skips entirely in
the common case where all matches are resolved and connected.

Also lower the find debounce from 250ms to 100ms for snappier feedback.

* Shorten highlight rebuild comment

* PR feedback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-09 22:46:59 -04:00
Tom Moor 7106263f88 fix: Dragging images in editor (#12647)
* fix: Editor image dragging

* feedback
2026-06-09 22:27:42 -04:00
Tom Moor 3c2e9a9723 fix: Default collections created through MCP to private (#12644) 2026-06-09 08:38:34 -04:00
Tom Moor bd01a62fc1 feat: MCP template support (#12639) 2026-06-09 07:43:21 -04:00
Tom Moor 95106e695f fix: Pasted content sometimes appears in plaintext (#12638)
* fix: 'Stuck' shift key forces plaintext paste
fix: Link on image does not survive copy/paste

* sanitize
2026-06-09 07:38:36 -04:00
Tom Moor 39623b90bd fix: Search prop is optional 2026-06-08 22:30:19 -04:00
Tom Moor a3fcd71582 fix: Widen validated emails for Azure (#12637) 2026-06-08 20:20:52 -04:00
Tom Moor a2f9962958 fix: Update validateUrlNotPrivate to match implementation in SSRF (#12636) 2026-06-08 19:35:46 -04:00
Tom Moor 709184ae0b fix: Before/After creation options appear in menu when no permission on parent doc (#12629)
* fix: Before/After creation options appear in menu when no permission on parent

closes #12624

* fix: Manager of root document sees non-functional Before/After create options

Before/After only gated on the team-level createDocument ability, so a user
with document-level manager access (but no collection access) saw the options
yet hit a backend rejection. Gate on the actual sibling location instead,
mirroring authorizeDocumentCreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:44:09 -04:00
dependabot[bot] bc5ffb79b2 chore(deps): bump react-day-picker from 8.10.1 to 8.10.2 (#12632)
Bumps [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) from 8.10.1 to 8.10.2.
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v8.10.2/packages/react-day-picker)

---
updated-dependencies:
- dependency-name: react-day-picker
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:55 -04:00
dependabot[bot] d703f8acf3 chore(deps-dev): bump @vitest/ui from 4.1.6 to 4.1.8 (#12631)
Bumps [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) from 4.1.6 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/ui)

---
updated-dependencies:
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:40 -04:00
dependabot[bot] 1c23cbec1b chore(deps): bump @simplewebauthn/server from 13.3.0 to 13.3.1 (#12633)
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.0 to 13.3.1.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:16 -04:00
dependabot[bot] 1caeafaeed chore(deps-dev): bump discord-api-types from 0.38.46 to 0.38.48 (#12634)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.46 to 0.38.48.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.46...0.38.48)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.48
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:59 -04:00
dependabot[bot] 969a7bb97d chore(deps-dev): bump prettier from 3.7.4 to 3.8.3 (#12635)
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:38 -04:00
Tom Moor 053693b9d5 fix: Spurious post-import edits (#12620) 2026-06-07 21:13:23 -04:00
Tom Moor 7938ffdd7a Restore SidebarButton position default to fix top padding regression (#12618)
The position prop is omitted at several call sites (App, Settings,
Shared sidebars) and relied on the top default for inset-titlebar
padding. Make the prop optional and restore the default so the lint
rule stays satisfied without changing runtime behavior.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 17:24:45 -04:00
Tom Moor 9b8acf3efb Remove unnecessary default parameter values from function signatures (#12617)
* Fix remaining no-useless-default-assignment lint warnings

* Promote no-useless-default-assignment lint rule to error
2026-06-07 15:46:01 -04:00
Tom Moor ac6b680cdb fix: notice query string consumed on unauthenticated routes 2026-06-07 14:22:26 -04:00
Tom Moor 27c633eb8b fix: Code blocks auto-collapse while editing (#12616) 2026-06-07 13:29:41 -04:00
Tom Moor ca36451e42 Improve handling of non-HD Google logins from root domain (#12615) 2026-06-07 13:16:25 -04:00
Tom Moor 0d198294eb fix: Improve patch merging of links in table cells (#12614)
* fix: Improve merging of links in table cells through patch

* PR feedback
2026-06-07 12:09:44 -04:00
Tom Moor bc63aba1d1 fix: place cursor at start of inserted table row/column (#12610)
* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* feat: Inline editor menu (#12611)

* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* Add handling for After variants
Add lint rule

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 11:58:48 -04:00
Tom Moor ea665b80ee feat: Inline editor menu (#12611)
* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:57:34 -04:00
Tom Moor 492af6683b Add document restore functionality to MCP tools (#12575)
* Add restore_document MCP tool and archived/trashed listing

Closes the delete/restore asymmetry in the MCP server: previously documents
could be archived or trashed via delete_document but never recovered.

- Add restore_document tool to recover archived or trashed documents,
  optionally into a different collection.
- Add a status option ("archived" | "trashed") to list_documents so agents
  can discover what to restore.
- Extract the documents.restore route logic into a shared documentRestorer
  command, used by both the REST endpoint and the MCP tool.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Use type-only import for Document in documentRestorer

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Revert archived/trashed status option on list_documents

Keeps the restore_document tool and shared documentRestorer command;
removes the list_documents status filter and its tests.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 15:08:52 -04:00
107 changed files with 3824 additions and 747 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 }}"
+18
View File
@@ -73,6 +73,23 @@
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "prosemirror-tables",
"importNames": [
"addRowBefore",
"addRowAfter",
"addColumnBefore",
"addColumnAfter"
],
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
}
]
}
],
"no-unused-expressions": "error",
"arrow-body-style": ["error", "as-needed"],
"react/react-in-jsx-scope": "off",
@@ -93,6 +110,7 @@
"typescript/consistent-type-imports": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-floating-promises": "error",
"typescript/no-useless-default-assignment": "error",
"no-unused-vars": [
"error",
{
+22 -2
View File
@@ -240,6 +240,26 @@ function findDocumentSiblingIndex(
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
/**
* Determines whether the user can create a sibling of the given document.
* A sibling shares the document's parent, so this mirrors the backend's
* create authorization: create permission on the parent document, or on the
* collection when the document is at the root.
*
* @param stores - the root stores.
* @param document - the document to create a sibling of.
* @returns true if the user can create a sibling.
*/
function canCreateSiblingDocument(
stores: ActionContext["stores"],
document: { collectionId?: string | null; parentDocumentId?: string }
): boolean {
return document.parentDocumentId
? stores.policies.abilities(document.parentDocumentId).createChildDocument
: !!document.collectionId &&
stores.policies.abilities(document.collectionId).createDocument;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
@@ -279,7 +299,7 @@ const createDocumentBefore = createInternalLinkAction({
if (collection?.sort.field === "title") {
return false;
}
return stores.policies.abilities(currentTeamId).createDocument;
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -321,7 +341,7 @@ const createDocumentAfter = createInternalLinkAction({
if (collection?.sort.field === "title") {
return false;
}
return stores.policies.abilities(currentTeamId).createDocument;
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
+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) =>
+2 -2
View File
@@ -1,8 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useLocation } from "react-router-dom";
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
@@ -106,7 +106,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<DndProvider backend={HTML5Backend}>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
+1 -1
View File
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
const hasDisabledUpdateMessage = useRef(false);
useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
Desktop.bridge?.redirect((path: string, replace: boolean) => {
if (replace) {
history.replace(path);
} else {
+63
View File
@@ -0,0 +1,63 @@
import type { BackendFactory } from "dnd-core";
import { HTML5Backend } from "react-dnd-html5-backend";
/**
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
* that call `preventDefault()` on drops whose dataTransfer resembles a native
* item including a dragged `<img>`, which is how ProseMirror serializes an
* image drag.
*
* These handlers run before ProseMirror's, and they live on `window`, so a
* propagation-based guard can't stop react-dnd without also starving the editor
* of the event. Instead we wrap the backend and make its top-level capture
* handlers no-op for events that occur within the editor surface.
*/
const captureHandlerNames = [
"handleTopDragStartCapture",
"handleTopDragEnterCapture",
"handleTopDragOverCapture",
"handleTopDragLeaveCapture",
"handleTopDropCapture",
"handleTopDragEndCapture",
] as const;
const isWithinEditor = (target: EventTarget | null): boolean =>
target instanceof Element && Boolean(target.closest(".ProseMirror"));
/**
* An HTML5 drag-and-drop backend that ignores drag events originating within the
* rich text editor so that ProseMirror can handle them itself.
*
* @param manager The drag-and-drop manager.
* @param context The global context.
* @param options Backend options.
* @returns The wrapped HTML5 backend instance.
*/
export const EditorAwareHTML5Backend: BackendFactory = (
manager,
context,
options
) => {
const backend = HTML5Backend(manager, context, options);
// The capture handlers are private instance fields on the backend, so reach
// for them through an index signature view of the instance.
const handlers = backend as unknown as Record<
string,
(event: DragEvent) => void
>;
for (const name of captureHandlerNames) {
const original = handlers[name];
if (typeof original === "function") {
handlers[name] = (event: DragEvent) => {
if (isWithinEditor(event.target)) {
return;
}
original.call(backend, event);
};
}
}
return backend;
};
+1 -1
View File
@@ -44,7 +44,7 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
selectedKeys,
className,
onSelect,
showFilter,
@@ -11,7 +11,7 @@ import Desktop from "~/utils/Desktop";
import { HStack } from "~/components/primitives/HStack";
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
position: "top" | "bottom";
position?: "top" | "bottom";
title: React.ReactNode;
image: React.ReactNode;
showMoreMenu?: boolean;
@@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a`
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
${BaseMenuItemCSS}
// Reserve space for the absolutely-positioned disclosure arrow so long
// labels truncate before it rather than overlapping.
padding-inline-end: 32px;
`;
export const MenuSeparator = styled.hr`
+37 -5
View File
@@ -1,3 +1,4 @@
import { debounce } from "es-toolkit/compat";
import {
CaretDownIcon,
CaretUpIcon,
@@ -211,9 +212,31 @@ export default function FindAndReplace({
});
}, [caseSensitive, editor.commands, searchTerm]);
// Searching the document on every keystroke is expensive in long documents
// it traverses the entire doc and rebuilds highlights so debounce.
const debouncedFind = React.useMemo(
() =>
debounce(
(attrs: {
text: string;
caseSensitive: boolean;
regexEnabled: boolean;
}) => {
editor.commands.find(attrs);
},
100
),
[editor.commands]
);
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious() {
// Ensure any pending debounced search has run so navigation acts on the
// results for the text currently in the input.
debouncedFind.flush();
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
@@ -243,7 +266,7 @@ export default function FindAndReplace({
}
}
},
[editor.commands, selectInputText]
[debouncedFind, editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -274,13 +297,13 @@ export default function FindAndReplace({
ev.stopPropagation();
setSearchTerm(ev.currentTarget.value);
editor.commands.find({
debouncedFind({
text: ev.currentTarget.value,
caseSensitive,
regexEnabled,
});
},
[caseSensitive, editor.commands, regexEnabled]
[caseSensitive, debouncedFind, regexEnabled]
);
const handleReplaceKeyDown = React.useCallback(
@@ -331,6 +354,9 @@ export default function FindAndReplace({
} else {
onClose();
setShowReplace(false);
// Cancel any pending debounced find so it can't reactivate highlights
// after the search has been cleared.
debouncedFind.cancel();
editor.commands.clearSearch();
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
@@ -346,7 +372,10 @@ export default function FindAndReplace({
>
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.prevSearchMatch();
}}
aria-label={t("Previous match")}
>
<CaretUpIcon />
@@ -355,7 +384,10 @@ export default function FindAndReplace({
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.nextSearchMatch();
}}
aria-label={t("Next match")}
>
<CaretDownIcon />
+190
View File
@@ -0,0 +1,190 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
import type { MenuItem } from "@shared/editor/types";
import { useTranslation } from "react-i18next";
import Scrollable from "~/components/Scrollable";
import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer";
import * as Components from "~/components/primitives/components/Menu";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types";
import useMobile from "~/hooks/useMobile";
import { mapMenuItems } from "../menus/mapMenuItems";
import { useEditor } from "./EditorContext";
import { useInlineMenuAnchor } from "./useInlineMenuAnchor";
type Props = {
items: MenuItem[];
/** Whether the document is right-to-left. */
rtl: boolean;
};
// The virtual anchor is an invisible zero-size element; the hook positions it
// over the selection and Radix anchors the menu to it.
const anchorStyle: React.CSSProperties = {
position: "fixed",
width: 0,
height: 0,
};
/**
* Renders a selection-toolbar menu inline — a vertical menu anchored to the
* selection with no trigger button — by holding a Radix dropdown `open`
* against a virtual anchor positioned over the selection. Radix provides the
* positioning, collision handling, submenus, and keyboard navigation. Page
* scroll is locked while open (via RemoveScroll, as Radix does for modal
* menus) without enabling Radix's modal mode, which conflicts with the menu
* being opened by an editor selection rather than a trigger.
*/
const InlineMenu: React.FC<Props> = ({ items, rtl }) => {
const { t } = useTranslation();
const { commands, view } = useEditor();
const { state } = view;
const isMobile = useMobile();
const {
ref: anchorRef,
key: anchorKey,
side,
align,
sideOffset,
} = useInlineMenuAnchor(rtl);
const mapped = React.useMemo(
() => mapMenuItems(items, commands, view, state),
[items, commands, view, state]
);
const preventFocus = React.useCallback((ev: Event) => {
ev.preventDefault();
}, []);
// Dismiss the menu by collapsing the selection so the toolbar stops matching.
const handleDismiss = React.useCallback(() => {
collapseSelection()(view.state, view.dispatch);
}, [view]);
if (isMobile) {
return (
<InlineMenuDrawer
items={mapped}
ariaLabel={t("Options")}
onDismiss={handleDismiss}
/>
);
}
return (
<MenuProvider variant="dropdown">
<DropdownMenuPrimitive.Root
key={anchorKey}
open={!!anchorKey}
modal={false}
>
<DropdownMenuPrimitive.Trigger asChild>
<div ref={anchorRef} aria-hidden style={anchorStyle} />
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
side={side}
align={align}
sideOffset={sideOffset}
collisionPadding={6}
aria-label={t("Options")}
onCloseAutoFocus={preventFocus}
onInteractOutside={handleDismiss}
onEscapeKeyDown={handleDismiss}
asChild
>
<RemoveScroll as={Slot} allowPinchZoom>
<Components.MenuContent
maxHeightVar="--radix-dropdown-menu-content-available-height"
transformOriginVar="--radix-dropdown-menu-content-transform-origin"
hiddenScrollbars
>
<EventBoundary>{toMenuItems(mapped)}</EventBoundary>
</Components.MenuContent>
</RemoveScroll>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
</MenuProvider>
);
};
// Time for the drawer's close animation to play before the selection is
// collapsed (which unmounts the menu).
const DRAWER_CLOSE_MS = 500;
type InlineMenuDrawerProps = {
items: TMenuItem[];
ariaLabel: string;
/** Collapse the selection so the toolbar stops rendering the menu. */
onDismiss: () => void;
};
/**
* Mobile presentation of the inline menu: a bottom drawer with submenu drill-in,
* matching the other menus. The menu is held open while the selection matches;
* closing animates the drawer out before collapsing the selection.
*/
function InlineMenuDrawer({
items,
ariaLabel,
onDismiss,
}: InlineMenuDrawerProps) {
const [open, setOpen] = React.useState(true);
const [submenuName, setSubmenuName] = React.useState<string>();
const close = React.useCallback(() => {
setOpen(false);
setTimeout(() => {
setSubmenuName(undefined);
onDismiss();
}, DRAWER_CLOSE_MS);
}, [onDismiss]);
const handleOpenChange = React.useCallback(
(isOpen: boolean) => {
if (!isOpen) {
close();
}
},
[close]
);
const menuItems = React.useMemo(() => {
if (!items.length || !submenuName) {
return items;
}
const submenu = items.find(
(item) => item.type === "submenu" && item.title === submenuName
) as MenuItemWithChildren | undefined;
return submenu?.items ?? items;
}, [items, submenuName]);
const content = toMobileMenuItems(menuItems, close, setSubmenuName);
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent aria-label={ariaLabel} aria-describedby={undefined}>
<DrawerTitle hidden>{ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export default InlineMenu;
+76 -4
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;
};
}
@@ -47,15 +59,72 @@ type Props = Omit<
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
function MentionMenu({ search = "", isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
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
) {
+12 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import type { MenuItem } from "@shared/editor/types";
import { MenuType, type MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
@@ -24,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import InlineMenu from "./InlineMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
@@ -264,6 +265,16 @@ export function SelectionToolbar(props: Props) {
setActiveToolbar(null);
};
// Inline menus render as a vertical menu anchored to the selection rather
// than as a horizontal toolbar with trigger buttons.
if (
matched?.variant === MenuType.inline &&
activeToolbar === Toolbar.Menu &&
items.length
) {
return <InlineMenu items={items} rtl={rtl} />;
}
return (
<FloatingToolbar
align={align}
+1 -1
View File
@@ -35,7 +35,7 @@ import { MenuHeader } from "~/components/primitives/components/Menu";
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
search: string;
search?: string;
trigger: string | string[];
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
+6 -63
View File
@@ -7,6 +7,7 @@ import type { MenuItem } from "@shared/editor/types";
import { hideScrollbars, s } from "@shared/styles";
import { TooltipProvider } from "~/components/TooltipContext";
import type { MenuItem as TMenuItem } from "~/types";
import { mapMenuItems } from "../menus/mapMenuItems";
import { useEditor } from "./EditorContext";
import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
@@ -49,69 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
closeHistory(view);
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
closeHistory(view);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
const resolvedItemChildren =
typeof item.children === "function" ? item.children() : item.children;
return resolvedItemChildren
? mapMenuItems(resolvedItemChildren, commands, view, state)
: [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
@@ -0,0 +1,152 @@
import { selectedRect } from "prosemirror-tables";
import * as React from "react";
import type { EditorView } from "prosemirror-view";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
import { useEditor } from "./EditorContext";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
const DEFAULT_SIDE_OFFSET = 4;
// Column and row menus open next to a grip handle. The grip is modelled as a
// strip just outside the cell edge so the two distances are independent:
// opening to the outside clears the grip (strip thickness + offset), while
// flipping across sits only a small gap (offset) away.
const OUTSIDE_CLEARANCE = 20;
const FLIP_GAP = 0;
const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP;
const GRIP_SIDE_OFFSET = FLIP_GAP;
type Anchor = {
/** Viewport rect to anchor the menu to. */
top: number;
left: number;
width: number;
height: number;
/** Which side of the anchor the menu opens towards. */
side: Side;
/** How the menu aligns along the anchor edge. */
align: Align;
/** Distance in pixels between the anchor and the menu. */
sideOffset: number;
/** Stable identifier for the anchored target, changes when it moves. */
key: string;
};
/**
* Computes the rect and placement to anchor an inline selection menu to, based
* on the current table/column/row selection. The menu opens to the "outside"
* of the table (above a column, beside a row) to cover the least content, and
* is centered on the anchor for minimal pointer movement. Returns null when
* there is no supported selection.
*
* @param view - the editor view.
* @param rtl - whether the document is right-to-left.
* @returns the anchor, or null.
*/
function getAnchor(view: EditorView, rtl: boolean): Anchor | null {
const { state } = view;
const { selection } = state;
if (isTableSelected(state)) {
const rect = selectedRect(state);
const bounds = (
view.domAtPos(rect.tableStart).node as HTMLElement
).getBoundingClientRect();
// A horizontal line at the table's top edge so it stays near the top
// whether the menu opens above or flips below.
return {
top: bounds.top,
left: bounds.left,
width: bounds.width,
height: 0,
side: "top",
align: "start",
sideOffset: DEFAULT_SIDE_OFFSET,
key: `table-${rect.tableStart}`,
};
}
if (selection instanceof ColumnSelection && selection.isColSelection()) {
const rect = selectedRect(state);
const cell = (
view.domAtPos(rect.tableStart).node as HTMLElement
).querySelector(`tr > *:nth-child(${rect.left + 1})`);
if (cell instanceof HTMLElement) {
const bounds = cell.getBoundingClientRect();
// A strip just above the column's top edge (the grip), spanning the
// column width so the menu centers on the column.
return {
top: bounds.top - GRIP_INSET,
left: bounds.left,
width: bounds.width,
height: GRIP_INSET,
side: "top",
align: "center",
sideOffset: GRIP_SIDE_OFFSET,
key: `col-${rect.tableStart}-${rect.left}`,
};
}
}
if (selection instanceof RowSelection && selection.isRowSelection()) {
const rect = selectedRect(state);
const cell = (
view.domAtPos(rect.tableStart).node as HTMLElement
).querySelector(`tr:nth-child(${rect.top + 1}) > *`);
if (cell instanceof HTMLElement) {
const bounds = cell.getBoundingClientRect();
// A strip just outside the row's grip edge (left, or right in RTL),
// spanning the row height so the menu centers on the row.
return {
top: bounds.top,
left: rtl ? bounds.right : bounds.left - GRIP_INSET,
width: GRIP_INSET,
height: bounds.height,
side: rtl ? "right" : "left",
align: "center",
sideOffset: GRIP_SIDE_OFFSET,
key: `row-${rect.tableStart}-${rect.top}`,
};
}
}
return null;
}
/**
* Positions an invisible virtual anchor element over the current table, column,
* or row selection so a Radix dropdown can anchor an inline menu to it. The
* returned `key` changes when the anchored target changes; spread it onto the
* menu root so Radix repositions for a new target.
*
* @param rtl - whether the document is right-to-left.
* @returns the anchor ref to attach to the virtual trigger, the target key, and
* the side/align the menu should open with.
*/
export function useInlineMenuAnchor(rtl: boolean) {
const { view } = useEditor();
const ref = React.useRef<HTMLDivElement>(null);
const anchor = getAnchor(view, rtl);
React.useLayoutEffect(() => {
const element = ref.current;
if (element && anchor) {
element.style.top = `${anchor.top}px`;
element.style.left = `${anchor.left}px`;
element.style.width = `${anchor.width}px`;
element.style.height = `${anchor.height}px`;
}
});
return {
ref,
key: anchor?.key,
side: anchor?.side ?? "top",
align: anchor?.align ?? "start",
sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET,
};
}
+42 -11
View File
@@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
});
// Tracks already-seen match positions so duplicate matches (possible because
// we search the deburred text concatenated with the original) can be skipped
// in constant time rather than rescanning the entire results array.
const seen = new Set<string>();
mergedTextNodes.forEach((node) => {
const { text = "", pos, type } = node;
try {
@@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
continue;
}
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
// Check if already exists in results, possible because we search
// over `deburr(text) + text`
const key = `${from}:${to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
this.results.push({ from, to, type });
}
@@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
}
this.highlightRanges = allRanges;
CSS.highlights.set("search-results", new Highlight(...allRanges));
if (currentRanges.length) {
CSS.highlights.set(
@@ -495,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
private clearHighlights() {
this.highlightRanges = [];
if (!supportsHighlightAPI) {
return;
}
@@ -503,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
this.currentHighlightRange = undefined;
}
/**
* Determine whether the highlight ranges need to be rebuilt against the live
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
* editor re-renders its DOM without changing the doc, so highlights are stale
* when a built range's nodes have disconnected, or when some matches have not
* yet been resolved to ranges (e.g. inside a node view that mounts later).
*
* @returns whether the highlights should be rebuilt.
*/
private highlightsStale() {
if (this.highlightRanges.length < this.results.length) {
return true;
}
return this.highlightRanges.some(
(range) =>
!range.startContainer.isConnected || !range.endContainer.isConnected
);
}
private handleEscape = () => {
const params = new URLSearchParams(window.location.search);
if (params.has("q")) {
@@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
private currentHighlightRange?: StaticRange;
private highlightRanges: StaticRange[] = [];
get allowInReadOnly() {
return true;
}
@@ -604,16 +634,17 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
// Rebuild highlights when the results change (generation bump) or,
// while a search is active, on any view update. The CSS Custom
// Highlight API relies on static DOM ranges that become detached
// when the editor re-renders its DOM — e.g. content settling after
// sync when navigating from search results, collaboration cursors,
// or node views mounting — none of which bump the generation. This
// keeps the highlights tracking the live DOM, as decorations do.
if (generation !== lastGeneration || this.searchTerm) {
// The results changed (search ran, doc changed, fold toggled), so
// always rebuild.
if (generation !== lastGeneration) {
lastGeneration = generation;
this.updateHighlights();
return;
}
// Results unchanged: only rebuild when the static highlight ranges
// have detached from a DOM re-render that didn't bump the generation.
if (this.searchTerm && this.highlightsStale()) {
this.updateHighlights();
}
},
destroy: () => {
+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,
};
};
+2 -6
View File
@@ -55,15 +55,11 @@ export default class PasteHandler extends Extension {
},
handleDOMEvents: {
keydown: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = true;
}
this.shiftKey = event.shiftKey;
return false;
},
keyup: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = false;
}
this.shiftKey = event.shiftKey;
return false;
},
},
+7 -1
View File
@@ -7,7 +7,10 @@ import Extension from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types";
import {
MenuType,
type SelectionToolbarMenuDescriptor,
} from "@shared/editor/types";
import { SelectionToolbar } from "../components/SelectionToolbar";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
@@ -62,16 +65,19 @@ export default class SelectionToolbarExtension extends Extension {
},
{
priority: 90,
variant: MenuType.inline,
matches: (ctx) => ctx.isTableSelected,
getItems: (ctx) => getTableMenuItems(ctx),
},
{
priority: 85,
variant: MenuType.inline,
matches: (ctx) => ctx.colIndex !== undefined,
getItems: (ctx) => getTableColMenuItems(ctx),
},
{
priority: 80,
variant: MenuType.inline,
matches: (ctx) => ctx.rowIndex !== undefined,
getItems: (ctx) => getTableRowMenuItems(ctx),
},
+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",
+80
View File
@@ -0,0 +1,80 @@
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { closeHistory } from "@shared/editor/lib/closeHistory";
import type { CommandFactory } from "@shared/editor/lib/Extension";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem as TMenuItem } from "~/types";
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
/**
* Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by
* `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu
* presentation stays consistent. Resolves nested children into submenus and
* binds each leaf to its editor command (or `onClick`).
*
* @param items - the editor menu items to map.
* @param commands - the editor command registry.
* @param view - the editor view, used to checkpoint history around commands.
* @param state - the editor state, used to resolve dynamic attrs and active state.
* @returns the mapped primitive menu items.
*/
export function mapMenuItems(
items: MenuItem[],
commands: Record<string, CommandFactory>,
view: EditorView,
state: EditorState
): TMenuItem[] {
const handleClick = (item: MenuItem) => () => {
if (!item.name) {
return;
}
if (commands[item.name]) {
closeHistory(view);
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
closeHistory(view);
} else if (item.onClick) {
item.onClick();
}
};
return items.map((item) => {
if (item.name === "separator") {
return { type: "separator", visible: item.visible };
}
if ("content" in item) {
return { type: "custom", visible: item.visible, content: item.content };
}
const resolvedChildren = resolveChildren(item.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(child) => "preventCloseCondition" in child
);
return {
type: "submenu",
title: item.label,
icon: item.icon,
visible: item.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapMenuItems(resolvedChildren, commands, view, state),
};
}
return {
type: "button",
title: item.label,
icon: item.icon,
dangerous: item.dangerous,
visible: item.visible,
selected: item.active !== undefined ? item.active(state) : undefined,
onClick: handleClick(item),
};
});
}
+11 -14
View File
@@ -15,9 +15,7 @@ import { TableLayout } from "@shared/editor/types";
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -30,33 +28,32 @@ export default function tableMenuItems(
return [
{
name: "setTableAttr",
tooltip: isFullWidth ? t("Default width") : t("Full width"),
label: isFullWidth ? t("Default width") : t("Full width"),
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: t("Distribute columns"),
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
tooltip: t("Delete table"),
icon: <TrashIcon />,
name: "exportTable",
label: t("Export as CSV"),
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: t("Export as CSV"),
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
name: "deleteTable",
label: t("Delete table"),
dangerous: true,
icon: <TrashIcon />,
},
];
}
+122 -118
View File
@@ -5,7 +5,6 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
@@ -24,7 +23,11 @@ import {
tableHasRowspan,
} from "@shared/editor/queries/table";
import { t } from "i18next";
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
import type {
MenuItem,
NodeAttrMark,
SelectionContext,
} from "@shared/editor/types";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
@@ -65,9 +68,7 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableColMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -94,60 +95,65 @@ export default function tableColMenuItems(
return [
{
name: "setColumnAttr",
tooltip: t("Align left"),
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
tooltip: t("Align center"),
label: t("Align"),
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
children: [
{
name: "setColumnAttr",
label: t("Align left"),
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
label: t("Align center"),
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
label: t("Align right"),
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
],
},
{
name: "setColumnAttr",
tooltip: t("Align right"),
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
{
name: "separator",
},
{
name: "sortTable",
tooltip: t("Sort ascending"),
attrs: { index, direction: "asc" },
label: t("Sort"),
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
children: [
{
name: "sortTable",
label: t("Sort ascending"),
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
},
{
name: "sortTable",
label: t("Sort descending"),
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
},
],
},
{
name: "sortTable",
tooltip: t("Sort descending"),
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "separator",
},
{
tooltip: t("Background color"),
label: t("Background"),
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
@@ -161,7 +167,7 @@ export default function tableColMenuItems(
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: t("None"),
icon: <DottedCircleIcon retainColor color="transparent" />,
icon: <DottedCircleIcon color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
@@ -205,71 +211,69 @@ export default function tableColMenuItems(
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderColumn",
label: t("Toggle header"),
icon: <TableHeaderColumnIcon />,
visible: index === 0,
},
{
name: rtl ? "addColumnAfter" : "addColumnBefore",
label: rtl ? t("Insert after") : t("Insert before"),
icon: <InsertLeftIcon />,
attrs: { index },
},
{
name: rtl ? "addColumnBefore" : "addColumnAfter",
label: rtl ? t("Insert before") : t("Insert after"),
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: t("Move left"),
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: t("Move right"),
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteColumn",
dangerous: true,
label: t("Delete"),
icon: <TrashIcon />,
},
],
name: "separator",
},
{
name: "toggleHeaderColumn",
label: t("Toggle header"),
icon: <TableHeaderColumnIcon />,
visible: index === 0,
},
{
name: rtl ? "addColumnAfter" : "addColumnBefore",
label: rtl ? t("Insert after") : t("Insert before"),
icon: <InsertLeftIcon />,
attrs: { index },
},
{
name: rtl ? "addColumnBefore" : "addColumnAfter",
label: rtl ? t("Insert before") : t("Insert after"),
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: t("Move left"),
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: t("Move right"),
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteColumn",
dangerous: true,
label: t("Delete"),
icon: <TrashIcon />,
},
];
}
+65 -66
View File
@@ -2,7 +2,6 @@ import {
TrashIcon,
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
@@ -16,7 +15,11 @@ import {
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { t } from "i18next";
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
import type {
MenuItem,
NodeAttrMark,
SelectionContext,
} from "@shared/editor/types";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
@@ -56,9 +59,7 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableRowMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -83,7 +84,42 @@ export default function tableRowMenuItems(
return [
{
tooltip: t("Background color"),
name: "toggleHeaderRow",
label: t("Toggle header"),
icon: <TableHeaderRowIcon />,
visible: index === 0,
},
{
name: "addRowBefore",
label: t("Insert before"),
icon: <InsertAboveIcon />,
attrs: { index },
},
{
name: "addRowAfter",
label: t("Insert after"),
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: t("Move up"),
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: t("Move down"),
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
label: t("Background"),
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
@@ -97,7 +133,7 @@ export default function tableRowMenuItems(
{
name: "toggleRowBackgroundAndCollapseSelection",
label: t("None"),
icon: <DottedCircleIcon retainColor color="transparent" />,
icon: <DottedCircleIcon color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
@@ -141,65 +177,28 @@ export default function tableRowMenuItems(
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderRow",
label: t("Toggle header"),
icon: <TableHeaderRowIcon />,
visible: index === 0,
},
{
name: "addRowBefore",
label: t("Insert before"),
icon: <InsertAboveIcon />,
attrs: { index },
},
{
name: "addRowAfter",
label: t("Insert after"),
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: t("Move up"),
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: t("Move down"),
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteRow",
label: t("Delete"),
dangerous: true,
icon: <TrashIcon />,
},
],
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteRow",
label: t("Delete"),
dangerous: true,
icon: <TrashIcon />,
},
];
}
+2
View File
@@ -11,6 +11,7 @@ import Route from "~/components/ProfiledRoute";
import WebsocketProvider from "~/components/WebsocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazy from "~/utils/lazyWithRetry";
import {
archivePath,
@@ -53,6 +54,7 @@ const RedirectDocument = ({
* the user to be logged in.
*/
function AuthenticatedRoutes() {
useQueryNotices();
const team = useCurrentTeam();
const can = usePolicy(team);
-2
View File
@@ -5,7 +5,6 @@ import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as documentSlug } from "~/utils/routeHelpers";
import useAutoRefresh from "~/hooks/useAutoRefresh";
@@ -18,7 +17,6 @@ const Logout = lazy(() => import("~/scenes/Logout"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
export default function Routes() {
useQueryNotices();
useAutoRefresh();
return (
@@ -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 -1
View File
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
: window.location.origin;
type PathParams = {
shareId: string;
shareId?: string;
collectionSlug?: string;
documentSlug?: string;
};
+8 -5
View File
@@ -95,6 +95,7 @@
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
@@ -103,7 +104,7 @@
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.2.3",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/form-data": "^2.5.2",
@@ -114,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",
@@ -213,7 +215,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-colorful": "^5.7.0",
"react-day-picker": "^8.10.1",
"react-day-picker": "^8.10.2",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
@@ -223,6 +225,7 @@
"react-i18next": "^12.3.1",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-remove-scroll": "^2.7.2",
"react-router-dom": "^5.3.4",
"react-use-measure": "^2.1.7",
"react-virtualized-auto-sizer": "^1.0.26",
@@ -349,14 +352,14 @@
"@types/validator": "^13.15.10",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"@vitest/ui": "^4.1.6",
"@vitest/ui": "^4.1.8",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.46",
"discord-api-types": "^0.38.48",
"husky": "^8.0.3",
"i18next-parser": "^9.4.0",
"ioredis-mock": "^8.13.1",
@@ -366,7 +369,7 @@
"oxlint": "1.66.0",
"oxlint-tsgolint": "0.22.1",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"prettier": "^3.8.3",
"react-refresh": "^0.18.0",
"rimraf": "^6.1.3",
"rollup-plugin-webpack-stats": "2.1.11",
+24 -10
View File
@@ -102,17 +102,31 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// Microsoft's email claim is mutable, only trust it when a verification
// claim confirms it — xms_edov for workforce tenants, or the standard
// email_verified claim in External ID / OIDC scenarios.
// The mail and userPrincipalName values come from the directory via the
// Graph API and are owned by the organization, so an email sourced from
// them is inherently trusted. Microsoft's mutable `email` token claim is
// only trusted when a verification claim confirms it — xms_edov for
// workforce tenants, or the standard email_verified claim in External ID
// / OIDC scenarios.
// https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization
const verificationClaims = [profile.xms_edov, profile.email_verified];
const presentClaims = verificationClaims.filter(
(claim) => claim !== undefined
);
const emailVerified = presentClaims.length
? presentClaims.some((claim) => claim === true || claim === "true")
: undefined;
const directoryEmails = [
profileResponse.mail,
profileResponse.userPrincipalName,
]
.filter(Boolean)
.map((value) => value.toLowerCase());
const verificationClaims = [
profile.xms_edov,
profile.email_verified,
].filter((claim) => claim !== undefined);
const emailVerified =
directoryEmails.includes(email.toLowerCase()) ||
(verificationClaims.length
? verificationClaims.some(
(claim) => claim === true || claim === "true"
)
: undefined);
const domain = parseEmail(email).domain;
const subdomain = slugifyDomain(domain);
+20 -9
View File
@@ -68,16 +68,18 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
try {
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(context);
let team = await getTeamFromContext(context);
const client = getClientFromOAuthState(context);
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
// This combination is always an error
// No profile domain means a personal gmail account, and no team means
// the request came from the apex domain rather than a workspace
// subdomain. We can't infer the workspace from the domain, so resolve
// it from the verified email's existing accounts instead.
if (!domain && !team) {
const userExists = await User.count({
const existingAccounts = await User.findAll({
attributes: ["id", "teamId"],
where: { email: profile.email.toLowerCase() },
include: [
{
@@ -86,14 +88,23 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
},
],
});
const teamIds = new Set(
existingAccounts.map((account) => account.teamId)
);
// Users cannot create a team with personal gmail accounts
if (!userExists) {
// A personal gmail account cannot be used to create a new workspace.
if (teamIds.size === 0) {
throw GmailAccountCreationError();
}
// To log-in with a personal account, users must specify a team subdomain
throw TeamDomainRequiredError();
// When the email belongs to more than one workspace it is ambiguous
// which to sign into, so the user must start from its subdomain.
if (teamIds.size > 1) {
throw TeamDomainRequiredError();
}
// Belongs to exactly one workspace — resolve it and sign in there.
team = existingAccounts[0].team;
}
// remove the TLD and form a subdomain from the remaining
@@ -0,0 +1,79 @@
import { Node } from "prosemirror-model";
import { prosemirrorToYDoc } from "y-prosemirror";
import { schema } from "@server/editor";
import { buildDocument, buildUser } from "@server/test/factories";
import documentCollaborativeUpdater from "./documentCollaborativeUpdater";
describe("documentCollaborativeUpdater", () => {
const buildYDoc = (content: object[]) => {
const doc = Node.fromJSON(schema, { type: "doc", content });
return prosemirrorToYDoc(doc, "default");
};
it("persists canonical JSON without empty attrs on marks", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
});
const ydoc = buildYDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "Deciders:",
marks: [{ type: "strong" }],
},
],
},
]);
await documentCollaborativeUpdater({
documentId: document.id,
ydoc,
sessionCollaboratorIds: [user.id],
isLastConnection: true,
clientVersion: null,
});
await document.reload();
const marks = JSON.stringify(document.content).match(/"attrs":\{\}/g);
expect(marks).toBeNull();
const text = document.content?.content?.[0]?.content?.[0];
expect(text?.marks).toEqual([{ type: "strong" }]);
});
it("does not persist when content is unchanged", async () => {
const user = await buildUser();
const content = [
{
type: "paragraph",
content: [{ type: "text", text: "Hello" }],
},
];
const ydoc = buildYDoc(content);
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
content: Node.fromJSON(schema, { type: "doc", content }).toJSON(),
});
const updatedAt = document.updatedAt;
await documentCollaborativeUpdater({
documentId: document.id,
ydoc,
sessionCollaboratorIds: [user.id],
isLastConnection: true,
clientVersion: null,
});
await document.reload();
expect(document.updatedAt).toEqual(updatedAt);
});
});
@@ -1,8 +1,10 @@
import isEqual from "fast-deep-equal";
import { uniq } from "es-toolkit/compat";
import { Node } from "prosemirror-model";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import type { ProsemirrorData } from "@shared/types";
import { schema } from "@server/editor";
import Logger from "@server/logging/Logger";
import { Document, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -50,7 +52,14 @@ export default async function documentCollaborativeUpdater({
});
const state = Y.encodeStateAsUpdate(ydoc);
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
// Round-trip through the schema so the stored JSON is canonical. The raw
// y-prosemirror output includes empty `attrs: {}` on every mark, and outputs
// properties in a different order - resulting in spurious "edits"
const content = Node.fromJSON(
schema,
yDocToProsemirrorJSON(ydoc, "default")
).toJSON() as ProsemirrorData;
const isUnchanged = isEqual(document.content, content);
const isDeleted = !!document.deletedAt;
const lastModifiedById = isDeleted
+1 -1
View File
@@ -24,7 +24,7 @@ async function documentMover(
ctx: APIContext,
{
document,
collectionId = null,
collectionId,
parentDocumentId = null,
// convert undefined to null so parentId comparison treats them as equal
index,
+98
View File
@@ -0,0 +1,98 @@
import { traceFunction } from "@server/logging/tracing";
import { ValidationError } from "@server/errors";
import { Collection, Revision } from "@server/models";
import type { Document } from "@server/models";
import { authorize } from "@server/policies";
import type { APIContext } from "@server/types";
import { assertPresent } from "@server/validation";
type Props = {
/** The document to restore. Must be loaded with `paranoid: false`. */
document: Document;
/** Destination collection to restore into. Defaults to the original collection. */
collectionId?: string | null;
/** Revision to restore the document's content from, when not archived or deleted. */
revisionId?: string | null;
};
/**
* Restores a previously archived or deleted document, or restores a document's
* content to a specific revision. Re-attaches the document to the destination
* collection's structure when applicable and authorizes the acting user.
*
* @param ctx - the API context, providing the acting user and transaction.
* @param props - the document and restore options.
* @returns the restored document.
* @throws ValidationError if the destination collection is not active.
*/
async function documentRestorer(
ctx: APIContext,
{ document, collectionId, revisionId }: Props
): Promise<Document> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const sourceCollectionId = document.collectionId;
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
: undefined;
const destCollection = destCollectionId
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
: undefined;
if (!destCollection?.isActive) {
throw ValidationError(
"Unable to restore, the collection may have been deleted or archived"
);
}
if (sourceCollectionId && sourceCollectionId !== destCollection.id) {
authorize(user, "updateDocument", srcCollection);
await srcCollection?.removeDocumentInStructure(document, {
save: true,
transaction,
});
}
if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
// restore a previously deleted document
await document.restoreTo(ctx, { collectionId: destCollection.id });
} else if (document.archivedAt) {
authorize(user, "unarchive", document);
authorize(user, "updateDocument", destCollection);
// restore a previously archived document
await document.restoreTo(ctx, { collectionId: destCollection.id });
} else if (revisionId) {
// restore a document to a specific revision
authorize(user, "update", document);
const revision = await Revision.findByPk(revisionId, { transaction });
authorize(document, "restore", revision);
await document.restoreFromRevision(revision);
await document.saveWithCtx(ctx, undefined, { name: "restore" });
} else {
assertPresent(revisionId, "revisionId is required");
}
return document;
}
export default traceFunction({
spanName: "documentRestorer",
})(documentRestorer);
+78
View File
@@ -3,6 +3,7 @@ import * as Y from "yjs";
import { TextEditMode } from "@shared/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { Event } from "@server/models";
import { parser } from "@server/editor";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { buildDocument, buildUser } from "@server/test/factories";
@@ -1481,5 +1482,82 @@ describe("documentUpdater", () => {
"Second item"
);
});
it("should apply a link when wrapping existing table cell text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
.toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[see](https://example.com/docs)",
TextEditMode.Patch,
"see"
);
// The cell text is unchanged but should now carry a link mark — it must
// not be silently dropped during the merge.
const cellText =
result.content!.content![0].content![1].content![1].content![0]
.content![0];
expect(cellText.text).toEqual("see");
expect(cellText.marks).toEqual([
expect.objectContaining({
type: "link",
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
}),
]);
});
it("should preserve other table cells when adding a link to one cell", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse(
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
)
.toJSON();
await document.save();
// Capture the untouched (Beta) row BEFORE patching so we can assert its
// structure and attrs are preserved exactly, not just its text.
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const untouchedRow = beforeDoc.content[0].content[2];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"see [docs](https://example.com/d)",
TextEditMode.Patch,
"see"
);
// The patched cell gained the link
expect(result.text).toContain("[docs](https://example.com/d)");
// The untouched row node must remain identical
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
});
it("should apply a mark when wrapping existing list item text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser.parse("- clickme\n- other\n").toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[clickme](https://example.com)",
TextEditMode.Patch,
"clickme"
);
expect(result.text).toContain("[clickme](https://example.com)");
expect(result.text).toContain("other");
});
});
});
+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,
});
},
};
+12 -2
View File
@@ -817,8 +817,18 @@ export class DocumentHelper {
const textSame = oldChild.textContent === newChild.textContent;
if (textSame && oldChild.sameMarkup(newChild)) {
// Fully unchanged — keep original with its rich content
merged.push(oldChild);
// Compare against the round-tripped baseline: when the
// updated child is identical to a plain round-trip of the original,
// the patch did not touch it
if (!rtChild || newChild.eq(rtChild)) {
merged.push(oldChild);
} else if (!oldChild.isTextblock && !oldChild.isLeaf) {
// Container child changed deeper down — recurse to preserve rich
// content in the parts that did not change.
merged.push(DocumentHelper.mergeNodes(oldChild, newChild, rtChild));
} else {
merged.push(newChild);
}
} else if (textSame) {
// Attrs changed (e.g. checked state) but content same — merge attrs
// so that non-markdown-representable values (colwidth, highlight
+10 -8
View File
@@ -20,6 +20,7 @@ import Diff from "@shared/editor/extensions/Diff";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
import textBetween from "@shared/editor/lib/textBetween";
import { withTrailingNode } from "@shared/editor/lib/trailingNode";
import EditorContainer from "@shared/editor/components/Styles";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
@@ -106,15 +107,16 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
* @returns The content as a Y.Doc.
*/
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
if (typeof input === "object") {
return prosemirrorToYDoc(
ProsemirrorHelper.toProsemirror(input),
fieldName
);
const node =
typeof input === "object"
? ProsemirrorHelper.toProsemirror(input)
: parser.parse(input);
if (!node) {
return new Y.Doc();
}
const node = parser.parse(input);
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
// Normalize to the editor's trailing-node form so the document opens without
// the editor inserting a trailing paragraph, which would be a spurious edit.
return prosemirrorToYDoc(withTrailingNode(node), fieldName);
}
/**
+1 -1
View File
@@ -17,7 +17,7 @@ export function presentDCRClient(
baseUrl: string,
oauthClient: OAuthClient,
{
includeRegistrationAccessToken = false,
includeRegistrationAccessToken,
includeCredentials = false,
}: {
includeRegistrationAccessToken: boolean;
+48 -32
View File
@@ -26,7 +26,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
zip,
pathInZip,
documentId,
format = FileOperationFormat.MarkdownZip,
format,
includeAttachments,
pathMap,
}: {
@@ -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);
}
});
});
@@ -1241,6 +1241,7 @@ describe("#collections.create", () => {
expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(body.data.permission).toBe(null);
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
});
@@ -1256,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, {
@@ -1447,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>;
+1 -1
View File
@@ -29,7 +29,7 @@ router.post(
auth(),
validate(T.CreateTestUsersSchema),
async (ctx: APIContext<T.CreateTestUsersReq>) => {
const { count = 10 } = ctx.input.body;
const { count } = ctx.input.body;
const invites = Array(Math.min(count, 100))
.fill(0)
.map(() => {
+2 -59
View File
@@ -29,6 +29,7 @@ import documentDuplicator from "@server/commands/documentDuplicator";
import documentLoader from "@server/commands/documentLoader";
import documentMover from "@server/commands/documentMover";
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
import documentRestorer from "@server/commands/documentRestorer";
import documentUpdater from "@server/commands/documentUpdater";
import env from "@server/env";
import {
@@ -51,7 +52,6 @@ import {
Document,
DocumentInsight,
Event,
Revision,
SearchQuery,
Template,
User,
@@ -89,7 +89,6 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
import { streamZipResponse } from "@server/utils/koa";
import { getTeamFromContext } from "@server/utils/passport";
import { assertPresent } from "@server/validation";
import pagination, { paginateQuery } from "../middlewares/pagination";
import * as T from "./schema";
import {
@@ -968,63 +967,7 @@ router.post(
transaction,
});
const sourceCollectionId = document.collectionId;
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
: undefined;
const destCollection = destCollectionId
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
: undefined;
if (!destCollection?.isActive) {
throw ValidationError(
"Unable to restore, the collection may have been deleted or archived"
);
}
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
authorize(user, "updateDocument", srcCollection);
await srcCollection?.removeDocumentInStructure(document, {
save: true,
transaction,
});
}
if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
// restore a previously deleted document
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
} else if (document.archivedAt) {
authorize(user, "unarchive", document);
authorize(user, "updateDocument", destCollection);
// restore a previously archived document
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
} else if (revisionId) {
// restore a document to a specific revision
authorize(user, "update", document);
const revision = await Revision.findByPk(revisionId, { transaction });
authorize(document, "restore", revision);
await document.restoreFromRevision(revision);
await document.saveWithCtx(ctx, undefined, { name: "restore" });
} else {
assertPresent(revisionId, "revisionId is required");
}
await documentRestorer(ctx, { document, collectionId, revisionId });
ctx.body = {
data: await presentDocument(ctx, document),
+5 -1
View File
@@ -19,6 +19,7 @@ import { collectionTools } from "@server/tools/collections";
import { commentTools } from "@server/tools/comments";
import { documentTools } from "@server/tools/documents";
import { fetchTool } from "@server/tools/fetch";
import { templateTools } from "@server/tools/templates";
import { userTools } from "@server/tools/users";
import { version } from "../../../package.json";
@@ -29,7 +30,9 @@ const defaultInstructions = `Document markdown content must not begin with a top
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs.
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.`;
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.
When asked to create a document that follows a template, use the "list_templates" tool to find a matching template; each result already includes the template body as markdown. To use it unchanged, pass its ID as templateId to "create_document" and the new document is pre-filled from it. To adapt it first, modify the returned body and pass the result as the text parameter to "create_document". Either way no separate fetch is needed.`;
/**
* Creates a fresh MCP server instance with tools filtered by the OAuth
@@ -62,6 +65,7 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
commentTools(server, scopes);
documentTools(server, scopes);
fetchTool(server, scopes);
templateTools(server, scopes);
userTools(server, scopes);
return server;
+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
+1
View File
@@ -60,6 +60,7 @@ describe("collection tools", () => {
expect(data.color).toEqual("#FF0000");
expect(data.id).toBeDefined();
expect(data.url).toMatch(/^https?:\/\//);
expect(data.permission).toEqual(null);
});
it("update_collection updates fields on existing collection", async () => {
+1 -2
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import { Sequelize, Op, type WhereOptions } from "sequelize";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CollectionPermission } from "@shared/types";
import { Collection, Team } from "@server/models";
import { sequelize } from "@server/storage/database";
import { authorize } from "@server/policies";
@@ -179,7 +178,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
color: input.color,
teamId: user.teamId,
createdById: user.id,
permission: CollectionPermission.ReadWrite,
permission: null,
});
await collection.saveWithCtx(ctx);
+175
View File
@@ -4,6 +4,7 @@ import {
buildViewer,
buildCollection,
buildDocument,
buildTemplate,
buildOAuthAuthentication,
} from "@server/test/factories";
import { Document } from "@server/models";
@@ -189,6 +190,82 @@ describe("create_document", () => {
expect(data.document.parentDocumentId).toEqual(parent.id);
});
it("creates from a template", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
text: "Content from the template",
});
const res = await callMcpTool(server, accessToken, "create_document", {
title: "From Template",
collectionId: collection.id,
templateId: template.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
const text = res?.result?.content?.[1]?.text ?? "";
expect(res?.result?.isError).not.toBe(true);
expect(data.document.title).toEqual("From Template");
expect(data.document.templateId).toEqual(template.id);
expect(text).toContain("Content from the template");
});
it("defaults the title to the template title", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
title: "Template Title",
});
const res = await callMcpTool(server, accessToken, "create_document", {
collectionId: collection.id,
templateId: template.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
expect(res?.result?.isError).not.toBe(true);
expect(data.document.title).toEqual("Template Title");
});
it("does not allow creating from a template the user cannot access", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const otherUser = await buildUser();
const otherCollection = await buildCollection({
teamId: otherUser.teamId,
userId: otherUser.id,
});
const template = await buildTemplate({
teamId: otherUser.teamId,
userId: otherUser.id,
collectionId: otherCollection.id,
});
const res = await callMcpTool(server, accessToken, "create_document", {
title: "From Template",
collectionId: collection.id,
templateId: template.id,
});
expect(res?.result?.isError).toBe(true);
});
it("does not allow a viewer to create a draft", async () => {
const user = await buildViewer();
const auth = await buildOAuthAuthentication({
@@ -458,3 +535,101 @@ describe("move_document", () => {
expect(res?.result?.isError).toBe(true);
});
});
describe("restore_document", () => {
it("restores an archived document", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
archivedAt: new Date(),
});
const res = await callMcpTool(server, accessToken, "restore_document", {
id: document.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
expect(res?.result?.isError).toBeUndefined();
expect(data.document.id).toEqual(document.id);
const reloaded = await Document.unscoped().findByPk(document.id);
expect(reloaded?.archivedAt).toBeNull();
});
it("restores a trashed document", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
await document.destroy();
const res = await callMcpTool(server, accessToken, "restore_document", {
id: document.id,
});
expect(res?.result?.isError).toBeUndefined();
const reloaded = await Document.unscoped().findByPk(document.id, {
paranoid: false,
});
expect(reloaded?.deletedAt).toBeNull();
});
it("restores into a different collection", async () => {
const { user, accessToken } = await buildOAuthUser();
const source = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const destination = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: source.id,
archivedAt: new Date(),
});
const res = await callMcpTool(server, accessToken, "restore_document", {
id: document.id,
collectionId: destination.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
expect(res?.result?.isError).toBeUndefined();
expect(data.document.collectionId).toEqual(destination.id);
});
it("fails when the document is not archived or trashed", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const res = await callMcpTool(server, accessToken, "restore_document", {
id: document.id,
});
expect(res?.result?.isError).toBe(true);
});
});
+92 -4
View File
@@ -6,9 +6,10 @@ import documentCreator, {
authorizeDocumentPublish,
} from "@server/commands/documentCreator";
import documentMover from "@server/commands/documentMover";
import documentRestorer from "@server/commands/documentRestorer";
import documentUpdater from "@server/commands/documentUpdater";
import { Op } from "sequelize";
import { Collection, Document } from "@server/models";
import { Collection, Document, Template } from "@server/models";
import { sequelize } from "@server/storage/database";
import { authorize, can } from "@server/policies";
import {
@@ -300,13 +301,15 @@ export function documentTools(server: McpServer, scopes: string[]) {
{
title: "Create document",
description:
"Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document.",
"Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document. Pass a templateId (from list_templates) to pre-fill the document from a template; the template's content is used unless text is also provided.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
title: z.string().describe("The title of the document."),
title: optionalString().describe(
"The title of the document. Defaults to the template's title when a templateId is provided."
),
text: z
.string()
.optional()
@@ -317,6 +320,9 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: optionalString().describe(
"The parent document ID to nest this document under."
),
templateId: optionalString().describe(
"The ID of a template to pre-fill the new document from. The template's title, content, icon, and color are used unless overridden by the corresponding parameters."
),
icon: optionalString().describe(
"An icon for the document, e.g. an emoji."
),
@@ -339,7 +345,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
},
withTracing("create_document", async (input, context) => {
try {
const { collectionId, parentDocumentId } = input;
const { collectionId, parentDocumentId, templateId } = input;
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
@@ -348,6 +354,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId,
});
let template: Template | null | undefined;
if (templateId) {
template = await Template.findByPk(templateId, {
userId: user.id,
});
authorize(user, "read", template);
}
const document = await documentCreator(ctx, {
title: input.title,
text: input.text,
@@ -356,6 +370,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: parentDocumentId,
publish: input.publish !== false,
collectionId: collection?.id,
template,
fullWidth: input.fullWidth,
});
@@ -697,4 +712,77 @@ export function documentTools(server: McpServer, scopes: string[]) {
})
);
}
if (AuthenticationHelper.canAccess("documents.restore", scopes)) {
server.registerTool(
"restore_document",
{
title: "Restore document",
description:
"Restores an archived or trashed document, making it active again. Optionally provide a collectionId to restore the document into a different collection; otherwise it returns to its original collection.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
id: z
.string()
.describe("The unique identifier of the document to restore."),
collectionId: optionalString().describe(
"The collection to restore the document into. Defaults to its original collection."
),
},
},
withTracing("restore_document", async ({ id, collectionId }, context) => {
try {
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
return await sequelize.transaction(async (transaction) => {
ctx.state.transaction = transaction;
ctx.context.transaction = transaction;
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
rejectOnEmpty: true,
transaction,
});
if (!document.deletedAt && !document.archivedAt) {
return error("Document is not archived or trashed");
}
await documentRestorer(ctx, { document, collectionId });
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
presentDocument(document, {
includeData: false,
includeText: true,
includeUpdatedAt: true,
}),
getDocumentBreadcrumb(document, user),
]);
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
document: pathToUrl(user.team, attributes),
...(breadcrumb !== undefined && { breadcrumb }),
}),
},
{
type: "text" as const,
text: typeof text === "string" ? text : "",
},
],
} satisfies CallToolResult;
});
} catch (message) {
return error(message);
}
})
);
}
}
+29
View File
@@ -3,6 +3,7 @@ import {
buildComment,
buildDocument,
buildResolvedComment,
buildTemplate,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
@@ -94,4 +95,32 @@ describe("fetch", () => {
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
expect(metadata.document.commentCount).toEqual(2);
});
it("returns template metadata and markdown", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
text: "Body of the template",
});
const res = await callMcpTool(server, accessToken, "fetch", {
resource: "template",
id: template.id,
});
expect(res?.result?.isError).not.toBe(true);
expect(res!.result!.content!.length).toEqual(2);
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
expect(metadata.id).toEqual(template.id);
expect(metadata.url).toMatch(/^https?:\/\//);
expect(res!.result!.content![1].text).toContain("Body of the template");
});
});
+39 -3
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Attachment, Collection, Document, User } from "@server/models";
import {
Attachment,
Collection,
Document,
Template,
User,
} from "@server/models";
import { authorize, can } from "@server/policies";
import { AuthorizationError } from "@server/errors";
import {
@@ -11,6 +17,7 @@ import {
} from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { presentDocument } from "./documents";
import { presentTemplate } from "./templates";
import {
error,
success,
@@ -68,12 +75,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
"attachments.info",
scopes
);
const canReadTemplates = AuthenticationHelper.canAccess(
"templates.info",
scopes
);
if (
!canReadDocuments &&
!canReadCollections &&
!canReadUsers &&
!canReadAttachments
!canReadAttachments &&
!canReadTemplates
) {
return;
}
@@ -83,6 +95,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []),
...(canReadTemplates ? ["template"] : []),
] as [string, ...string[]];
server.registerTool(
@@ -90,7 +103,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{
title: "Fetch",
description:
'Fetches a document, collection, user, or attachment by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly.',
'Fetches a document, collection, user, attachment, or template by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly. For templates, the response includes the template body as markdown.',
annotations: {
idempotentHint: true,
readOnlyHint: true,
@@ -198,6 +211,29 @@ export function fetchTool(server: McpServer, scopes: string[]) {
});
}
case "template": {
const template = await Template.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
});
authorize(actor, "read", template);
const { text, ...attributes } = await presentTemplate(template);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(pathToUrl(actor.team, attributes)),
},
{
type: "text" as const,
text,
},
],
} satisfies CallToolResult;
}
default:
return error(`Unknown resource: ${resource}`);
}
+115
View File
@@ -0,0 +1,115 @@
import { Scope } from "@shared/types";
import {
buildUser,
buildCollection,
buildTemplate,
buildOAuthAuthentication,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
const server = getTestServer();
describe("list_templates", () => {
it("returns workspace and collection templates the user can access", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const workspaceTemplate = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: null,
text: "Body of the workspace template",
});
const collectionTemplate = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const res = await callMcpTool(server, accessToken, "list_templates");
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(ids).toContain(workspaceTemplate.id);
expect(ids).toContain(collectionTemplate.id);
const match = data.find(
(t: { id: string }) => t.id === workspaceTemplate.id
) as { url: string; collectionId: string | null; text: string };
expect(match.url).toMatch(/^https?:\/\//);
expect(match.collectionId).toBeNull();
expect(match.text).toContain("Body of the workspace template");
});
it("filters by collection", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection1 = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const collection2 = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template1 = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection1.id,
});
await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection2.id,
});
const res = await callMcpTool(server, accessToken, "list_templates", {
collectionId: collection1.id,
});
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(ids).toContain(template1.id);
expect(
data.every(
(t: { collectionId: string }) => t.collectionId === collection1.id
)
).toBe(true);
});
it("does not return templates from collections the user cannot access", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const privateCollection = await buildCollection({
teamId: owner.teamId,
userId: owner.id,
permission: null,
});
const privateTemplate = await buildTemplate({
teamId: owner.teamId,
userId: owner.id,
collectionId: privateCollection.id,
});
const auth = await buildOAuthAuthentication({
user: otherUser,
scope: [Scope.Read],
});
const res = await callMcpTool(server, auth.accessToken!, "list_templates");
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(res?.result?.isError).not.toBe(true);
expect(ids).not.toContain(privateTemplate.id);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Op } from "sequelize";
import type { WhereOptions } from "sequelize";
import { Collection, Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import {
error,
success,
getActorFromContext,
optionalString,
pathToUrl,
withTracing,
} from "./util";
/**
* Presents a template's metadata and rendered markdown body for a tool
* response. Including the body lets a caller list templates and create a
* document from one — verbatim or adapted — without a separate fetch call.
*
* @param template - the template to present.
* @returns the presented template with its body as markdown.
*/
export async function presentTemplate(template: Template) {
return {
id: template.id,
url: template.path,
title: template.title,
collectionId: template.collectionId ?? null,
updatedAt: template.updatedAt,
text: template.content
? await DocumentHelper.toMarkdown(template.content, {
includeTitle: false,
})
: "",
};
}
/**
* Registers template-related MCP tools on the given server, filtered by the
* OAuth scopes granted to the current token.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function templateTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("templates.list", scopes)) {
server.registerTool(
"list_templates",
{
title: "List templates",
description:
"Lists document templates the user has access to, including workspace-wide templates and templates within accessible collections. Each result includes the template body as markdown. To create a document from a template unchanged, pass its ID as templateId to create_document. To adapt it first, modify the returned text and pass it as the text parameter to create_document — no separate fetch is needed.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
collectionId: optionalString().describe(
"A collection ID to filter templates by. Omit to include workspace-wide templates and templates from all accessible collections."
),
offset: z.coerce
.number()
.int()
.min(0)
.optional()
.describe("The pagination offset. Defaults to 0."),
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.optional()
.describe(
"The maximum number of results to return. Defaults to 25, max 100."
),
},
},
withTracing(
"list_templates",
async ({ collectionId, offset, limit }, extra) => {
try {
const user = getActorFromContext(extra);
const effectiveOffset = offset ?? 0;
const effectiveLimit = limit ?? 25;
const where: WhereOptions<Template> & {
[Op.and]: WhereOptions<Template>[];
} = {
teamId: user.teamId,
[Op.and]: [{ deletedAt: { [Op.eq]: null } }],
};
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
where[Op.and].push({ collectionId });
} else {
where[Op.and].push({
[Op.or]: [
{ collectionId: { [Op.eq]: null } },
{ collectionId: await user.collectionIds() },
],
});
}
const templates = await Template.scope([
"defaultScope",
{ method: ["withMembership", user.id] },
]).findAll({
where,
order: [["updatedAt", "DESC"]],
offset: effectiveOffset,
limit: effectiveLimit,
});
const presented = await Promise.all(
templates.map(async (template) =>
pathToUrl(user.team, await presentTemplate(template))
)
);
return success(presented);
} catch (message) {
return error(message);
}
}
)
);
}
}
+35
View File
@@ -50,6 +50,41 @@ describe("validateUrlNotPrivate", () => {
).rejects.toThrow("is not allowed");
});
it.each([
["::ffff:169.254.169.254", "metadata via IPv4-mapped IPv6"],
["::ffff:127.0.0.1", "loopback via IPv4-mapped IPv6"],
["::ffff:10.0.0.1", "RFC1918 via IPv4-mapped IPv6"],
["::ffff:192.168.1.1", "RFC1918 via IPv4-mapped IPv6"],
["64:ff9b::a9fe:a9fe", "metadata via NAT64"],
["2002:a9fe:a9fe::", "metadata via 6to4"],
])("should reject %s in URL (%s)", async (address) => {
await expect(
validateUrlNotPrivate(`https://[${address}]/api`)
).rejects.toThrow("is not allowed");
});
it("should reject IPv4-mapped IPv6 address resolved via DNS", async () => {
lookupSpy.mockResolvedValue({
address: "::ffff:169.254.169.254",
family: 6,
});
await expect(
validateUrlNotPrivate("https://metadata.example.com")
).rejects.toThrow("is not allowed");
});
it("should reject carrier-grade NAT address", async () => {
await expect(
validateUrlNotPrivate("https://100.64.0.1/api")
).rejects.toThrow("is not allowed");
});
it("should reject IPv4-mapped IPv6 addresses outright", async () => {
await expect(
validateUrlNotPrivate("https://[::ffff:8.8.8.8]/")
).rejects.toThrow("is not allowed");
});
describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => {
it("should allow exact IP match", async () => {
env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"];
+6 -11
View File
@@ -7,15 +7,6 @@ import { InvalidRequestError } from "@server/errors";
const UrlIdLength = 10;
/** IP ranges that are not allowed for outbound requests. */
const privateRanges = new Set([
"private",
"loopback",
"linkLocal",
"uniqueLocal",
"unspecified",
]);
export const generateUrlId = () => randomString(UrlIdLength);
// Paths probed by vulnerability scanners.
@@ -53,7 +44,9 @@ export function isPrivateIP(ip: string): boolean {
if (!ipaddr.isValid(ip)) {
return false;
}
return privateRanges.has(ipaddr.parse(ip).range());
// Only globally-routable unicast addresses are permitted
return ipaddr.parse(ip).range() !== "unicast";
}
/**
@@ -102,7 +95,9 @@ function isAllowedPrivateIP(ip: string): boolean {
* @throws InternalError if the URL resolves to a private IP that is not allowed.
*/
export async function validateUrlNotPrivate(url: string) {
const { hostname } = new URL(url);
// URL.hostname keeps the square brackets around IPv6 literals (e.g.
// "[::1]"), which net.isIP does not accept, so strip them before checking.
const hostname = new URL(url).hostname.replace(/^\[|\]$/g, "");
if (net.isIP(hostname)) {
if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) {
+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>
);
}
+88 -2
View File
@@ -65,6 +65,38 @@ function restoreColumnSelection(
}
}
/**
* A command that places a text cursor at the start of the cell at the given row
* and column index within the table that begins at the given position. Used
* after inserting a row or column so that the selection lands inside the newly
* inserted cell rather than the shifted neighbouring one.
*
* @param tableStart The position inside the table (after the table node).
* @param rowIndex The row index of the target cell.
* @param columnIndex The column index of the target cell.
* @returns The command.
*/
function setCursorInCell(
tableStart: number,
rowIndex: number,
columnIndex: number
): Command {
return (state, dispatch) => {
const table = state.doc.nodeAt(tableStart - 1);
if (!table) {
return false;
}
const map = TableMap.get(table);
if (rowIndex >= map.height || columnIndex >= map.width) {
return false;
}
const pos = map.positionAt(rowIndex, columnIndex, table);
const $pos = state.doc.resolve(tableStart + pos + 1);
dispatch?.(state.tr.setSelection(TextSelection.near($pos)));
return true;
};
}
export function createTable({
rowsCount,
colsCount,
@@ -522,7 +554,7 @@ export function addRowBefore({ index }: { index?: number }): Command {
(s, d) =>
!!d?.(addRowWithAlignment(s.tr, rect, position, copyFromRow, s)),
headerSpecialCase ? toggleHeader("row") : undefined,
collapseSelection()
setCursorInCell(rect.tableStart, position, 0)
)(state, dispatch);
return true;
@@ -588,7 +620,61 @@ export function addColumnBefore({ index }: { index?: number }): Command {
headerSpecialCase ? toggleHeader("column") : undefined,
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
headerSpecialCase ? toggleHeader("column") : undefined,
collapseSelection()
setCursorInCell(rect.tableStart, 0, position)
)(state, dispatch);
return true;
};
}
/**
* A command that adds a row after the given index (or the current selection),
* copying alignment from the row above and placing the cursor in the new row.
*
* @param index The index of the row to add after, if undefined the current selection is used
* @returns The command
*/
export function addRowAfter({ index }: { index?: number }): Command {
return (state, dispatch) => {
if (!isInTable(state)) {
return false;
}
const rect = selectedRect(state);
const position = index !== undefined ? index + 1 : rect.bottom;
// Copy alignment from the row above the insertion point.
const copyFromRow = position - 1;
chainTransactions(
(s, d) =>
!!d?.(addRowWithAlignment(s.tr, rect, position, copyFromRow, s)),
setCursorInCell(rect.tableStart, position, 0)
)(state, dispatch);
return true;
};
}
/**
* A command that adds a column after the given index (or the current selection),
* placing the cursor in the new column.
*
* @param index The index of the column to add after, if undefined the current selection is used
* @returns The command
*/
export function addColumnAfter({ index }: { index?: number }): Command {
return (state, dispatch) => {
if (!isInTable(state)) {
return false;
}
const rect = selectedRect(state);
const position = index !== undefined ? index + 1 : rect.right;
chainTransactions(
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
setCursorInCell(rect.tableStart, 0, position)
)(state, dispatch);
return true;
+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 } : {});
}
}
+1
View File
@@ -46,6 +46,7 @@ function Caption({ placeholder, children, isSelected, width, ...rest }: Props) {
tabIndex={-1}
aria-label={t("Caption")}
role="textbox"
draggable={false}
contentEditable
suppressContentEditableWarning
data-caption={placeholder}
@@ -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,
+11 -37
View File
@@ -1,6 +1,9 @@
import type { NodeType } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "../lib/Extension";
import {
requiresTrailingNode,
trailingNodeNotAfter,
} from "../lib/trailingNode";
/**
* Options for the TrailingNode extension.
@@ -20,15 +23,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
get defaultOptions(): TrailingNodeOptions {
return {
node: "paragraph",
notAfter: ["paragraph", "heading"],
notAfter: trailingNodeNotAfter,
};
}
get plugins() {
const plugin = new PluginKey(this.name);
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node: NodeType) => this.options.notAfter.includes(node.name));
return [
new Plugin({
@@ -49,38 +49,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
},
}),
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value;
}
const lastNode = tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
init: (_, state) =>
requiresTrailingNode(state.doc, this.options.notAfter),
apply: (tr, value) =>
tr.docChanged
? requiresTrailingNode(tr.doc, this.options.notAfter)
: value,
},
}),
];
+12
View File
@@ -273,7 +273,19 @@ export default class ExtensionManager {
return;
}
if (extension.focusAfterExecution) {
// Focusing a blurred editor (e.g. when the command is run from a
// menu that holds focus) can collapse a non-text selection such as
// a table cell selection. Restore it so selection-based commands
// operate on the intended selection.
const { selection } = view.state;
view.focus();
if (!view.state.selection.eq(selection)) {
view.dispatch(
view.state.tr
.setSelection(selection)
.setMeta("addToHistory", false)
);
}
}
return callback(attrs)?.(view.state, view.dispatch, view);
};
+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");
}
});
+77
View File
@@ -0,0 +1,77 @@
import { Schema } from "prosemirror-model";
import { requiresTrailingNode, withTrailingNode } from "./trailingNode";
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*" },
heading: { group: "block", content: "inline*" },
code_block: { group: "block", content: "inline*" },
image: { group: "inline", inline: true },
text: { group: "inline" },
},
});
const doc = (...children: object[]) =>
schema.nodeFromJSON({ type: "doc", content: children });
const paragraph = (text?: string) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
});
describe("requiresTrailingNode", () => {
it("is false when the document ends in a paragraph", () => {
expect(requiresTrailingNode(doc(paragraph("hello")))).toBe(false);
});
it("is false when the document ends in a heading", () => {
expect(
requiresTrailingNode(
doc({ type: "heading", content: [{ type: "text", text: "title" }] })
)
).toBe(false);
});
it("is true when the document ends in another block type", () => {
expect(
requiresTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
)
).toBe(true);
});
it("is true when the last paragraph contains only non-text content", () => {
expect(
requiresTrailingNode(
doc({ type: "paragraph", content: [{ type: "image" }] })
)
).toBe(true);
});
});
describe("withTrailingNode", () => {
it("appends a trailing paragraph when required", () => {
const result = withTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
);
expect(result.childCount).toBe(2);
expect(result.lastChild?.type.name).toBe("paragraph");
expect(result.lastChild?.content.size).toBe(0);
});
it("is a no-op when a trailing paragraph already exists", () => {
const input = doc(
{ type: "code_block", content: [{ type: "text", text: "x" }] },
paragraph()
);
expect(withTrailingNode(input).eq(input)).toBe(true);
});
it("is idempotent", () => {
const once = withTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
);
expect(withTrailingNode(once).eq(once)).toBe(true);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { Fragment, type Node } from "prosemirror-model";
/** Node names after which a trailing paragraph is not required. */
export const trailingNodeNotAfter = ["paragraph", "heading"];
/**
* Determines whether the editor would insert a trailing paragraph after the
* document's last node. Mirrors the behavior of the TrailingNode extension so
* that stored content can be normalized to match the editor, avoiding a
* spurious edit the first time a document is opened.
*
* @param doc The document node to inspect.
* @param notAfter Node names after which a trailing node is not required.
* @returns whether a trailing paragraph is required.
*/
export function requiresTrailingNode(
doc: Node,
notAfter: string[] = trailingNodeNotAfter
): boolean {
const lastNode = doc.lastChild;
if (!lastNode) {
return false;
}
// A paragraph holding only non-text content (eg. images) still needs a
// trailing node so the cursor can be placed after it.
if (
lastNode.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return !notAfter.includes(lastNode.type.name);
}
/**
* Appends a trailing paragraph to the document if the editor would add one on
* load, returning the normalized document unchanged otherwise.
*
* @param doc The document node to normalize.
* @param notAfter Node names after which a trailing node is not required.
* @returns the document, with a trailing paragraph appended when required.
*/
export function withTrailingNode(
doc: Node,
notAfter: string[] = trailingNodeNotAfter
): Node {
const paragraph = doc.type.schema.nodes.paragraph;
if (!paragraph || !requiresTrailingNode(doc, notAfter)) {
return doc;
}
return doc.copy(doc.content.append(Fragment.from(paragraph.create())));
}
+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);
});
+8 -4
View File
@@ -42,6 +42,7 @@ import {
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode, isMermaid } from "../lib/isCode";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
@@ -447,17 +448,20 @@ export default class CodeFence extends Node<CodeFenceOptions> {
return prev;
}
// Recompute tall blocks on doc changes, preserving
// user collapse/expand choices where possible.
// Recompute tall blocks on doc changes. Newly tall blocks are only
// auto-collapsed when content arrives via load/remote sync — never
// while the user is typing, which would collapse the block under
// the cursor.
if (tr.docChanged) {
const tallBlocks = findTallBlocks(newState.doc);
const collapsedBlocks = new Set<number>();
const isRemote = isRemoteTransaction(tr);
const inverse = tr.mapping.invert();
for (const pos of tallBlocks) {
const oldPos = inverse.map(pos);
if (!prev.tallBlocks.has(oldPos)) {
// Newly tall blocks start collapsed
if (isRemote && !prev.tallBlocks.has(oldPos)) {
// Newly tall blocks start collapsed on load
collapsedBlocks.add(pos);
} else if (prev.collapsedBlocks.has(oldPos)) {
// Preserve previous collapsed state
+61 -22
View File
@@ -9,12 +9,12 @@ import type {
import type { Command } from "prosemirror-state";
import { NodeSelection, Plugin, TextSelection } from "prosemirror-state";
import * as React from "react";
import { sanitizeImageSrc } from "../../utils/urls";
import { sanitizeImageSrc, sanitizeUrl } from "../../utils/urls";
import Caption from "../components/Caption";
import ImageComponent from "../components/Image";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import type { ComponentProps } from "../types";
import type { ComponentProps, NodeAttrMark } from "../types";
import SimpleImage from "./SimpleImage";
import { LightboxImageFactory } from "../lib/Lightbox";
import { ImageSource } from "../lib/FileHelper";
@@ -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, "");
}
@@ -132,8 +132,7 @@ export default class Image extends SimpleImage {
marks: "",
group: "inline",
selectable: true,
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1289000
draggable: false,
draggable: true,
atom: true,
parseDOM: [
{
@@ -151,6 +150,12 @@ export default class Image extends SimpleImage {
const width = img?.getAttribute("width");
const height = img?.getAttribute("height");
// A link wrapping the image is stored as a node attribute rather
// than a mark, parse it back so it survives copy/paste. Sanitize
// the href as it is rendered directly into the DOM by the view.
const href = sanitizeUrl(img?.closest("a")?.getAttribute("href"));
return {
src: img?.getAttribute("src"),
alt: img?.getAttribute("alt"),
@@ -159,17 +164,16 @@ export default class Image extends SimpleImage {
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
layoutClass,
marks: href ? [{ type: "link", attrs: { href } }] : undefined,
};
},
},
{
tag: "img",
getAttrs: (dom: HTMLImageElement) => {
// Don't parse images from our own editor with this rule.
if (
dom.parentElement?.classList.contains("image") ||
dom.parentElement?.classList.contains("emoji")
) {
// Don't parse images from our own editor with this rule. A linked
// image nests the <img> inside an <a>, so check ancestors too.
if (dom.closest(".image") || dom.closest(".emoji")) {
return false;
}
@@ -206,19 +210,28 @@ export default class Image extends SimpleImage {
? `image image-${node.attrs.layoutClass}`
: "image";
const children = [
[
"img",
{
...node.attrs,
src: sanitizeImageSrc(node.attrs.src),
width: node.attrs.width,
height: node.attrs.height,
contentEditable: "false",
},
],
// `marks` is held separately below and is not a valid DOM attribute.
const { marks, ...attrs } = node.attrs;
const img = [
"img",
{
...attrs,
src: sanitizeImageSrc(node.attrs.src),
width: node.attrs.width,
height: node.attrs.height,
contentEditable: "false",
},
];
// A link applied to an image is held as a node attribute rather than a
// mark, so it must be written into the DOM explicitly here.
const linkHref = (marks as NodeAttrMark[] | undefined)?.find(
(mark) => mark.type === "link"
)?.attrs?.href;
const href = typeof linkHref === "string" ? linkHref : undefined;
const children = [href ? ["a", { href: sanitizeUrl(href) }, img] : img];
if (node.attrs.alt) {
children.push([
"p",
@@ -246,6 +259,32 @@ export default class Image extends SimpleImage {
commentedImagePlugin(),
new Plugin({
props: {
handleDOMEvents: {
dragstart: (_view, event) => {
// ProseMirror lets the browser snapshot the dragged node's DOM as
// the drag image. For images that DOM includes the caption area and
// padding, which renders as a large white box around the image.
// Substitute the image element so the drag ghost is tight to it.
if (
!(event.target instanceof HTMLElement) ||
!event.dataTransfer
) {
return false;
}
const image = event.target
.closest(`.component-${this.name}`)
?.querySelector("img");
if (image) {
const rect = image.getBoundingClientRect();
event.dataTransfer.setDragImage(
image,
event.clientX - rect.left,
event.clientY - rect.top
);
}
return false;
},
},
handleKeyDown: (view, event) => {
// prevent prosemirror's default spacebar behavior
// & zoom in if the selected node is image
+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]) => {
+4 -4
View File
@@ -3,8 +3,6 @@ import { InputRule } from "prosemirror-inputrules";
import type { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import {
addColumnAfter,
addRowAfter,
columnResizing,
deleteColumn,
deleteRow,
@@ -17,7 +15,9 @@ import {
} from "prosemirror-tables";
import {
addRowBefore,
addRowAfter,
addColumnBefore,
addColumnAfter,
addRowAndMoveSelection,
setColumnAttr,
createTable,
@@ -92,10 +92,10 @@ export default class Table extends Node {
setTableAttr,
sortTable,
addColumnBefore,
addColumnAfter: () => addColumnAfter,
addColumnAfter,
deleteColumn: () => deleteColumn,
addRowBefore,
addRowAfter: () => addRowAfter,
addRowAfter,
moveTableRow,
moveTableColumn,
deleteRow: () => deleteRow,
+4 -1
View File
@@ -6,6 +6,7 @@ import type { EditorView } from "prosemirror-view";
import { DecorationSet, Decoration } from "prosemirror-view";
import { isInTable, moveTableColumn, TableMap } from "prosemirror-tables";
import { addColumnBefore, selectColumn } from "../commands/table";
import { isMobile } from "../../utils/browser";
import {
getCellAttrs,
isValidCellAlignment,
@@ -326,7 +327,9 @@ export default class TableHeader extends Node {
)
);
if (!isDragging) {
// The add-column affordance is too small to tap on mobile, where
// columns can be added via the inline menu instead.
if (!isDragging && !isMobile()) {
if (index === 0) {
decorations.push(buildAddColumnDecoration(pos, index));
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import type { EditorView } from "prosemirror-view";
import { Plugin } from "prosemirror-state";
import { addRowBefore, selectRow, selectTable } from "../commands/table";
import { isMobile } from "../../utils/browser";
import {
getCellsInRow,
getRowsInTable,
@@ -339,7 +340,9 @@ export default class TableRow extends Node {
)
);
if (!isDragging) {
// The add-row affordance is too small to tap on mobile, where
// rows can be added via the inline menu instead.
if (!isDragging && !isMobile()) {
if (index === 0) {
decorations.push(buildAddRowDecoration(pos, index));
}
+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";
+14
View File
@@ -17,6 +17,14 @@ export enum TableLayout {
fullWidth = "full-width",
}
/** How a selection toolbar menu is presented. */
export enum MenuType {
/** A horizontal strip of buttons; nested options open behind a trigger. */
toolbar = "toolbar",
/** A vertical menu rendered directly, anchored to the selection. */
inline = "inline",
}
type Section = ({ t }: { t: TFunction }) => string;
export type MenuItem = {
@@ -140,6 +148,12 @@ export interface SelectionToolbarMenuDescriptor {
priority: number;
/** Toolbar alignment when this menu is active. Defaults to "center". */
align?: "center" | "start" | "end";
/**
* How the menu is presented. "toolbar" (default) renders a horizontal strip
* of buttons; "inline" renders a vertical menu anchored to the selection
* without requiring a trigger button.
*/
variant?: MenuType;
/**
* Returns the menu items to display for the current selection.
*
+8 -4
View File
@@ -188,6 +188,7 @@
"Collection": "Collection",
"Collections": "Collections",
"Debug": "Debug",
"Date": "Date",
"Document": "Document",
"Search results": "Search results",
"Documents": "Documents",
@@ -567,6 +568,7 @@
"Replacement": "Replacement",
"Replace": "Replace",
"Replace all": "Replace all",
"Options": "Options",
"Go to link": "Go to link",
"Open link": "Open link",
"Remove link": "Remove link",
@@ -615,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",
@@ -647,10 +647,13 @@
"Edit image URL": "Edit image URL",
"Default width": "Default width",
"Distribute columns": "Distribute columns",
"Delete table": "Delete table",
"Export as CSV": "Export as CSV",
"Delete table": "Delete table",
"Align": "Align",
"Sort": "Sort",
"Sort ascending": "Sort ascending",
"Sort descending": "Sort descending",
"Background": "Background",
"Toggle header": "Toggle header",
"Insert after": "Insert after",
"Insert before": "Insert before",
@@ -1777,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"
}

Some files were not shown because too many files have changed in this diff Show More