Compare commits

..

150 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
Tom Moor f4b80d5301 fix: PDF display does not correctly scale on ombile (#12608) 2026-06-06 10:14:35 -04:00
Tom Moor 58f0613b5f Delete .github/workflows/docker-build-check.yml 2026-06-06 09:30:50 -04:00
Tom Moor c2edd41e87 Enable manual runs of 'Publish build' workflow 2026-06-06 09:05:01 -04:00
Tom Moor 62c0f15c06 Switch from ubicloud -> blacksmith (#12606) 2026-06-06 09:02:43 -04:00
Tom Moor c75815b90c v1.8.1 2026-06-06 08:46:07 -04:00
Translate-O-Tron 33e3680782 New Crowdin updates (#12464)
* fix: New Czech translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]
2026-06-06 08:43:35 -04:00
Tom Moor b23a39bd39 Add email verification check during sign-in flow (#12605)
* Add email verification check during sign-in flow

* Add support for Entra External ID with OIDC standard verification claim
2026-06-06 08:01:26 -04:00
Tom Moor f329b56d0e fix: Hard break serialization for commonMark (#12603)
* fix: Hard break serialization for commonMark

* tests
2026-06-06 07:24:16 -04:00
Tom Moor be3f28afea fix: Remove parsing rules when not supported in editor (#12604)
* fix: Remove table markdown parsing rule when not supported in editor

* Additional nodes, tests
2026-06-05 23:42:40 -04:00
Tom Moor 997d38a6ac fix: document.collaboratorIds iterable error (#12602) 2026-06-05 23:16:45 -04:00
Tom Moor b5465376ae fix: Mermaid persisted as last used language (#12601)
* fix: Mermaid persisted as last used language

* Suppress mermaid in getFrequentCodeLanguages too
2026-06-05 22:33:03 -04:00
Tom Moor 9ec6b8309d chore: Improve handling of 'expected' network errors from webhooks (#12599) 2026-06-05 18:00:37 -04:00
Tom Moor aad2483ff9 fix: Search term highlights missing (#12598) 2026-06-05 21:57:09 +00:00
Tom Moor 0c0facc2a1 perf: Avoid empty webhook processor work via cached subscription lookup (#12593)
* Avoid empty webhook processor work via cached subscription lookup

WebhookProcessor ran for every event but most teams have no matching
webhook subscription, costing an empty processor job and a database query
per event.

Cache a team's enabled subscriptions ({ id, events }) in Redis, invalidated
by model lifecycle hooks, and add an optional BaseProcessor.shouldQueue hook
consulted by the global event queue so the webhook processor only enqueues a
job when a matching subscription exists.

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

* feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:53:40 -04:00
Tom Moor e864684d56 fix: Private IP lookup should be invalid request rather than internal error (#12592) 2026-06-05 09:19:05 -04:00
Tom Moor 88de417a21 Refactor drag-and-drop to support dragging from document lists (#12587)
* Allow dragging documents from list views into the sidebar

Previously the react-dnd provider was scoped to the sidebar, so only
sidebar rows could be dragged. This lifts the DndProvider up to the
authenticated layout so both the main content and the sidebar share a
single drag-and-drop context, and makes DocumentListItem a drag source.

Now any document in search results or paginated lists (Home, Drafts,
Collections, etc.) can be dragged into the sidebar to move it between
collections, reparent it under another document, star it, or archive it
— reusing the existing sidebar drop targets.

* Make the whole Starred section a drop target to star documents

Previously the only "create star" drop targets in the Starred section
were the thin cursors between items, so dragging a document onto the
section header or a starred row showed the drop cursor but did nothing.

Wrap the section in a catch-all drop target (mirroring the Archive
section) so dropping anywhere in Starred stars the document, while the
precise inter-item cursors still control ordering. A didDrop guard on
useDropToCreateStar prevents the catch-all from double-starring when a
nested cursor already handled the drop, and the hover highlight uses a
shallow isOver check so it only lights up when not over a nested target.

* Let document list drag ghost follow the cursor

The sidebar drag placeholder tethers the ghost near its starting x so it
stays aligned with the sidebar during reordering. When a drag starts out
in the main content (a document list item), that clamp pinned the ghost
to a narrow band, making it look stuck in a small area.

Thread a constrainToSidebar flag through the drag item (true for sidebar
drags, false for document list drags) and let the placeholder follow the
cursor freely when the drag originated outside the sidebar.

* Clarify constrainToSidebar JSDoc to match placeholder behavior

The placeholder treats an unset flag as tethered (constrainToSidebar
!== false), so external drags must set it explicitly to false rather
than leaving it unset. Update the comment to reflect that.

* css
2026-06-05 08:27:10 -04:00
Tom Moor 985038525c fix: Sticky table header styling in Safari (#12590)
* fix: Sticky table header styling in Safari

* feedback
2026-06-05 07:42:26 -04:00
Tom Moor c808bed712 Add mobile drawer UI for FilterOptions component (#12576)
* Render filter options as drawer popover on mobile

Filter options on the search page (and other FilterOptions consumers)
previously rendered as a Radix dropdown on all viewports. On mobile this
now renders as a bottom-sheet Drawer, matching the popover style already
used by context menus.

https://claude.ai/code/session_01MSjTD67PWfGbwgNA5FFoSH

* Fix filter drawer search input overlapping first option on mobile

The Input wrapper uses flex: 0 (a 0% basis), which collapsed the search
input inside the drawer's flex column so its content painted over the
first list item. Use flex: none to retain the input's natural height.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 07:42:15 -04:00
Tom Moor bfddf4bb4c fix: Unhandled server error in MCP route (#12586) 2026-06-04 23:31:16 -04:00
Tom Moor 1cc10f5fff fix: Increase valid user-supplied URL length to 1024 (#12585)
* fix: Increase valid user-supplied URL length to 1024

* fix: Wrap URL length migration in a transaction

Wrap the multi-column changeColumn operations in a transaction so a
failure on any column rolls back the whole migration rather than leaving
the database partially migrated.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:30:55 -04:00
dependabot[bot] ce3d710888 chore(deps): bump hono from 4.12.18 to 4.12.23 (#12584)
Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.23
  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-04 17:33:06 -04:00
Tom Moor 2aff3907c5 Make collection title and icon inline editable like documents (#12574)
Collection title/icon editing was gated by `isEditRoute && separateEditMode`,
which meant that in the default inline editing mode (separateEditMode off) the
title and icon were never editable inline — even though the collection
description was. This diverged from documents and from the collection
description editor.

Align the Header editing gate with documents (DataLoader) and the Overview
description editor: `isEditRoute || !separateEditMode`, so title and icon are
seamlessly editable inline whenever the user has update permission.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 09:14:04 -04:00
Tom Moor 02c5c93bd8 Account for screen safe area in mobile drawer bottom padding (#12577)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 09:13:50 -04:00
Tom Moor e9e565dc2b fix: Access request logic for collection managers (#12579)
* fix: Access request logic for collection managers

* test: Exercise collection-manager path in access request regression tests

Grant the non-workspace-admin manager a collection-level Admin membership
instead of a direct document-level membership, so authorization flows
through the collection-manager path being tested for #12567.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:24:51 -04:00
Tom Moor 4126b94f7c fix: Add gap between search input and New doc button on mobile (#12578)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 23:52:18 -04:00
Tom Moor a21ddc4999 Add tagging of outgoing emails (#12570)
* Add tagging of outgoing emails

* Detect SES configured via well-known service key

The isSES check only matched "amazonaws" in the host, so SES configured
through SMTP_SERVICE (e.g. "SES" or "SES-US-EAST-1") was not detected and
tagging headers were not applied.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:51:09 -04:00
Tom Moor 0d50f0d60a fix: Do not close icon picker on choice (#12573) 2026-06-03 23:24:32 -04:00
Tom Moor c419c3ab63 Add admin interface to change user avatars (#12405) 2026-06-03 07:32:11 -04:00
Tom Moor 8464d99589 fix: Intermitent document highlighted (#12566)
closes #12554
2026-06-03 07:05:28 -04:00
dependabot[bot] a20c8e5371 chore(deps): bump zod from 4.3.6 to 4.4.3 (#12563)
* chore(deps): bump zod from 4.3.6 to 4.4.3

Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.4.3.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: Make files.create file param optional in schema for zod 4.4

zod 4.4 changed z.custom() to reject undefined. Since validate runs
before multipart injects the file, validation failed with 400 on all
files.create requests. Mark the field optional and guard in the handler.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:34:41 -04:00
Tom Moor 3a442ec5d3 closes #12552 (#12565) 2026-06-02 22:11:24 -04:00
dependabot[bot] e32b3772b2 chore(deps-dev): bump vite-plugin-babel from 1.6.0 to 1.7.3 (#12561)
* chore(deps-dev): bump vite-plugin-babel from 1.6.0 to 1.7.3

Bumps [vite-plugin-babel](https://github.com/owlsdepartment/vite-plugin-babel) from 1.6.0 to 1.7.3.
- [Commits](https://github.com/owlsdepartment/vite-plugin-babel/commits)

---
updated-dependencies:
- dependency-name: vite-plugin-babel
  dependency-version: 1.7.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: Use include option for vite-plugin-babel TS transform

vite-plugin-babel 1.7.0 added an `include` option defaulting to
`/\.jsx?$/` (JS only) that is applied before `filter`, so .ts/.tsx
files were no longer transformed by Babel and reached the parser
with types intact. Switch to the `include` option to match TS files.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:11:03 -04:00
dependabot[bot] b2c66c5190 chore(deps-dev): bump the babel group with 7 updates (#12560)
Bumps the babel group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.28.6` | `7.29.7` |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.29.0` | `7.29.7` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.29.0` | `7.29.7` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.28.6` | `7.29.7` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.29.5` | `7.29.7` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.28.5` | `7.29.7` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.28.5` | `7.29.7` |


Updates `@babel/cli` from 7.28.6 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-cli)

Updates `@babel/core` from 7.29.0 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.29.0 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.28.6 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-plugin-transform-class-properties)

Updates `@babel/preset-env` from 7.29.5 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.28.5 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-preset-react)

Updates `@babel/preset-typescript` from 7.28.5 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/cli"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/core"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 22:01:45 -04:00
dependabot[bot] 875ba8d03c chore(deps): bump yjs from 13.6.30 to 13.6.31 (#12562)
Bumps [yjs](https://github.com/yjs/yjs) from 13.6.30 to 13.6.31.
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.30...v13.6.31)

---
updated-dependencies:
- dependency-name: yjs
  dependency-version: 13.6.31
  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-02 22:01:30 -04:00
dependabot[bot] bcf1155818 chore(deps): bump semver from 7.7.4 to 7.8.1 (#12564)
Bumps [semver](https://github.com/npm/node-semver) from 7.7.4 to 7.8.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.7.4...v7.8.1)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  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-02 22:01:03 -04:00
Tom Moor 7e252f0892 fix: Add missing safeEqual to notification unsubscribe endpoints (#12551) 2026-06-01 22:07:09 -04:00
Tom Moor b2309df76d v1.8.0 2026-06-01 08:06:02 -04:00
Tom Moor 608a68b010 fix: Missing text color on search highlight (#12547) 2026-05-31 22:13:52 -04:00
Tom Moor 991df631ca Trigger hover previews when editor has focus (#12545)
* fix: Trigger hover previews when editor has focus
2026-05-31 16:29:56 -04:00
Tom Moor ad89288eac fix: Resolve uuid to ^11.1.1 to patch CVE-2026-41907 (#12541)
Forces transitive uuid copies (8.3.2 via sequelize/bull, 9.0.1 via
@hocuspocus/*) onto the patched 11.1.1, addressing GHSA-w5hq-g745-h8pq.
11.1.1 is the highest version that is both patched and ships a CommonJS
build, which the require()-based consumers depend on.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:26:09 -04:00
Apoorv Mishra b2bb2335a1 Words separated by hyphens to be treated as a single unit for word diffing (#11272)
* fix: hyphenated word diff

* Add tests, simplify, reduce gap allowance

* tsc

* simplify

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-05-30 18:11:14 -04:00
Tom Moor 224230eaa0 perf: Remove N+1 query in documents.search (#12540) 2026-05-30 18:11:00 -04:00
Tom Moor d0ede882c6 perf: More memory improvements (#12539)
* perf: Lazy import mailparser, @fast-csv, and franc deps

Moves heavy dependencies off the startup path into the narrow async code
paths that actually use them, mirroring the mammoth lazy-import change:

- mailparser: only needed for Confluence Word imports (confluenceToHtml)
- @fast-csv/parse: only needed for CSV imports (csvToMarkdown)
- franc / iso-639-3: only needed by the DocumentUpdateText worker task

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

* perf: Lazy import jsdom dep

jsdom is one of the heaviest server dependencies but is only needed for
HTML export (ProsemirrorHelper.toHTML) and HTML import
(DocumentConverter.htmlToProsemirror). Move it to a lazy `await import`
inside those methods so its dependency tree stays off the startup path.

Both methods become async; all callers were already in async contexts.
The type-only usage in patchGlobalEnv is now an `import type`.
2026-05-30 17:31:04 -04:00
Tom Moor b189c308e5 perf: Avoid loading unused services (#12537)
* fix: Run single process when only the worker service is enabled

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

* perf: Improve memory consumption through lazy service loading

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:48:31 -04:00
Tom Moor cecc9ef576 perf: Lazy import Mammoth dep (#12538) 2026-05-30 16:48:19 -04:00
Tom Moor 553daed606 fix: Mermaid diagrams mis-sized on high-DPI/RDP displays (#11782) (#12531)
Re-frame the rendered SVG viewBox from a getBBox() measurement taken in
the visible editor rather than the hidden render element, where the
measurement is unreliable on high-DPI/RDP sessions. Bump the cache
namespace so previously mis-sized diagrams are re-rendered.
2026-05-30 08:20:46 -04:00
Tom Moor 5c991bbd5f fix: Toggle block within collapsed heading display (#12536) 2026-05-30 08:06:50 -04:00
Tom Moor 334b179048 fix: Prevent Linear unfurl errors from bubbling to error tracking (#12532)
Returning the unfurl promises without awaiting them inside the try
block meant rejections (e.g. "Entity not found: Issue") escaped the
catch and were reported to error tracking. Await them so they are
caught and returned as a handled { error } result.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 07:59:05 -04:00
Tom Moor f6fbbcb1ad fix: Enter on image should ad (#12530)
d new paragraph below
2026-05-29 23:20:47 -04:00
Tom Moor 70b6476afa Remove resize grid-snap (#12528)
* fix: Remove unused grid snapping from element resizing

Horizontal resizing snapped widths to a 5% grid, which is no longer
desired. Replace the only remaining use of the gridSnap prop (the
minimum-width clamp) with a named constant and drop the prop entirely.

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

* fix: Remove resize lag by disabling size transition while dragging

The width/height CSS transition on resizable elements existed to smooth
the discrete jumps from grid snapping. With pixel-by-pixel resizing the
element perpetually animates toward a target ~150ms in the future, so it
visibly trails the cursor. Disable the transition while actively dragging
and restore it afterwards so snap-back and collaborative size changes
still animate.

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

* fix: Constrain image resizing to editor edge instead of snapping to natural size

When dragging an element past the editor bounds, the full-width sentinel
forced the width to the natural size. For images narrower than the editor
this snapped them back to their (smaller) natural width at the boundary.
Only use the natural-width sentinel when the image is genuinely wider than
the editor; otherwise constrain to the editor edge.

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

* PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:46:59 -04:00
Tom Moor a37bb13956 perf: Avoid redundant import lookup when presenting documents (#12529)
The FileOperation import association was fetched for every non-public
document but only used when sourceMetadata is present. Move the lookup
inside that branch to eliminate an N+1 query for documents that are not
imports, benefiting every endpoint that presents documents.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:46:43 -04:00
Tom Moor c91272f820 fix: Always use HTML output from Claude
closes #12520
2026-05-29 21:53:31 -04:00
Tom Moor 60bf47ede0 fix: Prevent foreign key violation when permanently deleting a team (#12527)
The attachment cleanup loop used findAllInBatches, which advances an
OFFSET each iteration. Because the callback deletes each batch, the
remaining rows shift backwards and the advancing offset skips over them,
leaving attachments that still reference the team. team.destroy() then
failed with attachments_teamId_fkey.

Page from offset 0 until no attachments remain, and remove the now
redundant per-user attachment delete so the loop is the single
authoritative cleanup.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:47:18 -04:00
Tom Moor 03fe74710c fix: Undo/redo events duplicated (#12525)
* fix: Undo/redo events duplicated

* fix: Guard history use
Prevent cross polination of editors

* Remove unused check
2026-05-29 20:04:11 -04:00
Tom Moor 370934bb0e fix: Prevent crash inserting files when schema has no attachment node (#12526)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:28:10 -04:00
Tom Moor e044014cea fix: Disable webhooks when deleting associated user (#12524)
* fix: Disable webhooks created by deleted users

* Delete -> disable
2026-05-29 17:44:29 -04:00
dependabot[bot] 5aff60e28b chore(deps): bump axios from 1.15.2 to 1.16.1 (#12523)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 12:15:24 -04:00
Tom Moor fecca544f9 chore: Normalize permission logic between API/MCP doc creation (#12517) 2026-05-28 22:42:40 -04:00
Tom Moor 1eba87020c fix: Prevent block menu trigger when marked (#12515)
* Prevent block menu trigger when marked

* PR feedback
2026-05-28 21:30:53 -04:00
Tom Moor 3f92e96006 fix: Outdent code with shift-tab behavior (#12514)
* fix: Outdent code with shift-tab behavior

* PR feedback
2026-05-28 21:08:46 -04:00
Tom Moor ae5cd6a159 fix: Allow service worker to load on custom domains (#12502)
* fix: Allow service worker to load on custom domains

Add explicit worker-src 'self' so the service worker can register on
team custom domains. Without it, browsers fall back to script-src which
only lists env.URL and env.CDN_URL, blocking /static/sw.js on hosts
like docs.getoutline.com.

* fix: Switch worker-src approach to script-src 'self' for type safety

The @types/koa-helmet definitions don't include workerSrc. Add 'self'
to script-src instead — worker-src falls back to script-src per spec,
and 'self' matches the document origin on custom domains.

* fix: Properly add worker-src directive without script-src widening

Extract the CSP directives to a local variable so workerSrc can be
included despite koa-helmet's outdated type definitions missing it
(the underlying helmet supports it). Also drop @types/koa-helmet
since the package now ships its own (equivalent) types.
2026-05-28 09:07:05 -04:00
Tom Moor d2a0bf9923 fix: Avoid team invariant violation on OAuth authorize error (#12506)
When /oauthClients.info returns an AuthorizationError, ApiClient logs
the user out and clears auth.team. The subsequent re-render of the
Authorize component hit the strict useCurrentTeam() and threw before
the error UI could render. Make the inner hook tolerate a missing team
and fold it into the existing error branch.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:06:58 -04:00
Tom Moor deadaa00f1 fix: Disable floating toolbar interaction during open animation (#12508)
Closes #12503

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:06:55 -04:00
Tom Moor 6366859935 chore: Remove Koa middleware span reporting (#12501) 2026-05-27 23:18:37 -04:00
Tom Moor 82743b1c0a feat: Allow http webhook urls when self-hosting (#12499) 2026-05-27 22:52:15 -04:00
Tom Moor 76a3ba4e83 fix: Normalize IP addresses to avoid validation errors (#12500)
* fix: Normalize IP addresses to avoid validation errors on audit columns

Koa's `ctx.request.ip` can yield values that fail Sequelize's `isIP`
validation (X-Forwarded-For chains, IPv6 zone identifiers, "unknown"
from misconfigured proxies). This drops the IP metadata silently
instead of raising a 500 on Event/User writes.

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

* test: Cover IP normalization on User setters

Reviewer feedback. Also switches the column-options `set` to TypeScript
get/set accessors — the original approach was shadowed by the class
field declaration and never actually fired, which the new tests would
have caught.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:52:05 -04:00
Tom Moor 09e99ac98d fix: Graceful exit when import is canceled beneath import task (#12497) 2026-05-27 22:37:54 -04:00
Tom Moor c158697c91 fix: Reject image/video dimension promises with real Error (#12498)
The onerror handlers in FileHelper passed the raw DOM Event to reject,
which Sentry surfaced as "Event captured as promise rejection" with no
stack. Reject with an Error and revoke the blob URL on failure.
2026-05-27 22:34:55 -04:00
Tom Moor 7473d5b437 fix: Allow reordering subdocuments with document-only access (#12493)
* fix: Allow reordering subdocuments with document-only access

When a user has "Manage" (or any move-eligible) permission on a parent
document but no access to its collection, the sidebar drop cursors were
hidden because they gated on collection.isManualSort, and the move
handler bailed out because it built the payload from collection.id.
Fall back to the document's own collectionId and the move policy so the
reorder UX works for sourced document memberships.

* fix: Structure not refetched
parentDocumentId not provided
2026-05-27 21:33:33 -04:00
Tom Moor ded7ff994e fix: Indent/outdent (#12496) 2026-05-27 20:55:41 -04:00
Tom Moor a4a67f2cdd fix: Upgrade yauzl, improve stream close handling 2026-05-27 20:33:33 -04:00
Tom Moor c3ba14f069 chore: Refactor SelectionToolbar to menu registry (#12439)
* refactor: introduce declarative menu registry for selection toolbar

Replace the hard-coded if-else chain in SelectionToolbar with a
priority-based menu registry system. Extensions can now declare
selection toolbar menus via `selectionToolbarMenus()`, following the
same pattern as `commands()` and `keys()`.

Key changes:
- Add SelectionContext interface computed once per toolbar render
- Add SelectionToolbarMenuDescriptor for declarative menu registration
- Add selectionToolbarMenus() to Extension base class
- Add buildSelectionContext() utility to eliminate repeated state queries
- ExtensionManager collects and sorts menus from all extensions
- SelectionToolbarExtension registers all 10 existing menus
- All menu functions now accept SelectionContext instead of raw state
- SelectionToolbar uses registry lookup instead of if-else chain

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: import t directly from i18next in menu functions

Remove the `t: TFunction` parameter from all menu functions and the
`SelectionToolbarMenuDescriptor.getItems` signature. Each menu file
now imports `t` directly from i18next, matching the pattern used
throughout the rest of the codebase (e.g. Image.tsx, Link.tsx).

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: move divider menu into HorizontalRule node extension

The divider selection toolbar menu is now declared via
selectionToolbarMenus() on the HorizontalRule node class, co-locating
the menu with the node that owns it. Delete the standalone
app/editor/menus/divider.tsx file and remove the entry from
SelectionToolbarExtension.

This is the first menu migrated from the centralized toolbar extension
to an individual node extension, demonstrating the pattern for the
remaining menus.

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: check readOnly in matches predicate for divider menu

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 20:28:17 -04:00
Tom Moor e9e13c4819 Another rev on transaction statement timeout (#12483)
* Another rev on transaction statement timeout

* docs

* PR feedback
2026-05-27 20:28:03 -04:00
Tom Moor 48aa4f33ce chore: Upgrade ipaddr.js (#12491) 2026-05-27 20:27:28 -04:00
Tom Moor f7b2eb0173 Use segmented OTP input for delete confirmation dialogs (#12495) 2026-05-27 19:44:16 -04:00
Tom Moor 45c797653f feat: Format word at cursor position (#12492)
* wip

* refactor

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 18:44:07 -04:00
Tom Moor b424d92724 chore: Bump tmp dep (#12494) 2026-05-27 18:39:49 -04:00
Tom Moor 798184435b fix: Show upload progress on import dialog button (#12488)
* fix: Show upload progress on import dialog button
2026-05-27 18:28:21 -04:00
Tom Moor 0f2513346a Hardening of scope validation (#12490) 2026-05-27 18:27:34 -04:00
Tom Moor 1186ddd3c0 fix: Enable import into document with write permissions only (#12485) 2026-05-27 08:32:09 -04:00
Tom Moor c4fe093a0d fix: Skip Sentry capture for expected websocket "No access token" error (#12487)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:31:50 -04:00
Tom Moor ecaf116990 fix: Guard against out-of-range position in scrollToAnchor (#12489)
The MutationObserver callback could throw an uncaught RangeError when
posAtDOM returned a position outside the document, since the existing
try/catch only wrapped the observer setup, not the async callback.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:31:43 -04:00
Tom Moor e6f9b48530 fix: Make search highlight chip clickable in desktop app (#12482) 2026-05-26 21:23:19 -04:00
Tom Moor 70c55e4a42 feat: Add support for code blocks in comments (#12480)
* feat: Add support for code blocks in comments

* Add code_block
2026-05-26 20:38:46 -04:00
Tom Moor 667bfe68c5 fix: Retry Notion API 5xx errors with exponential backoff (#12481)
The Notion API can return transient 5xx errors during imports. Retry these
up to 8 times with exponential backoff, tracked separately from the existing
timeout/rate-limit retry budget.
2026-05-26 20:38:12 -04:00
Tom Moor 84c00cfae7 fix: Distinguish rate limiter error (#12479) 2026-05-26 20:29:56 -04:00
Tom Moor 2c3e736eb3 fix: Avoid logging error when team not found in apex auth redirect (#12478)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:29:18 -04:00
Tom Moor 64ccdca0d7 fix: Guard table content changing mid-drag (#12476)
* fix: Guard table content changing mid-drag

* docs
2026-05-26 20:18:23 -04:00
Tom Moor 62788c45e0 fix: Remove fragment from AuthenticationHelper (#12477) 2026-05-26 20:18:14 -04:00
Tom Moor b9addda229 perf: Reduce deletion batch size (#12474) 2026-05-26 20:12:26 -04:00
Tom Moor a23b04c8fa fix: Prevent ISE when tsquery tail interleaves operator and escape chars (#12475)
When a user query produces a pg-tsquery output ending in mixed `&` and `\`
characters (e.g. `"plugins"&\`), stripping them with separate single-char
regexes in a fixed order could leave a dangling `&` operator, causing
Postgres to reject the query with "no operand in tsquery".

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:10:49 -04:00
Tom Moor 15213bbeb0 fix: Skip export attachments with malformed key (#12470)
Attachments whose key ends in a trailing slash have no filename
component and cause yazl to throw, aborting the entire export. Skip
them with a warning and continue the export instead.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:57:36 -04:00
Tom Moor 03950af3b7 fix: TypeError when document.collaboratorIds is null (#12471)
Guard against null collaboratorIds when persisting collaborative
updates; the DB column has no default and can be NULL on older rows.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:56:56 -04:00
Tom Moor 8989287e8a perf: Add missing indexes on foreign keys referencing documents (#12473)
* perf: Add missing indexes on foreign keys referencing documents

Cascade deletes on the documents table were forced into sequential scans
on file_operations, share_subscriptions, and access_requests because
their documentId columns lacked a usable single-column index.

Also fixes lint-staged to skip oxlint when every staged file matches an
.oxlintrc.json ignore pattern, since oxlint exits 1 in that case.

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

* Handle oxlint no-files exit instead of mirroring ignorePatterns

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:56:46 -04:00
Tom Moor 6bab00b92e chore(deps): upgrade octokit to v5 and @octokit/auth-app to v8 (#12472)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:47:58 -04:00
Tom Moor 1e3aa2c59a fix: Scroll into view broken in virtualized sidebar (#12462) 2026-05-26 07:07:05 -04:00
Tom Moor f3da1bc79e fix: Remapping internal links on import (#12461)
* fix: No remapping of internal links

closes #9584

* PR feedback, testing

* tsc
2026-05-26 07:06:56 -04:00
Tom Moor 8c1be70198 Disable collapsing settings sidebar (#12460) 2026-05-25 21:06:14 -04:00
dependabot[bot] a9a54d5ada chore(deps): bump the aws group with 5 updates (#12455)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.1045.0` | `3.1053.0` |


Updates `@aws-sdk/client-s3` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 18:57:01 -04:00
dependabot[bot] 5126d8540e chore(deps): bump uuid from 11.1.0 to 11.1.1 (#12456)
Bumps [uuid](https://github.com/uuidjs/uuid) from 11.1.0 to 11.1.1.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/v11.1.1/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v11.1.0...v11.1.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 11.1.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-05-25 18:56:47 -04:00
dependabot[bot] 0c3ddef228 chore(deps-dev): bump terser from 5.44.1 to 5.48.0 (#12457)
Bumps [terser](https://github.com/terser/terser) from 5.44.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.44.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  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-05-25 18:56:24 -04:00
dependabot[bot] 3f207aea49 chore(deps-dev): bump oxlint from 1.50.0 to 1.66.0 (#12458)
Bumps [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) from 1.50.0 to 1.66.0.
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.66.0/npm/oxlint)

---
updated-dependencies:
- dependency-name: oxlint
  dependency-version: 1.66.0
  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-05-25 18:56:01 -04:00
Translate-O-Tron dcc3805438 New Crowdin updates (#12400)
* fix: New Danish translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Catalan translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]
2026-05-25 17:03:18 -04:00
Tom Moor ecafd5f32a chore: Update JSON importer to use zip streaming (#12380)
* chore: Update JSON importer to use zip streaming, new importer flow

* chore: Drop teamId from import urlId collision check and remove unused internal-id scaffolding

urlId is globally unique on Document/Collection so the team scope was wrong.
Also removes leftover internal-id generation in JSONAPIImportTask that was
never used in task input/output.

* Restore classes used upstream
2026-05-25 17:03:02 -04:00
Tom Moor f9dc1a3983 fix: documents.list with Draft status filter throws database error (#12426)
* fix: documents.list with Draft status filter throws database error

The count() query referenced $memberships.id$ in WHERE but had no
membership include, causing "missing FROM-clause entry for table
memberships". The findAll path was also silently dropping drafts because
withMembershipScope defaulted to defaultScope (which filters publishedAt
!= null). Pre-fetch the user's UserMembership document IDs and filter by
id IN (...) on both find and count, and pass includeDrafts: true when
the Draft filter is active.

* Preserve template/trial filters when including drafts

* Move template/trial filters into withDrafts scope

* Revert withDrafts scope filters, apply at call site instead

Adding template/trial filters to withDrafts broke includes in places
like Share's withCollectionPermissions where the document include must
remain optional (LEFT JOIN) — adding a where promoted it to INNER JOIN
and dropped shares without a documentId.
2026-05-25 17:02:46 -04:00
Wars 38eda7fa61 fix: correct Safari heading widget handling for Chinese IME (#12453)
Safari keeps the heading actions widget at the end of headings to avoid
selection issues, but the widget was still using side metadata suited to
the leading placement.

With Chinese IME composition at the end of a heading, same-position
insertion could interact with the contentEditable=false widget and leave
the editor selection stuck. After that, Backspace stopped working until
the page was refreshed.

Use positive side metadata for the Safari trailing widget so composed
text stays before it, allow relaxed selection around the widget, and add
a narrow Safari-only ArrowLeft fallback for the heading-end boundary.

Chinese IME has been manually verified. Other composition-based IMEs may
follow a similar path, but are not claimed as verified here.
2026-05-25 16:51:35 -04:00
Tom Moor 6461aabc52 feat: Add Catalan language option (#12454)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:56:32 -04:00
github-actions[bot] 4bd5585f8c fix: Update Node.js to 24.16.0 (#12448)
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-05-25 08:34:10 -04:00
Tom Moor 1a4033dd2d fix: Sporadic infinite loop rendering document with imported code blocks that have unknown languages (#12444) 2026-05-24 12:03:02 -04:00
Tom Moor 9e725d618d perf: Sidebar virtualization and re-render optimization (#12443)
* perf: Prevent action context invalidation on location change

* PR feedback

* virtualization

* fix: Initial visiblity incorrect

* PR feedback
2026-05-24 08:57:43 -04:00
Tom Moor 08c0390295 chore: Remove unnecessary package resolutions (#12442)
Remove `debug: 4.3.4` resolution which was forcing a downgrade – packages
requesting ^4.4.x now resolve to their correct versions. Remove
`ajv@npm:~8.13.0` resolution as no package in the dependency tree requests
that range anymore.

https://claude.ai/code/session_01JmpWGCUCVdKqN3MgsYc3fi

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-24 07:27:50 -04:00
Tom Moor a1b9f900c7 perf: Avoid correlated subquery in Slack hooks user lookup (#12432)
* perf: Avoid correlated subquery in Slack hooks user lookup

Query UserAuthentication directly by indexed providerId and load the
associated User and Team, instead of driving from User.findOne with a
required hasMany include — which Sequelize translates into a correlated
subquery that scans the users table.

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

* fix: Scope Slack fallback user lookup to matching AuthenticationProvider

The fallback in findUserForRequest matched any UserAuthentication with
the same providerId, which is only unique per (providerId, userId).
A colliding external user id from another workspace or provider could
resolve a user from the wrong team. Constrain via the AuthenticationProvider
join (name = "slack", providerId = serviceTeamId).

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

* test

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 14:15:22 -04:00
dependabot[bot] 92be631350 chore(deps): bump qs from 6.15.1 to 6.15.2 (#12437) 2026-05-23 11:27:57 -04:00
319 changed files with 14810 additions and 6508 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 }}
-43
View File
@@ -1,43 +0,0 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+14 -21
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: outlinewiki/outline
@@ -11,13 +12,13 @@ env:
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -37,7 +38,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -45,8 +46,6 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
@@ -61,7 +60,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -69,8 +68,6 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -90,13 +87,13 @@ jobs:
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -116,7 +113,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -124,8 +121,6 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
@@ -140,7 +135,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -148,8 +143,6 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -169,7 +162,7 @@ jobs:
retention-days: 1
merge:
runs-on: ubicloud-standard-8
runs-on: blacksmith-8vcpu-ubuntu-2404
needs:
- build-amd
- build-arm
@@ -188,8 +181,8 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
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",
{
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:24.15.0-slim AS runner
FROM node:24.16.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:24.15.0 AS deps
FROM node:24.16.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.7.1
Licensed Work: Outline 1.8.1
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-05-04
Change Date: 2030-06-06
Change License: Apache License, Version 2.0
+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) =>
+12 -8
View File
@@ -1,6 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
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";
@@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</DndProvider>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
+2 -2
View File
@@ -79,10 +79,10 @@ function Collaborators(props: Props) {
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
uniq([...collaboratorIdsSet, ...presentIds])
.filter((userId) => !users.get(userId))
.sort(),
[document.collaboratorIds, presentIds, users]
[collaboratorIdsSet, presentIds, users]
);
useEffect(() => {
+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 {
+24 -1
View File
@@ -5,6 +5,7 @@ import {
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
@@ -27,6 +28,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
@@ -98,6 +100,23 @@ function DocumentListItem(
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const [{ isDragging }, draggableRef] = useDragDocument(
document.asNavigationNode,
0,
document,
false,
false
);
const mergedRef = React.useMemo(
() =>
mergeRefs<HTMLAnchorElement>([
itemRef,
draggableRef,
] as React.Ref<HTMLAnchorElement>[]),
[itemRef, draggableRef]
);
return (
<ActionContextProvider
value={{
@@ -114,9 +133,10 @@ function DocumentListItem(
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
ref={mergedRef}
dir={document.dir}
$isStarred={document.isStarred}
$isDragging={isDragging}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
@@ -227,6 +247,7 @@ const Actions = styled(EventBoundary)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
@@ -237,6 +258,8 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
&:focus-visible {
outline: none;
+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;
};
+123 -40
View File
@@ -1,16 +1,26 @@
import { deburr } from "es-toolkit/compat";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput, Outline } from "./Input";
import type { PaginatedItem } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import * as MenuComponents from "./primitives/components/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
interface TFilterOption extends PaginatedItem {
@@ -34,7 +44,7 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
selectedKeys,
className,
onSelect,
showFilter,
@@ -45,6 +55,7 @@ const FilterOptions = ({
...rest
}: Props) => {
const { t } = useTranslation();
const isMobile = useMobile();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
@@ -58,23 +69,45 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option) => (
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
}}
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, showIcons, selectedKeys]
(option) => {
const handleClick = () => {
onSelect(option.key);
setOpen(false);
};
const icon =
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined;
// On mobile the options render inside a Drawer (bottom sheet) rather than
// a Radix dropdown menu, so use the raw menu components directly instead
// of the dropdown-bound MenuButton which expects a menu root context.
if (isMobile) {
return (
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
{icon}
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
<MenuComponents.SelectedIconWrapper aria-hidden>
{selectedKeys.includes(option.key) ? (
<CheckmarkIcon size={18} />
) : null}
</MenuComponents.SelectedIconWrapper>
</MenuComponents.MenuButton>
);
}
return (
<MenuButton
key={option.key}
icon={icon}
label={option.label}
onClick={handleClick}
selected={selectedKeys.includes(option.key)}
/>
);
},
[onSelect, showIcons, selectedKeys, isMobile]
);
const handleFilter = React.useCallback(
@@ -169,39 +202,73 @@ const FilterOptions = ({
React.useEffect(() => {
if (open) {
searchInputRef.current?.focus();
// Avoid auto-focusing on mobile as it immediately pops the on-screen
// keyboard over the drawer.
if (!isMobile) {
searchInputRef.current?.focus();
}
} else {
setQuery("");
}
}, [open]);
}, [open, isMobile]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
const trigger = (
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
);
const list = (
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
empty={<Empty />}
/>
);
// On mobile render the options inside a Drawer (bottom sheet) to match the
// popover style used by context menus across the app.
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
<DrawerTitle>{defaultLabel}</DrawerTitle>
{showFilterInput && (
<MobileSearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
margin={0}
/>
)}
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuTrigger>{trigger}</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{list}
{showFilterInput && (
<SearchInput
ref={searchInputRef}
@@ -260,6 +327,22 @@ const SearchInput = styled(Input)`
}
`;
const MobileSearchInput = styled(Input)`
/* "none" keeps an auto basis so the input retains its natural height; a
flexible/0% basis would collapse it and overlap the list below. */
flex: none;
margin: 0 6px 6px;
${NativeInput} {
/* 16px avoids iOS zooming the viewport when the input is focused. */
font-size: 16px;
}
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
-1
View File
@@ -107,7 +107,6 @@ const IconPicker = ({
const handleIconChange = React.useCallback(
(ic: string) => {
setOpen(false);
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
+7
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import {
isModKey,
@@ -117,6 +118,12 @@ function InputSearchPage({
const InputMaxWidth = styled(Input).attrs({ round: true })`
max-width: min(calc(30vw + 20px), 100%);
/* On mobile the input grows to fill the header, so add a gap before the
* adjacent action button (e.g. "New doc"). */
${breakpoint("mobile", "tablet")`
margin-inline-end: 8px;
`}
`;
const Shortcut = styled.span<{ $visible: boolean }>`
+8 -1
View File
@@ -33,6 +33,12 @@ type Props = {
align?: "start" | "end";
/** ARIA label for the menu */
ariaLabel: string;
/**
* Whether the menu should lock page scroll and trap focus while open.
* Defaults to true. Set to false to avoid the scrollbar-removal layout
* shift when the menu lives inside a scrollable container.
*/
modal?: boolean;
/** Additional component to display at the bottom of the top-level menu */
append?: React.ReactNode;
/** Callback when menu is opened */
@@ -50,6 +56,7 @@ export const DropdownMenu = observer(
children,
align = "start",
ariaLabel,
modal = true,
append,
onOpen,
onClose,
@@ -116,7 +123,7 @@ export const DropdownMenu = observer(
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange}>
<Menu open={open} onOpenChange={handleOpenChange} modal={modal}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
+17 -2
View File
@@ -6,15 +6,30 @@ import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
/**
* Whether to accept uppercase letters in addition to digits. Lowercase input
* is normalized to uppercase. Defaults to numeric only.
*/
alphanumeric?: boolean;
};
const sanitizeAlphanumeric = (value: string) =>
value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
export const OneTimePasswordInput = React.forwardRef(
function OneTimePasswordInput_(
{ length = 6, ...rest }: Props,
{ length = 6, alphanumeric, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
const alphanumericProps = alphanumeric
? {
validationType: "none" as const,
sanitizeValue: sanitizeAlphanumeric,
}
: undefined;
return (
<OneTimePasswordRoot {...rest}>
<OneTimePasswordRoot {...alphanumericProps} {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
+68 -65
View File
@@ -1,8 +1,10 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { useEffect, useState, useCallback, useMemo } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useEffect, useState, useCallback, useRef } from "react";
import {
DragActiveProvider,
SidebarScrollProvider,
} from "./components/DragActiveContext";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
@@ -58,68 +60,69 @@ function AppSidebar() {
}
}, [documents, collections, user.isViewer]);
const [dndArea, setDndArea] = useState();
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
const html5Options = useMemo(
() => ({
rootElement: dndArea,
}),
[dndArea]
);
// Scrollable reads ref.current internally for its shadow/ResizeObserver
// logic, so we must pass an object ref — a callback ref would leave those
// reads undefined. We mirror the attached node into state so the
// SidebarScrollProvider can re-render descendants with the scroll element.
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollArea, setScrollArea] = useState<HTMLElement | null>(null);
useEffect(() => {
setScrollArea(scrollRef.current);
}, []);
return (
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragPlaceholder />
<Sidebar hidden={!ui.readyToShow}>
<DragActiveProvider>
<DragPlaceholder />
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow ref={scrollRef}>
<SidebarScrollProvider value={scrollArea}>
<Section>
<Starred />
</Section>
@@ -138,9 +141,9 @@ function AppSidebar() {
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
</DndProvider>
)}
</SidebarScrollProvider>
</Scrollable>
</DragActiveProvider>
<HistoryNavigation />
</Sidebar>
);
+4 -28
View File
@@ -1,35 +1,30 @@
import { groupBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { BackIcon, SidebarIcon } from "outline-icons";
import { BackIcon } from "outline-icons";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
import useMobile from "~/hooks/useMobile";
function SettingsSidebar() {
const { ui, integrations } = useStores();
const { integrations } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const isMobile = useMobile();
const groupedConfig = groupBy(
configs.filter((item) =>
@@ -45,31 +40,12 @@ function SettingsSidebar() {
}, [history]);
return (
<Sidebar>
<Sidebar canCollapse={false}>
<SidebarButton
title={t("Return to App")}
image={<StyledBackIcon />}
onClick={returnToApp}
>
{isMobile ? null : (
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
position="bottom"
image={<SidebarIcon />}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
/>
<Flex auto column>
<Scrollable shadow>
+29 -1
View File
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
@@ -63,6 +64,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const internalRef = React.useRef<HTMLDivElement | null>(null);
const mergedRef = React.useMemo(() => mergeRefs([internalRef, ref]), [ref]);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
@@ -174,6 +177,31 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
}
}, [ui.sidebarIsClosed]);
// Reset stale hover state when the sidebar becomes visible after being
// hidden via display:none (e.g. returning from settings). Without this, a
// pointer-leave event never fires when navigating away while hovering, so
// isHovering stays true and the sidebar appears expanded until the cursor
// re-enters and leaves.
React.useEffect(() => {
const el = internalRef.current;
if (!el || typeof IntersectionObserver === "undefined") {
return;
}
let wasVisible = false;
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const nowVisible = entry.isIntersecting;
if (nowVisible && !wasVisible) {
setHovering(false);
setPointerMoved(false);
}
wasVisible = nowVisible;
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
@@ -237,7 +265,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
<TooltipProvider>
<Container
id="sidebar"
ref={ref}
ref={mergedRef}
style={style}
$hidden={hidden}
$isHovering={isHovering}
@@ -3,9 +3,10 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { UserPreference } from "@shared/types";
import { DocumentPermission, UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import type Collection from "~/models/Collection";
@@ -16,6 +17,7 @@ import type { RefHandle } from "~/components/EditableTitle";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import useOnScreen from "~/hooks/useOnScreen";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
@@ -25,6 +27,7 @@ import {
useDropToReorderDocument,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import { useIsDragActive, useSidebarScrollElement } from "./DragActiveContext";
import { useSidebarExpansion } from "./SidebarExpansionContext";
import DocumentRow from "./DocumentRow";
import DropCursor from "./DropCursor";
@@ -44,34 +47,28 @@ type Props = {
parentId?: string;
};
const DocumentLink = observer(function DocumentLinkInner({
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
depth,
index,
parentId,
}: Props) {
// Approximate rendered row height; used to reserve space for unmounted rows so
// the scroll container stays the right height and IntersectionObserver triggers
// correctly as the user scrolls.
const ROW_HEIGHT = 30;
// Pre-mount rows just outside the viewport so scrolling stays smooth and drop
// targets exist a screen ahead when a drag starts.
const ROOT_MARGIN = "300px 0px";
const DocumentLink = observer(function DocumentLink(props: Props) {
const { node, collection, activeDocument } = props;
const { documents } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(node.id);
const canUpdate = can.update;
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
const document = documents.get(node.id);
const { fetchChildDocuments } = documents;
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const { fetchChildDocuments } = documents;
// Keep expansion/data effects on the outer so they run regardless of whether
// the heavy row content is currently mounted.
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
expansion.collapse(node.id);
@@ -93,6 +90,121 @@ const DocumentLink = observer(function DocumentLinkInner({
isActiveDocument,
]);
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node.children]
);
// Visibility gate: only mount the heavy inner content when scrolled near the
// viewport, but keep it mounted while a drag is in progress so the dragged
// source (or a drop target the user is heading toward) isn't yanked.
const scrollRoot = useSidebarScrollElement();
const placeholderRef = React.useRef<HTMLDivElement>(null);
const observerOptions = React.useMemo(
() => ({ root: scrollRoot, rootMargin: ROOT_MARGIN }),
[scrollRoot]
);
const isOnScreen = useOnScreen(placeholderRef, observerOptions);
const isDragActive = useIsDragActive();
const [mounted, setMounted] = React.useState(false);
// Flip mount state during render (not in an effect) so the first paint
// already contains the row content when the placeholder is on screen,
// avoiding a blank frame.
if (isOnScreen && !mounted) {
setMounted(true);
} else if (!isOnScreen && !isDragActive && mounted) {
setMounted(false);
}
// The inner row's own scrollIntoView only fires while it is mounted, which
// skips active documents that are virtualized off-screen
React.useLayoutEffect(() => {
if (
isActiveDocument &&
sidebarContext === "collections" &&
placeholderRef.current
) {
scrollIntoView(placeholderRef.current, {
scrollMode: "if-needed",
behavior: "auto",
boundary: (parent) => parent.id !== "sidebar",
});
}
}, [isActiveDocument, sidebarContext]);
return (
<>
<div ref={placeholderRef} style={{ minHeight: ROW_HEIGHT }}>
{mounted ? (
<DocumentLinkInner {...props} hasChildren={nodeChildren.length > 0} />
) : null}
</div>
<Folder expanded={expanded}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={props.membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={props.prefetchDocument}
isDraft={childNode.isDraft}
depth={props.depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</>
);
});
type InnerProps = Props & {
hasChildren: boolean;
};
const DocumentLinkInner = observer(function DocumentLinkInner({
node,
collection,
membership,
prefetchDocument,
isDraft,
depth,
index,
parentId,
hasChildren,
}: InnerProps) {
const { documents } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(node.id);
const canUpdate = can.update;
const document = documents.get(node.id);
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const handleDisclosureClick = React.useCallback(
(ev?: React.MouseEvent<HTMLElement>) => {
if (expanded) {
@@ -192,14 +304,17 @@ const DocumentLink = observer(function DocumentLinkInner({
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
useDropToReparentDocument(node, handleExpand, parentRef);
// Fall back so document-only access (e.g. "Manage" on a parent) can reorder.
const moveCollectionId = collection?.id ?? document?.collectionId;
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
if (!moveCollectionId) {
return;
}
return {
documentId: item.id,
collectionId: collection.id,
collectionId: moveCollectionId,
parentDocumentId: parentId,
index,
};
@@ -207,49 +322,26 @@ const DocumentLink = observer(function DocumentLinkInner({
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
if (!moveCollectionId) {
return;
}
if (expansion.isExpanded(node.id)) {
return {
documentId: item.id,
collectionId: collection.id,
collectionId: moveCollectionId,
parentDocumentId: node.id,
index: 0,
};
}
return {
documentId: item.id,
collectionId: collection.id,
collectionId: moveCollectionId,
parentDocumentId: parentId,
index: index + 1,
};
});
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node.children]
);
const title = document?.title || node.title || t("Untitled");
const hasChildren = nodeChildren.length > 0;
const handleNewDoc = React.useCallback(
async (input: string) => {
@@ -300,8 +392,15 @@ const DocumentLink = observer(function DocumentLinkInner({
/>
) : undefined;
// Without a collection we can't read isManualSort; fall back to the shared
// membership's permission, which is the same for every descendant.
const canReorderHere = collection
? collection.isManualSort
: membership?.permission === DocumentPermission.Admin ||
membership?.permission === DocumentPermission.ReadWrite;
const cursorBefore =
isDraggingAnyDocument && collection?.isManualSort && index === 0 ? (
isDraggingAnyDocument && canReorderHere && index === 0 ? (
<DropCursor
isActiveDrop={isOverReorderAbove}
innerRef={dropToReorderAbove}
@@ -310,7 +409,7 @@ const DocumentLink = observer(function DocumentLinkInner({
) : undefined;
const cursorAfter =
isDraggingAnyDocument && collection?.isManualSort ? (
isDraggingAnyDocument && canReorderHere ? (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
) : undefined;
@@ -321,7 +420,7 @@ const DocumentLink = observer(function DocumentLinkInner({
to={toPath}
depth={depth}
isDraft={isDraft}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
scrollIntoViewIfNeeded={false}
icon={iconElement}
canEdit={canUpdate}
labelText={title}
@@ -348,24 +447,7 @@ const DocumentLink = observer(function DocumentLinkInner({
contextAction={contextMenuAction}
isActiveOverride={isActiveCheck}
onClickIntent={handlePrefetch}
>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</DocumentRow>
/>
);
});
@@ -0,0 +1,47 @@
import * as React from "react";
import { useDragLayer } from "react-dnd";
const DragActiveContext = React.createContext(false);
const SidebarScrollContext = React.createContext<HTMLElement | null>(null);
/**
* Provides the sidebar's scroll container so descendants can use it as the
* IntersectionObserver root when deciding whether to render heavy content.
*/
export const SidebarScrollProvider = SidebarScrollContext.Provider;
/**
* Returns the sidebar scroll container element, or null if not within a
* SidebarScrollProvider.
*/
export function useSidebarScrollElement(): HTMLElement | null {
return React.useContext(SidebarScrollContext);
}
/**
* Subscribes once to react-dnd's drag state and exposes a boolean via context.
*
* Visibility-gated sidebar rows read this to keep their inner content mounted
* for the duration of a drag, so that scrolling away from the dragged source
* (or a drop target the user is heading toward) does not unmount it mid-drag.
*/
export function DragActiveProvider({
children,
}: {
children: React.ReactNode;
}) {
const isDragging = useDragLayer((monitor) => monitor.isDragging());
return (
<DragActiveContext.Provider value={isDragging}>
{children}
</DragActiveContext.Provider>
);
}
/**
* Returns whether any react-dnd drag is currently active.
*/
export function useIsDragActive(): boolean {
return React.useContext(DragActiveContext);
}
@@ -19,7 +19,8 @@ const layerStyles: React.CSSProperties = {
function getItemStyles(
initialOffset: XYCoord | null,
currentOffset: XYCoord | null,
sidebarWidth: number
sidebarWidth: number,
constrainToSidebar: boolean
) {
if (!initialOffset || !currentOffset) {
return {
@@ -27,10 +28,14 @@ function getItemStyles(
};
}
const { y } = currentOffset;
const x = Math.max(
initialOffset.x,
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
);
// Sidebar drags keep the ghost tethered near its origin, but drags from
// outside the sidebar should follow the cursor freely.
const x = constrainToSidebar
? Math.max(
initialOffset.x,
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
)
: currentOffset.x;
const transform = `translate(${x}px, ${y}px)`;
return {
@@ -60,7 +65,14 @@ const DragPlaceholder = () => {
return (
<div style={layerStyles}>
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
<div
style={getItemStyles(
initialOffset,
currentOffset,
ui.sidebarWidth,
item.constrainToSidebar !== false
)}
>
<GhostLink
icon={item.icon}
label={item.title || t("Untitled")}
@@ -210,6 +210,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
isDraft={childNode.isDraft}
depth={2}
index={index}
parentId={document.id}
/>
))}
</Folder>
@@ -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;
+59 -38
View File
@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type Star from "~/models/Star";
import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
@@ -29,6 +31,7 @@ function Starred() {
);
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
const [createStarProps, dropToStarRef] = useDropToCreateStar();
const [sectionStarProps, dropToSectionRef] = useDropToCreateStar();
useEffect(() => {
if (error) {
@@ -42,46 +45,64 @@ function Starred() {
return (
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
<Section
ref={dropToSectionRef}
$isActiveDrop={
sectionStarProps.isDragging && sectionStarProps.isOverCursor
}
>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Section>
</Flex>
);
}
const Section = styled.div<{ $isActiveDrop?: boolean }>`
border-radius: 8px;
transition: background 100ms ease-in-out;
${(props) =>
props.$isActiveDrop &&
css`
background: ${s("sidebarActiveBackground")};
`}
`;
export default observer(Starred);
@@ -22,6 +22,12 @@ import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
/**
* Whether the drag ghost should stay tethered to the sidebar. Defaults to
* tethered when unset — the placeholder only lets the ghost follow the
* cursor when this is explicitly `false` (e.g. drags from a document list).
*/
constrainToSidebar?: boolean;
};
function useHover(
@@ -105,6 +111,12 @@ export function useDropToCreateStar(getIndex?: () => string) {
>({
accept,
drop: async (item, monitor) => {
// A more specific drop target (e.g. a reorder cursor) has already
// handled this drop, so avoid creating a duplicate star.
if (monitor.didDrop()) {
return;
}
const type = monitor.getItemType();
let model;
@@ -122,7 +134,7 @@ export function useDropToCreateStar(getIndex?: () => string) {
);
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isOverCursor: !!monitor.isOver({ shallow: true }),
isDragging: accept.includes(String(monitor.getItemType())),
}),
});
@@ -163,12 +175,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
* @param constrainToSidebar Whether the drag ghost should stay tethered to the
* sidebar. Defaults to true; pass false when dragging from outside the sidebar
* (e.g. a document list) so the ghost follows the cursor.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document,
isEditing?: boolean
isEditing?: boolean,
constrainToSidebar = true
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -188,6 +204,7 @@ export function useDragDocument(
<Icon initial={initial} value={icon} color={color} />
) : undefined,
collectionId: document?.collectionId || "",
constrainToSidebar,
}) as DragObject,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
+47 -1
View File
@@ -1,13 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import { UserValidation } from "@shared/validations";
import type User from "~/models/User";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import { client } from "~/utils/ApiClient";
import Text from "./Text";
@@ -71,7 +75,7 @@ export function UserDeleteDialog({ user, onSubmit }: Props) {
danger
>
{t(
"Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.",
"Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable. Any API keys, webhooks, and integrations they created will stop working — consider suspending the user instead.",
{
userName: user.name,
}
@@ -142,6 +146,48 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
);
}
export const UserChangeAvatarDialog = observer(function UserChangeAvatarDialog({
user,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleAvatarChange = async (avatarUrl: string | null) => {
try {
await user.save({ avatarUrl });
toast.success(t("Profile picture updated"));
} catch (err) {
toast.error(err.message);
}
};
const handleAvatarError = (error: string | null | undefined) => {
toast.error(error || t("Unable to upload new profile picture"));
};
return (
<Flex column gap={16}>
<Flex justify="center">
<ImageInput
alt={t("Profile picture")}
onSuccess={handleAvatarChange}
onError={handleAvatarError}
model={user}
showRemoveOption={false}
/>
</Flex>
<Flex justify="flex-end" gap={8}>
{user.avatarUrl && (
<Button onClick={() => handleAvatarChange(null)} neutral>
{t("Remove")}
</Button>
)}
<Button onClick={onSubmit}>{t("Done")}</Button>
</Flex>
</Flex>
);
});
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
const { t } = useTranslation();
const actor = useCurrentUser();
+4
View File
@@ -96,6 +96,10 @@ function useConnectionHandlers() {
toast.error(message);
if (message === "No access token") {
return;
}
if (err instanceof Error) {
Sentry.captureException(err);
} else {
+1
View File
@@ -102,6 +102,7 @@ const StyledContent = styled(m.div)`
const StyledInnerContent = styled(Flex)`
padding: 6px;
padding-bottom: calc(6px + var(--sab, 0px));
height: 100%;
`;
@@ -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 />
+13
View File
@@ -420,11 +420,24 @@ const Wrapper = styled.div<WrapperProps>`
box-sizing: border-box;
}
& button,
& a,
& input {
pointer-events: none;
}
${({ active }) =>
active &&
`
transform: translateY(-6px) scale(1);
opacity: 1;
& button,
& a,
& input {
pointer-events: auto;
transition: pointer-events 0s 300ms;
}
`};
@media print {
+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
) {
+22 -65
View File
@@ -2,34 +2,19 @@ import type { EditorState, Selection } from "prosemirror-state";
import Suggestion from "~/editor/extensions/Suggestion";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { useTranslation } from "react-i18next";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { buildSelectionContext } from "@shared/editor/lib/buildSelectionContext";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getColumnIndex,
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
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";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import {
columnDragPluginKey,
rowDragPluginKey,
@@ -39,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 = {
@@ -64,7 +50,6 @@ function useIsDragging(state: EditorState) {
useEventListener("dragend", setNotDragging);
useEventListener("drop", setNotDragging);
// Check if table row or column is being dragged
const columnDragState = columnDragPluginKey.getState(state);
const rowDragState = rowDragPluginKey.getState(state);
const isTableDragging =
@@ -81,8 +66,7 @@ enum Toolbar {
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, extensions, commands } = useEditor();
const { t } = useTranslation();
const { view, extensions, commands, selectionToolbarMenus } = useEditor();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
@@ -144,7 +128,6 @@ export function SelectionToolbar(props: Props) {
}
}, [activeToolbar]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useLayoutEffect(() => {
if (
@@ -175,7 +158,6 @@ export function SelectionToolbar(props: Props) {
return;
}
// Don't collapse selection if any suggestion menu is open
const isSuggestionMenuOpen = extensions.extensions.some(
(ext) => ext instanceof Suggestion && ext.isOpen
);
@@ -228,51 +210,16 @@ export function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
// Build selection context once, shared across all menu matchers
const ctx = buildSelectionContext(state, { readOnly, isTemplate, rtl });
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
// Find the first matching menu from the registry (sorted by priority)
const matched = selectionToolbarMenus.find((menu) => menu.matches(ctx));
if (
isCodeSelection &&
(selection.empty || selection instanceof NodeSelection)
) {
items = getCodeMenuItems(state, readOnly, t);
align = "end";
} else if (isTableSelected(state)) {
items = getTableMenuItems(state, readOnly, t);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, readOnly, t, {
index: colIndex,
rtl,
});
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, readOnly, t, {
index: rowIndex,
});
} else if (isImageSelection) {
items = getImageMenuItems(state, readOnly, t);
} else if (isAttachmentSelection) {
items = getAttachmentMenuItems(state, readOnly, t);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, readOnly, t);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, t);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, t);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, t);
}
let items: MenuItem[] = matched ? matched.getItems(ctx) : [];
const align = matched?.align ?? "center";
// Some extensions may be disabled, remove corresponding items
// Filter out items for disabled extensions or invisible items
items = items.filter((item) => {
if (item.name === "separator") {
return true;
@@ -318,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;
+9 -61
View File
@@ -2,10 +2,12 @@ import { useCallback, useMemo, useState } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { closeHistory } from "@shared/editor/lib/closeHistory";
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";
@@ -48,67 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} 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) => {
@@ -158,9 +104,11 @@ function ToolbarMenu(props: Props) {
}
// otherwise, run the associated editor command
closeHistory(view);
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
closeHistory(view);
};
return (
@@ -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,
};
}
+3 -1
View File
@@ -17,6 +17,7 @@ export default class BlockMenuExtension extends Suggestion {
allowSpaces: false,
requireSearchTerm: false,
enabledInCode: false,
enabledInMarks: false,
};
}
@@ -87,7 +88,8 @@ export default class BlockMenuExtension extends Suggestion {
condition: ({ node, $start, state }) =>
$start.depth === 1 &&
state.selection.$from.pos === $start.pos + node.content.size &&
node.textContent === "/",
node.textContent === "/" &&
node.firstChild?.marks.length === 0,
text: ` ${t("Keep typing to filter")}`,
},
]),
@@ -61,7 +61,7 @@ export default class ClipboardTextSerializer extends Extension {
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("\n")
: mdSerializer.serialize(slice.content, {
softBreak: true,
commonMark: true,
});
},
},
+48 -4
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,13 +634,27 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
// 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: () => {
this.clearHighlights();
// The highlight registry is global and keyed by fixed names, so
// only tear down highlights when this editor actually owns an
// active search — otherwise an unmounting editor could wipe the
// highlights another editor just set during a route transition.
if (this.searchTerm) {
this.clearHighlights();
}
},
};
},
+15 -8
View File
@@ -41,10 +41,9 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
const isHoverTarget = (target: Element | null) =>
target instanceof HTMLElement &&
this.editor.elementRef.current?.contains(target) &&
(!view.editable || (view.editable && !view.hasFocus()));
this.editor.elementRef.current?.contains(target);
let hoveringTimeout: ReturnType<typeof setTimeout>;
@@ -52,11 +51,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
new Plugin({
props: {
handleDOMEvents: {
mouseover: (view: EditorView, event: MouseEvent) => {
mouseover: (_view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
if (isHoverTarget(target)) {
hoveringTimeout = setTimeout(
action(async () => {
const element = target as HTMLElement;
@@ -79,7 +78,15 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
documentId,
});
if (unfurl) {
// The fetch is async, so the pointer may have already
// left the target (or the node may have been removed) by
// the time it resolves only show the preview if the
// element is still hovered.
if (
unfurl &&
element.isConnected &&
element.matches(":hover")
) {
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
@@ -94,11 +101,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
}
return false;
},
mouseout: action((view: EditorView, event: MouseEvent) => {
mouseout: action((_view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
if (isHoverTarget(target)) {
clearTimeout(hoveringTimeout);
this.state.activeLinkElement = null;
}
+12 -1
View File
@@ -7,10 +7,13 @@ import {
yUndoPlugin,
undo,
redo,
undoCommand,
redoCommand,
} from "y-prosemirror";
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 = {
@@ -105,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
return {
style: `background-color: ${u.color}${opacity}`,
class: "ProseMirror-yjs-selection",
class: EditorStyleHelper.multiplayerSelection,
};
};
@@ -136,4 +139,12 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
redo: () => redo,
};
}
keys() {
return {
"Mod-z": undoCommand,
"Mod-y": redoCommand,
"Shift-Mod-z": redoCommand,
};
}
}
+8 -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;
},
},
@@ -108,6 +104,12 @@ export default class PasteHandler extends Extension {
return false;
}
// If the HTML on the clipboard is from Claude then the best
// compatability is to just use the HTML parser.
if (html?.includes("font-claude-response-body")) {
return false;
}
// Check if the clipboard contents can be parsed as a single url.
// Trim first so surrounding whitespace from the clipboard (e.g. a
// trailing newline appended by the source) doesn't prevent URL
@@ -7,7 +7,21 @@ 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 {
MenuType,
type SelectionToolbarMenuDescriptor,
} from "@shared/editor/types";
import { SelectionToolbar } from "../components/SelectionToolbar";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
export default class SelectionToolbarExtension extends Extension {
get name() {
@@ -31,6 +45,75 @@ export default class SelectionToolbarExtension extends Extension {
@observable
state: Selection | boolean = false;
/**
* Returns all selection toolbar menu descriptors. Each descriptor declares
* when it matches (via a predicate on SelectionContext) and what items to
* show. The toolbar evaluates them in priority order and uses the first
* match.
*
* @returns an array of selection toolbar menu descriptors.
*/
selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] {
return [
{
priority: 100,
align: "end",
matches: (ctx) =>
ctx.isInCodeBlock &&
(ctx.isEmpty || ctx.selectedNodeType !== undefined),
getItems: (ctx) => getCodeMenuItems(ctx),
},
{
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),
},
{
priority: 50,
matches: (ctx) => ctx.selectedNodeType === "image",
getItems: (ctx) => getImageMenuItems(ctx),
},
{
priority: 50,
matches: (ctx) => ctx.selectedNodeType === "attachment",
getItems: (ctx) => getAttachmentMenuItems(ctx),
},
{
priority: 30,
matches: (ctx) => ctx.readOnly,
getItems: (ctx) =>
getReadOnlyMenuItems(
ctx,
this.editor.props.canUpdate ?? false
),
},
{
priority: 20,
align: "end",
matches: (ctx) => ctx.isInNotice && ctx.isEmpty,
getItems: (ctx) => getNoticeMenuItems(ctx),
},
{
priority: 0,
matches: () => true,
getItems: (ctx) => getFormattingMenuItems(ctx),
},
];
}
private handleUpdate = action((view: EditorView) => {
const { state } = view;
this.state = this.calculateState(state);
+44 -16
View File
@@ -4,7 +4,10 @@ import { InputRule } from "prosemirror-inputrules";
import type { NodeType, Schema } from "prosemirror-model";
import type { EditorState, Plugin } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/SuggestionsMenuPlugin";
import {
isTriggerMarked,
SuggestionsMenuPlugin,
} from "@shared/editor/plugins/SuggestionsMenuPlugin";
import { isInCode } from "@shared/editor/queries/isInCode";
/**
@@ -14,6 +17,12 @@ import { isInCode } from "@shared/editor/queries/isInCode";
export type SuggestionOptions = {
/** Whether the suggestion menu is allowed to open inside code blocks or inline code. */
enabledInCode: boolean;
/**
* Whether the suggestion menu may open when the trigger character carries a
* mark (e.g. bold, italic, link). Defaults to true disable for menus where
* the trigger is only meaningful as plain text, such as the block menu.
*/
enabledInMarks?: boolean;
/** Character (or list of characters) that opens the suggestion menu. */
trigger: string | string[];
/** Whether spaces are allowed inside the search term. */
@@ -37,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"
@@ -45,7 +54,18 @@ export default class Suggestion<
}
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.state, this.openRegex)];
return [
new SuggestionsMenuPlugin(
this.state,
this.openRegex,
this.enabledInMarks
),
];
}
/** Whether the menu may open when the trigger character carries a mark. */
protected get enabledInMarks(): boolean {
return this.options.enabledInMarks ?? true;
}
keys() {
@@ -62,21 +82,29 @@ export default class Suggestion<
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(
this.openRegex,
action((state: EditorState, match: RegExpMatchArray) => {
const { parent } = state.selection.$from;
if (
match &&
(parent.type.name === "paragraph" ||
parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode)
) {
if (match[0].length <= 2) {
this.state.open = true;
action(
(
state: EditorState,
match: RegExpMatchArray,
_start: number,
end: number
) => {
const { parent } = state.selection.$from;
if (
match &&
(parent.type.name === "paragraph" ||
parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) &&
(this.enabledInMarks || !isTriggerMarked(state, end, match))
) {
if (match[0].length <= 2) {
this.state.open = true;
}
this.state.query = match[1];
}
this.state.query = match[1];
return null;
}
return null;
})
)
),
];
+18 -7
View File
@@ -35,7 +35,10 @@ import type { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"
import textBetween from "@shared/editor/lib/textBetween";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import type ReactNode from "@shared/editor/nodes/ReactNode";
import type { ComponentProps } from "@shared/editor/types";
import type {
ComponentProps,
SelectionToolbarMenuDescriptor,
} from "@shared/editor/types";
import type {
ProsemirrorData,
ProsemirrorMark,
@@ -228,6 +231,7 @@ export class Editor extends React.PureComponent<
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
selectionToolbarMenus: SelectionToolbarMenuDescriptor[];
rulePlugins: PluginSimple[];
events = new EventEmitter();
mutationObserver?: MutationObserver;
@@ -341,6 +345,7 @@ export class Editor extends React.PureComponent<
this.view = this.createView();
this.commands = this.createCommands();
this.selectionToolbarMenus = this.extensions.selectionToolbarMenus;
}
private createExtensions() {
@@ -572,12 +577,18 @@ export class Editor extends React.PureComponent<
this.mutationObserver = observe(
hash,
(element) => {
const pos = this.view.posAtDOM(element, 0, 1);
this.view.dispatch(
this.view.state.tr.setSelection(
TextSelection.near(this.view.state.doc.resolve(pos), 1)
)
);
try {
const pos = this.view.posAtDOM(element, 0, 1);
if (pos >= 0 && pos <= this.view.state.doc.content.size) {
this.view.dispatch(
this.view.state.tr.setSelection(
TextSelection.near(this.view.state.doc.resolve(pos), 1)
)
);
}
} catch (_err) {
// posAtDOM may throw if the element is not part of the editor doc
}
if (isVisible(element)) {
element.scrollIntoView();
+11 -8
View File
@@ -1,19 +1,22 @@
import type { TFunction } from "i18next";
import { t } from "i18next";
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import type { MenuItem } from "@shared/editor/types";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
/**
* Returns menu items for the attachment selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function attachmentMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction
ctx: SelectionContext
): MenuItem[] {
if (readOnly) {
if (ctx.readOnly) {
return [];
}
const { schema } = state;
const { schema, state } = ctx;
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
preview: true,
});
+13 -17
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";
@@ -124,8 +126,6 @@ export default function blockMenuItems(
keywords: "pdf upload attach",
attrs: {
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
@@ -186,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",
+10 -6
View File
@@ -1,7 +1,6 @@
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
import type { Node as ProseMirrorNode } from "prosemirror-model";
import { NodeSelection } from "prosemirror-state";
import type { EditorState } from "prosemirror-state";
import {
pluginKey as mermaidPluginKey,
type MermaidState,
@@ -12,15 +11,20 @@ import {
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { isMermaid } from "@shared/editor/lib/isCode";
import type { TFunction } from "i18next";
import type { MenuItem } from "@shared/editor/types";
import { t } from "i18next";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
/**
* Returns menu items for the code block selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function codeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
t: TFunction
ctx: SelectionContext
): MenuItem[] {
const { state, readOnly } = ctx;
const node =
state.selection instanceof NodeSelection
? state.selection.node
-33
View File
@@ -1,33 +0,0 @@
import type { TFunction } from "i18next";
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { MenuItem } from "@shared/editor/types";
export default function dividerMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
return [
{
name: "hr",
tooltip: t("Divider"),
attrs: { markup: "---" },
active: isNodeActive(schema.nodes.hr, { markup: "---" }),
icon: <HorizontalRuleIcon />,
},
{
name: "hr",
tooltip: t("Page break"),
attrs: { markup: "***" },
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
icon: <PageBreakIcon />,
},
];
}
+55 -52
View File
@@ -25,22 +25,16 @@ import {
import { v4 as uuidv4 } from "uuid";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import HighlightColorPicker from "../components/HighlightColorPicker";
import type { EditorState } from "prosemirror-state";
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import type { TFunction } from "i18next";
import { t } from "i18next";
import CircleIcon from "~/components/Icons/CircleIcon";
import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
getDocumentTableBackgroundColors,
@@ -49,24 +43,30 @@ import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
import type { CellSelection } from "prosemirror-tables";
import TableCell from "@shared/editor/nodes/TableCell";
import Highlight from "@shared/editor/marks/Highlight";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
export default function formattingMenuItems(
state: EditorState,
isTemplate: boolean,
t: TFunction
): MenuItem[] {
const { schema } = state;
const isCode = isInCode(state);
const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isList = isInList(state);
const isTableCell = state.selection instanceof CellSelection;
/**
* Returns menu items for the default formatting selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function formattingMenuItems(ctx: SelectionContext): MenuItem[] {
const {
schema,
state,
isTemplate,
isMobile,
isTouch,
isEmpty,
isInCode,
isInCodeBlock,
isInList: isList,
isTableCell,
} = ctx;
const highlight = getMarksBetween(
state.selection.from,
@@ -83,6 +83,9 @@ export default function formattingMenuItems(
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
const canFormatInline = !isInCodeBlock && (!isMobile || !isEmpty);
const canFormatBlock = !isInCodeBlock && (!isMobile || isEmpty);
return [
{
name: "placeholder",
@@ -101,7 +104,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
name: "em",
@@ -109,7 +112,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
name: "strikethrough",
@@ -117,7 +120,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
tooltip: t("Background color"),
@@ -133,12 +136,10 @@ export default function formattingMenuItems(
) : (
<PaletteIcon />
),
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
visible: !isInCode && (!isMobile || !isEmpty) && isTableCell,
children: (): MenuItem[] => {
// Get all unique background colors used in table cells (lazily computed when menu opens)
const documentTableColors = getDocumentTableBackgroundColors(state);
// Filter out preset colors and currently selected colors
const nonPresetDocumentColors = documentTableColors.filter(
(color: string) =>
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
@@ -181,7 +182,6 @@ export default function formattingMenuItems(
},
]
: []),
// Add all other document table background colors
...nonPresetDocumentColors.map((color: string) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: color,
@@ -225,12 +225,10 @@ export default function formattingMenuItems(
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
visible: !isInCode && (!isMobile || !isEmpty) && !isTableCell,
children: (): MenuItem[] => {
// Get all unique highlight colors used in the document (lazily computed when menu opens)
const documentHighlightColors = getDocumentHighlightColors(state);
// Filter out preset colors and the currently selected color
const currentHighlightColor = highlight?.mark.attrs.color;
const nonPresetDocumentColors = documentHighlightColors.filter(
(color: string) =>
@@ -276,7 +274,6 @@ export default function formattingMenuItems(
},
]
: []),
// Add all other document highlight colors
...nonPresetDocumentColors.map((color: string) => ({
name: "highlight",
label: color,
@@ -313,11 +310,11 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+E`,
icon: <CodeIcon />,
active: isMarkActive(schema.marks.code_inline),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
name: "separator",
visible: !isCodeBlock,
visible: !isInCodeBlock,
},
{
name: "heading",
@@ -326,7 +323,7 @@ export default function formattingMenuItems(
icon: <Heading1Icon />,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: canFormatBlock,
},
{
name: "heading",
@@ -335,7 +332,7 @@ export default function formattingMenuItems(
icon: <Heading2Icon />,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: canFormatBlock,
},
{
name: "heading",
@@ -344,7 +341,7 @@ export default function formattingMenuItems(
icon: <Heading3Icon />,
active: isNodeActive(schema.nodes.heading, { level: 3 }),
attrs: { level: 3 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: canFormatBlock,
},
{
name: "blockquote",
@@ -353,7 +350,7 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isInCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "separator",
@@ -376,7 +373,7 @@ export default function formattingMenuItems(
tooltip: t("Toggle block"),
active: isNodeActive(schema.nodes.container_toggle),
attrs: { id: uuidv4() },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: canFormatBlock,
},
{
name: "separator",
@@ -388,7 +385,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "bullet_list",
@@ -396,7 +393,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "ordered_list",
@@ -404,39 +401,45 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "outdentList",
tooltip: t("Outdent"),
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible: isTouch && isList,
visible:
(isTouch || isMobile) &&
isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "indentList",
tooltip: t("Indent"),
shortcut: `Tab`,
icon: <IndentIcon />,
visible: isTouch && isList,
visible:
(isTouch || isMobile) &&
isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "outdentCheckboxList",
tooltip: t("Outdent"),
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
visible:
(isTouch || isMobile) && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: t("Indent"),
shortcut: `Tab`,
icon: <IndentIcon />,
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
visible:
(isTouch || isMobile) && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "separator",
visible: !isCodeBlock,
visible: !isInCodeBlock,
},
{
name: "addLink",
@@ -445,14 +448,14 @@ export default function formattingMenuItems(
icon: <LinkIcon />,
attrs: { href: "" },
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
name: "comment",
tooltip: t("Comment"),
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
label: isCodeBlock ? t("Comment") : undefined,
label: isInCodeBlock ? t("Comment") : undefined,
active: isMarkActive(
schema.marks.comment,
{ resolved: false },
@@ -462,14 +465,14 @@ export default function formattingMenuItems(
},
{
name: "separator",
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty),
},
{
name: "copyToClipboard",
icon: <CopyIcon />,
tooltip: t("Copy"),
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty),
},
];
}
+11 -8
View File
@@ -10,24 +10,27 @@ import {
CommentIcon,
LinkIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { TFunction } from "i18next";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { t } from "i18next";
/**
* Returns menu items for the image selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function imageMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction
ctx: SelectionContext
): MenuItem[] {
if (readOnly) {
if (ctx.readOnly) {
return [];
}
const { schema } = state;
const { schema, state } = ctx;
const isLeftAligned = isNodeActive(schema.nodes.image, {
layoutClass: "left-50",
});
+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 -8
View File
@@ -1,4 +1,4 @@
import type { TFunction } from "i18next";
import { t } from "i18next";
import {
DoneIcon,
ExpandedIcon,
@@ -6,16 +6,19 @@ import {
StarredIcon,
WarningIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
/**
* Returns menu items for the notice/callout selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function noticeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
t: TFunction
ctx: SelectionContext
): MenuItem[] {
const node = state.selection.$from.node(-1);
const node = ctx.selection.$from.node(-1);
const currentStyle = node?.attrs.style as NoticeTypes;
const mapping = {
@@ -28,7 +31,7 @@ export default function noticeMenuItems(
return [
{
name: "container_notice",
visible: !readOnly,
visible: !ctx.readOnly,
label: mapping[currentStyle],
icon: <ExpandedIcon />,
children: [
+13 -9
View File
@@ -1,20 +1,24 @@
import type { TFunction } from "i18next";
import { t } from "i18next";
import { CommentIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
/**
* Returns menu items for the read-only selection toolbar.
*
* @param ctx - the current selection context.
* @param canUpdate - whether the user has permission to update the document.
* @returns an array of menu items.
*/
export default function readOnlyMenuItems(
state: EditorState,
canUpdate: boolean,
t: TFunction
ctx: SelectionContext,
canUpdate: boolean
): MenuItem[] {
const { schema } = state;
const isEmpty = state.selection.empty;
const { schema } = ctx;
return [
{
visible: canUpdate && !isEmpty,
visible: canUpdate && !ctx.isEmpty,
name: "comment",
tooltip: t("Comment"),
label: t("Comment"),
+21 -21
View File
@@ -4,21 +4,22 @@ import {
TableColumnsDistributeIcon,
TrashIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { TFunction } from "i18next";
import type { MenuItem } from "@shared/editor/types";
import { t } from "i18next";
import type { MenuItem, SelectionContext } from "@shared/editor/types";
import { TableLayout } from "@shared/editor/types";
export default function tableMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction
): MenuItem[] {
if (readOnly) {
/**
* Returns menu items for the table selection toolbar (full table selected).
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
const { schema } = state;
const { schema, state } = ctx;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
@@ -27,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 />,
},
];
}
+140 -130
View File
@@ -5,7 +5,6 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
@@ -14,7 +13,6 @@ import {
SortDescendingIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
@@ -24,16 +22,25 @@ import {
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { TFunction } from "i18next";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import { t } from "i18next";
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";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
import type { EditorState } from "prosemirror-state";
/**
* Get the set of background colors used in a column
* Get the set of background colors used in a column.
*
* @param state - the current editor state.
* @param colIndex - the column index.
* @returns a set of hex color strings.
*/
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
const colors = new Set<string>();
@@ -55,21 +62,21 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
return colors;
}
export default function tableColMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction,
options: {
index: number;
rtl: boolean;
}
): MenuItem[] {
if (readOnly) {
/**
* Returns menu items for the table column selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
const { index, rtl } = options;
const { schema, selection } = state;
const index = ctx.colIndex!;
const rtl = ctx.rtl;
const { schema, state } = ctx;
const { selection } = state;
const selectedCols = getAllSelectedColumns(state);
if (!(selection instanceof CellSelection)) {
@@ -88,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" />
@@ -155,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 },
},
@@ -199,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 />,
},
];
}
+80 -75
View File
@@ -2,7 +2,6 @@ import {
TrashIcon,
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
@@ -15,8 +14,12 @@ import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { TFunction } from "i18next";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import { t } from "i18next";
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";
@@ -24,7 +27,11 @@ import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a row
* Get the set of background colors used in a row.
*
* @param state - the current editor state.
* @param rowIndex - the row index.
* @returns a set of hex color strings.
*/
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
const colors = new Set<string>();
@@ -46,19 +53,19 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
return colors;
}
export default function tableRowMenuItems(
state: EditorState,
readOnly: boolean,
t: TFunction,
options: {
index: number;
}
): MenuItem[] {
if (readOnly) {
/**
* Returns menu items for the table row selection toolbar.
*
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
const { index } = options;
const index = ctx.rowIndex!;
const { state } = ctx;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
@@ -77,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" />
@@ -91,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 },
},
@@ -135,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 />,
},
];
}
+154 -88
View File
@@ -1,8 +1,8 @@
import { observer } from "mobx-react";
import type { ReactNode } from "react";
import React, { createContext, useContext } from "react";
import React, { createContext, useCallback, useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { useHistory } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
@@ -55,98 +55,159 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
const parentContext = useContext(ActionContext);
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const { activeModels: valueModels, ...overrides } = value;
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
// Use history (stable reference) and read location lazily via a getter so
// navigation does not invalidate the context value. Action perform/visible
// callbacks see the current location at call time via history.location,
// which react-router updates on every navigation.
const history = useHistory();
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
const {
activeModels: valueModels,
isMenu,
isCommandBar,
isButton,
sidebarContext,
event,
} = value;
getActiveModels: <T extends Model>(
modelClass: new (...args: never[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
// Track membership of stores.ui.activeModels so memos invalidate when it changes.
// Reading inside the observer-wrapped render keeps MobX subscriptions intact.
const activeModelsKey = Array.from(stores.ui.activeModels.keys()).join(",");
const activeCollectionIdFromStore = stores.ui.activeCollectionId ?? undefined;
const activeDocumentIdFromStore = stores.ui.activeDocumentId ?? undefined;
const currentUserId = stores.auth.user?.id;
const currentTeamId = stores.auth.team?.id;
getActiveModel: <T extends Model>(
modelClass: new (...args: never[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
const getActiveModels = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
if (valueModels && valueModels.length > 0) {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
if (matching.length > 0) {
return matching;
}
}
if (parentContext) {
return parentContext.getActiveModels(modelClass);
}
return stores.ui.getActiveModels<T>(modelClass);
},
[valueModels, parentContext, stores]
);
getActivePolicies: <T extends Model>(
modelClass: new (...args: never[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
const getActiveModel = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): T | undefined =>
getActiveModels(modelClass)[0],
[getActiveModels]
);
const getActivePolicies = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
[getActiveModels, stores]
);
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: new Set(stores.ui.activeModels.values()),
const allActiveModels = useMemo(() => {
const base = parentContext
? parentContext.activeModels
: new Set(stores.ui.activeModels.values());
if (valueModels && valueModels.length > 0) {
return new Set([...base, ...valueModels]);
}
return base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentContext, stores, valueModels, activeModelsKey]);
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
const isModelActive = useCallback(
(model: Model): boolean => allActiveModels.has(model),
[allActiveModels]
);
const contextValue = useMemo<ActionContextType>(() => {
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: activeCollectionIdFromStore,
activeDocumentId: activeDocumentIdFromStore,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
currentUserId,
currentTeamId,
// Consumers reading `ctx.location` get the current location at access time.
location: history.location,
stores,
t,
};
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const result = {
...baseContext,
...(isMenu !== undefined ? { isMenu } : {}),
...(isCommandBar !== undefined ? { isCommandBar } : {}),
...(isButton !== undefined ? { isButton } : {}),
...(sidebarContext !== undefined ? { sidebarContext } : {}),
...(event !== undefined ? { event } : {}),
activeCollectionId,
activeDocumentId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
// Define `location` as a getter so reads always return the current
// location without invalidating this memo on navigation.
Object.defineProperty(result, "location", {
get: () => history.location,
enumerable: true,
configurable: true,
});
return result;
}, [
parentContext,
stores,
t,
};
// Override model accessors when models are provided in value
const getActiveModels =
valueModels && valueModels.length > 0
? <T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
return matching.length > 0
? matching
: baseContext.getActiveModels(modelClass);
}
: baseContext.getActiveModels;
const getActiveModel = <T extends Model>(
modelClass: new (...args: never[]) => T
): T | undefined => getActiveModels(modelClass)[0];
const getActivePolicies = <T extends Model>(
modelClass: new (...args: never[]) => T
): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined);
const allActiveModels =
valueModels && valueModels.length > 0
? new Set([...baseContext.activeModels, ...valueModels])
: baseContext.activeModels;
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const contextValue: ActionContextType = {
...baseContext,
...overrides,
activeCollectionId,
activeDocumentId,
history,
valueModels,
isMenu,
isCommandBar,
isButton,
sidebarContext,
event,
activeCollectionIdFromStore,
activeDocumentIdFromStore,
currentUserId,
currentTeamId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
allActiveModels,
]);
return (
<ActionContext.Provider value={contextValue}>
@@ -173,15 +234,20 @@ export default function useActionContext(
): ActionContextType {
const contextValue = useContext(ActionContext);
// If we have a context value from a provider, use it as the base
if (contextValue) {
return {
...contextValue,
...overrides,
};
if (!contextValue) {
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
}
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
// Short-circuit when no overrides are provided so consumers get a stable
// reference and don't re-render unnecessarily.
if (!overrides || Object.keys(overrides).length === 0) {
return contextValue;
}
return {
...contextValue,
...overrides,
};
}
+61 -15
View File
@@ -2,34 +2,80 @@ import * as React from "react";
const isSupported = "IntersectionObserver" in window;
// Parses a rootMargin string ("10px 20px" / "10px" / "10px 20px 30px 40px")
// into [top, right, bottom, left] in pixels. Percentages are not supported in
// the synchronous fast path and fall back to 0.
function parseRootMargin(
rootMargin: string | undefined
): [number, number, number, number] {
if (!rootMargin) {
return [0, 0, 0, 0];
}
const parts = rootMargin
.split(/\s+/)
.map((p) => (p.endsWith("px") ? parseFloat(p) : 0));
const [t = 0, r = t, b = t, l = r] = parts;
return [t, r, b, l];
}
/**
* Hook to return if a given ref is visible on screen.
*
* @returns boolean if the node is visible
*/
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
export default function useOnScreen(
ref: React.RefObject<HTMLElement>,
options?: IntersectionObserverInit
) {
const root = options?.root;
const rootMargin = options?.rootMargin;
const threshold = Array.isArray(options?.threshold)
? options?.threshold.join(",")
: options?.threshold;
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
React.useEffect(() => {
React.useLayoutEffect(() => {
const element = ref.current;
let observer: IntersectionObserver | undefined;
if (isSupported) {
observer = new IntersectionObserver(([entry]) => {
// Update our state when observer callback fires
setIntersecting(entry.isIntersecting);
});
if (!element) {
return undefined;
}
if (element) {
observer?.observe(element);
// Synchronous initial check so the first paint is correct.
const [mt, mr, mb, ml] = parseRootMargin(rootMargin);
const rect = element.getBoundingClientRect();
const rootRect =
root instanceof Element
? root.getBoundingClientRect()
: {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
};
const initialVisible =
rect.bottom >= rootRect.top - mt &&
rect.top <= rootRect.bottom + mb &&
rect.right >= rootRect.left - ml &&
rect.left <= rootRect.right + mr;
setIntersecting(initialVisible);
if (!isSupported) {
return undefined;
}
const observer = new IntersectionObserver(([entry]) => {
setIntersecting(entry.isIntersecting);
}, options);
observer.observe(element);
return () => {
if (element) {
observer?.unobserve(element);
}
observer.unobserve(element);
};
}, [ref]);
// Re-create when option primitives change; options object identity ignored
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, root, rootMargin, threshold]);
return isIntersecting;
}
+23
View File
@@ -20,6 +20,7 @@ import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
UserChangeAvatarDialog,
} from "~/components/UserDialogs";
/**
@@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) {
const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User));
const openAvatarDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change profile picture"),
content: (
<UserChangeAvatarDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openNameDialog = React.useCallback(() => {
if (!targetUser) {
return;
@@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) {
visible: can.demote || can.promote,
children: roleChangeActions,
}),
createAction({
name: `${t("Change profile picture")}`,
section: UserSection,
visible: can.update,
perform: openAvatarDialog,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
@@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) {
can.update,
can.resendInvite,
roleChangeActions,
openAvatarDialog,
openNameDialog,
openEmailDialog,
resendInvitation,
+1
View File
@@ -91,6 +91,7 @@ function CommentMenu({
action={rootAction}
align="end"
ariaLabel={t("Comment options")}
modal={false}
>
<OverflowMenuButton className={className} />
</DropdownMenu>
+1 -1
View File
@@ -29,7 +29,7 @@ export class ProsemirrorHelper {
);
const markdown = serializer.serialize(doc, {
softBreak: true,
commonMark: true,
});
return markdown;
};
+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}
+1 -1
View File
@@ -179,7 +179,7 @@ const CollectionScene = observer(function CollectionScene_() {
<Notices collection={collection} />
<Header
collection={collection}
isEditing={isEditRoute && !!user?.separateEditMode}
isEditing={isEditRoute || !user?.separateEditMode}
/>
<PinnedDocuments
@@ -13,6 +13,38 @@ export interface Example {
* Node and mark names are matched against those defined in the shared editor schema.
*/
export const examples: Example[] = [
{
id: "word-replacement",
name: "Word replacement (interleaved)",
before: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "no-await-in-loop",
},
],
},
],
},
after: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "jsx-no-jsx-as-prop",
},
],
},
],
},
},
{
id: "simple-text",
name: "Simple text change",
@@ -632,6 +664,846 @@ export const examples: Example[] = [
],
},
},
{
id: "tc",
name: "More table changes",
before: {
type: "doc",
content: [
{
type: "heading",
attrs: {
level: 1,
},
content: [
{
text: "Perf:",
type: "text",
},
],
},
{
type: "paragraph",
content: [
{
text: "Code that can be written to run faster.",
type: "text",
},
],
},
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Rule name",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Source",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Default",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Fixable?",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-await-in-loop",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "eslint",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-set-has",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f",
type: "text",
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-map-spread",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "oxc",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f\ud83d\udca1",
type: "text",
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-array-index-key",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "react",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
],
},
{
type: "paragraph",
},
],
},
after: {
type: "doc",
content: [
{
type: "heading",
attrs: {
level: 1,
},
content: [
{
text: "Perf:",
type: "text",
},
],
},
{
type: "paragraph",
content: [
{
text: "Code that can be written to run faster.",
type: "text",
},
],
},
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Rule name",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Source",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Default",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Fixable?",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "jsx-no-jsx-as-prop",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "react-perf",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-map-spread",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "oxc",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f",
type: "text",
},
{
type: "emoji",
attrs: {
"data-name": "bulb",
},
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-array-find",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
type: "emoji",
attrs: {
"data-name": "construction",
},
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-set-has",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
type: "emoji",
attrs: {
"data-name": "warning",
},
},
{
type: "emoji",
attrs: {
"data-name": "hammer_and_wrench",
},
},
],
},
],
},
],
},
],
},
{
type: "paragraph",
},
],
},
},
{
id: "table-add-row",
name: "Table: add row",
@@ -1,6 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { basicExtensions, withComments } from "@shared/editor/nodes";
import CodeBlock from "@shared/editor/nodes/CodeBlock";
import CodeFence from "@shared/editor/nodes/CodeFence";
import HardBreak from "@shared/editor/nodes/HardBreak";
import type { Props as EditorProps } from "~/components/Editor";
import Editor from "~/components/Editor";
@@ -17,6 +19,8 @@ import useCurrentUser from "~/hooks/useCurrentUser";
const extensions = [
...withComments(basicExtensions),
CodeBlock,
CodeFence,
HardBreak,
SmartText,
PasteHandler,
@@ -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);
@@ -27,6 +27,7 @@ import type { Editor as TEditor } from "~/editor";
import type { Properties } from "~/types";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import isTextInput from "~/utils/isTextInput";
import { client } from "~/utils/ApiClient";
import { emojiToUrl } from "~/utils/emoji";
import { documentHistoryPath, documentEditPath } from "~/utils/routeHelpers";
@@ -151,6 +152,17 @@ function DocumentScene({
const onUndoRedo = useCallback(
(event: KeyboardEvent) => {
if (isModKey(event)) {
const target =
event.target instanceof Element ? event.target : undefined;
// The editor handles undo/redo through its own keymap when focused
if (
editorRef.current?.view?.hasFocus() ||
(target && (isTextInput(target) || !!target.closest(".ProseMirror")))
) {
return;
}
event.preventDefault();
if (event.shiftKey) {
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
import History from "@shared/editor/extensions/History";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import type { Props as EditorProps } from "~/components/Editor";
@@ -256,8 +257,12 @@ function MultiplayerEditor(
return props.extensions;
}
// The Yjs undo manager (added by the Multiplayer extension below) is the
// sole source of undo/redo history when collaborating.
return [
...(props.extensions || []),
...(props.extensions || []).filter(
(extension) => extension !== History && !(extension instanceof History)
),
new MultiplayerExtension({
user,
provider: remoteProvider,
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
import Tooltip from "~/components/Tooltip";
import { undraggableOnDesktop } from "~/styles";
export function SearchHighlightChip() {
const { t } = useTranslation();
@@ -50,6 +51,7 @@ export function SearchHighlightChip() {
}
const Chip = styled.button`
${undraggableOnDesktop()}
display: inline-flex;
align-items: center;
gap: 2px;
+2 -2
View File
@@ -76,7 +76,7 @@ function inputScopes(scope?: string): string[] {
* and allows the user to either authorize or cancel the request.
*/
function Authorize() {
const team = useCurrentTeam();
const team = useCurrentTeam({ rejectOnEmpty: false });
const params = useQuery();
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -133,7 +133,7 @@ function Authorize() {
!state && "state",
].filter(Boolean);
if (missingParams.length || clientError) {
if (missingParams.length || clientError || !team) {
return (
<Background>
<Centered>
+98
View File
@@ -0,0 +1,98 @@
import type { TFunction } from "i18next";
import { OAuthScopeHelper } from "./OAuthScopeHelper";
const t = ((key: string) => key) as unknown as TFunction;
describe("OAuthScopeHelper", () => {
describe("normalizeScopes", () => {
it("renders the full wildcard as Full access", () => {
expect(OAuthScopeHelper.normalizeScopes(["*"], t)).toEqual([
"Full access",
]);
});
it("renders the equivalent /api/*.* route scope as Full access", () => {
expect(OAuthScopeHelper.normalizeScopes(["/api/*.*"], t)).toEqual([
"Full access",
]);
});
it("renders global access scopes", () => {
expect(OAuthScopeHelper.normalizeScopes(["read"], t)).toEqual([
"Read all data",
]);
expect(OAuthScopeHelper.normalizeScopes(["write"], t)).toEqual([
"Write all data",
]);
expect(OAuthScopeHelper.normalizeScopes(["create"], t)).toEqual([
"Create all data",
]);
});
it("renders route scopes with both namespace and method", () => {
expect(
OAuthScopeHelper.normalizeScopes(["/api/documents.list"], t)
).toEqual(["Read documents"]);
expect(
OAuthScopeHelper.normalizeScopes(["/api/documents.create"], t)
).toEqual(["Create documents"]);
expect(
OAuthScopeHelper.normalizeScopes(["/api/collections.update"], t)
).toEqual(["Write collections"]);
});
it("renders wildcard methods", () => {
expect(OAuthScopeHelper.normalizeScopes(["/api/documents.*"], t)).toEqual(
["Read and write documents"]
);
});
it("renders wildcard namespaces", () => {
expect(OAuthScopeHelper.normalizeScopes(["/api/*.list"], t)).toEqual([
"Read workspace",
]);
});
it("renders namespaced access scopes", () => {
expect(OAuthScopeHelper.normalizeScopes(["documents:read"], t)).toEqual([
"Read documents",
]);
expect(OAuthScopeHelper.normalizeScopes(["documents:write"], t)).toEqual([
"Write documents",
]);
});
it("translates known namespaces", () => {
// `capitalize` lowercases the rest of the string, so "API keys" becomes
// "api keys" after composition with the verb. This matches what users
// see today.
expect(
OAuthScopeHelper.normalizeScopes(["/api/apiKeys.list"], t)
).toEqual(["Read api keys"]);
});
it("falls back to the raw namespace when unknown", () => {
expect(
OAuthScopeHelper.normalizeScopes(["/api/widgets.list"], t)
).toEqual(["Read widgets"]);
});
it("deduplicates equivalent scopes", () => {
expect(
OAuthScopeHelper.normalizeScopes(
["/api/documents.list", "/api/documents.info", "documents:read"],
t
)
).toEqual(["Read documents"]);
});
it("renders legacy malformed mixed scopes by their wildcard prefix", () => {
// These scopes can no longer be saved but may exist in older rows.
// The trailing access level is dropped and the result reflects the
// effective `*.*` access that enforcement granted.
expect(OAuthScopeHelper.normalizeScopes(["/api/*.*:read"], t)).toEqual([
"Read and write workspace",
]);
});
});
});
+7 -1
View File
@@ -9,7 +9,7 @@ export class OAuthScopeHelper {
info: t("read"),
read: t("read"),
write: t("write"),
create: t("write"),
create: t("create"),
update: t("write"),
delete: t("write"),
"*": t("read and write"),
@@ -34,12 +34,18 @@ export class OAuthScopeHelper {
};
const normalizedScopes = scopes.map((scope) => {
if (scope === "*" || scope === "/api/*.*") {
return t("Full access");
}
if (scope === Scope.Read) {
return t("Read all data");
}
if (scope === Scope.Write) {
return t("Write all data");
}
if (scope === Scope.Create) {
return t("Create all data");
}
const [namespace, method] = scope.replace("/api/", "").split(/[:.]/g);
const readableMethod =
@@ -35,6 +35,7 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
const { collections, imports } = useStores();
const [file, setFile] = useState<File | null>(null);
const [isImporting, setImporting] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [permission, setPermission] = useState<CollectionPermission | null>(
CollectionPermission.ReadWrite
);
@@ -52,11 +53,13 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
return;
}
setImporting(true);
setUploadProgress(0);
try {
const attachment = await uploadFile(file, {
name: file.name,
preset: AttachmentPreset.WorkspaceImport,
onProgress: (progress) => setUploadProgress(progress),
});
if (format === FileOperationFormat.MarkdownZip) {
@@ -67,6 +70,14 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
permission: permission ?? undefined,
}
);
} else if (format === FileOperationFormat.JSON) {
await imports.create(
{ service: IntegrationService.JSON },
{
attachmentId: attachment.id,
permission: permission ?? undefined,
}
);
} else {
await collections.import(attachment.id, { format, permission });
}
@@ -81,6 +92,7 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
toast.error(err.message);
} finally {
setImporting(false);
setUploadProgress(0);
}
};
@@ -136,7 +148,11 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
</div>
<Flex justify="flex-end">
<Button disabled={!file || isImporting} onClick={handleStartImport}>
{isImporting ? t("Uploading") + "…" : t("Start import")}
{isImporting
? t("Uploading {{progress}}%", {
progress: Math.min(99, Math.floor(uploadProgress * 100)),
})
: t("Start import")}
</Button>
</Flex>
</Flex>
+17 -4
View File
@@ -11,11 +11,24 @@ import type { Props as ImageUploadProps } from "./ImageUpload";
import ImageUpload from "./ImageUpload";
type Props = ImageUploadProps & {
/** The model whose avatar is displayed and updated by this input. */
model: IAvatar;
/** Alt text for the avatar image. */
alt: string;
/**
* Whether to render the inline "Remove" button when the model has an
* existing avatar. Defaults to true.
*/
showRemoveOption?: boolean;
};
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
export default function ImageInput({
model,
onSuccess,
alt,
showRemoveOption = true,
...rest
}: Props) {
const { t } = useTranslation();
return (
@@ -29,7 +42,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
<Avatar
model={model}
size={AvatarSize.Upload}
variant={AvatarVariant.Square}
variant={AvatarVariant.Round}
alt={alt}
/>
<Flex auto align="center" justify="center" className="upload">
@@ -37,7 +50,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
</Flex>
</ImageUpload>
</ImageBox>
{model.avatarUrl && (
{model.avatarUrl && showRemoveOption && (
<Button onClick={() => onSuccess(null)} neutral>
{t("Remove")}
</Button>
@@ -55,7 +68,7 @@ const ImageBox = styled(Flex)`
${avatarStyles};
position: relative;
font-size: 14px;
border-radius: 8px;
border-radius: 50%;
box-shadow: 0 0 0 1px ${s("backgroundSecondary")};
background: ${s("background")};
overflow: hidden;
+1 -1
View File
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
: window.location.origin;
type PathParams = {
shareId: string;
shareId?: string;
collectionSlug?: string;
documentSlug?: string;
};
+24 -13
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -25,7 +25,7 @@ function TeamDelete({ onSubmit }: Props) {
const team = useCurrentTeam({ rejectOnEmpty: false });
const { t } = useTranslation();
const {
register,
control,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>();
@@ -62,9 +62,6 @@ function TeamDelete({ onSubmit }: Props) {
[auth, onSubmit]
);
const inputProps = register("code", {
required: env.EMAIL_ENABLED,
});
const appName = env.APP_NAME;
const workspaceName = team?.name;
@@ -78,13 +75,27 @@ function TeamDelete({ onSubmit }: Props) {
enter the code below to permanently destroy this workspace.
</Trans>
</Text>
<Input
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
<Controller
control={control}
name="code"
rules={{
required: env.EMAIL_ENABLED,
minLength: 8,
}}
render={({ field }) => (
<OneTimePasswordInput
length={8}
alphanumeric
autoComplete="off"
autoFocus
name={field.name}
value={field.value ?? ""}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
style={{ marginBottom: "1em" }}
/>
)}
/>
</>
) : (
+24 -13
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
@@ -24,7 +24,7 @@ function UserDelete({ onSubmit }: Props) {
const { auth } = useStores();
const { t } = useTranslation();
const {
register,
control,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>();
@@ -61,9 +61,6 @@ function UserDelete({ onSubmit }: Props) {
[auth, onSubmit]
);
const inputProps = register("code", {
required: env.EMAIL_ENABLED,
});
const appName = env.APP_NAME;
return (
@@ -76,13 +73,27 @@ function UserDelete({ onSubmit }: Props) {
enter the code below to permanently destroy your account.
</Trans>
</Text>
<Input
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
<Controller
control={control}
name="code"
rules={{
required: env.EMAIL_ENABLED,
minLength: 8,
}}
render={({ field }) => (
<OneTimePasswordInput
length={8}
alphanumeric
autoComplete="off"
autoFocus
name={field.name}
value={field.value ?? ""}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
style={{ marginBottom: "1em" }}
/>
)}
/>
</>
) : (
+10
View File
@@ -503,6 +503,16 @@ export default class DocumentsStore extends Store<Document> {
invariant(res?.data, "Data not available");
res.data.documents.forEach(this.add);
this.addPolicies(res.policies);
// The websocket "documents.move" event is only broadcast to the
// collection channel, so users with document-only access never receive
// it. Refresh the affected membership tree locally so the sidebar
// reflects the new structure.
const membership =
this.rootStore.userMemberships.getByDocumentId(documentId);
if (membership) {
await membership.fetchDocuments({ force: true });
}
} finally {
this.movingDocumentId = undefined;
}
+6
View File
@@ -264,6 +264,12 @@ class UiStore {
@computed
get activeCollectionId(): string | undefined {
// Derive from the active document so it resolves even if the collection
// loads after the document became active.
const activeDocument = this.getPrimaryActiveModel<Document>(Document);
if (activeDocument?.isActive && activeDocument.collectionId) {
return activeDocument.collectionId;
}
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
+7 -4
View File
@@ -1,12 +1,15 @@
// oxlint exits 1 when every input matches .oxlintrc.json ignorePatterns
// (e.g. migration-only commits). Swallow that specific case so the hook
// passes, while still surfacing real lint failures.
const oxlint = (files) =>
`bash -c 'out=$(oxlint ${files.join(" ")} --fix --type-aware 2>&1); rc=$?; printf "%s\\n" "$out"; if [ $rc -ne 0 ] && ! printf "%s" "$out" | grep -q "No files found to lint"; then exit $rc; fi'`;
export default {
// Run prettier first for formatting, then oxlint for linting, and translation updates on changes to JS and
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) =>
f.length > 20
? `yarn lint --fix`
: `oxlint ${f.join(" ")} --fix --type-aware`,
(f) => (f.length > 20 ? `yarn lint --fix` : oxlint(f)),
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+37 -35
View File
@@ -52,11 +52,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/lib-storage": "^3.1045.0",
"@aws-sdk/s3-presigned-post": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@aws-sdk/signature-v4-crt": "^3.1045.0",
"@aws-sdk/client-s3": "^3.1053.0",
"@aws-sdk/lib-storage": "^3.1053.0",
"@aws-sdk/s3-presigned-post": "^3.1053.0",
"@aws-sdk/s3-request-presigner": "^3.1053.0",
"@aws-sdk/signature-v4-crt": "^3.1053.0",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.21.3",
"@bull-board/koa": "^6.21.3",
@@ -83,7 +83,8 @@
"@modelcontextprotocol/sdk": "^1.25.3",
"@node-oauth/oauth2-server": "^5.3.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/auth-app": "^8.2.0",
"@octokit/webhooks-types": "^7.6.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -94,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",
@@ -102,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",
@@ -113,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",
@@ -146,7 +149,7 @@
"i18next-http-backend": "^3.0.6",
"invariant": "^2.2.4",
"ioredis": "^5.10.1",
"ipaddr.js": "^2.3.0",
"ipaddr.js": "^2.4.0",
"is-printable-key-event": "^1.0.0",
"iso-639-3": "^3.0.1",
"js-yaml": "^4.1.1",
@@ -177,7 +180,7 @@
"natural-sort": "^1.0.0",
"node-fetch": "2.7.0",
"nodemailer": "^7.0.13",
"octokit": "^3.2.2",
"octokit": "^5.0.5",
"outline-icons": "^4.3.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
@@ -212,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",
@@ -222,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",
@@ -234,7 +238,7 @@
"resolve-path": "^1.4.0",
"sanitize-filename": "^1.6.4",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.4",
"semver": "^7.8.1",
"sequelize": "^6.37.8",
"sequelize-cli": "^6.6.5",
"sequelize-encrypted": "^1.0.0",
@@ -253,12 +257,12 @@
"styled-normalize": "^8.1.1",
"throng": "^5.0.0",
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.5",
"tmp": "^0.2.6",
"tunnel-agent": "^0.6.0",
"ukkonen": "^2.2.0",
"umzug": "^3.8.3",
"utility-types": "^3.11.0",
"uuid": "^11.1.0",
"uuid": "^11.1.1",
"validator": "13.15.35",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@7.3.1",
@@ -269,19 +273,19 @@
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yauzl": "^3.2.1",
"yauzl": "^3.3.1",
"yazl": "^3.3.1",
"yjs": "^13.6.30",
"zod": "^4.3.6"
"yjs": "^13.6.31",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.6",
"@babel/plugin-proposal-decorators": "^7.28.6",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/plugin-proposal-decorators": "^7.29.7",
"@babel/plugin-transform-class-properties": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@swc/core": "^1.15.32",
@@ -306,7 +310,6 @@
"@types/katex": "^0.16.8",
"@types/koa": "^2.15.1",
"@types/koa-compress": "^4.0.7",
"@types/koa-helmet": "^6.0.8",
"@types/koa-logger": "^3.1.5",
"@types/koa-mount": "^4.0.5",
"@types/koa-router": "^7.4.9",
@@ -349,31 +352,31 @@
"@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",
"lint-staged": "^16.4.0",
"msw": "^2.14.2",
"nodemon": "^3.1.14",
"oxlint": "1.50.0",
"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",
"terser": "^5.44.1",
"terser": "^5.48.0",
"typescript": "^5.9.3",
"unplugin-swc": "^1.5.9",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-babel": "^1.7.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.5"
},
@@ -381,17 +384,16 @@
"@types/react": "17.0.91",
"@types/koa": "2.15.1",
"prosemirror-transform": "1.10.5",
"debug": "4.3.4",
"prismjs": "1.30.0",
"zod": "^4.3.6",
"ajv@npm:~8.13.0": "^8.18.0",
"zod": "^4.4.3",
"@types/markdown-it": "14.1.2",
"ip-address@npm:10.1.0": "^10.2.0",
"minimatch@npm:9.0.1": "9.0.9",
"lodash@npm:4.17.21": "^4.18.1",
"i18next-parser/i18next": "^23.16.8",
"ws@npm:~8.17.1": "^8.20.1"
"ws@npm:~8.17.1": "^8.20.1",
"uuid": "^11.1.1"
},
"version": "1.7.1",
"version": "1.8.1",
"packageManager": "yarn@4.11.0"
}
+27
View File
@@ -102,6 +102,32 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// 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 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);
@@ -121,6 +147,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
user: {
name: profile.name,
email,
emailVerified,
avatarUrl: profile.picture,
},
authenticationProvider: {
+1
View File
@@ -201,6 +201,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
},
user: {
email,
emailVerified: profile.verified,
name: userName,
language,
avatarUrl: userAvatarUrl,
-1
View File
@@ -97,7 +97,6 @@ router.get(
id: installationId!,
account: {
id: installation.account?.id,
// @ts-expect-error Property 'login' does not exist on type
name: installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
+22 -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
@@ -127,6 +138,8 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
},
user: {
email: profile.email,
// Google only returns confirmed workspace email addresses.
emailVerified: true,
name: profile.displayName,
language,
avatarUrl,
+2 -2
View File
@@ -148,9 +148,9 @@ export class Linear {
switch (resource.type) {
case UnfurlResourceType.Issue:
return Linear.unfurlIssue(client, resource.id, actor);
return await Linear.unfurlIssue(client, resource.id, actor);
case UnfurlResourceType.Project:
return Linear.unfurlProject(client, resource.id, actor);
return await Linear.unfurlProject(client, resource.id, actor);
default:
return;
}
+29
View File
@@ -6,6 +6,7 @@ import {
isFullPageOrDatabase,
isFullUser,
RequestTimeoutError,
UnknownHTTPResponseError,
} from "@notionhq/client";
import type {
BlockObjectResponse,
@@ -60,6 +61,7 @@ export class NotionClient {
private limiter: ReturnType<typeof RateLimit>;
private pageSize = 100;
private maxRetries = 3;
private maxServerErrorRetries = 8;
private retryDelay = 1000;
private skipChildrenForBlock = [
"unsupported",
@@ -94,6 +96,7 @@ export class NotionClient {
*/
private async fetchWithRetry<T>(apiCall: () => Promise<T>): Promise<T> {
let retries = 0;
let serverErrorRetries = 0;
// oxlint-disable-next-line no-constant-condition
while (true) {
@@ -149,6 +152,32 @@ export class NotionClient {
);
}
// Check if this is a server-side error (5xx) — Notion's API can be
// unreliable, so retry these for longer with exponential backoff.
if (
(error instanceof APIResponseError ||
error instanceof UnknownHTTPResponseError) &&
error.status >= 500
) {
if (serverErrorRetries < this.maxServerErrorRetries) {
serverErrorRetries++;
const delay = this.retryDelay * 2 ** (serverErrorRetries - 1);
Logger.info(
"task",
`Notion API returned ${error.status}, retrying in ${delay}ms (retry ${serverErrorRetries}/${this.maxServerErrorRetries})`
);
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
Logger.warn(
`Notion API returned ${error.status} after ${this.maxServerErrorRetries} retries`,
{ error: error.message }
);
}
// Re-throw the error if it's not a rate limit issue or we've exhausted retries
throw error;
}
+11
View File
@@ -105,6 +105,7 @@ export function createOIDCRouter(
return decoded as {
email?: string;
email_verified?: boolean | string;
preferred_username?: string;
sub?: string;
};
@@ -122,6 +123,15 @@ export function createOIDCRouter(
);
}
// The email_verified claim is part of the OIDC standard claims.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
const emailVerifiedClaim =
profile.email_verified ?? token.email_verified;
const emailVerified =
emailVerifiedClaim === undefined
? undefined
: emailVerifiedClaim === true || emailVerifiedClaim === "true";
const team = await getTeamFromContext(context);
const client = getClientFromOAuthState(context);
const user =
@@ -206,6 +216,7 @@ export function createOIDCRouter(
user: {
name,
email,
emailVerified,
avatarUrl,
},
authenticationProvider: {

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