Compare commits

...

27 Commits

Author SHA1 Message Date
tommoor b555dab9dc chore: Compressed inefficient images automatically 2025-10-05 20:41:29 +00:00
github-actions[bot] 643188b2f3 chore: Compressed inefficient images automatically (#10303)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-05 16:41:05 -04:00
Tobias Genannt 6f8f25b0d1 Small improvements to the Docker build (#10204)
- Use same Node.js version in build and runner image
- Reduce size of the image by applying the chown directly in the COPY
2025-10-05 16:29:37 -04:00
Tom Moor 10c3edded7 fix: Do not update lastModifiedById on deleted documents (edit history still stored in revisions) (#10302) 2025-10-05 16:08:13 -04:00
Tom Moor 398943d084 feat: Restore 'Copy' button on public code blocks (#10301)
closes #9897
2025-10-05 14:48:50 -04:00
Tom Moor a02677c2b1 fix: Empty state for no collections (#10300) 2025-10-05 14:48:38 -04:00
Tom Moor ebf2029539 fix: Allow formatting toolbar to appear with cell selection (#10299) 2025-10-05 10:54:30 -04:00
Tom Moor 0df42cb4c7 fix: Prefer non-deleted teams in teamProvisioner (#10298) 2025-10-04 14:28:09 -04:00
Salihu 72c9091b7e enhancement: add support for auto linking typed urls (#10266)
* add support for auto linking typed urls

* implement review fixes

* Minor changes

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-04 12:26:15 +00:00
Tom Moor 740e33156d Allow export_all endpoint to include all collections (#10291)
* Allow export_all endpoint to include collections the admin is not a member of

* Update ExportTask.ts
2025-10-04 08:16:22 -04:00
Apoorv Mishra d8ef7b2892 Include mermaid SVGs in Lightbox (#10146)
* fix: include mermaid svgs in lightbox

* Fixes:
1. Focus isn't restored back to mermaid code block when Lightbox is closed
2. Read-only mode requires extra click on to both open and close Lightbox for mermaid SVGs

* fix: `zoom-in` cursor for SVGs

* fix: make SVGs downloadable

* fix: tsc

* fix: graphite

* fix: zoom-in should span the wrapper

* fix: graphite

* fix: name

* fix: no need to re-render mermaid svg within lightbox

fix: rely on `code-block` as the `svg` is updated upon doc change

* fix: graphite

* fix: lightbox crash when mermaid block is deleted

* fix: render mermaid at pos `0`

* fix: graphite

* fix: refactor to simplify Lightbox

* fix: graphite
2025-10-04 08:16:02 -04:00
Tom Moor 0f9146066c fix: Overlap of unread badge on long titles in sidebar (#10296) 2025-10-04 11:56:43 +00:00
Salihu 06a1428cbc fix CORS err on img download (#10279)
* fix CORS err on img download

* add check to prevent accidental double download

* disable download button when downloading
2025-10-04 07:48:06 -04:00
Tom Moor e71a425268 fix: Letter icon not displayed correctly in Starred section (#10292) 2025-10-03 18:25:11 -04:00
wmTJc9IK0Q 12d31468f8 Fix print layout (#10264)
Add print media query to display body as block.
2025-10-03 06:55:42 -04:00
Translate-O-Tron 211c57f6aa New Crowdin updates (#10208)
* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-10-03 06:43:48 -04:00
Tom Moor bb475f3e4e fix: Allow admins to bypass allowed domains (#10290) 2025-10-02 22:14:59 -04:00
Tom Moor 9b95a58822 feat: Add context menus to sidebar items (#10181)
* Add context menu to sidebar document link

* tsc

* tsc

* Add context menu for sidebar collections

* fix

* Starred document context menu
2025-10-02 06:58:05 -04:00
Tom Moor fce02996f9 Add option to choose default TOC visibility on public shares (#10283)
* Add show TOC option

* Revert copy change
2025-10-02 06:53:56 -04:00
codegen-sh[bot] 1aa05b797c Increase JSON payload limit to 5MB for API requests (#10287)
- Add jsonLimit: 5MB to bodyParser configuration in API routes
- Fixes issue with 413 'request entity too large' errors when uploading large documents via API
- Resolves #10239

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-02 06:49:25 -04:00
dependabot[bot] b69feb50a7 chore(deps): bump prosemirror-view from 1.40.1 to 1.41.2 (#10276)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.40.1 to 1.41.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.40.1...1.41.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 06:24:14 -04:00
Tom Moor 640ecca9ca perf: Reduce upfront component loading (#10285)
* Reducing loading on first open, closes #10263

* perf: Prosemirror deps loaded with Document model

* More initial component reduction

* more

* refactor
2025-10-02 06:22:19 -04:00
dependabot[bot] 5fbaa32f18 chore(deps): bump dd-trace from 5.64.0 to 5.67.0 (#10272)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.64.0 to 5.67.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.64.0...v5.67.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 17:23:32 -04:00
dependabot[bot] 50b2cf2706 chore(deps): bump the aws group with 5 updates (#10273)
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.893.0` | `3.896.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.893.0` | `3.896.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.893.0` | `3.896.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.893.0` | `3.896.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.893.0` | `3.896.0` |


Updates `@aws-sdk/client-s3` from 3.893.0 to 3.896.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.896.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.893.0 to 3.896.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.896.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.893.0 to 3.896.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.896.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.893.0 to 3.896.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.896.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.893.0 to 3.896.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.896.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.896.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-10-01 17:23:24 -04:00
dependabot[bot] db9deb2a46 chore(deps): bump mammoth from 1.10.0 to 1.11.0 (#10274)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.10.0...1.11.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 17:23:16 -04:00
codegen-sh[bot] 72cc740b1c Add clipboard-read; clipboard-write permissions to embedded Frame (#10282)
* Add clipboard permissions to embedded Frame component

- Add clipboard-read and clipboard-write permissions to iframe allow policy
- Ensure clipboard permissions are always included even when custom allow prop is provided
- Update Frame component to properly handle allow prop parameter

* Simplify clipboard permissions implementation

- Remove allow prop handling from Frame component
- Simply add clipboard-read and clipboard-write to default permissions list
- Keep implementation minimal as requested

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-01 17:22:57 -04:00
Salihu 4d9717631d enhancement: return group total (#10268)
* return group total when retrieving all groups

* add tests

* add test case for group total
2025-09-29 16:26:59 -04:00
172 changed files with 2422 additions and 1401 deletions
+7 -8
View File
@@ -14,7 +14,13 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
COPY --from=base $APP_PATH/build ./build
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -26,13 +32,6 @@ RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:22 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+134 -1
View File
@@ -1,8 +1,12 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -22,11 +26,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionV2,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
@@ -37,10 +41,16 @@ import {
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
@@ -137,6 +147,129 @@ export const editCollectionPermissions = createActionV2({
},
});
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
};
input.click();
},
});
export const sortCollection = createActionV2WithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
);
},
children: [
createActionV2({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "asc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "desc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
direction: "asc",
},
});
},
}),
],
});
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
+19 -8
View File
@@ -50,7 +50,6 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
@@ -82,7 +81,14 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -593,12 +599,15 @@ export const copyDocumentAsMarkdown = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toMarkdown());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -612,12 +621,15 @@ export const copyDocumentAsPlainText = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
},
@@ -849,7 +861,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
@@ -862,7 +874,6 @@ export const importDocument = createActionV2({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
+5 -6
View File
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -30,6 +29,7 @@ import {
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
type Props = {
children?: React.ReactNode;
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
<CommandBar />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+2 -1
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
@@ -44,4 +45,4 @@ const Link = styled.a`
}
`;
export default Branding;
export default React.memo(Branding);
+7 -4
View File
@@ -1,7 +1,10 @@
import { observer } from "mobx-react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
function Dialogs() {
const { dialogs } = useStores();
@@ -9,7 +12,7 @@ function Dialogs() {
const modals = [...modalStack];
return (
<>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -33,7 +36,7 @@ function Dialogs() {
{modal.content}
</Modal>
))}
</>
</Suspense>
);
}
+16 -23
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { useRef, useCallback, useMemo } from "react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
type Props = {
/** The pin record */
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
const updatedAt = (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
);
return (
<Reorderable
ref={setNodeRef}
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
updatedAt
) : (
<ReadingTime document={document} />
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
)}
</DocumentMeta>
</div>
@@ -177,21 +185,6 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ document });
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
+1
View File
@@ -39,6 +39,7 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
+93 -67
View File
@@ -1,12 +1,9 @@
import { useEditor } from "~/editor/components/EditorContext";
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import { findChildren } from "@shared/editor/queries/findChildren";
import findIndex from "lodash/findIndex";
import styled, { css, Keyframes, keyframes } from "styled-components";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { isInternalUrl } from "@shared/utils/urls";
import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
@@ -19,7 +16,6 @@ import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import { downloadImageNode } from "@shared/editor/nodes/Image";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
@@ -29,6 +25,9 @@ import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { LightboxImage } from "@shared/editor/lib/Lightbox";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -60,22 +59,22 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** List of allowed images */
images: LightboxImage[];
/** The position of the currently active image in the document */
activePos: number | null;
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
};
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [imageElements] = useState(
view?.dom.querySelectorAll(".component-image img")
);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
@@ -83,23 +82,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
height: number;
} | null>(null);
const imageNodes = useMemo(
() =>
view
? findChildren(
view.state.doc,
(child) => child.type === view.state.schema.nodes.image,
true
)
: [],
[view]
);
const currentImageIndex = findIndex(
imageNodes,
(node) => node.pos === activePos
images,
(img) => img.getPos() === activeImage.getPos()
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
@@ -108,15 +94,21 @@ function Lightbox({ onUpdate, activePos }: Props) {
// );
// }, [status]);
useEffect(() => () => view.focus(), []);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => {
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
@@ -179,11 +171,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -289,7 +280,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
};
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
@@ -364,33 +355,23 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(imageNodes[prevIndex].pos);
onUpdate(images[prevIndex]);
}
};
const next = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
if (nextIndex >= images.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
onUpdate(images[nextIndex]);
}
};
@@ -406,12 +387,63 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
}
const encodedSVGData = match[1];
const decodedSVGData = decodeURIComponent(encodedSVGData);
// Convert string to Uint8Array
const uint8 = new Uint8Array(decodedSVGData.length);
for (let i = 0; i < decodedSVGData.length; ++i) {
uint8[i] = decodedSVGData.charCodeAt(i);
}
// Create and return the Blob
return new Blob([uint8], { type: "image/svg+xml" });
};
const downloadImage = async (src: string, saveAs: string) => {
let imageBlob;
if (isInternalUrl(src)) {
const image = await fetch(src);
imageBlob = await image.blob();
} else {
// Assuming it's a mermaid svg
imageBlob = svgDataURLToBlob(src);
}
if (!imageBlob) {
toast.error(t("Unable to download image"));
return;
}
const imageURL = URL.createObjectURL(imageBlob);
const name = saveAs || "image";
const extension = imageBlob.type.split(/\/|\+/g)[1];
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${name}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
URL.revokeObjectURL(imageURL);
};
const download = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
}
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
switch (ev.key) {
@@ -459,14 +491,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!currentImageNode) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={!!activePos}>
<Dialog.Root open={true}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -529,8 +555,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
)}
<Image
ref={imgRef}
src={src}
alt={currentImageNode.attrs.alt ?? ""}
src={activeImage.getSrc()}
alt={activeImage.getAlt()}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
@@ -556,7 +582,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
status={status}
animation={animation.current}
/>
{currentImageIndex < imageNodes.length - 1 && (
{currentImageIndex < images.length - 1 && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
@@ -787,7 +813,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(Error)<{
const StyledError = styled(ImageError)<{
animation: Animation | null;
}>`
${(props) =>
+5 -5
View File
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
action?: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
}, [open, action?.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -68,7 +68,7 @@ export const ContextMenu = observer(
[]
);
if (isMobile) {
if (isMobile || !action || menuItems.length === 0) {
return <>{children}</>;
}
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/CommentEditor")
);
type Props = {
notification: Notification;
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
@@ -7,7 +7,9 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import Notifications from "./Notifications";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
type Props = {
children?: React.ReactNode;
@@ -16,18 +18,18 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = React.useCallback(() => {
const handleRequestClose = useCallback(() => {
setOpen(false);
}, []);
const handleAutoFocus = React.useCallback((event: Event) => {
const handleAutoFocus = useCallback((event: Event) => {
// Prevent focus from moving to the popover content
event.preventDefault();
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
</PopoverContent>
</Popover>
);
+26
View File
@@ -0,0 +1,26 @@
import { EyeIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(
() => ProsemirrorHelper.toMarkdown(document),
[document]
);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
export default ReadingTime;
@@ -71,6 +71,19 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handleShowTOCChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = useCallback(
async (checked: boolean) => {
try {
@@ -204,6 +217,31 @@ function InnerPublicAccess({ collection, share }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
@@ -77,6 +77,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
@@ -241,6 +254,31 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
+5
View File
@@ -22,6 +22,7 @@ import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
type Props = {
share: Share;
@@ -37,6 +38,10 @@ function SharedSidebar({ share }: Props) {
const rootNode = share.tree;
const shareId = share.urlId || share.id;
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
if (!rootNode?.children.length) {
return null;
}
@@ -25,6 +25,8 @@ import DropToImport from "./DropToImport";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
collection: Collection;
@@ -109,8 +111,12 @@ const CollectionLink: React.FC<Props> = ({
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<>
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -122,6 +128,7 @@ const CollectionLink: React.FC<Props> = ({
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -189,7 +196,7 @@ const CollectionLink: React.FC<Props> = ({
}
/>
)}
</>
</ActionContextProvider>
);
};
@@ -18,10 +18,14 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import Text from "@shared/components/Text";
import usePolicy from "~/hooks/usePolicy";
function Collections() {
const { documents, collections } = useStores();
const { documents, auth, collections } = useStores();
const { t } = useTranslation();
const can = usePolicy(auth.team?.id);
const orderedCollections = collections.allActive;
const params = useMemo(
@@ -57,7 +61,7 @@ function Collections() {
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={collections.allActive}
items={orderedCollections}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -68,6 +72,20 @@ function Collections() {
/>
) : undefined
}
empty={
// No need for empty state if we're displaying the createCollection action
can.createCollection ? null : (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
{t("No collections")}
</Text>
}
onClick={() => {}}
depth={1.5}
/>
)
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item, index) => (
<DraggableCollectionLink
@@ -35,6 +35,8 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import UserMembership from "~/models/UserMembership";
import GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
node: NavigationNode;
@@ -316,8 +318,14 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
return (
<>
<ActionContextProvider
value={{
activeDocumentId: node.id,
}}
>
<Relative ref={parentRef}>
<Draggable
key={node.id}
@@ -334,6 +342,7 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
@@ -425,7 +434,7 @@ function InnerDocumentLink(
/>
))}
</Folder>
</>
</ActionContextProvider>
);
}
@@ -11,6 +11,10 @@ import useClickIntent from "~/hooks/useClickIntent";
import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionV2WithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import useBoolean from "~/hooks/useBoolean";
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
@@ -32,6 +36,7 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
depth?: number;
scrollIntoViewIfNeeded?: boolean;
contextAction?: ActionV2WithChildren;
};
const activeDropStyle = {
@@ -62,19 +67,29 @@ function SidebarLink(
onDisclosureClick,
disabled,
unreadBadge,
contextAction,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingRight: unreadBadge ? "32px" : undefined,
}),
[depth]
);
const unreadStyle = React.useMemo(
() => ({
right: -12,
}),
[]
);
const activeStyle = React.useMemo(
() => ({
color: theme.text,
@@ -84,41 +99,58 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const hoverStyle = React.useMemo(
() => ({
color: theme.text,
...style,
}),
[theme.text, style]
);
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
return (
<>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>
</Link>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</Link>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
+190 -83
View File
@@ -28,11 +28,173 @@ import SidebarContext, {
starredSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { type ConnectDragSource } from "react-dnd";
type Props = {
star: Star;
};
type StarredDocumentLinkProps = {
star: Star;
documentId: string;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
handlePrefetch: () => void;
icon: React.ReactNode;
label: React.ReactNode;
menuOpen: boolean;
handleMenuOpen: () => void;
handleMenuClose: () => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
};
type StarredCollectionLinkProps = {
star: Star;
collection: any;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
displayChildDocuments: boolean;
reorderStarProps: any;
};
function StarredDocumentLink({
star,
documentId,
expanded,
sidebarContext,
isDragging,
handleDisclosureClick,
handlePrefetch,
icon,
label,
menuOpen,
handleMenuOpen,
handleMenuClose,
draggableRef,
cursor,
}: StarredDocumentLinkProps) {
const { collections, documents } = useStores();
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</ActionContextProvider>
);
}
function StarredCollectionLink({
star,
collection,
sidebarContext,
isDragging,
handleDisclosureClick,
draggableRef,
cursor,
displayChildDocuments,
reorderStarProps,
}: StarredCollectionLinkProps) {
const { documents } = useStores();
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
@@ -123,95 +285,40 @@ function StarredLink({ star }: Props) {
);
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
return (
<>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
);
}
if (collection) {
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
);
}
@@ -22,7 +22,11 @@ export function useSidebarLabelAndIcon(
return {
label: document.titleWithDefault,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
) : (
icon
),
-2
View File
@@ -5,7 +5,6 @@ import GlobalStyles from "@shared/styles/globals";
import { TeamPreference, UserPreference } from "@shared/types";
import useBuildTheme from "~/hooks/useBuildTheme";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -30,7 +29,6 @@ const Theme: React.FC = ({ children }: Props) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
+1 -5
View File
@@ -1,7 +1,7 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { createGlobalStyle, keyframes } from "styled-components";
import styled, { keyframes } from "styled-components";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -285,8 +285,4 @@ const StyledContent = styled(TooltipPrimitive.Content)`
}
`;
export const TooltipStyles = createGlobalStyle`
/* Legacy styles for backward compatibility - can be removed after migration */
`;
export default Tooltip;
+2 -1
View File
@@ -119,5 +119,6 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;
+7 -5
View File
@@ -1,5 +1,5 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { selectedRect } from "prosemirror-tables";
import * as React from "react";
import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
@@ -15,6 +15,9 @@ import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
type Props = {
align?: "start" | "end" | "center";
@@ -104,11 +107,11 @@ function usePosition({
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection && selection.isColSelection();
selection instanceof ColumnSelection && selection.isColSelection();
const isRowSelection =
selection instanceof CellSelection && selection.isRowSelection();
selection instanceof RowSelection && selection.isRowSelection();
if (isColSelection && isRowSelection) {
if (isTableSelected(view.state)) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
@@ -349,7 +352,6 @@ const Background = styled.div<{ align: Props["align"] }>`
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
+2 -1
View File
@@ -282,7 +282,8 @@ const LinkEditor: React.FC<Props> = ({
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
+12 -12
View File
@@ -1,6 +1,5 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
@@ -8,7 +7,11 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import {
getColumnIndex,
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
@@ -23,7 +26,6 @@ import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableCellMenuItems from "../menus/tableCell";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
@@ -178,15 +180,13 @@ export default function SelectionToolbar(props: Props) {
const { state } = view;
const { selection } = state;
if ((readOnly && !canComment) || isDragging) {
if (isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const isCellSelection = selection instanceof CellSelection;
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
@@ -204,14 +204,14 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isCellSelection) {
items = getTableCellMenuItems(state, dictionary);
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
+4 -1
View File
@@ -31,6 +31,9 @@ export default styled.button.attrs((props) => ({
&:hover {
opacity: 1;
// extraArea overlaps slightly, this ensures the currently hovered button is on top
z-index: 1;
}
${(props) =>
@@ -44,7 +47,7 @@ export default styled.button.attrs((props) => ({
cursor: default;
}
${extraArea(4)}
${extraArea(5)}
${(props) =>
props.active &&
+1
View File
@@ -157,6 +157,7 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
+26 -8
View File
@@ -54,6 +54,12 @@ import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import isNull from "lodash/isNull";
import { map } from "lodash";
import {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
export type Props = {
@@ -146,8 +152,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
};
/**
@@ -177,7 +183,7 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImgPos: null,
activeLightboxImage: null,
};
isInitialized = false;
@@ -640,6 +646,16 @@ export class Editor extends React.PureComponent<
*/
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
public getLightboxImages = (): LightboxImage[] => {
const lightboxNodes = ProsemirrorHelper.getLightboxNodes(
this.view.state.doc
);
return map(lightboxNodes, (node) =>
LightboxImageFactory.createLightboxImage(this.view, node.pos)
);
};
/**
* Return the tasks/checkmarks in the current editor.
*
@@ -717,10 +733,10 @@ export class Editor extends React.PureComponent<
dispatch(tr);
};
public updateActiveLightbox = (pos: number | null) => {
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
this.setState((state) => ({
...state,
activeLightboxImgPos: pos,
activeLightboxImage: activeImage,
}));
};
@@ -843,10 +859,12 @@ export class Editor extends React.PureComponent<
)}
</Observer>
</Flex>
{this.state.activeLightboxImgPos && (
{!isNull(this.state.activeLightboxImage) && (
<Lightbox
onUpdate={this.updateActiveLightbox}
activePos={this.state.activeLightboxImgPos}
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
/>
)}
</EditorContext.Provider>
+27 -5
View File
@@ -17,6 +17,8 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import styled from "styled-components";
@@ -34,6 +36,11 @@ import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
export default function formattingMenuItems(
state: EditorState,
@@ -46,6 +53,7 @@ export default function formattingMenuItems(
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isTableCell = state.selection instanceof CellSelection;
const highlight = getMarksBetween(
state.selection.from,
@@ -166,11 +174,25 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "separator",
},
{
name: "mergeCells",
tooltip: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
tooltip: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
visible: !isCodeBlock,
},
{
name: "checkbox_list",
@@ -179,7 +201,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "bullet_list",
@@ -187,7 +209,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "ordered_list",
@@ -195,7 +217,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "outdentList",
-36
View File
@@ -1,36 +0,0 @@
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableCellMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
// Only show menu items if we have a CellSelection
if (!(selection instanceof CellSelection)) {
return [];
}
return [
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
];
}
+72
View File
@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { useMenuAction } from "./useMenuAction";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
deleteCollection,
editCollection,
editCollectionPermissions,
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
importDocument,
sortCollection,
} from "~/actions/definitions/collections";
import { ActiveCollectionSection } from "~/actions/sections";
import { InputIcon } from "outline-icons";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
import { useTranslation } from "react-i18next";
type Props = {
/** Collection ID for which the actions are generated */
collectionId: string;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
};
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(collectionId);
const can = usePolicy(collection);
const actions = useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
importDocument,
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortCollection,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[t, can.createDocument, can.update, onRename]
);
return useMenuAction(actions);
}
+5 -6
View File
@@ -43,8 +43,8 @@ import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document for which the actions are generated */
document: Document;
/** Document ID for which the actions are generated */
documentId: string;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Invoked when the "Rename" menu item is clicked */
@@ -54,7 +54,7 @@ type Props = {
};
export function useDocumentMenuAction({
document,
documentId,
onFindAndReplace,
onRename,
onSelectTemplate,
@@ -62,11 +62,10 @@ export function useDocumentMenuAction({
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(document);
const can = usePolicy(documentId);
const templateMenuActions = useTemplateMenuActions({
document,
documentId,
onSelectTemplate,
});
+3 -2
View File
@@ -19,7 +19,6 @@ import {
import { ComponentProps, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import { settingsPath } from "~/utils/routeHelpers";
@@ -37,6 +36,7 @@ const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
const Import = lazy(() => import("~/scenes/Settings/Import"));
const Integrations = lazy(() => import("~/scenes/Settings/Integrations"));
const Members = lazy(() => import("~/scenes/Settings/Members"));
const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
@@ -211,7 +211,8 @@ const useSettingsConfig = () => {
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
component: Integrations.Component,
preload: Integrations.preload,
enabled: can.update,
group: t("Integrations"),
icon: PlusIcon,
+7 -3
View File
@@ -17,7 +17,7 @@ import { useComputed } from "./useComputed";
type Props = {
/** The document to which the templates will be applied */
document: Document;
documentId: string;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
@@ -33,10 +33,14 @@ type Props = {
* @returns An array of Action objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
export function useTemplateMenuActions({
documentId,
onSelectTemplate,
}: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): ActionV2 =>
@@ -70,7 +74,7 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
template.collectionId === document?.collectionId
)
.map(templateToAction);
+10 -10
View File
@@ -55,11 +55,11 @@ if (element) {
<Analytics>
<Router history={history}>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ActionContextProvider>
<ActionContextProvider>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<PageScroll>
<PageTheme />
<ScrollToTop>
@@ -69,11 +69,11 @@ if (element) {
<Dialogs />
<Desktop />
</PageScroll>
</ActionContextProvider>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</ActionContextProvider>
</Theme>
</Router>
</Analytics>
+6 -189
View File
@@ -1,47 +1,14 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { observer } from "mobx-react";
import {
ImportIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
ManualSortIcon,
InputIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
ActionV2Separator,
createActionV2,
createActionV2WithChildren,
} from "~/actions";
import {
deleteCollection,
editCollection,
editCollectionPermissions,
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import { ActionContextProvider } from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { ActiveCollectionSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
type Props = {
collection: Collection;
@@ -60,10 +27,8 @@ function CollectionMenu({
onOpen,
onClose,
}: Props) {
const { documents, subscriptions } = useStores();
const { subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
@@ -82,161 +47,13 @@ function CollectionMenu({
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(() => {
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
}, [file]);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
try {
const file = files[0];
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
ev.target.value = "";
}
},
[history, collection.id, documents]
);
const handleChangeSort = React.useCallback(
(field: string, direction = "asc") =>
collection.save({
sort: {
field,
direction,
},
}),
[collection]
);
const can = usePolicy(collection);
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
const sortAction = React.useMemo(
() =>
createActionV2WithChildren({
name: t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: can.update,
icon: sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
),
children: [
createActionV2({
name: t("A-Z sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "asc",
perform: () => handleChangeSort("title", "asc"),
}),
createActionV2({
name: t("Z-A sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "desc",
perform: () => handleChangeSort("title", "desc"),
}),
createActionV2({
name: t("Manual sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: !sortAlphabetical,
perform: () => handleChangeSort("index"),
}),
],
}),
[t, can.update, sortAlphabetical, sortDir, handleChangeSort]
);
const actions = React.useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
createActionV2({
name: t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: can.createDocument,
perform: handleImportDocument,
}),
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortAction,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[
t,
can.createDocument,
can.update,
sortAction,
handleImportDocument,
onRename,
]
);
const rootAction = useMenuAction(actions);
const rootAction = useCollectionMenuAction({
collectionId: collection.id,
onRename,
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<VisuallyHidden.Root>
<label>
{t("Import document")}
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex={-1}
/>
</label>
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
align={align}
+1 -1
View File
@@ -126,7 +126,7 @@ function DocumentMenu({
);
const rootAction = useDocumentMenuAction({
document,
documentId: document.id,
onFindAndReplace,
onRename,
onSelectTemplate,
+4 -1
View File
@@ -18,7 +18,10 @@ type Props = {
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const { t } = useTranslation();
const allActions = useTemplateMenuActions({ onSelectTemplate, document });
const allActions = useTemplateMenuActions({
onSelectTemplate,
documentId: document.id,
});
const rootAction = useMenuAction(allActions);
if (!allActions.length) {
-45
View File
@@ -3,9 +3,6 @@ import i18n, { t } from "i18next";
import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { Node, Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import type {
JSONObject,
NavigationNode,
@@ -17,7 +14,6 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -687,47 +683,6 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
toMarkdown = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data)
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
+4
View File
@@ -75,6 +75,10 @@ class Share extends Model implements Searchable {
@observable
showLastUpdated: boolean;
@Field
@observable
showTOC: boolean;
@observable
views: number;
+49
View File
@@ -0,0 +1,49 @@
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type Document from "../Document";
import { Schema } from "prosemirror-model";
import { Node } from "prosemirror-model";
export class ProsemirrorHelper {
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
static toMarkdown = (document: Document) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
SharedProsemirrorHelper.attachmentsToAbsoluteUrls(document.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
static toPlainText = (document: Document) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = SharedProsemirrorHelper.toPlainText(
Node.fromJSON(schema, document.data)
);
return text;
};
}
@@ -1,10 +1,9 @@
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import { useCallback, useState } from "react";
import { Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import {
Popover,
PopoverTrigger,
@@ -13,6 +12,11 @@ import {
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
type Props = {
/** Collection being shared */
@@ -56,11 +60,13 @@ function ShareButton({ collection }: Props) {
side="bottom"
align="end"
>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
<Suspense fallback={null}>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
</PopoverContent>
</Popover>
);
+5 -3
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { lazy, useState, useCallback, useEffect, Suspense } from "react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -47,10 +47,12 @@ import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
import first from "lodash/first";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazy(() => import("~/components/IconPicker"));
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
enum CollectionPath {
Overview = "overview",
@@ -22,9 +22,11 @@ import type { Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import CommentEditor from "./CommentEditor";
import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
type Props = {
/** Callback when the form is submitted. */
+6 -4
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
import { decodeURIComponentSafe } from "~/utils/urls";
@@ -37,7 +37,9 @@ function Contents() {
}
}
setActiveSlug(activeId);
if (activeSlug !== activeId) {
setActiveSlug(activeId);
}
}, [scrollPosition, headings]);
// calculate the minimum heading level and adjust all the headings to make
@@ -76,16 +78,16 @@ function Contents() {
const StickyWrapper = styled.div`
display: none;
position: sticky;
top: 90px;
max-height: calc(100vh - 90px);
width: ${EditorStyleHelper.tocWidth}px;
${hideScrollbars()}
padding: 0 16px;
overflow-y: auto;
border-radius: 8px;
background: ${s("background")};
@supports (backdrop-filter: blur(20px)) {
+2 -27
View File
@@ -27,16 +27,13 @@ import {
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import { isModKey } from "@shared/utils/keyboard";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import ConnectionStatus from "~/scenes/Document/components/ConnectionStatus";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import Branding from "~/components/Branding";
import ErrorBoundary from "~/components/ErrorBoundary";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
@@ -57,13 +54,11 @@ import Container from "./Container";
import Contents from "./Contents";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import { MeasuredContainer } from "./MeasuredContainer";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
import { SizeWarning } from "./SizeWarning";
const AUTOSAVE_DELAY = 3000;
@@ -433,6 +428,7 @@ class DocumentScene extends React.Component<Props> {
render() {
const {
children,
document,
revision,
readOnly,
@@ -633,19 +629,8 @@ class DocumentScene extends React.Component<Props> {
)}
</React.Suspense>
</Main>
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
{children}
</Container>
{!isShare && (
<Footer>
<KeyboardShortcutsButton />
<ConnectionStatus />
<SizeWarning document={document} />
</Footer>
)}
</MeasuredContainer>
</ErrorBoundary>
);
@@ -754,16 +739,6 @@ const RevisionContainer = styled.div<RevisionContainerProps>`
`}
`;
const Footer = styled.div`
position: fixed;
bottom: 12px;
right: 20px;
text-align: right;
display: flex;
justify-content: flex-end;
gap: 20px;
`;
const Background = styled(Container)`
position: relative;
background: ${s("background")};
@@ -24,8 +24,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
+27
View File
@@ -0,0 +1,27 @@
import styled from "styled-components";
import type Document from "~/models/Document";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import ConnectionStatus from "./ConnectionStatus";
import { SizeWarning } from "./SizeWarning";
type Props = {
document: Document;
};
export const Footer = ({ document }: Props) => (
<FooterWrapper>
<KeyboardShortcutsButton />
<ConnectionStatus />
<SizeWarning document={document} />
</FooterWrapper>
);
const FooterWrapper = styled.div`
position: fixed;
bottom: 12px;
right: 20px;
text-align: right;
display: flex;
justify-content: flex-end;
gap: 20px;
`;
+2 -1
View File
@@ -14,6 +14,7 @@ import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { useFormatNumber } from "~/hooks/useFormatNumber";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
type Props = {
document: Document;
@@ -22,7 +23,7 @@ type Props = {
function Insights({ document }: Props) {
const { t } = useTranslation();
const selectedText = useTextSelection();
const text = document.toPlainText();
const text = ProsemirrorHelper.toPlainText(document);
const stats = useTextStats(text ?? "", selectedText);
const formatNumber = useFormatNumber();
+13 -7
View File
@@ -1,10 +1,9 @@
import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import { useCallback, useState } from "react";
import { Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import Button from "~/components/Button";
import SharePopover from "~/components/Sharing/Document";
import {
Popover,
PopoverTrigger,
@@ -12,6 +11,11 @@ import {
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document")
);
type Props = {
/** Document being shared */
@@ -50,11 +54,13 @@ function ShareButton({ document }: Props) {
side="bottom"
align="end"
>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
<Suspense fallback={null}>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
</PopoverContent>
</Popover>
);
@@ -7,6 +7,7 @@ import type Document from "~/models/Document";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
type Props = {
document: Document;
@@ -14,7 +15,7 @@ type Props = {
export const SizeWarning = ({ document }: Props) => {
const { t } = useTranslation();
const length = document.toPlainText().length;
const length = ProsemirrorHelper.toPlainText(document).length;
if (length < DocumentValidation.maxRecommendedLength) {
return null;
+6 -1
View File
@@ -6,6 +6,7 @@ import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
import { Footer } from "./components/Footer";
type Params = {
documentSlug: string;
@@ -65,7 +66,11 @@ export default function DocumentScene(props: Props) {
history={props.history}
location={props.location}
>
{(rest) => <Document {...rest} />}
{(rest) => (
<Document {...rest}>
<Footer document={rest.document} />
</Document>
)}
</DataLoader>
);
}
+10 -2
View File
@@ -40,8 +40,12 @@ import { BackButton } from "./components/BackButton";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { Notices } from "./components/Notices";
import WorkspaceSetup from "./components/WorkspaceSetup";
import { getRedirectUrl, navigateToSubdomain } from "./urls";
import lazyWithRetry from "~/utils/lazyWithRetry";
const WorkspaceSetup = lazyWithRetry(
() => import("./components/WorkspaceSetup")
);
type Props = {
children?: (config?: Config) => React.ReactNode;
@@ -205,7 +209,11 @@ function Login({ children, onBack }: Props) {
const preferOTP = isPWA;
if (firstRun) {
return <WorkspaceSetup onBack={onBack} />;
return (
<React.Suspense fallback={null}>
<WorkspaceSetup onBack={onBack} />
</React.Suspense>
);
}
if (emailLinkSentTo) {
+4 -1
View File
@@ -12,8 +12,9 @@ import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
import { observer } from "mobx-react";
export function Integrations() {
function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
@@ -70,3 +71,5 @@ const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
export default observer(Integrations);
+22 -8
View File
@@ -5,6 +5,9 @@ import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useMemo } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
type Props = {
document: DocumentModel;
@@ -14,8 +17,14 @@ type Props = {
function SharedDocument({ document, shareId, sharedTree }: Props) {
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument } = useDocumentContext();
const abilities = useMemo(() => ({}), []);
const isCustomDomain = useMemo(
() => parseDomain(window.location.origin).custom,
[]
);
const showBranding = !isCustomDomain && !user;
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
@@ -23,14 +32,19 @@ function SharedDocument({ document, shareId, sharedTree }: Props) {
setDocument(document);
return (
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
<>
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
{showBranding ? (
<Branding href="//www.getoutline.com?ref=sharelink" />
) : null}
</>
);
}
+21 -17
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useCallback, useEffect } from "react";
import { Suspense, useCallback, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
@@ -28,10 +28,12 @@ import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import Loading from "../Document/components/Loading";
import ErrorOffline from "../Errors/ErrorOffline";
import Login from "../Login";
import { Collection as CollectionScene } from "./Collection";
import { Document as DocumentScene } from "./Document";
import DelayedMount from "~/components/DelayedMount";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Login = lazyWithRetry(() => import("../Login"));
// Parse the canonical origin from the SSR HTML, only needs to be done once.
const canonicalUrl = document
@@ -194,21 +196,23 @@ function SharedScene() {
if (error instanceof AuthorizationError) {
setPostLoginPath(location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
<Suspense fallback={null}>
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
</Suspense>
);
}
return <Error404 />;
+4 -1
View File
@@ -3,7 +3,10 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix --type-aware`),
(f) =>
f.length > 20
? `yarn lint --fix`
: `oxlint ${f.join(" ")} --fix --type-aware`,
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+8 -8
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.893.0",
"@aws-sdk/lib-storage": "3.893.0",
"@aws-sdk/s3-presigned-post": "3.893.0",
"@aws-sdk/s3-request-presigner": "3.893.0",
"@aws-sdk/signature-v4-crt": "^3.893.0",
"@aws-sdk/client-s3": "3.896.0",
"@aws-sdk/lib-storage": "3.896.0",
"@aws-sdk/s3-presigned-post": "3.896.0",
"@aws-sdk/s3-request-presigner": "3.896.0",
"@aws-sdk/signature-v4-crt": "^3.896.0",
"@babel/core": "^7.28.4",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -129,7 +129,7 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.64.0",
"dd-trace": "^5.67.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -168,7 +168,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.4",
"mammoth": "^1.10.0",
"mammoth": "^1.11.0",
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
@@ -206,7 +206,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.8.1",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.40.1",
"prosemirror-view": "^1.41.2",
"proxy-from-env": "^1.1.0",
"query-string": "^7.1.3",
"rate-limiter-flexible": "^2.4.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 B

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

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