Compare commits

...

188 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
423 changed files with 15022 additions and 9961 deletions

View File

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

View File

@@ -126,7 +126,7 @@ jobs:
docker buildx install
docker context create docker-multiarch
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 use docker-multiarch
- run:
@@ -142,9 +142,9 @@ jobs:
name: Build and push Docker image
command: |
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
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
workflows:

View File

@@ -127,6 +127,26 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
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
# Base64 encoded private key and certificate for HTTPS termination. This is only

View File

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

46
.github/workflows/publish-docker.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: CI/CD Pipeline
on: [push]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Setup Node.js environment
# uses: actions/setup-node@v4
# with:
# node-version: "18"
# - name: Install Dependencies
# run: yarn install
# - name: Build Project
# run: yarn build
- name: Docker login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
push: true
tags: |
yuuza/outline-base:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-from: type=registry,ref=yuuza/outline-base:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-to: type=inline
- run: docker tag yuuza/outline-base:${{ github.ref_name == 'main' && 'latest' || github.ref_name }} outlinewiki/outline-base:latest
- name: Build runner image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
yuuza/outline:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-from: type=registry,ref=yuuza/outline:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-to: type=inline

View File

@@ -7,7 +7,8 @@
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@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"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
@@ -22,7 +23,8 @@
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](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"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -37,7 +39,8 @@
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@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"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -50,7 +53,8 @@
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](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"],
"testEnvironment": "jsdom",

View File

@@ -5,9 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:20-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
FROM node:20-slim AS runner
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/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline

View File

@@ -1,19 +1,26 @@
ARG APP_PATH=/opt/outline
FROM node:20-alpine AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
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 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
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",
"required": true
},
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"ENABLE_UPDATES": {
"value": "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,
PlusIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
TrashIcon,
UnstarredIcon,
@@ -11,7 +12,6 @@ import {
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -21,9 +21,8 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import history from "~/utils/history";
import { searchPath } from "~/utils/routeHelpers";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
@@ -111,24 +110,16 @@ export const editCollectionPermissions = createAction({
return;
}
if (FeatureFlags.isEnabled(Feature.newCollectionSharing)) {
stores.dialogs.openModal({
title: t("Share this collection"),
content: (
<SharePopover
collection={collection}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
});
} else {
stores.dialogs.openModal({
title: t("Collection permissions"),
fullscreen: true,
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
}
stores.dialogs.openModal({
title: t("Share this collection"),
content: (
<SharePopover
collection={collection}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
});
},
});
@@ -137,7 +128,9 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: CollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => !!activeCollectionId,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
perform: ({ 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 = [
openCollection,
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,
homePath,
newDocumentPath,
newNestedDocumentPath,
searchPath,
documentPath,
urlify,
@@ -140,15 +141,10 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, {
parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
perform: ({ activeDocumentId, inStarredSection }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
starred: inStarredSection,
}),
});
export const starDocument = createAction({
@@ -676,22 +672,22 @@ export const importDocument = createAction({
},
});
export const createTemplate = createAction({
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!!document?.isActive
stores.policies.abilities(activeCollectionId).update
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -700,7 +696,6 @@ export const createTemplate = createAction({
}
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
@@ -988,7 +983,7 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplate,
createTemplateFromDocument,
deleteDocument,
importDocument,
downloadDocument,

View File

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

View File

@@ -2,13 +2,14 @@
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env";
type Props = {
children?: React.ReactNode;
};
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3
React.useEffect(() => {
@@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => {
React.useEffect(() => {
const measurementIds = [];
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(env.analytics.settings?.measurementId));
}
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
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) {
return;
}
@@ -75,6 +80,32 @@ const Analytics: React.FC = ({ children }: Props) => {
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}</>;
};

View File

@@ -1,54 +1,50 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: (composite: CompositeStateReturn) => React.ReactNode;
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
{ children, onEscape, items, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
(ev: React.KeyboardEvent<HTMLDivElement>) => {
if (onEscape) {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev);
}
if (
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);
}
}
},
[composite.currentId, composite.items, onEscape]
[onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
>
{children(composite)}
</Composite>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
</RovingTabIndexProvider>
);
}

View File

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

View File

@@ -11,19 +11,22 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
}
@@ -37,7 +40,16 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
handleSubmit: formHandleSubmit,
@@ -53,7 +65,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: collection?.color ?? randomElement(colorPalette),
color: iconColor,
},
});
@@ -70,20 +82,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection"
);
}
}, [values.name, collection]);
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => {
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("color", color);
setValue("icon", icon);
setValue("color", color);
},
[setFocus, setValue, values.icon]
);
@@ -105,13 +117,16 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<StyledIconPicker
onOpen={setHasOpenedIconPicker}
onChange={handleIconPickerChange}
initial={values.name[0]}
color={values.color}
icon={values.icon}
/>
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
}
autoComplete="off"
autoFocus
@@ -128,8 +143,10 @@ export const CollectionForm = observer(function CollectionForm_({
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(value: CollectionPermission) => {
field.onChange(value);
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
note={t(
"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 { CheckmarkIcon } from "outline-icons";
import { ellipsis } from "polished";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
@@ -160,6 +161,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${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)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
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({
isContextMenu: true,
});
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading"
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected}
dangerous={item.dangerous}
key={index}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={<Title title={item.title} icon={item.icon} />}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
);

View File

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

View File

@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin,
});
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = {
transform: CSS.Transform.toString(transform),
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" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
{document.icon ? (
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
/>
) : (
<Squircle color={collection?.color}>
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
{hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault}
</Heading>
<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)`
flex-shrink: 0;
`;

View File

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

View File

@@ -1,17 +1,21 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
@@ -20,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
document: Document;
@@ -32,7 +35,7 @@ type Props = {
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
} & CompositeStateReturn;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -49,6 +52,15 @@ function DocumentListItem(
const user = useCurrentUser();
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 {
document,
showParentDocuments,
@@ -68,9 +80,8 @@ function DocumentListItem(
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
@@ -81,13 +92,21 @@ function DocumentListItem(
title: document.titleWithDefault,
},
}}
onContextMenu={(e) => {
if (menuOpen) {
return;
}
e.preventDefault();
handleMenuOpen();
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
{document.icon && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}
@@ -137,12 +156,13 @@ function DocumentListItem(
<DocumentMenu
document={document}
showPin={showPin}
visible={menuOpen}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
</CompositeItem>
</DocumentLink>
);
}
@@ -263,6 +283,8 @@ const ResultContext = styled(Highlight)`
font-size: 15px;
margin-top: -0.25em;
margin-bottom: 0.25em;
max-height: 90px;
overflow: hidden;
`;
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 { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
@@ -32,7 +28,7 @@ type Props = {
document: Document;
event: Event;
latest?: boolean;
} & CompositeStateReturn;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
@@ -176,11 +172,7 @@ const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
return <ListItem to={to} ref={ref} {...rest} />;
});
const Subtitle = styled.span`
@@ -240,8 +232,4 @@ const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem);

View File

@@ -1,7 +1,9 @@
import * as React from "react";
import { richExtensions } from "@shared/editor/nodes";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import ErrorBoundary from "../ErrorBoundary";
import {
Preview,
Title,
@@ -21,20 +23,23 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
<Preview to={url}>
<Card ref={ref}>
<CardContent>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
defaultValue={summary}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
</Flex>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
extensions={richExtensions}
defaultValue={summary}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
</Flex>
</ErrorBoundary>
</CardContent>
</Card>
</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 { getLuminance } from "polished";
import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
/** The collection to show an icon for */
@@ -16,6 +16,7 @@ type Props = {
size?: number;
/** The color of the icon, defaults to the collection color */
color?: string;
className?: string;
};
function ResolvedCollectionIcon({
@@ -23,35 +24,41 @@ function ResolvedCollectionIcon({
color: inputColor,
expanded,
size,
className,
}: Props) {
const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color);
if (!collection.icon || collection.icon === "collection") {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const collectionColor = collection.color ?? colorPalette[0];
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
? getLuminance(collectionColor) > 0.09
? collectionColor
: "currentColor"
: collectionColor);
if (collection.icon && collection.icon !== "collection") {
try {
const Component = IconLibrary.getComponent(collection.icon);
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
return (
<CollectionIcon
color={color}
expanded={expanded}
size={size}
className={className}
/>
);
}
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);

View File

@@ -1,11 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
emoji: string;
/** The size of the emoji, 24px is default to match standard icons */
size?: number;
className?: string;
};
/**
@@ -15,19 +17,28 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
{emoji}
<SVG size={size} emoji={emoji} />
</Span>
);
}
const Span = styled.span<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
font-family: ${s("fontFamilyEmoji")};
display: inline-block;
width: ${(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";
type Props = {
/** A string representing where the search started, for tracking. */
source: string;
/** Placeholder text for the input. */
placeholder?: string;
/** Label for the input. */
label?: string;
/** Whether the label should be hidden. */
labelHidden?: boolean;
/** An optional ID of a collection to search within. */
collectionId?: string;
/** The current value of the input. */
value?: string;
/** Event handler for when the input value changes. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
/** Event handler for when a key is pressed. */
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};

View File

@@ -50,6 +50,11 @@ export type Props = {
note?: React.ReactNode;
onChange?: (value: string | null) => void;
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 {
@@ -79,6 +84,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note,
icon,
nude,
skipBodyScroll,
...rest
} = props;
@@ -91,7 +97,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: true,
preventBodyScroll: skipBodyScroll ? false : true,
disabled,
});
@@ -220,7 +226,12 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";

View File

@@ -23,15 +23,16 @@ function InputSelectPermission(
ref={ref}
label={t("Permission")}
options={[
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
divider: true,
label: t("No access"),
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 * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -12,6 +18,8 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */
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 */
exact?: boolean;
/** The title of the list item */
@@ -24,15 +32,50 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
border?: boolean;
/** Whether to display the list item in a compact style */
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
};
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 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) => (
<>
{image && <Image>{image}</Image>}
@@ -59,13 +102,30 @@ const ListItem = (
if (to) {
return (
<Wrapper
ref={ref}
ref={itemRef}
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
}}
{...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}
to={to}
>
@@ -75,7 +135,26 @@ const ListItem = (
}
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)}
</Wrapper>
);
@@ -84,6 +163,7 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
$hover?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
to?: LocationDescriptor;
}>`
@@ -100,9 +180,15 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
&:hover {
&:focus-visible {
outline: none;
}
&:${hover},
&:focus,
&:focus-within {
background: ${(props) =>
props.onClick ? props.theme.secondaryBackground : "inherit"};
props.$hover ? props.theme.secondaryBackground : "inherit"};
}
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`
animation: ${fadeAndScaleIn} 250ms ease;
margin: auto auto;
margin: 25vh auto auto auto;
width: 75vw;
min-width: 350px;
max-width: 450px;
@@ -282,7 +282,7 @@ const Small = styled.div`
`;
const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px;
padding: 12px 24px;
`;
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}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
renderItem={(item: Document, _index) => (
<DocumentListItem
key={item.id}
document={item}
@@ -52,7 +52,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
/>
)}
{...rest}

View File

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

View File

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

View File

@@ -5,13 +5,17 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
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({
includeTitle,
delay,
}: {
includeTitle?: boolean;
delay?: number;
}) {
delay = 500,
}: Props) {
const content = (
<>
<PlaceholderText delay={0.2} />

View File

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

View File

@@ -20,15 +20,18 @@ type Props = PopoverProps & {
hide: () => void;
};
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props) => {
const Popover = (
{
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
return (
<Dialog {...rest} modal>
<Contents
ref={ref}
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents
ref={ref}
$shrink={shrink}
$width={width}
$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 { useLocation } from "react-router-dom";
import usePrevious from "~/hooks/usePrevious";
import { useScrollContext } from "./ScrollContext";
type Props = {
children: JSX.Element;
@@ -10,6 +11,7 @@ type Props = {
export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>();
const previousLocationPathname = usePrevious(location.pathname);
const scrollContainerRef = useScrollContext();
React.useEffect(() => {
if (
@@ -25,8 +27,9 @@ export default function ScrollToTop({ children }: Props) {
) {
return;
}
window.scrollTo(0, 0);
(scrollContainerRef?.current || window).scrollTo(0, 0);
}, [
scrollContainerRef,
location.pathname,
previousLocationPathname,
location.state?.retainScrollPosition,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
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 Document from "~/models/Document";
import Flex from "~/components/Flex";
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/>
) : usersInCollection ? (
<ListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
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) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -131,7 +131,16 @@ const CollectionLink: React.FC<Props> = ({
return (
<>
<Relative ref={drop}>
<Relative
ref={drop}
onContextMenu={(e) => {
if (menuOpen || isEditing) {
return;
}
e.preventDefault();
handleMenuOpen();
}}
>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={{
@@ -178,6 +187,7 @@ const CollectionLink: React.FC<Props> = ({
onRename={() =>
editableTitleRef.current?.setIsEditing(true)
}
visible={menuOpen}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>

View File

@@ -14,13 +14,14 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -282,6 +283,8 @@ function InnerDocumentLink(
const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
@@ -310,6 +313,13 @@ function InnerDocumentLink(
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
onContextMenu={(e) => {
if (menuOpen || isEditing) {
return;
}
e.preventDefault();
handleMenuOpen();
}}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
@@ -324,7 +334,7 @@ function InnerDocumentLink(
starred: inStarredSection,
},
}}
emoji={document?.emoji || node.emoji}
icon={icon && <Icon value={icon} color={color} />}
label={
<EditableTitle
title={title}
@@ -359,9 +369,7 @@ function InnerDocumentLink(
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
to={newNestedDocumentPath(document.id)}
>
<PlusIcon />
</NudeButton>
@@ -372,6 +380,7 @@ function InnerDocumentLink(
onRename={() =>
editableTitleRef.current?.setIsEditing(true)
}
visible={menuOpen}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>

View File

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

View File

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

View File

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

View File

@@ -115,7 +115,18 @@ function StarredLink({ star }: Props) {
return (
<>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<Draggable
key={star.id}
ref={draggableRef}
$isDragging={isDragging}
onContextMenu={(e) => {
if (menuOpen) {
return;
}
e.preventDefault();
handleMenuOpen();
}}
>
<SidebarLink
depth={0}
to={{
@@ -136,6 +147,7 @@ function StarredLink({ star }: Props) {
<Fade>
<DocumentMenu
document={document}
visible={menuOpen}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>

View File

@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
interface SidebarItem {
@@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon(
if (document) {
return {
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 sortBy from "lodash/sortBy";
import React from "react";
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 SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -19,13 +15,6 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
@@ -34,36 +23,26 @@ type Props = Omit<
const EmojiMenu = (props: Props) => {
const { search = "" } = props;
if (!searcher) {
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
caseSensitive: false,
sort: true,
});
}
const items = React.useMemo(
() =>
emojiSearch({ query: search })
.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(() => {
const n = search.toLowerCase();
return sortBy(searcher.search(n), (item) => {
const nlc = item.name.toLowerCase();
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
})
.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.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 {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
[search]
);
return (
<SuggestionsMenu

View File

@@ -223,6 +223,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
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 createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import getMarkRange from "@shared/editor/queries/getMarkRange";
import isInCode from "@shared/editor/queries/isInCode";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls";
@@ -100,10 +100,10 @@ export default function SelectionToolbar(props: Props) {
const { view, commands } = useEditor();
const dictionary = useDictionary();
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 previousIsActive = usePrevious(isActive);
const isMobile = useMobile();
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
@@ -230,7 +230,7 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
} else if (isTableSelection) {
items = getTableMenuItems(dictionary);
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {

View File

@@ -78,6 +78,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const dictionary = useDictionary();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
@@ -344,6 +348,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async (
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 files = getEventFiles(event);
const parent = findParentNode((node) => !!node)(view.state.selection);
@@ -576,7 +583,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
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) {
setSelectedIndex(index);
}
@@ -585,8 +608,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return (
<ListItem
key={index}
onPointerMove={handlePointer}
onPointerDown={handlePointer}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,

View File

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

View File

@@ -12,8 +12,9 @@ import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion {
get defaultOptions() {
return {
openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\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,
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:
/(?:^|\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() {
try {
return RegExp(
this.searchTerm.replace(/\\+$/, ""),
!this.options.caseSensitive ? "gui" : "gu"
);
} catch (err) {
return RegExp("");
}
return RegExp(
this.searchTerm.replace(/\\+$/, ""),
!this.options.caseSensitive ? "gui" : "gu"
);
}
private goToMatch(direction: number): Command {
@@ -250,15 +246,19 @@ export default class FindAndReplaceExtension extends Extension {
const search = this.findRegExp;
let m;
while ((m = search.exec(text))) {
if (m[0] === "") {
break;
}
try {
while ((m = search.exec(text))) {
if (m[0] === "") {
break;
}
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
} catch (e) {
// Invalid RegExp
}
});
}

View File

@@ -8,7 +8,7 @@ import {
Command,
} from "prosemirror-state";
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 {
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
openRegex: /(?:^|\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 isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import { isInCode } from "@shared/editor/queries/isInCode";
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 { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
if (document) {
const { hash } = new URL(text);
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title);
}
})

View File

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

View File

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

View File

@@ -402,8 +402,8 @@ export class Editor extends React.PureComponent<
schema: this.schema,
doc,
plugins: [
...this.plugins,
...this.keymaps,
...this.plugins,
dropCursor({
color: this.props.theme.cursor,
}),
@@ -618,6 +618,13 @@ export class Editor extends React.PureComponent<
*/
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.
*
@@ -633,29 +640,63 @@ export class Editor extends React.PureComponent<
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
*/
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
let found = false;
const tr = state.tr;
state.doc.descendants((node, pos) => {
if (!node.isInline || found) {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(mark) =>
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
found = true;
tr.removeMark(pos, pos + node.nodeSize, mark);
}
});
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`
#comment-${props.focusedCommentId} {
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 { EditorState } from "prosemirror-state";
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 { Dictionary } from "~/hooks/useDictionary";

View File

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

View File

@@ -9,7 +9,7 @@ import {
} from "outline-icons";
import { EditorState } from "prosemirror-state";
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 { Dictionary } from "~/hooks/useDictionary";

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