Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 18aadd2b5a chore: Compressed inefficient images automatically 2025-09-28 20:05:54 +00:00
202 changed files with 2384 additions and 4016 deletions
+8 -7
View File
@@ -14,13 +14,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
# 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/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -32,6 +26,13 @@ RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22 AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+1 -134
View File
@@ -1,12 +1,8 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -26,11 +22,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";
@@ -41,16 +37,10 @@ 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"),
@@ -147,129 +137,6 @@ 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",
+8 -19
View File
@@ -50,6 +50,7 @@ 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 {
@@ -81,14 +82,7 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -599,15 +593,12 @@ export const copyDocumentAsMarkdown = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
copy(document.toMarkdown());
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -621,15 +612,12 @@ export const copyDocumentAsPlainText = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
@@ -861,7 +849,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
@@ -874,6 +862,7 @@ export const importDocument = createActionV2({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
+7 -6
View File
@@ -2,6 +2,7 @@ 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,
@@ -45,7 +46,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -201,7 +202,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -212,7 +213,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -223,7 +224,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -234,7 +235,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -251,7 +252,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: crypto.randomUUID(),
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
+6 -5
View File
@@ -13,6 +13,7 @@ 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";
@@ -29,7 +30,6 @@ 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,9 +37,8 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
children?: React.ReactNode;
@@ -131,7 +130,9 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+1 -2
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
@@ -45,4 +44,4 @@ const Link = styled.a`
}
`;
export default React.memo(Branding);
export default Branding;
+4 -7
View File
@@ -1,10 +1,7 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
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();
@@ -12,7 +9,7 @@ function Dialogs() {
const modals = [...modalStack];
return (
<Suspense fallback={null}>
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -36,7 +33,7 @@ function Dialogs() {
{modal.content}
</Modal>
))}
</Suspense>
</>
);
}
+23 -16
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { useRef, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,12 +19,10 @@ 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 */
@@ -78,13 +76,6 @@ 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}
@@ -159,11 +150,12 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
updatedAt
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
@@ -185,6 +177,21 @@ 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,
+17 -24
View File
@@ -3,7 +3,6 @@ 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";
@@ -28,6 +27,7 @@ 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,13 +49,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
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);
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
@@ -64,9 +59,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (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);
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
}
@@ -111,6 +104,19 @@ 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)
@@ -124,19 +130,6 @@ 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
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ActionContextProvider
-1
View File
@@ -39,7 +39,6 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
+118 -430
View File
@@ -1,19 +1,12 @@
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 {
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 { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
@@ -21,13 +14,12 @@ 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";
@@ -37,16 +29,6 @@ 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,
@@ -61,9 +43,6 @@ export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
@@ -81,125 +60,46 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** List of allowed images */
images: LightboxImage[];
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** The position of the currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
activePos: number | null;
};
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) {
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
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 currentImageIndex = findIndex(
images,
(img) => img.getPos() === activeImage.getPos()
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
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
@@ -208,21 +108,15 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
// );
// }, [status]);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => () => view.focus(), []);
useEffect(() => {
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
@@ -245,18 +139,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: 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();
@@ -274,15 +156,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: 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();
@@ -306,10 +179,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = activeImage.getElement();
const editorImageEl = imageElements[currentImageIndex];
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -396,13 +270,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
const setupZoomOut = () => {
if (
imgRef.current &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
) {
if (imgRef.current) {
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -421,7 +289,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
// in editor
const editorImageEl = activeImage.getElement();
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
@@ -496,31 +364,33 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(images[prevIndex]);
onUpdate(imageNodes[prevIndex].pos);
}
};
const next = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const nextIndex = currentImageIndex + 1;
if (nextIndex >= images.length) {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
onUpdate(images[nextIndex]);
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
}
};
@@ -536,63 +406,12 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
}
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) {
@@ -640,8 +459,14 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
if (!currentImageNode) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={true}>
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -649,7 +474,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<StyledContent onKeyDown={handleKeyDown}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
@@ -657,52 +482,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: 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 ?? ""}>
<ActionButton
<Button
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
@@ -712,9 +495,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<ActionButton
<Button
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={download}
aria-label={t("Download")}
size={32}
@@ -726,7 +508,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
<Button
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
@@ -738,86 +520,49 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
</Tooltip>
</Dialog.Close>
</Actions>
{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
)
{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,
})
}
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>
)}
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>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -836,9 +581,6 @@ type ImageProps = {
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
@@ -854,9 +596,6 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
@@ -869,25 +608,6 @@ 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
);
@@ -922,15 +642,9 @@ 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.MIN_ZOOM &&
{status.image === ImageStatus.LOADED &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
@@ -986,25 +700,12 @@ 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`
@@ -1016,12 +717,7 @@ 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)`
@@ -1032,10 +728,7 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
`;
const ActionButton = styled(Button)`
background: transparent;
padding: 56px;
`;
const Actions = styled.div<{
@@ -1048,10 +741,6 @@ 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
@@ -1079,7 +768,6 @@ 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
@@ -1099,7 +787,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(ImageError)<{
const StyledError = styled(Error)<{
animation: Animation | null;
}>`
${(props) =>
+5 -5
View File
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action?: ActionV2WithChildren;
action: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
return [];
}
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action?.children, actionContext]);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -68,7 +68,7 @@ export const ContextMenu = observer(
[]
);
if (isMobile || !action || menuItems.length === 0) {
if (isMobile) {
return <>{children}</>;
}
@@ -6,17 +6,13 @@ 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 { Suspense, useCallback, useEffect, useRef, useState } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
@@ -7,9 +7,7 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
import Notifications from "./Notifications";
type Props = {
children?: React.ReactNode;
@@ -18,18 +16,18 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
React.useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = useCallback(() => {
const handleRequestClose = React.useCallback(() => {
setOpen(false);
}, []);
const handleAutoFocus = useCallback((event: Event) => {
const handleAutoFocus = React.useCallback((event: Event) => {
// Prevent focus from moving to the popover content
event.preventDefault();
@@ -50,12 +48,10 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</PopoverContent>
</Popover>
);
-26
View File
@@ -1,26 +0,0 @@
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,19 +71,6 @@ 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 {
@@ -217,31 +204,6 @@ function InnerPublicAccess({ collection, share }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
@@ -77,19 +77,6 @@ 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 {
@@ -254,31 +241,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
+1 -8
View File
@@ -22,7 +22,6 @@ 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;
@@ -38,10 +37,6 @@ 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;
}
@@ -146,8 +141,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
@media (hover: hover) {
&:${hover} {
&: ${hover} {
${StyledSearchPopover} {
width: 85%;
}
@@ -155,7 +149,6 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
}
`}
@@ -25,8 +25,6 @@ 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;
@@ -111,12 +109,8 @@ 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
@@ -128,7 +122,6 @@ const CollectionLink: React.FC<Props> = ({
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -196,7 +189,7 @@ const CollectionLink: React.FC<Props> = ({
}
/>
)}
</ActionContextProvider>
</>
);
};
@@ -18,14 +18,10 @@ 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, auth, collections } = useStores();
const { documents, collections } = useStores();
const { t } = useTranslation();
const can = usePolicy(auth.team?.id);
const orderedCollections = collections.allActive;
const params = useMemo(
@@ -61,7 +57,7 @@ function Collections() {
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={orderedCollections}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -72,20 +68,6 @@ 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,8 +35,6 @@ 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;
@@ -318,14 +316,8 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
}}
>
<>
<Relative ref={parentRef}>
<Draggable
key={node.id}
@@ -342,7 +334,6 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
@@ -434,7 +425,7 @@ function InnerDocumentLink(
/>
))}
</Folder>
</ActionContextProvider>
</>
);
}
@@ -11,10 +11,6 @@ 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;
@@ -36,7 +32,6 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
depth?: number;
scrollIntoViewIfNeeded?: boolean;
contextAction?: ActionV2WithChildren;
};
const activeDropStyle = {
@@ -67,29 +62,19 @@ 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,
@@ -99,58 +84,41 @@ 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 (
<>
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
<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}
>
<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>
<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>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
+83 -190
View File
@@ -28,173 +28,11 @@ 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();
@@ -285,40 +123,95 @@ 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 (
<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}
/>
<>
<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>
</>
);
}
if (collection) {
return (
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
<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>
);
}
@@ -22,11 +22,7 @@ export function useSidebarLabelAndIcon(
return {
label: document.titleWithDefault,
icon: document.icon ? (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
+2
View File
@@ -5,6 +5,7 @@ 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;
@@ -29,6 +30,7 @@ const Theme: React.FC = ({ children }: Props) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
+5 -1
View File
@@ -1,7 +1,7 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import styled, { createGlobalStyle, keyframes } from "styled-components";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -285,4 +285,8 @@ const StyledContent = styled(TooltipPrimitive.Content)`
}
`;
export const TooltipStyles = createGlobalStyle`
/* Legacy styles for backward compatibility - can be removed after migration */
`;
export default Tooltip;
+1 -2
View File
@@ -119,6 +119,5 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
gap: 8px;
`;
+14 -22
View File
@@ -1,5 +1,5 @@
import { NodeSelection } from "prosemirror-state";
import { selectedRect } from "prosemirror-tables";
import { CellSelection, selectedRect } from "prosemirror-tables";
import * as React from "react";
import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
@@ -15,9 +15,6 @@ 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";
@@ -48,7 +45,11 @@ function usePosition({
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = 36;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
if (!active || !menuRef.current) {
return defaultPosition;
}
// based on the start and end of the selection calculate the position at
// the center top
@@ -70,7 +71,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,
@@ -95,23 +96,19 @@ function usePosition({
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top + menuHeight;
selectionBounds.top = bounds.top;
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 ColumnSelection && selection.isColSelection();
selection instanceof CellSelection && selection.isColSelection();
const isRowSelection =
selection instanceof RowSelection && selection.isRowSelection();
selection instanceof CellSelection && selection.isRowSelection();
if (isTableSelected(view.state)) {
if (isColSelection && isRowSelection) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
@@ -166,8 +163,6 @@ function usePosition({
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
blockSelection: false,
maxWidth: width,
};
}
}
@@ -212,12 +207,8 @@ 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,
};
}
@@ -358,6 +349,7 @@ const Background = styled.div<{ align: Props["align"] }>`
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
+1 -2
View File
@@ -282,8 +282,7 @@ const LinkEditor: React.FC<Props> = ({
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
+6 -5
View File
@@ -5,6 +5,7 @@ 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";
@@ -91,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -123,7 +124,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -151,7 +152,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -171,9 +172,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Document,
modelId: crypto.randomUUID(),
modelId: v4(),
actorId,
label: search,
},
+4 -3
View File
@@ -2,6 +2,7 @@ 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";
@@ -81,7 +82,7 @@ function useItems({
mentionType = integration
? determineMentionType({ url, integration })
: MentionType.URL;
: undefined;
}
return [
@@ -96,11 +97,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: crypto.randomUUID(),
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
+12 -12
View File
@@ -1,5 +1,6 @@
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";
@@ -7,11 +8,7 @@ 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,
isTableSelected,
} from "@shared/editor/queries/table";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
@@ -26,6 +23,7 @@ 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";
@@ -180,13 +178,15 @@ export default function SelectionToolbar(props: Props) {
const { state } = view;
const { selection } = state;
if (isDragging) {
if ((readOnly && !canComment) || 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 (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isCellSelection) {
items = getTableCellMenuItems(state, dictionary);
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
+1 -4
View File
@@ -31,9 +31,6 @@ 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) =>
@@ -47,7 +44,7 @@ export default styled.button.attrs((props) => ({
cursor: default;
}
${extraArea(5)}
${extraArea(4)}
${(props) =>
props.active &&
-1
View File
@@ -157,7 +157,6 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
+3 -2
View File
@@ -8,6 +8,7 @@ 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";
@@ -143,7 +144,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: crypto.randomUUID(),
id: v4(),
})
)
);
@@ -188,7 +189,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: crypto.randomUUID(),
id: v4(),
})
)
);
+8 -26
View File
@@ -54,12 +54,6 @@ 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 = {
@@ -152,8 +146,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
};
/**
@@ -183,7 +177,7 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
activeLightboxImgPos: null,
};
isInitialized = false;
@@ -646,16 +640,6 @@ 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.
*
@@ -733,10 +717,10 @@ export class Editor extends React.PureComponent<
dispatch(tr);
};
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
public updateActiveLightbox = (pos: number | null) => {
this.setState((state) => ({
...state,
activeLightboxImage: activeImage,
activeLightboxImgPos: pos,
}));
};
@@ -859,12 +843,10 @@ export class Editor extends React.PureComponent<
)}
</Observer>
</Flex>
{!isNull(this.state.activeLightboxImage) && (
{this.state.activeLightboxImgPos && (
<Lightbox
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
onUpdate={this.updateActiveLightbox}
activePos={this.state.activeLightboxImgPos}
/>
)}
</EditorContext.Provider>
+5 -27
View File
@@ -17,8 +17,6 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import styled from "styled-components";
@@ -36,11 +34,6 @@ 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,
@@ -53,7 +46,6 @@ 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,
@@ -174,25 +166,11 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
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),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "separator",
visible: !isCodeBlock,
},
{
name: "checkbox_list",
@@ -201,7 +179,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "bullet_list",
@@ -209,7 +187,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "ordered_list",
@@ -217,7 +195,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "outdentList",
+36
View File
@@ -0,0 +1,36 @@
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableCellMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
// Only show menu items if we have a CellSelection
if (!(selection instanceof CellSelection)) {
return [];
}
return [
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
];
}
-72
View File
@@ -1,72 +0,0 @@
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);
}
+6 -5
View File
@@ -43,8 +43,8 @@ import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document ID for which the actions are generated */
documentId: string;
/** Document for which the actions are generated */
document: Document;
/** 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({
documentId,
document,
onFindAndReplace,
onRename,
onSelectTemplate,
@@ -62,10 +62,11 @@ export function useDocumentMenuAction({
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(documentId);
const can = usePolicy(document);
const templateMenuActions = useTemplateMenuActions({
documentId,
document,
onSelectTemplate,
});
+2 -3
View File
@@ -19,6 +19,7 @@ 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";
@@ -36,7 +37,6 @@ 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,8 +211,7 @@ const useSettingsConfig = () => {
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations.Component,
preload: Integrations.preload,
component: Integrations,
enabled: can.update,
group: t("Integrations"),
icon: PlusIcon,
+9 -18
View File
@@ -26,22 +26,13 @@ export default function useSwipe({
touchYEnd.current = undefined;
};
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 onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
};
const onTouchMoveCapture = (e: React.TouchEvent<HTMLImageElement>) => {
if (
isNumber(touchXStart.current) &&
isNumber(touchYStart.current) &&
e.touches.length === 1
) {
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
touchXEnd.current = e.changedTouches[0].screenX;
touchYEnd.current = e.changedTouches[0].screenY;
const dx = touchXEnd.current - touchXStart.current;
@@ -73,13 +64,13 @@ export default function useSwipe({
}
};
const onTouchCancelCapture = () => {
const onTouchCancel = () => {
resetTouchPoints();
};
return {
onTouchStartCapture,
onTouchMoveCapture,
onTouchCancelCapture,
onTouchStart,
onTouchMove,
onTouchCancel,
};
}
+3 -7
View File
@@ -17,7 +17,7 @@ import { useComputed } from "./useComputed";
type Props = {
/** The document to which the templates will be applied */
documentId: string;
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
@@ -33,14 +33,10 @@ 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({
documentId,
onSelectTemplate,
}: Props) {
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): ActionV2 =>
@@ -74,7 +70,7 @@ export function useTemplateMenuActions({
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document?.collectionId
template.collectionId === document.collectionId
)
.map(templateToAction);
+10 -10
View File
@@ -55,11 +55,11 @@ if (element) {
<Analytics>
<Router history={history}>
<Theme>
<ActionContextProvider>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ActionContextProvider>
<PageScroll>
<PageTheme />
<ScrollToTop>
@@ -69,11 +69,11 @@ if (element) {
<Dialogs />
<Desktop />
</PageScroll>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</ActionContextProvider>
</ActionContextProvider>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Theme>
</Router>
</Analytics>
+189 -6
View File
@@ -1,14 +1,47 @@
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 { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActiveCollectionSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
collection: Collection;
@@ -27,8 +60,10 @@ function CollectionMenu({
onOpen,
onClose,
}: Props) {
const { subscriptions } = useStores();
const { documents, subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
@@ -47,13 +82,161 @@ function CollectionMenu({
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const rootAction = useCollectionMenuAction({
collectionId: collection.id,
onRename,
});
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);
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<VisuallyHidden.Root>
<label>
{t("Import document")}
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex={-1}
/>
</label>
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
align={align}
+1 -1
View File
@@ -126,7 +126,7 @@ function DocumentMenu({
);
const rootAction = useDocumentMenuAction({
documentId: document.id,
document,
onFindAndReplace,
onRename,
onSelectTemplate,
+1 -4
View File
@@ -18,10 +18,7 @@ type Props = {
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const { t } = useTranslation();
const allActions = useTemplateMenuActions({
onSelectTemplate,
documentId: document.id,
});
const allActions = useTemplateMenuActions({ onSelectTemplate, document });
const rootAction = useMenuAction(allActions);
if (!allActions.length) {
+45
View File
@@ -3,6 +3,9 @@ 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,
@@ -14,6 +17,7 @@ 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";
@@ -683,6 +687,47 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
toMarkdown = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data)
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
-4
View File
@@ -75,10 +75,6 @@ class Share extends Model implements Searchable {
@observable
showLastUpdated: boolean;
@Field
@observable
showTOC: boolean;
@observable
views: number;
-49
View File
@@ -1,49 +0,0 @@
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,9 +1,10 @@
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import { Suspense, useCallback, useState } from "react";
import { 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,
@@ -12,11 +13,6 @@ 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 */
@@ -60,13 +56,11 @@ function ShareButton({ collection }: Props) {
side="bottom"
align="end"
>
<Suspense fallback={null}>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
</PopoverContent>
</Popover>
);
+3 -5
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { lazy, useState, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -47,12 +47,10 @@ 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 = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
const IconPicker = lazy(() => import("~/components/IconPicker"));
enum CollectionPath {
Overview = "overview",
+3 -12
View File
@@ -7,6 +7,7 @@ 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";
@@ -21,11 +22,9 @@ 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. */
@@ -106,7 +105,6 @@ function CommentForm({
setForceRender((s) => ++s);
setInputFocused(false);
const commentDraft = draft;
const comment =
thread ??
new Comment(
@@ -126,9 +124,6 @@ function CommentForm({
})
.then(() => onSubmit?.())
.catch(() => {
onSaveDraft(commentDraft);
setForceRender((s) => ++s);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
@@ -145,7 +140,6 @@ function CommentForm({
return;
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
@@ -160,16 +154,13 @@ function CommentForm({
comments
);
comment.id = crypto.randomUUID();
comment.id = uuidv4();
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,7 +7,6 @@ import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
EditorUpdateError,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
@@ -38,10 +37,6 @@ 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
@@ -68,29 +63,20 @@ function ConnectionStatus() {
}
placement="bottom"
>
<Fade>
<Button width="auto">
{message?.title ?? t("Offline")}
<Button>
<Fade>
<DisconnectedIcon />
</Button>
</Fade>
</Fade>
</Button>
</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: flex;
gap: 4px;
align-items: center;
display: block;
`};
@media print {
+4 -6
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, hideScrollbars, s } from "@shared/styles";
import { depths, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
import { decodeURIComponentSafe } from "~/utils/urls";
@@ -37,9 +37,7 @@ function Contents() {
}
}
if (activeSlug !== activeId) {
setActiveSlug(activeId);
}
setActiveSlug(activeId);
}, [scrollPosition, headings]);
// calculate the minimum heading level and adjust all the headings to make
@@ -78,16 +76,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 -2
View File
@@ -27,13 +27,16 @@ 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";
@@ -54,11 +57,13 @@ 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;
@@ -428,7 +433,6 @@ class DocumentScene extends React.Component<Props> {
render() {
const {
children,
document,
revision,
readOnly,
@@ -629,8 +633,19 @@ class DocumentScene extends React.Component<Props> {
)}
</React.Suspense>
</Main>
{children}
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
{!isShare && (
<Footer>
<KeyboardShortcutsButton />
<ConnectionStatus />
<SizeWarning document={document} />
</Footer>
)}
</MeasuredContainer>
</ErrorBoundary>
);
@@ -739,6 +754,16 @@ 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,9 +24,8 @@ 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 = lazyWithRetry(() => import("~/components/IconPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
-27
View File
@@ -1,27 +0,0 @@
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;
`;
+1 -2
View File
@@ -14,7 +14,6 @@ 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;
@@ -23,7 +22,7 @@ type Props = {
function Insights({ document }: Props) {
const { t } = useTranslation();
const selectedText = useTextSelection();
const text = ProsemirrorHelper.toPlainText(document);
const text = document.toPlainText();
const stats = useTextStats(text ?? "", selectedText);
const formatNumber = useFormatNumber();
@@ -57,7 +57,6 @@ 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);
@@ -162,7 +161,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
setEditorVersionBehind(true);
window.location.reload();
}
}
});
@@ -310,7 +309,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
)}
<Editor
{...props}
readOnly={props.readOnly || editorVersionBehind}
value={undefined}
defaultValue={undefined}
extensions={extensions}
+7 -13
View File
@@ -1,9 +1,10 @@
import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import { Suspense, useCallback, useState } from "react";
import { 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,
@@ -11,11 +12,6 @@ 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 */
@@ -54,13 +50,11 @@ function ShareButton({ document }: Props) {
side="bottom"
align="end"
>
<Suspense fallback={null}>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
</PopoverContent>
</Popover>
);
@@ -7,7 +7,6 @@ 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;
@@ -15,7 +14,7 @@ type Props = {
export const SizeWarning = ({ document }: Props) => {
const { t } = useTranslation();
const length = ProsemirrorHelper.toPlainText(document).length;
const length = document.toPlainText().length;
if (length < DocumentValidation.maxRecommendedLength) {
return null;
+1 -6
View File
@@ -6,7 +6,6 @@ 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;
@@ -66,11 +65,7 @@ export default function DocumentScene(props: Props) {
history={props.history}
location={props.location}
>
{(rest) => (
<Document {...rest}>
<Footer document={rest.document} />
</Document>
)}
{(rest) => <Document {...rest} />}
</DataLoader>
);
}
+2 -10
View File
@@ -40,12 +40,8 @@ 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;
@@ -209,11 +205,7 @@ function Login({ children, onBack }: Props) {
const preferOTP = isPWA;
if (firstRun) {
return (
<React.Suspense fallback={null}>
<WorkspaceSetup onBack={onBack} />
</React.Suspense>
);
return <WorkspaceSetup onBack={onBack} />;
}
if (emailLinkSentTo) {
+2 -1
View File
@@ -6,6 +6,7 @@ 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 {
@@ -104,7 +105,7 @@ function Search() {
// without a flash of loading.
if (query) {
searches.add({
id: crypto.randomUUID(),
id: uuidv4(),
query,
createdAt: new Date().toISOString(),
});
+1 -4
View File
@@ -12,9 +12,8 @@ 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";
function Integrations() {
export function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
@@ -71,5 +70,3 @@ const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
export default observer(Integrations);
@@ -43,13 +43,11 @@ export function MembersTable({ canManage, ...rest }: Props) {
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text selectable>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary" selectable>
{user.email}
</Text>
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
</Flex>
+8 -22
View File
@@ -5,9 +5,6 @@ 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;
@@ -17,14 +14,8 @@ 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)
@@ -32,19 +23,14 @@ function SharedDocument({ document, shareId, sharedTree }: Props) {
setDocument(document);
return (
<>
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
{showBranding ? (
<Branding href="//www.getoutline.com?ref=sharelink" />
) : null}
</>
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
+17 -21
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { Suspense, useCallback, useEffect } from "react";
import { useCallback, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
@@ -28,12 +28,10 @@ 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
@@ -196,23 +194,21 @@ function SharedScene() {
if (error instanceof AuthorizationError) {
setPostLoginPath(location.pathname);
return (
<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>
<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>
);
}
return <Error404 />;
+2 -1
View File
@@ -1,5 +1,6 @@
import { observable, action } from "mobx";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
type DialogDefinition = {
title: string;
@@ -65,7 +66,7 @@ export default class DialogsStore {
this.modalStack.clear();
}
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
title,
content,
style,
+1 -4
View File
@@ -3,10 +3,7 @@ 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",
],
+16 -16
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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",
"@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",
"@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.13.0",
"@bull-board/koa": "^6.12.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.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@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.67.0",
"dd-trace": "^5.64.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.11.0",
"mammoth": "^1.10.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": "^7.0.7",
"nodemailer": "^6.10.1",
"octokit": "^3.2.2",
"outline-icons": "^3.13.0",
"outline-icons": "^3.12.1",
"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.41.2",
"prosemirror-view": "^1.40.1",
"proxy-from-env": "^1.1.0",
"query-string": "^7.1.3",
"rate-limiter-flexible": "^2.4.2",
@@ -228,7 +228,6 @@
"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",
@@ -262,6 +261,7 @@
"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.3",
"@types/validator": "^13.15.2",
"@types/yauzl": "^2.10.3",
"babel-jest": "^29.7.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
+2 -2
View File
@@ -9,7 +9,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "iframely"
type = "oembed"
): 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.URL };
: { ...data, type: UnfurlResourceType.OEmbed };
};
}
@@ -1,3 +1,4 @@
import { v4 as uuidv4 } from "uuid";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
@@ -26,7 +27,7 @@ export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
const res = await FileStorage.storeFromUrl(
props.logoUrl,
`${Buckets.avatars}/${integration.teamId}/${crypto.randomUUID()}`,
`${Buckets.avatars}/${integration.teamId}/${uuidv4()}`,
"public-read",
{
headers: {
+6 -5
View File
@@ -3,6 +3,7 @@ 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";
@@ -134,7 +135,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, crypto.randomUUID(), fileName);
const key = path.join("uploads", user.id, uuidV4(), fileName);
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
@@ -152,7 +153,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, crypto.randomUUID(), fileName);
const key = path.join("uploads", user.id, uuidV4(), fileName);
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(404);
});
@@ -278,7 +279,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, crypto.randomUUID());
const key = path.join("avatars", user.id, uuidV4());
const attachment = await buildAttachment({
key,
teamId: user.teamId,
@@ -307,7 +308,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, crypto.randomUUID());
const key = path.join("avatars", user.id, uuidV4());
await buildAttachment({
key,
teamId: user.teamId,
@@ -334,7 +335,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}/${crypto.randomUUID()}/${fileName}`;
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
await buildFileOperation({
userId: user.id,
@@ -1,4 +1,5 @@
import fetchMock from "jest-fetch-mock";
import { v4 as uuidv4 } from "uuid";
import { WebhookDelivery } from "@server/models";
import {
buildUser,
@@ -98,7 +99,7 @@ describe("DeliverWebhookTask", () => {
url: "http://example.com",
events: ["*"],
});
const deletedUserId = crypto.randomUUID();
const deletedUserId = uuidv4();
const signedInUser = await buildUser({ teamId: subscription.teamId });
const task = new DeliverWebhookTask();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 631 B

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