Compare commits

...

158 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
dependabot[bot] f1c5b145a4 chore(deps-dev): bump @types/node from 20.17.14 to 20.17.16 (#8311)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.14 to 20.17.16.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  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>
2025-01-27 17:27:51 -08:00
dependabot[bot] 4c7b36dfca chore(deps): bump react-hook-form from 7.53.1 to 7.54.2 (#8310)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.53.1 to 7.54.2.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.53.1...v7.54.2)

---
updated-dependencies:
- dependency-name: react-hook-form
  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>
2025-01-27 17:27:40 -08:00
dependabot[bot] e1d0d4717c chore(deps): bump @tanstack/react-virtual from 3.10.9 to 3.11.3 (#8312)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.10.9 to 3.11.3.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Commits](https://github.com/TanStack/virtual/commits/v3.11.3/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  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>
2025-01-27 17:27:03 -08:00
dependabot[bot] e3f836c22b chore(deps): bump the babel group with 6 updates (#8308)
Bumps the babel group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.25.2` | `7.26.7` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.24.7` | `7.25.9` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.7` | `7.25.9` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.7` | `7.25.9` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.25.8` | `7.26.7` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.24.1` | `7.26.0` |


Updates `@babel/core` from 7.25.2 to 7.26.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.26.7/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.24.7 to 7.25.9
- [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.25.9/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.7 to 7.25.9
- [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.25.9/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.7 to 7.25.9
- [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.25.9/packages/babel-plugin-transform-destructuring)

Updates `@babel/preset-env` from 7.25.8 to 7.26.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.26.7/packages/babel-preset-env)

Updates `@babel/preset-typescript` from 7.24.1 to 7.26.0
- [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.26.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  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>
2025-01-27 17:26:32 -08:00
dependabot[bot] e9602ada24 chore(deps): bump @dnd-kit/core from 6.1.0 to 6.3.1 (#8304)
Bumps [@dnd-kit/core](https://github.com/clauderic/dnd-kit/tree/HEAD/packages/core) from 6.1.0 to 6.3.1.
- [Release notes](https://github.com/clauderic/dnd-kit/releases)
- [Changelog](https://github.com/clauderic/dnd-kit/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/clauderic/dnd-kit/commits/@dnd-kit/core@6.3.1/packages/core)

---
updated-dependencies:
- dependency-name: "@dnd-kit/core"
  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>
2025-01-27 17:18:50 -08:00
Tom Moor 0ff4bed18f chore: Add groups to dependabot 2025-01-27 20:13:10 -05:00
dependabot[bot] 6b49d91f2f chore(deps): bump mailparser and @types/mailparser (#8306)
Bumps [mailparser](https://github.com/nodemailer/mailparser) and [@types/mailparser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mailparser). These dependencies needed to be updated together.

Updates `mailparser` from 3.7.1 to 3.7.2
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.7.1...v3.7.2)

Updates `@types/mailparser` from 3.4.4 to 3.4.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mailparser)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/mailparser"
  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>
2025-01-27 17:03:36 -08:00
dependabot[bot] 77f0572445 chore(deps): bump @tanstack/react-table from 8.20.5 to 8.20.6 (#8307)
Bumps [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) from 8.20.5 to 8.20.6.
- [Release notes](https://github.com/TanStack/table/releases)
- [Commits](https://github.com/TanStack/table/commits/v8.20.6/packages/react-table)

---
updated-dependencies:
- dependency-name: "@tanstack/react-table"
  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>
2025-01-27 17:03:21 -08:00
Tom Moor 5b11a0cc16 feat: Upgrade FA, add new icons 2025-01-25 15:19:58 -05:00
Tom Moor dfe97bee50 fix: Comment sidebar should not overtake scrolling if linked to anchor, closes #8296 2025-01-25 11:44:38 -05:00
Tom Moor 500730b243 chore: React warning 2025-01-25 08:09:43 -05:00
Tom Moor ec6ed809a4 Add triggers to keyboard shortcut help, make search input sticky 2025-01-25 07:49:06 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
Viorel Cojocaru 08385b8a9e chore: Bump rollup-plugin-webpack-stats to v2.0.1 (#8293) 2025-01-24 15:04:07 -08:00
Tom Moor 9929020b44 chore: fix WSS connection issue in local development 2025-01-24 09:44:54 -05:00
Tom Moor 48a330347f chore: fix CORS issue in local development 2025-01-24 09:21:54 -05:00
Tom Moor 5b6bebc308 fix: Email content should account for untitled documents 2025-01-23 23:46:02 -05:00
Tom Moor c831c71c51 fix: Incorrect horizontal borders on Settings -> Profile 2025-01-23 23:41:55 -05:00
Tom Moor 90350e82fe fix: Events lacking teamId published for sourced memberships (#8295) 2025-01-23 20:19:53 -08:00
Tom Moor b7bbaac2eb fix: Default to user mention for backwards compat 2025-01-23 22:03:27 -05:00
Hemachandar 5a45b95a48 fix: Render TOC only when the shared document has headings (#8264)
* fix: Render TOC only when the shared document has headings

* simplify condition

* fix inconsistent toc button state

* toc visible check

* remove shareHasHeadings prop
2025-01-23 05:12:34 -08:00
Hemachandar 9deb9268b5 fix: Skip events for sourced group memberships (#8286) 2025-01-23 05:06:57 -08:00
Tom Moor 53f4c724bb chore: Remove duplicate trigger definition for suggestion extensions 2025-01-22 22:29:36 -05:00
Tom Moor 184e56264c feat: Add reading time on pinned documents 2025-01-22 21:17:26 -05:00
Tom Moor ffa7043cf0 fix: Outgoing emails trigger spoofing warnings due to exact matching from name 2025-01-22 20:55:10 -05:00
Tom Moor ff3c157554 fix: Crash in share menu when query looks like regex 2025-01-22 20:52:00 -05:00
Tom Moor 13f23d19fc fix: JS error selecting 'Keep as link' with keyboard.
Hacky quick fix, better coming soon
closes #8276
2025-01-22 20:47:38 -05:00
Hemachandar b527048b76 Remove namespace filter for publishing events (#8252)
* groupuser namespace

* remove namespace

* handle reactions

* handle group memberships

* cache changeset before all create and update flows
2025-01-22 17:16:05 -08:00
Apoorv Mishra e1b0cfb6a0 Use explicitly passed title and text in favor of template title and text (#8274)
* fix: use explicitly passed title and text in favor of template title and text while creating doc from the template

* fix: retain template variables when creating a template from another template
2025-01-22 05:41:18 -08:00
ZhuoYang Wu(阿离) 2205b9ee87 feat: add paste menu (#8229)
* feat: add paste menu

* fix: for comment

* Add supported embed detection

* fix: Menu is modified before it closes

* Add multiplayer support

* refactor

* perf: Avoid unneccessary mapping

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-22 05:41:05 -08:00
dependabot[bot] 1122f030a9 chore(deps): bump vite from 5.4.11 to 5.4.12 (#8273)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 05:15:10 -08:00
Translate-O-Tron 4cc0beb90d New Crowdin updates (#8257)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-01-20 16:00:33 -08:00
dependabot[bot] 16084322ca chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14 (#8261)
* chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.2 to 20.17.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* tsc

* tsc

---------

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.moor@gmail.com>
2025-01-20 16:00:08 -08:00
dependabot[bot] fa70735585 chore(deps): bump dotenv from 16.4.5 to 16.4.7 (#8258)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.4.5 to 16.4.7.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.4.5...v16.4.7)

---
updated-dependencies:
- dependency-name: dotenv
  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>
2025-01-20 14:24:33 -08:00
dependabot[bot] 8d694e666c chore(deps): bump react-window from 1.8.10 to 1.8.11 (#8259)
Bumps [react-window](https://github.com/bvaughn/react-window) from 1.8.10 to 1.8.11.
- [Release notes](https://github.com/bvaughn/react-window/releases)
- [Changelog](https://github.com/bvaughn/react-window/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bvaughn/react-window/compare/1.8.10...1.8.11)

---
updated-dependencies:
- dependency-name: react-window
  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>
2025-01-20 14:24:19 -08:00
dependabot[bot] 324ce96aaf chore(deps): bump umzug from 3.8.1 to 3.8.2 (#8260)
Bumps [umzug](https://github.com/sequelize/umzug) from 3.8.1 to 3.8.2.
- [Release notes](https://github.com/sequelize/umzug/releases)
- [Changelog](https://github.com/sequelize/umzug/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sequelize/umzug/compare/v3.8.1...v3.8.2)

---
updated-dependencies:
- dependency-name: umzug
  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>
2025-01-20 14:24:07 -08:00
Tom Moor cc7f9d1a72 Improve canva share link support, closes #8140 2025-01-19 23:24:37 -05:00
Tom Moor 0116441a58 fix: findByQuery with no query 2025-01-19 22:33:03 -05:00
Tom Moor be93b4ffe9 fix: Various bugs in the suggestion menu logic (#8256) 2025-01-19 17:41:34 -08:00
Tom Moor 11cb90b4fa chore: Simplify Enter rule on mentions 2025-01-19 11:46:11 -05:00
Tom Moor d1b7d0ee45 findByQuery 2025-01-18 22:56:24 -05:00
Tom Moor 029161002b Move hover helper to shared 2025-01-18 21:14:00 -05:00
Tom Moor 1e10985626 Add hover states to mention 2025-01-18 20:58:54 -05:00
Tom Moor e5fdaae09a feat: Add prefetching to shared document sidebar items 2025-01-18 16:35:35 -05:00
Tom Moor cfdb213cc1 Ensure both people and documents are showin in mention menu with no search term 2025-01-18 14:51:40 -05:00
Tom Moor 64106979ba Merge branch 'main' of github.com:outline/outline 2025-01-18 10:49:33 -05:00
Tom Moor 6dffa023b1 fix: Empty title column on shares management 2025-01-17 22:56:30 -05:00
Translate-O-Tron 869b6e7394 New Crowdin updates (#8235) 2025-01-17 18:27:57 -08:00
Tom Moor 73086139d2 Document mentions (#8225) 2025-01-17 15:56:38 -08:00
dependabot[bot] 92b257381b chore(deps): bump katex from 0.16.11 to 0.16.21 (#8253)
Bumps [katex](https://github.com/KaTeX/KaTeX) from 0.16.11 to 0.16.21.
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.11...v0.16.21)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 15:15:48 -08:00
Tom Moor 79df75e09d chore: Update bug_report.yml 2025-01-16 17:26:28 -08:00
Tom Moor 4517cd6ab1 Move bug report to form format 2025-01-16 20:22:51 -05:00
Hemachandar 3c86b48533 Convert GroupMembership mutations (#8242)
* Convert 'GroupMembership' mutations

* cleanup collectionGroupMemberships

* remove duplicate data
2025-01-16 15:23:09 -08:00
Tom Moor bcba35550a fix: NodeView does not re-render when editable prop changes (#8237) 2025-01-16 14:28:50 -08:00
Tom Moor 4af3ac98d1 fix: Improve styling with mixed RTL content (#8247)
* fix: Improve styling with mixed RTL content

* fix
2025-01-16 13:35:04 -08:00
Tom Moor 7421a9fbdc fix: Mentions should not be able to contain node content (#8246)
closes #8238
2025-01-16 03:49:38 -08:00
Tom Moor 56b9c60388 Revert "Updated ImportJsonTask file mapDocuments method to use unshift instea…" (#8241)
This reverts commit 9cab404194.
2025-01-15 07:16:41 -08:00
Tom Moor 8fec6758b8 fix: Move compression middleware to cover all /api and /auth routes 2025-01-14 19:01:51 -05:00
Tom Moor 1aaabf113b fix: Incorrect plain text serialization in exportTable (#8234) 2025-01-14 06:35:43 -08:00
Translate-O-Tron a0d78378d7 New Crowdin updates (#8204) 2025-01-13 19:35:08 -08:00
dependabot[bot] 78bf8fd641 chore(deps): bump @sentry/react from 7.119.0 to 7.120.3 (#8233)
Bumps [@sentry/react](https://github.com/getsentry/sentry-javascript) from 7.119.0 to 7.120.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.120.3/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.119.0...7.120.3)

---
updated-dependencies:
- dependency-name: "@sentry/react"
  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>
2025-01-13 19:34:24 -08:00
dependabot[bot] 5374d32801 chore(deps): bump @sentry/node from 7.120.0 to 7.120.3 (#8232)
Bumps [@sentry/node](https://github.com/getsentry/sentry-javascript) from 7.120.0 to 7.120.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.120.3/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.120.0...7.120.3)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  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>
2025-01-13 18:52:28 -08:00
dependabot[bot] 68de78ead8 chore(deps): bump sonner from 1.4.3 to 1.7.1 (#8231)
Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.4.3 to 1.7.1.
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/compare/v1.4.3...v1.7.1)

---
updated-dependencies:
- dependency-name: sonner
  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>
2025-01-13 18:52:17 -08:00
WEI-HUA CHIEN 3998a80ae9 fix: Handle nested collapsed headings in findCollapsedNodes (#8223) 2025-01-11 08:59:40 -08:00
Hemachandar e910ecf559 fix: Update counter cache when a user is deleted (or) suspended (#8222) 2025-01-10 19:36:39 -08:00
Hemachandar e42b533b07 Move group management to table (#8212)
* convert to table

* refactor edit group modal

* refactor delete group modal

* refactor add people modal

* refactor create group modal

* rebased changes

* filter works

* empty group message

* retain group title click

* fade

* cleanup

* pre-filtered for determining isEmpty

* remove fade, unnecessary role check

* StickyFilters component

* createdAt column

* Remove DelayedMount
Add 'External ID' in menu when present

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-09 20:06:09 -05:00
Hemachandar 81d7492e5e Convert Comment and Reaction mutations (#8181)
* handle create, update, delete

* handle resolve, unresolve

* handle add_reaction, remove_reaction

* cleanup

* fix type

* afterDestroy hook

* remove unnecessary 'hooks:false' added in this PR

* tests
2025-01-09 15:48:09 -08:00
Hemachandar 3c5ce8cb3d Publish event in withCtx flow only (#8188)
* api key

* attachment

* file operation

* group

* share

* star

* subscription

* publish events in withCtx flow only

* cleanup GroupUser hooks:false

* type and rename

* rename publish to create
2025-01-08 05:27:49 -08:00
Hemachandar cf3e29bbab Improve useTableRequest for better reactivity (#8206)
* use data from store directly

* load active users only when no filter is set

* return invited user email in users.invite response

* shares
2025-01-08 05:27:36 -08:00
Translate-O-Tron 92a5954ec7 New Crowdin updates (#8193) 2025-01-07 05:29:21 -08:00
Hemachandar 4afa225967 Simplify email references determination (#8189)
* Simplify email references determination

* individual thread for comments

* use toPlainText
2025-01-07 05:29:04 -08:00
dependabot[bot] 48feaf9bc0 chore(deps): bump nodemailer and @types/nodemailer (#8197)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer). These dependencies needed to be updated together.

Updates `nodemailer` from 6.9.14 to 6.9.16
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.14...v6.9.16)

Updates `@types/nodemailer` from 6.4.15 to 6.4.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/nodemailer"
  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>
2025-01-06 17:14:23 -08:00
dependabot[bot] 3f2ac2d23b chore(deps-dev): bump @babel/cli from 7.25.9 to 7.26.4 (#8198)
Bumps [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) from 7.25.9 to 7.26.4.
- [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.26.4/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/cli"
  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>
2025-01-06 17:14:06 -08:00
dependabot[bot] 38c12bd2a9 chore(deps-dev): bump @types/resolve-path from 1.4.2 to 1.4.3 (#8199)
Bumps [@types/resolve-path](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/resolve-path) from 1.4.2 to 1.4.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/resolve-path)

---
updated-dependencies:
- dependency-name: "@types/resolve-path"
  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>
2025-01-06 17:13:52 -08:00
Hemachandar fafaddf07f feat: Option to return anchor text for comments (#8196)
* feat: Option to return anchor text for comments

* cleanup anchorText presentation

* consolidated anchor text

* cleanup unused method
2025-01-06 17:13:37 -08:00
Hemachandar 25f264a763 fix: Notify previously mentioned users when new comment is added to a thread (#8194) 2025-01-05 18:23:16 -08:00
Tom Moor 085785a94c Remove yellow badge for guests 2025-01-05 19:40:30 -05:00
Tom Moor 9c71566d66 fix: Filter input divider in light theme 2025-01-05 19:40:30 -05:00
Hemachandar 4a64a767e1 Convert GroupUser mutations (#8187)
* Convert 'GroupUser' mutations

* cleanup commands
2025-01-05 16:33:51 -08:00
Hemachandar 9bc1788bc0 Upgrade and virtualize table component (#8157)
* Upgrade and virtualize table component

* width in column def

* container height

* share query options

* full page scroll

* change z-index and remove shrink

* non-modal menu
2025-01-05 04:55:05 -08:00
Tom Moor e93ef8b392 fix: shares.update written on every view, regressed in #8177 2025-01-04 21:10:18 -05:00
Tom Moor db30d080ae fix: Highlight matching mentions in find and replace (#8184) 2025-01-02 19:06:42 -08:00
Translate-O-Tron 63d70c2cd5 New Crowdin updates (#8144) 2025-01-02 12:06:20 -08:00
Hemachandar 98fef1bb1f Convert Share mutations (#8177)
* Convert 'Share' mutations

* createContext

* name override in share.revoke method
2025-01-02 12:06:02 -08:00
Prasad Bandaru 9cab404194 Updated ImportJsonTask file mapDocuments method to use unshift instead of push for sorting order (#8183) 2025-01-02 12:05:50 -08:00
Tom Moor d6459150fe feat: Allow resizing of embed height (#8154)
* stash

* tsc

* remove console log

* Restore bottom bar on embeds

* fix: Cannot see selected state

* fix layout issue
2025-01-02 05:49:51 -08:00
Hemachandar 4789ddd947 Skip unnecessary update of comment sort preference (#8182) 2025-01-02 05:49:33 -08:00
Tom Moor 1c179a3c6b Move Group to model event writing (#8179)
* Move Group to model event writing

* cleanup type
2025-01-02 07:45:09 -05:00
Tom Moor b8c07eb298 chore: Cleanup unused pinDestroyer (#8180) 2025-01-01 14:02:45 -08:00
Tom Moor adfca1e5ca fix: Attempting to split undefined 2024-12-31 17:33:47 -05:00
Tom Moor 6ca3c25d35 fix: Do not report errors due to unsupported file types 2024-12-31 08:26:34 -05:00
Tom Moor 05a2c6ae1e fix: Zoom cursor shown while drag-resizing image 2024-12-31 08:26:34 -05:00
Hemachandar 234915f4a0 Convert Subscription mutations (#8166)
* createContext accepts object

* handle subscriptions

* use createContext

* should've done this on the initial attempt...
2024-12-31 05:25:43 -08:00
Tom Moor 538a1274ab fix: Scale width of caption with image (#8174) 2024-12-31 03:36:14 -08:00
Hemachandar 63422373ac Add teamId index on attachments table (#8175) 2024-12-31 03:36:05 -08:00
dependabot[bot] 708bd8a544 chore(deps): bump prosemirror-view from 1.36.0 to 1.37.1 (#8172)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.36.0 to 1.37.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.36.0...1.37.1)

---
updated-dependencies:
- dependency-name: prosemirror-view
  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>
2024-12-30 16:30:23 -08:00
dependabot[bot] 120191d4d7 chore(deps-dev): bump eslint-plugin-react from 7.35.0 to 7.37.3 (#8169)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.35.0 to 7.37.3.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.35.0...v7.37.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  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>
2024-12-30 16:29:40 -08:00
dependabot[bot] 6a2ab299a8 chore(deps): bump @babel/preset-react from 7.25.9 to 7.26.3 (#8170)
Bumps [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) from 7.25.9 to 7.26.3.
- [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.26.3/packages/babel-preset-react)

---
updated-dependencies:
- dependency-name: "@babel/preset-react"
  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>
2024-12-30 16:29:27 -08:00
dependabot[bot] 74dc7094e1 chore(deps): bump i18next-http-backend from 2.5.0 to 2.7.1 (#8171)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 2.5.0 to 2.7.1.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/commits)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  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>
2024-12-30 16:29:09 -08:00
Hemachandar 5dd993adf5 Convert WebhookSubscription mutations (#8161)
* Convert 'WebhookSubscription' mutations

* add tests

* remove unnecessary assignment
2024-12-30 16:11:32 -08:00
Hemachandar 41832bbaf1 fix: Use parent transaction for findOrCreate after-commit hook (#8173) 2024-12-30 16:11:11 -08:00
Tom Moor f448be5830 feat: Allow querying groups by externalId 2024-12-27 16:48:19 +00:00
Tom Moor f0fcb26b50 fix: Cannot read properties of undefined (reading 'replace'), closes #8123 2024-12-27 10:41:45 +00:00
Tom Moor ad237a619c fix: Avoid document scrolling behavior when auto-scrolling sidebar 2024-12-26 21:41:25 +00:00
Tom Moor 5f49938267 chore: Fix react key warning 2024-12-26 17:40:22 +00:00
Tom Moor 68a469daa7 Add externalId property on groups (#8127)
* Add 'externalId' property on groups

* Remove clientside Field decorator

* Allow querying by externalId
2024-12-26 08:44:04 -08:00
Tom Moor 3d5a167f7f fix: textBetween line breaks (#8145)
* fix: textBetween line breaks

* test
2024-12-26 03:31:12 -08:00
Tom Moor b58671cbd1 Exclude state column by default in document queries (#8139)
* Exclude state column by default in document queries

* restore withoutState scope
2024-12-26 03:30:48 -08:00
Tom Moor b3a3b0763f fix: Exported HTML does not include table column sizes (#8128) 2024-12-26 03:06:03 -08:00
Tom Moor a4becd66bd feat: Add 'Protobuf' highlighting, closes #8141 2024-12-26 11:05:25 +00:00
Tom Moor 3437bd3a6c fix: Additional Canva embed format, closes #8140 2024-12-25 11:28:17 +00:00
Tom Moor 86cfd62afa feat: Allow users to change email in-app (#8119) 2024-12-25 02:58:26 -08:00
Translate-O-Tron 85b62d3146 New Crowdin updates (#8132)
* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2024-12-24 09:38:02 -08:00
dependabot[bot] 1fa0a5ea98 chore(deps): bump i18next-fs-backend from 2.3.2 to 2.6.0 (#8136)
Bumps [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend) from 2.3.2 to 2.6.0.
- [Changelog](https://github.com/i18next/i18next-fs-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-fs-backend/compare/v2.3.2...v2.6.0)

---
updated-dependencies:
- dependency-name: i18next-fs-backend
  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>
2024-12-24 09:30:57 -08:00
dependabot[bot] 2b4c8d981c chore(deps-dev): bump nodemon from 3.1.7 to 3.1.9 (#8135)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.7 to 3.1.9.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.7...v3.1.9)

---
updated-dependencies:
- dependency-name: nodemon
  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>
2024-12-24 09:30:23 -08:00
Tom Moor ce55719626 chore: Print emails to console when Ethereal unavailable (offline) 2024-12-19 17:07:10 +09:00
Tom Moor b9f0f67fb2 chore: Tidy mention menu, remove unneccessary component 2024-12-19 14:39:16 +09:00
Tom Moor 02aa4c2928 fix: Consider CDN urls to not be internal 2024-12-19 09:48:21 +09:00
Tom Moor 77e8dbefd6 fix: Ensure signed urls on shared documents are valid longer than 60s 2024-12-19 09:43:49 +09:00
Tom Moor 1e5d281870 chore: Improve warning for SMTP_FROM_EMAIL not set, closes #8125 2024-12-19 06:57:05 +09:00
Tom Moor 9b68e6835e fix: Reduce visual strength of collection in doc breadcrumb 2024-12-18 11:07:39 +09:00
Tom Moor f17926f912 fix: Update slate to WCAG AA compliant, closes #8113 2024-12-18 10:46:06 +09:00
Tom Moor 2397196be8 fix: Shared document header always in mobile styling, closes #8121 2024-12-18 10:27:26 +09:00
Tom Moor 133db9c22c Improve error message when database URI contains invalid characters, closes #8110 2024-12-18 10:18:09 +09:00
Translate-O-Tron 0dd14cdf1a New Crowdin updates (#8058) 2024-12-17 16:56:43 -08:00
dependabot[bot] cc8ec28a39 chore(deps-dev): bump typescript from 5.6.3 to 5.7.2 (#8118)
* chore(deps-dev): bump typescript from 5.6.3 to 5.7.2

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.3 to 5.7.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.3...v5.7.2)

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

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

* tsc

---------

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.moor@gmail.com>
2024-12-18 09:56:31 +09:00
Tom Moor c8cbb9ef9c Add HEIC to supported mimes, closes #8122 2024-12-18 09:49:28 +09:00
dependabot[bot] 4af07ab6c4 chore(deps-dev): bump eslint-plugin-import from 2.29.1 to 2.31.0 (#8116)
Bumps [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import) from 2.29.1 to 2.31.0.
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...v2.31.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  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>
2024-12-17 16:22:49 -08:00
dependabot[bot] 742c138b3d chore(deps): bump mermaid from 11.4.0 to 11.4.1 (#8117)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.4.0 to 11.4.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Changelog](https://github.com/mermaid-js/mermaid/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.4.0...mermaid@11.4.1)

---
updated-dependencies:
- dependency-name: mermaid
  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>
2024-12-16 20:36:59 -08:00
Tom Moor ec1eacaeea fix: Cannot leave overlapping comments (#8107) 2024-12-16 20:36:32 -08:00
Tom Moor 8b15cc45b0 fix: Permissions checks on notification emails do not take into account shares (#8109)
* fix: Comment notifications not sent on drafts outside collection, shared docs

* fix: DocumentPublishedOrUpdatedEmail not sent for drafts

* tsc
2024-12-15 20:53:06 -08:00
Tom Moor e89c32424f fix: Subscribe to document automatically on share (#8108) 2024-12-15 17:37:20 -08:00
Tom Moor a458690bfc fix: Non-unique key parameter, closes #8104 2024-12-12 11:39:19 -05:00
Tom Moor df03a6da8c fix: Markdown escape characters left in titles on import (#8102) 2024-12-12 05:15:45 -08:00
Tom Moor 6dfe7d707a fix: Token type not supported by Markdown parser, closes #8101 2024-12-11 21:07:15 -05:00
Tom Moor c063709f1c Allow resizing final table column 2024-12-11 20:39:02 -05:00
Tom Moor dd8f6a987c perf: Avoid iterating child documents in documents.info when direct descendant 2024-12-09 22:13:04 -05:00
Tom Moor fa117870a2 perf: One less query in documents.info 2024-12-09 21:52:32 -05:00
dependabot[bot] 40b1e3c8c6 chore(deps): bump prosemirror-model from 1.23.0 to 1.24.0 (#8092)
* chore(deps): bump prosemirror-model from 1.23.0 to 1.24.0

Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.23.0 to 1.24.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.23.0...1.24.0)

---
updated-dependencies:
- dependency-name: prosemirror-model
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* tsc

* tsc

---------

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.moor@gmail.com>
2024-12-09 17:43:04 -08:00
Tom Moor e3b0f7db86 fix: Parsing of grist links with utm parameters, closes #8082 2024-12-09 20:42:36 -05:00
dependabot[bot] 6fddb29ff6 chore(deps): bump nanoid from 3.3.7 to 3.3.8 (#8098)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-09 17:37:36 -08:00
dependabot[bot] 569a7876ae chore(deps): bump utility-types from 3.10.0 to 3.11.0 (#8093)
Bumps [utility-types](https://github.com/piotrwitek/utility-types) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/piotrwitek/utility-types/releases)
- [Commits](https://github.com/piotrwitek/utility-types/compare/v3.10.0...v3.11.0)

---
updated-dependencies:
- dependency-name: utility-types
  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>
2024-12-09 17:31:03 -08:00
dependabot[bot] bea56159ec chore(deps-dev): bump eslint-import-resolver-typescript from 3.6.3 to 3.7.0 (#8096)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.6.3 to 3.7.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.6.3...v3.7.0)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  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>
2024-12-09 17:30:41 -08:00
Tom Moor 908f053920 Add UI element to images that are externally hosted 2024-12-08 12:51:55 -05:00
Tom Moor 033c298bff fix: Retrieve image dimensions for all types, not only PNG 2024-12-07 17:15:25 -05:00
Tom Moor 22f02ad713 feat: Add 'Neon' highlighter color, increase presence of highlights 2024-12-07 16:27:57 -05:00
Tom Moor 92b1c578f6 feat: Improve inline rule matching (#8085)
* stash

* fix: Allow inline mark matching to work with preceding brackets
Refactor markInputRule, add markInputRuleForPattern

* docs
2024-12-07 12:46:25 -08:00
Tom Moor a738ea97b5 feat: Dropping a remote image will now upload (#8086)
* feat: Dropping a remote image will now upload

* refactor,DRY

* guard

* Parse correct file name from url where possible
2024-12-07 12:46:14 -08:00
Tom Moor 7fbe442863 Show editor tooltip shortcuts on separate line 2024-12-07 14:10:36 -05:00
355 changed files with 9584 additions and 7844 deletions
-37
View File
@@ -1,37 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
+63
View File
@@ -0,0 +1,63 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+13
View File
@@ -13,3 +13,16 @@ updates:
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
+1 -2
View File
@@ -32,6 +32,7 @@ import {
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
ExportContentType,
TeamPreference,
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -732,7 +732,6 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
}
};
+4
View File
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+5 -1
View File
@@ -37,7 +37,11 @@ function Breadcrumb(
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
{item.icon}
{item.to ? (
<Item
+1 -1
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
+6 -3
View File
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Callback when the dialog is submitted. Return false to prevent closing. */
onSubmit: () => Promise<void | boolean> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
ev.preventDefault();
setIsSaving(true);
try {
await onSubmit();
const res = await onSubmit();
if (res === false) {
return;
}
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
+3 -1
View File
@@ -23,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -109,6 +109,8 @@ const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
+1 -1
View File
@@ -3,10 +3,10 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
+32 -6
View File
@@ -1,24 +1,25 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -71,6 +72,10 @@ function DocumentCard(props: Props) {
[pin]
);
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return (
<Reorderable
ref={setNodeRef}
@@ -143,8 +148,14 @@ function DocumentCard(props: Props) {
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
</Content>
@@ -165,6 +176,21 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+1 -1
View File
@@ -14,12 +14,12 @@ import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
+2 -3
View File
@@ -9,13 +9,13 @@ import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
@@ -23,7 +23,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
+6 -2
View File
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Strong>
<DocumentBreadcrumb document={document} onlyText />
</strong>
</Strong>
</span>
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
@@ -210,6 +210,10 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
+1 -74
View File
@@ -1,6 +1,4 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -50,76 +41,13 @@ export type Props = Optional<
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const { comments } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles({ query: term });
return sortBy(
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[locale, documents]
);
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
@@ -263,7 +191,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
+1 -2
View File
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
@@ -22,7 +22,6 @@ import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { hover } from "~/styles";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
+2 -2
View File
@@ -46,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: true,
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid rgb(34 40 52);
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
}
-89
View File
@@ -1,89 +0,0 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
group: Group;
membership?: GroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = memberCount - users.length;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
subtitle={t("{{ count }} member", { count: memberCount })}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
onClick={setMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: setMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title={t("Group members")}
onRequestClose={setMembersModalClosed}
isOpen={membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
const Title = styled.span`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
export default observer(GroupListItem);
@@ -1,13 +1,12 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s } from "@shared/styles";
import { breakpoints, s, hover } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
@@ -1,7 +1,6 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
@@ -1,7 +1,6 @@
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
@@ -2,13 +2,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { depths, s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
+2 -3
View File
@@ -10,19 +10,18 @@ import {
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { hover } from "~/styles";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
+1 -1
View File
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
type Props = {
+1 -2
View File
@@ -6,10 +6,9 @@ import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { s, hover, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -4,11 +4,10 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { hover, truncateMultiline } from "~/styles";
import { Avatar, AvatarSize } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
@@ -3,13 +3,12 @@ import { MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import { hover } from "~/styles";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
+1 -2
View File
@@ -3,7 +3,7 @@ import { transparentize } from "polished";
import React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import type { ReactionSummary } from "@shared/types";
import { getEmojiId } from "@shared/utils/emoji";
import User from "~/models/User";
@@ -13,7 +13,6 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { hover } from "~/styles";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Tab, TabPanel, useTabState } from "reakit";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import Comment from "~/models/Comment";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Emoji } from "~/components/Emoji";
@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
type Props = {
/** Model for which to show the reactions. */
+1 -2
View File
@@ -7,10 +7,9 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import { s, hover, ellipsis } from "@shared/styles";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
@@ -1,7 +1,7 @@
import { PlusIcon } from "outline-icons";
import styled from "styled-components";
import { hover } from "@shared/styles";
import BaseListItem from "~/components/List/Item";
import { hover } from "~/styles";
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
@@ -5,7 +5,7 @@ import { CheckmarkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -20,7 +20,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
+1 -2
View File
@@ -1,9 +1,8 @@
import { darken } from "polished";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
+2 -1
View File
@@ -3,6 +3,7 @@ import { SidebarIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
@@ -11,7 +12,6 @@ import SearchPopover from "~/components/SearchPopover";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { useTeamContext } from "../TeamContext";
@@ -67,6 +67,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
depth={0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
+1
View File
@@ -197,6 +197,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
return (
<TooltipProvider>
<Container
id="sidebar"
ref={ref}
style={style}
$hidden={hidden}
@@ -5,13 +5,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
@@ -93,15 +93,11 @@ const NavLink = ({
React.useLayoutEffect(() => {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
// If the page has an anchor hash then this means we're linking to an
// anchor in the document smooth scrolling the sidebar may the scrolling
// to the anchor of the document so we must avoid it.
if (!window.location.hash) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "auto",
});
}
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "auto",
boundary: (parent) => parent.id !== "sidebar",
});
}
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -16,6 +16,7 @@ type Props = {
collection?: Collection;
activeDocumentId?: string;
activeDocument?: Document;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
isDraft?: boolean;
depth: number;
index: number;
@@ -29,6 +30,7 @@ function DocumentLink(
collection,
activeDocument,
activeDocumentId,
prefetchDocument,
isDraft,
depth,
shareId,
@@ -97,6 +99,10 @@ function DocumentLink(
node,
]);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
}, [prefetchDocument, node]);
const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
@@ -114,6 +120,7 @@ function DocumentLink(
}}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon && <Icon value={icon} color={node.color} />}
label={title}
depth={depth}
@@ -132,6 +139,7 @@ function DocumentLink(
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
@@ -1,5 +1,5 @@
import styled from "styled-components";
import { hover } from "~/styles";
import { hover } from "@shared/styles";
import SidebarButton from "./SidebarButton";
const ToggleButton = styled(SidebarButton)`
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -13,7 +14,6 @@ import GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "../components/SidebarLink";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
+31
View File
@@ -0,0 +1,31 @@
import { ColumnSort } from "@tanstack/react-table";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props as TableProps } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
export function SortableTable<T>(props: Props<T>) {
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort: ColumnSort) => {
params.set("sort", sort.id);
params.set("direction", sort.desc ? "desc" : "asc");
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
return <Table onChangeSort={handleChangeSort} {...props} />;
}
+1 -1
View File
@@ -3,6 +3,7 @@ import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import { hover } from "@shared/styles";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import {
@@ -11,7 +12,6 @@ import {
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
+1 -2
View File
@@ -4,9 +4,8 @@ import isEqual from "lodash/isEqual";
import queryString from "query-string";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
/**
+302 -240
View File
@@ -1,231 +1,283 @@
import isEqual from "lodash/isEqual";
import {
useReactTable,
getCoreRowModel,
SortingState,
flexRender,
ColumnSort,
functionalUpdate,
Row as TRow,
createColumnHelper,
AccessorFn,
CellContext,
} from "@tanstack/react-table";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import { s } from "@shared/styles";
import Button from "~/components/Button";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
export type Props = {
data: any[];
offset?: number;
isLoading: boolean;
empty?: React.ReactNode;
currentPage?: number;
page: number;
pageSize?: number;
totalPages?: number;
defaultSort?: string;
topRef?: React.Ref<any>;
onChangePage: (index: number) => void;
onChangeSort: (
sort: string | null | undefined,
direction: "ASC" | "DESC"
) => void;
columns: any;
defaultSortDirection: "ASC" | "DESC";
const HEADER_HEIGHT = 40;
type DataColumn<TData> = {
type: "data";
header: string;
accessor: AccessorFn<TData>;
sortable?: boolean;
};
function Table({
data,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
type ActionColumn = {
type: "action";
header?: string;
};
return newState;
},
},
useSortBy,
usePagination
export type Column<TData> = {
id: string;
component: (data: TData) => React.ReactNode;
width: string;
} & (DataColumn<TData> | ActionColumn);
export type Props<TData> = {
data: TData[];
columns: Column<TData>[];
sort: ColumnSort;
onChangeSort: (sort: ColumnSort) => void;
loading: boolean;
page: {
hasNext: boolean;
fetchNext?: () => void;
};
rowHeight: number;
stickyOffset?: number;
};
function Table<TData>({
data,
columns,
sort,
onChangeSort,
loading,
page,
rowHeight,
stickyOffset = 0,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
const [virtualContainerTop, setVirtualContainerTop] =
React.useState<number>();
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
const observedColumns = React.useMemo(
() =>
columns.map((column) => {
const cell = ({ row }: CellContext<TData, unknown>) => (
<ObservedCell data={row.original} render={column.component} />
);
return column.type === "data"
? columnHelper.accessor(column.accessor, {
id: column.id,
header: column.header,
enableSorting: column.sortable ?? true,
cell,
})
: columnHelper.display({
id: column.id,
header: column.header ?? "",
cell,
});
}),
[columns, columnHelper]
);
const prevSortBy = React.useRef(sortBy);
const gridColumns = React.useMemo(
() => columns.map((column) => column.width).join(" "),
[columns]
);
const handleChangeSort = React.useCallback(
(sortState: SortingState) => {
const newState = functionalUpdate(sortState, [sort]);
const newSort = newState[0];
onChangeSort(newSort);
},
[sort, onChangeSort]
);
const prevSort = usePrevious(sort);
const sortChanged = sort !== prevSort;
const isEmpty = !loading && data.length === 0;
const showPlaceholder = loading && data.length === 0;
const table = useReactTable({
data,
columns: observedColumns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
enableMultiSort: false,
enableSortingRemoval: false,
state: {
sorting: [sort],
},
onSortingChange: handleChangeSort,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
scrollMargin: virtualContainerTop,
overscan: 5,
});
React.useEffect(() => {
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
if (!sortChanged || !virtualContainerTop) {
return;
}
const scrollThreshold =
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
const reset = window.scrollY > scrollThreshold;
if (reset) {
rowVirtualizer.scrollToOffset(scrollThreshold, {
behavior: "smooth",
});
}
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
React.useLayoutEffect(() => {
if (virtualContainerRef.current) {
// determine the scrollable virtual container offsetTop on mount
setVirtualContainerTop(
virtualContainerRef.current.getBoundingClientRect().top
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
}, []);
return (
<div style={{ overflowX: "auto" }}>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => {
const groupProps = headerGroup.getHeaderGroupProps();
return (
<tr {...groupProps} key={groupProps.key}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
<>
<InnerTable role="table">
<THead role="rowgroup" $topPos={stickyOffset}>
{table.getHeaderGroups().map((headerGroup) => (
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
{headerGroup.headers.map((header) => (
<TH role="columnheader" key={header.id}>
<SortWrapper
align="center"
gap={4}
onClick={header.column.getToggleSortingHandler()}
$sortable={header.column.getCanSort()}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()} key={row.id}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
className: cell.column.className,
},
])}
key={cell.column.id}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" ? (
<AscSortIcon />
) : header.column.getIsSorted() === "desc" ? (
<DescSortIcon />
) : (
<div />
)}
</SortWrapper>
</TH>
))}
</TR>
))}
</THead>
<TBody
ref={virtualContainerRef}
role="rowgroup"
$height={rowVirtualizer.getTotalSize()}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
<TR
role="row"
key={row.id}
data-index={virtualRow.index}
style={{
position: "absolute",
transform: `translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
height: `${virtualRow.size}px`,
}}
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD role="cell" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
</TR>
);
})}
</TBody>
{showPlaceholder && (
<Placeholder columns={columns.length} gridColumns={gridColumns} />
)}
</InnerTable>
{page.hasNext && (
<Waypoint
key={data?.length}
onEnter={page.fetchNext}
bottomOffset={-rowHeight * 5}
/>
)}
</div>
{isEmpty && <Empty>{t("No results")}</Empty>}
</>
);
}
export const Placeholder = ({
const ObservedCell = observer(function <TData>({
data,
render,
}: {
data: TData;
render: (data: TData) => React.ReactNode;
}) {
return <>{render(data)}</>;
});
function Placeholder({
columns,
rows = 3,
gridColumns,
}: {
columns: number;
rows?: number;
}) => (
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
);
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
gridColumns: string;
}) {
return (
<DelayedMount>
<TBody $height={150}>
{new Array(rows).fill(1).map((_r, row) => (
<TR key={row} $columns={gridColumns}>
{new Array(columns).fill(1).map((_c, col) => (
<TD key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</TD>
))}
</TR>
))}
</TBody>
</DelayedMount>
);
}
const DescSortIcon = styled(CollapsedIcon)`
margin-left: -2px;
@@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
min-width: 100%;
`;
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
display: inline-flex;
height: 24px;
@@ -261,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
}
`;
const Cell = styled.td`
padding: 10px 6px;
border-bottom: 1px solid ${s("divider")};
const InnerTable = styled.div`
width: 100%;
`;
const THead = styled.div<{ $topPos: number }>`
position: sticky;
top: ${({ $topPos }) => `${$topPos}px`};
height: ${HEADER_HEIGHT}px;
z-index: 1;
font-size: 14px;
text-wrap: nowrap;
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
`;
const TBody = styled.div<{ $height: number }>`
position: relative;
height: ${({ $height }) => `${$height}px`};
`;
const TR = styled.div<{ $columns: string }>`
width: 100%;
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
&:last-child {
border-bottom: 0;
}
`;
const TH = styled.span`
padding: 6px 6px 2px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
`;
const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
word-break: break-word;
&:first-child {
font-size: 15px;
font-weight: 500;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&.actions,
@@ -292,39 +389,4 @@ const Cell = styled.td`
}
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
padding: 6px 6px 2px;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
font-size: 14px;
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
-71
View File
@@ -1,71 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
const TableFromParams = (
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
params.set("direction", direction.toLowerCase());
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default observer(TableFromParams);
+13 -4
View File
@@ -12,6 +12,8 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
content?: React.ReactChild | React.ReactChild[];
/** A keyboard shortcut to display next to the content */
shortcut?: React.ReactNode;
/** Whether to show the shortcut on a new line */
shortcutOnNewline?: boolean;
};
/**
@@ -21,7 +23,13 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
* singleton instance (delay, animation, etc).
*/
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
function Tooltip({
shortcut,
shortcutOnNewline,
content: tooltip,
delay = 500,
...rest
}: Props) {
const isMobile = useMobile();
const singleton = useTooltipContext();
@@ -34,12 +42,13 @@ function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
if (shortcut) {
content = (
<>
{tooltip}{" "}
{tooltip}
{shortcutOnNewline ? <br /> : " "}
{typeof shortcut === "string" ? (
shortcut
.split("+")
.map((key) => (
<Shortcut key={key}>
.map((key, i) => (
<Shortcut key={`${key}${i}`}>
{key.length === 1 ? key.toUpperCase() : key}
</Shortcut>
))
+72 -2
View File
@@ -1,10 +1,14 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import User from "~/models/User";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "~/components/Input";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Text from "./Text";
type Props = {
user: User;
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
};
return (
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}`}>
<ConfirmationDialog
onSubmit={handleSubmit}
savingText={`${t("Saving")}`}
danger
>
{t(
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
{
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
onChange={handleChange}
error={!name ? t("Name can't be empty") : undefined}
value={name}
autoSelect
required
flex
/>
</ConfirmationDialog>
);
}
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
const { t } = useTranslation();
const actor = useCurrentUser();
const [email, setEmail] = React.useState<string>(user.email);
const [error, setError] = React.useState<string | undefined>();
const handleSubmit = async () => {
try {
await client.post(`/users.updateEmail`, { id: user.id, email });
onSubmit();
toast.info(
actor.id === user.id
? t("Check your email to verify the new address.")
: t("The email will be changed once verified.")
);
return true;
} catch (err) {
setError(err.message);
return false;
}
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setEmail(ev.target.value);
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Save")}
savingText={`${t("Saving")}`}
disabled={!email || email === user.email}
>
<Text as="p">
{actor.id === user.id ? (
<Trans>
You will receive an email to verify your new address. It must be
unique in the workspace.
</Trans>
) : (
<Trans>
A confirmation email will be sent to the new address before it is
changed.
</Trans>
)}
</Text>
<Input
type="email"
name="email"
label={t("New email")}
onChange={handleChange}
error={!email ? t("Email can't be empty") : error}
value={email}
autoSelect
required
flex
/>
+1 -4
View File
@@ -6,10 +6,7 @@ import SuggestionsMenu, {
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "trigger"
> &
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
+1 -2
View File
@@ -17,7 +17,7 @@ type Emoji = {
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
const EmojiMenu = (props: Props) => {
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
return (
<SuggestionsMenu
{...props}
trigger=":"
filterable={false}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
+9 -242
View File
@@ -1,43 +1,24 @@
import {
ArrowIcon,
DocumentIcon,
CloseIcon,
PlusIcon,
OpenIcon,
} from "outline-icons";
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, hideScrollbars } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import Input from "./Input";
import LinkSearchResult from "./LinkSearchResult";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
url: string;
};
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onSelectLink: (options: {
href: string;
title?: string;
@@ -52,46 +33,25 @@ type Props = {
};
type State = {
results: {
[keyword: string]: SearchResult[];
};
value: string;
previousValue: string;
selectedIndex: number;
};
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
resultsRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
state: State = {
selectedIndex: -1,
value: this.href,
previousValue: "",
results: {},
};
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
get selectedText(): string {
const { state } = this.props.view;
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
return selectionText.trim();
}
get suggestedLinkTitle(): string {
return this.state.value.trim() || this.selectedText;
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
};
handleKeyDown = (event: React.KeyboardEvent): void => {
const results = this.results;
switch (event.key) {
case "Enter": {
event.preventDefault();
const { selectedIndex, value } = this.state;
const { onCreateLink } = this.props;
const { value } = this.state;
if (selectedIndex >= 0) {
const result = results[selectedIndex];
if (result) {
this.save(result.url, result.title);
} else if (onCreateLink && selectedIndex === results.length) {
void this.handleCreateLink(this.suggestedLinkTitle);
}
} else {
// saves the raw input as href
this.save(value, value);
}
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
}
return;
}
case "ArrowUp": {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const prevIndex = this.state.selectedIndex - 1;
this.setState({
selectedIndex: Math.max(-1, prevIndex),
});
return;
}
case "ArrowDown":
case "Tab": {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const { selectedIndex } = this.state;
const total = results.length + 1;
const nextIndex = selectedIndex + 1;
this.setState({
selectedIndex: Math.min(nextIndex, total),
});
return;
}
}
};
handleFocusLink = (selectedIndex: number) => {
this.setState({ selectedIndex });
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim() || this.selectedText;
const trimmedValue = value.trim();
if (trimmedValue && this.props.onSearchLink) {
if (trimmedValue) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
this.setState({
previousValue: trimmedValue,
}));
});
} catch (err) {
Logger.error("Error searching for link", err);
}
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
}
};
handleCreateLink = async (title: string, nested?: boolean) => {
this.discardInputValue = true;
const { onCreateLink } = this.props;
title = title.trim();
if (title.length === 0) {
return;
}
if (onCreateLink) {
return onCreateLink(title, nested);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
handleSelectLink =
(url: string, title: string) => (event: React.MouseEvent) => {
event.preventDefault();
this.save(url, title);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
@@ -305,42 +186,17 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
get results() {
const { value } = this.state;
return (
this.state.results[value.trim()] ||
this.state.results[this.state.previousValue] ||
[]
);
}
render() {
const { dictionary } = this.props;
const { value, selectedIndex } = this.state;
const results = this.results;
const looksLikeUrl = value.match(/^https?:\/\//i);
const suggestedLinkTitle = this.suggestedLinkTitle;
const { value } = this.state;
const isInternal = isInternalUrl(value);
const showCreateLink =
!!this.props.onCreateLink &&
!(suggestedLinkTitle === this.initialValue) &&
suggestedLinkTitle.length > 0 &&
!looksLikeUrl;
const hasResults =
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
return (
<Wrapper>
<Input
ref={this.inputRef}
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
<CloseIcon />
</ToolbarButton>
</Tooltip>
<SearchResults
ref={this.resultsRef}
$hasResults={hasResults}
role="menu"
>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((result, index) => (
<LinkSearchResult
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={result.icon ?? <DocumentIcon />}
onPointerMove={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
containerRef={this.resultsRef}
/>
))}
{showCreateLink && (
<>
<LinkSearchResult
key="create"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewDoc}
icon={<PlusIcon />}
onPointerMove={() => this.handleFocusLink(results.length)}
onClick={async () => {
await this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
<LinkSearchResult
key="create-nested"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewChildDoc}
icon={<PlusIcon />}
onPointerMove={() =>
this.handleFocusLink(results.length + 1)
}
onClick={async () => {
await this.handleCreateLink(suggestedLinkTitle, true);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length + 1 === selectedIndex}
/>
</>
)}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</Wrapper>
);
}
@@ -434,29 +226,4 @@ const Wrapper = styled(Flex)`
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
max-height: 240px;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default LinkEditor;
-109
View File
@@ -1,109 +0,0 @@
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import { s, ellipsis } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
icon: React.ReactNode;
selected: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement>;
};
function LinkSearchResult({
title,
subtitle,
containerRef,
selected,
icon,
...rest
}: Props) {
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent !== containerRef.current,
});
}
},
[containerRef, selected]
);
return (
<ListItem
ref={ref}
compact={!subtitle}
selected={selected}
role="menuitem"
{...rest}
>
<IconWrapper selected={selected}>{icon}</IconWrapper>
<Content>
<Title title={title}>{title}</Title>
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
</Content>
</ListItem>
);
}
const Content = styled.div`
overflow: hidden;
`;
const IconWrapper = styled.span<{ selected: boolean }>`
flex-shrink: 0;
margin-right: 4px;
height: 24px;
opacity: 0.8;
${(props) =>
props.selected &&
css`
svg {
fill: ${s("accentText")};
color: ${s("accentText")};
}
`};
`;
const ListItem = styled.div<{
selected: boolean;
compact: boolean;
}>`
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
margin: 0 6px;
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
background: ${(props) => (props.selected ? s("accent") : "transparent")};
font-family: ${s("fontFamily")};
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: var(--pointer);
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
`;
const Title = styled.div`
${ellipsis()}
font-size: 14px;
font-weight: 500;
`;
const Subtitle = styled.div<{
selected: boolean;
}>`
${ellipsis()}
font-size: 13px;
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
`;
export default LinkSearchResult;
-146
View File
@@ -1,146 +0,0 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
type Props = {
isActive: boolean;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onClose: () => void;
};
function isActive(view: EditorView, active: boolean): boolean {
try {
const { selection } = view.state;
const paragraph = view.domAtPos(selection.from);
return active && !!paragraph.node;
} catch (err) {
return false;
}
}
export default function LinkToolbar({
onCreateLink,
onSearchLink,
onClickLink,
onClose,
...rest
}: Props) {
const dictionary = useDictionary();
const { view } = useEditor();
const menuRef = React.useRef<HTMLDivElement>(null);
useEventListener("mousedown", (event: Event) => {
if (
event.target instanceof HTMLElement &&
menuRef.current &&
menuRef.current.contains(event.target)
) {
return;
}
onClose();
});
const handleOnCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
onClose();
view.focus();
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
const href = `${creatingUrlPrefix}#${title}`;
// Insert a placeholder link
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
},
[onCreateLink, onClose, view, dictionary]
);
const handleOnSelectLink = React.useCallback(
({
href,
title,
}: {
href: string;
title: string;
from: number;
to: number;
}) => {
onClose();
view.focus();
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
},
[onClose, view]
);
const { selection } = view.state;
const active = isActive(view, rest.isActive);
return (
<FloatingToolbar ref={menuRef} active={active} width={336}>
{active && (
<LinkEditor
key={`${selection.from}-${selection.to}`}
from={selection.from}
to={selection.to}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
onRemoveLink={onClose}
onClickLink={onClickLink}
onSearchLink={onSearchLink}
dictionary={dictionary}
view={view}
/>
)}
</FloatingToolbar>
);
}
+108 -46
View File
@@ -1,27 +1,29 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { v4 } from "uuid";
import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import MentionMenuItem from "./MentionMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
interface MentionItem extends MenuItem {
name: string;
user: User;
appendSpace: boolean;
attrs: {
id: string;
type: MentionType;
@@ -33,24 +35,31 @@ interface MentionItem extends MenuItem {
type Props = Omit<
SuggestionsMenuProps<MentionItem>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, users } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const { data, loading, request } = useRequest(
React.useCallback(
() =>
documentId
? users.fetchPage({ id: documentId, query: search })
: Promise.resolve([]),
[users, documentId, search]
)
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
}, [search, documents, users])
);
React.useEffect(() => {
@@ -60,28 +69,95 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
}, [request, isActive]);
React.useEffect(() => {
if (data && !loading) {
const items = data.map((user) => ({
name: "mention",
user,
title: user.name,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId: auth.currentUserId ?? undefined,
label: user.name,
},
}));
if (actorId && !loading) {
const items = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
} as MentionItem)
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collection?.name,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: v4(),
actorId,
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [auth.currentUserId, loading, data]);
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
@@ -118,29 +194,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
{...rest}
isActive={isActive}
filterable={false}
trigger="@"
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
<MentionMenuItem
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
label={item.attrs.label}
icon={
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={item.user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
}
icon={item.icon}
/>
)}
items={items}
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react";
import SuggestionsMenuItem, {
Props as SuggestionsMenuItemProps,
} from "./SuggestionsMenuItem";
type MentionMenuItemProps = Omit<
SuggestionsMenuItemProps,
"shortcut" | "theme"
> & {
label: string;
};
export default function MentionMenuItem({
label,
...rest
}: MentionMenuItemProps) {
return <SuggestionsMenuItem {...rest} title={label} />;
}
@@ -25,4 +25,9 @@ export class NodeViewRenderer<T extends object> {
this.props = props;
}
}
@action
public setProp<K extends keyof T>(key: K, value: T[K]) {
this.props[key] = value;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "embeds" | "trigger"
> & {
pastedText: string;
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
<SuggestionsMenu
{...props}
trigger=""
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
};
export default PasteMenu;
+2 -43
View File
@@ -1,7 +1,6 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
@@ -37,12 +35,10 @@ type Props = {
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
};
function useIsActive(state: EditorState) {
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
};
}, [isActive, previousIsActive, readOnly, view]);
const handleOnCreateLink = async (
title: string,
nested?: boolean
): Promise<void> => {
const { onCreateLink } = props;
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from === to) {
// Do not display a selection toolbar for collapsed selections
return;
}
const href = `${creatingUrlPrefix}${title}`;
const markType = state.schema.marks.link;
// Insert a placeholder link
dispatch(
view.state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
};
const handleOnSelectLink = ({
href,
from,
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
);
};
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
props;
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSearchLink={props.onSearchLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
/>
) : (
+59 -21
View File
@@ -1,7 +1,8 @@
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import * as React from "react";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import styled from "styled-components";
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
};
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -230,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const attrs =
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (command) {
if (item.name === "noop") {
// Do nothing
} else if (command) {
command(attrs);
} else {
commands[`create${capitalize(item.name)}`](attrs);
@@ -250,6 +255,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onSelect?.(item);
switch (item.name) {
case "link":
insertNode({
...item,
name: "mention",
});
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
@@ -264,7 +279,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
insertNode(item);
}
},
[insertNode]
[editorProps, props, insertNode]
);
const close = React.useCallback(() => {
@@ -414,11 +429,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return true;
}
if (item.visible === false) {
return false;
}
// Some extensions may be disabled, remove corresponding menu items
if (
item.name &&
!commands[item.name] &&
!commands[`create${capitalize(item.name)}`]
!commands[`create${capitalize(item.name)}`] &&
item.name !== "noop"
) {
return false;
}
@@ -445,16 +465,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
return filterExcessSeparators(
filtered
.map((item) => ({
orderBy(
filtered.map((item) => ({
item,
section:
"section" in item && item.section && "priority" in item.section
? (item.section.priority as number) ?? 0
: 0,
priority: "priority" in item ? item.priority : 0,
score:
searchInput && item.title
? commandScore(item.title, searchInput)
: 0,
}))
.sort((a, b) => b.score - a.score)
.map(({ item }) => item)
})),
["section", "priority", "score"],
["desc", "desc", "desc"]
).map(({ item }) => item)
);
}, [commands, props]);
@@ -555,6 +581,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
return (
<Portal>
@@ -614,18 +641,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
return (
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
+10 -1
View File
@@ -12,9 +12,11 @@ export type Props = {
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactElement;
icon?: React.ReactNode;
/** The title of the item */
title: React.ReactNode;
/** An optional subtitle for the item */
subtitle?: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
};
@@ -24,6 +26,7 @@ function SuggestionsMenuItem({
disabled,
onClick,
title,
subtitle,
shortcut,
icon,
}: Props) {
@@ -53,11 +56,17 @@ function SuggestionsMenuItem({
icon={icon}
>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
}
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
+1
View File
@@ -12,6 +12,7 @@ const WrappedTooltip: React.FC<Props> = ({
delay={150}
content={content}
placement="top"
shortcutOnNewline
{...rest}
>
<TooltipContent>{children}</TooltipContent>
+20 -13
View File
@@ -12,9 +12,10 @@ import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion {
get defaultOptions() {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
trigger: "/",
allowSpaces: false,
requireSearchTerm: false,
enabledInCode: false,
};
}
@@ -98,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
];
}
private handleClose = action((insertNewLine: boolean) => {
const { view } = this.editor;
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
});
widget = ({ rtl }: WidgetProps) => {
const { props, view } = this.editor;
const { props } = this.editor;
return (
<BlockMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action((insertNewLine) => {
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
})}
onClose={this.handleClose}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
@@ -1,6 +1,7 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
/**
* A plugin that allows overriding the default behavior of the editor to allow
@@ -13,11 +14,7 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const textSerializers = Object.fromEntries(
Object.entries(this.editor.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
const textSerializers = getTextSerializers(this.editor.schema);
return [
new Plugin({
+9 -9
View File
@@ -1,6 +1,7 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import { isBrowser } from "@shared/utils/browser";
import Suggestion from "~/editor/extensions/Suggestion";
import EmojiMenu from "../components/EmojiMenu";
@@ -13,17 +14,15 @@ const languagesUsingColon = ["fr"];
export default class EmojiMenuExtension extends Suggestion {
get defaultOptions() {
const languageIsUsingColon =
typeof window === "undefined"
? false
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
const languageIsUsingColon = isBrowser
? languagesUsingColon.includes(window.navigator.language.slice(0, 2))
: false;
return {
openRegex: new RegExp(
`(?:^|\\s|\\():([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
),
closeRegex:
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
trigger: ":",
allowSpaces: false,
requireSearchTerm: languageIsUsingColon,
enabledInCode: false,
};
}
@@ -34,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<EmojiMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
+39 -16
View File
@@ -79,15 +79,17 @@ export default class FindAndReplaceExtension extends Extension {
}
private get decorations() {
return this.results.map((deco, index) =>
Decoration.inline(deco.from, deco.to, {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
this.options.resultClassName +
(this.currentResultIndex === index
? ` ${this.options.resultCurrentClassName}`
: ""),
})
);
});
});
}
public replace(replace: string): Command {
@@ -175,7 +177,7 @@ export default class FindAndReplaceExtension extends Extension {
private goToMatch(direction: number): Command {
return (state, dispatch) => {
if (direction > 0) {
if (this.currentResultIndex === this.results.length - 1) {
if (this.currentResultIndex >= this.results.length - 1) {
this.currentResultIndex = 0;
} else {
this.currentResultIndex += 1;
@@ -212,11 +214,12 @@ export default class FindAndReplaceExtension extends Extension {
const { from: currentFrom, to: currentTo } = this.results[index];
const offset = currentTo - currentFrom - replace.length + lastOffset;
const { from, to } = this.results[nextIndex];
const { from, to, type } = this.results[nextIndex];
this.results[nextIndex] = {
to: to - offset,
from: from - offset,
type,
};
return offset;
@@ -224,10 +227,19 @@ export default class FindAndReplaceExtension extends Extension {
private search(doc: Node) {
this.results = [];
const mergedTextNodes: {
text: string | undefined;
pos: number;
}[] = [];
const mergedTextNodes: (
| {
text: string | undefined;
pos: number;
type: "inline";
}
| {
text: string | undefined;
pos: number;
type: "node";
nodeSize: number;
}
)[] = [];
let index = 0;
if (!this.searchTerm) {
@@ -238,21 +250,32 @@ export default class FindAndReplaceExtension extends Extension {
if (node.isText) {
if (mergedTextNodes[index]) {
mergedTextNodes[index] = {
type: "inline",
text: mergedTextNodes[index].text + (node.text ?? ""),
pos: mergedTextNodes[index].pos,
};
} else {
mergedTextNodes[index] = {
type: "inline",
text: node.text,
pos,
};
}
} else if (node.type.name === "mention") {
mergedTextNodes[++index] = {
type: "node",
nodeSize: node.nodeSize,
text: node.attrs.label,
pos,
};
++index;
} else {
index += 1;
++index;
}
});
mergedTextNodes.forEach(({ text = "", pos }) => {
mergedTextNodes.forEach((node) => {
const { text = "", pos, type } = node;
try {
let m;
const search = this.findRegExp;
@@ -266,8 +289,8 @@ export default class FindAndReplaceExtension extends Extension {
// Reconstruct the correct match position
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
const from = type === "inline" ? pos + i : pos;
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
// Check if already exists in results, possible due to duplicated
// search string on L257
@@ -275,7 +298,7 @@ export default class FindAndReplaceExtension extends Extension {
continue;
}
this.results.push({ from, to });
this.results.push({ from, to, type });
}
} catch (e) {
// Invalid RegExp
@@ -349,7 +372,7 @@ export default class FindAndReplaceExtension extends Extension {
private open = false;
@observable
private results: { from: number; to: number }[] = [];
private results: { from: number; to: number; type: "inline" | "node" }[] = [];
@observable
private currentResultIndex = 0;
+5 -3
View File
@@ -7,9 +7,10 @@ import MentionMenu from "../components/MentionMenu";
export default class MentionMenuExtension extends Suggestion {
get defaultOptions() {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
trigger: "@",
allowSpaces: true,
requireSearchTerm: false,
enabledInCode: false,
};
}
@@ -20,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<MentionMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
@@ -1,17 +1,29 @@
import { action, observable } from "mobx";
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import {
EditorState,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
@@ -60,13 +72,26 @@ function parseSingleIframeSrc(html: string) {
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
query: string;
pastedText: string;
} = observable({
open: false,
query: "",
pastedText: "",
});
get name() {
return "paste-handler";
}
private key = new PluginKey(this.name);
get plugins() {
return [
new Plugin({
key: this.key,
props: {
transformPastedHTML(html: string) {
if (isDropboxPaper(html)) {
@@ -106,23 +131,6 @@ export default class PasteHandler extends Extension {
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
function insertLink(href: string, title?: string) {
// If it's not an embed and there is no text selected just go ahead and insert the
// link directly
const transaction = view.state.tr
.insertText(
title ?? href,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + (title ?? href).length,
state.schema.marks.link.create({ href })
);
view.dispatch(transaction);
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
@@ -151,28 +159,6 @@ export default class PasteHandler extends Extension {
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -185,26 +171,42 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
const { hash } = new URL(text);
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) ===
IconType.Emoji;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
this.insertLink(`${document.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
this.insertLink(text);
});
}
} else {
insertLink(text);
this.insertLink(text);
}
return true;
@@ -306,10 +308,170 @@ export default class PasteHandler extends Extension {
return false;
},
},
state: {
init: () => DecorationSet.empty,
apply: (tr, set) => {
let mapping = tr.mapping;
// See if the transaction adds or removes any placeholders
const meta = tr.getMeta(this.key);
const hasDecorations = set.find().length;
// We only want a single paste placeholder at a time, so if we're adding a new
// placeholder we can just return a new DecorationSet and avoid mapping logic.
if (meta?.add) {
const { from, to, id } = meta.add;
const decorations = [
Decoration.inline(
from,
to,
{
class: "paste-placeholder",
},
{
id,
}
),
];
return DecorationSet.create(tr.doc, decorations);
}
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
try {
mapping = recreateTransform(tr.before, tr.doc, {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
set = set.map(mapping, tr.doc);
if (meta?.remove) {
const { id } = meta.remove;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return set.remove(decorations);
}
return set;
},
},
}),
];
}
/** Tracks whether the Shift key is currently held down */
private shiftKey = false;
private showPasteMenu = action((text: string) => {
this.state.pastedText = text;
this.state.open = true;
});
private hidePasteMenu = action(() => {
this.state.open = false;
});
private insertLink(href: string, title?: string) {
const { view } = this.editor;
const { state } = view;
const { from } = state.selection;
const to = from + (title ?? href).length;
const transaction = view.state.tr
.insertText(title ?? href, state.selection.from, state.selection.to)
.addMark(from, to, state.schema.marks.link.create({ href }))
.setMeta(this.key, { add: { from, to, id: href } });
view.dispatch(transaction);
this.showPasteMenu(href);
}
private insertEmbed = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
this.editor.commands.embed({
href: this.state.pastedText,
});
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
view.dispatch(
state.tr.setMeta(this.key, {
remove: { id: this.state.pastedText },
})
);
}
};
private findPlaceholder = (
state: EditorState,
id: string
): [number, number] | null => {
const decos = this.key.getState(state) as DecorationSet;
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
return found?.length ? [found[0].from, found[0].to] : null;
};
private handleSelect = (item: MenuItem) => {
switch (item.name) {
case "noop": {
this.hidePasteMenu();
this.removePlaceholder();
break;
}
case "embed": {
this.hidePasteMenu();
this.insertEmbed();
break;
}
default:
break;
}
};
keys() {
return {
Backspace: () => {
this.hidePasteMenu();
return false;
},
"Mod-z": () => {
this.hidePasteMenu();
return false;
},
};
}
widget = ({ rtl }: WidgetProps) => (
<PasteMenu
rtl={rtl}
embeds={this.editor.props.embeds}
pastedText={this.state.pastedText}
isActive={this.state.open}
search={this.state.query}
onClose={this.hidePasteMenu}
onSelect={this.handleSelect}
/>
);
}
+38 -33
View File
@@ -1,3 +1,4 @@
import escapeRegExp from "lodash/escapeRegExp";
import { action, observable } from "mobx";
import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model";
@@ -6,35 +7,37 @@ import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import { isInCode } from "@shared/editor/queries/isInCode";
type Options = {
enabledInCode: boolean;
trigger: string;
allowSpaces: boolean;
requireSearchTerm: boolean;
};
export default class Suggestion extends Extension {
state: {
open: boolean;
query: string;
} = observable({
open: false,
query: "",
});
constructor(options: Options) {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.options, this.state)];
return [
new SuggestionsMenuPlugin(this.options, this.state, this.openRegex),
];
}
keys() {
return {
Backspace: action((state: EditorState) => {
const { $from } = state.selection;
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - 500), // 500 = max match
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
null,
"\ufffc"
);
if (this.options.openRegex.test(textBefore)) {
return false;
Space: action(() => {
if (this.state.open && !this.options.allowSpaces) {
this.state.open = false;
}
this.state.open = false;
return false;
}),
};
@@ -42,7 +45,7 @@ export default class Suggestion extends Extension {
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(
this.options.openRegex,
this.openRegex,
action((state: EditorState, match: RegExpMatchArray) => {
const { parent } = state.selection.$from;
if (
@@ -51,21 +54,23 @@ export default class Suggestion extends Extension {
parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode)
) {
this.state.open = true;
if (match[0].length <= 2) {
this.state.open = true;
}
this.state.query = match[1];
}
return null;
})
),
new InputRule(
this.options.closeRegex,
action((_: EditorState, match: RegExpMatchArray) => {
if (match) {
this.state.open = false;
this.state.query = "";
}
return null;
})
),
];
protected openRegex: RegExp;
protected state: {
open: boolean;
query: string;
} = observable({
open: false,
query: "",
});
}
+23 -56
View File
@@ -1,5 +1,7 @@
/* global File Promise */
import { PluginSimple } from "markdown-it";
import { observable } from "mobx";
import { Observer } from "mobx-react";
import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
@@ -33,22 +35,23 @@ import Extension, {
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { ComponentProps, EventType } from "@shared/editor/types";
import { ComponentProps } from "@shared/editor/types";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
import { Dictionary } from "~/hooks/useDictionary";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -115,13 +118,11 @@ export type Props = {
/** Callback when a file upload ends */
onFileUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onCreateLink?: (params: Properties<Document>) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@@ -145,8 +146,6 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** If the insert link toolbar is visible */
linkToolbarOpen: boolean;
};
/**
@@ -176,7 +175,6 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
linkToolbarOpen: false,
};
isInitialized = false;
@@ -197,7 +195,7 @@ export class Editor extends React.PureComponent<
};
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
@@ -205,11 +203,6 @@ export class Editor extends React.PureComponent<
events = new EventEmitter();
mutationObserver?: MutationObserver;
public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
}
/**
* We use componentDidMount instead of constructor as the init method requires
* that the dom is already mounted.
@@ -246,6 +239,12 @@ export class Editor extends React.PureComponent<
...this.view.props,
editable: () => !this.props.readOnly,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", !this.props.readOnly)
);
}
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
@@ -265,7 +264,6 @@ export class Editor extends React.PureComponent<
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
@@ -274,9 +272,7 @@ export class Editor extends React.PureComponent<
if (
this.isBlurred &&
(this.state.isEditorFocused ||
this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen)
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
@@ -713,11 +709,7 @@ export class Editor extends React.PureComponent<
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
const textSerializers = getTextSerializers(this.schema);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
@@ -779,25 +771,8 @@ export class Editor extends React.PureComponent<
}));
};
private handleOpenLinkToolbar = () => {
if (this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
linkToolbarOpen: true,
}));
};
private handleCloseLinkToolbar = () => {
this.setState((state) => ({
...state,
linkToolbarOpen: false,
}));
};
public render() {
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
const { isRTL } = this.state;
@@ -814,7 +789,6 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
dir={dir}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
@@ -834,25 +808,18 @@ export class Editor extends React.PureComponent<
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
)}
{!readOnly && this.view && this.marks.link && (
<LinkToolbar
isActive={this.state.linkToolbarOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onClose={this.handleCloseLinkToolbar}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
))}
{Array.from(this.renderers).map((view) => view.content)}
<Observer>
{() => (
<>{Array.from(this.renderers).map((view) => view.content)}</>
)}
</Observer>
</Flex>
</EditorContext.Provider>
</PortalContext.Provider>
+6 -1
View File
@@ -241,7 +241,11 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment, { resolved: false }),
active: isMarkActive(
schema.marks.comment,
{ resolved: false },
{ exact: true }
),
visible: !isMobile || !isEmpty,
},
{
@@ -252,6 +256,7 @@ export default function formattingMenuItems(
name: "copyToClipboard",
icon: <CopyIcon />,
tooltip: dictionary.copy,
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
},
];
+1 -1
View File
@@ -39,7 +39,7 @@ export default function useDictionary() {
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Paste a link, search, or create")}`,
enterLink: `${t("Enter a link")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
+12 -3
View File
@@ -1,9 +1,10 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { isModKey } from "@shared/utils/keyboard";
import { isInternalUrl } from "@shared/utils/urls";
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
type Params = {
/** The share ID of the document being viewed, if any */
@@ -12,8 +13,9 @@ type Params = {
export default function useEditorClickHandlers({ shareId }: Params) {
const history = useHistory();
const { documents } = useStores();
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
(href: string, event?: MouseEvent) => {
// on page hash
if (isHash(href)) {
window.location.href = href;
@@ -49,13 +51,20 @@ export default function useEditorClickHandlers({ shareId }: Params) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
if (document) {
navigateTo = document.path;
}
}
// If we're navigating to a share link from a non-share link then open it in a new tab
if (!shareId && navigateTo.startsWith("/s/")) {
window.open(href, "_blank");
return;
}
if (!isModKey(event) && !event.shiftKey) {
if (!event || (!isModKey(event) && !event.shiftKey)) {
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
} else {
window.open(navigateTo, "_blank");
+9 -5
View File
@@ -45,12 +45,16 @@ export default function useImportDocument(
}
for (const file of files) {
const doc = await documents.import(file, documentId, cId, {
publish: true,
});
try {
const doc = await documents.import(file, documentId, cId, {
publish: true,
});
if (redirect) {
history.push(documentPath(doc));
if (redirect) {
history.push(documentPath(doc));
}
} catch (err) {
toast.error(err.message);
}
}
} catch (err) {
+2 -1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { Primitive } from "utility-types";
import Storage from "@shared/utils/Storage";
import { isBrowser } from "@shared/utils/browser";
import Logger from "~/utils/Logger";
import useEventListener from "./useEventListener";
@@ -41,7 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
options?: Options
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = React.useState(() => {
if (typeof window === "undefined") {
if (!isBrowser) {
return defaultValue;
}
return Storage.get(key) ?? defaultValue;
+9 -1
View File
@@ -1,5 +1,13 @@
import React from "react";
import { useLocation } from "react-router-dom";
export default function useQuery() {
return new URLSearchParams(useLocation().search);
const location = useLocation();
const query = React.useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
return query;
}
+95
View File
@@ -0,0 +1,95 @@
import { ColumnSort } from "@tanstack/react-table";
import orderBy from "lodash/orderBy";
import React from "react";
import {
FetchPageParams,
PaginatedResponse,
PAGINATION_SYMBOL,
} from "~/stores/base/Store";
import useRequest from "./useRequest";
const INITIAL_OFFSET = 0;
const PAGE_SIZE = 25;
type Props<T> = {
data: T[];
sort: ColumnSort;
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
reqParams: Omit<FetchPageParams, "offset" | "limit">;
};
type Response<T> = {
data: T[] | undefined;
error: unknown;
loading: boolean;
next: (() => void) | undefined;
};
export function useTableRequest<T extends { id: string }>({
data,
sort,
reqFn,
reqParams,
}: Props<T>): Response<T> {
const [total, setTotal] = React.useState<number>();
const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET });
const prevParamsRef = React.useRef(reqParams);
const sortRef = React.useRef<ColumnSort>(sort);
const fetchPage = React.useCallback(
() => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }),
[reqFn, reqParams, offset]
);
const { request, loading, error } = useRequest(fetchPage);
const nextPage = React.useCallback(
() =>
setOffset((prev) => ({
value: prev.value + PAGE_SIZE,
})),
[]
);
const sortedData = data
? orderBy(data, sortRef.current.id, sortRef.current.desc ? "desc" : "asc")
: undefined;
const next =
!loading && total && sortedData && sortedData.length < total
? nextPage
: undefined;
React.useEffect(() => {
if (prevParamsRef.current !== reqParams) {
prevParamsRef.current = reqParams;
setOffset({ value: INITIAL_OFFSET });
return;
}
let ignore = false;
const handleRequest = async () => {
const response = await request();
if (!response || ignore) {
return;
}
sortRef.current = sort; // Change sort once we receive a response from server - avoids flicker with stale data.
setTotal(response[PAGINATION_SYMBOL]?.total);
};
void handleRequest();
return () => {
ignore = true;
};
}, [sort, reqParams, offset, request]);
return {
data: sortedData,
error,
loading,
next,
};
}
+33
View File
@@ -0,0 +1,33 @@
import emojiRegex from "emoji-regex";
/**
* Hook to calculate text statistics
* @param text The string to calculate statistics for
* @param selectedText A substring of the text to calculate statistics for
* @returns An object containing total and selected statistics
*/
export function useTextStats(text: string, selectedText: string = "") {
const numTotalWords = countWords(text);
const regex = emojiRegex();
const matches = Array.from(text.matchAll(regex));
return {
total: {
words: numTotalWords,
characters: text.length,
emoji: matches.length ?? 0,
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
},
selected: {
words: countWords(selectedText),
characters: selectedText.length,
},
};
}
function countWords(text: string): number {
const t = text.trim();
// Hyphenated words are counted as two words
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
-1
View File
@@ -123,7 +123,6 @@ function CollectionMenu({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
} finally {
ev.target.value = "";
}
+47 -24
View File
@@ -4,44 +4,57 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Group from "~/models/Group";
import GroupDelete from "~/scenes/GroupDelete";
import GroupEdit from "~/scenes/GroupEdit";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
group: Group;
onMembers: () => void;
};
function GroupMenu({ group, onMembers }: Props) {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const menu = useMenuState({
modal: true,
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group);
const handleViewMembers = React.useCallback(() => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
fullscreen: true,
});
}, [t, group, dialogs]);
const handleEditGroup = React.useCallback(() => {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const handleDeleteGroup = React.useCallback(() => {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
return (
<>
<Modal
title={t("Edit group")}
onRequestClose={() => setEditModalOpen(false)}
isOpen={editModalOpen}
>
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
</Modal>
<Modal
title={t("Delete group")}
onRequestClose={() => setDeleteModalOpen(false)}
isOpen={deleteModalOpen}
>
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
</Modal>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Group options")}>
<Template
@@ -51,7 +64,7 @@ function GroupMenu({ group, onMembers }: Props) {
type: "button",
title: `${t("Members")}`,
icon: <GroupIcon />,
onClick: onMembers,
onClick: handleViewMembers,
visible: !!(group && can.read),
},
{
@@ -61,7 +74,7 @@ function GroupMenu({ group, onMembers }: Props) {
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: () => setEditModalOpen(true),
onClick: handleEditGroup,
visible: !!(group && can.update),
},
{
@@ -69,9 +82,19 @@ function GroupMenu({ group, onMembers }: Props) {
title: `${t("Delete")}`,
icon: <TrashIcon />,
dangerous: true,
onClick: () => setDeleteModalOpen(true),
onClick: handleDeleteGroup,
visible: !!(group && can.delete),
},
{
type: "separator",
},
{
type: "link",
href: "",
title: group.externalId,
disabled: true,
visible: !!group.externalId,
},
]}
/>
</ContextMenu>
+1 -2
View File
@@ -3,14 +3,13 @@ import { MoreIcon } from "outline-icons";
import React from "react";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
import { actionToMenuItem } from "~/actions";
import { toggleViewerInsights } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles";
import { MenuItem } from "~/types";
const InsightsMenu: React.FC = () => {
+1 -2
View File
@@ -3,7 +3,7 @@ import { MoreIcon } from "outline-icons";
import React from "react";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
@@ -12,7 +12,6 @@ import { navigateToNotificationSettings } from "~/actions/definitions/navigation
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { hover } from "~/styles";
import { MenuItem } from "~/types";
const NotificationMenu: React.FC = () => {
+1 -1
View File
@@ -3,12 +3,12 @@ import { DocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
+25 -1
View File
@@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
import { actionToMenuItem } from "~/actions";
import {
@@ -49,6 +50,22 @@ function UserMenu({ user }: Props) {
[dialogs, t, user]
);
const handleChangeEmail = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[dialogs, t, user]
);
const handleSuspend = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
@@ -117,7 +134,13 @@ function UserMenu({ user }: Props) {
type: "button",
title: `${t("Change name")}`,
onClick: handleChangeName,
visible: can.update && user.role !== "admin",
visible: can.update,
},
{
type: "button",
title: `${t("Change email")}`,
onClick: handleChangeEmail,
visible: can.update,
},
{
type: "button",
@@ -144,6 +167,7 @@ function UserMenu({ user }: Props) {
{
type: "button",
title: `${t("Suspend user")}`,
dangerous: true,
onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
+6 -1
View File
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
/** The user chosen name of the API key. */
/** The human-readable name of this API key */
@Field
@observable
name: string;
/** A list of scopes that this API key has access to. If empty, the key has full access. */
@Field
@observable
scope?: string[];
/** An optional datetime that the API key expires. */
@Field
@observable
+7 -1
View File
@@ -31,6 +31,7 @@ import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
type SaveOptions = JSONObject & {
publish?: boolean;
@@ -38,7 +39,7 @@ type SaveOptions = JSONObject & {
autosave?: boolean;
};
export default class Document extends ArchivableModel {
export default class Document extends ArchivableModel implements Searchable {
static modelName = "Document";
constructor(fields: Record<string, any>, store: DocumentsStore) {
@@ -85,6 +86,11 @@ export default class Document extends ArchivableModel {
fileName?: string;
};
@computed
get searchContent(): string {
return this.title;
}
/**
* The name of the original data source, if imported.
*/
+3
View File
@@ -10,6 +10,9 @@ class Group extends Model {
@observable
name: string;
@observable
externalId: string | undefined;
@observable
memberCount: number;
+3
View File
@@ -59,6 +59,9 @@ class Share extends Model {
@observable
allowIndexing: boolean;
@observable
views: number;
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
+7 -1
View File
@@ -18,8 +18,9 @@ import Group from "./Group";
import UserMembership from "./UserMembership";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import { Searchable } from "./interfaces/Searchable";
class User extends ParanoidModel {
class User extends ParanoidModel implements Searchable {
static modelName = "User";
@Field
@@ -62,6 +63,11 @@ class User extends ParanoidModel {
@observable
isSuspended: boolean;
@computed
get searchContent(): string[] {
return [this.name, this.email].filter(Boolean);
}
@computed
get initial(): string {
return (this.name ? this.name[0] : "?").toUpperCase();
+7
View File
@@ -0,0 +1,7 @@
/**
* An interface for objects that can be searched.
*/
export interface Searchable {
/** The content to be used for search */
get searchContent(): string | string[];
}
+22 -6
View File
@@ -22,6 +22,7 @@ type Props = {
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
setName(event.target.value);
}, []);
const handleScopeChange = React.useCallback((event) => {
setScope(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
scope: scope ? scope.split(" ") : undefined,
});
toast.success(
t(
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
[t, name, scope, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
placeholder={t("Development")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
autoFocus
flex
/>
<Input
type="text"
label={t("Scopes")}
placeholder="documents.info"
onChange={handleScopeChange}
value={scope}
flex
/>
<Text type="secondary" size="small" as="p">
{t(
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
)}
.
</Text>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
+1 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -22,7 +23,6 @@ import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
+34 -25
View File
@@ -25,6 +25,7 @@ import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
@@ -109,6 +110,16 @@ function SharedDocumentScene(props: Props) {
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
React.useEffect(() => {
if (shareId) {
client.setShareId(shareId);
}
return () => {
client.setShareId(undefined);
};
}, [shareId]);
React.useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
@@ -206,33 +217,31 @@ function SharedDocumentScene(props: Props) {
);
}
const SharedDocument = ({
shareId,
response,
}: {
shareId?: string;
response: Response;
}) => {
const { setDocument } = useDocumentContext();
const SharedDocument = observer(
({ shareId, response }: { shareId?: string; response: Response }) => {
const { hasHeadings, setDocument } = useDocumentContext();
if (!response.document) {
return null;
if (!response.document) {
return null;
}
const tocPosition = hasHeadings
? response.team?.tocPosition ?? TOCPosition.Left
: false;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
};
);
const Content = styled(Text)`
color: ${s("textSecondary")};
@@ -19,19 +19,23 @@ const CommentSortMenu = () => {
const user = useCurrentUser();
const params = useQuery();
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
if (type !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
}
};
const showResolved = () => {
@@ -6,8 +6,9 @@ import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -20,7 +21,6 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { sidebarAppearDuration } from "~/styles/animations";
import CommentForm from "./CommentForm";
import CommentThreadItem from "./CommentThreadItem";
@@ -54,7 +54,7 @@ function CommentThread({
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
@@ -74,10 +74,10 @@ function CommentThread({
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
?.getComments()
.filter((comment) => comment.id === thread.id);
const highlightedText = highlightedCommentMarks?.map((c) => c.text).join("");
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
editor?.getComments() ?? [],
thread.id
);
const commentsInThread = comments
.inThread(thread.id)
@@ -165,7 +165,7 @@ function CommentThread({
React.useEffect(() => {
if (focused) {
if (focusedOnMount) {
if (scrollOnMount) {
setTimeout(() => {
if (!topRef.current) {
return;
@@ -209,7 +209,7 @@ function CommentThread({
isMarkVisible ? 0 : sidebarAppearDuration
);
}
}, [focused, focusedOnMount, thread.id]);
}, [focused, scrollOnMount, thread.id]);
return (
<Thread
@@ -9,7 +9,7 @@ import { toast } from "sonner";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { s, hover } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import { dateToRelative } from "@shared/utils/date";
import { Minute } from "@shared/utils/time";
@@ -28,7 +28,6 @@ import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import { hover } from "~/styles";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
@@ -244,7 +243,7 @@ function CommentThreadItem({
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
$rounded
/>
) : undefined
}
@@ -265,7 +264,7 @@ function CommentThreadItem({
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
$rounded
/>
</>
)}
@@ -303,7 +302,7 @@ const ResolveButton = ({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
$rounded
>
<DoneIcon size={22} outline />
</Action>
@@ -341,10 +340,10 @@ const Body = styled.form`
border-radius: 2px;
`;
const Action = styled.span<{ rounded?: boolean }>`
const Action = styled.span<{ $rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
props.$rounded &&
css`
border-radius: 50%;
`}
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
NotFoundError,
@@ -46,7 +47,10 @@ type Children = (options: {
revision: Revision | undefined;
abilities: Record<string, boolean>;
readOnly: boolean;
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
onCreateLink: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
@@ -143,7 +147,7 @@ function DataLoader({ match, children }: Props) {
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
async (params: Properties<Document>, nested?: boolean) => {
if (!document) {
throw new Error("Document not loaded yet");
}
@@ -152,8 +156,8 @@ function DataLoader({ match, children }: Props) {
{
collectionId: nested ? undefined : document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: document.isDraft ? undefined : true,
+14 -11
View File
@@ -45,7 +45,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { emojiToUrl } from "~/utils/emoji";
@@ -89,9 +89,11 @@ type Props = WithTranslation &
revision?: Revision;
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
tocPosition?: TOCPosition | false;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
};
@observer
@@ -436,13 +438,15 @@ class DocumentScene extends React.Component<Props> {
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const showContents =
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -571,7 +575,6 @@ class DocumentScene extends React.Component<Props> {
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeIcon={this.handleChangeIcon}
@@ -621,7 +624,7 @@ class DocumentScene extends React.Component<Props> {
type MainProps = {
fullWidth: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const Main = styled.div<MainProps>`
@@ -649,7 +652,7 @@ const Main = styled.div<MainProps>`
type ContentsContainerProps = {
docFullWidth: boolean;
position: TOCPosition;
position: TOCPosition | false;
};
const ContentsContainer = styled.div<ContentsContainerProps>`
@@ -667,7 +670,7 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
type EditorContainerProps = {
docFullWidth: boolean;
showContents: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const EditorContainer = styled.div<EditorContainerProps>`
@@ -6,6 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { extraArea, s } from "@shared/styles";
@@ -19,7 +20,6 @@ import { isModKey } from "@shared/utils/keyboard";
import { DocumentValidation } from "@shared/validations";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";

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