193 Commits

Author SHA1 Message Date
261660cd9c chore: max-old-space-size=8192 2024-07-09 04:52:14 +08:00
2ad566cb4f chore: github build cache 2024-07-09 04:52:14 +08:00
ad3fef5658 chore: build docker image by github actions 2024-07-09 04:52:14 +08:00
eaea7b043e feat: right-click to open menu for document/collection links 2024-07-09 04:52:13 +08:00
Tom Moor
43cf33fc0a fix: Light icons are not responsive to dark theme 2024-07-07 21:06:04 -04:00
Tom Moor
07b6441655 fix: Retain space for hidden TOC to prevent horizontal movement 2024-07-07 20:21:51 -04:00
Tom Moor
49198aafe9 Hide document TOC when empty 2024-07-07 20:15:26 -04:00
Tom Moor
ddd103542a fix: Focusing shared document title on mobile causes it to move 2024-07-07 19:56:31 -04:00
Tom Moor
4654dfb658 test: Fix incorrect userProvisioner test 2024-07-07 11:00:10 -04:00
Tom Moor
bdcde1aa53 centralize email parsing logic 2024-07-07 10:54:19 -04:00
Tom Moor
c484d1defe fix: Error when accessing search from share logged in, 0067d1a58d 2024-07-06 11:49:51 -04:00
Tom Moor
1efd3b6f96 fix: Do not prompt for app install on public shares, closes #7198 2024-07-06 11:10:58 -04:00
Tom Moor
e07be1ee5e chore: Remove long deprecated filter options 2024-07-06 10:53:00 -04:00
Tom Moor
0067d1a58d fix: Document data from documents.search with shareId does not include signed asset urls
closes #7196
2024-07-06 10:51:27 -04:00
Tom Moor
17451c180a fix: Header search input is on the wrong side on Drafts page 2024-07-03 21:14:56 -04:00
Tom Moor
4e989e5c44 fix: Resizing sidebars can also select text 2024-07-03 20:37:56 -04:00
Tom Moor
335957d914 fix: Alignment of keyboard help button 2024-07-03 20:31:51 -04:00
Tom Moor
1711d17e25 fix: newMentionIds no longer always in event data 2024-07-03 17:58:15 -04:00
Apoorv Mishra
de90f879f1 Prevent modal top from shrinking (#7167)
* fix: prevent modal top from shrinking

* fix: scroll and max height for share modal and popover

* fix: review
2024-07-03 09:11:00 +05:30
Tom Moor
303125b682 chore: Upgrade nodemon 2024-07-02 21:50:32 -04:00
Tom Moor
f33026f7b3 chore: Upgrade socket.io deps 2024-07-02 21:47:01 -04:00
Tom Moor
06b5efd18a chore: Update babel, aws-sdk deps 2024-07-02 21:38:21 -04:00
Tom Moor
c8e67b969d fix: Gap between icon picker and title 2024-07-02 20:28:37 -04:00
Tom Moor
b84851a4c3 tsc 2024-07-02 19:41:48 -04:00
Tom Moor
c6408f7b3f fix: CJK content results in long context strings in search results
closes #7183
2024-07-02 19:34:49 -04:00
Tom Moor
18f729b970 Make selected icon in color picker 2px weight to match others 2024-07-02 18:31:41 -04:00
Tom Moor
2f9a7f9a21 Remove random color on document icons, closes #7181 2024-07-02 18:30:34 -04:00
Tom Moor
c5b94e50df fix: RangeError when resolving or removing comment across marks, closes #7182 2024-07-02 18:24:23 -04:00
Tom Moor
8a8dad15ef fix: newMentionIds no longer always in event data, closes #7186 2024-07-02 18:03:22 -04:00
dependabot[bot]
a8d4a5b587 chore(deps): bump koa from 2.15.0 to 2.15.3 (#7134)
Bumps [koa](https://github.com/koajs/koa) from 2.15.0 to 2.15.3.
- [Changelog](https://github.com/koajs/koa/blob/2.15.3/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.15.0...2.15.3)

---
updated-dependencies:
- dependency-name: koa
  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-07-02 14:58:03 -07:00
Liam Robinson
a67c35257e fixed wrong discord callback url (#7184) 2024-07-02 13:35:55 -07:00
Tom Moor
f9dadf5548 fix: Comment resolution control visible to non-editors 2024-07-02 07:52:21 -04:00
Tom Moor
117c4f5009 feat: Comment resolving (#7115) 2024-07-02 03:55:16 -07:00
dependabot[bot]
f34557337d chore(deps): bump @octokit/auth-app from 6.0.4 to 6.1.1 (#7173)
Bumps [@octokit/auth-app](https://github.com/octokit/auth-app.js) from 6.0.4 to 6.1.1.
- [Release notes](https://github.com/octokit/auth-app.js/releases)
- [Commits](https://github.com/octokit/auth-app.js/compare/v6.0.4...v6.1.1)

---
updated-dependencies:
- dependency-name: "@octokit/auth-app"
  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-07-01 12:22:27 -07:00
dependabot[bot]
9b25e623b4 chore(deps-dev): bump @types/randomstring from 1.1.11 to 1.3.0 (#7172)
Bumps [@types/randomstring](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/randomstring) from 1.1.11 to 1.3.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/randomstring)

---
updated-dependencies:
- dependency-name: "@types/randomstring"
  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-07-01 12:21:28 -07:00
dependabot[bot]
fe41824ef2 chore(deps): bump sequelize-cli from 6.6.1 to 6.6.2 (#7174)
Bumps [sequelize-cli](https://github.com/sequelize/cli) from 6.6.1 to 6.6.2.
- [Release notes](https://github.com/sequelize/cli/releases)
- [Changelog](https://github.com/sequelize/cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sequelize/cli/compare/v6.6.1...v6.6.2)

---
updated-dependencies:
- dependency-name: sequelize-cli
  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-07-01 12:21:19 -07:00
dependabot[bot]
e9755faf9a chore(deps): bump @sentry/react from 7.99.0 to 7.118.0 (#7175)
Bumps [@sentry/react](https://github.com/getsentry/sentry-javascript) from 7.99.0 to 7.118.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.118.0/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.99.0...7.118.0)

---
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>
2024-07-01 12:21:07 -07:00
Tom Moor
9dcb04b58a fix: Empty space where logo should be on shared docs with no branding
fix: Not using emoji fallback on shared links
2024-07-01 11:04:00 -04:00
Tom Moor
d8e571d82d fix: Flickering of resize cursor on sidebar when dragging 2024-07-01 10:49:00 -04:00
Tom Moor
3f4027c6fa fix: Event listener memory leak 2024-07-01 10:42:15 -04:00
Apoorv Mishra
e507f09ff9 fix: collection permission check (#7171) 2024-07-01 19:40:35 +05:30
Apoorv Mishra
63ddc31710 Disallow empty comments and comments with only whitespaces (#7156)
* fix: disallow empty comments

* fix: avoid full traversal to validate comment

* fix: text

* fix:review

* fix: review
2024-06-30 11:38:43 +05:30
Tom Moor
5aa5ba0aa1 fix: Possible fix for #7161 2024-06-29 14:47:26 -07:00
Tom Moor
ed496bdf60 fix: Flash of mispositioned document loading placeholder 2024-06-29 10:12:06 -07:00
Tom Moor
7201bdb9d8 fix: Flickering suggestion menu selection in Safari 2024-06-28 13:56:37 -07:00
Tom Moor
53e3245b15 fix: Fallback to emoji attribute in document structure 2024-06-26 11:44:17 -04:00
Tom Moor
2c666ddde2 fix: Duplicated documents use text rather than content 2024-06-26 09:20:16 -04:00
Tom Moor
6bb2953e8d 0.78.0-0 2024-06-25 22:21:32 -04:00
Tom Moor
6a1a3eee91 fix: Crash rendering some document hover previews 2024-06-25 22:20:33 -04:00
Tom Moor
d03c7b33d3 Cast values in error response, related outline/openapi#5 2024-06-25 21:33:51 -04:00
Translate-O-Tron
355bc33f7c New Crowdin updates (#7128) 2024-06-25 18:19:03 -07:00
Tom Moor
bf2378ec81 fix: Iframely is not correctly disabled with no API_KEY in env
closes #7147
2024-06-25 21:10:21 -04:00
Tom Moor
5c999f5327 Add Swedish translations (#7146) 2024-06-25 05:28:42 -07:00
Tom Moor
29a653aaeb fix: Admins cannot query permissions on private collections (#7145)
* fix: Admins have permission to see existence of all collections (in settings)

* fix: Current user filtered from suggestions. As an admin managing other collections this is limiting

* test
2024-06-25 05:28:32 -07:00
Jack Woodgate
beabd32e6a fix: Improve SmartText fraction regex pattern (#7141)
Modify fraction regex statements to not match if directly preceded by a character
2024-06-25 05:11:13 -07:00
Tom Moor
77d6797d85 fix: Error choosing 'No access' as bulk import permission 2024-06-24 21:24:43 -04:00
Tom Moor
fbd8f5981b fix: Consistently default letter icon content to ? 2024-06-24 21:20:45 -04:00
Tom Moor
8336207c23 fix: Document title steals focus on mount 2024-06-24 21:20:02 -04:00
Tom Moor
3054f34a90 fix: 'Search in collection' appearing in collection menu user does not have access to read documents within 2024-06-24 18:13:01 -04:00
Tom Moor
07a805696d fix: Templatize spuriously appearing in collection menu 2024-06-24 18:08:30 -04:00
dependabot[bot]
142493ddcc chore(deps): bump vite from 5.2.11 to 5.3.1 (#7132)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.11 to 5.3.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  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-06-24 14:32:24 -07:00
dependabot[bot]
bd18b33b9d chore(deps): bump class-validator from 0.14.0 to 0.14.1 (#7131)
Bumps [class-validator](https://github.com/typestack/class-validator) from 0.14.0 to 0.14.1.
- [Release notes](https://github.com/typestack/class-validator/releases)
- [Changelog](https://github.com/typestack/class-validator/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/typestack/class-validator/compare/v0.14.0...v0.14.1)

---
updated-dependencies:
- dependency-name: class-validator
  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-06-24 14:32:05 -07:00
dependabot[bot]
03373804fa chore(deps-dev): bump eslint-plugin-react from 7.34.1 to 7.34.3 (#7133)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.34.1 to 7.34.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.34.1...v7.34.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  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-06-24 14:31:48 -07:00
dependabot[bot]
daba308440 chore(deps): bump nodemailer and @types/nodemailer (#7135)
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.9 to 6.9.14
- [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.9...v6.9.14)

Updates `@types/nodemailer` from 6.4.14 to 6.4.15
- [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>
2024-06-24 14:31:36 -07:00
Tom Moor
1451f70b9e Changes plugin interface from 'roles' to 'enabled' method for more flexibility 2024-06-24 08:33:48 -04:00
Translate-O-Tron
6bb8a3d935 New Crowdin updates (#7079) 2024-06-23 14:26:19 -07:00
Tom Moor
95c768f444 Allow direct editing of collection icon (#7120)
* Allow direct editing of collection icon

* feedback
2024-06-23 14:26:00 -07:00
Tom Moor
2cad16d6b3 fix: Disable hover background on non-interactive share list items 2024-06-23 17:25:36 -04:00
Hemachandar
36a2a4709c fix: allow user to remove document icon (#7124) 2024-06-23 15:47:25 -04:00
Hemachandar
6fd3a0fa8a feat: Unified icon picker (#7038) 2024-06-23 06:31:18 -07:00
Tom Moor
56d90e6bc3 Add HEALTHCHECK into docker image (#7085)
* Add wget into docker image

* Add healthcheck
2024-06-22 07:05:55 -07:00
Tom Moor
eaab97dcbf fix: Unable to scroll until multiple comments (#7112)
* fix: Unable to scroll in comments
fix: Missing highlighted text on first comment while composing

* docs
2024-06-22 07:05:23 -07:00
Tom Moor
d8f14377f8 fix: Scrollwheel can cause image zoom to get stuck, closes #7083 2024-06-21 23:39:25 -04:00
Apoorv Mishra
0f1f0e82c2 Enable keyboard navigation in member invite list (#7102)
* feat: keyboard nav in share popover

* fix: memoize
2024-06-21 17:35:05 -07:00
Tom Moor
5ddc36555d fix: Remove breaking requirement to not pass collectionId with parentDocumentId.
This was regressed as part of 95b9453269 and unfortunately is a breaking change for API consumers
2024-06-21 20:34:31 -04:00
Tom Moor
f17ce9d50b fix: collectionId and parentDocumentId now mutually exclusive in payload
closes #7110
2024-06-21 19:38:05 -04:00
Tom Moor
9e5d5c0347 fix: Incorrect policies returned from documents.update (#7111)
* fix: Incorrect policies returned from documents.update

* Regression test
2024-06-21 16:37:39 -07:00
Hemachandar
4897f001e4 Add icon column to document (#7066)
* Add icon column to document

* Backfill columns

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-21 06:26:20 -07:00
Hemachandar
b6d178943a fix: Include drafts when loading templates (#7100)
* fix: Include drafts when loading templates

* fetch drafts when mounting templates settings
2024-06-21 04:36:22 -07:00
Hemachandar
1bf9012992 feat: Add lastUsedAt to API keys (#7082)
* feat: Add lastUsedAt to API keys

* rename column to lastActiveAt

* switch order
2024-06-20 06:18:35 -07:00
Tom Moor
a19fb25bea fix: Open permissions for guests that have collection manage permission (#7075)
* fix: Opens up permissions for guests that have collection manage permission

* tsc

* tests
2024-06-20 06:18:18 -07:00
Brian Krausz
95b9453269 feat: docs managers can action docs & create subdocs (#7077)
* feat: docs managers can action docs & create subdocs

* tests

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-19 19:22:33 -07:00
Hemachandar
2333602f25 fix: Allow user to remove team logo (#7095) 2024-06-19 17:11:54 -07:00
Tom Moor
a825925a31 fix: Layout issue with full-width documents and left TOC 2024-06-19 09:04:01 -04:00
Tom Moor
711b8acebc Add Norweigan translations (#7086) 2024-06-19 05:19:16 -07:00
Tom Moor
d7ee63217b fix: Improve translation strings for api key management 2024-06-18 22:32:04 -04:00
Tom Moor
6dae1c2a5c Tweak language on API key list 2024-06-18 21:51:32 -04:00
Hemachandar
3af9861c4a feat: add API key expiry options (#7064)
* feat: add API key expiry options

* review
2024-06-18 18:34:45 -07:00
Hemachandar
c04bedef4c fix: remove attachment signing for document export (#7081) 2024-06-18 18:34:05 -07:00
Translate-O-Tron
aad709eca4 New Crowdin updates (#7073) 2024-06-17 18:48:45 -07:00
dependabot[bot]
044f5256db chore(deps): bump @sentry/node from 7.99.0 to 7.117.0 (#7069)
Bumps [@sentry/node](https://github.com/getsentry/sentry-javascript) from 7.99.0 to 7.117.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.117.0/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.99.0...7.117.0)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  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-06-17 14:03:08 -07:00
dependabot[bot]
5a6bb85f65 chore(deps-dev): bump @types/readable-stream from 4.0.12 to 4.0.14 (#7067)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.12 to 4.0.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  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-06-17 14:02:56 -07:00
dependabot[bot]
ef45788a0b chore(deps): bump ws from 7.5.9 to 7.5.10 (#7068)
Bumps [ws](https://github.com/websockets/ws) from 7.5.9 to 7.5.10.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.9...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  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-06-17 14:02:46 -07:00
dependabot[bot]
4b4e593e83 chore(deps): bump umzug from 3.2.1 to 3.8.1 (#7071)
Bumps [umzug](https://github.com/sequelize/umzug) from 3.2.1 to 3.8.1.
- [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.2.1...v3.8.1)

---
updated-dependencies:
- dependency-name: umzug
  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-06-17 14:02:30 -07:00
Translate-O-Tron
15a9bd225f New Crowdin updates (#7019) 2024-06-17 04:25:14 -07:00
Michael Fowler
77579bb4f1 fix: Use the default credential strategy in S3Client construction (#7061)
By omitting this option, we fall back to the hierarchy used by S3Client by
default.  When defined, the provider chain will use the values of
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN); in their
absence, the provider chain can retrieve credentials from a range of other
sources, including e.g. ECS credentials.

Although there are no longer any application reads from `env.AWS_ACCESS_KEY_ID`
and `env.AWS_SECRET_ACCESS_KEY`, they continue to serve a useful documentary
role.
2024-06-17 03:15:15 -07:00
Tom Moor
92301791f6 fix: Styling of disabled accent buttons 2024-06-16 20:49:46 -04:00
Tom Moor
9b542c451b fix: Unreadable tertiary text on InputSelect 2024-06-16 20:41:51 -04:00
Hemachandar
3edaf4f8ea feat: TOC position for publicly shared docs (#7057)
* feat: TOC position for publicly shared docs

* remove preferences object

* comment

* fix: Allow sidebar position preference without public branding switch

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-16 17:26:29 -04:00
Tom Moor
1290aecbc9 Add after option to setting plugin (#7056) 2024-06-16 09:43:32 -07:00
Hemachandar
05c1bee412 feat: allow user to set TOC display preference (#6943)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-16 09:21:08 -07:00
Tom Moor
3d0160463c chore: Refactor client plugin management (#7053)
* Update clientside plugin management to work as server

* docs

* tsc

* Rebase main
2024-06-16 08:11:26 -07:00
Sebastian Pietschner
a9f1086422 Enhanced Discord Support (#7005)
* Add Discord Provider Prototype

* Add Discord Logo

* Add Plugin to Plugin Manager

* fixed discord auth support and added icon

* add csv role verification

* grab discord server icon and test server id and roles

* subdomain derived from server name

* use discord server specific nickname if available

* Cleanup and comment

* move discord api types to dev deps

* cleanup of server vs default params

* remove commented out lines

* revert envv.development

* revert in vscode

* update yarn lock

* add gif support for discord server icon

* add comment with docs link

* add env section for discord

* fix errors and clarify env

* add new cannot use without

* fix suggestions
2024-06-16 07:04:25 -07:00
Tom Moor
379d2cb788 fix: Attributes lost creating template on server (#7049) 2024-06-15 19:06:37 -07:00
Tom Moor
eb1882eb96 fix: Signed file urls not returning inline content disposition 2024-06-15 12:29:58 -04:00
Tom Moor
6318714aee fix: Escape does not close CMD+K when viewing a document or collection 2024-06-14 22:56:39 -04:00
Tom Moor
9415a35795 chore: Add eslint rule to prevent app imports in shared (see: bf130f9915) 2024-06-14 22:22:55 -04:00
Tom Moor
da9ea9f82c fix: Tweak top padding on TOC to always align with metadata 2024-06-14 21:30:52 -04:00
Tom Moor
e733fd27e4 0.77.1 2024-06-14 20:32:13 -04:00
Tom Moor
63cfa6e25a fix: Restore field in document webhooks for backwards compat (#7044)
closes #7042
2024-06-14 16:36:54 -07:00
Tom Moor
f8a9c18650 fix: Scroll does not reset when navigating shared docs on mobile (#7037)
closes #6968
2024-06-14 16:36:36 -07:00
Tom Moor
f35676f347 Switch from Alpine -> Debian-slim (#7040)
* Switch from Alpine -> Debian-slim

* Drop arm/v6
2024-06-14 12:51:38 -07:00
Tom Moor
bf130f9915 fix: EventBoundary import 2024-06-14 14:55:02 -04:00
Tom Moor
dfe36fcbf5 fix: Add a plugin to fix the last column in a table (#7036) 2024-06-14 05:53:32 -07:00
Tom Moor
e1c44ba1a8 fix: Exiting lightbox unfocuses image causing rerender part-way through transition, closes #7034 2024-06-13 20:57:50 -04:00
Tom Moor
e69c0e62fa Merge branch 'main' of github.com:outline/outline 2024-06-13 18:19:46 -04:00
Tom Moor
fd17364ebf 0.77.0 2024-06-13 15:31:33 -04:00
Apoorv Mishra
23c8adc5d1 Replace reakit/Composite with react-roving-tabindex (#6985)
* fix: replace reakit composite with react-roving-tabindex

* fix: touch points

* fix: focus stuck at first list item

* fix: document history navigation

* fix: remove ununsed ListItem components

* fix: keyboard navigation in recent search list

* fix: updated lib
2024-06-13 18:45:44 +05:30
Tom Moor
20b1766e8d Add link to guide in welcome email 2024-06-12 22:33:00 -04:00
Tom Moor
076d564aa3 fix: Sidebar hidden when viewing shared document logged-in 2024-06-12 22:30:39 -04:00
Tom Moor
5b866a7451 fix: isInternalUrl helper returns true for other cloud-hosted workspaces 2024-06-12 21:50:42 -04:00
Tom Moor
4ef3615516 fix: Reduce flashing of loaders in sidebar on first load 2024-06-12 21:30:10 -04:00
Brian Krausz
b907d1887a [docs] Remove dead link for Node Security Project (#7022) 2024-06-12 18:13:09 -07:00
Tom Moor
8a4555f565 Allow using / anywhere on a line or table (#7026) 2024-06-12 05:50:54 -07:00
Tom Moor
df3cd22aee Matomo integration (#7009) 2024-06-12 05:03:38 -07:00
dependabot[bot]
0bf66cc560 chore(deps): bump braces from 3.0.2 to 3.0.3 (#7018)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-11 20:09:10 -07:00
Tom Moor
b769da2626 fix: Case where email platform will spend the email signin link (#7023) 2024-06-11 20:08:25 -07:00
Tom Moor
7bf5c4e533 Add manage permission to documents (#7003) 2024-06-10 17:38:23 -07:00
dependabot[bot]
1ad7c7409a chore(deps): bump @aws-sdk/s3-request-presigner from 3.577.0 to 3.592.0 (#7015)
Bumps [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) from 3.577.0 to 3.592.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.592.0/packages/s3-request-presigner)

---
updated-dependencies:
- dependency-name: "@aws-sdk/s3-request-presigner"
  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-06-10 17:38:04 -07:00
Translate-O-Tron
428908b2df New Crowdin updates (#6915) 2024-06-10 17:37:23 -07:00
dependabot[bot]
1df1b0c110 chore(deps): bump turndown from 7.1.3 to 7.2.0 (#7013)
Bumps [turndown](https://github.com/mixmark-io/turndown) from 7.1.3 to 7.2.0.
- [Release notes](https://github.com/mixmark-io/turndown/releases)
- [Commits](https://github.com/mixmark-io/turndown/compare/v7.1.3...v7.2.0)

---
updated-dependencies:
- dependency-name: turndown
  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-06-10 17:37:03 -07:00
dependabot[bot]
9d95c673d1 chore(deps): bump prosemirror-model from 1.21.0 to 1.21.1 (#7014)
Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.21.0 to 1.21.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.21.0...1.21.1)

---
updated-dependencies:
- dependency-name: prosemirror-model
  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-06-10 17:35:06 -07:00
dependabot[bot]
203cd3c2a3 chore(deps-dev): bump terser from 5.19.2 to 5.31.1 (#7016)
Bumps [terser](https://github.com/terser/terser) from 5.19.2 to 5.31.1.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.19.2...v5.31.1)

---
updated-dependencies:
- dependency-name: terser
  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-06-10 17:34:48 -07:00
dependabot[bot]
f663c5a7ef chore(deps-dev): bump @types/node from 20.10.0 to 20.14.2 (#7017)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.10.0 to 20.14.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:34:34 -07:00
Tom Moor
be0a0f4e40 fix: Unexpected behavior when changing color of existing highlight 2024-06-09 21:19:37 -04:00
Tom Moor
f439293a7b fix: Image placeholders incorrectly sized for uploading retina images 2024-06-09 14:28:54 -04:00
Tom Moor
2e466aefc3 fix: Missing delay before mounting loading skeleton 2024-06-09 14:23:48 -04:00
Tom Moor
ed59b3e350 Add more highlighter color choices (#7012)
* Add more highlighter color choices, closes #7011

* docs
2024-06-09 10:54:31 -07:00
Tom Moor
808415b906 fix: Indent/outdent list controls, closes #6974 2024-06-08 21:51:52 -04:00
Tom Moor
2f495f0add chore: Extend use of Event.createFromContext (#7010) 2024-06-08 14:57:55 -07:00
Tom Moor
1bd37ad40b fix: Hide share button on templates 2024-06-08 13:59:57 -04:00
Tom Moor
562b03711f Add menu option to create template directly in collection 2024-06-08 13:42:59 -04:00
Tom Moor
30a5c8ea8b feat: Add ability to remove team logo/profile picture 2024-06-08 13:17:42 -04:00
Tom Moor
c02f7c9c85 Remove gist.github.com, gitlab.com from default CSP (#7008) 2024-06-08 07:54:55 -07:00
Tom Moor
946cbce06e fix: Share filtering does not pass query to server, closes #7006 2024-06-07 21:36:27 -04:00
Tom Moor
8762adacbb fix: Gap after input prefix in web share settings 2024-06-06 23:38:18 -04:00
Tom Moor
2002c20bd3 fix: Cannot remove groups from collection 2024-06-06 21:47:21 -04:00
Tom Moor
1b60d7c946 fix: Remove delay on save after changing document emoji, closes #6999 2024-06-05 22:55:19 -04:00
Tom Moor
20d71391bb fix: Unneccessary height animation on share popover when reopening 2024-06-05 09:26:22 -04:00
Tom Moor
7bdafff235 Add ApiKeyCleanupProcessor 2024-06-05 08:30:56 -04:00
Tom Moor
025ee63f0b Add MembersCanCreateApiKey team preference 2024-06-05 08:30:30 -04:00
Tom Moor
cf16d25a67 chore: Tidy API key settings page 2024-06-05 07:13:35 -04:00
Tom Moor
593f7a79b8 Remove ability to create additional API keys with an existing API key 2024-06-05 06:53:07 -04:00
Tom Moor
c9d5ff7ca5 fix: Remove trust of state.host in auth error redirect 2024-06-05 06:45:23 -04:00
Tom Moor
1d97a6c10b chore: Remove old collection permissions UI (#6995) 2024-06-05 03:33:39 -07:00
Tom Moor
7eb6dcf00b fix: Prevent email login token reuse 2024-06-04 23:38:00 -04:00
Tom Moor
70bc8f1a5a fix: Shift-Enter in code block in table should behave correctly
closes #6994
2024-06-04 22:20:37 -04:00
Tom Moor
7a32271992 fix: Allow delete table row and column with mod+backspace 2024-06-04 21:59:22 -04:00
Tom Moor
dd4c8c5546 fix: Add explicit error for missing auth token 2024-06-04 21:36:26 -04:00
dependabot[bot]
42f9971368 chore(deps): bump passport-oauth2 and @types/passport-oauth2 (#6988)
Bumps [passport-oauth2](https://github.com/jaredhanson/passport-oauth2) and [@types/passport-oauth2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/passport-oauth2). These dependencies needed to be updated together.

Updates `passport-oauth2` from 1.7.0 to 1.8.0
- [Changelog](https://github.com/jaredhanson/passport-oauth2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jaredhanson/passport-oauth2/compare/v1.7.0...v1.8.0)

Updates `@types/passport-oauth2` from 1.4.15 to 1.4.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/passport-oauth2)

---
updated-dependencies:
- dependency-name: passport-oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/passport-oauth2"
  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-06-04 06:49:09 -07:00
Tom Moor
e7602f8f1b fix: caption positioning 2024-06-03 21:42:50 -04:00
dependabot[bot]
20e275c0b9 chore(deps): bump octokit from 3.1.2 to 3.2.1 (#6989)
Bumps [octokit](https://github.com/octokit/octokit.js) from 3.1.2 to 3.2.1.
- [Release notes](https://github.com/octokit/octokit.js/releases)
- [Commits](https://github.com/octokit/octokit.js/compare/v3.1.2...v3.2.1)

---
updated-dependencies:
- dependency-name: octokit
  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-06-03 18:34:38 -07:00
Tom Moor
723c5c13f4 fix: Collapsed sidebar closes on far left hover in Arc/Edge redesign
closes #6982
2024-06-03 21:30:10 -04:00
Tom Moor
c6423c47b3 fix: try/catch in correct location for regex find and replace
closes #6983
2024-06-03 20:54:07 -04:00
Tom Moor
cfc7ae6d04 feat: Add support for Figam boards, add support for pasting Figma embed code
closes #6986
2024-06-03 20:40:20 -04:00
Tom Moor
23606dad1d Move image zooming back to unvendorized lib (#6980)
* Move image zooming back to unvendorized lib

* refactor

* perf: Avoid mounting zoom dialog until interacted

* Add captions to lightbox

* lightbox
2024-06-03 17:26:25 -07:00
dependabot[bot]
62ebba1c32 chore(deps): bump ioredis from 5.3.2 to 5.4.1 (#6987)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.3.2 to 5.4.1.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.3.2...v5.4.1)

---
updated-dependencies:
- dependency-name: ioredis
  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-06-03 17:26:14 -07:00
dependabot[bot]
884a51e98b chore(deps): bump tmp from 0.2.1 to 0.2.3 (#6991)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.1 to 0.2.3.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.1...v0.2.3)

---
updated-dependencies:
- dependency-name: tmp
  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-06-03 17:25:54 -07:00
dependabot[bot]
553057d4f9 chore(deps): bump @aws-sdk/s3-presigned-post from 3.577.0 to 3.588.0 (#6990)
Bumps [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) from 3.577.0 to 3.588.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.588.0/packages/s3-presigned-post)

---
updated-dependencies:
- dependency-name: "@aws-sdk/s3-presigned-post"
  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-06-03 17:25:43 -07:00
Tom Moor
5ad6a63db8 tsc 2024-06-02 20:39:16 -04:00
Tom Moor
899a15afaf fix: Shift+click on table grip does not select rows/columns in between 2024-06-02 15:39:37 -04:00
Tom Moor
321f89effb chore: Bump prosemirror deps 2024-06-02 12:13:35 -04:00
Tom Moor
5bbe320c8c chore: Bump outline-icons 2024-06-02 12:09:11 -04:00
Tom Moor
5bd6c7b9c7 fix: Layout only changes not synced to content property 2024-06-02 10:29:26 -04:00
Tom Moor
cb0f03d698 fix: Comment action not visible on mobile formatting toolbar 2024-06-01 16:39:32 -04:00
Tom Moor
938fd6ed2d feat: Enable block insertion in table cells from formatting menu
fix: Remove gutter controls from headers not at root
2024-06-01 12:21:33 -04:00
Tom Moor
f2e9c0ab23 perf: Remove useComponentSize from image and video node render 2024-06-01 11:13:03 -04:00
Tom Moor
009458e435 fix: Finicky click target on share dialog permission action 2024-05-31 19:18:35 -04:00
Tom Moor
715b2b1b3f Hide collection header share button on mobile (no space, available in ...) 2024-05-31 18:21:55 -04:00
Tom Moor
da19054555 Table improvements (#6958)
* Header toggling, resizable columns

* Allow all blocks in table cells, disable column resizing in read-only

* Fixed dynamic scroll shadows

* Refactor, scroll styling

* fix scrolling, tweaks

* fix: Table layout lost on sort

* fix: Caching of grip decorators

* refactor

* stash

* fix first render shadows

* stash

* First add column grip, styles

* Just add column/row click handlers left

* fix: isTableSelected for single cell table

* Refactor mousedown handlers

* fix: 'Add row before' command missing on first row

* fix overflow on rhs

* fix: Error clicking column grip when menu is open

* Hide table controls when printing

* Restore table header background

* fix: Header behavior when adding columns and rows at the edges

* Tweak header styling

* fix: Serialize and parsing of column attributes when copy/pasting
fix: Column width is lost when changing column alignment
2024-05-31 14:52:39 -07:00
Tom Moor
1db46f4aac fix: Change to 'No access' is not persisted in collection sharing dialog 2024-05-31 16:45:54 -04:00
François
9dda0da0e8 fix(app.json): add UTILS_SECRET env var (#6971) 2024-05-31 04:07:48 -07:00
Tom Moor
09782939d1 Update icon for drafts 2024-05-30 19:04:54 -04:00
Tom Moor
1f980050ca fix: Incorrect empty check for collection description results in large empty space below title 2024-05-30 18:57:40 -04:00
Tom Moor
6920f13ae4 fix: Missing space in new child document menu 2024-05-30 18:32:41 -04:00
Tom Moor
a05beab3b6 Revert "Bump paragraph spacing"
This reverts commit 1c4817486b.
2024-05-30 18:28:15 -04:00
Tom Moor
3b9cbb08c8 fix: AggregateError thrown from ValidateSSOAccessTask 2024-05-30 00:02:37 -04:00
Tom Moor
30c43690c0 fix: Correctly replace urls with signed versions when display=link 2024-05-29 23:35:03 -04:00
Tom Moor
1ceb87515d fix: Default feature flag logic 2024-05-29 21:30:42 -04:00
Tom Moor
1c4817486b Bump paragraph spacing 2024-05-29 21:29:10 -04:00
Tom Moor
4b1b87abde fix: Cannot remove user from collection in beta sharing UI 2024-05-29 20:04:20 -04:00
Tom Moor
5e841f6b16 fix: Correctly replace urls with signed versions when fully qualified 2024-05-29 19:55:54 -04:00
dependabot[bot]
6fd7e755b0 chore(deps): bump semver and @types/semver (#6954)
Bumps [semver](https://github.com/npm/node-semver) and [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver). These dependencies needed to be updated together.

Updates `semver` from 7.6.0 to 7.6.2
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.6.0...v7.6.2)

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

---
updated-dependencies:
- dependency-name: semver
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/semver"
  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-05-29 15:50:33 -07:00
422 changed files with 14956 additions and 10012 deletions

View File

@@ -12,7 +12,7 @@
"legacy": true "legacy": true
} }
], ],
"@babel/plugin-proposal-class-properties", "@babel/plugin-transform-class-properties",
[ [
"transform-inline-environment-variables", "transform-inline-environment-variables",
{ {
@@ -60,4 +60,4 @@
] ]
} }
} }
} }

View File

@@ -126,7 +126,7 @@ jobs:
docker buildx install docker buildx install
docker context create docker-multiarch docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch docker buildx use docker-multiarch
- run: - run:
@@ -142,9 +142,9 @@ jobs:
name: Build and push Docker image name: Build and push Docker image
command: | command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push . docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push . docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi fi
workflows: workflows:

View File

@@ -127,6 +127,26 @@ GITHUB_APP_NAME=
GITHUB_APP_ID= GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY= GITHUB_APP_PRIVATE_KEY=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# OPTIONAL # OPTIONAL
# Base64 encoded private key and certificate for HTTPS termination. This is only # Base64 encoded private key and certificate for HTTPS termination. This is only

View File

@@ -41,6 +41,7 @@
"@typescript-eslint/no-shadow": [ "@typescript-eslint/no-shadow": [
"warn", "warn",
{ {
"allow": ["transaction"],
"hoist": "all", "hoist": "all",
"ignoreTypeValueShadow": true "ignoreTypeValueShadow": true
} }
@@ -139,4 +140,4 @@
"typescript": {} "typescript": {}
} }
} }
} }

View File

@@ -7,7 +7,8 @@
"roots": ["<rootDir>/server", "<rootDir>/plugins"], "roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1" "^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
@@ -22,7 +23,8 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js" "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"modulePaths": ["<rootDir>/app"], "modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -37,7 +39,8 @@
"roots": ["<rootDir>/shared"], "roots": ["<rootDir>/shared"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1" "^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -50,7 +53,8 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js" "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom", "testEnvironment": "jsdom",

View File

@@ -5,9 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
# --- # ---
FROM node:20-alpine AS runner FROM node:20-slim AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
LABEL org.opencontainers.image.source="https://github.com/outline/outline" LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -22,8 +20,9 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json COPY --from=base $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \ # Create a non-root user compatible with Debian and BusyBox based images
adduser -S nodejs -u 1001 && \ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \ chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \ mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline chown -R nodejs:nodejs /var/lib/outline

View File

@@ -1,19 +1,26 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
FROM node:20-alpine AS deps FROM node:20-slim AS deps
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./ COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches COPY ./patches ./patches
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \ RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
COPY . . COPY . .
ARG CDN_URL ARG CDN_URL
RUN yarn build RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build
RUN rm -rf node_modules RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
ENV PORT 3000
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1

1
__mocks__/react-medium-image-zoom.js vendored Normal file
View File

@@ -0,0 +1 @@
export default null;

View File

@@ -33,6 +33,11 @@
"generator": "secret", "generator": "secret",
"required": true "required": true
}, },
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"ENABLE_UPDATES": { "ENABLE_UPDATES": {
"value": "true", "value": "true",
"required": true "required": true

View File

@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
name: ({ t }) => t("New API key"),
analyticsName: "New API key",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});

View File

@@ -4,6 +4,7 @@ import {
PadlockIcon, PadlockIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
ShapesIcon,
StarredIcon, StarredIcon,
TrashIcon, TrashIcon,
UnstarredIcon, UnstarredIcon,
@@ -11,7 +12,6 @@ import {
import * as React from "react"; import * as React from "react";
import stores from "~/stores"; import stores from "~/stores";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import { CollectionEdit } from "~/components/Collection/CollectionEdit"; import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew"; import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -21,9 +21,8 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections"; import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState"; import { setPersistedState } from "~/hooks/usePersistedState";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import history from "~/utils/history"; import history from "~/utils/history";
import { searchPath } from "~/utils/routeHelpers"; import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => ( const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} /> <DynamicCollectionIcon collection={collection} />
@@ -111,24 +110,16 @@ export const editCollectionPermissions = createAction({
return; return;
} }
if (FeatureFlags.isEnabled(Feature.newCollectionSharing)) { stores.dialogs.openModal({
stores.dialogs.openModal({ title: t("Share this collection"),
title: t("Share this collection"), content: (
content: ( <SharePopover
<SharePopover collection={collection}
collection={collection} onRequestClose={stores.dialogs.closeAllModals}
onRequestClose={stores.dialogs.closeAllModals} visible
visible />
/> ),
), });
});
} else {
stores.dialogs.openModal({
title: t("Collection permissions"),
fullscreen: true,
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
}
}, },
}); });
@@ -137,7 +128,9 @@ export const searchInCollection = createAction({
analyticsName: "Search collection", analyticsName: "Search collection",
section: CollectionSection, section: CollectionSection,
icon: <SearchIcon />, icon: <SearchIcon />,
visible: ({ activeCollectionId }) => !!activeCollectionId, visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
perform: ({ activeCollectionId }) => { perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId })); history.push(searchPath(undefined, { collectionId: activeCollectionId }));
}, },
@@ -230,6 +223,27 @@ export const deleteCollection = createAction({
}, },
}); });
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
},
});
export const rootCollectionActions = [ export const rootCollectionActions = [
openCollection, openCollection,
createCollection, createCollection,

View File

@@ -0,0 +1,90 @@
import { DoneIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
history.replace({
...history.location,
state: null,
});
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
history.replace({
...history.location,
state: null,
});
onUnresolve();
},
});

View File

@@ -51,6 +51,7 @@ import {
documentHistoryPath, documentHistoryPath,
homePath, homePath,
newDocumentPath, newDocumentPath,
newNestedDocumentPath,
searchPath, searchPath,
documentPath, documentPath,
urlify, urlify,
@@ -140,15 +141,10 @@ export const createNestedDocument = createAction({
!!activeDocumentId && !!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument && stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument, stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => perform: ({ activeDocumentId, inStarredSection }) =>
history.push( history.push(newNestedDocumentPath(activeDocumentId), {
newDocumentPath(activeCollectionId, { starred: inStarredSection,
parentDocumentId: activeDocumentId, }),
}),
{
starred: inStarredSection,
}
),
}); });
export const starDocument = createAction({ export const starDocument = createAction({
@@ -676,22 +672,22 @@ export const importDocument = createAction({
}, },
}); });
export const createTemplate = createAction({ export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"), name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document", analyticsName: "Templatize document",
section: DocumentSection, section: DocumentSection,
icon: <ShapesIcon />, icon: <ShapesIcon />,
keywords: "new create template", keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => { visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) { const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
return false; return false;
} }
const document = stores.documents.get(activeDocumentId);
return !!( return !!(
!!activeCollectionId && !!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update && stores.policies.abilities(activeCollectionId).update
!document?.isTemplate &&
!!document?.isActive
); );
}, },
perform: ({ activeDocumentId, stores, t, event }) => { perform: ({ activeDocumentId, stores, t, event }) => {
@@ -700,7 +696,6 @@ export const createTemplate = createAction({
} }
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Create template"), title: t("Create template"),
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />, content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
@@ -988,7 +983,7 @@ export const rootDocumentActions = [
openDocument, openDocument,
archiveDocument, archiveDocument,
createDocument, createDocument,
createTemplate, createTemplateFromDocument,
deleteDocument, deleteDocument,
importDocument, importDocument,
downloadDocument, downloadDocument,

View File

@@ -3,7 +3,6 @@ import {
SearchIcon, SearchIcon,
ArchiveIcon, ArchiveIcon,
TrashIcon, TrashIcon,
EditIcon,
OpenIcon, OpenIcon,
SettingsIcon, SettingsIcon,
KeyboardIcon, KeyboardIcon,
@@ -12,6 +11,7 @@ import {
ProfileIcon, ProfileIcon,
BrowserIcon, BrowserIcon,
ShapesIcon, ShapesIcon,
DraftsIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper"; import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -57,7 +57,7 @@ export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"), name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts", analyticsName: "Navigate to drafts",
section: NavigationSection, section: NavigationSection,
icon: <EditIcon />, icon: <DraftsIcon />,
perform: () => history.push(draftsPath()), perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(), visible: ({ location }) => location.pathname !== draftsPath(),
}); });

View File

@@ -2,13 +2,14 @@
/* global ga */ /* global ga */
import escape from "lodash/escape"; import escape from "lodash/escape";
import * as React from "react"; import * as React from "react";
import { IntegrationService } from "@shared/types"; import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env"; import env from "~/env";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
}; };
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => { const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3 // Google Analytics 3
React.useEffect(() => { React.useEffect(() => {
@@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => {
React.useEffect(() => { React.useEffect(() => {
const measurementIds = []; const measurementIds = [];
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(env.analytics.settings?.measurementId));
}
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) { if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
measurementIds.push(env.GOOGLE_ANALYTICS_ID); measurementIds.push(env.GOOGLE_ANALYTICS_ID);
} }
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(integration.settings?.measurementId));
}
});
if (measurementIds.length === 0) { if (measurementIds.length === 0) {
return; return;
} }
@@ -75,6 +80,32 @@ const Analytics: React.FC = ({ children }: Props) => {
document.getElementsByTagName("head")[0]?.appendChild(script); document.getElementsByTagName("head")[0]?.appendChild(script);
}, []); }, []);
// Matomo
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Matomo) {
return;
}
// @ts-expect-error - Matomo global variable
const _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = integration.settings?.instanceUrl;
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", integration.settings?.measurementId]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.type = "text/javascript";
g.async = true;
g.src = u + "matomo.js";
s.parentNode?.insertBefore(g, s);
})();
});
}, []);
return <>{children}</>; return <>{children}</>;
}; };

View File

@@ -1,54 +1,50 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & { type Props = React.HTMLAttributes<HTMLDivElement> & {
children: (composite: CompositeStateReturn) => React.ReactNode; children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void; onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
}; };
function ArrowKeyNavigation( function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props, { children, onEscape, items, ...rest }: Props,
ref: React.RefObject<HTMLDivElement> ref: React.RefObject<HTMLDivElement>
) { ) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(ev) => { (ev: React.KeyboardEvent<HTMLDivElement>) => {
if (onEscape) { if (onEscape) {
if (ev.nativeEvent.isComposing) { if (ev.nativeEvent.isComposing) {
return; return;
} }
if (ev.key === "Escape") { if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev); onEscape(ev);
} }
if ( if (
ev.key === "ArrowUp" && ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id // If the first item is focused and the user presses ArrowUp
ev.currentTarget.firstElementChild === document.activeElement
) { ) {
onEscape(ev); onEscape(ev);
} }
} }
}, },
[composite.currentId, composite.items, onEscape] [onEscape]
); );
return ( return (
<Composite <RovingTabIndexProvider
{...rest} options={{ focusOnClick: true, direction: "both" }}
{...composite} items={items}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
> >
{children(composite)} <div {...rest} onKeyDown={handleKeyDown} ref={ref}>
</Composite> {children()}
</div>
</RovingTabIndexProvider>
); );
} }

View File

@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
&:disabled { &:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
color: ${(props) => transparentize(0.5, props.theme.accentText)}; color: ${(props) => transparentize(0.3, props.theme.accentText)};
background: ${(props) => lighten(0.2, props.theme.accent)}; background: ${(props) => transparentize(0.1, props.theme.accent)};
svg { svg {
fill: ${(props) => props.theme.white50}; fill: ${(props) => props.theme.white50};

View File

@@ -11,19 +11,22 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker"; import Icon from "~/components/Icon";
import Input from "~/components/Input"; import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission"; import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch"; import Switch from "~/components/Switch";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags"; import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData { export interface FormData {
name: string; name: string;
icon: string; icon: string;
color: string; color: string | null;
sharing: boolean; sharing: boolean;
permission: CollectionPermission | undefined; permission: CollectionPermission | undefined;
} }
@@ -37,7 +40,16 @@ export const CollectionForm = observer(function CollectionForm_({
}) { }) {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useTranslation(); const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false); const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const { const {
register, register,
handleSubmit: formHandleSubmit, handleSubmit: formHandleSubmit,
@@ -53,7 +65,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon, icon: collection?.icon,
sharing: collection?.sharing ?? true, sharing: collection?.sharing ?? true,
permission: collection?.permission, permission: collection?.permission,
color: collection?.color ?? randomElement(colorPalette), color: iconColor,
}, },
}); });
@@ -70,20 +82,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection" "collection"
); );
} }
}, [values.name, collection]); }, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => { React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100); setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]); }, [setFocus]);
const handleIconPickerChange = React.useCallback( const handleIconChange = React.useCallback(
(color: string, icon: string) => { (icon: string, color: string | null) => {
if (icon !== values.icon) { if (icon !== values.icon) {
setFocus("name"); setFocus("name");
} }
setValue("color", color);
setValue("icon", icon); setValue("icon", icon);
setValue("color", color);
}, },
[setFocus, setValue, values.icon] [setFocus, setValue, values.icon]
); );
@@ -105,13 +117,16 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength, maxLength: CollectionValidation.maxNameLength,
})} })}
prefix={ prefix={
<StyledIconPicker <React.Suspense fallback={fallbackIcon}>
onOpen={setHasOpenedIconPicker} <StyledIconPicker
onChange={handleIconPickerChange} icon={values.icon}
initial={values.name[0]} color={values.color ?? iconColor}
color={values.color} initial={values.name[0]}
icon={values.icon} popoverPosition="right"
/> onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
} }
autoComplete="off" autoComplete="off"
autoFocus autoFocus
@@ -128,8 +143,10 @@ export const CollectionForm = observer(function CollectionForm_({
<InputSelectPermission <InputSelectPermission
ref={field.ref} ref={field.ref}
value={field.value} value={field.value}
onChange={(value: CollectionPermission) => { onChange={(
field.onChange(value); value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
}} }}
note={t( note={t(
"The default access for workspace members, you can share with more users or groups later." "The default access for workspace members, you can share with more users or groups later."

View File

@@ -1,11 +1,12 @@
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons"; import { CheckmarkIcon } from "outline-icons";
import { ellipsis } from "polished"; import { ellipsis, transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs"; import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu"; import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper"; import MenuIconWrapper from "./MenuIconWrapper";
type Props = { type Props = {
@@ -160,6 +161,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.accentText}; color: ${props.theme.accentText};
fill: ${props.theme.accentText}; fill: ${props.theme.accentText};
} }
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
} }
} }
`} `}

View File

@@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[]; actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>; context?: Partial<ActionContext>;
items?: TMenuItem[]; items?: TMenuItem[];
showIcons?: boolean;
}; };
const Disclosure = styled(ExpandedIcon)` const Disclosure = styled(ExpandedIcon)`
@@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
}); });
} }
function Template({ items, actions, context, ...menu }: Props) { function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({ const ctx = useActionContext({
isContextMenu: true, isContextMenu: true,
}); });
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
if ( if (
iconIsPresentInAnyMenuItem && iconIsPresentInAnyMenuItem &&
item.type !== "separator" && item.type !== "separator" &&
item.type !== "heading" item.type !== "heading" &&
showIcons !== false
) { ) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />; item.icon = item.icon || <MenuIconWrapper aria-hidden />;
} }
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
icon={item.icon} icon={showIcons !== false ? item.icon : undefined}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected} selected={item.selected}
level={item.level} level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"} target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon} icon={showIcons !== false ? item.icon : undefined}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected} selected={item.selected}
dangerous={item.dangerous} dangerous={item.dangerous}
key={index} key={index}
icon={item.icon} icon={showIcons !== false ? item.icon : undefined}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
templateItems={item.items} templateItems={item.items}
parentMenuState={menu} parentMenuState={menu}
title={<Title title={item.title} icon={item.icon} />} title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu} {...menu}
/> />
); );

View File

@@ -6,6 +6,7 @@ import styled from "styled-components";
import type { NavigationNode } from "@shared/types"; import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb"; import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types"; import { MenuInternalLink } from "~/types";
@@ -15,7 +16,6 @@ import {
settingsPath, settingsPath,
trashPath, trashPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.slice(0, -1).forEach((node: NavigationNode) => { path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({ output.push({
type: "route", type: "route",
title: node.emoji ? ( title: node.icon ? (
<> <>
<EmojiIcon emoji={node.emoji} /> {node.title} <StyledIcon value={node.icon} color={node.color} /> {node.title}
</> </>
) : ( ) : (
node.title node.title
@@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
); );
}; };
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)` const SmallSlash = styled(GoToIcon)`
width: 12px; width: 12px;
height: 12px; height: 12px;

View File

@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Pin from "~/models/Pin"; import Pin from "~/models/Pin";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { hover } from "~/styles"; import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon"; import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text"; import Text from "./Text";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin, disabled: !isDraggable || !canUpdatePin,
}); });
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -109,12 +113,18 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" /> <path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold> </Fold>
{document.emoji ? ( {document.icon ? (
<Squircle color={theme.slateLight}> <DocumentSquircle
<EmojiIcon emoji={document.emoji} size={24} /> icon={document.icon}
</Squircle> color={document.color ?? undefined}
/>
) : ( ) : (
<Squircle color={collection?.color}> <Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon && {collection?.icon &&
collection?.icon !== "letter" && collection?.icon !== "letter" &&
collection?.icon !== "collection" && collection?.icon !== "collection" &&
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
)} )}
<div> <div>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.emoji {hasEmojiInTitle
? document.titleWithDefault.replace(document.emoji, "") ? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault} : document.titleWithDefault}
</Heading> </Heading>
<DocumentMeta size="xsmall"> <DocumentMeta size="xsmall">
@@ -159,6 +169,24 @@ function DocumentCard(props: Props) {
); );
} }
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} />
</Squircle>
);
};
const Clock = styled(ClockIcon)` const Clock = styled(ClockIcon)`
flex-shrink: 0; flex-shrink: 0;
`; `;

View File

@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input"; import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch"; import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text"; import Text from "~/components/Text";
@@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}) => { }) => {
const node = data[index]; const node = data[index];
const isCollection = node.type === "collection"; const isCollection = node.type === "collection";
let icon, title: string, emoji: string | undefined, path; let renderedIcon,
title: string,
icon: string | undefined,
color: string | undefined,
path;
if (isCollection) { if (isCollection) {
const col = collections.get(node.collectionId as string); const col = collections.get(node.collectionId as string);
icon = col && ( renderedIcon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} /> <CollectionIcon collection={col} expanded={isExpanded(index)} />
); );
title = node.title; title = node.title;
} else { } else {
const doc = documents.get(node.id); const doc = documents.get(node.id);
emoji = doc?.emoji ?? node.emoji; icon = doc?.icon ?? node.icon;
color = doc?.color ?? node.color;
title = doc?.title ?? node.title; title = doc?.title ?? node.title;
if (emoji) { if (icon) {
icon = <EmojiIcon emoji={emoji} />; renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) { } else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />; renderedIcon = <StarredIcon color={theme.yellow} />;
} else { } else {
icon = <DocumentIcon color={theme.textSecondary} />; renderedIcon = <DocumentIcon color={theme.textSecondary} />;
} }
path = ancestors(node) path = ancestors(node)
@@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}} }}
onPointerMove={() => setActiveNode(index)} onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)} onClick={() => toggleSelect(index)}
icon={icon} icon={renderedIcon}
title={title} title={title}
path={path} path={path}
/> />
@@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
selected={isSelected(index)} selected={isSelected(index)}
active={activeNode === index} active={activeNode === index}
expanded={isExpanded(index)} expanded={isExpanded(index)}
icon={icon} icon={renderedIcon}
title={title} title={title}
depth={node.depth as number} depth={node.depth as number}
hasChildren={hasChildren(index)} hasChildren={hasChildren(index)}

View File

@@ -1,17 +1,21 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta"; import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight"; import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star"; import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -20,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles"; import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers"; import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
document: Document; document: Document;
@@ -32,7 +35,7 @@ type Props = {
showPin?: boolean; showPin?: boolean;
showDraft?: boolean; showDraft?: boolean;
showTemplate?: boolean; showTemplate?: boolean;
} & CompositeStateReturn; };
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -49,6 +52,15 @@ function DocumentListItem(
const user = useCurrentUser(); const user = useCurrentUser();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
const { const {
document, document,
showParentDocuments, showParentDocuments,
@@ -68,9 +80,8 @@ function DocumentListItem(
!document.isDraft && !document.isArchived && !document.isTemplate; !document.isDraft && !document.isArchived && !document.isTemplate;
return ( return (
<CompositeItem <DocumentLink
as={DocumentLink} ref={itemRef}
ref={ref}
dir={document.dir} dir={document.dir}
role="menuitem" role="menuitem"
$isStarred={document.isStarred} $isStarred={document.isStarred}
@@ -89,12 +100,13 @@ function DocumentListItem(
handleMenuOpen(); handleMenuOpen();
}} }}
{...rest} {...rest}
{...rovingTabIndex}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.emoji && ( {document.icon && (
<> <>
<EmojiIcon emoji={document.emoji} size={24} /> <Icon value={document.icon} color={document.color ?? undefined} />
&nbsp; &nbsp;
</> </>
)} )}
@@ -150,7 +162,7 @@ function DocumentListItem(
modal={false} modal={false}
/> />
</Actions> </Actions>
</CompositeItem> </DocumentLink>
); );
} }
@@ -271,6 +283,8 @@ const ResultContext = styled(Highlight)`
font-size: 15px; font-size: 15px;
margin-top: -0.25em; margin-top: -0.25em;
margin-bottom: 0.25em; margin-bottom: 0.25em;
max-height: 90px;
overflow: hidden;
`; `;
export default observer(React.forwardRef(DocumentListItem)); export default observer(React.forwardRef(DocumentListItem));

View File

@@ -1,23 +0,0 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;

View File

@@ -1,262 +0,0 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
locale={supportedLocales.includes(locale) ? locale : "en"}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;

View File

@@ -11,16 +11,12 @@ import {
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Event from "~/models/Event"; import Event from "~/models/Event";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import CompositeItem, { import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu"; import RevisionMenu from "~/menus/RevisionMenu";
@@ -32,7 +28,7 @@ type Props = {
document: Document; document: Document;
event: Event; event: Event;
latest?: boolean; latest?: boolean;
} & CompositeStateReturn; };
const EventListItem = ({ event, latest, document, ...rest }: Props) => { const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -176,11 +172,7 @@ const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps, { to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement> ref?: React.Ref<HTMLAnchorElement>
) { ) {
if (to) { return <ListItem to={to} ref={ref} {...rest} />;
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}); });
const Subtitle = styled.span` const Subtitle = styled.span`
@@ -240,8 +232,4 @@ const ListItem = styled(Item)`
${ItemStyle} ${ItemStyle}
`; `;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem); export default observer(EventListItem);

View File

@@ -1,7 +1,9 @@
import * as React from "react"; import * as React from "react";
import { richExtensions } from "@shared/editor/nodes";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import ErrorBoundary from "../ErrorBoundary";
import { import {
Preview, Preview,
Title, Title,
@@ -21,20 +23,23 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
<Preview to={url}> <Preview to={url}>
<Card ref={ref}> <Card ref={ref}>
<CardContent> <CardContent>
<Flex column gap={2}> <ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Title>{title}</Title> <Flex column gap={2}>
<Info>{lastActivityByViewer}</Info> <Title>{title}</Title>
<Description as="div"> <Info>{lastActivityByViewer}</Info>
<React.Suspense fallback={<div />}> <Description as="div">
<Editor <React.Suspense fallback={<div />}>
key={id} <Editor
defaultValue={summary} key={id}
embedsDisabled extensions={richExtensions}
readOnly defaultValue={summary}
/> embedsDisabled
</React.Suspense> readOnly
</Description> />
</Flex> </React.Suspense>
</Description>
</Flex>
</ErrorBoundary>
</CardContent> </CardContent>
</Card> </Card>
</Preview> </Preview>

107
app/components/Icon.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import { randomElement } from "@shared/random";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { determineIconType } from "@shared/utils/icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
export type Props = {
/** The icon to render */
value: string;
/** The color of the icon */
color?: string;
/** The size of the icon */
size?: number;
/** The initial to display if the icon is a letter icon */
initial?: string;
/** Optional additional class name */
className?: string;
/**
* Ensure the color does not change in response to theme and contrast. Should only be
* used in color picker UI.
*/
forceColor?: boolean;
};
const Icon = ({
value: icon,
color,
size = 24,
initial,
forceColor,
className,
}: Props) => {
const iconType = determineIconType(icon);
if (!iconType) {
Logger.warn("Failed to determine icon type", {
icon,
});
return null;
}
try {
if (iconType === IconType.SVG) {
return (
<SVGIcon
value={icon}
color={color}
size={size}
initial={initial}
className={className}
forceColor={forceColor}
/>
);
}
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
Logger.warn("Failed to render icon", {
icon,
});
}
return null;
};
const SVGIcon = observer(
({
value: icon,
color: inputColor,
initial,
size,
className,
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? randomElement(colorPalette);
// If the chosen icon color is very dark then we invert it in dark mode
if (!forceColor) {
if (ui.resolvedTheme === "dark" && color !== "currentColor") {
color = getLuminance(color) > 0.09 ? color : "currentColor";
}
// If the chosen icon color is very light then we invert it in light mode
if (ui.resolvedTheme === "light" && color !== "currentColor") {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
const Component = IconLibrary.getComponent(icon);
return (
<Component color={color} size={size} className={className}>
{initial}
</Component>
);
}
);
export default Icon;

View File

@@ -1,211 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
);
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
className?: string;
};
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "right",
modal: true,
});
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
swatch: {
cursor: "var(--cursor-pointer)",
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}),
[theme]
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
popover.unstable_popoverRef,
(event) => {
if (popover.visible) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton
aria-label={t("Show menu")}
className={className}
{...props}
>
<Icon
as={IconLibrary.getComponent(icon || "collection")}
color={color}
>
{initial}
</Icon>
</NudeButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
>
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
);
}
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
height: 30px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: 100% !important;
`;
export default IconPicker;

View File

@@ -0,0 +1,218 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s } 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,
Hex,
}
type Props = {
width: number;
activeColor: string;
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
);
};
const BuiltinColors = ({
activeColor,
onClick,
className,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
/>
</Flex>
);
};
const Container = styled(Flex)`
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ color }) => color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
}
& ${Selected} {
display: ${({ active }) => (active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 380px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;

View File

@@ -0,0 +1,8 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;

View File

@@ -0,0 +1,245 @@
import concat from "lodash/concat";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel and InputSearch.
*/
const GRID_HEIGHT = 362;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const EmojiPanel = ({
panelWidth,
query,
panelActive,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleSkinChange = React.useCallback(
(emojiSkinTone: EmojiSkinTone) => {
setEmojiSkinTone(emojiSkinTone);
},
[setEmojiSkinTone]
);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementEmojiCount(id);
},
[onEmojiChange, incrementEmojiCount]
);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
})
: getAllEmojis({
skinTone,
freqEmojis,
});
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search emoji")}`}
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const getSearchResults = ({
query,
skinTone,
}: {
query: string;
skinTone: EmojiSkinTone;
}): DataNode[] => {
const emojis = search({ query, skinTone });
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
},
];
};
const getAllEmojis = ({
skinTone,
freqEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentEmojis = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
const emojis = emojisWithCategory[emojiCategory] ?? [];
return {
category: emojiCategory,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
return concat(
getFrequentEmojis(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
getCategoryData(EmojiCategory.Activity),
getCategoryData(EmojiCategory.Places),
getCategoryData(EmojiCategory.Objects),
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;

View File

@@ -0,0 +1,61 @@
import React from "react";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import styled from "styled-components";
type Props = {
width: number;
height: number;
data: React.ReactNode[][];
columns: number;
itemWidth: number;
};
const Grid = (
{ width, height, data, columns, itemWidth }: Props,
ref: React.Ref<HTMLDivElement>
) => (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
>
{Row}
</Container>
);
type RowProps = {
data: React.ReactNode[][];
columns: number;
};
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const { data: rows, columns } = data;
const row = rows[index];
return (
<RowContainer style={style} columns={columns}>
{row}
</RowContainer>
);
};
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
& > div {
position: relative;
}
`;
const RowContainer = styled.div<{ columns: number }>`
display: grid;
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
align-content: center;
`;
export default React.forwardRef(Grid);

View File

@@ -0,0 +1,120 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
*/
const BUTTON_SIZE = 32;
type OutlineNode = {
type: IconType.SVG;
name: string;
color: string;
initial: string;
delay: number;
};
type EmojiNode = {
type: IconType.Emoji;
id: string;
value: string;
};
export type DataNode = {
category: keyof typeof TRANSLATED_CATEGORIES;
icons: (OutlineNode | EmojiNode)[];
};
type Props = {
width: number;
height: number;
data: DataNode[];
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
type="tertiary"
size="xsmall"
weight="bold"
>
{TRANSLATED_CATEGORIES[node.category]}
</CategoryName>
);
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
delay={item.delay}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
</Icon>
</IconButton>
);
}
return (
<IconButton
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji>{item.value}</Emoji>
</IconButton>
);
});
const chunks = chunk(items, itemsPerRow);
return [[category], ...chunks];
})
);
return (
<Grid
ref={ref}
width={width}
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
/>
);
};
const CategoryName = styled(Text)`
grid-column: 1 / -1;
padding-left: 6px;
`;
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
export default React.forwardRef(GridTemplate);

View File

@@ -0,0 +1,15 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
`;

View File

@@ -0,0 +1,200 @@
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
color: string;
query: string;
panelActive: boolean;
onIconChange: (icon: string) => void;
onColorChange: (icon: string) => void;
onQueryChange: (query: string) => void;
};
const IconPanel = ({
panelWidth,
initial,
color,
query,
panelActive,
onIconChange,
onColorChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFreqIcons } = useIconState();
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
() => IconLibrary.findIcons(query),
[query]
);
const isSearch = query !== "";
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleIconSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onIconChange(value);
incrementIconCount(id);
},
[onIconChange, incrementIconCount]
);
const baseIcons: DataNode = {
category,
icons: filteredIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
};
const templateData: DataNode[] = isSearch
? [baseIcons]
: [
{
category: DisplayCategory.Frequent,
icons: freqIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
},
baseIcons,
];
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<InputSearchContainer align="center">
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search icons")}`}
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
/>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleIconSelection}
/>
</Flex>
);
};
const InputSearchContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default IconPanel;

View File

@@ -0,0 +1,20 @@
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
${({ $borderOnHover }) =>
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
`;

View File

@@ -0,0 +1,92 @@
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 { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
skinTone,
onChange,
}: {
skinTone: EmojiSkinTone;
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const menu = useMenuState({
placement: "bottom",
});
const handleSkinClick = React.useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
},
[menu, onChange]
);
const menuItems = React.useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji>{emoji.value}</Emoji>
</IconButton>
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
);
};
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;

View File

@@ -0,0 +1,312 @@
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
PopoverDisclosure,
Tab,
TabList,
TabPanel,
usePopoverState,
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import { s } 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";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
} as const;
const POPOVER_WIDTH = 408;
type Props = {
icon: string | null;
color: string;
size?: number;
initial?: string;
className?: string;
popoverPosition: "bottom-start" | "right";
allowDelete?: boolean;
borderOnHover?: boolean;
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
};
const IconPicker = ({
icon,
color,
size = 24,
initial,
className,
popoverPosition,
allowDelete,
onChange,
onOpen,
onClose,
borderOnHover,
}: Props) => {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [chosenColor, setChosenColor] = React.useState(color);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const iconType = determineIconType(icon);
const defaultTab = React.useMemo(
() =>
iconType === IconType.Emoji ? TAB_NAMES["Emoji"] : TAB_NAMES["Icon"],
[iconType]
);
const popover = usePopoverState({
placement: popoverPosition,
modal: true,
unstable_offset: [0, 0],
});
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const resetDefaultTab = React.useCallback(() => {
tab.select(defaultTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultTab]);
const handleIconChange = React.useCallback(
(ic: string) => {
popover.hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[popover, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
(c: string) => {
setChosenColor(c);
const icType = determineIconType(icon);
// Outline icon set; propagate color change
if (icType === IconType.SVG) {
onChange(icon, c);
}
},
[icon, onChange]
);
const handleIconRemove = React.useCallback(() => {
popover.hide();
onChange(null, null);
}, [popover, onChange]);
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible && !previouslyVisible) {
onOpen?.();
} else if (!popover.visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Show menu")}
className={className}
size={size}
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.textTertiary} size={size} />
)}
</PopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
>
<>
<TabActionsWrapper justify="space-between" align="center">
<TabList {...tab}>
<StyledTab
{...tab}
id={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
active={tab.selectedId === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
<StyledTab
{...tab}
id={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
active={tab.selectedId === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
</TabList>
{allowDelete && icon && (
<RemoveButton onClick={handleIconRemove}>
{t("Remove")}
</RemoveButton>
)}
</TabActionsWrapper>
<StyledTabPanel {...tab}>
<IconPanel
panelWidth={panelWidth}
initial={initial ?? "?"}
color={chosenColor}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
}
onIconChange={handleIconChange}
onColorChange={handleIconColorChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
<StyledTabPanel {...tab}>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
}
onEmojiChange={handleIconChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
</>
</Popover>
</>
);
};
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(NudeButton)`
width: auto;
font-weight: 500;
font-size: 14px;
color: ${s("textTertiary")};
padding: 8px 12px;
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
`;
const TabActionsWrapper = styled(Flex)`
padding-left: 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
cursor: var(--pointer);
background: none;
border: 0;
padding: 8px 12px;
user-select: none;
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
${({ active }) =>
active &&
css`
&:after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${s("textSecondary")};
}
`}
`;
const StyledTabPanel = styled(TabPanel)`
height: 410px;
overflow-y: auto;
`;
export default IconPicker;

View File

@@ -0,0 +1,50 @@
import i18next from "i18next";
export enum DisplayCategory {
All = "All",
Frequent = "Frequent",
Search = "Search",
}
export const TRANSLATED_CATEGORIES = {
All: i18next.t("All"),
Frequent: i18next.t("Frequently Used"),
Search: i18next.t("Search Results"),
People: i18next.t("Smileys & People"),
Nature: i18next.t("Animals & Nature"),
Foods: i18next.t("Food & Drink"),
Activity: i18next.t("Activity"),
Places: i18next.t("Travel & Places"),
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
};
export const FREQUENTLY_USED_COUNT = {
Get: 24,
Track: 30,
};
const STORAGE_KEYS = {
Base: "icon-state",
EmojiSkinTone: "emoji-skintone",
IconsFrequency: "icons-freq",
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone);
export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency);
export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency);
export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));

View File

@@ -0,0 +1,31 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
/** If true, the icon will retain its color in selected menus and other places that attempt to override it */
retainColor?: boolean;
};
export default function CircleIcon({
size = 24,
color = "currentColor",
retainColor,
...rest
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
</svg>
);
}

View File

@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons"; import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished"; import { getLuminance } from "polished";
import * as React from "react"; import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary"; import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = { type Props = {
/** The collection to show an icon for */ /** The collection to show an icon for */
@@ -16,6 +16,7 @@ type Props = {
size?: number; size?: number;
/** The color of the icon, defaults to the collection color */ /** The color of the icon, defaults to the collection color */
color?: string; color?: string;
className?: string;
}; };
function ResolvedCollectionIcon({ function ResolvedCollectionIcon({
@@ -23,35 +24,41 @@ function ResolvedCollectionIcon({
color: inputColor, color: inputColor,
expanded, expanded,
size, size,
className,
}: Props) { }: Props) {
const { ui } = useStores(); const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode if (!collection.icon || collection.icon === "collection") {
// otherwise it will be impossible to see against the dark background. // If the chosen icon color is very dark then we invert it in dark mode
const color = // otherwise it will be impossible to see against the dark background.
inputColor || const collectionColor = collection.color ?? colorPalette[0];
(ui.resolvedTheme === "dark" && collection.color !== "currentColor" const color =
? getLuminance(collection.color) > 0.09 inputColor ||
? collection.color (ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
: "currentColor" ? getLuminance(collectionColor) > 0.09
: collection.color); ? collectionColor
: "currentColor"
: collectionColor);
if (collection.icon && collection.icon !== "collection") { return (
try { <CollectionIcon
const Component = IconLibrary.getComponent(collection.icon); color={color}
return ( expanded={expanded}
<Component color={color} size={size}> size={size}
{collection.initial} className={className}
</Component> />
); );
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
} }
return <CollectionIcon color={color} expanded={expanded} size={size} />; return (
<Icon
value={collection.icon}
color={inputColor ?? collection.color ?? undefined}
size={size}
initial={collection.initial}
className={className}
/>
);
} }
export default observer(ResolvedCollectionIcon); export default observer(ResolvedCollectionIcon);

View File

@@ -1,11 +1,13 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
type Props = { type Props = {
/** The emoji to render */ /** The emoji to render */
emoji: string; emoji: string;
/** The size of the emoji, 24px is default to match standard icons */ /** The size of the emoji, 24px is default to match standard icons */
size?: number; size?: number;
className?: string;
}; };
/** /**
@@ -15,19 +17,28 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return ( return (
<Span $size={size} {...rest}> <Span $size={size} {...rest}>
{emoji} <SVG size={size} emoji={emoji} />
</Span> </Span>
); );
} }
const Span = styled.span<{ $size: number }>` const Span = styled.span<{ $size: number }>`
display: inline-flex; font-family: ${s("fontFamilyEmoji")};
align-items: center; display: inline-block;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px; width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px; height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: ${(props) => props.$size - 10}px;
`; `;
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
>
{emoji}
</text>
</svg>
);

View File

@@ -11,13 +11,21 @@ import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input"; import Input, { Outline } from "./Input";
type Props = { type Props = {
/** A string representing where the search started, for tracking. */
source: string; source: string;
/** Placeholder text for the input. */
placeholder?: string; placeholder?: string;
/** Label for the input. */
label?: string; label?: string;
/** Whether the label should be hidden. */
labelHidden?: boolean; labelHidden?: boolean;
/** An optional ID of a collection to search within. */
collectionId?: string; collectionId?: string;
/** The current value of the input. */
value?: string; value?: string;
/** Event handler for when the input value changes. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown; onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
/** Event handler for when a key is pressed. */
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown; onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
}; };

View File

@@ -50,6 +50,11 @@ export type Props = {
note?: React.ReactNode; note?: React.ReactNode;
onChange?: (value: string | null) => void; onChange?: (value: string | null) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
}; };
export interface InputSelectRef { export interface InputSelectRef {
@@ -79,6 +84,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note, note,
icon, icon,
nude, nude,
skipBodyScroll,
...rest ...rest
} = props; } = props;
@@ -91,7 +97,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({ const popover = useSelectPopover({
...select, ...select,
hideOnClickOutside: false, hideOnClickOutside: false,
preventBodyScroll: true, preventBodyScroll: skipBodyScroll ? false : true,
disabled, disabled,
}); });
@@ -220,7 +226,12 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</StyledButton> </StyledButton>
)} )}
</Select> </Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}> <SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => { {(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0"; const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end"; const rightAnchor = popoverProps.placement === "bottom-end";

View File

@@ -23,15 +23,16 @@ function InputSelectPermission(
ref={ref} ref={ref}
label={t("Permission")} label={t("Permission")}
options={[ options={[
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{ {
label: t("View only"), label: t("View only"),
value: CollectionPermission.Read, value: CollectionPermission.Read,
}, },
{ {
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
divider: true,
label: t("No access"), label: t("No access"),
value: EmptySelectValue, value: EmptySelectValue,
}, },

View File

@@ -1,17 +0,0 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);

View File

@@ -1,9 +1,15 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import * as React from "react"; import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink"; import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & { export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */ /** An icon or image to display to the left of the list item */
@@ -12,6 +18,8 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor; to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */ /** An optional click handler, if provided the list item will have hover styles */
onClick?: React.MouseEventHandler<HTMLAnchorElement>; onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** An optional keydown handler, if provided the list item will have hover styles */
onKeyDown?: React.KeyboardEventHandler<HTMLAnchorElement>;
/** Whether to match the location exactly */ /** Whether to match the location exactly */
exact?: boolean; exact?: boolean;
/** The title of the list item */ /** The title of the list item */
@@ -24,15 +32,50 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
border?: boolean; border?: boolean;
/** Whether to display the list item in a compact style */ /** Whether to display the list item in a compact style */
small?: boolean; small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
}; };
const ListItem = ( const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props, {
ref?: React.Ref<HTMLAnchorElement> image,
title,
subtitle,
actions,
small,
border,
to,
keyboardNavigation,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) => { ) => {
const theme = useTheme(); const theme = useTheme();
const compact = !subtitle; const compact = !subtitle;
let itemRef: React.RefObject<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef,
keyboardNavigation || to ? false : true
);
useFocusEffect(focused, itemRef);
const handleFocus = React.useCallback(() => {
if (itemRef.current) {
scrollIntoView(itemRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "center",
boundary: window.document.body,
});
}
}, [itemRef]);
const content = (selected: boolean) => ( const content = (selected: boolean) => (
<> <>
{image && <Image>{image}</Image>} {image && <Image>{image}</Image>}
@@ -59,13 +102,30 @@ const ListItem = (
if (to) { if (to) {
return ( return (
<Wrapper <Wrapper
ref={ref} ref={itemRef}
$border={border} $border={border}
$small={small} $small={small}
activeStyle={{ activeStyle={{
background: theme.accent, background: theme.accent,
}} }}
{...rest} {...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
if (rest.onKeyDown) {
rest.onKeyDown(ev);
}
rovingTabIndex.onKeyDown(ev);
}}
onFocus={(ev) => {
rovingTabIndex.onFocus(ev);
handleFocus();
}}
as={NavLink} as={NavLink}
to={to} to={to}
> >
@@ -75,7 +135,26 @@ const ListItem = (
} }
return ( return (
<Wrapper ref={ref} $border={border} $small={small} {...rest}> <Wrapper
ref={itemRef}
$border={border}
$small={small}
$hover={!!rest.onClick}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
rest.onKeyDown?.(ev);
rovingTabIndex.onKeyDown(ev);
}}
onFocus={(ev) => {
rovingTabIndex.onFocus(ev);
handleFocus();
}}
>
{content(false)} {content(false)}
</Wrapper> </Wrapper>
); );
@@ -84,6 +163,7 @@ const ListItem = (
const Wrapper = styled.a<{ const Wrapper = styled.a<{
$small?: boolean; $small?: boolean;
$border?: boolean; $border?: boolean;
$hover?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement>; onClick?: React.MouseEventHandler<HTMLAnchorElement>;
to?: LocationDescriptor; to?: LocationDescriptor;
}>` }>`
@@ -100,9 +180,15 @@ const Wrapper = styled.a<{
border-bottom: 0; border-bottom: 0;
} }
&:hover { &:focus-visible {
outline: none;
}
&:${hover},
&:focus,
&:focus-within {
background: ${(props) => background: ${(props) =>
props.onClick ? props.theme.secondaryBackground : "inherit"}; props.$hover ? props.theme.secondaryBackground : "inherit"};
} }
cursor: ${(props) => cursor: ${(props) =>

View File

@@ -1,28 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
const MobileScrollWrapper = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
return isMobile && !isPrinting ? (
<MobileWrapper>{children}</MobileWrapper>
) : (
<>{children}</>
);
};
export default MobileScrollWrapper;

View File

@@ -254,7 +254,7 @@ const Header = styled(Flex)`
const Small = styled.div` const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease; animation: ${fadeAndScaleIn} 250ms ease;
margin: auto auto; margin: 25vh auto auto auto;
width: 75vw; width: 75vw;
min-width: 350px; min-width: 350px;
max-width: 450px; max-width: 450px;
@@ -282,7 +282,7 @@ const Small = styled.div`
`; `;
const SmallContent = styled(Scrollable)` const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px; padding: 12px 24px;
`; `;
export default observer(Modal); export default observer(Modal);

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
import ScrollContext from "./ScrollContext";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
/**
* A component that wraps its children in a scrollable container on mobile devices.
* This allows us to place a fixed toolbar at the bottom of the page in the document
* editor, which would otherwise be obscured by the on-screen keyboard.
*
* On desktop devices, the children are rendered directly without any wrapping.
*/
const PageScroll = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
const ref = React.useRef<HTMLDivElement>(null);
return isMobile && !isPrinting ? (
<ScrollContext.Provider value={ref}>
<MobileWrapper ref={ref}>{children}</MobileWrapper>
</ScrollContext.Provider>
) : (
<>{children}</>
);
};
export default PageScroll;

View File

@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch} fetch={fetch}
options={options} options={options}
renderError={(props) => <Error {...props} />} renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => ( renderItem={(item: Document, _index) => (
<DocumentListItem <DocumentListItem
key={item.id} key={item.id}
document={item} document={item}
@@ -52,7 +52,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showPublished={showPublished} showPublished={showPublished}
showTemplate={showTemplate} showTemplate={showTemplate}
showDraft={showDraft} showDraft={showDraft}
{...compositeProps}
/> />
)} )}
{...rest} {...rest}

View File

@@ -30,13 +30,12 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading} heading={heading}
fetch={fetch} fetch={fetch}
options={options} options={options}
renderItem={(item: Event, index, compositeProps) => ( renderItem={(item: Event, index) => (
<EventListItem <EventListItem
key={item.id} key={item.id}
event={item} event={item}
document={document} document={document}
latest={index === 0} latest={index === 0}
{...compositeProps}
/> />
)} )}
renderHeading={(name) => <Heading>{name}</Heading>} renderHeading={(name) => <Heading>{name}</Heading>}

View File

@@ -1,10 +1,9 @@
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { observable, action } from "mobx"; import { observable, action, computed } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next"; import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint"; import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { Pagination } from "@shared/constants"; import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
@@ -30,11 +29,7 @@ type Props<T> = WithTranslation &
loading?: React.ReactElement; loading?: React.ReactElement;
items?: T[]; items?: T[];
className?: string; className?: string;
renderItem: ( renderItem: (item: T, index: number) => React.ReactNode;
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: { renderError?: (options: {
error: Error; error: Error;
retry: () => void; retry: () => void;
@@ -44,7 +39,9 @@ type Props<T> = WithTranslation &
}; };
@observer @observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> { class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable @observable
error?: Error; error?: Error;
@@ -150,6 +147,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
} }
}; };
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
render() { render() {
const { const {
items = [], items = [],
@@ -193,11 +195,12 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
aria-label={this.props["aria-label"]} aria-label={this.props["aria-label"]}
onEscape={onEscape} onEscape={onEscape}
className={this.props.className} className={this.props.className}
items={this.itemsToRender}
> >
{(composite: CompositeStateReturn) => { {() => {
let previousHeading = ""; let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => { return this.itemsToRender.map((item, index) => {
const children = this.props.renderItem(item, index, composite); const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date // If there is no renderHeading method passed then no date
// headings are rendered // headings are rendered

View File

@@ -5,13 +5,17 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
type Props = {
/** Whether to include a title placeholder. */
includeTitle?: boolean;
/** Delay before mounting the component. Defaults to 500ms */
delay?: number;
};
export default function PlaceholderDocument({ export default function PlaceholderDocument({
includeTitle, includeTitle,
delay, delay = 500,
}: { }: Props) {
includeTitle?: boolean;
delay?: number;
}) {
const content = ( const content = (
<> <>
<PlaceholderText delay={0.2} /> <PlaceholderText delay={0.2} />

View File

@@ -1,29 +1,37 @@
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import PluginLoader from "~/utils/PluginLoader"; import Logger from "~/utils/Logger";
import { Hook, usePluginValue } from "~/utils/PluginManager";
type Props = { type Props = {
/** The ID of the plugin to render an Icon for. */
id: string; id: string;
/** The size of the icon. */
size?: number; size?: number;
/** The color of the icon. */
color?: string; color?: string;
}; };
/**
* Renders an icon defined in a plugin (Hook.Icon).
*/
function PluginIcon({ id, color, size = 24 }: Props) { function PluginIcon({ id, color, size = 24 }: Props) {
const plugin = PluginLoader.plugins[id]; const Icon = usePluginValue(Hook.Icon, id);
const Icon = plugin?.icon;
if (Icon) { if (Icon) {
return ( return (
<Wrapper> <IconPosition>
<Icon size={size} fill={color} /> <Icon size={size} fill={color} />
</Wrapper> </IconPosition>
); );
} }
Logger.warn("No Icon registered for plugin", { id });
return null; return null;
} }
const Wrapper = styled.div` const IconPosition = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -32,4 +40,4 @@ const Wrapper = styled.div`
height: 24px; height: 24px;
`; `;
export default PluginIcon; export default observer(PluginIcon);

View File

@@ -20,15 +20,18 @@ type Props = PopoverProps & {
hide: () => void; hide: () => void;
}; };
const Popover: React.FC<Props> = ({ const Popover = (
children, {
shrink, children,
width = 380, shrink,
scrollable = true, width = 380,
flex, scrollable = true,
mobilePosition, flex,
...rest mobilePosition,
}: Props) => { ...rest
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile(); const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can // Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
return ( return (
<Dialog {...rest} modal> <Dialog {...rest} modal>
<Contents <Contents
ref={ref}
$shrink={shrink} $shrink={shrink}
$scrollable={scrollable} $scrollable={scrollable}
$flex={flex} $flex={flex}
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
return ( return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside> <StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents <Contents
ref={ref}
$shrink={shrink} $shrink={shrink}
$width={width} $width={width}
$scrollable={scrollable} $scrollable={scrollable}
@@ -123,4 +128,4 @@ const Contents = styled.div<ContentsProps>`
`}; `};
`; `;
export default Popover; export default React.forwardRef(Popover);

View File

@@ -0,0 +1,15 @@
import * as React from "react";
/**
* Context to provide a reference to the scrollable container
*/
const ScrollContext = React.createContext<
React.RefObject<HTMLDivElement> | undefined
>(undefined);
/**
* Hook to get the scrollable container reference
*/
export const useScrollContext = () => React.useContext(ScrollContext);
export default ScrollContext;

View File

@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import usePrevious from "~/hooks/usePrevious"; import usePrevious from "~/hooks/usePrevious";
import { useScrollContext } from "./ScrollContext";
type Props = { type Props = {
children: JSX.Element; children: JSX.Element;
@@ -10,6 +11,7 @@ type Props = {
export default function ScrollToTop({ children }: Props) { export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>(); const location = useLocation<{ retainScrollPosition?: boolean }>();
const previousLocationPathname = usePrevious(location.pathname); const previousLocationPathname = usePrevious(location.pathname);
const scrollContainerRef = useScrollContext();
React.useEffect(() => { React.useEffect(() => {
if ( if (
@@ -25,8 +27,9 @@ export default function ScrollToTop({ children }: Props) {
) { ) {
return; return;
} }
window.scrollTo(0, 0); (scrollContainerRef?.current || window).scrollTo(0, 0);
}, [ }, [
scrollContainerRef,
location.pathname, location.pathname,
previousLocationPathname, previousLocationPathname,
location.state?.retainScrollPosition, location.state?.retainScrollPosition,

View File

@@ -1,7 +1,10 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
@@ -34,10 +37,18 @@ function DocumentListItem(
) { ) {
const { document, highlight, context, shareId, ...rest } = props; const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return ( return (
<CompositeItem <DocumentLink
as={DocumentLink} ref={itemRef}
ref={ref}
dir={document.dir} dir={document.dir}
to={{ to={{
pathname: shareId pathname: shareId
@@ -48,6 +59,13 @@ function DocumentListItem(
}, },
}} }}
{...rest} {...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
@@ -66,7 +84,7 @@ function DocumentListItem(
/> />
} }
</Content> </Content>
</CompositeItem> </DocumentLink>
); );
} }

View File

@@ -206,7 +206,7 @@ function SearchPopover({ shareId }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults> <NoResults>{t("No results for {{query}}", { query })}</NoResults>
} }
loading={<PlaceholderList count={3} header={{ height: 20 }} />} loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index, compositeProps) => ( renderItem={(item: SearchResult, index) => (
<SearchListItem <SearchListItem
key={item.document.id} key={item.document.id}
shareId={shareId} shareId={shareId}
@@ -215,7 +215,6 @@ function SearchPopover({ shareId }: Props) {
context={item.context} context={item.context}
highlight={cachedQuery} highlight={cachedQuery}
onClick={handleSearchItemClick} onClick={handleSearchItemClick}
{...compositeProps}
/> />
)} )}
/> />

View File

@@ -53,16 +53,16 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
() => () =>
[ [
{ {
label: t("Admin"), label: t("View only"),
value: CollectionPermission.Admin, value: CollectionPermission.Read,
}, },
{ {
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
label: t("View only"), label: t("Manage"),
value: CollectionPermission.Read, value: CollectionPermission.Admin,
}, },
{ {
divider: true, divider: true,
@@ -99,18 +99,20 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
<InputMemberPermissionSelect <InputMemberPermissionSelect
style={{ margin: 0 }} style={{ margin: 0 }}
permissions={permissions} permissions={permissions}
onChange={async (permission: CollectionPermission) => { onChange={async (
if (permission) { permission: CollectionPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await collectionGroupMemberships.create({ await collectionGroupMemberships.create({
collectionId: collection.id, collectionId: collection.id,
groupId: membership.groupId, groupId: membership.groupId,
permission, permission,
}); });
} else {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} }
}} }}
disabled={!can.update} disabled={!can.update}
@@ -146,18 +148,20 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
<InputMemberPermissionSelect <InputMemberPermissionSelect
style={{ margin: 0 }} style={{ margin: 0 }}
permissions={permissions} permissions={permissions}
onChange={async (permission: CollectionPermission) => { onChange={async (
if (permission) { permission: CollectionPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({ await memberships.create({
collectionId: collection.id, collectionId: collection.id,
userId: membership.userId, userId: membership.userId,
permission, permission,
}); });
} else {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} }
}} }}
disabled={!can.update} disabled={!can.update}

View File

@@ -20,8 +20,9 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { Permission } from "~/types"; import { EmptySelectValue, Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers"; import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components"; import { Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton"; import { CopyLinkButton } from "../components/CopyLinkButton";
@@ -56,9 +57,17 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
CollectionPermission.Read CollectionPermission.Read
); );
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown( useKeyDown(
"Escape", "Escape",
(ev) => { (ev) => {
if (!visible) {
return;
}
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();
@@ -94,6 +103,19 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
} }
}, [visible]); }, [visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const handleQuery = React.useCallback( const handleQuery = React.useCallback(
(event) => { (event) => {
showPicker(); showPicker();
@@ -116,6 +138,39 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
[setPendingIds] [setPendingIds]
); );
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const inviteAction = React.useMemo( const inviteAction = React.useMemo(
() => () =>
createAction({ createAction({
@@ -229,16 +284,16 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
() => () =>
[ [
{ {
label: t("Admin"), label: t("View only"),
value: CollectionPermission.Admin, value: CollectionPermission.Read,
}, },
{ {
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
label: t("View only"), label: t("Manage"),
value: CollectionPermission.Read, value: CollectionPermission.Admin,
}, },
] as Permission[], ] as Permission[],
[t] [t]
@@ -289,8 +344,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<Wrapper> <Wrapper>
{can.update && ( {can.update && (
<SearchInput <SearchInput
ref={searchInputRef}
onChange={handleQuery} onChange={handleQuery}
onClick={showPicker} onClick={showPicker}
onKeyDown={handleKeyDown}
query={query} query={query}
back={backButton} back={backButton}
action={rightButton} action={rightButton}
@@ -298,15 +355,15 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
)} )}
{picker && ( {picker && (
<div> <Suggestions
<Suggestions ref={suggestionsRef}
query={query} query={query}
collection={collection} collection={collection}
pendingIds={pendingIds} pendingIds={pendingIds}
addPendingId={handleAddPendingId} addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId} removePendingId={handleRemovePendingId}
/> onEscape={handleEscape}
</div> />
)} )}
<div style={{ display: picker ? "none" : "block" }}> <div style={{ display: picker ? "none" : "block" }}>
@@ -322,8 +379,12 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<div style={{ marginRight: -8 }}> <div style={{ marginRight: -8 }}>
<InputSelectPermission <InputSelectPermission
style={{ margin: 0 }} style={{ margin: 0 }}
onChange={(permission) => { onChange={(
void collection.save({ permission }); value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}} }}
disabled={!can.update} disabled={!can.update}
value={collection?.permission} value={collection?.permission}

View File

@@ -51,6 +51,10 @@ const DocumentMemberListItem = ({
label: t("Can edit"), label: t("Can edit"),
value: DocumentPermission.ReadWrite, value: DocumentPermission.ReadWrite,
}, },
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
{ {
divider: true, divider: true,
label: t("Remove"), label: t("Remove"),

View File

@@ -4,7 +4,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components"; import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection"; import type Collection from "~/models/Collection";
import type Document from "~/models/Document"; import type Document from "~/models/Document";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/> />
) : usersInCollection ? ( ) : usersInCollection ? (
<ListItem <ListItem
image={ image={<CollectionSquircle collection={collection} />}
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name} title={collection.name}
subtitle={t("Everyone in the collection")} subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>} actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
@@ -136,6 +129,24 @@ const AccessTooltip = ({
); );
}; };
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) { function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores(); const { users, memberships } = useStores();
const { request } = useRequest(() => const { request } = useRequest(() =>

View File

@@ -172,11 +172,9 @@ function PublicAccess({ document, share, sharedParent }: Props) {
error={validationError} error={validationError}
defaultValue={urlId} defaultValue={urlId}
prefix={ prefix={
<DomainPrefix <DomainPrefix onClick={() => inputRef.current?.focus()}>
readOnly {env.URL.replace(/https?:\/\//, "") + "/s/"}
onClick={() => inputRef.current?.focus()} </DomainPrefix>
value={env.URL.replace(/https?:\/\//, "") + "/s/"}
/>
} }
> >
{copyButton} {copyButton}
@@ -208,9 +206,9 @@ const Wrapper = styled.div`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
const DomainPrefix = styled(NativeInput)` const DomainPrefix = styled.span`
padding: 0 2px 0 8px;
flex: 0 1 auto; flex: 0 1 auto;
padding-right: 0 !important;
cursor: text; cursor: text;
color: ${s("placeholder")}; color: ${s("placeholder")};
user-select: none; user-select: none;

View File

@@ -18,6 +18,7 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { Permission } from "~/types"; import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers"; import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -64,9 +65,17 @@ function SharePopover({
DocumentPermission.Read DocumentPermission.Read
); );
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown( useKeyDown(
"Escape", "Escape",
(ev) => { (ev) => {
if (!visible) {
return;
}
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();
@@ -104,6 +113,19 @@ function SharePopover({
} }
}, [picker]); }, [picker]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const inviteAction = React.useMemo( const inviteAction = React.useMemo(
() => () =>
createAction({ createAction({
@@ -199,16 +221,53 @@ function SharePopover({
[setPendingIds] [setPendingIds]
); );
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const permissions = React.useMemo( const permissions = React.useMemo(
() => () =>
[ [
{
label: t("View only"),
value: DocumentPermission.Read,
},
{ {
label: t("Can edit"), label: t("Can edit"),
value: DocumentPermission.ReadWrite, value: DocumentPermission.ReadWrite,
}, },
{ {
label: t("View only"), label: t("Manage"),
value: DocumentPermission.Read, value: DocumentPermission.Admin,
}, },
] as Permission[], ] as Permission[],
[t] [t]
@@ -259,8 +318,10 @@ function SharePopover({
<Wrapper> <Wrapper>
{can.manageUsers && ( {can.manageUsers && (
<SearchInput <SearchInput
ref={searchInputRef}
onChange={handleQuery} onChange={handleQuery}
onClick={showPicker} onClick={showPicker}
onKeyDown={handleKeyDown}
query={query} query={query}
back={backButton} back={backButton}
action={rightButton} action={rightButton}
@@ -268,15 +329,15 @@ function SharePopover({
)} )}
{picker && ( {picker && (
<div> <Suggestions
<Suggestions ref={suggestionsRef}
document={document} document={document}
query={query} query={query}
pendingIds={pendingIds} pendingIds={pendingIds}
addPendingId={handleAddPendingId} addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId} removePendingId={handleRemovePendingId}
/> onEscape={handleEscape}
</div> />
)} )}
<div style={{ display: picker ? "none" : "block" }}> <div style={{ display: picker ? "none" : "block" }}>
@@ -287,7 +348,7 @@ function SharePopover({
/> />
</OtherAccess> </OtherAccess>
{team.sharing && can.share && !collectionSharingDisabled && ( {team.sharing && can.share && !collectionSharingDisabled && visible && (
<> <>
{document.members.length ? <Separator /> : null} {document.members.length ? <Separator /> : null}
<PublicAccess <PublicAccess

View File

@@ -15,7 +15,9 @@ export const ListItem = styled(BaseListItem).attrs({
padding: 6px 16px; padding: 6px 16px;
border-radius: 8px; border-radius: 8px;
&: ${hover} ${InviteIcon} { &: ${hover} ${InviteIcon},
&:focus ${InviteIcon},
&:focus-within ${InviteIcon} {
opacity: 1; opacity: 1;
} }
`; `;

View File

@@ -1,6 +1,7 @@
import { AnimatePresence } from "framer-motion"; import { AnimatePresence } from "framer-motion";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input"; import Input, { NativeInput } from "../../Input";
@@ -10,17 +11,25 @@ type Props = {
query: string; query: string;
onChange: React.ChangeEventHandler; onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler; onClick: React.MouseEventHandler;
onKeyDown: React.KeyboardEventHandler;
back: React.ReactNode; back: React.ReactNode;
action: React.ReactNode; action: React.ReactNode;
}; };
export function SearchInput({ onChange, onClick, query, back, action }: Props) { export const SearchInput = React.forwardRef(function _SearchInput(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile(); const isMobile = useMobile();
const focusInput = React.useCallback( const focusInput = React.useCallback(
(event) => { (event) => {
if (event.target.closest("button")) {
return;
}
inputRef.current?.focus(); inputRef.current?.focus();
onClick(event); onClick(event);
}, },
@@ -36,6 +45,7 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
value={query} value={query}
onChange={onChange} onChange={onChange}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
autoFocus autoFocus
margin={0} margin={0}
flex flex
@@ -49,15 +59,16 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
{back} {back}
<NativeInput <NativeInput
key="input" key="input"
ref={inputRef} ref={mergeRefs([inputRef, ref])}
placeholder={`${t("Add or invite")}`} placeholder={`${t("Add or invite")}`}
value={query} value={query}
onChange={onChange} onChange={onChange}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
style={{ padding: "6px 0" }} style={{ padding: "6px 0" }}
/> />
{action} {action}
</AnimatePresence> </AnimatePresence>
</HeaderInput> </HeaderInput>
); );
} });

View File

@@ -1,4 +1,5 @@
import { isEmail } from "class-validator"; import { isEmail } from "class-validator";
import concat from "lodash/concat";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons"; import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
@@ -11,11 +12,14 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Group from "~/models/Group"; import Group from "~/models/Group";
import User from "~/models/User"; import User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar"; import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty"; import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder"; import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback"; import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles"; import { hover } from "~/styles";
@@ -40,30 +44,41 @@ type Props = {
removePendingId: (id: string) => void; removePendingId: (id: string) => void;
/** Show group suggestions. */ /** Show group suggestions. */
showGroups?: boolean; showGroups?: boolean;
/** Handles escape from suggestions list */
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
}; };
export const Suggestions = observer( export const Suggestions = observer(
({ React.forwardRef(function _Suggestions(
document, {
collection, document,
query, collection,
pendingIds, query,
addPendingId, pendingIds,
removePendingId, addPendingId,
showGroups, removePendingId,
}: Props) => { showGroups,
onEscape,
}: Props,
ref: React.Ref<HTMLDivElement>
) {
const neverRenderedList = React.useRef(false); const neverRenderedList = React.useRef(false);
const { users, groups } = useStores(); const { users, groups } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const theme = useTheme(); const theme = useTheme();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const maxHeight = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
const fetchUsersByQuery = useThrottledCallback( const fetchUsersByQuery = useThrottledCallback(
(params) => { (query: string) => {
void users.fetchPage({ query: params.query }); void users.fetchPage({ query });
if (showGroups) { if (showGroups) {
void groups.fetchPage({ query: params.query }); void groups.fetchPage({ query });
} }
}, },
250, 250,
@@ -92,7 +107,7 @@ export const Suggestions = observer(
: collection : collection
? users.notInCollection(collection.id, query) ? users.notInCollection(collection.id, query)
: users.orderedData : users.orderedData
).filter((u) => u.id !== user.id && !u.isSuspended); ).filter((u) => !u.isSuspended);
if (isEmail(query)) { if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query)); filtered.push(getSuggestionForEmail(query));
@@ -174,34 +189,65 @@ export const Suggestions = observer(
neverRenderedList.current = false; neverRenderedList.current = false;
return ( return (
<> <ScrollableContainer
{pending.map((suggestion) => ( ref={containerRef}
<PendingListItem hiddenScrollbars
{...getListItemProps(suggestion)} style={{ maxHeight }}
key={suggestion.id} >
onClick={() => removePendingId(suggestion.id)} <ArrowKeyNavigation
actions={ ref={ref}
<> onEscape={onEscape}
<InvitedIcon /> aria-label={t("Suggestions for invitation")}
<RemoveIcon /> items={concat(pending, suggestionsWithPending)}
</> >
} {() => [
/> ...pending.map((suggestion) => (
))} <PendingListItem
{pending.length > 0 && keyboardNavigation
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />} {...getListItemProps(suggestion)}
{suggestionsWithPending.map((suggestion) => ( key={suggestion.id}
<ListItem onClick={() => removePendingId(suggestion.id)}
{...getListItemProps(suggestion as User)} onKeyDown={(ev) => {
key={suggestion.id} if (ev.key === "Enter") {
onClick={() => addPendingId(suggestion.id)} ev.preventDefault();
actions={<InviteIcon />} ev.stopPropagation();
/> removePendingId(suggestion.id);
))} }
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>} }}
</> actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
addPendingId(suggestion.id);
}
}}
actions={<InviteIcon />}
/>
)),
isEmpty && (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
),
]}
</ArrowKeyNavigation>
</ScrollableContainer>
); );
} })
); );
const InvitedIcon = styled(CheckmarkIcon)` const InvitedIcon = styled(CheckmarkIcon)`
@@ -228,3 +274,8 @@ const Separator = styled.div`
border-top: 1px dashed ${s("divider")}; border-top: 1px dashed ${s("divider")};
margin: 12px 0; margin: 12px 0;
`; `;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;

View File

@@ -1,5 +1,5 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons"; import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
@@ -55,7 +55,7 @@ function AppSidebar() {
); );
return ( return (
<Sidebar ref={handleSidebarRef}> <Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
<HistoryNavigation /> <HistoryNavigation />
{dndArea && ( {dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}> <DndProvider backend={HTML5Backend} options={html5Options}>
@@ -109,7 +109,7 @@ function AppSidebar() {
{can.createDocument && ( {can.createDocument && (
<SidebarLink <SidebarLink
to={draftsPath()} to={draftsPath()}
icon={<EditIcon />} icon={<DraftsIcon />}
label={ label={
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
{t("Drafts")} {t("Drafts")}

View File

@@ -50,7 +50,8 @@ function Right({ children, border, className }: Props) {
} }
}, []); }, []);
const handleMouseDown = React.useCallback(() => { const handleMouseDown = React.useCallback((event) => {
event.preventDefault();
setResizing(true); setResizing(true);
}, []); }, []);

View File

@@ -29,7 +29,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
return ( return (
<Sidebar> <Sidebar>
{team && ( {team?.name && (
<SidebarButton <SidebarButton
title={team.name} title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />} image={<TeamLogo model={team} size={32} alt={t("Logo")} />}

View File

@@ -24,11 +24,12 @@ const ANIMATION_MS = 250;
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
hidden?: boolean;
className?: string; className?: string;
}; };
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar( const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, className }: Props, { children, hidden = false, className }: Props,
ref: React.RefObject<HTMLDivElement> ref: React.RefObject<HTMLDivElement>
) { ) {
const [isCollapsing, setCollapsing] = React.useState(false); const [isCollapsing, setCollapsing] = React.useState(false);
@@ -93,6 +94,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handleMouseDown = React.useCallback( const handleMouseDown = React.useCallback(
(event) => { (event) => {
event.preventDefault();
setOffset(event.pageX - width); setOffset(event.pageX - width);
setResizing(true); setResizing(true);
setAnimating(false); setAnimating(false);
@@ -111,10 +113,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
(ev) => { (ev) => {
if (hasPointerMoved) { if (hasPointerMoved) {
setHovering( setHovering(
ev.pageX < width && ev.pageX < width && ev.pageY < window.innerHeight && ev.pageY > 0
ev.pageX > 0 &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
); );
} }
}, },
@@ -145,8 +144,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => { React.useEffect(() => {
if (isResizing) { if (isResizing) {
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleDrag); document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag); document.addEventListener("mouseup", handleStopDrag);
} else {
document.body.style.cursor = "initial";
} }
return () => { return () => {
@@ -181,6 +183,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
<Container <Container
ref={ref} ref={ref}
style={style} style={style}
$hidden={hidden}
$isHovering={isHovering} $isHovering={isHovering}
$isAnimating={isAnimating} $isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum} $isSmallerThanMinimum={isSmallerThanMinimum}
@@ -252,6 +255,7 @@ type ContainerProps = {
$isSmallerThanMinimum: boolean; $isSmallerThanMinimum: boolean;
$isHovering: boolean; $isHovering: boolean;
$collapsed: boolean; $collapsed: boolean;
$hidden: boolean;
}; };
const hoverStyles = (props: ContainerProps) => ` const hoverStyles = (props: ContainerProps) => `
@@ -270,13 +274,14 @@ const hoverStyles = (props: ContainerProps) => `
`; `;
const Container = styled(Flex)<ContainerProps>` const Container = styled(Flex)<ContainerProps>`
opacity: ${(props) => (props.$hidden ? 0 : 1)};
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
background: ${s("sidebarBackground")}; background: ${s("sidebarBackground")};
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out, transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out,
transform 100ms ease-out, transform 150ms ease-out,
${s("backgroundTransition")} ${s("backgroundTransition")}
${(props: ContainerProps) => ${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""}; props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
@@ -319,7 +324,7 @@ const Container = styled(Flex)<ContainerProps>`
& > div { & > div {
opacity: 1; opacity: 1;
} }
} }
`}; `};
`; `;

View File

@@ -14,13 +14,14 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { newDocumentPath } from "~/utils/routeHelpers"; import { newNestedDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle"; import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -282,6 +283,8 @@ function InnerDocumentLink(
const title = const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) || (activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled"); t("Untitled");
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const isExpanded = expanded && !isDragging; const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0; const hasChildren = nodeChildren.length > 0;
@@ -331,7 +334,7 @@ function InnerDocumentLink(
starred: inStarredSection, starred: inStarredSection,
}, },
}} }}
emoji={document?.emoji || node.emoji} icon={icon && <Icon value={icon} color={color} />}
label={ label={
<EditableTitle <EditableTitle
title={title} title={title}
@@ -366,9 +369,7 @@ function InnerDocumentLink(
type={undefined} type={undefined}
aria-label={t("New nested document")} aria-label={t("New nested document")}
as={Link} as={Link}
to={newDocumentPath(document.collectionId, { to={newNestedDocumentPath(document.id)}
parentDocumentId: document.id,
})}
> >
<PlusIcon /> <PlusIcon />
</NudeButton> </NudeButton>

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers"; import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree"; import { descendants } from "~/utils/tree";
@@ -100,6 +101,8 @@ function DocumentLink(
(activeDocument?.id === node.id ? activeDocument.title : node.title) || (activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled"); t("Untitled");
const icon = node.icon ?? node.emoji;
return ( return (
<> <>
<SidebarLink <SidebarLink
@@ -111,7 +114,7 @@ function DocumentLink(
}} }}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined} expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick} onDisclosureClick={handleDisclosureClick}
emoji={node.emoji} icon={icon && <Icon value={icon} color={node.color} />}
label={title} label={title}
depth={depth} depth={depth}
exact={false} exact={false}

View File

@@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { NotificationEventType } from "@shared/types"; import { IconType, NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import UserMembership from "~/models/UserMembership"; import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
@@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) {
return null; return null;
} }
const { emoji } = document; const { icon: docIcon } = document;
const label = emoji const label =
? document.title.replace(emoji, "") determineIconType(docIcon) === IconType.Emoji
: document.titleWithDefault; ? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;

View File

@@ -2,10 +2,9 @@ import { LocationDescriptor } from "history";
import * as React from "react"; import * as React from "react";
import styled, { useTheme, css } from "styled-components"; import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import EventBoundary from "~/components/EventBoundary";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge"; import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount"; import useUnmount from "~/hooks/useUnmount";
@@ -27,7 +26,6 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void; onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>; onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode; icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode; label?: React.ReactNode;
menu?: React.ReactNode; menu?: React.ReactNode;
unreadBadge?: boolean; unreadBadge?: boolean;
@@ -52,7 +50,6 @@ function SidebarLink(
onClick, onClick,
onClickIntent, onClickIntent,
to, to,
emoji,
label, label,
active, active,
isActiveDrop, isActiveDrop,
@@ -142,7 +139,6 @@ function SidebarLink(
/> />
)} )}
{icon && <IconWrapper>{icon}</IconWrapper>} {icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label> <Label>{label}</Label>
{unreadBadge && <UnreadBadge />} {unreadBadge && <UnreadBadge />}
</Content> </Content>

View File

@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons"; import { DocumentIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
interface SidebarItem { interface SidebarItem {
@@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon(
if (document) { if (document) {
return { return {
label: document.titleWithDefault, label: document.titleWithDefault,
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon, icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
}; };
} }
} }

View File

@@ -1,11 +1,7 @@
import data, { type Emoji as TEmoji } from "@emoji-mart/data";
import { init, Data } from "emoji-mart";
import FuzzySearch from "fuzzy-search";
import capitalize from "lodash/capitalize"; import capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react"; import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { isMac } from "@shared/utils/browser"; import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem"; import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, { import SuggestionsMenu, {
Props as SuggestionsMenuProps, Props as SuggestionsMenuProps,
@@ -19,13 +15,6 @@ type Emoji = {
attrs: { markup: string; "data-name": string }; attrs: { markup: string; "data-name": string };
}; };
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<Emoji>, SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger" "renderMenuItem" | "items" | "embeds" | "trigger"
@@ -34,36 +23,26 @@ type Props = Omit<
const EmojiMenu = (props: Props) => { const EmojiMenu = (props: Props) => {
const { search = "" } = props; const { search = "" } = props;
if (!searcher) { const items = React.useMemo(
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], { () =>
caseSensitive: false, emojiSearch({ query: search })
sort: true, .map((item) => {
}); // We snake_case the shortcode for backwards compatability with gemoji to
} // avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
const items = React.useMemo(() => { return {
const n = search.toLowerCase(); name: "emoji",
title: emoji,
return sortBy(searcher.search(n), (item) => { description: capitalize(item.name.toLowerCase()),
const nlc = item.name.toLowerCase(); emoji,
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1; attrs: { markup: shortcode, "data-name": shortcode },
}) };
.map((item) => { })
// We snake_case the shortcode for backwards compatability with gemoji to .slice(0, 15),
// avoid multiple formats being written into documents. [search]
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id); );
const emoji = item.skins[0].native;
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15);
}, [search]);
return ( return (
<SuggestionsMenu <SuggestionsMenu

View File

@@ -223,6 +223,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
return ( return (
<ReactPortal> <ReactPortal>
<MobileWrapper <MobileWrapper
ref={menuRef}
style={{ style={{
bottom: `calc(100% - ${height - rect.y}px)`, bottom: `calc(100% - ${height - rect.y}px)`,
}} }}

View File

@@ -3,10 +3,10 @@ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import getMarkRange from "@shared/editor/queries/getMarkRange"; import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import isInCode from "@shared/editor/queries/isInCode"; import { isInCode } from "@shared/editor/queries/isInCode";
import isMarkActive from "@shared/editor/queries/isMarkActive"; import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table"; import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls"; import { creatingUrlPrefix } from "@shared/utils/urls";
@@ -100,10 +100,10 @@ export default function SelectionToolbar(props: Props) {
const { view, commands } = useEditor(); const { view, commands } = useEditor();
const dictionary = useDictionary(); const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null); const menuRef = React.useRef<HTMLDivElement | null>(null);
const isActive = useIsActive(view.state); const isMobile = useMobile();
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging(); const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive); const previousIsActive = usePrevious(isActive);
const isMobile = useMobile();
React.useEffect(() => { React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed // Trigger callbacks when the toolbar is opened or closed
@@ -230,7 +230,7 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) { if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary); items = getCodeMenuItems(state, readOnly, dictionary);
} else if (isTableSelection) { } else if (isTableSelection) {
items = getTableMenuItems(dictionary); items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) { } else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary); items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) { } else if (rowIndex !== undefined) {

View File

@@ -78,6 +78,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor(); const { view, commands } = useEditor();
const dictionary = useDictionary(); const dictionary = useDictionary();
const hasActivated = React.useRef(false); const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition); const [position, setPosition] = React.useState<Position>(defaultPosition);
@@ -344,6 +348,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async ( const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement> event: React.ChangeEvent<HTMLInputElement>
) => { ) => {
// Re-focus the editor as it loses focus when file picker is opened on iOS
view.focus();
const { uploadFile, onFileUploadStart, onFileUploadStop } = props; const { uploadFile, onFileUploadStart, onFileUploadStop } = props;
const files = getEventFiles(event); const files = getEventFiles(event);
const parent = findParentNode((node) => !!node)(view.state.selection); const parent = findParentNode((node) => !!node)(view.state.selection);
@@ -576,7 +583,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null; return null;
} }
const handlePointer = () => { const handlePointerMove = (ev: React.PointerEvent) => {
if (
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (selectedIndex !== index) { if (selectedIndex !== index) {
setSelectedIndex(index); setSelectedIndex(index);
} }
@@ -585,8 +608,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return ( return (
<ListItem <ListItem
key={index} key={index}
onPointerMove={handlePointer} onPointerMove={handlePointerMove}
onPointerDown={handlePointer} onPointerDown={handlePointerDown}
> >
{props.renderMenuItem(item as any, index, { {props.renderMenuItem(item as any, index, {
selected: index === selectedIndex, selected: index === selectedIndex,

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useMenuState } from "reakit"; import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu"; import { MenuButton } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu"; import ContextMenu from "~/components/ContextMenu";
@@ -19,7 +20,7 @@ type Props = {
/* /*
* Renders a dropdown menu in the floating toolbar. * Renders a dropdown menu in the floating toolbar.
*/ */
function ToolbarDropdown(props: { item: MenuItem }) { function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState(); const menu = useMenuState();
const { commands, view } = useEditor(); const { commands, view } = useEditor();
const { item } = props; const { item } = props;
@@ -101,7 +102,7 @@ function ToolbarMenu(props: Props) {
key={index} key={index}
> >
{item.children ? ( {item.children ? (
<ToolbarDropdown item={item} /> <ToolbarDropdown active={isActive && !item.label} item={item} />
) : ( ) : (
<ToolbarButton <ToolbarButton
onClick={handleClick(item)} onClick={handleClick(item)}
@@ -123,6 +124,11 @@ const FlexibleWrapper = styled.div`
overflow: hidden; overflow: hidden;
display: flex; display: flex;
gap: 6px; gap: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
align-items: baseline;
`}
`; `;
const Label = styled.span` const Label = styled.span`

View File

@@ -12,8 +12,9 @@ import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion { export default class BlockMenuExtension extends Suggestion {
get defaultOptions() { get defaultOptions() {
return { return {
openRegex: /^\/(\w+)?$/, // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/, openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
}; };
} }

View File

@@ -24,7 +24,6 @@ export default class EmojiMenuExtension extends Suggestion {
), ),
closeRegex: closeRegex:
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/, /(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
}; };
} }

View File

@@ -156,14 +156,10 @@ export default class FindAndReplaceExtension extends Extension {
} }
private get findRegExp() { private get findRegExp() {
try { return RegExp(
return RegExp( this.searchTerm.replace(/\\+$/, ""),
this.searchTerm.replace(/\\+$/, ""), !this.options.caseSensitive ? "gui" : "gu"
!this.options.caseSensitive ? "gui" : "gu" );
);
} catch (err) {
return RegExp("");
}
} }
private goToMatch(direction: number): Command { private goToMatch(direction: number): Command {
@@ -250,15 +246,19 @@ export default class FindAndReplaceExtension extends Extension {
const search = this.findRegExp; const search = this.findRegExp;
let m; let m;
while ((m = search.exec(text))) { try {
if (m[0] === "") { while ((m = search.exec(text))) {
break; if (m[0] === "") {
} break;
}
this.results.push({ this.results.push({
from: pos + m.index, from: pos + m.index,
to: pos + m.index + m[0].length, to: pos + m.index + m[0].length,
}); });
}
} catch (e) {
// Invalid RegExp
} }
}); });
} }

View File

@@ -8,7 +8,7 @@ import {
Command, Command,
} from "prosemirror-state"; } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import isInCode from "@shared/editor/queries/isInCode"; import { isInCode } from "@shared/editor/queries/isInCode";
export default class Keys extends Extension { export default class Keys extends Extension {
get name() { get name() {

View File

@@ -10,7 +10,6 @@ export default class MentionMenuExtension extends Suggestion {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u, openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u, closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
}; };
} }

View File

@@ -5,8 +5,10 @@ import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown"; import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import isInCode from "@shared/editor/queries/isInCode"; import { isInCode } from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList"; import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls"; import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores"; import stores from "~/stores";
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
if (document) { if (document) {
const { hash } = new URL(text); const { hash } = new URL(text);
const title = `${ const hasEmoji =
document.emoji ? document.emoji + " " : "" determineIconType(document.icon) === IconType.Emoji;
}${document.titleWithDefault}`;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title); insertLink(`${document.path}${hash}`, title);
} }
}) })

View File

@@ -4,8 +4,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→"); const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—"); const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/1\/2$/, "½"); const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/3\/4$/, "¾"); const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️"); const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️"); const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️"); const trademarked = new InputRule(/\(tm\)$/, "™️");

View File

@@ -2,10 +2,9 @@ import { action, observable } from "mobx";
import { InputRule } from "prosemirror-inputrules"; import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model"; import { NodeType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions"; import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import isInCode from "@shared/editor/queries/isInCode"; import { isInCode } from "@shared/editor/queries/isInCode";
export default class Suggestion extends Extension { export default class Suggestion extends Extension {
state: { state: {
@@ -50,8 +49,7 @@ export default class Suggestion extends Extension {
match && match &&
(parent.type.name === "paragraph" || (parent.type.name === "paragraph" ||
parent.type.name === "heading") && parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) && (!isInCode(state) || this.options.enabledInCode)
(!isInTable(state) || this.options.enabledInTable)
) { ) {
this.state.open = true; this.state.open = true;
this.state.query = match[1]; this.state.query = match[1];

View File

@@ -402,8 +402,8 @@ export class Editor extends React.PureComponent<
schema: this.schema, schema: this.schema,
doc, doc,
plugins: [ plugins: [
...this.plugins,
...this.keymaps, ...this.keymaps,
...this.plugins,
dropCursor({ dropCursor({
color: this.props.theme.cursor, color: this.props.theme.cursor,
}), }),
@@ -618,6 +618,13 @@ export class Editor extends React.PureComponent<
*/ */
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc); public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
/**
* Return the images in the current editor.
*
* @returns A list of images in the document
*/
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
/** /**
* Return the tasks/checkmarks in the current editor. * Return the tasks/checkmarks in the current editor.
* *
@@ -633,29 +640,63 @@ export class Editor extends React.PureComponent<
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc); public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
/** /**
* Remove a specific comment mark from the document. * Remove all marks related to a specific comment from the document.
* *
* @param commentId The id of the comment to remove * @param commentId The id of the comment to remove
*/ */
public removeComment = (commentId: string) => { public removeComment = (commentId: string) => {
const { state, dispatch } = this.view; const { state, dispatch } = this.view;
let found = false; const tr = state.tr;
state.doc.descendants((node, pos) => { state.doc.descendants((node, pos) => {
if (!node.isInline || found) { if (!node.isInline) {
return; return;
} }
const mark = node.marks.find( const mark = node.marks.find(
(mark) => (m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
); );
if (mark) { if (mark) {
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark)); tr.removeMark(pos, pos + node.nodeSize, mark);
found = true;
} }
}); });
dispatch(tr);
};
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
const { state, dispatch } = this.view;
const tr = state.tr;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
const from = pos;
const to = pos + node.nodeSize;
const newMark = state.schema.marks.comment.create({
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
}
});
dispatch(tr);
}; };
/** /**
@@ -801,6 +842,7 @@ const EditorContainer = styled(Styles)<{
css` css`
#comment-${props.focusedCommentId} { #comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)}; background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
} }
`} `}

View File

@@ -1,7 +1,7 @@
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import isNodeActive from "@shared/editor/queries/isNodeActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -19,13 +19,15 @@ import {
Heading3Icon, Heading3Icon,
} from "outline-icons"; } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import * as React from "react"; import * as React from "react";
import isInCode from "@shared/editor/queries/isInCode"; import Highlight from "@shared/editor/marks/Highlight";
import isInList from "@shared/editor/queries/isInList"; import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import isMarkActive from "@shared/editor/queries/isMarkActive"; import { isInCode } from "@shared/editor/queries/isInCode";
import isNodeActive from "@shared/editor/queries/isNodeActive"; 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 { MenuItem } from "@shared/editor/types";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems( export default function formattingMenuItems(
@@ -35,11 +37,15 @@ export default function formattingMenuItems(
dictionary: Dictionary dictionary: Dictionary
): MenuItem[] { ): MenuItem[] {
const { schema } = state; const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const isCode = isInCode(state); const isCode = isInCode(state);
const isCodeBlock = isInCode(state, { onlyBlock: true }); const isCodeBlock = isInCode(state, { onlyBlock: true });
const allowBlocks = !isTable && !isList; const isEmpty = state.selection.empty;
const highlight = getMarksBetween(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
return [ return [
{ {
@@ -47,50 +53,60 @@ export default function formattingMenuItems(
tooltip: dictionary.placeholder, tooltip: dictionary.placeholder,
icon: <InputIcon />, icon: <InputIcon />,
active: isMarkActive(schema.marks.placeholder), active: isMarkActive(schema.marks.placeholder),
visible: isTemplate, visible: isTemplate && (!isMobile || !isEmpty),
}, },
{ {
name: "separator", name: "separator",
visible: isTemplate, visible: isTemplate && (!isMobile || !isEmpty),
}, },
{ {
name: "strong", name: "strong",
tooltip: dictionary.strong, tooltip: dictionary.strong,
icon: <BoldIcon />, icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong), active: isMarkActive(schema.marks.strong),
visible: !isCode, visible: !isCode && (!isMobile || !isEmpty),
}, },
{ {
name: "em", name: "em",
tooltip: dictionary.em, tooltip: dictionary.em,
icon: <ItalicIcon />, icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em), active: isMarkActive(schema.marks.em),
visible: !isCode, visible: !isCode && (!isMobile || !isEmpty),
}, },
{ {
name: "strikethrough", name: "strikethrough",
tooltip: dictionary.strikethrough, tooltip: dictionary.strikethrough,
icon: <StrikethroughIcon />, icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough), active: isMarkActive(schema.marks.strikethrough),
visible: !isCode, visible: !isCode && (!isMobile || !isEmpty),
}, },
{ {
name: "highlight",
tooltip: dictionary.mark, tooltip: dictionary.mark,
icon: <HighlightIcon />, icon: highlight ? (
active: isMarkActive(schema.marks.highlight), <CircleIcon color={highlight.mark.attrs.color} />
visible: !isTemplate && !isCode, ) : (
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
children: Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
}, },
{ {
name: "code_inline", name: "code_inline",
tooltip: dictionary.codeInline, tooltip: dictionary.codeInline,
icon: <CodeIcon />, icon: <CodeIcon />,
active: isMarkActive(schema.marks.code_inline), active: isMarkActive(schema.marks.code_inline),
visible: !isCodeBlock, visible: !isCodeBlock && (!isMobile || !isEmpty),
}, },
{ {
name: "separator", name: "separator",
visible: allowBlocks && !isCode, visible: !isCodeBlock,
}, },
{ {
name: "heading", name: "heading",
@@ -98,7 +114,7 @@ export default function formattingMenuItems(
icon: <Heading1Icon />, icon: <Heading1Icon />,
active: isNodeActive(schema.nodes.heading, { level: 1 }), active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 }, attrs: { level: 1 },
visible: allowBlocks && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "heading", name: "heading",
@@ -106,7 +122,7 @@ export default function formattingMenuItems(
icon: <Heading2Icon />, icon: <Heading2Icon />,
active: isNodeActive(schema.nodes.heading, { level: 2 }), active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 }, attrs: { level: 2 },
visible: allowBlocks && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "heading", name: "heading",
@@ -114,7 +130,7 @@ export default function formattingMenuItems(
icon: <Heading3Icon />, icon: <Heading3Icon />,
active: isNodeActive(schema.nodes.heading, { level: 3 }), active: isNodeActive(schema.nodes.heading, { level: 3 }),
attrs: { level: 3 }, attrs: { level: 3 },
visible: allowBlocks && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "blockquote", name: "blockquote",
@@ -122,11 +138,11 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />, icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote), active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 }, attrs: { level: 2 },
visible: allowBlocks && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "separator", name: "separator",
visible: (allowBlocks || isList) && !isCode, visible: !isCodeBlock,
}, },
{ {
name: "checkbox_list", name: "checkbox_list",
@@ -134,37 +150,51 @@ export default function formattingMenuItems(
icon: <TodoListIcon />, icon: <TodoListIcon />,
keywords: "checklist checkbox task", keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list), active: isNodeActive(schema.nodes.checkbox_list),
visible: (allowBlocks || isList) && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "bullet_list", name: "bullet_list",
tooltip: dictionary.bulletList, tooltip: dictionary.bulletList,
icon: <BulletedListIcon />, icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list), active: isNodeActive(schema.nodes.bullet_list),
visible: (allowBlocks || isList) && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "ordered_list", name: "ordered_list",
tooltip: dictionary.orderedList, tooltip: dictionary.orderedList,
icon: <OrderedListIcon />, icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list), active: isNodeActive(schema.nodes.ordered_list),
visible: (allowBlocks || isList) && !isCode, visible: !isCodeBlock && (!isMobile || isEmpty),
}, },
{ {
name: "outdentList", name: "outdentList",
tooltip: dictionary.outdent, tooltip: dictionary.outdent,
icon: <OutdentIcon />, icon: <OutdentIcon />,
visible: isList && isMobile, visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
}, },
{ {
name: "indentList", name: "indentList",
tooltip: dictionary.indent, tooltip: dictionary.indent,
icon: <IndentIcon />, icon: <IndentIcon />,
visible: isList && isMobile, visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
}, },
{ {
name: "separator", name: "separator",
visible: !isCode, visible: !isCodeBlock,
}, },
{ {
name: "link", name: "link",
@@ -172,24 +202,25 @@ export default function formattingMenuItems(
icon: <LinkIcon />, icon: <LinkIcon />,
active: isMarkActive(schema.marks.link), active: isMarkActive(schema.marks.link),
attrs: { href: "" }, attrs: { href: "" },
visible: !isCode, visible: !isCodeBlock && (!isMobile || !isEmpty),
}, },
{ {
name: "comment", name: "comment",
tooltip: dictionary.comment, tooltip: dictionary.comment,
icon: <CommentIcon />, icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined, label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment), active: isMarkActive(schema.marks.comment, { resolved: false }),
visible: !isMobile || !isEmpty,
}, },
{ {
name: "separator", name: "separator",
visible: isCode && !isCodeBlock, visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
}, },
{ {
name: "copyToClipboard", name: "copyToClipboard",
icon: <CopyIcon />, icon: <CopyIcon />,
tooltip: dictionary.copy, tooltip: dictionary.copy,
visible: isCode && !isCodeBlock, visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
}, },
]; ];
} }

View File

@@ -9,7 +9,7 @@ import {
} from "outline-icons"; } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import isNodeActive from "@shared/editor/queries/isNodeActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -1,7 +1,7 @@
import { CommentIcon } from "outline-icons"; import { CommentIcon } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import isMarkActive from "@shared/editor/queries/isMarkActive"; import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -1,10 +1,32 @@
import { TrashIcon } from "outline-icons"; import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { MenuItem } from "@shared/editor/types"; import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem, TableLayout } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(dictionary: Dictionary): MenuItem[] { export default function tableMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
})(state);
return [ return [
{
name: "setTableAttr",
tooltip: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "separator",
},
{ {
name: "deleteTable", name: "deleteTable",
tooltip: dictionary.deleteTable, tooltip: dictionary.deleteTable,

View File

@@ -7,11 +7,12 @@ import {
InsertRightIcon, InsertRightIcon,
ArrowIcon, ArrowIcon,
MoreIcon, MoreIcon,
TableHeaderColumnIcon,
} from "outline-icons"; } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import isNodeActive from "@shared/editor/queries/isNodeActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
@@ -78,15 +79,23 @@ export default function tableColMenuItems(
{ {
icon: <MoreIcon />, icon: <MoreIcon />,
children: [ children: [
{
name: "toggleHeaderColumn",
label: dictionary.toggleHeader,
icon: <TableHeaderColumnIcon />,
visible: index === 0,
},
{ {
name: rtl ? "addColumnAfter" : "addColumnBefore", name: rtl ? "addColumnAfter" : "addColumnBefore",
label: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore, label: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore,
icon: <InsertLeftIcon />, icon: <InsertLeftIcon />,
attrs: { index },
}, },
{ {
name: rtl ? "addColumnBefore" : "addColumnAfter", name: rtl ? "addColumnBefore" : "addColumnAfter",
label: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter, label: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter,
icon: <InsertRightIcon />, icon: <InsertRightIcon />,
attrs: { index },
}, },
{ {
name: "deleteColumn", name: "deleteColumn",

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