Compare commits

...

47 Commits

Author SHA1 Message Date
Tom Moor 6863fe3698 Merge branch 'main' into codegen-bot/implement-mention-model-tracking-9268 2025-12-12 19:47:16 -05:00
Apoorv Mishra 948e557bdd Utilize GitHub integration to fetch information about public issues/PRs (#10827)
* fix: use github APIs to unfurl public gh issues/prs

* fix: revert

* fix: multiple gh accounts

* fix: use replacements
2025-12-12 19:05:14 -05:00
Copilot d5dbf286cc Add missing database indexes for hooks.unfurl endpoint (#10870)
* Initial plan

* Add database indexes to improve hooks.unfurl performance

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Verify migrations and query plans for new indexes

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback: improve migration rollback order and add comments

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Change index column order to teamId first as requested

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Update .env.test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-12 19:02:20 -05:00
Tom Moor 27f4ba7062 perf: Reorder policy checks (#10874)
* Reorder document policy checks

* Reorder collection policy checks
2025-12-12 18:58:23 -05:00
Tom Moor c3ffcd8d38 Update documents.ts (#10873) 2025-12-12 18:46:08 -05:00
Copilot b68997f78a Fix MentionsProcessor tests: implement parseMentions and resolve circular dependency (#10866)
* Initial plan

* Fix circular dependency in Mention model by using lazy import for can function

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Implement parseMentions method and fix test markdown format for mentions

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2025-12-12 07:20:41 -05:00
Tom Moor e19b23c22f perf: Remove serialization of tasks for public API responses (#10864) 2025-12-11 22:40:26 -05:00
Tom Moor 2e471f88be perf: Policy evaluation (#10863)
* perf: Several O(n) improvements in policy calculation

* perf: Simplify to single loop in can method

* perf: refactor ability lookups
2025-12-11 20:51:22 -05:00
Tom Moor 8cb07889ce perf: Further break up popularity batch querying (#10862) 2025-12-11 18:58:18 -05:00
Apoorv Mishra f8a79f9e79 Bring back notice menu (#10860)
* fix: notice menu regression

* fix: local var
2025-12-11 10:51:19 -05:00
Tom Moor 1e894aabdf fix: Query not forwarded on internal links (#10854)
closes #10853
2025-12-11 02:17:12 +00:00
Tom Moor 6cd2346d46 fix: Media editor crashes page (#10852)
closes #10851
2025-12-11 00:43:19 +00:00
Tom Moor 35510fb4be fix: Ignore missing .env in bootstrap.ts (#10848) 2025-12-10 17:14:39 -05:00
Tom Moor ac460318fd fix: Quick fix for selection behavior (#10845) 2025-12-10 14:24:55 +00:00
Tom Moor 6b3900cfc5 fix: Code language picker missing (#10844) 2025-12-10 08:35:35 -05:00
Tom Moor 2543d6d56c fix: Code words should wrap on mobile (#10842) 2025-12-09 22:05:47 -05:00
Apoorv Mishra 5140d2434e fix: apply react/rules-of-hooks (#10840) 2025-12-09 18:53:49 -05:00
dependabot[bot] 108e14338b chore(deps): bump the aws group with 5 updates (#10830)
Bumps the aws group with 5 updates:

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


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

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

Updates `@aws-sdk/s3-presigned-post` from 3.927.0 to 3.946.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.946.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.927.0 to 3.946.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.946.0/packages/s3-request-presigner)

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:15:22 -05:00
dependabot[bot] df284756f1 chore(deps): bump prosemirror-inputrules from 1.5.0 to 1.5.1 (#10831)
Bumps [prosemirror-inputrules](https://github.com/prosemirror/prosemirror-inputrules) from 1.5.0 to 1.5.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-inputrules/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-inputrules/compare/1.5.0...1.5.1)

---
updated-dependencies:
- dependency-name: prosemirror-inputrules
  dependency-version: 1.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:15:09 -05:00
dependabot[bot] 410c196943 chore(deps-dev): bump discord-api-types from 0.38.30 to 0.38.36 (#10832)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.30 to 0.38.36.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.30...0.38.36)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.36
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:14:45 -05:00
dependabot[bot] 4893c61a1f chore(deps-dev): bump ioredis-mock from 8.9.0 to 8.13.1 (#10833)
Bumps [ioredis-mock](https://github.com/stipsan/ioredis-mock) from 8.9.0 to 8.13.1.
- [Release notes](https://github.com/stipsan/ioredis-mock/releases)
- [Changelog](https://github.com/stipsan/ioredis-mock/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stipsan/ioredis-mock/compare/v8.9.0...v8.13.1)

---
updated-dependencies:
- dependency-name: ioredis-mock
  dependency-version: 8.13.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:14:35 -05:00
dependabot[bot] 6e2793a751 chore(deps): bump form-data from 4.0.4 to 4.0.5 (#10834)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.4 to 4.0.5.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.4...v4.0.5)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:14:26 -05:00
Salihu 5e5b37c418 only show "show more" when not loading (#10837) 2025-12-08 17:14:07 -05:00
Tom Moor e30c35acdb fix: Another spot rules of hooks were broken (#10836) 2025-12-08 19:27:40 +00:00
Tom Moor c3ad5bb7f6 fix: Rules of hooks error (#10820) 2025-12-07 11:29:02 +00:00
Tom Moor 971c542613 fix: "Edit" button for collection overview (#10816)
* refactor

* working

* collectionPath refactor

* fix: Flush debounced save

* Move to Actions component

* Keyboard shortcuts

* PR feedback
2025-12-06 18:02:58 -05:00
Tom Moor 3681d1c9b2 fix: Fetch emoji name if necessary (#10819)
* fix: Fetch emoji name if neccessary

* Avoid re-render

* fix: Minor layout issues in emoji dialog picker

* Add upload button to empty search results
2025-12-06 18:45:40 +00:00
Tom Moor 621409ae0b fix: Ensure shutdown with db migration lock correctly releases (#10817)
* fix: Ensure unsafe shutdown with db migration lock correctly releases

* Update server/utils/MutexLock.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-06 13:43:00 -05:00
Apoorv Mishra 8af6fdcc4f Backlink to toolbar menu (#10762)
* fix: port link related commands to work for image selection

* fix: selection

* fix: click img to open link

* fix: hover preview for image with link

* cleanup: hasLink not needed

* fix: we've img wrapped in an `a` tag now, so this is no more required

* fix: cover all edge cases

* fix: cleanup

* fix: zoom in action button in edit mode

* fix: separator div instead of gap

* fix: toolbar refactor

* fix: back button press

* fix: import

* fix: revert

* fix: enum

* fix: onClick on item

* fix: selection at end after link

* fix: show linkbar if link present

* fix: ReturnIcon

* fix: onClickBack

* fix: TOOLBAR -> Toolbar

* fix: show zoom in icon even when selected

* fix: isInlineMarkActive

* fix: jsdoc

* yarn.lock

* Revert "yarn.lock"

This reverts commit 5f44e5e017.

* fix: yarn.lock

* fix: link editor closes upon zoom in click action

* Update shared/editor/queries/isMarkActive.ts

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

* Update shared/editor/queries/getMarkRange.ts

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

* Update shared/editor/components/Image.tsx

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

* Update app/editor/components/LinkEditor.tsx

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

* fix: prevent toolbar state reset

* fix: tooltip

* fix: copilot

* fix: i18n, misuse of attrs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-06 12:21:35 -05:00
Tom Moor ccbc7779ea feat: Add fade variant of Scrollable (#10814) 2025-12-06 09:47:10 -05:00
Tom Moor 2eeeae4a7c perf: Table decorations (#10812)
* perf: CodeFence decos

* perf: Caching for table decos

* PR feedback
2025-12-06 12:08:22 +00:00
Tom Moor 050499b8fc chore: Convert dashes to underscores instead of removing (#10811) 2025-12-06 03:54:26 +00:00
Tom Moor 7d88c97914 fix: Heading caret positioning (#10810)
* fix: Avoid decoration

* perf
2025-12-06 03:49:43 +00:00
Tom Moor e82c848051 feat: Add upload button in emoji picker (#10809) 2025-12-05 20:52:59 -05:00
Salihu 4b6c6f7b36 feat: Distribute table columns evenly (#10645)
* space columns evenly feature

* more accurate col width

* distribute table width more accurately

* minor fix

* code cleanup

* minor fixes

* minor fix

* adjust icon

* language, remove font awesome usage

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-06 01:25:04 +00:00
Tom Moor d795e78b79 fix: Shared document root node alignment (#10808)
closes #10800
2025-12-06 01:09:30 +00:00
Tom Moor 5b3d6c3535 fix: Use XMLSerializer to extract valid XML (#10807) 2025-12-06 01:02:07 +00:00
Tom Moor 6f3534c713 feat: Custom emoji reactions (#10805)
* Claude first pass

* Move custom emojis first in search results

* refactor

* fix: Remove extra load emoji call
2025-12-05 18:47:12 -05:00
Copilot 133ec073be Add CSV export for member list (#10803)
* Initial plan

* Add CSV export functionality to members page

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Align Export CSV button to the right

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback: improve type safety, error handling, and date formatting

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve CSV utility and date handling consistency

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve error messages and fix useCallback dependencies

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add comprehensive tests for CSV utility

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Refactor: reduce limit to 100, replace lastActiveIp with role, extract ExportCSV component

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve type safety and extract pagination constant

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-05 09:42:36 -05:00
Tom Moor 305b81fbf4 fix: Badges should never wrap (#10804) 2025-12-05 13:43:22 +00:00
Tom Moor aeb777b2f5 fix: Shift paste inserts next to selection (#10799) 2025-12-04 23:11:33 -05:00
Tom Moor f307c678c2 feat: Support user initials in mention search (#10797)
* feat: Support user initials in mention search

* test
2025-12-05 03:32:29 +00:00
Tom Moor ac23277b7c fix: Find and replace positioning (#10795) 2025-12-05 03:19:48 +00:00
Copilot eee64e363f Skip compression for GIF emoji uploads to preserve animation (#10792)
* Initial plan

* Skip compression for GIF files to preserve animation

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Simplify type assertions in tests

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Remove unnecessary test file

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2025-12-05 02:45:39 +00:00
Copilot 55116b4761 Fix template insertion to use cursor position instead of document start (#10783)
* Initial plan

* Fix template insertion to use current cursor position

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Apply prettier formatting

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2025-12-04 21:45:18 -05:00
codegen-sh[bot] ac2d3bf3cb Fix TypeScript compilation errors in Mention model
- Replace authorize() with can() function for proper boolean return
- Add missing createdAt property to documents.update events in tests
- Add non-null assertions for required properties
- Fix event type definitions to match DocumentEvent interface
2025-05-31 00:17:15 +00:00
codegen-sh[bot] 94a8326c68 Implement Mention model for tracking user mentions
- Add Mention model with relationships to User and Document
- Create database migration for mentions table
- Add MentionsProcessor to handle document events
- Add comprehensive tests for MentionsProcessor
- Update model relationships in Document and User models

Addresses issue #9268 for tracking user mentions similar to backlinks
2025-05-30 23:53:38 +00:00
117 changed files with 4152 additions and 1781 deletions
+5
View File
@@ -1,6 +1,10 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET=test-utils-secret-key
REDIS_URL=redis://localhost:6379
URL=http://localhost:3000
COLLABORATION_URL=http://localhost:3001
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
@@ -12,6 +16,7 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
+2 -1
View File
@@ -94,7 +94,8 @@
"args": "after-used",
"ignoreRestSiblings": true
}
]
],
"react/rules-of-hooks": "error"
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
+1 -1
View File
@@ -21,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
function ActionButton_(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
+1
View File
@@ -21,6 +21,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
+1 -1
View File
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
to: collectionPath(collection),
}),
],
[collection, t]
+1 -1
View File
@@ -31,7 +31,7 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function _ContentEditable(
const ContentEditable = React.forwardRef(function ContentEditable_(
{
disabled,
onChange,
+29 -23
View File
@@ -8,7 +8,7 @@ import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
@@ -108,12 +108,16 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
// Skip compression for GIFs to preserve animation
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(compressed, {
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
@@ -147,26 +151,11 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
>
<Text as="p" type="secondary">
{t(
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
)}
</Text>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
@@ -197,6 +186,22 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
</Flex>
</DropZone>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
@@ -213,6 +218,7 @@ const DropZone = styled.div`
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
@@ -15,7 +15,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
{ url, id, title, summary, lastActivityByViewer }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -17,7 +17,7 @@ import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
{ name, description, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -24,7 +24,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
{ url, id, title, description, author, labels, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ type Props = {
description: string;
};
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
const HoverPreviewLink = React.forwardRef(function HoverPreviewLink_(
{ url, thumbnailUrl, title, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -6,7 +6,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
const HoverPreviewPullRequest = React.forwardRef(
function _HoverPreviewPullRequest(
function HoverPreviewPullRequest_(
{ url, title, id, description, author, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -9,4 +9,5 @@ export const UserInputContainer = styled(Flex)`
export const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
min-width: 0;
`;
@@ -1,170 +0,0 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import { IconType } from "@shared/types";
import { DisplayCategory } from "../utils";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import Emoji from "~/models/Emoji";
const GRID_HEIGHT = 410;
type Props = {
panelWidth: number;
height?: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const CustomEmojiPanel = ({
query,
panelActive,
panelWidth,
height = GRID_HEIGHT,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
useEffect(() => {
if (query.trim()) {
const initialData = emojis.findByQuery(query);
if (initialData.length) {
setSearchData([
{
category: DisplayCategory.Search,
icons: initialData?.map(toIcon),
},
]);
}
emojis
.fetchAll({
query,
})
.then((data) => {
if (data.length) {
const iconMap = new Map([
...initialData.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
...data.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
]);
setSearchData([
{
category: DisplayCategory.Search,
icons: Array.from(iconMap.values()),
},
]);
return;
}
setSearchData([]);
});
} else {
setSearchData([]);
}
}, [query, emojis]);
useEffect(() => {
getFrequentIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [getFrequentIcons, emojis]);
const handleEmojiSelection = React.useCallback(
({ id }: { id: string }) => {
onEmojiChange(id);
incrementIconCount(id);
},
[onEmojiChange, incrementIconCount]
);
const templateData: DataNode[] = React.useMemo(
() => [
{
category: DisplayCategory.Frequent,
icons: freqEmojis,
},
{
category: DisplayCategory.All,
icons: emojis.orderedData.map(toIcon),
},
],
[emojis.orderedData, freqEmojis]
);
React.useLayoutEffect(() => {
if (!panelActive) {
return;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;
@@ -1,14 +1,23 @@
import concat from "lodash/concat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import useStores from "~/hooks/useStores";
import Emoji from "~/models/Emoji";
import { useComputed } from "~/hooks/useComputed";
import { MenuButton } from "./MenuButton";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { IconButton } from "./IconButton";
const GRID_HEIGHT = 410;
@@ -30,9 +39,15 @@ const EmojiPanel = ({
height = GRID_HEIGHT,
}: Props) => {
const { t } = useTranslation();
const { emojis, dialogs } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const customEmojis = useComputed(
() => emojis.orderedData.map(toIcon),
[emojis.orderedData]
);
const {
emojiSkinTone: skinTone,
@@ -41,11 +56,20 @@ const EmojiPanel = ({
getFrequentIcons,
} = useIconState(IconType.Emoji);
const {
incrementIconCount: incrementCustomIconCount,
getFrequentIcons: getFrequentCustomIcons,
} = useIconState(IconType.Custom);
const freqEmojis = React.useMemo(
() => getFrequentIcons(),
[getFrequentIcons]
);
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
[]
);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
@@ -60,23 +84,68 @@ const EmojiPanel = ({
[setEmojiSkinTone]
);
const handleUploadClick = React.useCallback(() => {
dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={dialogs.closeAllModals} />,
});
}, [dialogs, t]);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementIconCount(id);
// Determine if this is a custom emoji by checking if it's in the custom emoji data
const isCustomEmoji =
customEmojis.some((emoji) => emoji.id === id) ||
freqCustomEmojis.some((emoji) => emoji.id === id);
if (isCustomEmoji) {
incrementCustomIconCount(id);
} else {
incrementIconCount(id);
}
},
[onEmojiChange, incrementIconCount]
[
onEmojiChange,
incrementIconCount,
incrementCustomIconCount,
customEmojis,
freqCustomEmojis,
]
);
React.useEffect(() => {
// Load frequent custom emojis
getFrequentCustomIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqCustomEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [emojis, getFrequentCustomIcons]);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
customEmojis,
})
: getAllEmojis({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
});
React.useLayoutEffect(() => {
@@ -89,7 +158,7 @@ const EmojiPanel = ({
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<UserInputContainer align="center" gap={8}>
<StyledInputSearch
ref={searchRef}
value={query}
@@ -97,6 +166,14 @@ const EmojiPanel = ({
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
{can.update && (
<MenuButton
onClick={handleUploadClick}
aria-label={t("Upload emoji")}
>
<PlusIcon />
</MenuButton>
)}
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
@@ -104,6 +181,11 @@ const EmojiPanel = ({
height={height - 48}
data={templateData}
onIconSelect={handleEmojiSelection}
empty={
<IconButton onClick={handleUploadClick}>
<PlusIcon />
</IconButton>
}
/>
</Flex>
);
@@ -112,19 +194,32 @@ const EmojiPanel = ({
const getSearchResults = ({
query,
skinTone,
customEmojis,
}: {
query: string;
skinTone: EmojiSkinTone;
customEmojis: EmojiNode[];
}): DataNode[] => {
const emojis = search({ query, skinTone });
// Search custom emojis by name
const matchingCustomEmojis = customEmojis.filter((emoji) =>
emoji.name?.toLowerCase().includes(query.toLowerCase())
);
const allResults = [
...matchingCustomEmojis,
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
})),
];
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
icons: allResults,
},
];
};
@@ -132,21 +227,32 @@ const getSearchResults = ({
const getAllEmojis = ({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
customEmojis: EmojiNode[];
freqCustomEmojis: EmojiNode[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentIcons = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
// Combine frequent standard and custom emojis
const allFrequent = [
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
})),
...freqCustomEmojis,
];
return {
category: DisplayCategory.Frequent,
icons: allFrequent,
};
};
@@ -162,7 +268,7 @@ const getAllEmojis = ({
};
};
return concat(
const allData = concat(
getFrequentIcons(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
@@ -173,6 +279,22 @@ const getAllEmojis = ({
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
if (customEmojis.length) {
allData.push({
category: "Custom",
icons: customEmojis,
});
}
return allData;
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default EmojiPanel;
@@ -37,14 +37,20 @@ export type DataNode = {
};
type Props = {
/** Width of the grid container */
width: number;
/** Height of the grid container */
height: number;
/** Data to be displayed in the grid */
data: DataNode[];
/** Content to display when search results are empty */
empty?: React.ReactNode;
/** Callback when an icon is selected */
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, onIconSelect }: Props,
{ width, height, data, empty, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
@@ -52,10 +58,6 @@ const GridTemplate = (
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
@@ -67,6 +69,13 @@ const GridTemplate = (
</CategoryName>
);
if (node.icons.length === 0) {
if (node.category !== "Search") {
return [];
}
return [[category], [empty]];
}
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
@@ -0,0 +1,19 @@
import { hover, s } from "@shared/styles";
import styled from "styled-components";
import NudeButton from "~/components/NudeButton";
export const MenuButton = styled(NudeButton)`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
color: ${s("textSecondary")};
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
@@ -1,18 +1,17 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { IconButton } from "./IconButton";
import { MenuButton } from "./MenuButton";
const SkinTonePicker = ({
skinTone,
@@ -57,9 +56,9 @@ const SkinTonePicker = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledMenuButton aria-label={t("Choose default skin tone")}>
<MenuButton aria-label={t("Choose default skin tone")}>
{handEmojiVariants[skinTone]?.value}
</StyledMenuButton>
</MenuButton>
</PopoverTrigger>
<PopoverContent
side="bottom"
@@ -79,15 +78,4 @@ const Emojis = styled(Flex)`
padding: 0 8px;
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;
+1 -19
View File
@@ -21,13 +21,11 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
import CustomEmojiPanel from "./components/CustomEmojiPanel";
import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -175,7 +173,7 @@ const IconPicker = ({
if (open) {
void emojis.fetchAll();
}
}, [open]);
}, [open, emojis]);
if (isMobile) {
return (
@@ -254,13 +252,6 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -287,15 +278,6 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};
+1 -1
View File
@@ -904,7 +904,7 @@ type ImageProps = {
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
{
src,
alt,
+1 -1
View File
@@ -9,7 +9,7 @@ type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
};
export const OneTimePasswordInput = React.forwardRef(
function _OneTimePasswordInput(
function OneTimePasswordInput_(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
+22 -3
View File
@@ -13,6 +13,9 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { isUUID } from "validator";
import { CustomEmoji } from "@shared/components/CustomEmoji";
import useStores from "~/hooks/useStores";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
@@ -39,13 +42,25 @@ const useTooltipContent = ({
active: boolean;
}) => {
const { t } = useTranslation();
const { emojis } = useStores();
const customEmoji = emojis.get(emoji);
const [transformedEmoji, setTransformedEmoji] = React.useState(
customEmoji?.shortName ?? `:${getEmojiId(emoji)}:`
);
// If the emoji is a custom emoji ID, we need to get its short name for display
if (isUUID(emoji)) {
emojis.fetch(emoji).then((ce) => {
if (ce) {
setTransformedEmoji(ce.shortName);
}
});
}
if (!reactedUsers.length) {
return;
}
const transformedEmoji = `:${getEmojiId(emoji)}:`;
switch (reactedUsers.length) {
case 1: {
return t("{{ username }} reacted with {{ emoji }}", {
@@ -120,7 +135,11 @@ const Reaction: React.FC<Props> = ({
() => (
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
<Flex gap={6} justify="center" align="center">
<Emoji size={15}>{reaction.emoji}</Emoji>
{isUUID(reaction.emoji) ? (
<CustomEmoji size={15} value={reaction.emoji} />
) : (
<Emoji size={15}>{reaction.emoji}</Emoji>
)}
<Count weight="xbold">{reaction.userIds.length}</Count>
</Flex>
</EmojiButton>
+65 -7
View File
@@ -1,27 +1,43 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { hideScrollbars } from "@shared/styles";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
/** Whether to show shadows at top and bottom when scrolled */
shadow?: boolean;
/** Whether to show shadow at the top when scrolled */
topShadow?: boolean;
/** Whether to show shadow at the bottom when scrolled */
bottomShadow?: boolean;
/** Whether to hide the scrollbars */
hiddenScrollbars?: boolean;
/** Color to fade to (enables fade effect) */
fadeTo?: string;
/** Whether to use flexbox layout */
flex?: boolean;
/** Custom overflow style */
overflow?: string;
children: React.ReactNode;
};
/**
* A scrollable container component with optional shadow indicators and custom scrollbar styling.
*
* @param props - component properties.
* @param ref - forwarded ref to the scrollable div element.
* @returns the scrollable container element.
*/
function Scrollable(
{
shadow,
topShadow,
bottomShadow,
hiddenScrollbars,
fadeTo,
flex,
overflow,
children,
...rest
}: Props,
ref: React.RefObject<HTMLDivElement>
@@ -36,14 +52,17 @@ function Scrollable(
return;
}
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow) && scrollTop > 0);
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
const wrapperHeight = c.scrollHeight - c.clientHeight;
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
const bsv = !!(
(shadow || bottomShadow || fadeTo) &&
wrapperHeight - scrollTop !== 0
);
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
@@ -52,6 +71,7 @@ function Scrollable(
shadow,
topShadow,
bottomShadow,
fadeTo,
ref,
topShadowVisible,
bottomShadowVisible,
@@ -67,21 +87,59 @@ function Scrollable(
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
$topShadowVisible={topShadowVisible && !fadeTo}
$bottomShadowVisible={bottomShadowVisible && !fadeTo}
$overflow={overflow}
{...rest}
/>
>
{fadeTo && <Fade to={fadeTo} visible={topShadowVisible} top />}
{children}
{fadeTo && <Fade to={fadeTo} visible={bottomShadowVisible} bottom />}
</Wrapper>
);
}
const Fade = styled.div<{
to: string;
top?: boolean;
bottom?: boolean;
visible: boolean;
}>`
--height: 1.5em;
position: sticky;
${(props) =>
props.top &&
css`
top: 0;
background: linear-gradient(to bottom, ${props.to}, transparent);
margin-bottom: calc(-1 * var(--height));
`}
${(props) =>
props.bottom &&
css`
bottom: 0;
background: linear-gradient(to top, ${props.to}, transparent);
margin-top: calc(-1 * var(--height));
`}
flex-shrink: 0;
height: var(--height);
width: calc(100% - var(--scrollbar-width, 0px));
pointer-events: none;
opacity: ${(props) => (props.visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
z-index: 1;
`;
const Wrapper = styled.div<{
$flex?: boolean;
$fadeTo?: string;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
$overflow?: string;
}>`
position: relative;
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
height: 100%;
@@ -331,7 +331,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
) : (
<CopyLinkButton
key="copy-link"
url={urlify(collectionPath(collection.path))}
url={urlify(collectionPath(collection))}
onCopy={onRequestClose}
/>
);
@@ -16,7 +16,7 @@ type Props = {
action: React.ReactNode;
};
export const SearchInput = React.forwardRef(function _SearchInput(
export const SearchInput = React.forwardRef(function SearchInput_(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
@@ -44,7 +44,7 @@ type Props = {
};
export const Suggestions = observer(
React.forwardRef(function _Suggestions(
React.forwardRef(function Suggestions_(
{
document,
collection,
+2 -1
View File
@@ -88,7 +88,8 @@ function SharedSidebar({ share }: Props) {
) : (
<SharedDocumentLink
index={0}
depth={0}
// If the root node has an icon we need some extra space for it
depth={rootNode.icon ? 1 : 0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
+1 -1
View File
@@ -37,7 +37,7 @@ type Props = {
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
{ children, hidden = false, canCollapse = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
@@ -123,7 +123,7 @@ function SharedWithMe() {
.map((membership) => (
<SharedWithMeLink key={membership.id} membership={membership} />
))}
{!end && (
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
@@ -18,7 +18,7 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function _SidebarButton(
function SidebarButton_(
{
position = "top",
showMoreMenu,
@@ -63,7 +63,7 @@ function Starred() {
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
+3 -3
View File
@@ -302,9 +302,8 @@ export default function FindAndReplace({
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
left: "initial",
top: 60,
right: 16,
top: 0,
right: 0,
zIndex: depths.popover,
}),
[]
@@ -375,6 +374,7 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
<Flex gap={4}>
+4 -2
View File
@@ -294,7 +294,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
<Portal>
<Wrapper
active={props.active && position.visible}
arrow={!position.blockSelection}
arrow={!!props.children && !position.blockSelection}
ref={menuRef}
$offset={position.offset}
style={{
@@ -304,7 +304,9 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children && (
<Background align={props.align}>{props.children}</Background>
)}
</Wrapper>
</Portal>
);
+50 -22
View File
@@ -1,7 +1,12 @@
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import {
ArrowIcon,
CloseIcon,
DocumentIcon,
OpenIcon,
ReturnIcon,
} from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
@@ -28,9 +33,25 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
onClickBack: () => void;
};
const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
onClickBack,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const { commands } = useEditor();
@@ -58,7 +79,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
}
}, [trimmedQuery, request]);
useOnClickOutside(wrapperRef, () => {
useOnClickOutside(wrapperRef, (ev) => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return removeLink();
@@ -66,9 +87,14 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
onClickOutside(ev);
return;
}
if (!mark) {
return addLink(trimmedQuery);
}
updateLink(trimmedQuery);
});
@@ -78,26 +104,23 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const removeLink = React.useCallback(() => {
commands["removeLink"]();
}, []);
onLinkRemove();
}, [commands, onLinkRemove]);
const updateLink = (link: string) => {
if (!link) {
return;
}
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkUpdate();
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.to),
1,
true
);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
const addLink = (link: string) => {
if (!link) {
return;
}
view.focus();
commands["addLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkAdd();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -119,9 +142,11 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
updateLink(selectedDoc.url);
!mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url);
} else if (!trimmedQuery) {
removeLink();
} else if (!mark) {
addLink(trimmedQuery);
} else {
updateLink(trimmedQuery);
}
@@ -135,11 +160,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
return removeLink();
}
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -169,6 +190,13 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
disabled: false,
handler: removeLink,
},
{
tooltip: dictionary.formattingControls,
icon: <ReturnIcon />,
visible: view.editable,
disabled: false,
handler: onClickBack,
},
];
return (
@@ -208,7 +236,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
updateLink(doc.url);
!mark ? addLink(doc.url) : updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
+36 -8
View File
@@ -2,24 +2,43 @@ import { OpenIcon, TrashIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import ToolbarButton from "./ToolbarButton";
import useOnClickOutside from "~/hooks/useOnClickOutside";
type Props = {
node: Node;
node?: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
};
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
export function MediaLinkEditor({
node,
view,
dictionary,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
}: Props) {
const url = (node?.attrs.href ?? node?.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const wrapperRef = useRef<HTMLDivElement>(null);
// If we're attempting to edit an image, autofocus the input
// Not doing for embed type because it made the editor scroll to top
// unexpectedlyleaving that out for now
const isEditingImgUrl = node?.type.name === "image";
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
@@ -41,20 +60,24 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
onLinkRemove();
}, [view]);
const update = useCallback(() => {
const { state } = view;
const hrefType = node.type.name === "image" ? "src" : "href";
const hrefType = node?.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
...node?.attrs,
[hrefType]: localUrl,
});
view.dispatch(tr);
moveSelectionToEnd();
onLinkUpdate();
}, [localUrl, node, view, moveSelectionToEnd]);
useOnClickOutside(wrapperRef, onClickOutside);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
@@ -71,6 +94,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
case "Escape": {
event.preventDefault();
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -78,10 +102,14 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
[update, moveSelectionToEnd]
);
if (!node) {
return null;
}
return (
<Wrapper>
<Wrapper ref={wrapperRef}>
<Input
autoFocus={autoFocus}
autoFocus={isEditingImgUrl}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
+117 -34
View File
@@ -1,7 +1,10 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -30,6 +33,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
/** Whether the text direction is right-to-left */
@@ -56,6 +60,12 @@ function useIsDragging() {
return isDragging;
}
enum Toolbar {
Link = "link",
Media = "media",
Menu = "menu",
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
@@ -64,11 +74,41 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
const { state } = view;
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
React.useEffect(() => {
setIsEditingImgUrl(false);
}, [isActive]);
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (linkMark && !activeToolbar && !readOnly) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
} else if (!selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (isNoticeSelection && selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -91,8 +131,6 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -106,27 +144,46 @@ export function SelectionToolbar(props: Props) {
};
}, [isActive, readOnly, view]);
useEventListener(
"keydown",
(ev: KeyboardEvent) => {
if (
isModKey(ev) &&
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
{ capture: true }
);
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -178,47 +235,73 @@ export function SelectionToolbar(props: Props) {
});
items = filterExcessSeparators(items);
if (!items.length) {
return null;
}
items = items.map((item) => {
if (item.children) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
setActiveToolbar(Toolbar.Media);
};
}
return child;
});
}
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setActiveToolbar(Toolbar.Link);
};
}
return item;
});
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
return;
}
setActiveToolbar(null);
};
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
width={
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
? 336
: undefined
}
>
{showLinkToolbar ? (
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`${link.from}-${link.to}`}
key={`${selection.from}-${selection.to}`}
dictionary={dictionary}
view={view}
mark={link.mark}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
/>
) : isEditingMedia ? (
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={selection.node}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
/>
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
)}
) : activeToolbar === Toolbar.Menu && items.length ? (
<ToolbarMenu items={items} {...rest} />
) : null}
</FloatingToolbar>
);
}
+11 -14
View File
@@ -20,20 +20,15 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, handlers } = props;
const { item } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -48,12 +43,8 @@ function ToolbarDropdown(props: {
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
@@ -113,6 +104,13 @@ function ToolbarMenu(props: Props) {
return;
}
// if item has an associated onClick prop, run it
if (item.onClick) {
item.onClick();
return;
}
// otherwise, run the associated editor command
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
@@ -141,7 +139,6 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
+11 -17
View File
@@ -282,28 +282,22 @@ export default class PasteHandler extends Extension {
const slice = paste.slice(0);
const tr = view.state.tr;
let currentPos = view.state.selection.from;
// If the pasted content is a single paragraph then we loop over
// it's content and insert each node one at a time to allow it to
// be pasted inline with surrounding content.
// If the pasted content is a single paragraph then we slice
// the outer paragraph so that the text is inserted directly.
const singleNode = sliceSingleNode(slice);
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
singleNode.forEach((node) => {
tr.insert(currentPos, node);
currentPos += node.nodeSize;
});
} else {
if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
const slice = new Slice(singleNode.content, 0, 0);
tr.replaceSelection(slice);
} else if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelection(slice);
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
} else {
tr.replaceSelection(slice);
}
view.dispatch(
+1 -1
View File
@@ -914,7 +914,7 @@ const EditorContainer = styled(Styles)<{
`;
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
function _LazyLoadedEditor(props: Props, ref) {
function LazyLoadedEditor_(props: Props, ref) {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
+1
View File
@@ -258,6 +258,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
+9
View File
@@ -8,6 +8,7 @@ import {
AlignFullWidthIcon,
EditIcon,
CommentIcon,
LinkIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -16,6 +17,7 @@ import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
export default function imageMenuItems(
state: EditorState,
@@ -123,6 +125,13 @@ export default function imageMenuItems(
{
name: "separator",
},
{
name: "linkOnImage",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
active: isMarkActive(schema.marks.link),
icon: <LinkIcon />,
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
+12 -1
View File
@@ -1,4 +1,9 @@
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
import {
AlignFullWidthIcon,
DownloadIcon,
TableColumnsDistributeIcon,
TrashIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem, TableLayout } from "@shared/editor/types";
@@ -13,6 +18,7 @@ export default function tableMenuItems(
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
})(state);
@@ -27,6 +33,11 @@ export default function tableMenuItems(
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
+9
View File
@@ -11,11 +11,13 @@ import {
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
@@ -38,6 +40,7 @@ export default function tableColMenuItems(
const { index, rtl } = options;
const { schema, selection } = state;
const selectedCols = getAllSelectedColumns(state);
if (!(selection instanceof CellSelection)) {
return [];
@@ -147,6 +150,12 @@ export default function tableColMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
+2
View File
@@ -110,6 +110,8 @@ export default function useDictionary() {
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
}),
[t]
);
+1 -1
View File
@@ -37,7 +37,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
navigateTo = url.pathname + url.search + url.hash;
} catch (_err) {
navigateTo = href;
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -36,6 +36,11 @@ class Emoji extends Model {
return this.name;
}
@computed
get shortName(): string {
return `:${this.name}:`;
}
/**
* emoji name
*/
+1 -1
View File
@@ -177,7 +177,7 @@ class Notification extends Model {
const collection = this.collectionId
? this.store.rootStore.collections.get(this.collectionId)
: undefined;
return collection ? collectionPath(collection.path) : "";
return collection ? collectionPath(collection) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
+197
View File
@@ -0,0 +1,197 @@
import User from "./User";
import stores from "~/stores";
describe("User model", () => {
const users = stores.users;
describe("initial", () => {
test("should return first character of name uppercased", () => {
const user = new User(
{
id: "123",
name: "alice smith",
},
users
);
expect(user.initial).toBe("A");
});
test("should return first character when name is already uppercase", () => {
const user = new User(
{
id: "124",
name: "Bob Johnson",
},
users
);
expect(user.initial).toBe("B");
});
test("should return ? when name is empty", () => {
const user = new User(
{
id: "125",
name: "",
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is null", () => {
const user = new User(
{
id: "126",
name: null,
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is undefined", () => {
const user = new User(
{
id: "127",
name: undefined,
},
users
);
expect(user.initial).toBe("?");
});
});
describe("initials", () => {
test("should return empty string when name is empty", () => {
const user = new User(
{
id: "201",
name: "",
},
users
);
expect(user.initials).toBe("");
});
test("should return empty string when name is null", () => {
const user = new User(
{
id: "202",
name: null,
},
users
);
expect(user.initials).toBe("");
});
test("should return single character uppercased for single word name", () => {
const user = new User(
{
id: "203",
name: "alice",
},
users
);
expect(user.initials).toBe("A");
});
test("should return single character uppercased for single word name already uppercase", () => {
const user = new User(
{
id: "204",
name: "BOB",
},
users
);
expect(user.initials).toBe("B");
});
test("should return first and last initials for two word name", () => {
const user = new User(
{
id: "205",
name: "alice smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for three word name", () => {
const user = new User(
{
id: "206",
name: "alice marie smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for many word name", () => {
const user = new User(
{
id: "207",
name: "alice marie jane doe smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with extra spaces", () => {
const user = new User(
{
id: "208",
name: " alice smith ",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with mixed case", () => {
const user = new User(
{
id: "209",
name: "aLiCe sMiTh",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with special characters", () => {
const user = new User(
{
id: "210",
name: "Jean-Pierre O'Connor",
},
users
);
expect(user.initials).toBe("JO");
});
test("should handle single letter names", () => {
const user = new User(
{
id: "211",
name: "X",
},
users
);
expect(user.initials).toBe("X");
});
test("should handle names with unicode characters", () => {
const user = new User(
{
id: "212",
name: "José García",
},
users
);
expect(user.initials).toBe("JG");
});
});
});
+13 -1
View File
@@ -65,7 +65,7 @@ class User extends ParanoidModel implements Searchable {
@computed
get searchContent(): string[] {
return [this.name, this.email].filter(Boolean);
return [this.name, this.email, this.initials].filter(Boolean);
}
@computed
@@ -78,6 +78,18 @@ class User extends ParanoidModel implements Searchable {
return (this.name ? this.name[0] : "?").toUpperCase();
}
@computed
get initials(): string {
if (!this.name) {
return "";
}
const names = this.name.trim().split(" ");
if (names.length === 1) {
return names[0][0].toUpperCase();
}
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
}
/**
* Whether the user has been invited but not yet signed in.
*/
+25 -7
View File
@@ -17,7 +17,8 @@ import {
homePath,
searchPath,
settingsPath,
matchDocumentSlug as slug,
matchDocumentSlug as documentSlug,
matchCollectionSlug as collectionSlug,
trashPath,
} from "~/utils/routeHelpers";
@@ -80,22 +81,39 @@ function AuthenticatedRoutes() {
to={settingsPath("templates")}
/>
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route
exact
path="/collection/:id/:tab?"
path={`/collection/${collectionSlug}/new`}
component={DocumentNew}
/>
<Route
exact
path={`/collection/${collectionSlug}/overview/edit`}
component={Collection}
/>
<Route
exact
path={`/collection/${collectionSlug}/:tab?`}
component={Collection}
/>
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
path={`/d/${documentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${documentSlug}/history/:revisionId?`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
exact
path={`/doc/${documentSlug}/edit`}
component={Document}
/>
<Route path={`/doc/${documentSlug}`} component={Document} />
<Route
exact
path={`${searchPath()}/:query?`}
+62 -3
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import { EditIcon, PlusIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
@@ -8,18 +8,76 @@ import Button from "~/components/Button";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
import {
collectionEditPath,
collectionPath,
newDocumentPath,
} from "~/utils/routeHelpers";
import useCurrentUser from "~/hooks/useCurrentUser";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { CollectionTab } from "./Navigation";
import lazyWithRetry from "~/utils/lazyWithRetry";
import history from "~/utils/history";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { useCallback } from "react";
const ShareButton = lazyWithRetry(() => import("./ShareButton"));
type Props = {
/** The collection for which to render actions */
collection: Collection;
/** Whether the collection is in editing mode */
isEditing: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
function Actions({ collection }: Props) {
function Actions({ collection, isEditing, sidebarContext }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const user = useCurrentUser();
const goToEdit = useCallback(() => {
history.push({
pathname: collectionEditPath(collection),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
const goBack = useCallback(() => {
history.push({
pathname: collectionPath(collection, CollectionTab.Overview),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
return (
<>
{(!isEditing || !user?.separateEditMode) && (
<Action>
<ShareButton collection={collection} />
</Action>
)}
{!isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="e" handler={goToEdit} />
<Tooltip
content={t("Edit collection")}
shortcut="e"
placement="bottom"
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
{t("Edit")}
</Button>
</Tooltip>
</Action>
)}
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack}>{t("Done editing")}</Button>
</Action>
)}
{can.createDocument && (
<>
<Action>
@@ -33,6 +91,7 @@ function Actions({ collection }: Props) {
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
neutral={isEditing}
>
{t("New doc")}
</Button>
@@ -0,0 +1,68 @@
import { IconTitleWrapper } from "@shared/components/Icon";
import breakpoint from "styled-components-breakpoint";
import first from "lodash/first";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
import usePolicy from "~/hooks/usePolicy";
import { observer } from "mobx-react";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** The collection for which to render a header */
collection: Collection;
};
export const Header = observer(function Header_({ collection }: Props) {
const can = usePolicy(collection);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
return (
<StyledHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</StyledHeading>
);
});
const StyledHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
@@ -0,0 +1,78 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { collectionPath } from "~/utils/routeHelpers";
import { type SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
export enum CollectionTab {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
type Props = {
/** The collection for which to render navigation tabs */
collection: Collection;
/** Callback when the tab is changed */
onChangeTab: (tab: CollectionTab) => void;
/** Whether to show the overview tab */
showOverview?: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
/**
* Navigation component for collection tabs, providing navigation between
* different views of collection documents.
*/
const Navigation = observer(function Navigation({
collection,
onChangeTab,
showOverview,
sidebarContext,
}: Props) {
const { t } = useTranslation();
const tabProps = (path: CollectionTab) => ({
exact: true,
onClick: () => onChangeTab(path),
to: {
pathname: collectionPath(collection, path),
state: { sidebarContext },
},
});
return (
<Tabs>
{showOverview && (
<Tab {...tabProps(CollectionTab.Overview)} exact={false}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionTab.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionTab.Popular)}>{t("Popular")}</Tab>
<Tab {...tabProps(CollectionTab.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionTab.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Alphabetical)}>{t("AZ")}</Tab>
</>
)}
</Tabs>
);
});
export default Navigation;
+11 -4
View File
@@ -1,6 +1,6 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { useMemo, useRef, useCallback, Suspense } from "react";
import { useMemo, useRef, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
@@ -24,10 +24,10 @@ const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
shareId?: string;
readOnly?: boolean;
};
function Overview({ collection, shareId }: Props) {
function Overview({ collection, readOnly }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
@@ -48,6 +48,13 @@ function Overview({ collection, shareId }: Props) {
[collection, t]
);
useEffect(
() => () => {
handleSave.flush();
},
[handleSave]
);
const childRef = useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = useMemo(
@@ -91,7 +98,7 @@ function Overview({ collection, shareId }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update || !!shareId}
readOnly={!can.update || readOnly}
userId={user?.id}
editorStyle={editorStyle}
/>
+69 -156
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -11,13 +11,9 @@ import {
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import Heading from "~/components/Heading";
@@ -28,77 +24,62 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NotFoundError } from "~/utils/errors";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import {
collectionEditPath,
collectionPath,
matchCollectionEdit,
updateCollectionPath,
} from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Navigation, { CollectionTab } from "./components/Navigation";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import first from "lodash/first";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { Header } from "./components/Header";
import usePersistedState from "~/hooks/usePersistedState";
import useCurrentUser from "~/hooks/useCurrentUser";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
const params = useParams<{ id?: string }>();
const CollectionScene = observer(function CollectionScene_() {
const params = useParams<{ collectionSlug?: string }>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const user = useCurrentUser();
const { documents, collections, shares, ui } = useStores();
const [error, setError] = useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const sidebarContext = useLocationSidebarContext();
const isEditRoute = match.path === matchCollectionEdit;
const id = params.id || "";
const id = params.collectionSlug || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
{
listen: false,
}
);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
@@ -149,23 +130,13 @@ const CollectionScene = observer(function _CollectionScene() {
if (!collection && error) {
return <Error404 />;
}
if (!collection) {
return <Loading />;
}
const hasOverview = can.update || collection?.hasDescription;
const showOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
return (
<Scene
centered={false}
textTitle={collection.name}
@@ -190,10 +161,11 @@ const CollectionScene = observer(function _CollectionScene() {
actions={
<>
<MembershipPreview collection={collection} />
<Action>
{can.update && <ShareButton collection={collection} />}
</Action>
<Actions collection={collection} />
<Actions
collection={collection}
isEditing={isEditRoute}
sidebarContext={sidebarContext}
/>
</>
}
>
@@ -204,28 +176,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</CollectionHeading>
<Header collection={collection} />
<PinnedDocuments
pins={pins}
@@ -233,54 +184,39 @@ const CollectionScene = observer(function _CollectionScene() {
placeholderCount={count}
/>
<Documents>
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Popular)}>
{t("Popular")}
</Tab>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Content>
<Navigation
collection={collection}
onChangeTab={setCollectionTab}
showOverview={showOverview}
sidebarContext={sidebarContext}
/>
<Switch>
<Route path={collectionPath(collection.path)} exact>
<Route path={collectionPath(collection)} exact>
<Redirect
to={{
pathname: collectionPath(collection!.path, collectionTab),
pathname: collectionPath(collection!, collectionTab),
state: { sidebarContext },
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Overview)}
path={[
collectionPath(collection, CollectionTab.Overview),
collectionEditPath(collection),
]}
>
{hasOverview ? (
<Overview collection={collection} />
{showOverview ? (
<Overview
collection={collection}
readOnly={!isEditRoute && !!user?.separateEditMode}
/>
) : (
<Redirect
to={{
pathname: collectionPath(
collection.path,
CollectionPath.Recent
collection,
CollectionTab.Recent
),
state: { sidebarContext },
}}
@@ -293,8 +229,8 @@ const CollectionScene = observer(function _CollectionScene() {
<>
<Route
path={collectionPath(
collection.path,
CollectionPath.Alphabetical
collection,
CollectionTab.Alphabetical
)}
>
<PaginatedDocumentList
@@ -308,9 +244,7 @@ const CollectionScene = observer(function _CollectionScene() {
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<Route path={collectionPath(collection, CollectionTab.Old)}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@@ -323,10 +257,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Published
)}
path={collectionPath(collection, CollectionTab.Published)}
>
<PaginatedDocumentList
key="published"
@@ -341,10 +272,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Updated
)}
path={collectionPath(collection, CollectionTab.Updated)}
>
<PaginatedDocumentList
key="updated"
@@ -358,10 +286,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Popular
)}
path={collectionPath(collection, CollectionTab.Popular)}
>
<PaginatedDocumentList
key="popular"
@@ -373,10 +298,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
path={collectionPath(collection, CollectionTab.Recent)}
exact
>
<PaginatedDocumentList
@@ -394,7 +316,7 @@ const CollectionScene = observer(function _CollectionScene() {
</>
) : (
<Route
path={collectionPath(collection.path, CollectionPath.Recent)}
path={collectionPath(collection, CollectionTab.Recent)}
exact
>
<PaginatedDocumentList
@@ -412,20 +334,22 @@ const CollectionScene = observer(function _CollectionScene() {
</Route>
)}
</Switch>
</Documents>
</Content>
</CenteredContent>
</DropToImport>
</Scene>
) : (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
});
const Loading = () => (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
@@ -434,20 +358,9 @@ const KeyedCollection = () => {
return <CollectionScene key={params.id} />;
};
const Documents = styled.div`
const Content = styled.div`
position: relative;
background: ${s("background")};
`;
const CollectionHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
export default KeyedCollection;
+10 -8
View File
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection, TextSelection } from "prosemirror-state";
import { AllSelection, Selection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -148,10 +148,7 @@ class DocumentScene extends React.Component<Props> {
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (
template: Document | Revision,
selection?: TextSelection | AllSelection
) => {
replaceSelection = (template: Document | Revision, selection?: Selection) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -418,14 +415,19 @@ class DocumentScene extends React.Component<Props> {
});
handleSelectTemplate = async (template: Document | Revision) => {
const doc = this.editor.current?.view.state.doc;
if (!doc) {
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
const { view } = editorRef;
const doc = view.state.doc;
return this.replaceSelection(
template,
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
ProsemirrorHelper.isEmpty(doc)
? new AllSelection(doc)
: view.state.selection
);
};
@@ -56,7 +56,7 @@ type Props = {
const lineHeight = "1.25";
const fontSize = "2.25em";
const DocumentTitle = React.forwardRef(function _DocumentTitle(
const DocumentTitle = React.forwardRef(function DocumentTitle_(
{
documentId,
title,
+4 -1
View File
@@ -11,6 +11,7 @@ import useStores from "~/hooks/useStores";
import {
collectionPath,
documentPath,
homePath,
settingsPath,
} from "~/utils/routeHelpers";
@@ -67,7 +68,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collectionPath(collection?.path || "/");
: collection
? collectionPath(collection)
: homePath();
history.push(path);
}
+20 -15
View File
@@ -10,6 +10,7 @@ import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -21,6 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { ExportCSV } from "./components/ExportCSV";
import { MembersTable } from "./components/MembersTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
@@ -144,21 +146,24 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
<StickyFilters gap={8} justify="space-between">
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<ExportCSV reqParams={reqParams} />
</StickyFilters>
<ConditionalFade animate={!data}>
<MembersTable
@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import { CSVHelper } from "@shared/utils/csv";
import download from "~/utils/download";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import useCurrentTeam from "~/hooks/useCurrentTeam";
type Props = {
/** Request parameters for filtering users */
reqParams: {
query?: string;
filter?: string;
role?: string;
sort?: string;
direction?: "ASC" | "DESC";
};
};
/**
* A button that exports all members to a CSV file.
*/
export function ExportCSV({ reqParams }: Props) {
const { t } = useTranslation();
const { users } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const [isExporting, setIsExporting] = useState(false);
const handleExportCSV = useCallback(async () => {
setIsExporting(true);
try {
const allUsers = await users.fetchAll({
...reqParams,
limit: 100,
});
// Convert to CSV format with formatted dates
const csvData = allUsers.map((user) => ({
id: user.id,
name: user.name,
email: user.email || "",
role: user.role,
lastActiveAt: user.lastActiveAt
? new Date(user.lastActiveAt).toISOString()
: "",
createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : "",
}));
const headers: (keyof (typeof csvData)[0])[] = [
"id",
"name",
"email",
"role",
"lastActiveAt",
"createdAt",
];
const csv = CSVHelper.convertToCSV(csvData, headers);
// Trigger download
download(csv, "members.csv", "text/csv");
toast.success(t("Members exported successfully"));
} catch {
toast.error(t("Failed to export members"));
} finally {
setIsExporting(false);
}
}, [users, reqParams, t]);
if (!can.createExport) {
return null;
}
return (
<Button
type="button"
onClick={handleExportCSV}
disabled={isExporting}
neutral
>
{isExporting ? t("Exporting") + "…" : t("Download CSV")}
</Button>
);
}
+2 -2
View File
@@ -40,7 +40,7 @@ function SharedCollection({ collection }: Props) {
as={Link}
icon={<EditIcon />}
to={{
pathname: collectionPath(collection.path, "overview"),
pathname: collectionPath(collection, "overview"),
}}
neutral
>
@@ -83,7 +83,7 @@ function SharedCollection({ collection }: Props) {
</SharedMeta>
) : null}
</Flex>
<Overview collection={collection} shareId={shareId} />
<Overview collection={collection} readOnly />
</CenteredContent>
</Scene>
);
+4 -6
View File
@@ -15,7 +15,7 @@ describe("generateEmojiNameFromFilename", () => {
);
});
test("should replace spaces with underscores", () => {
test("should replace spaces and dashes with underscores", () => {
expect(generateEmojiNameFromFilename("party parrot.gif")).toBe(
"party_parrot"
);
@@ -26,7 +26,7 @@ describe("generateEmojiNameFromFilename", () => {
test("should remove invalid characters", () => {
expect(generateEmojiNameFromFilename("party-parrot.gif")).toBe(
"partyparrot"
"party_parrot"
);
expect(generateEmojiNameFromFilename("happy!@#$%.png")).toBe("happy");
expect(generateEmojiNameFromFilename("emoji(1).png")).toBe("emoji");
@@ -57,9 +57,7 @@ describe("generateEmojiNameFromFilename", () => {
expect(generateEmojiNameFromFilename("party___parrot.gif")).toBe(
"party_parrot"
);
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe(
"test_emoji"
);
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe("test_emoji");
});
test("should handle complex filenames", () => {
@@ -67,7 +65,7 @@ describe("generateEmojiNameFromFilename", () => {
"party_parrot"
);
expect(generateEmojiNameFromFilename("dumpster-fire-2023.png")).toBe(
"dumpsterfire"
"dumpster_fire"
);
});
+6 -3
View File
@@ -6,7 +6,7 @@ export function emojiToUrl(text: string) {
* Generates a valid emoji name from a filename by:
* - Removing file extension
* - Converting to lowercase
* - Replacing spaces with underscores
* - Replacing spaces and dashes with underscores
* - Removing invalid characters (only allowing lowercase letters and underscores)
* - Removing numbers
* - Removing leading/trailing underscores
@@ -18,8 +18,11 @@ export function generateEmojiNameFromFilename(filename: string): string {
// Remove file extension
const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
// Convert to lowercase, replace spaces with underscores
let name = nameWithoutExt.toLowerCase().replace(/\s+/g, "_");
// Convert to lowercase, replace spaces and dashes with underscores
let name = nameWithoutExt
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/-+/g, "_");
// Remove all characters that aren't lowercase letters or underscores (including numbers)
name = name.replace(/[^a-z_]/g, "");
+2 -8
View File
@@ -14,20 +14,14 @@ export const isURLMentionable = ({
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
return hostname === "github.com";
}
case IntegrationService.Linear: {
const pathParts = pathname.split("/");
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
+15 -6
View File
@@ -1,7 +1,7 @@
import queryString from "query-string";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import type Collection from "~/models/Collection";
import type Comment from "~/models/Comment";
import type Document from "~/models/Document";
import env from "~/env";
export function homePath(): string {
@@ -37,11 +37,18 @@ export function commentPath(document: Document, comment: Comment): string {
}`;
}
export function collectionPath(url: string, section?: string): string {
export function collectionPath(
collection: Collection,
section?: string
): string {
if (section) {
return `${url}/${section}`;
return `${collection.path}/${section}`;
}
return url;
return collection.path;
}
export function collectionEditPath(collection: Collection): string {
return collectionPath(collection, "overview/edit");
}
export function updateCollectionPath(
@@ -144,6 +151,8 @@ export function urlify(path: string): string {
export const matchCollectionSlug =
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
export const matchCollectionEdit = `/collection/${matchCollectionSlug}/overview/edit`;
export const matchDocumentSlug =
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
+10 -10
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.927.0",
"@aws-sdk/lib-storage": "3.927.0",
"@aws-sdk/s3-presigned-post": "3.927.0",
"@aws-sdk/s3-request-presigner": "3.927.0",
"@aws-sdk/signature-v4-crt": "^3.927.0",
"@aws-sdk/client-s3": "3.946.0",
"@aws-sdk/lib-storage": "3.946.0",
"@aws-sdk/s3-presigned-post": "3.946.0",
"@aws-sdk/s3-request-presigner": "3.946.0",
"@aws-sdk/signature-v4-crt": "^3.946.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -136,7 +136,7 @@
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"form-data": "^4.0.4",
"form-data": "^4.0.5",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"franc": "^6.2.0",
@@ -182,7 +182,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.13.1",
"outline-icons": "^3.15.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -200,7 +200,7 @@
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.4",
@@ -353,10 +353,10 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.30",
"discord-api-types": "^0.38.36",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
"ioredis-mock": "^8.13.1",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
+13 -1
View File
@@ -4,6 +4,7 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Sequelize } from "sequelize";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
@@ -229,11 +230,22 @@ export class GitHub {
return;
}
// Find integration, prioritizing one where the installation account matches the resource owner
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
"settings.github.installation.account.name": resource.owner,
},
order: [
[
Sequelize.literal(
`CASE WHEN "settings"->'github'->'installation'->'account'->>'name' = :owner THEN 0 ELSE 1 END`
),
"ASC",
],
],
replacements: {
owner: resource.owner,
},
})) as Integration<IntegrationType.Embed>;
@@ -0,0 +1,55 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("mentions", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
},
},
mentionedUserId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
mentionType: {
type: Sequelize.STRING,
allowNull: false,
},
mentionId: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex("mentions", ["mentionedUserId"]);
await queryInterface.addIndex("mentions", ["documentId"]);
await queryInterface.addIndex("mentions", ["mentionId", "mentionType"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("mentions");
},
};
@@ -0,0 +1,16 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addIndex("authentications", ["teamId", "service"], {
name: "authentications_team_id_service",
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex(
"authentications",
"authentications_team_id_service"
);
},
};
@@ -0,0 +1,27 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Add composite index on service and type for better filtering
await queryInterface.addIndex("integrations", ["service", "type"], {
name: "integrations_service_type",
});
// Add GIN index on settings for JSONB queries
// Using raw SQL as Sequelize doesn't support GIN index type natively
await queryInterface.sequelize.query(
'CREATE INDEX "integrations_settings_gin" ON "integrations" USING GIN ("settings");'
);
},
async down(queryInterface, Sequelize) {
// Drop indexes in reverse order of creation
await queryInterface.sequelize.query(
'DROP INDEX IF EXISTS "integrations_settings_gin";'
);
await queryInterface.removeIndex(
"integrations",
"integrations_service_type"
);
},
};
+4
View File
@@ -62,6 +62,7 @@ import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Mention from "./Mention";
import Relationship from "./Relationship";
import Revision from "./Revision";
import Star from "./Star";
@@ -668,6 +669,9 @@ class Document extends ArchivableModel<
@HasMany(() => Relationship)
relationships: Relationship[];
@HasMany(() => Mention)
mentions: Mention[];
@HasMany(() => Star)
starred: Star[];
+104
View File
@@ -0,0 +1,104 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
} from "sequelize-typescript";
import { MentionType } from "@shared/types";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "mentions", modelName: "mention" })
@Fix
class Mention extends IdModel<
InferAttributes<Mention>,
Partial<InferCreationAttributes<Mention>>
> {
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "mentionedUserId")
mentionedUser: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
mentionedUserId: string;
@Column(DataType.STRING)
mentionType: MentionType;
@Column(DataType.STRING)
mentionId: string;
/**
* Find all mentions for a user in documents they have access to
*
* @param userId The user ID to find mentions for
* @param user The user to check document access for
*/
public static async findMentionsForUser(userId: string, user: User) {
// Lazy import to avoid circular dependency
const { can } = await import("@server/policies");
const mentions = await this.findAll({
where: {
mentionedUserId: userId,
},
include: [
{
model: Document,
as: "document",
},
],
});
// Filter mentions to only include documents the user has access to
const accessibleMentions = [];
for (const mention of mentions) {
if (mention.document) {
const hasAccess = can(user, "read", mention.document);
if (hasAccess) {
accessibleMentions.push(mention);
}
}
}
return accessibleMentions;
}
/**
* Find all mentions in a specific document
*
* @param documentId The document ID to find mentions for
*/
public static async findMentionsInDocument(documentId: string) {
return this.findAll({
where: {
documentId,
},
include: [
{
model: User,
as: "mentionedUser",
},
],
});
}
}
export default Mention;
+4
View File
@@ -57,6 +57,7 @@ import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Group from "./Group";
import Mention from "./Mention";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import UserMembership from "./UserMembership";
@@ -248,6 +249,9 @@ class User extends ParanoidModel<
@HasMany(() => UserAuthentication)
authentications: UserAuthentication[];
@HasMany(() => Mention, "mentionedUserId")
mentions: Mention[];
// getters
get isSuspended(): boolean {
+2
View File
@@ -32,6 +32,8 @@ export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
export { default as Mention } from "./Mention";
export { default as Notification } from "./Notification";
export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication";
+98 -43
View File
@@ -1,6 +1,4 @@
import flattenDeep from "lodash/flattenDeep";
import isObject from "lodash/isPlainObject";
import uniq from "lodash/uniq";
import { Model } from "sequelize-typescript";
import { AuthorizationError } from "@server/errors";
@@ -26,8 +24,6 @@ type Ability = {
* This is originally adapted from https://www.npmjs.com/package/cancan
*/
export class CanCan {
public abilities: Ability[] = [];
/**
* Define an authorized ability for a model, action, and target.
*
@@ -58,7 +54,17 @@ export class CanCan {
(this.toArray(actions) as string[]).forEach((action) => {
(this.toArray(targets) as T[]).forEach((target) => {
this.abilities.push({ model, action, target, condition } as Ability);
const ability = { model, action, target, condition } as Ability;
// Add to index
if (!this.abilities.has(model)) {
this.abilities.set(model, new Map());
}
const actionMap = this.abilities.get(model)!;
if (!actionMap.has(action)) {
actionMap.set(action, []);
}
actionMap.get(action)!.push(ability);
});
});
};
@@ -85,25 +91,31 @@ export class CanCan {
);
// Check conditions only for matching abilities
const conditions = uniq(
flattenDeep(
matchingAbilities.map((ability) => {
if (!ability.condition) {
return false;
}
return ability.condition(performer, target, options || {});
})
)
);
const seenConditions = new Set<boolean | string>();
const membershipIds: string[] = [];
let hasNonMembershipMatch = false;
const matchingConditions = conditions.filter(Boolean);
const matchingMembershipIds = matchingConditions.filter(
(m) => typeof m === "string"
) as string[];
for (const ability of matchingAbilities) {
if (!ability.condition) {
continue;
}
return matchingMembershipIds.length > 0
? matchingMembershipIds
: matchingConditions.length > 0;
const result = ability.condition(performer, target, options);
if (!result || seenConditions.has(result)) {
continue;
}
seenConditions.add(result);
if (typeof result === "string") {
membershipIds.push(result);
} else {
hasNonMembershipMatch = true;
}
}
return membershipIds.length > 0 ? membershipIds : hasNonMembershipMatch;
};
/*
@@ -113,22 +125,31 @@ export class CanCan {
*/
public serialize = (performer: Model, target: Model | null): Policy => {
const output: Record<string, boolean | string[]> = {};
abilities.forEach((ability) => {
if (
performer instanceof ability.model &&
target instanceof (ability.target as any)
) {
let response: boolean | string[] = true;
try {
response = this.can(performer, ability.action, target);
} catch (_err) {
response = false;
// Get all unique actions to check from the index
const actionsToCheck = new Set<string>();
for (const [model, actionMap] of this.abilities.entries()) {
if (performer instanceof model) {
for (const [action, abilities] of actionMap.entries()) {
for (const ability of abilities) {
if (target instanceof (ability.target as any)) {
actionsToCheck.add(action);
break;
}
}
}
}
}
output[ability.action] = response;
// Check each unique action once
actionsToCheck.forEach((action) => {
try {
output[action] = this.can(performer, action, target);
} catch (_err) {
output[action] = false;
}
});
return output;
};
@@ -174,15 +195,49 @@ export class CanCan {
performer: Model,
action: string,
target: Model | null | undefined
) =>
this.abilities.filter(
(ability) =>
performer instanceof ability.model &&
(ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)) &&
(ability.action === "manage" || action === ability.action)
);
) => {
const matchingAbilities: Ability[] = [];
// Use index to find abilities by model and action
for (const [model, actionMap] of this.abilities.entries()) {
if (!(performer instanceof model)) {
continue;
}
// Check for specific action
const specificAbilities = actionMap.get(action);
if (specificAbilities) {
for (const ability of specificAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
// Check for "manage" action (applies to all actions)
const manageAbilities = actionMap.get("manage");
if (manageAbilities) {
for (const ability of manageAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
}
return matchingAbilities;
};
// Index for fast lookups: Map<model, Map<action, Ability[]>>
private abilities: Map<Constructor, Map<string, Ability[]>> = new Map();
private get = <T extends object>(obj: T, key: keyof T) =>
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
@@ -219,7 +274,7 @@ export class CanCan {
const cancan = new CanCan();
export const { allow, can, cannot, abilities, serialize } = cancan;
export const { allow, can, cannot, serialize } = cancan;
// This is exported separately as a workaround for the following issue:
// https://github.com/microsoft/TypeScript/issues/36931
+18 -9
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow } from "./cancan";
@@ -7,10 +6,10 @@ import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
and(
isTeamModel(actor, team),
isTeamMutable(actor),
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor),
or(actor.isAdmin, !!team?.memberCollectionCreate)
)
);
@@ -26,9 +25,9 @@ allow(User, "importCollection", Team, (actor, team) =>
allow(User, "move", Collection, (actor, collection) =>
and(
//
!!collection?.isActive,
isTeamAdmin(actor, collection),
isTeamMutable(actor),
!!collection?.isActive
isTeamMutable(actor)
)
);
@@ -194,10 +193,20 @@ function includesMembership(
"Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?"
);
const membershipIds = filter(
[...collection.memberships, ...collection.groupMemberships],
(m) => permissions.includes(m.permission as CollectionPermission)
).map((m) => m.id);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of collection.memberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of collection.groupMemberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
return membershipIds.length > 0 ? membershipIds : false;
}
+45 -31
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { DocumentPermission, TeamPreference } from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, cannot, can } from "./cancan";
@@ -36,8 +35,8 @@ allow(User, "read", Document, (actor, document) =>
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
or(
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
)
);
@@ -53,14 +52,14 @@ allow(User, "download", Document, (actor, document) =>
allow(User, "comment", Document, (actor, document) =>
and(
// TODO: We'll introduce a separate permission for commenting
or(
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
// TODO: We'll introduce a separate permission for commenting
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
),
or(!document?.collection, document?.collection?.commenting !== false)
)
);
@@ -72,26 +71,26 @@ allow(
(actor, document) =>
and(
//
can(actor, "read", document),
!document?.template
!document?.template,
can(actor, "read", document)
)
);
allow(User, "share", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
can(actor, "read", document),
or(!document?.collection, can(actor, "share", document?.collection))
)
);
allow(User, "update", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
isTeamMutable(actor),
can(actor, "read", document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -115,8 +114,8 @@ allow(User, "update", Document, (actor, document) =>
allow(User, "publish", Document, (actor, document) =>
and(
//
can(actor, "update", document),
!!document?.isDraft
!!document?.isDraft,
can(actor, "update", document)
)
);
@@ -171,35 +170,40 @@ allow(User, "move", Document, (actor, document) =>
);
allow(User, "createChildDocument", Document, (actor, document) =>
and(can(actor, "update", document), !document?.isDraft, !document?.template)
and(
//
!document?.isDraft,
!document?.template,
can(actor, "update", document)
)
);
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
and(
can(actor, "update", document),
can(actor, "update", document?.collection),
!document?.isDraft,
!document?.template,
!actor.isGuest
!actor.isGuest,
can(actor, "update", document),
can(actor, "update", document?.collection)
)
);
allow(User, "pinToHome", Document, (actor, document) =>
and(
//
isTeamAdmin(actor, document),
isTeamMutable(actor),
!document?.isDraft,
!document?.template,
!!document?.isActive
!!document?.isActive,
isTeamAdmin(actor, document),
isTeamMutable(actor)
)
);
allow(User, "delete", Document, (actor, document) =>
and(
!document?.isDeleted,
isTeamModel(actor, document),
isTeamMutable(actor),
!document?.isDeleted,
or(
can(actor, "unarchive", document),
can(actor, "update", document),
@@ -210,9 +214,9 @@ allow(User, "delete", Document, (actor, document) =>
allow(User, "restore", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -231,9 +235,9 @@ allow(User, "restore", Document, (actor, document) =>
allow(User, "permanentDelete", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
isTeamAdmin(actor, document)
)
);
@@ -322,10 +326,20 @@ function includesMembership(
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
);
const membershipIds = filter(
[...document.memberships, ...document.groupMemberships],
(m) => permissions.includes(m.permission as DocumentPermission)
).map((m) => m.id);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of document.memberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of document.groupMemberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
return membershipIds.length > 0 ? membershipIds : false;
}
+6 -2
View File
@@ -7,8 +7,12 @@ import invariant from "invariant";
type Args = boolean | string | Args[];
export function and(...args: Args[]) {
const filtered = args.filter(Boolean);
return filtered.length === args.length ? filtered : false;
for (const arg of args) {
if (!arg) {
return false;
}
}
return args;
}
export function or(...args: Args[]) {
+2 -2
View File
@@ -14,7 +14,7 @@ type Options = {
includeText?: boolean;
/** Always include the data of the document in the payload. */
includeData?: boolean;
/** Include the updatedAt timestamp for public documents. */
includeUpdatedAt?: boolean;
};
@@ -56,7 +56,6 @@ async function presentDocument(
text,
icon: document.icon,
color: document.color,
tasks: document.tasks,
language: document.language,
createdAt: document.createdAt,
createdBy: undefined,
@@ -85,6 +84,7 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
res.tasks = document.tasks;
res.isCollectionDeleted = await document.isCollectionDeleted();
res.collectionId = document.collectionId;
res.parentDocumentId = document.parentDocumentId;
@@ -0,0 +1,232 @@
import { v4 as uuidv4 } from "uuid";
import { parser } from "@server/editor";
import { Mention } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import MentionsProcessor from "./MentionsProcessor";
describe("MentionsProcessor", () => {
it("should create new mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
});
it("should not create mention records for unpublished documents", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
publishedAt: null,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
createdAt: new Date().toISOString(),
actorId: user.id!,
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
it("should update mention records when document is updated", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to mention a different user
const mentionId2 = uuidv4();
const newText = `Hello @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(anotherMentionedUser.id);
});
it("should destroy removed mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const mentionId2 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id}) and @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to remove one mention
const mentionId3 = uuidv4();
const newText = `Hello @[${mentionedUser.name}](mention://${mentionId3}/user/${mentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(mentionedUser.id);
});
it("should destroy related mentions", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
await processor.perform({
name: "documents.delete",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
});
@@ -0,0 +1,119 @@
import { Op } from "sequelize";
import { MentionType } from "@shared/types";
import { Document, Mention, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class MentionsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.update",
"documents.delete",
];
async perform(event: DocumentEvent | RevisionEvent) {
switch (event.name) {
case "documents.publish": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
})
);
break;
}
case "documents.update": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
// mentions are only created for published documents
if (!document.publishedAt) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const mentionIds: string[] = [];
// create or find existing mention records for mentioned users
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
mentionIds.push(mention.id);
})
);
// delete any mentions that no longer exist
await Mention.destroy({
where: {
mentionId: {
[Op.notIn]: mentionIds,
},
documentId: event.documentId,
},
});
break;
}
case "documents.delete": {
await Mention.destroy({
where: {
documentId: event.documentId,
},
});
break;
}
default:
}
}
}
@@ -65,8 +65,9 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const threshold = subWeeks(now, env.POPULARITY_ACTIVITY_THRESHOLD_WEEKS);
// Generate unique table name for this run to prevent conflicts
const uniqueId = crypto.randomBytes(8).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${uniqueId}`;
const dateStr = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const uniqueId = crypto.randomBytes(4).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${dateStr}_${uniqueId}`;
try {
// Setup: Create working table and populate with active document IDs
@@ -150,31 +151,64 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const [startUuid, endUuid] = this.getPartitionBounds(partition);
// Populate with documents that have recent activity and are valid
// (published, not deleted). Using JOINs to filter upfront.
await sequelize.query(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
// (published, not deleted). Process in chunks to avoid long-running queries.
let offset = 0;
let insertedCount = 0;
const chunkSize = 500;
while (true) {
const result = await sequelize.query<{ documentId: string }>(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id as "documentId"
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
)
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
)
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
`,
{ replacements: { threshold, startUuid, endUuid } }
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
ORDER BY d.id
LIMIT :limit
OFFSET :offset
ON CONFLICT ("documentId") DO NOTHING
RETURNING "documentId"
`,
{
replacements: {
threshold,
startUuid,
endUuid,
limit: chunkSize,
offset,
},
type: QueryTypes.SELECT,
}
);
insertedCount += result.length;
if (result.length < chunkSize) {
break;
}
offset += chunkSize;
}
Logger.debug(
"task",
`Populated working table with ${insertedCount} documents in ${Math.ceil(insertedCount / chunkSize)} chunks`
);
// Create index on processed column for efficient batch selection
@@ -10,6 +10,7 @@ import {
buildComment,
buildCommentMark,
buildDocument,
buildEmoji,
buildResolvedComment,
buildTeam,
buildUser,
@@ -886,6 +887,72 @@ describe("#comments.add_reaction", () => {
]);
expect(addedReaction).toBeTruthy();
});
it("should add a custom emoji reaction to a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const addedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: emoji.id, userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: emoji.id, userIds: [user.id] },
]);
expect(addedReaction).toBeTruthy();
});
it("should fail with custom emoji from different team", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: otherTeam.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#comments.remove_reaction", () => {
+10 -1
View File
@@ -5,14 +5,16 @@ import {
CommentStatusFilter,
TeamPreference,
MentionType,
IconType,
} from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { parser } from "@server/editor";
import auth from "@server/middlewares/authentication";
import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Comment, Collection, Reaction } from "@server/models";
import { Document, Comment, Collection, Reaction, Emoji } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize } from "@server/policies";
@@ -407,6 +409,13 @@ router.post(
authorize(user, "comment", document);
authorize(user, "addReaction", comment);
if (determineIconType(emoji) === IconType.Custom) {
const customEmoji = await Emoji.findByPk(emoji, {
transaction,
});
authorize(user, "read", customEmoji);
}
await Reaction.findOrCreate({
where: {
emoji,
+2 -2
View File
@@ -1,8 +1,8 @@
import emojiRegex from "emoji-regex";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { CommentStatusFilter } from "@shared/types";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
import { zodEmojiType } from "@server/utils/zod";
const BaseIdSchema = z.object({
/** Comment Id */
@@ -104,7 +104,7 @@ export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>;
export const CommentsReactionSchema = z.object({
body: BaseIdSchema.extend({
/** Emoji that's added to (or) removed from a comment as a reaction. */
emoji: z.string().regex(emojiRegex()),
emoji: zodEmojiType(),
}),
});
+2
View File
@@ -469,6 +469,7 @@ router.post(
]),
required: true,
where: {
teamId: user.teamId,
collectionId: collectionIds,
},
include: [
@@ -522,6 +523,7 @@ router.post(
? [collectionId]
: await user.collectionIds();
const where: WhereOptions = {
teamId: user.teamId,
createdById: user.id,
collectionId: {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
+1
View File
@@ -2,6 +2,7 @@ if (process.env.NODE_ENV !== "test") {
// oxlint-disable-next-line @typescript-eslint/no-var-requires
require("@dotenvx/dotenvx").config({
silent: true,
ignore: ["MISSING_ENV_FILE"],
});
}
+34
View File
@@ -26,6 +26,7 @@ import {
User,
Event,
Document,
Emoji,
Star,
Collection,
Group,
@@ -575,6 +576,39 @@ export async function buildAttachment(
});
}
export async function buildEmoji(
overrides: Partial<Emoji> = {}
): Promise<Emoji> {
if (!overrides.teamId) {
const team = await buildTeam();
overrides.teamId = team.id;
}
if (!overrides.createdById) {
const user = await buildUser({
teamId: overrides.teamId,
});
overrides.createdById = user.id;
}
if (!overrides.attachmentId) {
const attachment = await buildAttachment({
teamId: overrides.teamId,
userId: overrides.createdById,
contentType: "image/png",
});
overrides.attachmentId = attachment.id;
}
return Emoji.create({
name: faker.word
.adjective()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_"),
...overrides,
});
}
export async function buildWebhookSubscription(
overrides: Partial<WebhookSubscription> = {}
): Promise<WebhookSubscription> {
+30 -5
View File
@@ -1,5 +1,11 @@
import Redlock, { type Lock } from "redlock";
import Redis from "@server/storage/redis";
import ShutdownHelper, { ShutdownOrder } from "./ShutdownHelper";
type AcquireOptions = {
/** Whether a lock should be automatically released on server shutdown */
releaseOnShutdown?: boolean;
};
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
@@ -25,8 +31,19 @@ export class MutexLock {
* @param timeout The duration to acquire the lock for if not released in milliseconds
* @returns A promise that resolves a to a Lock
*/
public static acquire(resource: string, timeout: number) {
return this.lock.acquire([resource], timeout);
public static async acquire(
resource: string,
timeout: number,
options?: AcquireOptions
) {
const lock = await this.lock.acquire([resource], timeout);
if (options?.releaseOnShutdown) {
const key = `lock:${resource}`;
// @ts-expect-error Attach resource for use in shutdown
lock._key = key;
ShutdownHelper.add(key, ShutdownOrder.last, lock.release.bind(lock));
}
return lock;
}
/**
@@ -35,10 +52,18 @@ export class MutexLock {
* @param lock The lock to release
*/
public static release(lock: Lock) {
if (lock && lock.expiration > new Date().getTime()) {
return lock.release();
try {
if (lock && lock.expiration > new Date().getTime()) {
return lock.release();
}
return false;
} finally {
// @ts-expect-error Attach resource for use in shutdown
const key = lock._key;
if (key) {
ShutdownHelper.remove(key);
}
}
return false;
}
private static redlock: Redlock;
+16 -7
View File
@@ -9,7 +9,7 @@ export enum ShutdownOrder {
}
type Handler = {
name: string;
key: string;
order: ShutdownOrder;
callback: () => Promise<unknown>;
};
@@ -38,15 +38,24 @@ export default class ShutdownHelper {
/**
* Add a shutdown handler to be executed when the process is exiting
*
* @param name The name of the handler
* @param key The key of the handler
* @param callback The callback to execute
*/
public static add(
name: string,
key: string,
order: ShutdownOrder,
callback: () => Promise<unknown>
) {
this.handlers.push({ name, order, callback });
this.handlers.push({ key, order, callback });
}
/**
* Remove a shutdown handler, if it exists
*
* @param key The key of the handler to remove
*/
public static remove(key: string) {
this.handlers = this.handlers.filter((handler) => handler.key !== key);
}
/**
@@ -75,14 +84,14 @@ export default class ShutdownHelper {
await Promise.allSettled(
handlers.map(async (handler) => {
Logger.debug("lifecycle", `Running shutdown handler ${handler.name}`);
Logger.debug("lifecycle", `Running shutdown handler ${handler.key}`);
await handler.callback().catch((error) => {
Logger.error(
`Error inside shutdown handler ${handler.name}`,
`Error inside shutdown handler ${handler.key}`,
error,
{
name: handler.name,
key: handler.key,
}
);
});
+1 -1
View File
@@ -1,4 +1,4 @@
/* oxlint-disable no-restricted-imports */
/* oxlint-disable no-restricted-imports, react/rules-of-hooks */
import http from "http";
import https from "https";
import nodeFetch, { type RequestInit, type Response } from "node-fetch";
+4 -1
View File
@@ -12,7 +12,10 @@ import { Minute } from "@shared/utils/time";
export async function checkPendingMigrations() {
let lock;
try {
lock = await MutexLock.acquire("migrations", 10 * Minute.ms);
lock = await MutexLock.acquire("migrations", 10 * Minute.ms, {
releaseOnShutdown: true,
});
const pending = await migrations.pending();
if (!isEmpty(pending)) {
if (getArg("no-migrate")) {
+3
View File
@@ -23,6 +23,9 @@ export const zodIconType = () =>
z.string().uuid(),
]);
export const zodEmojiType = () =>
z.union([z.string().regex(emojiRegex()), z.string().uuid()]);
export const zodShareIdType = () =>
z.union([
z.string().uuid(),
+257
View File
@@ -0,0 +1,257 @@
import { chainCommands, toggleMark } from "prosemirror-commands";
import { Attrs } from "prosemirror-model";
import {
Command,
NodeSelection,
Selection,
TextSelection,
} from "prosemirror-state";
import { getMarkRange } from "../queries/getMarkRange";
import { toast } from "sonner";
import { sanitizeUrl } from "@shared/utils/urls";
import { getMarkRangeNodeSelection } from "../queries/getMarkRange";
import { NodeMarkAttr } from "@shared/editor/types";
const addLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
dispatch?.(
state.tr
.setSelection(TextSelection.create(state.doc, state.tr.selection.to))
.addMark(
state.selection.from,
state.selection.to,
state.schema.marks.link.create(attrs)
)
);
return true;
};
const addLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { selection } = state;
const existingMarks = selection.node.attrs.marks ?? [];
const newMark = {
type: "link",
attrs,
};
const updatedMarks = [...existingMarks, newMark];
dispatch?.(
state.tr.setNodeAttribute(selection.from, "marks", updatedMarks)
);
return true;
};
const openLinkTextSelection =
(
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
(state) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark && onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
onClickLink(sanitizeUrl(range.mark.attrs.href) ?? "", event);
} catch (_err) {
toast.error(dictionary.openLinkError);
}
return true;
}
return false;
};
const openLinkNodeSelection =
(
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
(state) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
if (!onClickLink) {
return false;
}
const marks = state.selection.node.attrs.marks ?? [];
const linkMark = marks.find((mark: NodeMarkAttr) => mark.type === "link");
if (!linkMark) {
return false;
}
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
onClickLink(sanitizeUrl(linkMark.attrs.href) ?? "", event);
} catch (_err) {
toast.error(dictionary.openLinkError);
}
return true;
};
const updateLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, state.schema.marks.link)
.addMark(range.from, range.to, state.schema.marks.link.create(attrs))
);
return true;
}
return false;
};
const updateLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const markRange = getMarkRangeNodeSelection(
state.selection,
state.schema.marks.link
);
if (!markRange) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.map((mark: NodeMarkAttr) =>
mark.type === "link"
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
const nextValidSelection =
Selection.findFrom(state.doc.resolve(markRange.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextValidSelection)
.setNodeAttribute(state.selection.from, "marks", updatedMarks)
);
return true;
};
const removeLinkTextSelection = (): Command => (state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, range.mark)
);
return true;
}
return false;
};
const removeLinkNodeSelection = (): Command => (state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const markRange = getMarkRangeNodeSelection(
state.selection,
state.schema.marks.link
);
if (!markRange) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.filter(
(mark: NodeMarkAttr) => mark.type !== "link"
);
const nextValidSelection =
Selection.findFrom(state.doc.resolve(markRange.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextValidSelection)
.setNodeAttribute(state.selection.from, "marks", updatedMarks)
);
return true;
};
const toggleLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
return toggleMark(state.schema.marks.link, attrs)(state, dispatch);
};
const toggleLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const linkMark = existingMarks.find(
(mark: NodeMarkAttr) => mark.type === "link"
);
if (linkMark) {
return removeLinkNodeSelection()(state, dispatch);
} else {
return addLinkNodeSelection(attrs)(state, dispatch);
}
};
export const toggleLink = (attrs: Attrs): Command =>
chainCommands(toggleLinkTextSelection(attrs), toggleLinkNodeSelection(attrs));
export const addLink = (attrs: Attrs): Command =>
chainCommands(addLinkTextSelection(attrs), addLinkNodeSelection(attrs));
export const openLink = (
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
chainCommands(
openLinkTextSelection(onClickLink, dictionary),
openLinkNodeSelection(onClickLink, dictionary)
);
export const updateLink = (attrs: Attrs): Command =>
chainCommands(updateLinkTextSelection(attrs), updateLinkNodeSelection(attrs));
export const removeLink = (): Command =>
chainCommands(removeLinkTextSelection(), removeLinkNodeSelection());
+97
View File
@@ -24,10 +24,13 @@ import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
import { CSVHelper } from "../../utils/csv";
import { chainTransactions } from "../lib/chainTransactions";
import {
getAllSelectedColumns,
getCellsInColumn,
getCellsInRow,
isHeaderEnabled,
isTableSelected,
getWidthFromDom,
getWidthFromNodes,
} from "../queries/table";
import { TableLayout } from "../types";
import { collapseSelection } from "./collapseSelection";
@@ -174,6 +177,100 @@ export function exportTable({
};
}
/**
* A Commands that distributes the width of all selected columns evenly between them in the current table selection.
*
*
* @returns {Command}
*/
export function distributeColumns(): Command {
return (state, dispatch, view) => {
if (!isInTable(state) || !dispatch) {
return false;
}
const rect = selectedRect(state);
const { tr, doc } = state;
const { map } = rect;
const selectedColumns = getAllSelectedColumns(state);
if (selectedColumns.length <= 1) {
return false;
}
const hasNullWidth = selectedColumns.some((colIndex) =>
isNullWidth({ state, colIndex })
);
// whenever we can, we want to take the column width that prose-mirror sets
// since that will always be accurate, when set
const totalWidth = hasNullWidth
? getWidthFromDom({ view, rect, selectedColumns })
: getWidthFromNodes({ state, selectedColumns });
if (totalWidth < 1) {
return false;
}
const evenWidth = totalWidth / selectedColumns.length;
const isLastColSelected = selectedColumns.includes(map.width - 1);
const tableNode = doc.nodeAt(rect.tableStart - 1);
const isFullWidth = tableNode?.attrs.layout === TableLayout.fullWidth;
for (let row = 0; row < map.height; row++) {
const cellsInRow = getCellsInRow(row)(state);
if (!cellsInRow || cellsInRow.length < 1) {
continue;
}
selectedColumns.forEach((colIndex) => {
const pos = cellsInRow[colIndex];
const cell = pos !== undefined ? doc.nodeAt(pos) : null;
if (!cell) {
return;
}
const isLastColumn = colIndex === map.width - 1;
const shouldKeepNull =
isLastColumn && isLastColSelected && isFullWidth && hasNullWidth;
tr.setNodeMarkup(pos, undefined, {
...cell.attrs,
colwidth: shouldKeepNull ? null : [evenWidth],
});
});
}
dispatch(tr);
return true;
};
}
/**
* Determines whether the width of a specified column is null.
*
* @param state - The current editor state.
* @param colIndex - The index of the column to check.
*
* @returns {boolean} True if the column width is null, false otherwise.
*/
function isNullWidth({
state,
colIndex,
}: {
state: EditorState;
colIndex: number;
}): boolean {
const firstRowCells = getCellsInRow(0)(state);
const cell =
firstRowCells?.[colIndex] !== undefined
? state.doc.nodeAt(firstRowCells[colIndex])
: null;
const colwidth = cell?.attrs.colwidth;
return !colwidth?.[0];
}
export function sortTable({
index,
direction,
+58 -23
View File
@@ -1,4 +1,4 @@
import { CrossIcon, DownloadIcon, GlobeIcon } from "outline-icons";
import { CrossIcon, DownloadIcon, GlobeIcon, ZoomInIcon } from "outline-icons";
import type { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
@@ -10,12 +10,15 @@ import { ComponentProps } from "../types";
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
import useDragResize from "./hooks/useDragResize";
import { useTranslation } from "react-i18next";
import find from "lodash/find";
type Props = ComponentProps & {
/** Callback triggered when the image is clicked */
onClick: () => void;
/** Callback triggered when the download button is clicked */
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
/** Callback triggered when the zoom in button is clicked */
onZoomIn?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Callback triggered when the image is resized */
onChangeSize?: (props: { width: number; height?: number }) => void;
/** The editor view */
@@ -66,7 +69,13 @@ const Image = (props: Props) => {
}, [node.attrs.width]);
const sanitizedSrc = sanitizeUrl(src);
const linkMarkType = props.view.state.schema.marks.link;
const imgLink =
find(node.attrs.marks ?? [], (mark) => mark.type === linkMarkType.name)
?.attrs.href ||
// Coalescing to `undefined` to avoid empty string in href because empty string
// in href still shows pointer on hover and click navigates to nowhere
undefined;
const handleOpen = React.useCallback(() => {
window.open(sanitizedSrc, "_blank");
}, [sanitizedSrc]);
@@ -120,12 +129,30 @@ const Image = (props: Props) => {
{!dragging && width > 60 && isDownloadable && (
<Actions>
{isExternalUrl(src) && (
<Button onClick={handleOpen} aria-label={t("Open")}>
<GlobeIcon />
</Button>
<>
<Button onClick={handleOpen} aria-label={t("Open")}>
<GlobeIcon />
</Button>
<Separator height={24} />
</>
)}
{imgLink && (
<>
<Button
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
onMouseDown={(e) => e.stopPropagation()}
onClick={props.onZoomIn}
aria-label={t("Zoom in")}
>
<ZoomInIcon />
</Button>
<Separator height={24} />
</>
)}
<Button
onClick={handleDownload}
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
onMouseDown={(e) => e.stopPropagation()}
aria-label={t("Download")}
disabled={isDownloading}
>
@@ -138,16 +165,18 @@ const Image = (props: Props) => {
<CrossIcon size={16} /> Image failed to load
</Error>
) : (
<>
<a
href={imgLink}
// Do not show hover preview when the image is selected
className={!isSelected ? "use-hover-preview" : ""}
target="_blank"
rel="noopener noreferrer nofollow"
>
<img
className={EditorStyleHelper.imageHandle}
style={{
...widthStyle,
display: loaded ? "block" : "none",
pointerEvents:
dragging || (!props.isSelected && props.isEditable)
? "none"
: "all",
}}
src={sanitizedSrc}
alt={node.attrs.alt || ""}
@@ -175,18 +204,18 @@ const Image = (props: Props) => {
onClick={handleImageClick}
onTouchStart={handleImageTouchStart}
/>
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
getPlaceholder(width, height)
)}`}
/>
)}
</>
</a>
)}
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
getPlaceholder(width, height)
)}`}
/>
)}
{isEditable && !isFullWidth && isResizable && (
<>
@@ -231,7 +260,6 @@ const Actions = styled.div`
display: flex;
align-items: center;
position: absolute;
gap: 1px;
top: 8px;
right: 8px;
opacity: 0;
@@ -316,4 +344,11 @@ const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
}
`;
const Separator = styled.div<{ height?: number }>`
flex-shrink: 0;
width: 1px;
height: ${(props) => props.height || 28}px;
background: ${s("divider")};
`;
export default Image;
+44 -5
View File
@@ -1,7 +1,7 @@
/* oxlint-disable no-irregular-whitespace */
import { lighten, transparentize } from "polished";
import styled, { DefaultTheme, css, keyframes } from "styled-components";
import { hover } from "../../styles";
import { breakpoints, hover } from "../../styles";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { videoStyle } from "./Video";
@@ -785,9 +785,6 @@ img.ProseMirror-separator {
.component-image + img.ProseMirror-separator + br.ProseMirror-trailingBreak {
display: none;
}
.component-image img {
cursor: zoom-in;
}
.${EditorStyleHelper.imageCaption} {
border: 0;
@@ -888,6 +885,41 @@ h6:not(.placeholder)::before {
}
}
.ProseMirror[contenteditable="true"] {
& .image-wrapper.ProseMirror-selectednode > a {
/* force zoom-in cursor if image node is selected */
cursor: zoom-in !important;
}
&.ProseMirror-focused {
.image-wrapper:not(.ProseMirror-selectednode) > a {
/* prevents cursor from turning to pointer on pointer down */
pointer-events: none;
}
}
&:not(.ProseMirror-focused) {
.image-wrapper {
& > a[href] {
cursor: pointer;
}
& > a:not([href]) {
/* prevents cursor from turning to pointer on pointer down */
pointer-events: none;
}
}
}
}
.ProseMirror[contenteditable="false"] {
.image-wrapper {
& > a[href] {
cursor: pointer;
}
& > a:not([href]) {
cursor: zoom-in;
}
}
}
.with-emoji {
margin-${props.rtl ? "right" : "left"}: -1em;
}
@@ -1436,7 +1468,9 @@ code {
font-size: 90%;
.${EditorStyleHelper.codeWord} {
white-space: nowrap;
@media (min-width: ${breakpoints.tablet}px) {
white-space: nowrap;
}
color: ${props.theme.codeKeyword};
}
}
@@ -1902,6 +1936,11 @@ table {
padding-right: ${EditorStyleHelper.padding}px;
transition: border 250ms ease-in-out 0s;
table {
table-layout: fixed;
word-break: break-word;
}
&:hover {
scrollbar-color: ${props.theme.scrollbarThumb} ${
props.theme.scrollbarBackground
+3 -2
View File
@@ -42,7 +42,7 @@ class LightboxMermaidImage extends LightboxImage {
}
private svgToSrc(svg: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
private extractSvg(): string {
@@ -55,7 +55,8 @@ class LightboxMermaidImage extends LightboxImage {
return "";
}
return svg.outerHTML;
const serializer = new XMLSerializer();
return serializer.serializeToString(svg);
}
getElement() {
+24 -103
View File
@@ -1,29 +1,20 @@
import { Token } from "markdown-it";
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkdownSerializerState } from "prosemirror-markdown";
import {
Attrs,
MarkSpec,
MarkType,
Node,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import {
Command,
EditorState,
Plugin,
Selection,
TextSelection,
} from "prosemirror-state";
import { Command, EditorState, Plugin, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { toast } from "sonner";
import { isUrl, sanitizeUrl } from "../../utils/urls";
import { getMarkRange } from "../queries/getMarkRange";
import { isMarkActive } from "../queries/isMarkActive";
import Mark from "./Mark";
import { isInCode } from "../queries/isInCode";
import { addMark } from "../commands/addMark";
import { addLink, openLink, removeLink, updateLink } from "../commands/link";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
@@ -113,102 +104,19 @@ export default class Link extends Mark {
];
}
keys({ type }: { type: MarkType }): Record<string, Command> {
keys(): Record<string, Command> {
return {
"Mod-k": (state, dispatch) => {
if (state.selection.empty) {
return false;
}
return toggleMark(type, { href: "" })(state, dispatch);
},
"Mod-Enter": (state) => {
if (isMarkActive(type)(state)) {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark && this.options.onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
this.options.onClickLink(
sanitizeUrl(range.mark.attrs.href),
event
);
} catch (_err) {
toast.error(this.options.dictionary.openLinkError);
}
return true;
}
}
return false;
},
"Mod-Enter": openLink(this.options.onClickLink, this.options.dictionary),
};
}
commands({ type }: { type: MarkType }) {
commands() {
return {
addLink: (attrs: Attrs): Command => addMark(type, attrs),
updateLink:
(attrs: Attrs): Command =>
(state, dispatch) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, state.schema.marks.link)
.addMark(
range.from,
range.to,
state.schema.marks.link.create(attrs)
)
);
return true;
}
return false;
},
openLink: (): Command => (state) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark && this.options.onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
this.options.onClickLink(sanitizeUrl(range.mark.attrs.href), event);
} catch (_err) {
toast.error(this.options.dictionary.openLinkError);
}
return true;
}
return false;
},
removeLink: (): Command => (state, dispatch) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, range.mark)
);
return true;
}
return false;
},
addLink,
updateLink,
openLink: (): Command =>
openLink(this.options.onClickLink, this.options.dictionary),
removeLink,
};
}
@@ -268,6 +176,19 @@ export default class Link extends Mark {
return false;
}
// If an image is selected in write mode, disallow navigation to its href
const selectedDOMNode = view.nodeDOM(view.state.selection.from);
if (
view.editable &&
selectedDOMNode &&
selectedDOMNode instanceof HTMLSpanElement &&
selectedDOMNode.classList.contains("component-image") &&
event.target instanceof HTMLImageElement &&
selectedDOMNode.contains(event.target)
) {
return false;
}
// clicking a link while editing should show the link toolbar,
// clicking in read-only will navigate
if (!view.editable || (view.editable && !view.hasFocus())) {
@@ -278,7 +199,7 @@ export default class Link extends Mark {
: "");
try {
if (this.options.onClickLink) {
if (this.options.onClickLink && href) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(sanitizeUrl(href), event);

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