Compare commits

..

132 Commits

Author SHA1 Message Date
Tom Moor d1bad8dbf4 perf: Avoid unneccessary mapping 2025-01-21 23:02:20 -05:00
Tom Moor 58a70ab62e refactor 2025-01-21 22:49:18 -05:00
Tom Moor aff26aa809 Add multiplayer support 2025-01-21 22:31:20 -05:00
Tom Moor e1cc6395f5 fix: Menu is modified before it closes 2025-01-20 21:35:32 -05:00
Tom Moor 38186d64e5 Merge main 2025-01-20 21:06:36 -05: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 28d5d0da5d Add supported embed detection 2025-01-16 23:18:03 -05: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
Ali 5eb95f7bd9 fix: for comment 2025-01-14 11:50:48 +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
Ali c3ba07cee4 feat: add paste menu 2025-01-13 11:45:32 +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
Tom Moor 2db7690e27 feat: Triple clicking in code mark should select entire mark, closes #8072 2024-12-07 12:58:15 -05:00
Tom Moor 06b89635be Improved tooltip context – separate for header,sidebar,editor. 2024-12-07 12:45:29 -05:00
Tom Moor 1ff23756ac fix: Make FindAndReplace popover dynamic, fixes button overflow.
closes #8079
2024-12-06 19:55:45 -05:00
Tom Moor a00b677076 fix: Use sidebarContext in header breadcrumbs (#8077) 2024-12-06 08:00:50 -05:00
Tom Moor 6c1e4a5b40 Add shortcuts to formatting menu tooltips (#8080)
* Add shortcuts to formatting menu tooltips

* Tooltip styling

* tsc
2024-12-05 20:50:16 -08:00
Tom Moor 59078704c8 fix: Embed toggle is unresponsive (#8078)
* fix: Embed toggle is unresponsive

* fix: View recorded when toggling embeds
2024-12-05 20:01:16 -08:00
Hemachandar f1a20b27fd fix: auto-scroll sidebar to show active document (#7956) 2024-12-05 17:23:13 -08:00
Tom Moor 313b046e4e fix: Use singleton for tooltips, ensures that only one is visible at a time. (#8069)
* fix: Use singleton for tooltips, ensures that only one is visible at a time and animations are shared

* fix: give toolbar menu its own context

* Remove duplicate props
2024-12-05 16:10:12 -08:00
357 changed files with 8717 additions and 7086 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
+10 -2
View File
@@ -53,9 +53,13 @@ export const resolveCommentFactory = ({
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: null,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
@@ -81,9 +85,13 @@ export const unresolveCommentFactory = ({
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: null,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
+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 = {
+13 -10
View File
@@ -3,20 +3,16 @@ 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";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
@@ -64,6 +60,7 @@ function DocumentBreadcrumb(
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -80,7 +77,10 @@ function DocumentBreadcrumb(
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
to: {
pathname: collection.path,
state: { sidebarContext },
},
};
} else if (document.isCollectionDeleted) {
collectionNode = {
@@ -114,11 +114,14 @@ function DocumentBreadcrumb(
) : (
node.title
),
to: node.url,
to: {
pathname: node.url,
state: { sidebarContext },
},
});
});
return output;
}, [path, category, collectionNode]);
}, [path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
+2 -3
View File
@@ -7,18 +7,17 @@ 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 CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
+2 -2
View File
@@ -14,18 +14,18 @@ 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";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
+12 -3
View File
@@ -9,21 +9,22 @@ 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";
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";
type Props = {
document: Document;
@@ -50,6 +51,7 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
@@ -78,6 +80,12 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
@@ -89,6 +97,7 @@ function DocumentListItem(
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
+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 || ""}
+7 -3
View File
@@ -13,15 +13,15 @@ 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";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
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";
@@ -35,6 +35,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const opts = {
userName: event.actor.name,
};
@@ -66,7 +67,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: { retainScrollPosition: true },
state: {
sidebarContext,
retainScrollPosition: true,
},
};
break;
+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);
+34 -31
View File
@@ -17,6 +17,7 @@ import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { TooltipProvider } from "./TooltipContext";
export const HEADER_HEIGHT = 64;
@@ -71,38 +72,40 @@ function Header(
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
<TooltipProvider>
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
</TooltipProvider>
);
}
@@ -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 -1
View File
@@ -4,9 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
+1 -1
View File
@@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
@@ -13,7 +14,6 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
children?: React.ReactNode;
+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";
+15 -1
View File
@@ -10,13 +10,23 @@ import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
children: React.ReactNode;
/** The width of the popover, defaults to 380px. */
width?: number;
/** The minimum width of the popover, use instead of width if contents adjusts size. */
minWidth?: number;
/** Shrink the padding of the popover */
shrink?: boolean;
/** Make the popover flex */
flex?: boolean;
/** The tab index of the popover */
tabIndex?: number;
/** Whether the popover should be scrollable, defaults to true. */
scrollable?: boolean;
/** The position of the popover on mobile, defaults to "top". */
mobilePosition?: "top" | "bottom";
/** Function to show the popover */
show: () => void;
/** Function to hide the popover */
hide: () => void;
};
@@ -25,6 +35,7 @@ const Popover = (
children,
shrink,
width = 380,
minWidth,
scrollable = true,
flex,
mobilePosition,
@@ -71,6 +82,7 @@ const Popover = (
ref={ref}
$shrink={shrink}
$width={width}
$minWidth={minWidth}
$scrollable={scrollable}
$flex={flex}
>
@@ -83,6 +95,7 @@ const Popover = (
type ContentsProps = {
$shrink?: boolean;
$width?: number;
$minWidth?: number;
$flex?: boolean;
$scrollable: boolean;
$mobilePosition?: "top" | "bottom";
@@ -101,7 +114,8 @@ const Contents = styled.div<ContentsProps>`
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 75vh;
box-shadow: ${s("menuShadow")};
width: ${(props) => props.$width}px;
${(props) => props.$width && `width: ${props.$width}px`};
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
${(props) =>
props.$scrollable
+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`
+1 -1
View File
@@ -5,6 +5,7 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
@@ -14,7 +15,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
+1 -1
View File
@@ -5,12 +5,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { metaDisplay } from "~/utils/keyboard";
import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
+3 -2
View File
@@ -3,16 +3,16 @@ 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";
import Scrollable from "~/components/Scrollable";
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 { metaDisplay } from "~/utils/keyboard";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
@@ -67,6 +67,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
depth={0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
+4 -2
View File
@@ -17,6 +17,7 @@ import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
@@ -194,8 +195,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
);
return (
<>
<TooltipProvider>
<Container
id="sidebar"
ref={ref}
style={style}
$hidden={hidden}
@@ -242,7 +244,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</>
</TooltipProvider>
);
});
@@ -16,6 +16,7 @@ import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
@@ -49,38 +50,40 @@ function Collections() {
});
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
<SidebarContext.Provider value="collections">
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
</SidebarContext.Provider>
);
}
@@ -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";
@@ -7,8 +7,8 @@ import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
prefetchDocument,
belowCollection,
}: Props) {
const locationSidebarContext = useLocationState();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = useSidebarContext();
const { ui, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import Group from "~/models/Group";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -14,13 +15,23 @@ type Props = {
};
const GroupLink: React.FC<Props> = ({ group }) => {
const [expanded, setExpanded] = React.useState(false);
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
setExpanded(true);
}
}, [sidebarContext, locationSidebarContext, setExpanded]);
return (
<Relative>
<SidebarLink
@@ -30,7 +41,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
onClick={handleDisclosureClick}
depth={0}
/>
<SidebarContext.Provider value={group.id}>
<SidebarContext.Provider value={sidebarContext}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
@@ -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}
@@ -9,6 +9,7 @@ import GroupMembership from "~/models/GroupMembership";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
@@ -16,7 +17,6 @@ import {
useDropToReorderUserMembership,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId } = membership;
const isActiveDocument = documentId === ui.activeDocumentId;
const locationSidebarContext = useLocationState();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = useSidebarContext();
const document = documentId ? documents.get(documentId) : undefined;
@@ -1,9 +1,57 @@
import * as React from "react";
import Document from "~/models/Document";
import User from "~/models/User";
export type SidebarContextType = "collections" | "starred" | string | undefined;
export type SidebarContextType =
| "collections"
| "shared"
| `group-${string}`
| `starred-${string}`
| undefined;
const SidebarContext = React.createContext<SidebarContextType>(undefined);
export const useSidebarContext = () => React.useContext(SidebarContext);
export const groupSidebarContext = (groupId: string): SidebarContextType =>
`group-${groupId}`;
export const starredSidebarContext = (modelId: string): SidebarContextType =>
`starred-${modelId}`;
export const determineSidebarContext = ({
document,
user,
currentContext,
}: {
document: Document;
user: User;
currentContext?: SidebarContextType;
}): SidebarContextType => {
const isStarred = document.isStarred || !!document.collection?.isStarred;
const preferStarred = !currentContext || currentContext.startsWith("starred");
if (isStarred && preferStarred) {
const currentlyInStarredCollection =
currentContext === starredSidebarContext(document.collectionId ?? "");
return document.isStarred && !currentlyInStarredCollection
? starredSidebarContext(document.id)
: starredSidebarContext(document.collectionId!);
}
if (document.collection) {
return "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
return "shared";
} else {
const group = user.groupsWithDocumentMemberships.find(
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
);
return groupSidebarContext(group?.id ?? "");
}
};
export default SidebarContext;
+40 -43
View File
@@ -15,7 +15,6 @@ import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
@@ -42,48 +41,46 @@ function Starred() {
}
return (
<SidebarContext.Provider value="starred">
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
</SidebarContext.Provider>
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
);
}
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
@@ -15,7 +16,6 @@ import {
useDropToCreateStar,
useDropToReorderStar,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
@@ -25,7 +25,7 @@ import Folder from "./Folder";
import Relative from "./Relative";
import SidebarContext, {
SidebarContextType,
useSidebarContext,
starredSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -39,10 +39,14 @@ function StarredLink({ star }: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId
);
const [expanded, setExpanded] = useState(
star.collectionId === ui.activeCollectionId &&
(star.documentId
? star.documentId === ui.activeDocumentId
: star.collectionId === ui.activeCollectionId) &&
sidebarContext === locationSidebarContext
);
@@ -159,7 +163,7 @@ function StarredLink({ star }: Props) {
}
/>
</Draggable>
<SidebarContext.Provider value={document.id}>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
@@ -183,7 +187,7 @@ function StarredLink({ star }: Props) {
if (collection) {
return (
<>
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
@@ -193,16 +197,14 @@ function StarredLink({ star }: Props) {
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<SidebarContext.Provider value={collection.id}>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
@@ -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);
+47 -8
View File
@@ -1,6 +1,8 @@
import Tippy, { TippyProps } from "@tippyjs/react";
import { transparentize } from "polished";
import * as React from "react";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -10,9 +12,24 @@ 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;
};
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
/**
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
* displays a keyboard shortcut next to the content.
*
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
* singleton instance (delay, animation, etc).
*/
function Tooltip({
shortcut,
shortcutOnNewline,
content: tooltip,
delay = 500,
...rest
}: Props) {
const isMobile = useMobile();
const singleton = useTooltipContext();
@@ -25,28 +42,50 @@ function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
if (shortcut) {
content = (
<>
{tooltip} &middot; <Shortcut>{shortcut}</Shortcut>
{tooltip}
{shortcutOnNewline ? <br /> : " "}
{typeof shortcut === "string" ? (
shortcut
.split("+")
.map((key, i) => (
<Shortcut key={`${key}${i}`}>
{key.length === 1 ? key.toUpperCase() : key}
</Shortcut>
))
) : (
<Shortcut>{shortcut}</Shortcut>
)}
</>
);
}
return (
<Tippy content={content} delay={delay} singleton={singleton} {...rest} />
<Tippy
arrow={roundArrow}
content={content}
delay={delay}
animation="shift-away"
singleton={singleton}
duration={[200, 150]}
inertia
{...rest}
/>
);
}
const Shortcut = styled.kbd`
position: relative;
top: -2px;
top: -1px;
margin-left: 2px;
display: inline-block;
padding: 2px 4px;
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
font-size: 12px;
font-family: ${s("fontFamilyMono")};
line-height: 10px;
color: ${s("tooltipBackground")};
color: ${s("tooltipText")};
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
vertical-align: middle;
background-color: ${s("tooltipText")};
border-radius: 3px;
`;
+4
View File
@@ -11,9 +11,13 @@ export function useTooltipContext() {
type Props = {
children: React.ReactNode;
/** Props to pass to the Tippy component */
tippyProps?: TippyProps;
};
/**
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
*/
export function TooltipProvider({ children, tippyProps }: Props) {
const [source, target] = useSingleton();
+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
/>
+3 -2
View File
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import { usePopoverState } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
@@ -21,7 +22,6 @@ import Tooltip from "~/components/Tooltip";
import useKeyDown from "~/hooks/useKeyDown";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
import { useEditor } from "./EditorContext";
type Props = {
@@ -314,7 +314,8 @@ export default function FindAndReplace({
style={style}
aria-label={t("Find and replace")}
scrollable={false}
width={420}
minWidth={420}
width={0}
>
<Content column>
<Flex gap={4}>
+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>
);
}
+107 -44
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;
@@ -40,17 +42,24 @@ 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,
@@ -122,25 +198,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
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;
}
}
+67
View File
@@ -0,0 +1,67 @@
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"
> & {
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: "link",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
<SuggestionsMenu
{...props}
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}
/>
) : (
+54 -19
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,
@@ -250,6 +253,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 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
insertNode(item);
}
},
[insertNode]
[editorProps, props, insertNode]
);
const close = React.useCallback(() => {
@@ -414,6 +427,10 @@ 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 &&
@@ -445,16 +462,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 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
return (
<Portal>
@@ -614,18 +638,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};
+2 -1
View File
@@ -108,8 +108,9 @@ function ToolbarMenu(props: Props) {
return (
<Tooltip
content={item.label === item.tooltip ? undefined : item.tooltip}
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
+2 -7
View File
@@ -1,12 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
type Props = {
/** The content to display in the tooltip. */
content?: string;
children?: React.ReactNode;
};
import Tooltip, { Props } from "~/components/Tooltip";
const WrappedTooltip: React.FC<Props> = ({
children,
@@ -18,6 +12,7 @@ const WrappedTooltip: React.FC<Props> = ({
delay={150}
content={content}
placement="top"
shortcutOnNewline
{...rest}
>
<TooltipContent>{children}</TooltipContent>
+4 -3
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,
};
}
@@ -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({
+8 -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,
};
}
+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;
+4 -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,
};
}
@@ -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,171 @@ 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 "link": {
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}
trigger=""
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>
+1 -1
View File
@@ -26,8 +26,8 @@ import * as React from "react";
import styled from "styled-components";
import Image from "@shared/editor/components/Img";
import { MenuItem } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "~/utils/keyboard";
const Img = styled(Image)`
border-radius: 2px;
+25 -1
View File
@@ -28,6 +28,7 @@ import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary";
@@ -63,6 +64,7 @@ export default function formattingMenuItems(
{
name: "strong",
tooltip: dictionary.strong,
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCode && (!isMobile || !isEmpty),
@@ -70,6 +72,7 @@ export default function formattingMenuItems(
{
name: "em",
tooltip: dictionary.em,
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCode && (!isMobile || !isEmpty),
@@ -77,12 +80,14 @@ export default function formattingMenuItems(
{
name: "strikethrough",
tooltip: dictionary.strikethrough,
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
shortcut: `${metaDisplay}+Ctrl+H`,
icon: highlight ? (
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
) : (
@@ -114,6 +119,7 @@ export default function formattingMenuItems(
{
name: "code_inline",
tooltip: dictionary.codeInline,
shortcut: `${metaDisplay}+E`,
icon: <CodeIcon />,
active: isMarkActive(schema.marks.code_inline),
visible: !isCodeBlock && (!isMobile || !isEmpty),
@@ -125,6 +131,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.heading,
shortcut: `⇧+Ctrl+1`,
icon: <Heading1Icon />,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
@@ -133,6 +140,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.subheading,
shortcut: `⇧+Ctrl+2`,
icon: <Heading2Icon />,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
@@ -141,6 +149,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.subheading,
shortcut: `⇧+Ctrl+3`,
icon: <Heading3Icon />,
active: isNodeActive(schema.nodes.heading, { level: 3 }),
attrs: { level: 3 },
@@ -149,6 +158,7 @@ export default function formattingMenuItems(
{
name: "blockquote",
tooltip: dictionary.quote,
shortcut: `${metaDisplay}+]`,
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
@@ -161,6 +171,7 @@ export default function formattingMenuItems(
{
name: "checkbox_list",
tooltip: dictionary.checkboxList,
shortcut: `⇧+Ctrl+7`,
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
@@ -169,6 +180,7 @@ export default function formattingMenuItems(
{
name: "bullet_list",
tooltip: dictionary.bulletList,
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
@@ -176,6 +188,7 @@ export default function formattingMenuItems(
{
name: "ordered_list",
tooltip: dictionary.orderedList,
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
@@ -183,6 +196,7 @@ export default function formattingMenuItems(
{
name: "outdentList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
@@ -190,6 +204,7 @@ export default function formattingMenuItems(
{
name: "indentList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
@@ -197,12 +212,14 @@ export default function formattingMenuItems(
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
@@ -213,6 +230,7 @@ export default function formattingMenuItems(
{
name: "link",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
visible: !isCodeBlock && (!isMobile || !isEmpty),
@@ -220,9 +238,14 @@ export default function formattingMenuItems(
{
name: "comment",
tooltip: dictionary.comment,
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,
},
{
@@ -233,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"),
+14 -5
View File
@@ -1,9 +1,10 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { isInternalUrl } from "@shared/utils/urls";
import { isModKey } from "~/utils/keyboard";
import { isModKey } from "@shared/utils/keyboard";
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,14 +51,21 @@ 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) {
history.push(navigateTo);
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) {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { isModKey } from "@shared/utils/keyboard";
import isTextInput from "~/utils/isTextInput";
import { isModKey } from "~/utils/keyboard";
type Callback = (event: KeyboardEvent) => void;
@@ -1,10 +1,10 @@
import { useLocation } from "react-router-dom";
import { SidebarContextType } from "../components/SidebarContext";
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
/**
* Hook to retrieve the sidebar context from the current location state.
*/
export function useLocationState() {
export function useLocationSidebarContext() {
const location = useLocation<{
sidebarContext?: SidebarContextType;
}>();
+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,
};
}
+15 -18
View File
@@ -20,7 +20,6 @@ import { initI18n } from "~/utils/i18n";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
import PageScroll from "./components/PageScroll";
import { TooltipProvider } from "./components/TooltipContext";
import Routes from "./routes";
import Logger from "./utils/Logger";
import { PluginManager } from "./utils/PluginManager";
@@ -56,23 +55,21 @@ if (element) {
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<TooltipProvider>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</Router>
</LazyMotion>
</LazyPolyfill>
</TooltipProvider>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</Router>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Theme>
-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 = "";
}
+3 -3
View File
@@ -136,14 +136,14 @@ type MenuContentProps = {
showToggleEmbeds?: boolean;
};
const MenuContent: React.FC<MenuContentProps> = ({
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onRename,
showDisplayOptions,
showToggleEmbeds,
}) => {
}) {
const user = useCurrentUser();
const { model: document, menuState } = useMenuContext<Document>();
const can = usePolicy(document);
@@ -348,7 +348,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
)}
</ContextMenu>
) : null;
};
});
function DocumentMenu({
document,
+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 = () => {

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