mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 396ceb34bb | |||
| e1b2993bca | |||
| b3d4563730 | |||
| 7106263f88 | |||
| 3c2e9a9723 | |||
| bd01a62fc1 | |||
| 95106e695f | |||
| 39623b90bd | |||
| a3fcd71582 | |||
| a2f9962958 | |||
| 709184ae0b | |||
| bc5ffb79b2 | |||
| d703f8acf3 | |||
| 1c23cbec1b | |||
| 1caeafaeed | |||
| 969a7bb97d | |||
| 053693b9d5 | |||
| 7938ffdd7a | |||
| 9b8acf3efb | |||
| ac6b680cdb | |||
| 27c633eb8b | |||
| ca36451e42 | |||
| 0d198294eb | |||
| bc63aba1d1 | |||
| ea665b80ee | |||
| 492af6683b | |||
| f4b80d5301 | |||
| 58f0613b5f |
@@ -1,41 +0,0 @@
|
||||
name: Docker Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
|
||||
env:
|
||||
BASE_IMAGE_NAME: outline-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Build base image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
push: false
|
||||
|
||||
- name: Build main image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
|
||||
@@ -73,6 +73,23 @@
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "prosemirror-tables",
|
||||
"importNames": [
|
||||
"addRowBefore",
|
||||
"addRowAfter",
|
||||
"addColumnBefore",
|
||||
"addColumnAfter"
|
||||
],
|
||||
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@@ -93,6 +110,7 @@
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-useless-default-assignment": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -240,6 +240,26 @@ function findDocumentSiblingIndex(
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the user can create a sibling of the given document.
|
||||
* A sibling shares the document's parent, so this mirrors the backend's
|
||||
* create authorization: create permission on the parent document, or on the
|
||||
* collection when the document is at the root.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to create a sibling of.
|
||||
* @returns true if the user can create a sibling.
|
||||
*/
|
||||
function canCreateSiblingDocument(
|
||||
stores: ActionContext["stores"],
|
||||
document: { collectionId?: string | null; parentDocumentId?: string }
|
||||
): boolean {
|
||||
return document.parentDocumentId
|
||||
? stores.policies.abilities(document.parentDocumentId).createChildDocument
|
||||
: !!document.collectionId &&
|
||||
stores.policies.abilities(document.collectionId).createDocument;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
@@ -279,7 +299,7 @@ const createDocumentBefore = createInternalLinkAction({
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -321,7 +341,7 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
@@ -106,7 +106,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DndProvider backend={EditorAwareHTML5Backend}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
|
||||
const hasDisabledUpdateMessage = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
Desktop.bridge?.redirect((path: string, replace: boolean) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BackendFactory } from "dnd-core";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
/**
|
||||
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
|
||||
* that call `preventDefault()` on drops whose dataTransfer resembles a native
|
||||
* item – including a dragged `<img>`, which is how ProseMirror serializes an
|
||||
* image drag.
|
||||
*
|
||||
* These handlers run before ProseMirror's, and they live on `window`, so a
|
||||
* propagation-based guard can't stop react-dnd without also starving the editor
|
||||
* of the event. Instead we wrap the backend and make its top-level capture
|
||||
* handlers no-op for events that occur within the editor surface.
|
||||
*/
|
||||
const captureHandlerNames = [
|
||||
"handleTopDragStartCapture",
|
||||
"handleTopDragEnterCapture",
|
||||
"handleTopDragOverCapture",
|
||||
"handleTopDragLeaveCapture",
|
||||
"handleTopDropCapture",
|
||||
"handleTopDragEndCapture",
|
||||
] as const;
|
||||
|
||||
const isWithinEditor = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && Boolean(target.closest(".ProseMirror"));
|
||||
|
||||
/**
|
||||
* An HTML5 drag-and-drop backend that ignores drag events originating within the
|
||||
* rich text editor so that ProseMirror can handle them itself.
|
||||
*
|
||||
* @param manager The drag-and-drop manager.
|
||||
* @param context The global context.
|
||||
* @param options Backend options.
|
||||
* @returns The wrapped HTML5 backend instance.
|
||||
*/
|
||||
export const EditorAwareHTML5Backend: BackendFactory = (
|
||||
manager,
|
||||
context,
|
||||
options
|
||||
) => {
|
||||
const backend = HTML5Backend(manager, context, options);
|
||||
|
||||
// The capture handlers are private instance fields on the backend, so reach
|
||||
// for them through an index signature view of the instance.
|
||||
const handlers = backend as unknown as Record<
|
||||
string,
|
||||
(event: DragEvent) => void
|
||||
>;
|
||||
|
||||
for (const name of captureHandlerNames) {
|
||||
const original = handlers[name];
|
||||
if (typeof original === "function") {
|
||||
handlers[name] = (event: DragEvent) => {
|
||||
if (isWithinEditor(event.target)) {
|
||||
return;
|
||||
}
|
||||
original.call(backend, event);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return backend;
|
||||
};
|
||||
@@ -44,7 +44,7 @@ type Props = {
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
selectedKeys,
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
|
||||
@@ -11,7 +11,7 @@ import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
position?: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
showMoreMenu?: boolean;
|
||||
|
||||
@@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
// Reserve space for the absolutely-positioned disclosure arrow so long
|
||||
// labels truncate before it rather than overlapping.
|
||||
padding-inline-end: 32px;
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { debounce } from "es-toolkit/compat";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
@@ -211,9 +212,31 @@ export default function FindAndReplace({
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
// Searching the document on every keystroke is expensive in long documents –
|
||||
// it traverses the entire doc and rebuilds highlights – so debounce.
|
||||
const debouncedFind = React.useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
(attrs: {
|
||||
text: string;
|
||||
caseSensitive: boolean;
|
||||
regexEnabled: boolean;
|
||||
}) => {
|
||||
editor.commands.find(attrs);
|
||||
},
|
||||
100
|
||||
),
|
||||
[editor.commands]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious() {
|
||||
// Ensure any pending debounced search has run so navigation acts on the
|
||||
// results for the text currently in the input.
|
||||
debouncedFind.flush();
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
@@ -243,7 +266,7 @@ export default function FindAndReplace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands, selectInputText]
|
||||
[debouncedFind, editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -274,13 +297,13 @@ export default function FindAndReplace({
|
||||
ev.stopPropagation();
|
||||
setSearchTerm(ev.currentTarget.value);
|
||||
|
||||
editor.commands.find({
|
||||
debouncedFind({
|
||||
text: ev.currentTarget.value,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
},
|
||||
[caseSensitive, editor.commands, regexEnabled]
|
||||
[caseSensitive, debouncedFind, regexEnabled]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = React.useCallback(
|
||||
@@ -331,6 +354,9 @@ export default function FindAndReplace({
|
||||
} else {
|
||||
onClose();
|
||||
setShowReplace(false);
|
||||
// Cancel any pending debounced find so it can't reactivate highlights
|
||||
// after the search has been cleared.
|
||||
debouncedFind.cancel();
|
||||
editor.commands.clearSearch();
|
||||
}
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -346,7 +372,10 @@ export default function FindAndReplace({
|
||||
>
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.prevSearchMatch();
|
||||
}}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
@@ -355,7 +384,10 @@ export default function FindAndReplace({
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.nextSearchMatch();
|
||||
}}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useInlineMenuAnchor } from "./useInlineMenuAnchor";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
/** Whether the document is right-to-left. */
|
||||
rtl: boolean;
|
||||
};
|
||||
|
||||
// The virtual anchor is an invisible zero-size element; the hook positions it
|
||||
// over the selection and Radix anchors the menu to it.
|
||||
const anchorStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a selection-toolbar menu inline — a vertical menu anchored to the
|
||||
* selection with no trigger button — by holding a Radix dropdown `open`
|
||||
* against a virtual anchor positioned over the selection. Radix provides the
|
||||
* positioning, collision handling, submenus, and keyboard navigation. Page
|
||||
* scroll is locked while open (via RemoveScroll, as Radix does for modal
|
||||
* menus) without enabling Radix's modal mode, which conflicts with the menu
|
||||
* being opened by an editor selection rather than a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, rtl }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const { state } = view;
|
||||
const isMobile = useMobile();
|
||||
const {
|
||||
ref: anchorRef,
|
||||
key: anchorKey,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
} = useInlineMenuAnchor(rtl);
|
||||
|
||||
const mapped = React.useMemo(
|
||||
() => mapMenuItems(items, commands, view, state),
|
||||
[items, commands, view, state]
|
||||
);
|
||||
|
||||
const preventFocus = React.useCallback((ev: Event) => {
|
||||
ev.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Dismiss the menu by collapsing the selection so the toolbar stops matching.
|
||||
const handleDismiss = React.useCallback(() => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
}, [view]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<InlineMenuDrawer
|
||||
items={mapped}
|
||||
ariaLabel={t("Options")}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<DropdownMenuPrimitive.Root
|
||||
key={anchorKey}
|
||||
open={!!anchorKey}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<div ref={anchorRef} aria-hidden style={anchorStyle} />
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={6}
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={preventFocus}
|
||||
onInteractOutside={handleDismiss}
|
||||
onEscapeKeyDown={handleDismiss}
|
||||
asChild
|
||||
>
|
||||
<RemoveScroll as={Slot} allowPinchZoom>
|
||||
<Components.MenuContent
|
||||
maxHeightVar="--radix-dropdown-menu-content-available-height"
|
||||
transformOriginVar="--radix-dropdown-menu-content-transform-origin"
|
||||
hiddenScrollbars
|
||||
>
|
||||
<EventBoundary>{toMenuItems(mapped)}</EventBoundary>
|
||||
</Components.MenuContent>
|
||||
</RemoveScroll>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Time for the drawer's close animation to play before the selection is
|
||||
// collapsed (which unmounts the menu).
|
||||
const DRAWER_CLOSE_MS = 500;
|
||||
|
||||
type InlineMenuDrawerProps = {
|
||||
items: TMenuItem[];
|
||||
ariaLabel: string;
|
||||
/** Collapse the selection so the toolbar stops rendering the menu. */
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile presentation of the inline menu: a bottom drawer with submenu drill-in,
|
||||
* matching the other menus. The menu is held open while the selection matches;
|
||||
* closing animates the drawer out before collapsing the selection.
|
||||
*/
|
||||
function InlineMenuDrawer({
|
||||
items,
|
||||
ariaLabel,
|
||||
onDismiss,
|
||||
}: InlineMenuDrawerProps) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
setSubmenuName(undefined);
|
||||
onDismiss();
|
||||
}, DRAWER_CLOSE_MS);
|
||||
}, [onDismiss]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
close();
|
||||
}
|
||||
},
|
||||
[close]
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
const submenu = items.find(
|
||||
(item) => item.type === "submenu" && item.title === submenuName
|
||||
) as MenuItemWithChildren | undefined;
|
||||
return submenu?.items ?? items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, close, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent aria-label={ariaLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle hidden>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -47,7 +47,7 @@ type Props = Omit<
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -24,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
@@ -264,6 +265,16 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
// Inline menus render as a vertical menu anchored to the selection rather
|
||||
// than as a horizontal toolbar with trigger buttons.
|
||||
if (
|
||||
matched?.variant === MenuType.inline &&
|
||||
activeToolbar === Toolbar.Menu &&
|
||||
items.length
|
||||
) {
|
||||
return <InlineMenu items={items} rtl={rtl} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
search: string;
|
||||
search?: string;
|
||||
trigger: string | string[];
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MenuItem } from "@shared/editor/types";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
@@ -49,69 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
closeHistory(view);
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
const resolvedItemChildren =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, view, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { selectedRect } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
|
||||
import { RowSelection } from "@shared/editor/selection/RowSelection";
|
||||
import { isTableSelected } from "@shared/editor/queries/table";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
|
||||
const DEFAULT_SIDE_OFFSET = 4;
|
||||
|
||||
// Column and row menus open next to a grip handle. The grip is modelled as a
|
||||
// strip just outside the cell edge so the two distances are independent:
|
||||
// opening to the outside clears the grip (strip thickness + offset), while
|
||||
// flipping across sits only a small gap (offset) away.
|
||||
const OUTSIDE_CLEARANCE = 20;
|
||||
const FLIP_GAP = 0;
|
||||
const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP;
|
||||
const GRIP_SIDE_OFFSET = FLIP_GAP;
|
||||
|
||||
type Anchor = {
|
||||
/** Viewport rect to anchor the menu to. */
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Which side of the anchor the menu opens towards. */
|
||||
side: Side;
|
||||
/** How the menu aligns along the anchor edge. */
|
||||
align: Align;
|
||||
/** Distance in pixels between the anchor and the menu. */
|
||||
sideOffset: number;
|
||||
/** Stable identifier for the anchored target, changes when it moves. */
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the rect and placement to anchor an inline selection menu to, based
|
||||
* on the current table/column/row selection. The menu opens to the "outside"
|
||||
* of the table (above a column, beside a row) to cover the least content, and
|
||||
* is centered on the anchor for minimal pointer movement. Returns null when
|
||||
* there is no supported selection.
|
||||
*
|
||||
* @param view - the editor view.
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor, or null.
|
||||
*/
|
||||
function getAnchor(view: EditorView, rtl: boolean): Anchor | null {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
if (isTableSelected(state)) {
|
||||
const rect = selectedRect(state);
|
||||
const bounds = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
// A horizontal line at the table's top edge so it stays near the top
|
||||
// whether the menu opens above or flips below.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: 0,
|
||||
side: "top",
|
||||
align: "start",
|
||||
sideOffset: DEFAULT_SIDE_OFFSET,
|
||||
key: `table-${rect.tableStart}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selection instanceof ColumnSelection && selection.isColSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr > *:nth-child(${rect.left + 1})`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just above the column's top edge (the grip), spanning the
|
||||
// column width so the menu centers on the column.
|
||||
return {
|
||||
top: bounds.top - GRIP_INSET,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: GRIP_INSET,
|
||||
side: "top",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `col-${rect.tableStart}-${rect.left}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (selection instanceof RowSelection && selection.isRowSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr:nth-child(${rect.top + 1}) > *`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just outside the row's grip edge (left, or right in RTL),
|
||||
// spanning the row height so the menu centers on the row.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: rtl ? bounds.right : bounds.left - GRIP_INSET,
|
||||
width: GRIP_INSET,
|
||||
height: bounds.height,
|
||||
side: rtl ? "right" : "left",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `row-${rect.tableStart}-${rect.top}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an invisible virtual anchor element over the current table, column,
|
||||
* or row selection so a Radix dropdown can anchor an inline menu to it. The
|
||||
* returned `key` changes when the anchored target changes; spread it onto the
|
||||
* menu root so Radix repositions for a new target.
|
||||
*
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor ref to attach to the virtual trigger, the target key, and
|
||||
* the side/align the menu should open with.
|
||||
*/
|
||||
export function useInlineMenuAnchor(rtl: boolean) {
|
||||
const { view } = useEditor();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const anchor = getAnchor(view, rtl);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element && anchor) {
|
||||
element.style.top = `${anchor.top}px`;
|
||||
element.style.left = `${anchor.left}px`;
|
||||
element.style.width = `${anchor.width}px`;
|
||||
element.style.height = `${anchor.height}px`;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
key: anchor?.key,
|
||||
side: anchor?.side ?? "top",
|
||||
align: anchor?.align ?? "start",
|
||||
sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET,
|
||||
};
|
||||
}
|
||||
@@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
});
|
||||
|
||||
// Tracks already-seen match positions so duplicate matches (possible because
|
||||
// we search the deburred text concatenated with the original) can be skipped
|
||||
// in constant time rather than rescanning the entire results array.
|
||||
const seen = new Set<string>();
|
||||
|
||||
mergedTextNodes.forEach((node) => {
|
||||
const { text = "", pos, type } = node;
|
||||
try {
|
||||
@@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
// Check if already exists in results, possible because we search
|
||||
// over `deburr(text) + text`
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
@@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
}
|
||||
|
||||
this.highlightRanges = allRanges;
|
||||
CSS.highlights.set("search-results", new Highlight(...allRanges));
|
||||
if (currentRanges.length) {
|
||||
CSS.highlights.set(
|
||||
@@ -495,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
|
||||
private clearHighlights() {
|
||||
this.highlightRanges = [];
|
||||
if (!supportsHighlightAPI) {
|
||||
return;
|
||||
}
|
||||
@@ -503,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
this.currentHighlightRange = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the highlight ranges need to be rebuilt against the live
|
||||
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
|
||||
* editor re-renders its DOM without changing the doc, so highlights are stale
|
||||
* when a built range's nodes have disconnected, or when some matches have not
|
||||
* yet been resolved to ranges (e.g. inside a node view that mounts later).
|
||||
*
|
||||
* @returns whether the highlights should be rebuilt.
|
||||
*/
|
||||
private highlightsStale() {
|
||||
if (this.highlightRanges.length < this.results.length) {
|
||||
return true;
|
||||
}
|
||||
return this.highlightRanges.some(
|
||||
(range) =>
|
||||
!range.startContainer.isConnected || !range.endContainer.isConnected
|
||||
);
|
||||
}
|
||||
|
||||
private handleEscape = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("q")) {
|
||||
@@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
|
||||
private currentHighlightRange?: StaticRange;
|
||||
|
||||
private highlightRanges: StaticRange[] = [];
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
@@ -604,16 +634,17 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
return {
|
||||
update: (view) => {
|
||||
const generation = pluginKey.getState(view.state) as number;
|
||||
// Rebuild highlights when the results change (generation bump) or,
|
||||
// while a search is active, on any view update. The CSS Custom
|
||||
// Highlight API relies on static DOM ranges that become detached
|
||||
// when the editor re-renders its DOM — e.g. content settling after
|
||||
// sync when navigating from search results, collaboration cursors,
|
||||
// or node views mounting — none of which bump the generation. This
|
||||
// keeps the highlights tracking the live DOM, as decorations do.
|
||||
if (generation !== lastGeneration || this.searchTerm) {
|
||||
// The results changed (search ran, doc changed, fold toggled), so
|
||||
// always rebuild.
|
||||
if (generation !== lastGeneration) {
|
||||
lastGeneration = generation;
|
||||
this.updateHighlights();
|
||||
return;
|
||||
}
|
||||
// Results unchanged: only rebuild when the static highlight ranges
|
||||
// have detached from a DOM re-render that didn't bump the generation.
|
||||
if (this.searchTerm && this.highlightsStale()) {
|
||||
this.updateHighlights();
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
|
||||
@@ -55,15 +55,11 @@ export default class PasteHandler extends Extension {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = true;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
keyup: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = false;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,10 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type SelectionToolbarMenuDescriptor,
|
||||
} from "@shared/editor/types";
|
||||
import { SelectionToolbar } from "../components/SelectionToolbar";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
@@ -62,16 +65,19 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
},
|
||||
{
|
||||
priority: 90,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.isTableSelected,
|
||||
getItems: (ctx) => getTableMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 85,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.colIndex !== undefined,
|
||||
getItems: (ctx) => getTableColMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 80,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.rowIndex !== undefined,
|
||||
getItems: (ctx) => getTableRowMenuItems(ctx),
|
||||
},
|
||||
|
||||
@@ -124,8 +124,6 @@ export default function blockMenuItems(
|
||||
keywords: "pdf upload attach",
|
||||
attrs: {
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { closeHistory } from "@shared/editor/lib/closeHistory";
|
||||
import type { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
/**
|
||||
* Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by
|
||||
* `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu
|
||||
* presentation stays consistent. Resolves nested children into submenus and
|
||||
* binds each leaf to its editor command (or `onClick`).
|
||||
*
|
||||
* @param items - the editor menu items to map.
|
||||
* @param commands - the editor command registry.
|
||||
* @param view - the editor view, used to checkpoint history around commands.
|
||||
* @param state - the editor state, used to resolve dynamic attrs and active state.
|
||||
* @returns the mapped primitive menu items.
|
||||
*/
|
||||
export function mapMenuItems(
|
||||
items: MenuItem[],
|
||||
commands: Record<string, CommandFactory>,
|
||||
view: EditorView,
|
||||
state: EditorState
|
||||
): TMenuItem[] {
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
if (commands[item.name]) {
|
||||
closeHistory(view);
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
} else if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.name === "separator") {
|
||||
return { type: "separator", visible: item.visible };
|
||||
}
|
||||
|
||||
if ("content" in item) {
|
||||
return { type: "custom", visible: item.visible, content: item.content };
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(item.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(child) => "preventCloseCondition" in child
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
visible: item.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, view, state),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
dangerous: item.dangerous,
|
||||
visible: item.visible,
|
||||
selected: item.active !== undefined ? item.active(state) : undefined,
|
||||
onClick: handleClick(item),
|
||||
};
|
||||
});
|
||||
}
|
||||
+11
-14
@@ -15,9 +15,7 @@ import { TableLayout } from "@shared/editor/types";
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -30,33 +28,32 @@ export default function tableMenuItems(
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth ? t("Default width") : t("Full width"),
|
||||
label: isFullWidth ? t("Default width") : t("Full width"),
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: t("Distribute columns"),
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: t("Delete table"),
|
||||
icon: <TrashIcon />,
|
||||
name: "exportTable",
|
||||
label: t("Export as CSV"),
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: t("Export as CSV"),
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
name: "deleteTable",
|
||||
label: t("Delete table"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+122
-118
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
@@ -65,9 +68,7 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableColMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -94,60 +95,65 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align center"),
|
||||
label: t("Align"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
children: [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align center"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
label: t("Sort"),
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
label: t("Background"),
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -161,7 +167,7 @@ export default function tableColMenuItems(
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -205,71 +211,69 @@ export default function tableColMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -16,7 +15,11 @@ import {
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
@@ -56,9 +59,7 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableRowMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -83,7 +84,42 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: t("Background"),
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -97,7 +133,7 @@ export default function tableRowMenuItems(
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -141,65 +177,28 @@ export default function tableRowMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Route from "~/components/ProfiledRoute";
|
||||
import WebsocketProvider from "~/components/WebsocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
archivePath,
|
||||
@@ -53,6 +54,7 @@ const RedirectDocument = ({
|
||||
* the user to be logged in.
|
||||
*/
|
||||
function AuthenticatedRoutes() {
|
||||
useQueryNotices();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import env from "~/env";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as documentSlug } from "~/utils/routeHelpers";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
@@ -18,7 +17,6 @@ const Logout = lazy(() => import("~/scenes/Logout"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
useAutoRefresh();
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
|
||||
: window.location.origin;
|
||||
|
||||
type PathParams = {
|
||||
shareId: string;
|
||||
shareId?: string;
|
||||
collectionSlug?: string;
|
||||
documentSlug?: string;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,12 @@ export const isURLMentionable = ({
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
// Review (Diffs) urls mirror GitHub pull request urls and don't include
|
||||
// the workspace key, so any installed workspace can resolve them.
|
||||
if (hostname === "linear.review") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pathParts = pathname.split("/");
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
@@ -84,6 +90,12 @@ export const determineMentionType = ({
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
if (url.hostname === "linear.review") {
|
||||
return pathParts[3] === "pull" && pathParts[4]
|
||||
? MentionType.PullRequest
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const type = pathParts[2];
|
||||
return type === "issue"
|
||||
? MentionType.Issue
|
||||
|
||||
+7
-5
@@ -95,6 +95,7 @@
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toolbar": "^1.1.11",
|
||||
@@ -103,7 +104,7 @@
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/form-data": "^2.5.2",
|
||||
@@ -213,7 +214,7 @@
|
||||
"react": "^17.0.2",
|
||||
"react-avatar-editor": "^13.0.2",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-day-picker": "^8.10.2",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
@@ -223,6 +224,7 @@
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
@@ -349,14 +351,14 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@types/yazl": "^2.4.6",
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"@vitest/ui": "^4.1.8",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.4.0",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.38.46",
|
||||
"discord-api-types": "^0.38.48",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"ioredis-mock": "^8.13.1",
|
||||
@@ -366,7 +368,7 @@
|
||||
"oxlint": "1.66.0",
|
||||
"oxlint-tsgolint": "0.22.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.8.3",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"rollup-plugin-webpack-stats": "2.1.11",
|
||||
|
||||
@@ -102,17 +102,31 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// Microsoft's email claim is mutable, only trust it when a verification
|
||||
// claim confirms it — xms_edov for workforce tenants, or the standard
|
||||
// email_verified claim in External ID / OIDC scenarios.
|
||||
// The mail and userPrincipalName values come from the directory via the
|
||||
// Graph API and are owned by the organization, so an email sourced from
|
||||
// them is inherently trusted. Microsoft's mutable `email` token claim is
|
||||
// only trusted when a verification claim confirms it — xms_edov for
|
||||
// workforce tenants, or the standard email_verified claim in External ID
|
||||
// / OIDC scenarios.
|
||||
// https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization
|
||||
const verificationClaims = [profile.xms_edov, profile.email_verified];
|
||||
const presentClaims = verificationClaims.filter(
|
||||
(claim) => claim !== undefined
|
||||
);
|
||||
const emailVerified = presentClaims.length
|
||||
? presentClaims.some((claim) => claim === true || claim === "true")
|
||||
: undefined;
|
||||
const directoryEmails = [
|
||||
profileResponse.mail,
|
||||
profileResponse.userPrincipalName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((value) => value.toLowerCase());
|
||||
|
||||
const verificationClaims = [
|
||||
profile.xms_edov,
|
||||
profile.email_verified,
|
||||
].filter((claim) => claim !== undefined);
|
||||
const emailVerified =
|
||||
directoryEmails.includes(email.toLowerCase()) ||
|
||||
(verificationClaims.length
|
||||
? verificationClaims.some(
|
||||
(claim) => claim === true || claim === "true"
|
||||
)
|
||||
: undefined);
|
||||
|
||||
const domain = parseEmail(email).domain;
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
@@ -68,16 +68,18 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
const team = await getTeamFromContext(context);
|
||||
let team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// No profile domain means personal gmail account
|
||||
// No team implies the request came from the apex domain
|
||||
// This combination is always an error
|
||||
// No profile domain means a personal gmail account, and no team means
|
||||
// the request came from the apex domain rather than a workspace
|
||||
// subdomain. We can't infer the workspace from the domain, so resolve
|
||||
// it from the verified email's existing accounts instead.
|
||||
if (!domain && !team) {
|
||||
const userExists = await User.count({
|
||||
const existingAccounts = await User.findAll({
|
||||
attributes: ["id", "teamId"],
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
include: [
|
||||
{
|
||||
@@ -86,14 +88,23 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
},
|
||||
],
|
||||
});
|
||||
const teamIds = new Set(
|
||||
existingAccounts.map((account) => account.teamId)
|
||||
);
|
||||
|
||||
// Users cannot create a team with personal gmail accounts
|
||||
if (!userExists) {
|
||||
// A personal gmail account cannot be used to create a new workspace.
|
||||
if (teamIds.size === 0) {
|
||||
throw GmailAccountCreationError();
|
||||
}
|
||||
|
||||
// To log-in with a personal account, users must specify a team subdomain
|
||||
throw TeamDomainRequiredError();
|
||||
// When the email belongs to more than one workspace it is ambiguous
|
||||
// which to sign into, so the user must start from its subdomain.
|
||||
if (teamIds.size > 1) {
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
// Belongs to exactly one workspace — resolve it and sign in there.
|
||||
team = existingAccounts[0].team;
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
|
||||
@@ -11,7 +11,7 @@ PluginManager.add([
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
description:
|
||||
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
|
||||
"Connect your Linear account to Outline to enable rich, realtime, issue and pull request review previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { UnfurlResourceType } from "@shared/types";
|
||||
import { Linear } from "./linear";
|
||||
|
||||
describe("Linear.parseUrl", () => {
|
||||
it("should parse an issue url", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.app/acme/issue/ACM-123/fix-the-thing")
|
||||
).toEqual({
|
||||
workspaceKey: "acme",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: "ACM-123",
|
||||
name: "fix-the-thing",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a project url", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.app/acme/project/my-project-abc123")
|
||||
).toEqual({
|
||||
workspaceKey: "acme",
|
||||
type: UnfurlResourceType.Project,
|
||||
id: "my-project-abc123",
|
||||
name: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a review url", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.review/outline/outline/pull/1234")
|
||||
).toEqual({
|
||||
type: UnfurlResourceType.PR,
|
||||
owner: "outline",
|
||||
repo: "outline",
|
||||
number: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not parse a review url with an invalid pull request number", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.review/outline/outline/pull/abc")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not parse a review url missing path segments", () => {
|
||||
expect(Linear.parseUrl("https://linear.review/outline")).toBeUndefined();
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.review/outline/outline/issues/123")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not parse an in-app review url", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://linear.app/acme/review/fix-the-thing-abc123")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not parse urls from other hosts", () => {
|
||||
expect(
|
||||
Linear.parseUrl("https://github.com/outline/outline/pull/1234")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not parse invalid urls", () => {
|
||||
expect(Linear.parseUrl("not a url")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+122
-36
@@ -29,12 +29,31 @@ const AccessTokenResponseSchema = z.object({
|
||||
scope: z.string(),
|
||||
});
|
||||
|
||||
export class Linear {
|
||||
private static supportedUnfurls = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.Project,
|
||||
];
|
||||
// Pull request data Linear syncs onto issue attachments through its GitHub integration.
|
||||
const PullRequestAttachmentMetadataSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
userLogin: z.string().optional(),
|
||||
});
|
||||
|
||||
type ParsedIssueOrProject = {
|
||||
type: UnfurlResourceType.Issue | UnfurlResourceType.Project;
|
||||
workspaceKey: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ParsedPullRequest = {
|
||||
type: UnfurlResourceType.PR;
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
};
|
||||
|
||||
type LinearResource = ParsedIssueOrProject | ParsedPullRequest;
|
||||
|
||||
export class Linear {
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -106,6 +125,53 @@ export class Linear {
|
||||
return client.organization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for Linear specific URLs.
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns An object identifying a Linear issue, project or pull request review, or undefined.
|
||||
*/
|
||||
public static parseUrl(url: string): LinearResource | undefined {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const parts = pathname.split("/");
|
||||
|
||||
// Review (Diffs) urls mirror GitHub pull request urls, e.g.
|
||||
// https://linear.review/{owner}/{repo}/pull/{number}
|
||||
if (hostname === LinearUtils.reviewHost) {
|
||||
const [, owner, repo, type, id] = parts;
|
||||
const number = Number(id);
|
||||
|
||||
if (!owner || !repo || type !== "pull" || isNaN(number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { type: UnfurlResourceType.PR, owner, repo, number };
|
||||
}
|
||||
|
||||
if (hostname !== "linear.app") {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceKey = parts[1];
|
||||
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
|
||||
const id = parts[3];
|
||||
const name = parts[4];
|
||||
|
||||
if (
|
||||
type !== UnfurlResourceType.Issue &&
|
||||
type !== UnfurlResourceType.Project
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { workspaceKey, type, id, name };
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url Linear resource url
|
||||
@@ -135,7 +201,9 @@ export class Linear {
|
||||
// Prefer integration with matching workspaceKey, otherwise pick the first one
|
||||
const integration =
|
||||
integrations.find(
|
||||
(int) => int.settings.linear?.workspace.key === resource.workspaceKey
|
||||
(int) =>
|
||||
"workspaceKey" in resource &&
|
||||
int.settings.linear?.workspace.key === resource.workspaceKey
|
||||
) ?? integrations[0];
|
||||
|
||||
try {
|
||||
@@ -151,6 +219,8 @@ export class Linear {
|
||||
return await Linear.unfurlIssue(client, resource.id, actor);
|
||||
case UnfurlResourceType.Project:
|
||||
return await Linear.unfurlProject(client, resource.id, actor);
|
||||
case UnfurlResourceType.PR:
|
||||
return await Linear.unfurlPullRequest(client, resource, url);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -264,6 +334,52 @@ export class Linear {
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfurls a Linear review (Diffs) url. Linear's public API does not expose pull
|
||||
* requests directly, so this reads the pull request data Linear syncs onto the
|
||||
* attachments of linked issues, keyed by the GitHub url the review mirrors.
|
||||
*/
|
||||
private static async unfurlPullRequest(
|
||||
client: LinearClient,
|
||||
resource: ParsedPullRequest,
|
||||
url: string
|
||||
) {
|
||||
const githubUrl = `https://github.com/${resource.owner}/${resource.repo}/pull/${resource.number}`;
|
||||
const attachments = await client.attachmentsForURL(githubUrl);
|
||||
const attachment = attachments.nodes[0];
|
||||
|
||||
if (!attachment) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const parsed = PullRequestAttachmentMetadataSchema.safeParse(
|
||||
attachment.metadata
|
||||
);
|
||||
const metadata: z.infer<typeof PullRequestAttachmentMetadataSchema> =
|
||||
parsed.success ? parsed.data : {};
|
||||
const status = metadata.status ?? "open";
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.PR,
|
||||
url,
|
||||
id: `#${resource.number}`,
|
||||
title: metadata.title ?? attachment.title,
|
||||
description: null,
|
||||
author: {
|
||||
name: metadata.userLogin ?? "",
|
||||
avatarUrl: metadata.userLogin
|
||||
? `https://github.com/${metadata.userLogin}.png`
|
||||
: "",
|
||||
},
|
||||
state: {
|
||||
name: status === "merged" || status === "closed" ? status : "open",
|
||||
color: LinearUtils.getColorForPullRequestStatus(status),
|
||||
draft: metadata.draft ?? status === "draft",
|
||||
},
|
||||
createdAt: attachment.createdAt.toISOString(),
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
}
|
||||
|
||||
private static async completionPercentage(
|
||||
client: LinearClient,
|
||||
issue: Issue,
|
||||
@@ -309,34 +425,4 @@ export class Linear {
|
||||
return (idx + 1) / (states.length + 1); // add 1 to states for the "done" state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for Linear specific URLs
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "linear.app") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const workspaceKey = parts[1];
|
||||
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
|
||||
const id = parts[3];
|
||||
const name = parts[4];
|
||||
|
||||
if (!type || !Linear.supportedUnfurls.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { workspaceKey, type, id, name };
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export class LinearUtils {
|
||||
|
||||
public static tokenUrl = "https://api.linear.app/oauth/token";
|
||||
public static revokeUrl = "https://api.linear.app/oauth/revoke";
|
||||
|
||||
/** Hostname Linear uses for review (Diffs) urls, mirroring GitHub pull request urls. */
|
||||
public static reviewHost = "linear.review";
|
||||
|
||||
private static authBaseUrl = "https://linear.app/oauth/authorize";
|
||||
|
||||
private static settingsUrl = integrationSettingsPath("linear");
|
||||
@@ -45,6 +49,30 @@ export class LinearUtils {
|
||||
: `${baseUrl}/api/linear.callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a color representing the given pull request status.
|
||||
*
|
||||
* @param status Pull request status synced from Linear, e.g. "open" or "merged".
|
||||
* @returns a hex color string.
|
||||
*/
|
||||
public static getColorForPullRequestStatus(status: string) {
|
||||
switch (status) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
case "approved":
|
||||
return "#238636";
|
||||
case "inReview":
|
||||
return "#d29922";
|
||||
case "merged":
|
||||
return "#8250df";
|
||||
case "closed":
|
||||
return "#f85149";
|
||||
case "draft":
|
||||
default:
|
||||
return "#848d97";
|
||||
}
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.LINEAR_CLIENT_ID,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import { schema } from "@server/editor";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import documentCollaborativeUpdater from "./documentCollaborativeUpdater";
|
||||
|
||||
describe("documentCollaborativeUpdater", () => {
|
||||
const buildYDoc = (content: object[]) => {
|
||||
const doc = Node.fromJSON(schema, { type: "doc", content });
|
||||
return prosemirrorToYDoc(doc, "default");
|
||||
};
|
||||
|
||||
it("persists canonical JSON without empty attrs on marks", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const ydoc = buildYDoc([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Deciders:",
|
||||
marks: [{ type: "strong" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await documentCollaborativeUpdater({
|
||||
documentId: document.id,
|
||||
ydoc,
|
||||
sessionCollaboratorIds: [user.id],
|
||||
isLastConnection: true,
|
||||
clientVersion: null,
|
||||
});
|
||||
|
||||
await document.reload();
|
||||
|
||||
const marks = JSON.stringify(document.content).match(/"attrs":\{\}/g);
|
||||
expect(marks).toBeNull();
|
||||
|
||||
const text = document.content?.content?.[0]?.content?.[0];
|
||||
expect(text?.marks).toEqual([{ type: "strong" }]);
|
||||
});
|
||||
|
||||
it("does not persist when content is unchanged", async () => {
|
||||
const user = await buildUser();
|
||||
const content = [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
];
|
||||
const ydoc = buildYDoc(content);
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
content: Node.fromJSON(schema, { type: "doc", content }).toJSON(),
|
||||
});
|
||||
|
||||
const updatedAt = document.updatedAt;
|
||||
|
||||
await documentCollaborativeUpdater({
|
||||
documentId: document.id,
|
||||
ydoc,
|
||||
sessionCollaboratorIds: [user.id],
|
||||
isLastConnection: true,
|
||||
clientVersion: null,
|
||||
});
|
||||
|
||||
await document.reload();
|
||||
expect(document.updatedAt).toEqual(updatedAt);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import { uniq } from "es-toolkit/compat";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { schema } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -50,7 +52,14 @@ export default async function documentCollaborativeUpdater({
|
||||
});
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
|
||||
|
||||
// Round-trip through the schema so the stored JSON is canonical. The raw
|
||||
// y-prosemirror output includes empty `attrs: {}` on every mark, and outputs
|
||||
// properties in a different order - resulting in spurious "edits"
|
||||
const content = Node.fromJSON(
|
||||
schema,
|
||||
yDocToProsemirrorJSON(ydoc, "default")
|
||||
).toJSON() as ProsemirrorData;
|
||||
const isUnchanged = isEqual(document.content, content);
|
||||
const isDeleted = !!document.deletedAt;
|
||||
const lastModifiedById = isDeleted
|
||||
|
||||
@@ -24,7 +24,7 @@ async function documentMover(
|
||||
ctx: APIContext,
|
||||
{
|
||||
document,
|
||||
collectionId = null,
|
||||
collectionId,
|
||||
parentDocumentId = null,
|
||||
// convert undefined to null so parentId comparison treats them as equal
|
||||
index,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Collection, Revision } from "@server/models";
|
||||
import type { Document } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { assertPresent } from "@server/validation";
|
||||
|
||||
type Props = {
|
||||
/** The document to restore. Must be loaded with `paranoid: false`. */
|
||||
document: Document;
|
||||
/** Destination collection to restore into. Defaults to the original collection. */
|
||||
collectionId?: string | null;
|
||||
/** Revision to restore the document's content from, when not archived or deleted. */
|
||||
revisionId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores a previously archived or deleted document, or restores a document's
|
||||
* content to a specific revision. Re-attaches the document to the destination
|
||||
* collection's structure when applicable and authorizes the acting user.
|
||||
*
|
||||
* @param ctx - the API context, providing the acting user and transaction.
|
||||
* @param props - the document and restore options.
|
||||
* @returns the restored document.
|
||||
* @throws ValidationError if the destination collection is not active.
|
||||
*/
|
||||
async function documentRestorer(
|
||||
ctx: APIContext,
|
||||
{ document, collectionId, revisionId }: Props
|
||||
): Promise<Document> {
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const sourceCollectionId = document.collectionId;
|
||||
const destCollectionId = collectionId ?? sourceCollectionId;
|
||||
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.findByPk(sourceCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const destCollection = destCollectionId
|
||||
? await Collection.findByPk(destCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId && sourceCollectionId !== destCollection.id) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.restoreTo(ctx, { collectionId: destCollection.id });
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, "unarchive", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously archived document
|
||||
await document.restoreTo(ctx, { collectionId: destCollection.id });
|
||||
} else if (revisionId) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, "update", document);
|
||||
const revision = await Revision.findByPk(revisionId, { transaction });
|
||||
authorize(document, "restore", revision);
|
||||
|
||||
await document.restoreFromRevision(revision);
|
||||
await document.saveWithCtx(ctx, undefined, { name: "restore" });
|
||||
} else {
|
||||
assertPresent(revisionId, "revisionId is required");
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
export default traceFunction({
|
||||
spanName: "documentRestorer",
|
||||
})(documentRestorer);
|
||||
@@ -3,6 +3,7 @@ import * as Y from "yjs";
|
||||
import { TextEditMode } from "@shared/types";
|
||||
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
|
||||
import { Event } from "@server/models";
|
||||
import { parser } from "@server/editor";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
@@ -1481,5 +1482,82 @@ describe("documentUpdater", () => {
|
||||
"Second item"
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply a link when wrapping existing table cell text", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser
|
||||
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
|
||||
.toJSON();
|
||||
await document.save();
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"[see](https://example.com/docs)",
|
||||
TextEditMode.Patch,
|
||||
"see"
|
||||
);
|
||||
|
||||
// The cell text is unchanged but should now carry a link mark — it must
|
||||
// not be silently dropped during the merge.
|
||||
const cellText =
|
||||
result.content!.content![0].content![1].content![1].content![0]
|
||||
.content![0];
|
||||
expect(cellText.text).toEqual("see");
|
||||
expect(cellText.marks).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "link",
|
||||
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve other table cells when adding a link to one cell", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser
|
||||
.parse(
|
||||
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
|
||||
)
|
||||
.toJSON();
|
||||
await document.save();
|
||||
|
||||
// Capture the untouched (Beta) row BEFORE patching so we can assert its
|
||||
// structure and attrs are preserved exactly, not just its text.
|
||||
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
||||
const untouchedRow = beforeDoc.content[0].content[2];
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"see [docs](https://example.com/d)",
|
||||
TextEditMode.Patch,
|
||||
"see"
|
||||
);
|
||||
|
||||
// The patched cell gained the link
|
||||
expect(result.text).toContain("[docs](https://example.com/d)");
|
||||
// The untouched row node must remain identical
|
||||
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
|
||||
});
|
||||
|
||||
it("should apply a mark when wrapping existing list item text", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser.parse("- clickme\n- other\n").toJSON();
|
||||
await document.save();
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"[clickme](https://example.com)",
|
||||
TextEditMode.Patch,
|
||||
"clickme"
|
||||
);
|
||||
|
||||
expect(result.text).toContain("[clickme](https://example.com)");
|
||||
expect(result.text).toContain("other");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -817,8 +817,18 @@ export class DocumentHelper {
|
||||
const textSame = oldChild.textContent === newChild.textContent;
|
||||
|
||||
if (textSame && oldChild.sameMarkup(newChild)) {
|
||||
// Fully unchanged — keep original with its rich content
|
||||
merged.push(oldChild);
|
||||
// Compare against the round-tripped baseline: when the
|
||||
// updated child is identical to a plain round-trip of the original,
|
||||
// the patch did not touch it
|
||||
if (!rtChild || newChild.eq(rtChild)) {
|
||||
merged.push(oldChild);
|
||||
} else if (!oldChild.isTextblock && !oldChild.isLeaf) {
|
||||
// Container child changed deeper down — recurse to preserve rich
|
||||
// content in the parts that did not change.
|
||||
merged.push(DocumentHelper.mergeNodes(oldChild, newChild, rtChild));
|
||||
} else {
|
||||
merged.push(newChild);
|
||||
}
|
||||
} else if (textSame) {
|
||||
// Attrs changed (e.g. checked state) but content same — merge attrs
|
||||
// so that non-markdown-representable values (colwidth, highlight
|
||||
|
||||
@@ -20,6 +20,7 @@ import Diff from "@shared/editor/extensions/Diff";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { withTrailingNode } from "@shared/editor/lib/trailingNode";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
@@ -106,15 +107,16 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
|
||||
* @returns The content as a Y.Doc.
|
||||
*/
|
||||
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
|
||||
if (typeof input === "object") {
|
||||
return prosemirrorToYDoc(
|
||||
ProsemirrorHelper.toProsemirror(input),
|
||||
fieldName
|
||||
);
|
||||
const node =
|
||||
typeof input === "object"
|
||||
? ProsemirrorHelper.toProsemirror(input)
|
||||
: parser.parse(input);
|
||||
if (!node) {
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
const node = parser.parse(input);
|
||||
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
|
||||
// Normalize to the editor's trailing-node form so the document opens without
|
||||
// the editor inserting a trailing paragraph, which would be a spurious edit.
|
||||
return prosemirrorToYDoc(withTrailingNode(node), fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ export function presentDCRClient(
|
||||
baseUrl: string,
|
||||
oauthClient: OAuthClient,
|
||||
{
|
||||
includeRegistrationAccessToken = false,
|
||||
includeRegistrationAccessToken,
|
||||
includeCredentials = false,
|
||||
}: {
|
||||
includeRegistrationAccessToken: boolean;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
format,
|
||||
includeAttachments,
|
||||
pathMap,
|
||||
}: {
|
||||
|
||||
@@ -1241,6 +1241,7 @@ describe("#collections.create", () => {
|
||||
expect(body.data.name).toBe("Test");
|
||||
expect(body.data.sort.field).toBe("index");
|
||||
expect(body.data.sort.direction).toBe("asc");
|
||||
expect(body.data.permission).toBe(null);
|
||||
expect(body.policies.length).toBe(1);
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ router.post(
|
||||
auth(),
|
||||
validate(T.CreateTestUsersSchema),
|
||||
async (ctx: APIContext<T.CreateTestUsersReq>) => {
|
||||
const { count = 10 } = ctx.input.body;
|
||||
const { count } = ctx.input.body;
|
||||
const invites = Array(Math.min(count, 100))
|
||||
.fill(0)
|
||||
.map(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import documentDuplicator from "@server/commands/documentDuplicator";
|
||||
import documentLoader from "@server/commands/documentLoader";
|
||||
import documentMover from "@server/commands/documentMover";
|
||||
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
|
||||
import documentRestorer from "@server/commands/documentRestorer";
|
||||
import documentUpdater from "@server/commands/documentUpdater";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
Document,
|
||||
DocumentInsight,
|
||||
Event,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Template,
|
||||
User,
|
||||
@@ -89,7 +89,6 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
|
||||
import { streamZipResponse } from "@server/utils/koa";
|
||||
import { getTeamFromContext } from "@server/utils/passport";
|
||||
import { assertPresent } from "@server/validation";
|
||||
import pagination, { paginateQuery } from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
import {
|
||||
@@ -968,63 +967,7 @@ router.post(
|
||||
transaction,
|
||||
});
|
||||
|
||||
const sourceCollectionId = document.collectionId;
|
||||
const destCollectionId = collectionId ?? sourceCollectionId;
|
||||
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.findByPk(sourceCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const destCollection = destCollectionId
|
||||
? await Collection.findByPk(destCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, "unarchive", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously archived document
|
||||
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
|
||||
} else if (revisionId) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, "update", document);
|
||||
const revision = await Revision.findByPk(revisionId, { transaction });
|
||||
authorize(document, "restore", revision);
|
||||
|
||||
await document.restoreFromRevision(revision);
|
||||
await document.saveWithCtx(ctx, undefined, { name: "restore" });
|
||||
} else {
|
||||
assertPresent(revisionId, "revisionId is required");
|
||||
}
|
||||
await documentRestorer(ctx, { document, collectionId, revisionId });
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { collectionTools } from "@server/tools/collections";
|
||||
import { commentTools } from "@server/tools/comments";
|
||||
import { documentTools } from "@server/tools/documents";
|
||||
import { fetchTool } from "@server/tools/fetch";
|
||||
import { templateTools } from "@server/tools/templates";
|
||||
import { userTools } from "@server/tools/users";
|
||||
import { version } from "../../../package.json";
|
||||
|
||||
@@ -29,7 +30,9 @@ const defaultInstructions = `Document markdown content must not begin with a top
|
||||
|
||||
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs.
|
||||
|
||||
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.`;
|
||||
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.
|
||||
|
||||
When asked to create a document that follows a template, use the "list_templates" tool to find a matching template; each result already includes the template body as markdown. To use it unchanged, pass its ID as templateId to "create_document" and the new document is pre-filled from it. To adapt it first, modify the returned body and pass the result as the text parameter to "create_document". Either way no separate fetch is needed.`;
|
||||
|
||||
/**
|
||||
* Creates a fresh MCP server instance with tools filtered by the OAuth
|
||||
@@ -62,6 +65,7 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
|
||||
commentTools(server, scopes);
|
||||
documentTools(server, scopes);
|
||||
fetchTool(server, scopes);
|
||||
templateTools(server, scopes);
|
||||
userTools(server, scopes);
|
||||
|
||||
return server;
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("collection tools", () => {
|
||||
expect(data.color).toEqual("#FF0000");
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
expect(data.permission).toEqual(null);
|
||||
});
|
||||
|
||||
it("update_collection updates fields on existing collection", async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { Sequelize, Op, type WhereOptions } from "sequelize";
|
||||
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { Collection, Team } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { authorize } from "@server/policies";
|
||||
@@ -179,7 +178,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
color: input.color,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
permission: null,
|
||||
});
|
||||
|
||||
await collection.saveWithCtx(ctx);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildViewer,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildTemplate,
|
||||
buildOAuthAuthentication,
|
||||
} from "@server/test/factories";
|
||||
import { Document } from "@server/models";
|
||||
@@ -189,6 +190,82 @@ describe("create_document", () => {
|
||||
expect(data.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("creates from a template", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const template = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
text: "Content from the template",
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "From Template",
|
||||
collectionId: collection.id,
|
||||
templateId: template.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
const text = res?.result?.content?.[1]?.text ?? "";
|
||||
|
||||
expect(res?.result?.isError).not.toBe(true);
|
||||
expect(data.document.title).toEqual("From Template");
|
||||
expect(data.document.templateId).toEqual(template.id);
|
||||
expect(text).toContain("Content from the template");
|
||||
});
|
||||
|
||||
it("defaults the title to the template title", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const template = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Template Title",
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
collectionId: collection.id,
|
||||
templateId: template.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(res?.result?.isError).not.toBe(true);
|
||||
expect(data.document.title).toEqual("Template Title");
|
||||
});
|
||||
|
||||
it("does not allow creating from a template the user cannot access", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const otherUser = await buildUser();
|
||||
const otherCollection = await buildCollection({
|
||||
teamId: otherUser.teamId,
|
||||
userId: otherUser.id,
|
||||
});
|
||||
const template = await buildTemplate({
|
||||
teamId: otherUser.teamId,
|
||||
userId: otherUser.id,
|
||||
collectionId: otherCollection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "From Template",
|
||||
collectionId: collection.id,
|
||||
templateId: template.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow a viewer to create a draft", async () => {
|
||||
const user = await buildViewer();
|
||||
const auth = await buildOAuthAuthentication({
|
||||
@@ -458,3 +535,101 @@ describe("move_document", () => {
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore_document", () => {
|
||||
it("restores an archived document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "restore_document", {
|
||||
id: document.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
expect(data.document.id).toEqual(document.id);
|
||||
|
||||
const reloaded = await Document.unscoped().findByPk(document.id);
|
||||
expect(reloaded?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a trashed document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await document.destroy();
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "restore_document", {
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
|
||||
const reloaded = await Document.unscoped().findByPk(document.id, {
|
||||
paranoid: false,
|
||||
});
|
||||
expect(reloaded?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("restores into a different collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const source = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const destination = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: source.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "restore_document", {
|
||||
id: document.id,
|
||||
collectionId: destination.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
expect(data.document.collectionId).toEqual(destination.id);
|
||||
});
|
||||
|
||||
it("fails when the document is not archived or trashed", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "restore_document", {
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,10 @@ import documentCreator, {
|
||||
authorizeDocumentPublish,
|
||||
} from "@server/commands/documentCreator";
|
||||
import documentMover from "@server/commands/documentMover";
|
||||
import documentRestorer from "@server/commands/documentRestorer";
|
||||
import documentUpdater from "@server/commands/documentUpdater";
|
||||
import { Op } from "sequelize";
|
||||
import { Collection, Document } from "@server/models";
|
||||
import { Collection, Document, Template } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { authorize, can } from "@server/policies";
|
||||
import {
|
||||
@@ -300,13 +301,15 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
{
|
||||
title: "Create document",
|
||||
description:
|
||||
"Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document.",
|
||||
"Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document. Pass a templateId (from list_templates) to pre-fill the document from a template; the template's content is used unless text is also provided.",
|
||||
annotations: {
|
||||
idempotentHint: false,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
inputSchema: {
|
||||
title: z.string().describe("The title of the document."),
|
||||
title: optionalString().describe(
|
||||
"The title of the document. Defaults to the template's title when a templateId is provided."
|
||||
),
|
||||
text: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -317,6 +320,9 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
parentDocumentId: optionalString().describe(
|
||||
"The parent document ID to nest this document under."
|
||||
),
|
||||
templateId: optionalString().describe(
|
||||
"The ID of a template to pre-fill the new document from. The template's title, content, icon, and color are used unless overridden by the corresponding parameters."
|
||||
),
|
||||
icon: optionalString().describe(
|
||||
"An icon for the document, e.g. an emoji."
|
||||
),
|
||||
@@ -339,7 +345,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
},
|
||||
withTracing("create_document", async (input, context) => {
|
||||
try {
|
||||
const { collectionId, parentDocumentId } = input;
|
||||
const { collectionId, parentDocumentId, templateId } = input;
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
@@ -348,6 +354,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
parentDocumentId,
|
||||
});
|
||||
|
||||
let template: Template | null | undefined;
|
||||
if (templateId) {
|
||||
template = await Template.findByPk(templateId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", template);
|
||||
}
|
||||
|
||||
const document = await documentCreator(ctx, {
|
||||
title: input.title,
|
||||
text: input.text,
|
||||
@@ -356,6 +370,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
parentDocumentId: parentDocumentId,
|
||||
publish: input.publish !== false,
|
||||
collectionId: collection?.id,
|
||||
template,
|
||||
fullWidth: input.fullWidth,
|
||||
});
|
||||
|
||||
@@ -697,4 +712,77 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (AuthenticationHelper.canAccess("documents.restore", scopes)) {
|
||||
server.registerTool(
|
||||
"restore_document",
|
||||
{
|
||||
title: "Restore document",
|
||||
description:
|
||||
"Restores an archived or trashed document, making it active again. Optionally provide a collectionId to restore the document into a different collection; otherwise it returns to its original collection.",
|
||||
annotations: {
|
||||
idempotentHint: false,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe("The unique identifier of the document to restore."),
|
||||
collectionId: optionalString().describe(
|
||||
"The collection to restore the document into. Defaults to its original collection."
|
||||
),
|
||||
},
|
||||
},
|
||||
withTracing("restore_document", async ({ id, collectionId }, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
return await sequelize.transaction(async (transaction) => {
|
||||
ctx.state.transaction = transaction;
|
||||
ctx.context.transaction = transaction;
|
||||
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!document.deletedAt && !document.archivedAt) {
|
||||
return error("Document is not archived or trashed");
|
||||
}
|
||||
|
||||
await documentRestorer(ctx, { document, collectionId });
|
||||
|
||||
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
|
||||
presentDocument(document, {
|
||||
includeData: false,
|
||||
includeText: true,
|
||||
includeUpdatedAt: true,
|
||||
}),
|
||||
getDocumentBreadcrumb(document, user),
|
||||
]);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
document: pathToUrl(user.team, attributes),
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
text: typeof text === "string" ? text : "",
|
||||
},
|
||||
],
|
||||
} satisfies CallToolResult;
|
||||
});
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildComment,
|
||||
buildDocument,
|
||||
buildResolvedComment,
|
||||
buildTemplate,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
@@ -94,4 +95,32 @@ describe("fetch", () => {
|
||||
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(metadata.document.commentCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("returns template metadata and markdown", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const template = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
text: "Body of the template",
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "fetch", {
|
||||
resource: "template",
|
||||
id: template.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).not.toBe(true);
|
||||
expect(res!.result!.content!.length).toEqual(2);
|
||||
|
||||
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(metadata.id).toEqual(template.id);
|
||||
expect(metadata.url).toMatch(/^https?:\/\//);
|
||||
|
||||
expect(res!.result!.content![1].text).toContain("Body of the template");
|
||||
});
|
||||
});
|
||||
|
||||
+39
-3
@@ -1,7 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Attachment, Collection, Document, User } from "@server/models";
|
||||
import {
|
||||
Attachment,
|
||||
Collection,
|
||||
Document,
|
||||
Template,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { authorize, can } from "@server/policies";
|
||||
import { AuthorizationError } from "@server/errors";
|
||||
import {
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
} from "@server/presenters";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { presentDocument } from "./documents";
|
||||
import { presentTemplate } from "./templates";
|
||||
import {
|
||||
error,
|
||||
success,
|
||||
@@ -68,12 +75,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
|
||||
"attachments.info",
|
||||
scopes
|
||||
);
|
||||
const canReadTemplates = AuthenticationHelper.canAccess(
|
||||
"templates.info",
|
||||
scopes
|
||||
);
|
||||
|
||||
if (
|
||||
!canReadDocuments &&
|
||||
!canReadCollections &&
|
||||
!canReadUsers &&
|
||||
!canReadAttachments
|
||||
!canReadAttachments &&
|
||||
!canReadTemplates
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -83,6 +95,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
|
||||
...(canReadCollections ? ["collection"] : []),
|
||||
...(canReadUsers ? ["user"] : []),
|
||||
...(canReadAttachments ? ["attachment"] : []),
|
||||
...(canReadTemplates ? ["template"] : []),
|
||||
] as [string, ...string[]];
|
||||
|
||||
server.registerTool(
|
||||
@@ -90,7 +103,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
|
||||
{
|
||||
title: "Fetch",
|
||||
description:
|
||||
'Fetches a document, collection, user, or attachment by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly.',
|
||||
'Fetches a document, collection, user, attachment, or template by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly. For templates, the response includes the template body as markdown.',
|
||||
annotations: {
|
||||
idempotentHint: true,
|
||||
readOnlyHint: true,
|
||||
@@ -198,6 +211,29 @@ export function fetchTool(server: McpServer, scopes: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
case "template": {
|
||||
const template = await Template.findByPk(id, {
|
||||
userId: actor.id,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
authorize(actor, "read", template);
|
||||
|
||||
const { text, ...attributes } = await presentTemplate(template);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(pathToUrl(actor.team, attributes)),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
],
|
||||
} satisfies CallToolResult;
|
||||
}
|
||||
|
||||
default:
|
||||
return error(`Unknown resource: ${resource}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Scope } from "@shared/types";
|
||||
import {
|
||||
buildUser,
|
||||
buildCollection,
|
||||
buildTemplate,
|
||||
buildOAuthAuthentication,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("list_templates", () => {
|
||||
it("returns workspace and collection templates the user can access", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const workspaceTemplate = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: null,
|
||||
text: "Body of the workspace template",
|
||||
});
|
||||
const collectionTemplate = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_templates");
|
||||
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
|
||||
JSON.parse(c.text ?? "{}")
|
||||
);
|
||||
|
||||
const ids = data.map((t: { id: string }) => t.id);
|
||||
expect(ids).toContain(workspaceTemplate.id);
|
||||
expect(ids).toContain(collectionTemplate.id);
|
||||
|
||||
const match = data.find(
|
||||
(t: { id: string }) => t.id === workspaceTemplate.id
|
||||
) as { url: string; collectionId: string | null; text: string };
|
||||
expect(match.url).toMatch(/^https?:\/\//);
|
||||
expect(match.collectionId).toBeNull();
|
||||
expect(match.text).toContain("Body of the workspace template");
|
||||
});
|
||||
|
||||
it("filters by collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection1 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const template1 = await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
await buildTemplate({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection2.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_templates", {
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
|
||||
JSON.parse(c.text ?? "{}")
|
||||
);
|
||||
|
||||
const ids = data.map((t: { id: string }) => t.id);
|
||||
expect(ids).toContain(template1.id);
|
||||
expect(
|
||||
data.every(
|
||||
(t: { collectionId: string }) => t.collectionId === collection1.id
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not return templates from collections the user cannot access", async () => {
|
||||
const owner = await buildUser();
|
||||
const otherUser = await buildUser({ teamId: owner.teamId });
|
||||
|
||||
const privateCollection = await buildCollection({
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
permission: null,
|
||||
});
|
||||
const privateTemplate = await buildTemplate({
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
collectionId: privateCollection.id,
|
||||
});
|
||||
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user: otherUser,
|
||||
scope: [Scope.Read],
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, auth.accessToken!, "list_templates");
|
||||
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
|
||||
JSON.parse(c.text ?? "{}")
|
||||
);
|
||||
const ids = data.map((t: { id: string }) => t.id);
|
||||
|
||||
expect(res?.result?.isError).not.toBe(true);
|
||||
expect(ids).not.toContain(privateTemplate.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod";
|
||||
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Op } from "sequelize";
|
||||
import type { WhereOptions } from "sequelize";
|
||||
import { Collection, Template } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import {
|
||||
error,
|
||||
success,
|
||||
getActorFromContext,
|
||||
optionalString,
|
||||
pathToUrl,
|
||||
withTracing,
|
||||
} from "./util";
|
||||
|
||||
/**
|
||||
* Presents a template's metadata and rendered markdown body for a tool
|
||||
* response. Including the body lets a caller list templates and create a
|
||||
* document from one — verbatim or adapted — without a separate fetch call.
|
||||
*
|
||||
* @param template - the template to present.
|
||||
* @returns the presented template with its body as markdown.
|
||||
*/
|
||||
export async function presentTemplate(template: Template) {
|
||||
return {
|
||||
id: template.id,
|
||||
url: template.path,
|
||||
title: template.title,
|
||||
collectionId: template.collectionId ?? null,
|
||||
updatedAt: template.updatedAt,
|
||||
text: template.content
|
||||
? await DocumentHelper.toMarkdown(template.content, {
|
||||
includeTitle: false,
|
||||
})
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers template-related MCP tools on the given server, filtered by the
|
||||
* OAuth scopes granted to the current token.
|
||||
*
|
||||
* @param server - the MCP server instance to register on.
|
||||
* @param scopes - the OAuth scopes granted to the access token.
|
||||
*/
|
||||
export function templateTools(server: McpServer, scopes: string[]) {
|
||||
if (AuthenticationHelper.canAccess("templates.list", scopes)) {
|
||||
server.registerTool(
|
||||
"list_templates",
|
||||
{
|
||||
title: "List templates",
|
||||
description:
|
||||
"Lists document templates the user has access to, including workspace-wide templates and templates within accessible collections. Each result includes the template body as markdown. To create a document from a template unchanged, pass its ID as templateId to create_document. To adapt it first, modify the returned text and pass it as the text parameter to create_document — no separate fetch is needed.",
|
||||
annotations: {
|
||||
idempotentHint: true,
|
||||
readOnlyHint: true,
|
||||
},
|
||||
inputSchema: {
|
||||
collectionId: optionalString().describe(
|
||||
"A collection ID to filter templates by. Omit to include workspace-wide templates and templates from all accessible collections."
|
||||
),
|
||||
offset: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe("The pagination offset. Defaults to 0."),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
"The maximum number of results to return. Defaults to 25, max 100."
|
||||
),
|
||||
},
|
||||
},
|
||||
withTracing(
|
||||
"list_templates",
|
||||
async ({ collectionId, offset, limit }, extra) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
|
||||
const where: WhereOptions<Template> & {
|
||||
[Op.and]: WhereOptions<Template>[];
|
||||
} = {
|
||||
teamId: user.teamId,
|
||||
[Op.and]: [{ deletedAt: { [Op.eq]: null } }],
|
||||
};
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
where[Op.and].push({ collectionId });
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{ collectionId: { [Op.eq]: null } },
|
||||
{ collectionId: await user.collectionIds() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const templates = await Template.scope([
|
||||
"defaultScope",
|
||||
{ method: ["withMembership", user.id] },
|
||||
]).findAll({
|
||||
where,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = await Promise.all(
|
||||
templates.map(async (template) =>
|
||||
pathToUrl(user.team, await presentTemplate(template))
|
||||
)
|
||||
);
|
||||
return success(presented);
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,41 @@ describe("validateUrlNotPrivate", () => {
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["::ffff:169.254.169.254", "metadata via IPv4-mapped IPv6"],
|
||||
["::ffff:127.0.0.1", "loopback via IPv4-mapped IPv6"],
|
||||
["::ffff:10.0.0.1", "RFC1918 via IPv4-mapped IPv6"],
|
||||
["::ffff:192.168.1.1", "RFC1918 via IPv4-mapped IPv6"],
|
||||
["64:ff9b::a9fe:a9fe", "metadata via NAT64"],
|
||||
["2002:a9fe:a9fe::", "metadata via 6to4"],
|
||||
])("should reject %s in URL (%s)", async (address) => {
|
||||
await expect(
|
||||
validateUrlNotPrivate(`https://[${address}]/api`)
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject IPv4-mapped IPv6 address resolved via DNS", async () => {
|
||||
lookupSpy.mockResolvedValue({
|
||||
address: "::ffff:169.254.169.254",
|
||||
family: 6,
|
||||
});
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://metadata.example.com")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject carrier-grade NAT address", async () => {
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://100.64.0.1/api")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject IPv4-mapped IPv6 addresses outright", async () => {
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://[::ffff:8.8.8.8]/")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => {
|
||||
it("should allow exact IP match", async () => {
|
||||
env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"];
|
||||
|
||||
+6
-11
@@ -7,15 +7,6 @@ import { InvalidRequestError } from "@server/errors";
|
||||
|
||||
const UrlIdLength = 10;
|
||||
|
||||
/** IP ranges that are not allowed for outbound requests. */
|
||||
const privateRanges = new Set([
|
||||
"private",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
"unspecified",
|
||||
]);
|
||||
|
||||
export const generateUrlId = () => randomString(UrlIdLength);
|
||||
|
||||
// Paths probed by vulnerability scanners.
|
||||
@@ -53,7 +44,9 @@ export function isPrivateIP(ip: string): boolean {
|
||||
if (!ipaddr.isValid(ip)) {
|
||||
return false;
|
||||
}
|
||||
return privateRanges.has(ipaddr.parse(ip).range());
|
||||
|
||||
// Only globally-routable unicast addresses are permitted
|
||||
return ipaddr.parse(ip).range() !== "unicast";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +95,9 @@ function isAllowedPrivateIP(ip: string): boolean {
|
||||
* @throws InternalError if the URL resolves to a private IP that is not allowed.
|
||||
*/
|
||||
export async function validateUrlNotPrivate(url: string) {
|
||||
const { hostname } = new URL(url);
|
||||
// URL.hostname keeps the square brackets around IPv6 literals (e.g.
|
||||
// "[::1]"), which net.isIP does not accept, so strip them before checking.
|
||||
const hostname = new URL(url).hostname.replace(/^\[|\]$/g, "");
|
||||
|
||||
if (net.isIP(hostname)) {
|
||||
if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) {
|
||||
|
||||
@@ -65,6 +65,38 @@ function restoreColumnSelection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that places a text cursor at the start of the cell at the given row
|
||||
* and column index within the table that begins at the given position. Used
|
||||
* after inserting a row or column so that the selection lands inside the newly
|
||||
* inserted cell rather than the shifted neighbouring one.
|
||||
*
|
||||
* @param tableStart The position inside the table (after the table node).
|
||||
* @param rowIndex The row index of the target cell.
|
||||
* @param columnIndex The column index of the target cell.
|
||||
* @returns The command.
|
||||
*/
|
||||
function setCursorInCell(
|
||||
tableStart: number,
|
||||
rowIndex: number,
|
||||
columnIndex: number
|
||||
): Command {
|
||||
return (state, dispatch) => {
|
||||
const table = state.doc.nodeAt(tableStart - 1);
|
||||
if (!table) {
|
||||
return false;
|
||||
}
|
||||
const map = TableMap.get(table);
|
||||
if (rowIndex >= map.height || columnIndex >= map.width) {
|
||||
return false;
|
||||
}
|
||||
const pos = map.positionAt(rowIndex, columnIndex, table);
|
||||
const $pos = state.doc.resolve(tableStart + pos + 1);
|
||||
dispatch?.(state.tr.setSelection(TextSelection.near($pos)));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTable({
|
||||
rowsCount,
|
||||
colsCount,
|
||||
@@ -522,7 +554,7 @@ export function addRowBefore({ index }: { index?: number }): Command {
|
||||
(s, d) =>
|
||||
!!d?.(addRowWithAlignment(s.tr, rect, position, copyFromRow, s)),
|
||||
headerSpecialCase ? toggleHeader("row") : undefined,
|
||||
collapseSelection()
|
||||
setCursorInCell(rect.tableStart, position, 0)
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
@@ -588,7 +620,61 @@ export function addColumnBefore({ index }: { index?: number }): Command {
|
||||
headerSpecialCase ? toggleHeader("column") : undefined,
|
||||
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
|
||||
headerSpecialCase ? toggleHeader("column") : undefined,
|
||||
collapseSelection()
|
||||
setCursorInCell(rect.tableStart, 0, position)
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that adds a row after the given index (or the current selection),
|
||||
* copying alignment from the row above and placing the cursor in the new row.
|
||||
*
|
||||
* @param index The index of the row to add after, if undefined the current selection is used
|
||||
* @returns The command
|
||||
*/
|
||||
export function addRowAfter({ index }: { index?: number }): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!isInTable(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = selectedRect(state);
|
||||
const position = index !== undefined ? index + 1 : rect.bottom;
|
||||
|
||||
// Copy alignment from the row above the insertion point.
|
||||
const copyFromRow = position - 1;
|
||||
|
||||
chainTransactions(
|
||||
(s, d) =>
|
||||
!!d?.(addRowWithAlignment(s.tr, rect, position, copyFromRow, s)),
|
||||
setCursorInCell(rect.tableStart, position, 0)
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that adds a column after the given index (or the current selection),
|
||||
* placing the cursor in the new column.
|
||||
*
|
||||
* @param index The index of the column to add after, if undefined the current selection is used
|
||||
* @returns The command
|
||||
*/
|
||||
export function addColumnAfter({ index }: { index?: number }): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!isInTable(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = selectedRect(state);
|
||||
const position = index !== undefined ? index + 1 : rect.right;
|
||||
|
||||
chainTransactions(
|
||||
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
|
||||
setCursorInCell(rect.tableStart, 0, position)
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -46,6 +46,7 @@ function Caption({ placeholder, children, isSelected, width, ...rest }: Props) {
|
||||
tabIndex={-1}
|
||||
aria-label={t("Caption")}
|
||||
role="textbox"
|
||||
draggable={false}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-caption={placeholder}
|
||||
|
||||
@@ -9,6 +9,13 @@ import { s } from "../../styles";
|
||||
import { Preview, Subtitle, Title } from "./Widget";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
/**
|
||||
* Default dimensions for the PDF preview – approximately the width of a standard
|
||||
* document with an A4 portrait aspect ratio.
|
||||
*/
|
||||
const naturalWidth = 768;
|
||||
const naturalHeight = 1086;
|
||||
|
||||
type Props = ComponentProps & {
|
||||
/** Icon to display on the left side of the widget */
|
||||
icon: React.ReactNode;
|
||||
@@ -27,16 +34,14 @@ export default function PdfViewer(props: Props) {
|
||||
const embedRef = useRef<HTMLEmbedElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||
{
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
naturalWidth: 300,
|
||||
naturalHeight: 424,
|
||||
onChangeSize,
|
||||
ref,
|
||||
}
|
||||
);
|
||||
const { width, setSize, handlePointerDown, dragging } = useDragResize({
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
onChangeSize,
|
||||
ref,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (node.attrs.width && node.attrs.width !== width) {
|
||||
@@ -96,7 +101,7 @@ export default function PdfViewer(props: Props) {
|
||||
? "pdf-wrapper ProseMirror-selectednode"
|
||||
: "pdf-wrapper"
|
||||
}
|
||||
style={{ width: width ?? "auto" }}
|
||||
style={{ width: width ?? "100%" }}
|
||||
$dragging={dragging}
|
||||
>
|
||||
<Flex gap={6} align="center">
|
||||
@@ -110,12 +115,10 @@ export default function PdfViewer(props: Props) {
|
||||
title={name}
|
||||
src={href}
|
||||
ref={embedRef}
|
||||
width={
|
||||
// subtract padding and borders from width
|
||||
width - 24
|
||||
}
|
||||
height={height}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: `${naturalWidth} / ${naturalHeight}`,
|
||||
pointerEvents:
|
||||
!isEditable || (isSelected && !dragging) ? "initial" : "none",
|
||||
marginTop: 6,
|
||||
@@ -153,6 +156,8 @@ const PDFWrapper = styled.div<{ $dragging: boolean }>`
|
||||
padding: ${EditorStyleHelper.blockRadius};
|
||||
|
||||
embed {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
transition-property: width, height;
|
||||
transition-duration: ${(props) => (props.$dragging ? "0ms" : "120ms")};
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
@@ -822,31 +822,6 @@ iframe.embed {
|
||||
}
|
||||
}
|
||||
|
||||
.pdf {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
max-width: 100%;
|
||||
clear: both;
|
||||
z-index: 1;
|
||||
transition-property: width, height;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
embed {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
contain: strict,
|
||||
content-visibility: auto,
|
||||
backface-visibility: hidden,
|
||||
transition-property: width, height;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.image-replacement-uploading {
|
||||
img {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -192,7 +192,9 @@ export default function useDragResize(props: Params): ReturnValue {
|
||||
: Infinity;
|
||||
setMaxWidth(max);
|
||||
setSizeAtDragStart({
|
||||
width: constrainWidth(size.width, max),
|
||||
// When no width has been set yet the element is displayed at full width,
|
||||
// so begin resizing from the maximum width rather than the minimum.
|
||||
width: constrainWidth(size.width || max, max),
|
||||
height: size.height,
|
||||
});
|
||||
setOffset(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NodeType } from "prosemirror-model";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
import {
|
||||
requiresTrailingNode,
|
||||
trailingNodeNotAfter,
|
||||
} from "../lib/trailingNode";
|
||||
|
||||
/**
|
||||
* Options for the TrailingNode extension.
|
||||
@@ -20,15 +23,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
|
||||
get defaultOptions(): TrailingNodeOptions {
|
||||
return {
|
||||
node: "paragraph",
|
||||
notAfter: ["paragraph", "heading"],
|
||||
notAfter: trailingNodeNotAfter,
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const plugin = new PluginKey(this.name);
|
||||
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||
.map(([, value]) => value)
|
||||
.filter((node: NodeType) => this.options.notAfter.includes(node.name));
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
@@ -49,38 +49,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
|
||||
},
|
||||
}),
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
const lastNode = state.tr.doc.lastChild;
|
||||
|
||||
// If paragraph has no text (only images/media), add trailing node
|
||||
if (
|
||||
lastNode?.type.name === "paragraph" &&
|
||||
lastNode.content.size > 0 &&
|
||||
lastNode.textContent.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
|
||||
},
|
||||
apply: (tr, value) => {
|
||||
if (!tr.docChanged) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const lastNode = tr.doc.lastChild;
|
||||
|
||||
// If paragraph has no text (only images/media), add trailing node
|
||||
if (
|
||||
lastNode?.type.name === "paragraph" &&
|
||||
lastNode.content.size > 0 &&
|
||||
lastNode.textContent.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
|
||||
},
|
||||
init: (_, state) =>
|
||||
requiresTrailingNode(state.doc, this.options.notAfter),
|
||||
apply: (tr, value) =>
|
||||
tr.docChanged
|
||||
? requiresTrailingNode(tr.doc, this.options.notAfter)
|
||||
: value,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -273,7 +273,19 @@ export default class ExtensionManager {
|
||||
return;
|
||||
}
|
||||
if (extension.focusAfterExecution) {
|
||||
// Focusing a blurred editor (e.g. when the command is run from a
|
||||
// menu that holds focus) can collapse a non-text selection such as
|
||||
// a table cell selection. Restore it so selection-based commands
|
||||
// operate on the intended selection.
|
||||
const { selection } = view.state;
|
||||
view.focus();
|
||||
if (!view.state.selection.eq(selection)) {
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(selection)
|
||||
.setMeta("addToHistory", false)
|
||||
);
|
||||
}
|
||||
}
|
||||
return callback(attrs)?.(view.state, view.dispatch, view);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { requiresTrailingNode, withTrailingNode } from "./trailingNode";
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*" },
|
||||
heading: { group: "block", content: "inline*" },
|
||||
code_block: { group: "block", content: "inline*" },
|
||||
image: { group: "inline", inline: true },
|
||||
text: { group: "inline" },
|
||||
},
|
||||
});
|
||||
|
||||
const doc = (...children: object[]) =>
|
||||
schema.nodeFromJSON({ type: "doc", content: children });
|
||||
|
||||
const paragraph = (text?: string) => ({
|
||||
type: "paragraph",
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
});
|
||||
|
||||
describe("requiresTrailingNode", () => {
|
||||
it("is false when the document ends in a paragraph", () => {
|
||||
expect(requiresTrailingNode(doc(paragraph("hello")))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the document ends in a heading", () => {
|
||||
expect(
|
||||
requiresTrailingNode(
|
||||
doc({ type: "heading", content: [{ type: "text", text: "title" }] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the document ends in another block type", () => {
|
||||
expect(
|
||||
requiresTrailingNode(
|
||||
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when the last paragraph contains only non-text content", () => {
|
||||
expect(
|
||||
requiresTrailingNode(
|
||||
doc({ type: "paragraph", content: [{ type: "image" }] })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withTrailingNode", () => {
|
||||
it("appends a trailing paragraph when required", () => {
|
||||
const result = withTrailingNode(
|
||||
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
|
||||
);
|
||||
expect(result.childCount).toBe(2);
|
||||
expect(result.lastChild?.type.name).toBe("paragraph");
|
||||
expect(result.lastChild?.content.size).toBe(0);
|
||||
});
|
||||
|
||||
it("is a no-op when a trailing paragraph already exists", () => {
|
||||
const input = doc(
|
||||
{ type: "code_block", content: [{ type: "text", text: "x" }] },
|
||||
paragraph()
|
||||
);
|
||||
expect(withTrailingNode(input).eq(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = withTrailingNode(
|
||||
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
|
||||
);
|
||||
expect(withTrailingNode(once).eq(once)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Fragment, type Node } from "prosemirror-model";
|
||||
|
||||
/** Node names after which a trailing paragraph is not required. */
|
||||
export const trailingNodeNotAfter = ["paragraph", "heading"];
|
||||
|
||||
/**
|
||||
* Determines whether the editor would insert a trailing paragraph after the
|
||||
* document's last node. Mirrors the behavior of the TrailingNode extension so
|
||||
* that stored content can be normalized to match the editor, avoiding a
|
||||
* spurious edit the first time a document is opened.
|
||||
*
|
||||
* @param doc The document node to inspect.
|
||||
* @param notAfter Node names after which a trailing node is not required.
|
||||
* @returns whether a trailing paragraph is required.
|
||||
*/
|
||||
export function requiresTrailingNode(
|
||||
doc: Node,
|
||||
notAfter: string[] = trailingNodeNotAfter
|
||||
): boolean {
|
||||
const lastNode = doc.lastChild;
|
||||
if (!lastNode) {
|
||||
return false;
|
||||
}
|
||||
// A paragraph holding only non-text content (eg. images) still needs a
|
||||
// trailing node so the cursor can be placed after it.
|
||||
if (
|
||||
lastNode.type.name === "paragraph" &&
|
||||
lastNode.content.size > 0 &&
|
||||
lastNode.textContent.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !notAfter.includes(lastNode.type.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a trailing paragraph to the document if the editor would add one on
|
||||
* load, returning the normalized document unchanged otherwise.
|
||||
*
|
||||
* @param doc The document node to normalize.
|
||||
* @param notAfter Node names after which a trailing node is not required.
|
||||
* @returns the document, with a trailing paragraph appended when required.
|
||||
*/
|
||||
export function withTrailingNode(
|
||||
doc: Node,
|
||||
notAfter: string[] = trailingNodeNotAfter
|
||||
): Node {
|
||||
const paragraph = doc.type.schema.nodes.paragraph;
|
||||
if (!paragraph || !requiresTrailingNode(doc, notAfter)) {
|
||||
return doc;
|
||||
}
|
||||
return doc.copy(doc.content.append(Fragment.from(paragraph.create())));
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
setRecentlyUsedCodeLanguage,
|
||||
} from "../lib/code";
|
||||
import { isCode, isMermaid } from "../lib/isCode";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
|
||||
@@ -447,17 +448,20 @@ export default class CodeFence extends Node<CodeFenceOptions> {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Recompute tall blocks on doc changes, preserving
|
||||
// user collapse/expand choices where possible.
|
||||
// Recompute tall blocks on doc changes. Newly tall blocks are only
|
||||
// auto-collapsed when content arrives via load/remote sync — never
|
||||
// while the user is typing, which would collapse the block under
|
||||
// the cursor.
|
||||
if (tr.docChanged) {
|
||||
const tallBlocks = findTallBlocks(newState.doc);
|
||||
const collapsedBlocks = new Set<number>();
|
||||
const isRemote = isRemoteTransaction(tr);
|
||||
|
||||
const inverse = tr.mapping.invert();
|
||||
for (const pos of tallBlocks) {
|
||||
const oldPos = inverse.map(pos);
|
||||
if (!prev.tallBlocks.has(oldPos)) {
|
||||
// Newly tall blocks start collapsed
|
||||
if (isRemote && !prev.tallBlocks.has(oldPos)) {
|
||||
// Newly tall blocks start collapsed on load
|
||||
collapsedBlocks.add(pos);
|
||||
} else if (prev.collapsedBlocks.has(oldPos)) {
|
||||
// Preserve previous collapsed state
|
||||
|
||||
@@ -9,12 +9,12 @@ import type {
|
||||
import type { Command } from "prosemirror-state";
|
||||
import { NodeSelection, Plugin, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { sanitizeImageSrc } from "../../utils/urls";
|
||||
import { sanitizeImageSrc, sanitizeUrl } from "../../utils/urls";
|
||||
import Caption from "../components/Caption";
|
||||
import ImageComponent from "../components/Image";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import type { ComponentProps } from "../types";
|
||||
import type { ComponentProps, NodeAttrMark } from "../types";
|
||||
import SimpleImage from "./SimpleImage";
|
||||
import { LightboxImageFactory } from "../lib/Lightbox";
|
||||
import { ImageSource } from "../lib/FileHelper";
|
||||
@@ -132,8 +132,7 @@ export default class Image extends SimpleImage {
|
||||
marks: "",
|
||||
group: "inline",
|
||||
selectable: true,
|
||||
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1289000
|
||||
draggable: false,
|
||||
draggable: true,
|
||||
atom: true,
|
||||
parseDOM: [
|
||||
{
|
||||
@@ -151,6 +150,12 @@ export default class Image extends SimpleImage {
|
||||
|
||||
const width = img?.getAttribute("width");
|
||||
const height = img?.getAttribute("height");
|
||||
|
||||
// A link wrapping the image is stored as a node attribute rather
|
||||
// than a mark, parse it back so it survives copy/paste. Sanitize
|
||||
// the href as it is rendered directly into the DOM by the view.
|
||||
const href = sanitizeUrl(img?.closest("a")?.getAttribute("href"));
|
||||
|
||||
return {
|
||||
src: img?.getAttribute("src"),
|
||||
alt: img?.getAttribute("alt"),
|
||||
@@ -159,17 +164,16 @@ export default class Image extends SimpleImage {
|
||||
width: width ? parseInt(width, 10) : undefined,
|
||||
height: height ? parseInt(height, 10) : undefined,
|
||||
layoutClass,
|
||||
marks: href ? [{ type: "link", attrs: { href } }] : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "img",
|
||||
getAttrs: (dom: HTMLImageElement) => {
|
||||
// Don't parse images from our own editor with this rule.
|
||||
if (
|
||||
dom.parentElement?.classList.contains("image") ||
|
||||
dom.parentElement?.classList.contains("emoji")
|
||||
) {
|
||||
// Don't parse images from our own editor with this rule. A linked
|
||||
// image nests the <img> inside an <a>, so check ancestors too.
|
||||
if (dom.closest(".image") || dom.closest(".emoji")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -206,19 +210,28 @@ export default class Image extends SimpleImage {
|
||||
? `image image-${node.attrs.layoutClass}`
|
||||
: "image";
|
||||
|
||||
const children = [
|
||||
[
|
||||
"img",
|
||||
{
|
||||
...node.attrs,
|
||||
src: sanitizeImageSrc(node.attrs.src),
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
contentEditable: "false",
|
||||
},
|
||||
],
|
||||
// `marks` is held separately below and is not a valid DOM attribute.
|
||||
const { marks, ...attrs } = node.attrs;
|
||||
const img = [
|
||||
"img",
|
||||
{
|
||||
...attrs,
|
||||
src: sanitizeImageSrc(node.attrs.src),
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
contentEditable: "false",
|
||||
},
|
||||
];
|
||||
|
||||
// A link applied to an image is held as a node attribute rather than a
|
||||
// mark, so it must be written into the DOM explicitly here.
|
||||
const linkHref = (marks as NodeAttrMark[] | undefined)?.find(
|
||||
(mark) => mark.type === "link"
|
||||
)?.attrs?.href;
|
||||
const href = typeof linkHref === "string" ? linkHref : undefined;
|
||||
|
||||
const children = [href ? ["a", { href: sanitizeUrl(href) }, img] : img];
|
||||
|
||||
if (node.attrs.alt) {
|
||||
children.push([
|
||||
"p",
|
||||
@@ -246,6 +259,32 @@ export default class Image extends SimpleImage {
|
||||
commentedImagePlugin(),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragstart: (_view, event) => {
|
||||
// ProseMirror lets the browser snapshot the dragged node's DOM as
|
||||
// the drag image. For images that DOM includes the caption area and
|
||||
// padding, which renders as a large white box around the image.
|
||||
// Substitute the image element so the drag ghost is tight to it.
|
||||
if (
|
||||
!(event.target instanceof HTMLElement) ||
|
||||
!event.dataTransfer
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const image = event.target
|
||||
.closest(`.component-${this.name}`)
|
||||
?.querySelector("img");
|
||||
if (image) {
|
||||
const rect = image.getBoundingClientRect();
|
||||
event.dataTransfer.setDragImage(
|
||||
image,
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// prevent prosemirror's default spacebar behavior
|
||||
// & zoom in if the selected node is image
|
||||
|
||||
@@ -3,8 +3,6 @@ import { InputRule } from "prosemirror-inputrules";
|
||||
import type { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import {
|
||||
addColumnAfter,
|
||||
addRowAfter,
|
||||
columnResizing,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
@@ -17,7 +15,9 @@ import {
|
||||
} from "prosemirror-tables";
|
||||
import {
|
||||
addRowBefore,
|
||||
addRowAfter,
|
||||
addColumnBefore,
|
||||
addColumnAfter,
|
||||
addRowAndMoveSelection,
|
||||
setColumnAttr,
|
||||
createTable,
|
||||
@@ -92,10 +92,10 @@ export default class Table extends Node {
|
||||
setTableAttr,
|
||||
sortTable,
|
||||
addColumnBefore,
|
||||
addColumnAfter: () => addColumnAfter,
|
||||
addColumnAfter,
|
||||
deleteColumn: () => deleteColumn,
|
||||
addRowBefore,
|
||||
addRowAfter: () => addRowAfter,
|
||||
addRowAfter,
|
||||
moveTableRow,
|
||||
moveTableColumn,
|
||||
deleteRow: () => deleteRow,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EditorView } from "prosemirror-view";
|
||||
import { DecorationSet, Decoration } from "prosemirror-view";
|
||||
import { isInTable, moveTableColumn, TableMap } from "prosemirror-tables";
|
||||
import { addColumnBefore, selectColumn } from "../commands/table";
|
||||
import { isMobile } from "../../utils/browser";
|
||||
import {
|
||||
getCellAttrs,
|
||||
isValidCellAlignment,
|
||||
@@ -326,7 +327,9 @@ export default class TableHeader extends Node {
|
||||
)
|
||||
);
|
||||
|
||||
if (!isDragging) {
|
||||
// The add-column affordance is too small to tap on mobile, where
|
||||
// columns can be added via the inline menu instead.
|
||||
if (!isDragging && !isMobile()) {
|
||||
if (index === 0) {
|
||||
decorations.push(buildAddColumnDecoration(pos, index));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { addRowBefore, selectRow, selectTable } from "../commands/table";
|
||||
import { isMobile } from "../../utils/browser";
|
||||
import {
|
||||
getCellsInRow,
|
||||
getRowsInTable,
|
||||
@@ -339,7 +340,9 @@ export default class TableRow extends Node {
|
||||
)
|
||||
);
|
||||
|
||||
if (!isDragging) {
|
||||
// The add-row affordance is too small to tap on mobile, where
|
||||
// rows can be added via the inline menu instead.
|
||||
if (!isDragging && !isMobile()) {
|
||||
if (index === 0) {
|
||||
decorations.push(buildAddRowDecoration(pos, index));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ export enum TableLayout {
|
||||
fullWidth = "full-width",
|
||||
}
|
||||
|
||||
/** How a selection toolbar menu is presented. */
|
||||
export enum MenuType {
|
||||
/** A horizontal strip of buttons; nested options open behind a trigger. */
|
||||
toolbar = "toolbar",
|
||||
/** A vertical menu rendered directly, anchored to the selection. */
|
||||
inline = "inline",
|
||||
}
|
||||
|
||||
type Section = ({ t }: { t: TFunction }) => string;
|
||||
|
||||
export type MenuItem = {
|
||||
@@ -140,6 +148,12 @@ export interface SelectionToolbarMenuDescriptor {
|
||||
priority: number;
|
||||
/** Toolbar alignment when this menu is active. Defaults to "center". */
|
||||
align?: "center" | "start" | "end";
|
||||
/**
|
||||
* How the menu is presented. "toolbar" (default) renders a horizontal strip
|
||||
* of buttons; "inline" renders a vertical menu anchored to the selection
|
||||
* without requiring a trigger button.
|
||||
*/
|
||||
variant?: MenuType;
|
||||
/**
|
||||
* Returns the menu items to display for the current selection.
|
||||
*
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
"Replacement": "Replacement",
|
||||
"Replace": "Replace",
|
||||
"Replace all": "Replace all",
|
||||
"Options": "Options",
|
||||
"Go to link": "Go to link",
|
||||
"Open link": "Open link",
|
||||
"Remove link": "Remove link",
|
||||
@@ -647,10 +648,13 @@
|
||||
"Edit image URL": "Edit image URL",
|
||||
"Default width": "Default width",
|
||||
"Distribute columns": "Distribute columns",
|
||||
"Delete table": "Delete table",
|
||||
"Export as CSV": "Export as CSV",
|
||||
"Delete table": "Delete table",
|
||||
"Align": "Align",
|
||||
"Sort": "Sort",
|
||||
"Sort ascending": "Sort ascending",
|
||||
"Sort descending": "Sort descending",
|
||||
"Background": "Background",
|
||||
"Toggle header": "Toggle header",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
|
||||
@@ -4817,7 +4817,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-slot@npm:1.2.4":
|
||||
"@radix-ui/react-slot@npm:1.2.4, @radix-ui/react-slot@npm:^1.2.3":
|
||||
version: 1.2.4
|
||||
resolution: "@radix-ui/react-slot@npm:1.2.4"
|
||||
dependencies:
|
||||
@@ -5887,9 +5887,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@simplewebauthn/server@npm:^13.2.3":
|
||||
version: 13.3.0
|
||||
resolution: "@simplewebauthn/server@npm:13.3.0"
|
||||
"@simplewebauthn/server@npm:^13.3.1":
|
||||
version: 13.3.1
|
||||
resolution: "@simplewebauthn/server@npm:13.3.1"
|
||||
dependencies:
|
||||
"@hexagon/base64": "npm:^1.1.27"
|
||||
"@levischuck/tiny-cbor": "npm:^0.2.2"
|
||||
@@ -5899,7 +5899,7 @@ __metadata:
|
||||
"@peculiar/asn1-schema": "npm:^2.6.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.6.1"
|
||||
"@peculiar/x509": "npm:^1.14.3"
|
||||
checksum: 10c0/ff85d4e6b54708ae2ea3be0dd4aa91cb9e27299281c755711caf388e0845e060bbf52b47a1ce6629faba8f5a2182c291bee22954471bbeaa8613a92738232907
|
||||
checksum: 10c0/843f9f4e80bfcf389acb3ba6af48f87937e1de1d87c975a77d92ab1309c4619f3cd8eecaf50499d5355c05476506c35aaf3c7a76a4c4e662a1e3721b06656239
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7601,12 +7601,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.6":
|
||||
version: 4.1.6
|
||||
resolution: "@vitest/pretty-format@npm:4.1.6"
|
||||
"@vitest/pretty-format@npm:4.1.8":
|
||||
version: 4.1.8
|
||||
resolution: "@vitest/pretty-format@npm:4.1.8"
|
||||
dependencies:
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10c0/f818a6abff9b7cf642edc2d0fe84d4f124911696bc7591f2af9ab6d88685b72133a1e9f87499e9b4dc2314dff85403ea66c64f7b408b2eb39f9880c6d3517ca0
|
||||
checksum: 10c0/553c456692a4b9ae13cd116c234c74b4495e0f1a0d5c51ffc3fab8ea085e3550769967e29db79bdac0cf127b1bf88b7f70bfba3dcc72be6bddf834433e30cc91
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7639,11 +7639,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/ui@npm:^4.1.6":
|
||||
version: 4.1.6
|
||||
resolution: "@vitest/ui@npm:4.1.6"
|
||||
"@vitest/ui@npm:^4.1.8":
|
||||
version: 4.1.8
|
||||
resolution: "@vitest/ui@npm:4.1.8"
|
||||
dependencies:
|
||||
"@vitest/utils": "npm:4.1.6"
|
||||
"@vitest/utils": "npm:4.1.8"
|
||||
fflate: "npm:^0.8.2"
|
||||
flatted: "npm:^3.4.2"
|
||||
pathe: "npm:^2.0.3"
|
||||
@@ -7651,8 +7651,8 @@ __metadata:
|
||||
tinyglobby: "npm:^0.2.15"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
vitest: 4.1.6
|
||||
checksum: 10c0/4e3a7416862feafe1ba2b0ca140bead49a2549ed189b2d42b8ea0bbd8656e103783b192151c0b9986861bc5f05e0fd33626c46e1f7572e8da068b85bd6a773ea
|
||||
vitest: 4.1.8
|
||||
checksum: 10c0/c2b559c9633df6d9019cf52b55f1deffa11f12500c124bf0a4fb47991e26d92d42ad549ef434e714dbecf1469b630764d264f78dd0518fbd472e81f43437390e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7667,14 +7667,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.6":
|
||||
version: 4.1.6
|
||||
resolution: "@vitest/utils@npm:4.1.6"
|
||||
"@vitest/utils@npm:4.1.8":
|
||||
version: 4.1.8
|
||||
resolution: "@vitest/utils@npm:4.1.8"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.6"
|
||||
"@vitest/pretty-format": "npm:4.1.8"
|
||||
convert-source-map: "npm:^2.0.0"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10c0/36437888088a1aae8565e62b9f145de9fb1599725574924477c655c7617ad677b575ac0eb3f2b3288854ed1aafff914a0417dffbb7f5244c821f157119701227
|
||||
checksum: 10c0/acda9d3d640c1ebc81afb358ac30589d7d7d583af81e2d09419f0af9cbe41f3ce0b90527326943bf0da51614be5fc31afcd32259f6beb32b3417999d6ef380f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10045,10 +10045,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"discord-api-types@npm:^0.38.46":
|
||||
version: 0.38.46
|
||||
resolution: "discord-api-types@npm:0.38.46"
|
||||
checksum: 10c0/a8b3d6bae79c33f02bef2892fdb23d139ce0139dc4a2cc2c0a3d1dc0c540685136a3c0f5a0ea5cb2f505c934caaeaca57a0def12d6ff1a98e36c6e348120d9ab
|
||||
"discord-api-types@npm:^0.38.48":
|
||||
version: 0.38.48
|
||||
resolution: "discord-api-types@npm:0.38.48"
|
||||
checksum: 10c0/898e57378e6e30987d072f88d4d1c08c5e01f1b534c3c3390a117f1a79a9107e766fd3aaf8728c0afbd934f4dc63e76b1f458f7096d54409a68ae9f848b7c564
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15087,6 +15087,7 @@ __metadata:
|
||||
"@radix-ui/react-one-time-password-field": "npm:^0.1.8"
|
||||
"@radix-ui/react-popover": "npm:^1.1.15"
|
||||
"@radix-ui/react-select": "npm:^2.2.6"
|
||||
"@radix-ui/react-slot": "npm:^1.2.3"
|
||||
"@radix-ui/react-switch": "npm:^1.2.6"
|
||||
"@radix-ui/react-tabs": "npm:^1.1.13"
|
||||
"@radix-ui/react-toolbar": "npm:^1.1.11"
|
||||
@@ -15096,7 +15097,7 @@ __metadata:
|
||||
"@sentry/node": "npm:^7.120.4"
|
||||
"@sentry/react": "npm:^7.120.4"
|
||||
"@simplewebauthn/browser": "npm:^13.3.0"
|
||||
"@simplewebauthn/server": "npm:^13.2.3"
|
||||
"@simplewebauthn/server": "npm:^13.3.1"
|
||||
"@swc/core": "npm:^1.15.32"
|
||||
"@tanstack/react-table": "npm:^8.21.3"
|
||||
"@tanstack/react-virtual": "npm:^3.13.24"
|
||||
@@ -15168,7 +15169,7 @@ __metadata:
|
||||
"@types/yauzl": "npm:^2.10.3"
|
||||
"@types/yazl": "npm:^2.4.6"
|
||||
"@vitejs/plugin-react-oxc": "npm:^0.2.3"
|
||||
"@vitest/ui": "npm:^4.1.6"
|
||||
"@vitest/ui": "npm:^4.1.8"
|
||||
addressparser: "npm:^1.0.1"
|
||||
async-sema: "npm:^3.1.1"
|
||||
babel-plugin-module-resolver: "npm:^5.0.3"
|
||||
@@ -15189,7 +15190,7 @@ __metadata:
|
||||
date-fns: "npm:^3.6.0"
|
||||
dd-trace: "npm:^5.98.0"
|
||||
diff: "npm:^5.2.2"
|
||||
discord-api-types: "npm:^0.38.46"
|
||||
discord-api-types: "npm:^0.38.48"
|
||||
email-providers: "npm:^1.14.0"
|
||||
emoji-mart: "npm:^5.6.0"
|
||||
emoji-regex: "npm:^10.6.0"
|
||||
@@ -15266,7 +15267,7 @@ __metadata:
|
||||
png-chunks-extract: "npm:^1.0.0"
|
||||
polished: "npm:^4.3.1"
|
||||
postinstall-postinstall: "npm:^2.1.0"
|
||||
prettier: "npm:^3.6.2"
|
||||
prettier: "npm:^3.8.3"
|
||||
prosemirror-changeset: "npm:2.4.1"
|
||||
prosemirror-codemark: "npm:^0.4.2"
|
||||
prosemirror-commands: "npm:^1.7.1"
|
||||
@@ -15288,7 +15289,7 @@ __metadata:
|
||||
react: "npm:^17.0.2"
|
||||
react-avatar-editor: "npm:^13.0.2"
|
||||
react-colorful: "npm:^5.7.0"
|
||||
react-day-picker: "npm:^8.10.1"
|
||||
react-day-picker: "npm:^8.10.2"
|
||||
react-dnd: "npm:^16.0.1"
|
||||
react-dnd-html5-backend: "npm:^16.0.1"
|
||||
react-dom: "npm:^17.0.2"
|
||||
@@ -15299,6 +15300,7 @@ __metadata:
|
||||
react-merge-refs: "npm:^2.1.1"
|
||||
react-portal: "npm:^4.3.0"
|
||||
react-refresh: "npm:^0.18.0"
|
||||
react-remove-scroll: "npm:^2.7.2"
|
||||
react-router-dom: "npm:^5.3.4"
|
||||
react-use-measure: "npm:^2.1.7"
|
||||
react-virtualized-auto-sizer: "npm:^1.0.26"
|
||||
@@ -16224,12 +16226,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:^3.6.2":
|
||||
version: 3.7.4
|
||||
resolution: "prettier@npm:3.7.4"
|
||||
"prettier@npm:^3.8.3":
|
||||
version: 3.8.3
|
||||
resolution: "prettier@npm:3.8.3"
|
||||
bin:
|
||||
prettier: bin/prettier.cjs
|
||||
checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389
|
||||
checksum: 10c0/754816fd7593eb80f6376d7476d463e832c38a12f32775a82683adb6e35b772b1f484d65f19401507b983a8c8a7cd5a4a9f12006bd56491e8f35503473f77473
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -16680,13 +16682,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-day-picker@npm:^8.10.1":
|
||||
version: 8.10.1
|
||||
resolution: "react-day-picker@npm:8.10.1"
|
||||
"react-day-picker@npm:^8.10.2":
|
||||
version: 8.10.2
|
||||
resolution: "react-day-picker@npm:8.10.2"
|
||||
peerDependencies:
|
||||
date-fns: ^2.28.0 || ^3.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/a0ff28c4b61b3882e6a825b19e5679e2fdf3256cf1be8eb0a0c028949815c1ae5a6561474c2c19d231c010c8e0e0b654d3a322610881e0655abca05a2e03d9df
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/701fe6dc0cc2de1430ddbf976c3a9e1f00b8b9970f1d11364047238d34e5f60f8089f505c8d9ec77faeef861b06a3b7b8ed6cc5d11d454abe46fe6b3a46270eb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -16853,7 +16855,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-remove-scroll@npm:^2.6.3":
|
||||
"react-remove-scroll@npm:^2.6.3, react-remove-scroll@npm:^2.7.2":
|
||||
version: 2.7.2
|
||||
resolution: "react-remove-scroll@npm:2.7.2"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user