Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce0936ebb6 | |||
| bb72774f2d | |||
| 76868a3083 | |||
| 0865052bb8 | |||
| de6bc9beca | |||
| e97944ab40 | |||
| 5cfea207e6 | |||
| 95f0c42d56 | |||
| ee7738c141 | |||
| 76701e35ec | |||
| ae8c2aae15 | |||
| a9fa2ed72b | |||
| 0deb7e7f09 | |||
| a544559de2 | |||
| 79fe08e9b6 | |||
| c8d8ba3914 | |||
| 7a148b0353 | |||
| ca891a56da | |||
| 294d3e896a | |||
| d947f8fda2 | |||
| 6dd228a533 | |||
| c7d847215c | |||
| 6995ca8521 | |||
| 8a3452e664 | |||
| f6315875b4 | |||
| f4e53da1bf | |||
| 643188b2f3 | |||
| 6f8f25b0d1 | |||
| 10c3edded7 | |||
| 398943d084 | |||
| a02677c2b1 | |||
| ebf2029539 | |||
| 0df42cb4c7 | |||
| 72c9091b7e | |||
| 740e33156d | |||
| d8ef7b2892 | |||
| 0f9146066c | |||
| 06a1428cbc | |||
| e71a425268 | |||
| 12d31468f8 | |||
| 211c57f6aa | |||
| bb475f3e4e | |||
| 9b95a58822 | |||
| fce02996f9 | |||
| 1aa05b797c | |||
| b69feb50a7 | |||
| 640ecca9ca | |||
| 5fbaa32f18 | |||
| 50b2cf2706 | |||
| db9deb2a46 | |||
| 72cc740b1c | |||
| 4d9717631d |
@@ -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,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20 AS deps
|
||||
FROM node:22 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
@@ -46,7 +45,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,7 +201,7 @@ export function createActionV2(
|
||||
return definition.perform(context);
|
||||
}
|
||||
: () => {},
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,7 +212,7 @@ export function createInternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,7 +223,7 @@ export function createExternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,7 +234,7 @@ export function createActionV2WithChildren(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +251,7 @@ export function createRootMenuAction(
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import flatten from "lodash/flatten";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -27,7 +28,6 @@ import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -49,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
@@ -59,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((ancestorNode) => ancestorNode.id);
|
||||
}
|
||||
@@ -104,19 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
@@ -130,6 +124,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
|
||||
@@ -94,7 +94,7 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
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 {
|
||||
ComponentProps,
|
||||
createContext,
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { Error as ImageError } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
@@ -14,12 +21,13 @@ import {
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
NextIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "outline-icons";
|
||||
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 +37,16 @@ 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";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
useTransformEffect,
|
||||
ReactZoomPanPinchRef,
|
||||
} from "react-zoom-pan-pinch";
|
||||
import { transparentize } from "polished";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -43,6 +61,9 @@ export enum ImageStatus {
|
||||
LOADING,
|
||||
ERROR,
|
||||
LOADED,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
ZOOMED,
|
||||
}
|
||||
type Status = {
|
||||
lightbox: LightboxStatus | null;
|
||||
@@ -60,46 +81,125 @@ 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();
|
||||
const ZoomPanPinchContext = createContext({ isImagePanning: false });
|
||||
type ZoomablePannablePinchableProps = {
|
||||
children: ReactNode;
|
||||
panningDisabled: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
const ZoomablePannablePinchable = forwardRef<
|
||||
ReactZoomPanPinchRef,
|
||||
ZoomablePannablePinchableProps
|
||||
>(({ children, panningDisabled, disabled }, ref) => {
|
||||
const { isPanning, ...panningHandlers } = usePanning();
|
||||
return (
|
||||
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
|
||||
<TransformWrapper
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
doubleClick={{ disabled: true }}
|
||||
minScale={1}
|
||||
maxScale={8}
|
||||
panning={{
|
||||
disabled: panningDisabled,
|
||||
}}
|
||||
{...panningHandlers}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{ width: "100%", height: "100%" }}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "56px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</ZoomPanPinchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
function usePanning() {
|
||||
const [isPanning, setPanning] = useState(false);
|
||||
const dragged = useRef(false);
|
||||
|
||||
const onPanningStart: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStart"] = (ref, event) => {
|
||||
if (!(event.target instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
const zoomedIn = ref.state.scale > 1;
|
||||
if (zoomedIn) {
|
||||
setPanning(ref.instance.isPanning);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanning: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanning"] = () => {
|
||||
dragged.current = true;
|
||||
};
|
||||
|
||||
const onPanningStop: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStop"] = (ref, event) => {
|
||||
if (!(event.target instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
setPanning(ref.instance.isPanning);
|
||||
if (dragged.current) {
|
||||
dragged.current = false;
|
||||
} else {
|
||||
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
|
||||
if (zoomedOut) {
|
||||
ref.zoomIn();
|
||||
} else {
|
||||
ref.resetTransform();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isPanning,
|
||||
onPanningStart,
|
||||
onPanning,
|
||||
onPanningStop,
|
||||
};
|
||||
}
|
||||
|
||||
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 contentRef = 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 };
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(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 +208,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) {
|
||||
@@ -139,6 +245,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.image, status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
status.image === ImageStatus.LOADED
|
||||
) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENED,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}
|
||||
}, [status.lightbox, status.image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
|
||||
setupFadeOut();
|
||||
@@ -156,6 +274,15 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.MIN_ZOOM) {
|
||||
// It was observed that focus went to `body` as the zoom out button was disabled
|
||||
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
|
||||
// So focusing the content div here to restore the functionality.
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
const rememberImagePosition = () => {
|
||||
if (imgRef.current) {
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
@@ -179,11 +306,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,
|
||||
@@ -270,7 +396,13 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
};
|
||||
|
||||
const setupZoomOut = () => {
|
||||
if (imgRef.current) {
|
||||
if (
|
||||
imgRef.current &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
) {
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
@@ -289,7 +421,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 +496,31 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activePos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
const nextIndex = currentImageIndex + 1;
|
||||
if (nextIndex >= imageNodes.length) {
|
||||
if (nextIndex >= images.length) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[nextIndex].pos);
|
||||
onUpdate(images[nextIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,12 +536,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 +640,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}
|
||||
@@ -474,7 +649,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown}>
|
||||
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
@@ -482,10 +657,52 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Zoom in")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
status.image === ImageStatus.MAX_ZOOM ||
|
||||
status.image === ImageStatus.ERROR
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomIn();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom in")}
|
||||
size={32}
|
||||
icon={<ZoomInIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Zoom out")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomOut();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom out")}
|
||||
size={32}
|
||||
icon={<ZoomOutIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Separator />
|
||||
<Tooltip content={t("Copy link")} placement="bottom">
|
||||
<CopyToClipboard text={imgRef.current?.src ?? ""}>
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
aria-label={t("Copy link")}
|
||||
size={32}
|
||||
icon={<LinkIcon />}
|
||||
@@ -495,8 +712,9 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Download")} placement="bottom">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
onClick={download}
|
||||
aria-label={t("Download")}
|
||||
size={32}
|
||||
@@ -508,7 +726,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
<Separator />
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={close}
|
||||
aria-label={t("Close")}
|
||||
@@ -520,49 +738,86 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
{currentImageIndex > 0 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<ZoomablePannablePinchable
|
||||
panningDisabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
{currentImageIndex < imageNodes.length - 1 && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
ref={zoomPanPinchRef}
|
||||
>
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={activeImage.getSrc()}
|
||||
alt={activeImage.getAlt()}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
onMinZoom={() => {
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}}
|
||||
onZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ZOOMED,
|
||||
})
|
||||
}
|
||||
onMaxZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MAX_ZOOM,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ZoomablePannablePinchable>
|
||||
{currentImageIndex < images.length - 1 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -581,6 +836,9 @@ type ImageProps = {
|
||||
onSwipeDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
onMinZoom: () => void;
|
||||
onZoom: () => void;
|
||||
onMaxZoom: () => void;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
@@ -596,6 +854,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
status,
|
||||
animation,
|
||||
onMinZoom,
|
||||
onZoom,
|
||||
onMaxZoom,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
@@ -608,6 +869,25 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
});
|
||||
|
||||
const { isImagePanning } = useContext(ZoomPanPinchContext);
|
||||
|
||||
useTransformEffect(({ state, instance }) => {
|
||||
const minScale = instance.props.minScale ?? 1;
|
||||
const maxScale = instance.props.maxScale ?? 8;
|
||||
const { scale } = state;
|
||||
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMinZoom();
|
||||
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMaxZoom();
|
||||
} else if (
|
||||
scale > minScale &&
|
||||
scale < maxScale &&
|
||||
status.image !== ImageStatus.ZOOMED
|
||||
) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
);
|
||||
@@ -642,9 +922,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
$zoomedIn={
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
}
|
||||
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
|
||||
$panning={isImagePanning}
|
||||
/>
|
||||
<Caption>
|
||||
{status.image === ImageStatus.LOADED &&
|
||||
{status.image === ImageStatus.MIN_ZOOM &&
|
||||
status.lightbox === LightboxStatus.OPENED ? (
|
||||
<Fade>{alt}</Fade>
|
||||
) : null}
|
||||
@@ -700,12 +986,25 @@ const StyledOverlay = styled(Dialog.Overlay)<{
|
||||
|
||||
const StyledImg = styled.img<{
|
||||
$hidden: boolean;
|
||||
$zoomedIn: boolean;
|
||||
$zoomedOut: boolean;
|
||||
$panning: boolean;
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
|
||||
pointer-events: auto !important;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
cursor: ${(props) =>
|
||||
props.$panning
|
||||
? "grab"
|
||||
: props.$zoomedOut
|
||||
? "zoom-in"
|
||||
: props.$zoomedIn
|
||||
? "zoom-out"
|
||||
: "default"};
|
||||
|
||||
${(props) =>
|
||||
props.animation?.zoomIn
|
||||
? css`
|
||||
@@ -717,7 +1016,12 @@ const StyledImg = styled.img<{
|
||||
animation: ${props.animation.zoomOut.apply()}
|
||||
${props.animation.zoomOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
: props.animation?.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
@@ -728,7 +1032,10 @@ const StyledContent = styled(Dialog.Content)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding: 56px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
background: transparent;
|
||||
`;
|
||||
|
||||
const Actions = styled.div<{
|
||||
@@ -741,6 +1048,10 @@ const Actions = styled.div<{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -768,6 +1079,7 @@ const Nav = styled.div<{
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
z-index: ${depths.modal};
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -787,7 +1099,7 @@ const Nav = styled.div<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)<{
|
||||
const StyledError = styled(ImageError)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
${(props) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
<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")}
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -141,7 +146,8 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
&: ${hover} {
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
@@ -149,6 +155,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -119,5 +119,6 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
@@ -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";
|
||||
@@ -45,11 +48,7 @@ function usePosition({
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const menuWidth = menuRef.current?.offsetWidth ?? 0;
|
||||
const menuHeight = menuRef.current?.offsetHeight ?? 0;
|
||||
|
||||
if (!active || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
}
|
||||
const menuHeight = 36;
|
||||
|
||||
// based on the start and end of the selection calculate the position at
|
||||
// the center top
|
||||
@@ -71,7 +70,7 @@ function usePosition({
|
||||
right: Math.max(fromPos.right, toPos.right),
|
||||
};
|
||||
|
||||
const offsetParent = menuRef.current.offsetParent
|
||||
const offsetParent = menuRef.current?.offsetParent
|
||||
? menuRef.current.offsetParent.getBoundingClientRect()
|
||||
: ({
|
||||
width: window.innerWidth,
|
||||
@@ -96,19 +95,23 @@ function usePosition({
|
||||
if (position !== null) {
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.top = bounds.top + menuHeight;
|
||||
selectionBounds.left = bounds.right;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (!active || !menuRef.current || !menuHeight) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -163,6 +166,8 @@ function usePosition({
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
blockSelection: false,
|
||||
maxWidth: width,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -207,8 +212,12 @@ function usePosition({
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
blockSelection:
|
||||
codeBlock || isColSelection || isRowSelection || noticeBlock,
|
||||
blockSelection: !!(
|
||||
codeBlock ||
|
||||
isColSelection ||
|
||||
isRowSelection ||
|
||||
noticeBlock
|
||||
),
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
@@ -349,7 +358,6 @@ const Background = styled.div<{ align: Props["align"] }>`
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
padding: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.align === "start" &&
|
||||
|
||||
@@ -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 }>`
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
@@ -92,7 +91,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
@@ -124,7 +123,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
@@ -152,7 +151,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
@@ -172,9 +171,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: MentionType.Document,
|
||||
modelId: v4(),
|
||||
modelId: crypto.randomUUID(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
@@ -82,7 +81,7 @@ function useItems({
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
: MentionType.URL;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -97,11 +96,11 @@ function useItems({
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
modelId: crypto.randomUUID(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -157,6 +157,7 @@ const FlexibleWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
justify-content: space-evenly;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { v4 } from "uuid";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
@@ -144,7 +143,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -189,7 +188,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
label: collection.name,
|
||||
id: v4(),
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,13 +26,22 @@ export default function useSwipe({
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
const onTouchStartCapture = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
if (e.touches.length === 1) {
|
||||
// Stop propagation only for single touch gestures, otherwise it prevents
|
||||
// multi-touch gestures like pinch to zoom to take effect
|
||||
e.stopPropagation();
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
|
||||
const onTouchMoveCapture = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
if (
|
||||
isNumber(touchXStart.current) &&
|
||||
isNumber(touchYStart.current) &&
|
||||
e.touches.length === 1
|
||||
) {
|
||||
touchXEnd.current = e.changedTouches[0].screenX;
|
||||
touchYEnd.current = e.changedTouches[0].screenY;
|
||||
const dx = touchXEnd.current - touchXStart.current;
|
||||
@@ -64,13 +73,13 @@ export default function useSwipe({
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchCancel = () => {
|
||||
const onTouchCancelCapture = () => {
|
||||
resetTouchPoints();
|
||||
};
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchCancel,
|
||||
onTouchStartCapture,
|
||||
onTouchMoveCapture,
|
||||
onTouchCancelCapture,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -126,7 +126,7 @@ function DocumentMenu({
|
||||
);
|
||||
|
||||
const rootAction = useDocumentMenuAction({
|
||||
document,
|
||||
documentId: document.id,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -75,6 +75,10 @@ class Share extends Model implements Searchable {
|
||||
@observable
|
||||
showLastUpdated: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
showTOC: boolean;
|
||||
|
||||
@observable
|
||||
views: number;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
@@ -22,9 +21,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. */
|
||||
@@ -105,6 +106,7 @@ function CommentForm({
|
||||
setForceRender((s) => ++s);
|
||||
setInputFocused(false);
|
||||
|
||||
const commentDraft = draft;
|
||||
const comment =
|
||||
thread ??
|
||||
new Comment(
|
||||
@@ -124,6 +126,9 @@ function CommentForm({
|
||||
})
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
onSaveDraft(commentDraft);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
});
|
||||
@@ -140,6 +145,7 @@ function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const commentDraft = draft;
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
@@ -154,13 +160,16 @@ function CommentForm({
|
||||
comments
|
||||
);
|
||||
|
||||
comment.id = uuidv4();
|
||||
comment.id = crypto.randomUUID();
|
||||
comments.add(comment);
|
||||
|
||||
comment
|
||||
.save()
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
onSaveDraft(commentDraft);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
comments.remove(comment.id);
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
EditorUpdateError,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -37,6 +38,10 @@ function ConnectionStatus() {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
[EditorUpdateError.code]: {
|
||||
title: t("New version available"),
|
||||
body: t("Please reload the page to update to the latest version"),
|
||||
},
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
@@ -63,20 +68,29 @@ function ConnectionStatus() {
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<Fade>
|
||||
<Button width="auto">
|
||||
{message?.title ?? t("Offline")}
|
||||
<DisconnectedIcon />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Button>
|
||||
</Fade>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.backgroundTertiary};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
<ConnectionStatus />
|
||||
<SizeWarning document={document} />
|
||||
<KeyboardShortcutsButton />
|
||||
</FooterWrapper>
|
||||
);
|
||||
|
||||
const FooterWrapper = styled.div`
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 20px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
`;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { presence, auth, ui } = useStores();
|
||||
const [editorVersionBehind, setEditorVersionBehind] = useState(false);
|
||||
const [showCursorNames, setShowCursorNames] = useState(false);
|
||||
const [remoteProvider, setRemoteProvider] =
|
||||
useState<HocuspocusProvider | null>(null);
|
||||
@@ -161,7 +162,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
ui.setMultiplayerStatus("disconnected", ev.event.code);
|
||||
|
||||
if (ev.event.code === EditorUpdateError.code) {
|
||||
window.location.reload();
|
||||
setEditorVersionBehind(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -309,6 +310,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
)}
|
||||
<Editor
|
||||
{...props}
|
||||
readOnly={props.readOnly || editorVersionBehind}
|
||||
value={undefined}
|
||||
defaultValue={undefined}
|
||||
extensions={extensions}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { hideScrollbars } from "@shared/styles";
|
||||
import {
|
||||
@@ -105,7 +104,7 @@ function Search() {
|
||||
// without a flash of loading.
|
||||
if (query) {
|
||||
searches.add({
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -43,11 +43,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Large} />{" "}
|
||||
<Flex column>
|
||||
<Text>
|
||||
<Text selectable>
|
||||
{user.name} {currentUser.id === user.id && `(${t("You")})`}
|
||||
</Text>
|
||||
{isMobile && canManage && (
|
||||
<Text type="tertiary">{user.email}</Text>
|
||||
<Text type="tertiary" selectable>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observable, action } from "mobx";
|
||||
import * as React from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
type DialogDefinition = {
|
||||
title: string;
|
||||
@@ -66,7 +65,7 @@ export default class DialogsStore {
|
||||
this.modalStack.clear();
|
||||
}
|
||||
|
||||
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
|
||||
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
|
||||
title,
|
||||
content,
|
||||
style,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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.901.0",
|
||||
"@aws-sdk/lib-storage": "3.903.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.901.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.901.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.901.0",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.12.0",
|
||||
"@bull-board/koa": "^6.13.0",
|
||||
"@css-inline/css-inline-wasm": "^0.17.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
@@ -73,9 +73,9 @@
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-redis": "1.1.2",
|
||||
@@ -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",
|
||||
@@ -179,9 +179,9 @@
|
||||
"mobx-utils": "^4.0.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"nodemailer": "^7.0.7",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.12.1",
|
||||
"outline-icons": "^3.13.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.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",
|
||||
@@ -228,6 +228,7 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
@@ -261,7 +262,6 @@
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.15",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
@@ -342,7 +342,7 @@
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@types/validator": "^13.15.3",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
|
||||
@@ -9,7 +9,7 @@ class Iframely {
|
||||
|
||||
public static async requestResource(
|
||||
url: string,
|
||||
type = "oembed"
|
||||
type = "iframely"
|
||||
): Promise<JSONObject | UnfurlError> {
|
||||
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
||||
|
||||
@@ -38,7 +38,7 @@ class Iframely {
|
||||
const data = await Iframely.requestResource(url);
|
||||
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
? ({ error: data.error } as UnfurlError)
|
||||
: { ...data, type: UnfurlResourceType.OEmbed };
|
||||
: { ...data, type: UnfurlResourceType.URL };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
@@ -27,7 +26,7 @@ export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
|
||||
|
||||
const res = await FileStorage.storeFromUrl(
|
||||
props.logoUrl,
|
||||
`${Buckets.avatars}/${integration.teamId}/${uuidv4()}`,
|
||||
`${Buckets.avatars}/${integration.teamId}/${crypto.randomUUID()}`,
|
||||
"public-read",
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import FormData from "form-data";
|
||||
import { ensureDirSync } from "fs-extra";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
@@ -135,7 +134,7 @@ describe("#files.get", () => {
|
||||
it("should fail with status 404 if existing file is requested with key", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "images.docx";
|
||||
const key = path.join("uploads", user.id, uuidV4(), fileName);
|
||||
const key = path.join("uploads", user.id, crypto.randomUUID(), fileName);
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
@@ -153,7 +152,7 @@ describe("#files.get", () => {
|
||||
it("should fail with status 404 if non-existing file is requested with key", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "images.docx";
|
||||
const key = path.join("uploads", user.id, uuidV4(), fileName);
|
||||
const key = path.join("uploads", user.id, crypto.randomUUID(), fileName);
|
||||
const res = await server.get(`/api/files.get?key=${key}`);
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
@@ -279,7 +278,7 @@ describe("#files.get", () => {
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
const key = path.join("avatars", user.id, crypto.randomUUID());
|
||||
const attachment = await buildAttachment({
|
||||
key,
|
||||
teamId: user.teamId,
|
||||
@@ -308,7 +307,7 @@ describe("#files.get", () => {
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
const key = path.join("avatars", user.id, crypto.randomUUID());
|
||||
await buildAttachment({
|
||||
key,
|
||||
teamId: user.teamId,
|
||||
@@ -335,7 +334,7 @@ describe("#files.get", () => {
|
||||
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "export-markdown.zip";
|
||||
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
|
||||
const key = `${Buckets.uploads}/${user.teamId}/${crypto.randomUUID()}/${fileName}`;
|
||||
|
||||
await buildFileOperation({
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { WebhookDelivery } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
@@ -99,7 +98,7 @@ describe("DeliverWebhookTask", () => {
|
||||
url: "http://example.com",
|
||||
events: ["*"],
|
||||
});
|
||||
const deletedUserId = uuidv4();
|
||||
const deletedUserId = crypto.randomUUID();
|
||||
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||
|
||||
const task = new DeliverWebhookTask();
|
||||
|
||||
|
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 534 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 977 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1003 B After Width: | Height: | Size: 893 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 769 B |
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 239 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 876 B After Width: | Height: | Size: 764 B |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 313 B |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1017 B After Width: | Height: | Size: 939 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 696 B |