mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd08bbf58 | |||
| bda95e4952 | |||
| 394c6e3b03 | |||
| 9113501906 | |||
| 92168c3641 | |||
| 5ea63aa1a2 | |||
| b1bf7c488b | |||
| 9811ab6aea | |||
| f0899f614b | |||
| c65b020655 | |||
| 9791ff1170 | |||
| a25f334bb1 | |||
| 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 |
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
|
||||
# running behind a proxy in production.
|
||||
# PROXY_HEADERS_TRUSTED=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.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
|
||||
|
||||
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { runInAction } from "mobx";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
@@ -14,11 +15,20 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DateSection,
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
@@ -26,18 +36,20 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
modelId: string;
|
||||
label: string;
|
||||
// Date mentions intentionally omit a label — their text is derived from
|
||||
// the ISO `modelId` so nothing human-readable is persisted.
|
||||
label?: string;
|
||||
actorId?: string;
|
||||
};
|
||||
}
|
||||
@@ -47,15 +59,72 @@ 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();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const userLocale = useUserLocale();
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
// Surface a date suggestion when the search query parses as a natural
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
|
||||
// asynchronous as chrono-node is loaded lazily, so the result is held in
|
||||
// state and applied once resolved.
|
||||
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setParsedISODate(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void parseNaturalLanguageDate(search)
|
||||
.then((date) => {
|
||||
if (!cancelled) {
|
||||
setParsedISODate(date ? toISODate(date) : undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Parsing failed (e.g. the chrono chunk failed to load); drop the
|
||||
// suggestion rather than leaving a stale one.
|
||||
if (!cancelled) {
|
||||
setParsedISODate(undefined);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
let dateItems: MentionItem[] = [];
|
||||
|
||||
if (actorId && parsedISODate) {
|
||||
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
|
||||
const subtitle = dateToReadable(parsedISODate, userLocale);
|
||||
|
||||
dateItems = [
|
||||
{
|
||||
name: "mention",
|
||||
icon: (
|
||||
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
|
||||
),
|
||||
title,
|
||||
subtitle: title !== subtitle ? subtitle : undefined,
|
||||
section: DateSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: parsedISODate,
|
||||
actorId,
|
||||
},
|
||||
} as MentionItem,
|
||||
];
|
||||
}
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
@@ -87,7 +156,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
const mentionItems: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
@@ -253,9 +322,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
])
|
||||
: [];
|
||||
|
||||
const items: MentionItem[] = [...dateItems, ...mentionItems];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Date ||
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -107,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
class: EditorStyleHelper.multiplayerSelection,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ export default class Suggestion<
|
||||
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
|
||||
+13
-17
@@ -17,7 +17,6 @@ import {
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
@@ -26,9 +25,12 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TFunction } from "i18next";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { toISODate } from "@shared/utils/date";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
@@ -124,8 +126,6 @@ export default function blockMenuItems(
|
||||
keywords: "pdf upload attach",
|
||||
attrs: {
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
@@ -186,22 +186,18 @@ export default function blockMenuItems(
|
||||
attrs: { markup: "***" },
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
// Inserts a date mention for today. Supersedes the deprecated "Current
|
||||
// date/time" commands that inserted a static string or template token.
|
||||
name: "mention",
|
||||
title: t("Current date"),
|
||||
keywords: "clock today",
|
||||
icon: <CalendarIcon />,
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
title: t("Current time"),
|
||||
keywords: "clock now",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
name: "datetime",
|
||||
title: t("Current date and time"),
|
||||
keywords: "clock today date",
|
||||
keywords: "clock today date time now",
|
||||
icon: <CalendarIcon />,
|
||||
appendSpace: true,
|
||||
attrs: () => ({
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: toISODate(new Date()),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { CalendarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "@shared/components/Calendar";
|
||||
import { dateLocale } from "@shared/utils/date";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
@@ -21,25 +20,10 @@ type Props = {
|
||||
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
|
||||
const styles = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
"--rdp-caption-font-size": "16px",
|
||||
"--rdp-cell-size": "34px",
|
||||
"--rdp-selected-text": theme.accentText,
|
||||
"--rdp-accent-color": theme.accent,
|
||||
"--rdp-accent-color-dark": theme.accent,
|
||||
"--rdp-background-color": theme.listItemHoverBackground,
|
||||
"--rdp-background-color-dark": theme.listItemHoverBackground,
|
||||
}) as React.CSSProperties,
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledPopoverButton icon={<Icon />} neutral>
|
||||
<StyledPopoverButton neutral>
|
||||
{selectedDate
|
||||
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
|
||||
: t("Choose a date")}
|
||||
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
side="right"
|
||||
shrink
|
||||
>
|
||||
<DayPicker
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
style={styles}
|
||||
locale={locale}
|
||||
disabled={{ before: new Date() }}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = () => (
|
||||
<IconWrapper>
|
||||
<CalendarIcon />
|
||||
</IconWrapper>
|
||||
);
|
||||
|
||||
const StyledPopoverButton = styled(Button)`
|
||||
margin-top: 12px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default ExpiryDatePicker;
|
||||
|
||||
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
import "react-day-picker/dist/style.css";
|
||||
import ExpiryDatePicker from "./components/ExpiryDatePicker";
|
||||
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
|
||||
|
||||
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<Flex align="center" gap={8}>
|
||||
<StyledExpirySelect
|
||||
options={expiryOptions}
|
||||
value={expiryType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
@@ -157,6 +158,30 @@ function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// "+:emoji:" shorthand: react to the comment above instead of replying.
|
||||
if (thread && !thread.isNew) {
|
||||
const emoji = parseReactionShorthand(draft);
|
||||
if (emoji) {
|
||||
const target = comments
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew)
|
||||
.pop();
|
||||
|
||||
if (target) {
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
void target.addReaction({ emoji, user });
|
||||
onSubmit?.();
|
||||
|
||||
// re-focus the comment editor
|
||||
setTimeout(() => {
|
||||
editorRef.current?.focusAtStart();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentDraft = draft;
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
|
||||
: window.location.origin;
|
||||
|
||||
type PathParams = {
|
||||
shareId: string;
|
||||
shareId?: string;
|
||||
collectionSlug?: string;
|
||||
documentSlug?: string;
|
||||
};
|
||||
|
||||
+8
-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",
|
||||
@@ -114,6 +115,7 @@
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.3.0",
|
||||
@@ -213,7 +215,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 +225,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 +352,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 +369,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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,6 +372,16 @@ export class Environment {
|
||||
@IsOptional()
|
||||
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
|
||||
|
||||
/**
|
||||
* Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
* X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
|
||||
* true for backwards compat. Set to false if not running behind a proxy in production.
|
||||
*/
|
||||
@IsBoolean()
|
||||
public PROXY_HEADERS_TRUSTED = this.toBoolean(
|
||||
environment.PROXY_HEADERS_TRUSTED ?? "true"
|
||||
);
|
||||
|
||||
/**
|
||||
* Should the installation send anonymized statistics to the maintainers.
|
||||
* Defaults to true.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.removeColumn("teams", "collaborativeEditing");
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("teams", "collaborativeEditing", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
}: {
|
||||
@@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
includeAttachments = true
|
||||
) {
|
||||
const pathMap = this.createPathMap(collections, format);
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
|
||||
for (const path of pathMap) {
|
||||
const documentId = path[0].replace("/doc/", "");
|
||||
const pathInZip = path[1];
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
@@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
format
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments: true,
|
||||
});
|
||||
|
||||
for (const entry of pathMap) {
|
||||
const documentId = entry[0].replace("/doc/", "");
|
||||
const pathInZip = entry[1];
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes each unique document in the path map and adds it to the zip.
|
||||
*
|
||||
* @param zip The yazl ZipFile to add files to
|
||||
* @param pathMap Map of document urls to their path in the zip
|
||||
* @param format The format to export in
|
||||
* @param includeAttachments Whether to include attachments in the export
|
||||
*/
|
||||
private async addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
}: {
|
||||
zip: ZipFile;
|
||||
pathMap: Map<string, string>;
|
||||
format: FileOperationFormat;
|
||||
includeAttachments: boolean;
|
||||
}) {
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
Logger.debug("task", `Start adding documents to archive`);
|
||||
|
||||
for (const [url, pathInZip] of pathMap) {
|
||||
// A document may be keyed by multiple urls in the path map, only
|
||||
// process each file in the zip once.
|
||||
if (processedPaths.has(pathInZip)) {
|
||||
continue;
|
||||
}
|
||||
processedPaths.add(pathInZip);
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments: true,
|
||||
documentId: url.replace("/doc/", ""),
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "fs-extra";
|
||||
import ZipHelper from "@server/utils/ZipHelper";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildFileOperation,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import ExportMarkdownZipTask from "./ExportMarkdownZipTask";
|
||||
|
||||
describe("ExportMarkdownZipTask", () => {
|
||||
it("should not duplicate documents in the zip file", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
const documents = await Promise.all([
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test1",
|
||||
}),
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test2",
|
||||
}),
|
||||
]);
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document);
|
||||
}
|
||||
const fileOperation = await buildFileOperation({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const task = new ExportMarkdownZipTask();
|
||||
const filePath = await task.exportCollections([collection], fileOperation);
|
||||
|
||||
try {
|
||||
const fileNames: string[] = [];
|
||||
await ZipHelper.walk(filePath, (entry) => {
|
||||
if (!entry.isDirectory) {
|
||||
fileNames.push(entry.fileName);
|
||||
}
|
||||
});
|
||||
|
||||
expect(fileNames.sort()).toEqual([
|
||||
`${collection.name}/Test1.md`,
|
||||
`${collection.name}/Test2.md`,
|
||||
]);
|
||||
} finally {
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1256,6 +1257,23 @@ describe("#collections.create", () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
body: {
|
||||
name: "Test",
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow setting sharing to false", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
@@ -1447,6 +1465,50 @@ describe("#collections.update", () => {
|
||||
expect(collection.content).toBeTruthy();
|
||||
});
|
||||
|
||||
it("replaces rendered content when description is updated post-create", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
const createRes = await server.post("/api/collections.create", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { name: "Foo", description: "Original" },
|
||||
});
|
||||
const { id } = (await createRes.json()).data;
|
||||
|
||||
const updateRes = await server.post("/api/collections.update", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id, description: "Replaced" },
|
||||
});
|
||||
expect(updateRes.status).toEqual(200);
|
||||
|
||||
const infoRes = await server.post("/api/collections.info", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id },
|
||||
});
|
||||
const content = JSON.stringify((await infoRes.json()).data.data);
|
||||
expect(content).toContain("Replaced");
|
||||
expect(content).not.toContain("Original");
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const res = await server.post("/api/collections.update", admin, {
|
||||
body: {
|
||||
id: collection.id,
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("allows editing data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
@@ -15,39 +15,50 @@ const BaseIdSchema = z.object({
|
||||
id: zodIdType(),
|
||||
});
|
||||
|
||||
/** The landing page can be set from description (markdown) or data (rich content), but not both. */
|
||||
const refineBodyContent = <T extends { description?: unknown; data?: unknown }>(
|
||||
body: T
|
||||
) => isUndefined(body.description) || isUndefined(body.data);
|
||||
|
||||
const bodyContentError = {
|
||||
error: "Only one of description or data may be provided",
|
||||
};
|
||||
|
||||
export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
})
|
||||
.refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
|
||||
@@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.optional(),
|
||||
}),
|
||||
}).refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-7
@@ -29,6 +29,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
void initI18n();
|
||||
|
||||
if (env.isProduction) {
|
||||
// Trust the X-Forwarded-* headers set by an upstream proxy, eg
|
||||
// X-Forwarded-For. Defaults to true, but can be disabled with
|
||||
// PROXY_HEADERS_TRUSTED when the app is reachable directly.
|
||||
if (env.PROXY_HEADERS_TRUSTED) {
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Force redirect to HTTPS protocol unless explicitly disabled
|
||||
if (env.FORCE_HTTPS) {
|
||||
app.use(
|
||||
@@ -37,19 +47,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
if (httpsResolver(ctx)) {
|
||||
return true;
|
||||
}
|
||||
return xForwardedProtoResolver(ctx);
|
||||
// Only honor X-Forwarded-Proto when proxy headers are trusted
|
||||
return env.PROXY_HEADERS_TRUSTED
|
||||
? xForwardedProtoResolver(ctx)
|
||||
: false;
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
Logger.warn("Enforced https was disabled with FORCE_HTTPS env variable");
|
||||
}
|
||||
|
||||
// trust header fields set by our proxy. eg X-Forwarded-For
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Make `ctx.userAgent` available
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../styles";
|
||||
|
||||
type Props = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
/**
|
||||
* A themed calendar built on react-day-picker. It is styled from scratch (the
|
||||
* library's base stylesheet is intentionally not relied upon) so that it looks
|
||||
* consistent everywhere it is used. Outside (previous/next month) days are
|
||||
* shown de-emphasised, the selected day is a solid accent-filled circle, and
|
||||
* today is highlighted with the accent colour.
|
||||
*
|
||||
* @param props the underlying react-day-picker props; `showOutsideDays` and
|
||||
* `fixedWeeks` default to true but may be overridden.
|
||||
* @returns the rendered calendar.
|
||||
*/
|
||||
export function Calendar(props: Props) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DayPicker showOutsideDays fixedWeeks {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding: 12px;
|
||||
color: ${s("text")};
|
||||
|
||||
.rdp {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Visually-hidden accessibility labels (would otherwise show without the
|
||||
base stylesheet). */
|
||||
.rdp-vhidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.rdp-month {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${s("text")};
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rdp-nav_button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
|
||||
&:hover {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-nav_icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.rdp-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-head_cell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
color: ${s("textTertiary")};
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rdp-cell {
|
||||
padding: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rdp-day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 50%;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: ${s("text")};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
|
||||
&:hover:not([disabled]):not(.rdp-day_selected) {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
&:focus-visible:not([disabled]) {
|
||||
outline: 2px solid ${s("accent")};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Today, when not selected, is emphasised with the accent colour. */
|
||||
.rdp-day_today:not(.rdp-day_selected) {
|
||||
font-weight: 700;
|
||||
color: ${s("accent")};
|
||||
}
|
||||
|
||||
/* Days belonging to the previous/next month are clearly de-emphasised. */
|
||||
.rdp-day_outside {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rdp-day[disabled] {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The selected day is a solid accent-filled circle. */
|
||||
.rdp-day_selected,
|
||||
.rdp-day_selected:hover,
|
||||
.rdp-day_selected:focus-visible {
|
||||
background: ${s("accent")};
|
||||
color: ${s("accentText")};
|
||||
font-weight: 500;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "styled-components";
|
||||
|
||||
type Props = { day?: number; className?: string };
|
||||
|
||||
export function DynamicCalendarIcon({ day, className }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
// Decorative icon: hide from assistive tech so the day digit isn't
|
||||
// announced out of context.
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
// Isolate so the day text only blends against the icon's own fill below
|
||||
// it, not whatever is behind the icon on the page.
|
||||
style={{ isolation: "isolate" }}
|
||||
>
|
||||
<path
|
||||
d="M10 5.01953C10.3319 5.00624 10.6846 5 11.0596 5H12.9404C13.3154 5 13.6681 5.00624 14 5.01953V4H16V5.24609C18.3996 5.78241 19 7.32118 19 11.0596V12.9404C19 17.9302 17.9302 19 12.9404 19H11.0596C6.06982 19 5 17.9302 5 12.9404V11.0596C5 7.32118 5.60035 5.78241 8 5.24609V4H10V5.01953Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<text
|
||||
// White blended with "difference" against the fill below produces the
|
||||
// exact inverse of the fill colour, so the day is always legible
|
||||
// regardless of the icon's (currentColor) fill.
|
||||
fill="white"
|
||||
style={{ mixBlendMode: "difference" }}
|
||||
fontFamily={theme.fontFamily}
|
||||
fontSize="8"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0em"
|
||||
>
|
||||
<tspan x="12" y="13.5">
|
||||
{day}
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { Command } from "prosemirror-state";
|
||||
import {
|
||||
createEditorStateWithSelection,
|
||||
doc,
|
||||
p,
|
||||
schema,
|
||||
} from "@shared/test/editor";
|
||||
import toggleList from "./toggleList";
|
||||
|
||||
const { bullet_list, ordered_list, list_item } = schema.nodes;
|
||||
|
||||
/**
|
||||
* Creates a list item node with the given block content.
|
||||
*/
|
||||
function li(content: Node[]) {
|
||||
return list_item.create(null, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a position inside the first text node matching the given text.
|
||||
*
|
||||
* @throws if no matching text node exists in the document.
|
||||
*/
|
||||
function posOfText(node: Node, text: string) {
|
||||
let found = -1;
|
||||
node.descendants((child, pos) => {
|
||||
if (found === -1 && child.isText && child.text === text) {
|
||||
found = pos;
|
||||
}
|
||||
return found === -1;
|
||||
});
|
||||
if (found === -1) {
|
||||
throw new Error(`Text "${text}" not found in document`);
|
||||
}
|
||||
return found + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command with the selection placed inside the given text and returns
|
||||
* the resulting document.
|
||||
*/
|
||||
function run(testDoc: Node, selectionText: string, command: Command) {
|
||||
let state = createEditorStateWithSelection(
|
||||
testDoc,
|
||||
posOfText(testDoc, selectionText)
|
||||
);
|
||||
command(state, (tr) => {
|
||||
state = state.apply(tr);
|
||||
});
|
||||
return state.doc;
|
||||
}
|
||||
|
||||
describe("toggleList", () => {
|
||||
it("converts a nested ordered list to bullet without changing the parent list", () => {
|
||||
const testDoc = doc([
|
||||
ordered_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "nested", toggleList(bullet_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("ordered_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
|
||||
});
|
||||
|
||||
it("converts a nested bullet list to ordered without changing the parent list", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "nested", toggleList(ordered_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("bullet_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("ordered_list");
|
||||
});
|
||||
|
||||
it("converts the list and its children when the selection is in the parent list", () => {
|
||||
const testDoc = doc([
|
||||
ordered_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("bullet_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
|
||||
});
|
||||
|
||||
it("lifts the item out of the list when toggling the same list type", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [li([p("one")]), li([p("two")])]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
|
||||
|
||||
expect(result.childCount).toBe(2);
|
||||
expect(result.child(0).type.name).toBe("bullet_list");
|
||||
expect(result.child(1).type.name).toBe("paragraph");
|
||||
expect(result.child(1).textContent).toBe("two");
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,14 @@ export default function toggleList(
|
||||
parentList.pos,
|
||||
parentList.pos + parentList.node.nodeSize,
|
||||
(node, pos) => {
|
||||
if (isList(node, schema)) {
|
||||
// nodesBetween also visits the ancestors of the given range, these
|
||||
// must be skipped so that toggling a nested list does not convert
|
||||
// the lists it is nested within.
|
||||
if (
|
||||
pos >= parentList.pos &&
|
||||
isList(node, schema) &&
|
||||
listType.validContent(node.content)
|
||||
) {
|
||||
tr.setNodeMarkup(pos, listType, listStyle ? { listStyle } : {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "../../components/Calendar";
|
||||
import { depths, s } from "../../styles";
|
||||
import { dateLocale, toISODate } from "../../utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The currently selected date, if any. */
|
||||
selectedDate?: Date;
|
||||
/** The user's language, used to localise the calendar. */
|
||||
language?: Parameters<typeof dateLocale>[0];
|
||||
/** Called with the new date-only ISO string when a day is picked. */
|
||||
onChange: (modelId: string) => void;
|
||||
/** The trigger element the calendar popover is anchored to. */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The interactive calendar popover for a date mention. It lives in its own
|
||||
* module so that its browser-only dependencies (Radix, react-day-picker) are
|
||||
* loaded lazily and stay out of the editor schema graph, which is also imported
|
||||
* on the server.
|
||||
*
|
||||
* @returns the popover wrapping the provided trigger.
|
||||
*/
|
||||
export default function DateMentionPicker({
|
||||
selectedDate,
|
||||
language,
|
||||
onChange,
|
||||
children,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
onChange(toISODate(date));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
aria-label={t("Choose a date")}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<RemoveScroll as={Slot} allowPinchZoom>
|
||||
<DatePopoverContent>
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
defaultMonth={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
locale={dateLocale(language)}
|
||||
/>
|
||||
</DatePopoverContent>
|
||||
</RemoveScroll>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const DatePopoverContent = styled.div`
|
||||
z-index: ${depths.modal};
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { dateToRelativeReadable, parseISODate } from "../../utils/date";
|
||||
import { Backticks } from "../../components/Backticks";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
@@ -510,6 +511,55 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
type DateProps = ComponentProps & {
|
||||
onChangeDate: (modelId: string) => void;
|
||||
};
|
||||
|
||||
// Loaded lazily so its browser-only dependencies (Radix, react-day-picker)
|
||||
// don't enter the editor schema's static import graph, which is also used on
|
||||
// the server.
|
||||
const DateMentionPicker = React.lazy(() => import("./DateMentionPicker"));
|
||||
|
||||
export const MentionDate = observer(function MentionDate_(props: DateProps) {
|
||||
const { isSelected, isEditable, node, onChangeDate } = props;
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useStores();
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
const language = auth.user?.language;
|
||||
const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : "";
|
||||
const display = dateToRelativeReadable(iso, t, language);
|
||||
const selectedDate = parseISODate(iso) ?? undefined;
|
||||
|
||||
const content = (
|
||||
<DateMention
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
$editable={isEditable}
|
||||
>
|
||||
{display}
|
||||
</DateMention>
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={content}>
|
||||
<DateMentionPicker
|
||||
selectedDate={selectedDate}
|
||||
language={language}
|
||||
onChange={onChangeDate}
|
||||
>
|
||||
{content}
|
||||
</DateMentionPicker>
|
||||
</React.Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -532,6 +582,11 @@ const MentionError = ({ className }: { className: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DateMention = styled.span<{ $editable: boolean }>`
|
||||
cursor: ${(props) => (props.$editable ? "pointer" : "default")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -570,6 +570,12 @@ width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Date mentions are plain text, so they inherit the surrounding font weight
|
||||
(e.g. bold when placed inside a heading). */
|
||||
&[data-type="date"] {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
&.mention-user::before {
|
||||
content: "@";
|
||||
}
|
||||
@@ -596,7 +602,7 @@ width: 100%;
|
||||
padding: ${props.editorStyle?.padding ?? "initial"};
|
||||
margin: ${props.editorStyle?.margin ?? "initial"};
|
||||
|
||||
& > .ProseMirror-yjs-cursor {
|
||||
& > .${EditorStyleHelper.multiplayerCursor} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -670,11 +676,11 @@ width: 100%;
|
||||
h5 { font-size: var(--font-size-h5); }
|
||||
h6 { font-size: var(--font-size-h6); }
|
||||
|
||||
.ProseMirror-yjs-selection {
|
||||
.${EditorStyleHelper.multiplayerSelection} {
|
||||
transition: background-color 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.ProseMirror-yjs-cursor {
|
||||
.${EditorStyleHelper.multiplayerCursor} {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
@@ -682,6 +688,7 @@ width: 100%;
|
||||
border-right: 1px solid black;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
user-select: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
@@ -719,7 +726,7 @@ width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.show-cursor-names .ProseMirror-yjs-cursor > div {
|
||||
&.show-cursor-names .${EditorStyleHelper.multiplayerCursor} > div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -822,31 +829,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;
|
||||
@@ -1023,7 +1005,7 @@ img.ProseMirror-separator {
|
||||
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child,
|
||||
// Edge case where multiplayer cursor is between start of cell and heading
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + .ProseMirror-yjs-cursor,
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + .${EditorStyleHelper.multiplayerCursor},
|
||||
// Edge case where table grips are between start of cell and heading
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + [role=button] + [role=button] {
|
||||
& + h1,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
|
||||
import type { ProsemirrorData } from "../../types";
|
||||
import {
|
||||
getNameFromEmoji,
|
||||
getEmojiFromName,
|
||||
loadEmojiData,
|
||||
parseReactionShorthand,
|
||||
} from "./emoji";
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadEmojiData();
|
||||
@@ -15,3 +21,91 @@ describe("getEmojiFromName", () => {
|
||||
expect(getEmojiFromName("thinking_face")).toBe("🤔");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReactionShorthand", () => {
|
||||
const doc = (content: ProsemirrorData[]): ProsemirrorData => ({
|
||||
type: "doc",
|
||||
content,
|
||||
});
|
||||
|
||||
const paragraph = (content: ProsemirrorData[]): ProsemirrorData => ({
|
||||
type: "paragraph",
|
||||
content,
|
||||
});
|
||||
|
||||
const text = (value: string): ProsemirrorData => ({
|
||||
type: "text",
|
||||
text: value,
|
||||
});
|
||||
|
||||
const emoji = (name: string): ProsemirrorData => ({
|
||||
type: "emoji",
|
||||
attrs: { "data-name": name },
|
||||
});
|
||||
|
||||
it("resolves a '+' followed by an emoji node", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+"), emoji("thumbs_up")])]))
|
||||
).toBe("👍");
|
||||
});
|
||||
|
||||
it("ignores whitespace between the '+' and the emoji node", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+"), text(" "), emoji("thinking_face")])])
|
||||
)
|
||||
).toBe("🤔");
|
||||
});
|
||||
|
||||
it("resolves a custom emoji UUID to its UUID", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+"), emoji(uuid)])]))
|
||||
).toBe(uuid);
|
||||
});
|
||||
|
||||
it("resolves literal '+:shortcode:' text", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+:thinking_face:")])]))
|
||||
).toBe("🤔");
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown shortcode", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+"), emoji("not_an_emoji")])])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is text alongside the emoji", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+ nice "), emoji("thumbs_up")])])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a regular comment", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("Looks good to me")])]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the '+' prefix is missing", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([emoji("thumbs_up")])]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for multiple paragraphs", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([
|
||||
paragraph([text("+"), emoji("thumbs_up")]),
|
||||
paragraph([text("more")]),
|
||||
])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { EmojiMartData } from "@emoji-mart/data";
|
||||
import { isUUID } from "validator";
|
||||
import type { ProsemirrorData } from "../../types";
|
||||
|
||||
export const emojiMartToGemoji: Record<string, string> = {
|
||||
"+1": "thumbs_up",
|
||||
@@ -74,3 +76,77 @@ export const getEmojiFromName = (name: string) =>
|
||||
*/
|
||||
export const getNameFromEmoji = (emoji: string) =>
|
||||
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
|
||||
|
||||
/**
|
||||
* Resolve an emoji node name to the value used to react with.
|
||||
*
|
||||
* @param name The emoji shortcode, or a UUID for a custom emoji.
|
||||
* @returns the native emoji character, the UUID of a custom emoji, or undefined
|
||||
* when the name does not resolve to a known emoji.
|
||||
*/
|
||||
function getReactionFromName(name: unknown): string | undefined {
|
||||
if (typeof name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Custom emojis are stored as UUIDs and reacted with directly.
|
||||
if (isUUID(name)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const character = getEmojiFromName(name);
|
||||
return character === "?" ? undefined : character;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the "+:emoji:" reaction shorthand within a comment's document. When a
|
||||
* comment consists solely of a leading "+" immediately followed by a single
|
||||
* emoji it is treated as a request to react to the comment above rather than as
|
||||
* a new comment, mirroring the Slack shorthand.
|
||||
*
|
||||
* @param data The Prosemirror document of the draft comment.
|
||||
* @returns the emoji to react with — a native emoji character, or a UUID for a
|
||||
* custom emoji — or undefined when the document is not a reaction shorthand.
|
||||
*/
|
||||
export function parseReactionShorthand(
|
||||
data: ProsemirrorData
|
||||
): string | undefined {
|
||||
const blocks = data.content ?? [];
|
||||
if (blocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const paragraph = blocks[0];
|
||||
if (paragraph.type !== "paragraph") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ignore whitespace-only text nodes so that "+ :emoji:" still matches.
|
||||
const inline = (paragraph.content ?? []).filter(
|
||||
(node) => !(node.type === "text" && !node.text?.trim())
|
||||
);
|
||||
|
||||
// The common case: a "+" text node followed by an emoji node inserted via
|
||||
// the emoji menu.
|
||||
if (inline.length === 2) {
|
||||
const [prefix, emoji] = inline;
|
||||
if (
|
||||
prefix.type === "text" &&
|
||||
prefix.text?.trim() === "+" &&
|
||||
emoji.type === "emoji"
|
||||
) {
|
||||
return getReactionFromName(emoji.attrs?.["data-name"]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fallback: literal "+:shortcode:" text that was never converted to a node.
|
||||
if (inline.length === 1 && inline[0].type === "text") {
|
||||
const match = inline[0].text?.trim().match(/^\+\s*:([\w-]+):$/);
|
||||
if (match) {
|
||||
return getReactionFromName(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ export class MarkdownSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks whether we have already warned about direct assignment to `out`,
|
||||
// so a hot loop cannot flood the console.
|
||||
let warnedDirectOutAssignment = false;
|
||||
|
||||
export interface BlockMapEntry {
|
||||
/** Start position in the ProseMirror document (offset within parent content). */
|
||||
pmFrom: number;
|
||||
@@ -103,14 +107,36 @@ export class MarkdownSerializerState {
|
||||
inTightList = false;
|
||||
closed = false;
|
||||
delim = "";
|
||||
out = "";
|
||||
_out = "";
|
||||
lastChar = "";
|
||||
options: Options;
|
||||
blockMap = null;
|
||||
|
||||
// The serialized output so far. Use `append` to add to it — direct
|
||||
// assignment still works but reads the last character back out of the
|
||||
// string, which forces V8 to flatten the internal rope and is slow when
|
||||
// done repeatedly on large documents.
|
||||
get out() {
|
||||
return this._out;
|
||||
}
|
||||
|
||||
set out(value) {
|
||||
if (!warnedDirectOutAssignment) {
|
||||
warnedDirectOutAssignment = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"MarkdownSerializerState: assigning `out` directly is slow on large documents, use append() instead."
|
||||
);
|
||||
}
|
||||
this._out = value;
|
||||
this.lastChar = value === "" ? "" : value.charAt(value.length - 1);
|
||||
}
|
||||
|
||||
constructor(nodes, marks, options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.delim = this.out = "";
|
||||
this.delim = this._out = "";
|
||||
this.lastChar = "";
|
||||
this.closed = false;
|
||||
this.inTightList = false;
|
||||
this.inTable = false;
|
||||
@@ -126,10 +152,21 @@ export class MarkdownSerializerState {
|
||||
}
|
||||
}
|
||||
|
||||
// :: (string)
|
||||
// Append a string to the output, tracking `lastChar` without reading
|
||||
// characters back out of `out` — that would force V8 to flatten the
|
||||
// internal rope, which is quadratic on large documents.
|
||||
append(content) {
|
||||
if (content) {
|
||||
this._out += content;
|
||||
this.lastChar = content.charAt(content.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
flushClose(size) {
|
||||
if (this.closed) {
|
||||
if (!this.atBlank()) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
if (size === null || size === undefined) {
|
||||
size = 2;
|
||||
@@ -141,7 +178,7 @@ export class MarkdownSerializerState {
|
||||
delimMin = delimMin.slice(0, delimMin.length - trim[0].length);
|
||||
}
|
||||
for (let i = 1; i < size; i++) {
|
||||
this.out += delimMin + "\n";
|
||||
this.append(delimMin + "\n");
|
||||
}
|
||||
}
|
||||
this.closed = false;
|
||||
@@ -163,14 +200,14 @@ export class MarkdownSerializerState {
|
||||
}
|
||||
|
||||
atBlank() {
|
||||
return /(^|\n)$/.test(this.out);
|
||||
return this.lastChar === "" || this.lastChar === "\n";
|
||||
}
|
||||
|
||||
// :: ()
|
||||
// Ensure the current content ends with a newline.
|
||||
ensureNewLine() {
|
||||
if (!this.atBlank()) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +218,10 @@ export class MarkdownSerializerState {
|
||||
write(content) {
|
||||
this.flushClose();
|
||||
if (this.delim && this.atBlank()) {
|
||||
this.out += this.delim;
|
||||
this.append(this.delim);
|
||||
}
|
||||
if (content) {
|
||||
this.out += content;
|
||||
this.append(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +239,11 @@ export class MarkdownSerializerState {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const startOfLine = this.atBlank() || this.closed;
|
||||
this.write();
|
||||
this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i];
|
||||
this.append(
|
||||
escape !== false ? this.esc(lines[i], startOfLine) : lines[i]
|
||||
);
|
||||
if (i !== lines.length - 1) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,9 +428,9 @@ export class MarkdownSerializerState {
|
||||
if (this.inTable) {
|
||||
node.forEach((child, _, i) => {
|
||||
if (i > 0) {
|
||||
this.out += " <br> ";
|
||||
this.append(" <br> ");
|
||||
}
|
||||
this.out += firstDelim(i).trim() + " ";
|
||||
this.append(firstDelim(i).trim() + " ");
|
||||
this.render(child, node, i);
|
||||
});
|
||||
return;
|
||||
@@ -438,12 +477,12 @@ export class MarkdownSerializerState {
|
||||
});
|
||||
|
||||
// Ensure there is an empty newline above all tables
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
|
||||
// Render rows
|
||||
node.forEach((row, _, i) => {
|
||||
row.forEach((cell, _, j) => {
|
||||
this.out += j === 0 ? "| " : " | ";
|
||||
this.append(j === 0 ? "| " : " | ");
|
||||
|
||||
const startPos = this.out.length;
|
||||
|
||||
@@ -463,26 +502,26 @@ export class MarkdownSerializerState {
|
||||
// Pad to column width
|
||||
const contentLength = this.out.length - startPos;
|
||||
const padding = Math.max(0, columnWidths[j] - contentLength);
|
||||
this.out += " ".repeat(padding);
|
||||
this.append(" ".repeat(padding));
|
||||
});
|
||||
|
||||
this.out += " |\n";
|
||||
this.append(" |\n");
|
||||
|
||||
// Header separator after first row
|
||||
if (i === 0) {
|
||||
headerRow.forEach((cell, _, j) => {
|
||||
const width = columnWidths[j];
|
||||
if (cell.attrs.alignment === "center") {
|
||||
this.out += "|:" + "-".repeat(width) + ":";
|
||||
this.append("|:" + "-".repeat(width) + ":");
|
||||
} else if (cell.attrs.alignment === "left") {
|
||||
this.out += "|:" + "-".repeat(width + 1);
|
||||
this.append("|:" + "-".repeat(width + 1));
|
||||
} else if (cell.attrs.alignment === "right") {
|
||||
this.out += "|" + "-".repeat(width + 1) + ":";
|
||||
this.append("|" + "-".repeat(width + 1) + ":");
|
||||
} else {
|
||||
this.out += "|" + "-".repeat(width + 2);
|
||||
this.append("|" + "-".repeat(width + 2));
|
||||
}
|
||||
});
|
||||
this.out += "|\n";
|
||||
this.append("|\n");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
@@ -116,11 +116,11 @@ export default class CheckboxItem extends Node {
|
||||
}
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
state.out += node.attrs.checked ? "[x] " : "[ ] ";
|
||||
state.append(node.attrs.checked ? "[x] " : "[ ] ");
|
||||
if (state.inTable) {
|
||||
node.forEach((block, _, i) => {
|
||||
if (i > 0) {
|
||||
state.out += " ";
|
||||
state.append(" ");
|
||||
}
|
||||
state.renderInline(block);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -52,8 +52,8 @@ const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
|
||||
|
||||
const match = tokenTitle.match(imageSizeRegex);
|
||||
if (match) {
|
||||
attributes.width = parseInt(match[1], 10);
|
||||
attributes.height = parseInt(match[2], 10);
|
||||
attributes.width = match[1] ? parseInt(match[1], 10) : undefined;
|
||||
attributes.height = match[2] ? parseInt(match[2], 10) : undefined;
|
||||
tokenTitle = tokenTitle.replace(imageSizeRegex, "");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -291,7 +291,7 @@ export default class ListItem extends Node {
|
||||
if (state.inTable) {
|
||||
node.forEach((block, _, i) => {
|
||||
if (i > 0) {
|
||||
state.out += " ";
|
||||
state.append(" ");
|
||||
}
|
||||
state.renderInline(block);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import type { UnfurlResponse } from "../../types";
|
||||
import { MentionType, UnfurlResourceType } from "../../types";
|
||||
import { dateToReadable } from "../../utils/date";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
MentionIssue,
|
||||
MentionProject,
|
||||
MentionPullRequest,
|
||||
MentionDate,
|
||||
MentionURL,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
@@ -39,17 +41,25 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
const toPlainText = (node: ProsemirrorNode) =>
|
||||
node.attrs.type === MentionType.User
|
||||
// Date mentions derive their text from the ISO `modelId`, which is the
|
||||
// single source of truth — no human-readable label is persisted for them.
|
||||
const toPlainText = (node: ProsemirrorNode) => {
|
||||
if (node.attrs.type === MentionType.Date) {
|
||||
return dateToReadable(node.attrs.modelId);
|
||||
}
|
||||
return node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label;
|
||||
};
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
type: {
|
||||
default: MentionType.User,
|
||||
},
|
||||
label: {},
|
||||
label: {
|
||||
default: undefined,
|
||||
},
|
||||
modelId: {},
|
||||
actorId: {
|
||||
default: undefined,
|
||||
@@ -84,7 +94,9 @@ export default class Mention extends Node {
|
||||
type,
|
||||
modelId,
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
// Date mentions derive their text from `modelId`; never capture
|
||||
// the rendered text as a persisted label.
|
||||
label: type === MentionType.Date ? undefined : dom.innerText,
|
||||
id: dom.id,
|
||||
href: dom.getAttribute("href"),
|
||||
unfurl: dom.dataset.unfurl
|
||||
@@ -95,12 +107,21 @@ export default class Mention extends Node {
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
node.attrs.type === MentionType.User ? "span" : "a",
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? "span"
|
||||
: "a",
|
||||
{
|
||||
class: `${node.type.name} use-hover-preview`,
|
||||
// Date mentions are self-contained and have nothing to unfurl, so
|
||||
// they opt out of the hover preview behaviour.
|
||||
class:
|
||||
node.attrs.type === MentionType.Date
|
||||
? node.type.name
|
||||
: `${node.type.name} use-hover-preview`,
|
||||
id: node.attrs.id,
|
||||
href:
|
||||
node.attrs.type === MentionType.User
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
@@ -162,6 +183,10 @@ export default class Mention extends Node {
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.Date:
|
||||
return (
|
||||
<MentionDate {...props} onChangeDate={this.handleChangeDate(props)} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -315,7 +340,10 @@ export default class Mention extends Node {
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
const mType = node.attrs.type;
|
||||
const mId = node.attrs.modelId;
|
||||
const label = node.attrs.label;
|
||||
// Date mentions have no stored label; the readable text is derived from
|
||||
// the ISO `modelId` so it can never drift from the source of truth.
|
||||
const label =
|
||||
mType === MentionType.Date ? dateToReadable(mId) : node.attrs.label;
|
||||
const id = node.attrs.id;
|
||||
|
||||
// Use regular links for document and collection mentions
|
||||
@@ -336,11 +364,32 @@ export default class Mention extends Node {
|
||||
id: tok.attrGet("id"),
|
||||
type: tok.attrGet("type"),
|
||||
modelId: tok.attrGet("modelId"),
|
||||
label: tok.content,
|
||||
// Date mentions derive their text from `modelId`; the link text is not
|
||||
// persisted as a label.
|
||||
label:
|
||||
tok.attrGet("type") === MentionType.Date ? undefined : tok.content,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeDate =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(modelId: string) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
const pos = getPos();
|
||||
|
||||
if (node.attrs.modelId === modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
modelId,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
|
||||
@@ -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,11 @@ import attachmentsRule from "../rules/links";
|
||||
import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
const parseDimension = (value: string | null): number | null => {
|
||||
const parsed = parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export default class Video extends Node {
|
||||
get name() {
|
||||
return "video";
|
||||
@@ -56,12 +61,12 @@ export default class Video extends Node {
|
||||
{
|
||||
priority: 100,
|
||||
tag: "video",
|
||||
getAttrs: (dom: HTMLAnchorElement) => ({
|
||||
getAttrs: (dom: HTMLVideoElement) => ({
|
||||
id: dom.id,
|
||||
title: dom.getAttribute("title"),
|
||||
src: dom.getAttribute("src"),
|
||||
width: parseInt(dom.getAttribute("width") ?? "", 10),
|
||||
height: parseInt(dom.getAttribute("height") ?? "", 10),
|
||||
width: parseDimension(dom.getAttribute("width")),
|
||||
height: parseDimension(dom.getAttribute("height")),
|
||||
}),
|
||||
},
|
||||
],
|
||||
@@ -184,7 +189,9 @@ export default class Video extends Node {
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
state.ensureNewLine();
|
||||
state.write(
|
||||
`[${node.attrs.title} ${node.attrs.width}x${node.attrs.height}](${node.attrs.src})\n\n`
|
||||
`[${node.attrs.title} ${node.attrs.width ?? ""}x${
|
||||
node.attrs.height ?? ""
|
||||
}](${node.attrs.src})\n\n`
|
||||
);
|
||||
state.ensureNewLine();
|
||||
}
|
||||
@@ -195,8 +202,8 @@ export default class Video extends Node {
|
||||
getAttrs: (tok: Token) => ({
|
||||
src: tok.attrGet("src"),
|
||||
title: tok.attrGet("title"),
|
||||
width: parseInt(tok.attrGet("width") ?? "", 10),
|
||||
height: parseInt(tok.attrGet("height") ?? "", 10),
|
||||
width: parseDimension(tok.attrGet("width")),
|
||||
height: parseDimension(tok.attrGet("height")),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { extensionManager, schema } from "../../test/editor";
|
||||
import { extensionManager, findNodes, schema } from "../../test/editor";
|
||||
|
||||
const serializer = extensionManager.serializer();
|
||||
const parser = extensionManager.parser({
|
||||
@@ -6,29 +6,19 @@ const parser = extensionManager.parser({
|
||||
plugins: extensionManager.rulePlugins,
|
||||
});
|
||||
|
||||
interface ProsemirrorNode {
|
||||
type: string;
|
||||
content?: ProsemirrorNode[];
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("preserves mixed checkbox and regular items in a list", () => {
|
||||
const markdown = `- [x] Checked item
|
||||
- Regular item
|
||||
- [ ] Unchecked item`;
|
||||
|
||||
const ast = parser.parse(markdown);
|
||||
const json = ast?.toJSON();
|
||||
|
||||
const checkboxList = json?.content?.find(
|
||||
(node: ProsemirrorNode) => node.type === "checkbox_list"
|
||||
);
|
||||
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
|
||||
|
||||
expect(checkboxList).toBeDefined();
|
||||
expect(checkboxList?.content).toHaveLength(3);
|
||||
expect(checkboxList?.content[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[2].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[2].type).toBe("checkbox_item");
|
||||
});
|
||||
|
||||
it("round-trips mixed checkbox lists through serializer", () => {
|
||||
@@ -52,22 +42,15 @@ it("does not convert nested bullet list items inside checkbox lists", () => {
|
||||
- [ ] Second checkbox`;
|
||||
|
||||
const ast = parser.parse(markdown);
|
||||
const json = ast?.toJSON();
|
||||
|
||||
const checkboxList = json?.content?.find(
|
||||
(node: ProsemirrorNode) => node.type === "checkbox_list"
|
||||
);
|
||||
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
|
||||
|
||||
expect(checkboxList).toBeDefined();
|
||||
expect(checkboxList?.content).toHaveLength(2);
|
||||
expect(checkboxList?.content[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
|
||||
|
||||
// Nested list should remain a bullet_list, not a checkbox_list
|
||||
const nestedContent = checkboxList?.content[0].content;
|
||||
const nestedList = nestedContent?.find(
|
||||
(node: ProsemirrorNode) => node.type === "bullet_list"
|
||||
);
|
||||
const [nestedList] = findNodes(checkboxList?.content?.[0], "bullet_list");
|
||||
expect(nestedList).toBeDefined();
|
||||
expect(nestedList?.content?.[0].type).toBe("list_item");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { JSONNode } from "../../test/editor";
|
||||
import { extensionManager, findNodes, schema } from "../../test/editor";
|
||||
|
||||
const parser = extensionManager.parser({
|
||||
schema,
|
||||
plugins: extensionManager.rulePlugins,
|
||||
});
|
||||
|
||||
const parseToJSON = (markdown: string): JSONNode | undefined =>
|
||||
parser.parse(markdown)?.toJSON();
|
||||
|
||||
describe("math markdown rules", () => {
|
||||
it("parses inline math", () => {
|
||||
const doc = parseToJSON("before $x + y$ after");
|
||||
const nodes = findNodes(doc, "math_inline");
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].content?.[0].text).toBe("x + y");
|
||||
});
|
||||
|
||||
it("parses block math with closing delimiter on its own line", () => {
|
||||
const doc = parseToJSON("$$\na = b\n$$\n\nparagraph after");
|
||||
const nodes = findNodes(doc, "math_block");
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].content?.[0].text).toContain("a = b");
|
||||
expect(findNodes(doc, "paragraph")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses block math with closing delimiter at the end of a content line", () => {
|
||||
const doc = parseToJSON("$$\na = b\nc = d$$\n\nparagraph after");
|
||||
const blocks = findNodes(doc, "math_block");
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].content?.[0].text).toContain("a = b");
|
||||
expect(blocks[0].content?.[0].text).toContain("c = d");
|
||||
|
||||
// The paragraph following the block must not be swallowed into the math
|
||||
const paragraphs = findNodes(doc, "paragraph");
|
||||
expect(paragraphs).toHaveLength(1);
|
||||
expect(blocks[0].content?.[0].text).not.toContain("paragraph after");
|
||||
});
|
||||
|
||||
it("leaves unclosed inline math as plain text", () => {
|
||||
const doc = parseToJSON("price is $5 and rising");
|
||||
|
||||
expect(findNodes(doc, "math_inline")).toHaveLength(0);
|
||||
expect(findNodes(doc, "math_block")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function mathInline(state: StateInline, silent: boolean): boolean {
|
||||
// we have found an opening delimiter already
|
||||
const start = state.pos + inlineMathDelimiter.length;
|
||||
match = start;
|
||||
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== 1) {
|
||||
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== -1) {
|
||||
// found potential delimeter, look for escapes, pos will point to
|
||||
// first non escape when complete
|
||||
pos = match - 1;
|
||||
@@ -166,7 +166,10 @@ function mathDisplay(
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.src.slice(pos, max).trim().slice(-3) === blockMathDelimiter) {
|
||||
if (
|
||||
state.src.slice(pos, max).trim().slice(-blockMathDelimiter.length) ===
|
||||
blockMathDelimiter
|
||||
) {
|
||||
lastPos = state.src.slice(0, max).lastIndexOf(blockMathDelimiter);
|
||||
lastLine = state.src.slice(pos, lastPos);
|
||||
found = true;
|
||||
|
||||
@@ -98,6 +98,21 @@ describe("mention rule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("date format", () => {
|
||||
it("should parse a date mention with an ISO date modelId", () => {
|
||||
const result = md.parse(
|
||||
"@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)",
|
||||
{}
|
||||
);
|
||||
const mentions = findMentionTokens(result);
|
||||
|
||||
expect(mentions).toHaveLength(1);
|
||||
expect(mentions[0].type).toBe("date");
|
||||
expect(mentions[0].modelId).toBe("2024-02-03");
|
||||
expect(mentions[0].label).toBe("February 3rd, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed content", () => {
|
||||
it("should parse mention within text", () => {
|
||||
const result = md.parse(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user