Compare commits

...

37 Commits

Author SHA1 Message Date
Tom Moor 394c6e3b03 fix: Duplicate paths in export ZIP (#12674) 2026-06-12 20:04:18 -04:00
Tom Moor 9113501906 Add PROXY_HEADERS_TRUSTED env (#12676)
* Add PROXY_HEADERS_TRUSTED env

* Don't trust X-Forwarded-Proto for HTTPS redirect when proxy headers untrusted

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:58:16 -04:00
Tom Moor 92168c3641 fix: Toggling a nested list no longer converts parent lists (#12670)
* fix: Toggling a nested list no longer converts parent lists

When the selection was inside a nested list, toggling the list type from
the toolbar or keyboard shortcut converted every list in the tree,
including ancestors of the selected list. This was caused by
doc.nodesBetween visiting ancestor nodes whose range overlaps the
selected list - these are now skipped so only the closest list and its
children are converted. Also guards against converting nested lists with
incompatible content such as checkbox lists.

Closes #12653

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

* test: Throw when selection text is not found in toggleList test helper

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-12 19:37:38 -04:00
Tom Moor 5ea63aa1a2 fix: Editor math block parsing and NaN media dimensions (#12668)
* fix: Block math not closed by trailing $$ on a content line

The closing delimiter check compared a 3-character slice against the
2-character "$$" delimiter, so block math closed on the same line as
content (e.g. "c = d$$") was never detected and the block swallowed the
rest of the document. Use the delimiter length rather than a hardcoded
slice. Also fix the indexOf sentinel comparison (!== 1 instead of
!== -1) in inline math parsing, which terminated correctly only by
coincidence.

Adds tests for the math markdown rules and moves the findNodes test
helper into shared/test/editor for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: NaN width and height parsed for video and image nodes

Video parseDOM and parseMarkdown used parseInt on a missing attribute,
storing NaN instead of null and persisting it to markdown as NaNxNaN.
Image size syntax with a missing dimension (e.g. "=x100") hit the same
issue through optional regex groups. Parse dimensions only when
present, matching the existing guard in Image parseDOM, and correct the
video getAttrs element type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Normalize non-numeric video dimensions, avoid serializing nullxnull

Review feedback: parseInt could still produce NaN when the attribute
exists but is not numeric (e.g. width="auto"), and toMarkdown wrote
null dimensions as "nullxnull". Parse dimensions through a helper that
normalizes non-finite values to null, and serialize nullish dimensions
as empty strings, which still round-trips as a video node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:29:29 -04:00
Tom Moor b1bf7c488b chore: Drop dead collaborativeEditing column from teams (#12669)
The collaborativeEditing toggle has been unused since collaborative
editing became always-on. The column is no longer defined in the Team
model nor referenced anywhere in the codebase, so this drops it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:08 -04:00
Tom Moor 9811ab6aea feat: Emoji reaction shorthand (#12650)
* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 21:51:11 -04:00
Tom Moor f0899f614b fix: Improve markdown serialization speed (#12667) 2026-06-11 21:50:47 -04:00
Tom Moor c65b020655 fix: Reject collections.update requests that include both description and data (#12648) 2026-06-11 21:30:47 -04:00
Tom Moor 9791ff1170 fix: Prevent selecting word-joiner characters around multiplayer cursor (#12660)
* Possible fix for word-joiner characters copied on Chrome+Windows

* simplify
2026-06-11 09:04:38 -04:00
dependabot[bot] a25f334bb1 chore(deps): bump shell-quote from 1.8.3 to 1.8.4 (#12659)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 17:50:41 -04:00
Tom Moor e1b2993bca Reduce debounce 2026-06-09 23:01:33 -04:00
Tom Moor b3d4563730 perf: Improve performance of in-page search (#12649)
* Fix editor find freezing on long documents

In-document search (Ctrl+F) blocked the UI for several seconds while
typing in long documents. Two compounding causes:

- The find command ran a full-document search and highlight rebuild on
  every keystroke. Debounce it so typing stays responsive; the input
  value still updates immediately and pending searches are flushed when
  navigating between matches.

- search() de-duplicated matches with an O(n) scan of all prior results
  per match, making a common term that matches many times quadratic.
  Track seen positions in a Set for constant-time lookups.

* Skip redundant search highlight rebuilds, lower debounce to 100ms

The highlight plugin rebuilt every match's DOM range via domAtPos on
every editor view update while a search was active, forcing synchronous
layout on cursor moves, selection changes, and collaboration cursors.

Track the built ranges and, when the result set is unchanged, only
rebuild when they are actually stale — a referenced node has detached or
some matches were not yet resolved to ranges. isConnected checks are
cheap property reads with no layout, versus domAtPos which forces
reflow, so this is strictly less work than before and skips entirely in
the common case where all matches are resolved and connected.

Also lower the find debounce from 250ms to 100ms for snappier feedback.

* Shorten highlight rebuild comment

* PR feedback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-09 22:46:59 -04:00
Tom Moor 7106263f88 fix: Dragging images in editor (#12647)
* fix: Editor image dragging

* feedback
2026-06-09 22:27:42 -04:00
Tom Moor 3c2e9a9723 fix: Default collections created through MCP to private (#12644) 2026-06-09 08:38:34 -04:00
Tom Moor bd01a62fc1 feat: MCP template support (#12639) 2026-06-09 07:43:21 -04:00
Tom Moor 95106e695f fix: Pasted content sometimes appears in plaintext (#12638)
* fix: 'Stuck' shift key forces plaintext paste
fix: Link on image does not survive copy/paste

* sanitize
2026-06-09 07:38:36 -04:00
Tom Moor 39623b90bd fix: Search prop is optional 2026-06-08 22:30:19 -04:00
Tom Moor a3fcd71582 fix: Widen validated emails for Azure (#12637) 2026-06-08 20:20:52 -04:00
Tom Moor a2f9962958 fix: Update validateUrlNotPrivate to match implementation in SSRF (#12636) 2026-06-08 19:35:46 -04:00
Tom Moor 709184ae0b fix: Before/After creation options appear in menu when no permission on parent doc (#12629)
* fix: Before/After creation options appear in menu when no permission on parent

closes #12624

* fix: Manager of root document sees non-functional Before/After create options

Before/After only gated on the team-level createDocument ability, so a user
with document-level manager access (but no collection access) saw the options
yet hit a backend rejection. Gate on the actual sibling location instead,
mirroring authorizeDocumentCreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:44:09 -04:00
dependabot[bot] bc5ffb79b2 chore(deps): bump react-day-picker from 8.10.1 to 8.10.2 (#12632)
Bumps [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) from 8.10.1 to 8.10.2.
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v8.10.2/packages/react-day-picker)

---
updated-dependencies:
- dependency-name: react-day-picker
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:55 -04:00
dependabot[bot] d703f8acf3 chore(deps-dev): bump @vitest/ui from 4.1.6 to 4.1.8 (#12631)
Bumps [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) from 4.1.6 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/ui)

---
updated-dependencies:
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:40 -04:00
dependabot[bot] 1c23cbec1b chore(deps): bump @simplewebauthn/server from 13.3.0 to 13.3.1 (#12633)
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.0 to 13.3.1.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:16 -04:00
dependabot[bot] 1caeafaeed chore(deps-dev): bump discord-api-types from 0.38.46 to 0.38.48 (#12634)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.46 to 0.38.48.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.46...0.38.48)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.48
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:59 -04:00
dependabot[bot] 969a7bb97d chore(deps-dev): bump prettier from 3.7.4 to 3.8.3 (#12635)
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:38 -04:00
Tom Moor 053693b9d5 fix: Spurious post-import edits (#12620) 2026-06-07 21:13:23 -04:00
Tom Moor 7938ffdd7a Restore SidebarButton position default to fix top padding regression (#12618)
The position prop is omitted at several call sites (App, Settings,
Shared sidebars) and relied on the top default for inset-titlebar
padding. Make the prop optional and restore the default so the lint
rule stays satisfied without changing runtime behavior.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 17:24:45 -04:00
Tom Moor 9b8acf3efb Remove unnecessary default parameter values from function signatures (#12617)
* Fix remaining no-useless-default-assignment lint warnings

* Promote no-useless-default-assignment lint rule to error
2026-06-07 15:46:01 -04:00
Tom Moor ac6b680cdb fix: notice query string consumed on unauthenticated routes 2026-06-07 14:22:26 -04:00
Tom Moor 27c633eb8b fix: Code blocks auto-collapse while editing (#12616) 2026-06-07 13:29:41 -04:00
Tom Moor ca36451e42 Improve handling of non-HD Google logins from root domain (#12615) 2026-06-07 13:16:25 -04:00
Tom Moor 0d198294eb fix: Improve patch merging of links in table cells (#12614)
* fix: Improve merging of links in table cells through patch

* PR feedback
2026-06-07 12:09:44 -04:00
Tom Moor bc63aba1d1 fix: place cursor at start of inserted table row/column (#12610)
* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* feat: Inline editor menu (#12611)

* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* Add handling for After variants
Add lint rule

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 11:58:48 -04:00
Tom Moor ea665b80ee feat: Inline editor menu (#12611)
* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:57:34 -04:00
Tom Moor 492af6683b Add document restore functionality to MCP tools (#12575)
* Add restore_document MCP tool and archived/trashed listing

Closes the delete/restore asymmetry in the MCP server: previously documents
could be archived or trashed via delete_document but never recovered.

- Add restore_document tool to recover archived or trashed documents,
  optionally into a different collection.
- Add a status option ("archived" | "trashed") to list_documents so agents
  can discover what to restore.
- Extract the documents.restore route logic into a shared documentRestorer
  command, used by both the REST endpoint and the MCP tool.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Use type-only import for Document in documentRestorer

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Revert archived/trashed status option on list_documents

Keeps the restore_document tool and shared documentRestorer command;
removes the list_documents status filter and its tests.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 15:08:52 -04:00
Tom Moor f4b80d5301 fix: PDF display does not correctly scale on ombile (#12608) 2026-06-06 10:14:35 -04:00
Tom Moor 58f0613b5f Delete .github/workflows/docker-build-check.yml 2026-06-06 09:30:50 -04:00
92 changed files with 2990 additions and 753 deletions
+5
View File
@@ -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 ––––––––––
-41
View File
@@ -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
View File
@@ -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",
{
+22 -2
View File
@@ -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
+2 -2
View File
@@ -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} />
+1 -1
View File
@@ -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 {
+63
View File
@@ -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;
};
+1 -1
View File
@@ -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`
+37 -5
View File
@@ -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 />
+190
View File
@@ -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 -1
View File
@@ -47,7 +47,7 @@ type Props = Omit<
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
function MentionMenu({ search = "", isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
+12 -1
View File
@@ -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}
+1 -1
View File
@@ -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;
+6 -63
View File
@@ -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,
};
}
+42 -11
View File
@@ -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: () => {
+2 -1
View File
@@ -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,
};
};
+2 -6
View File
@@ -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 -1
View File
@@ -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),
},
+1 -1
View File
@@ -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"
-2
View File
@@ -124,8 +124,6 @@ export default function blockMenuItems(
keywords: "pdf upload attach",
attrs: {
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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 />,
},
];
}
+65 -66
View File
@@ -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 />,
},
];
}
+2
View File
@@ -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);
-2
View File
@@ -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 (
@@ -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);
+1 -1
View File
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
: window.location.origin;
type PathParams = {
shareId: string;
shareId?: string;
collectionSlug?: string;
documentSlug?: string;
};
+7 -5
View File
@@ -95,6 +95,7 @@
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
@@ -103,7 +104,7 @@
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.2.3",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/form-data": "^2.5.2",
@@ -213,7 +214,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-colorful": "^5.7.0",
"react-day-picker": "^8.10.1",
"react-day-picker": "^8.10.2",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
@@ -223,6 +224,7 @@
"react-i18next": "^12.3.1",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-remove-scroll": "^2.7.2",
"react-router-dom": "^5.3.4",
"react-use-measure": "^2.1.7",
"react-virtualized-auto-sizer": "^1.0.26",
@@ -349,14 +351,14 @@
"@types/validator": "^13.15.10",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"@vitest/ui": "^4.1.6",
"@vitest/ui": "^4.1.8",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.46",
"discord-api-types": "^0.38.48",
"husky": "^8.0.3",
"i18next-parser": "^9.4.0",
"ioredis-mock": "^8.13.1",
@@ -366,7 +368,7 @@
"oxlint": "1.66.0",
"oxlint-tsgolint": "0.22.1",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"prettier": "^3.8.3",
"react-refresh": "^0.18.0",
"rimraf": "^6.1.3",
"rollup-plugin-webpack-stats": "2.1.11",
+24 -10
View File
@@ -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);
+20 -9
View File
@@ -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
+1 -1
View File
@@ -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,
+98
View File
@@ -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);
+78
View File
@@ -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");
});
});
});
+10
View File
@@ -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,
});
},
};
+12 -2
View File
@@ -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
+10 -8
View File
@@ -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);
}
/**
+1 -1
View File
@@ -17,7 +17,7 @@ export function presentDCRClient(
baseUrl: string,
oauthClient: OAuthClient,
{
includeRegistrationAccessToken = false,
includeRegistrationAccessToken,
includeCredentials = false,
}: {
includeRegistrationAccessToken: boolean;
+48 -32
View File
@@ -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 });
+44 -33
View File
@@ -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>;
+1 -1
View File
@@ -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(() => {
+2 -59
View File
@@ -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),
+5 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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 -2
View File
@@ -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);
+175
View File
@@ -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);
});
});
+92 -4
View File
@@ -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);
}
})
);
}
}
+29
View File
@@ -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
View File
@@ -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}`);
}
+115
View File
@@ -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);
});
});
+134
View File
@@ -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);
}
}
)
);
}
}
+35
View File
@@ -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
View File
@@ -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)) {
+88 -2
View File
@@ -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;
+112
View File
@@ -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");
});
});
+8 -1
View File
@@ -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 } : {});
}
}
+1
View File
@@ -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}
+21 -16
View File
@@ -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;
+6 -30
View File
@@ -596,7 +596,7 @@ width: 100%;
padding: ${props.editorStyle?.padding ?? "initial"};
margin: ${props.editorStyle?.margin ?? "initial"};
& > .ProseMirror-yjs-cursor {
& > .${EditorStyleHelper.multiplayerCursor} {
display: none;
}
@@ -670,11 +670,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 +682,7 @@ width: 100%;
border-right: 1px solid black;
height: 1em;
word-break: normal;
user-select: none;
&::after {
content: "";
@@ -719,7 +720,7 @@ width: 100%;
}
}
&.show-cursor-names .ProseMirror-yjs-cursor > div {
&.show-cursor-names .${EditorStyleHelper.multiplayerCursor} > div {
opacity: 1;
}
@@ -822,31 +823,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 +999,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(
+11 -37
View File
@@ -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,
},
}),
];
+12
View File
@@ -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);
};
+95 -1
View File
@@ -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();
});
});
+76
View File
@@ -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;
}
+60 -21
View File
@@ -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");
}
});
+77
View File
@@ -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);
});
});
+53
View File
@@ -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())));
}
+2 -2
View File
@@ -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);
});
+8 -4
View File
@@ -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
+61 -22
View File
@@ -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
+1 -1
View File
@@ -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);
});
+4 -4
View File
@@ -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,
+4 -1
View File
@@ -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));
}
+4 -1
View File
@@ -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));
}
+13 -6
View File
@@ -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")),
}),
};
}
+9 -26
View File
@@ -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");
});
+50
View File
@@ -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);
});
});
+5 -2
View File
@@ -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;
@@ -22,6 +22,14 @@ export class EditorStyleHelper {
static readonly comment = "comment-marker";
// Multiplayer
/** Remote collaborator's cursor */
static readonly multiplayerCursor = "ProseMirror-yjs-cursor";
/** Remote collaborator's selection */
static readonly multiplayerSelection = "ProseMirror-yjs-selection";
// Code
static readonly codeBlock = "code-block";
+14
View File
@@ -17,6 +17,14 @@ export enum TableLayout {
fullWidth = "full-width",
}
/** How a selection toolbar menu is presented. */
export enum MenuType {
/** A horizontal strip of buttons; nested options open behind a trigger. */
toolbar = "toolbar",
/** A vertical menu rendered directly, anchored to the selection. */
inline = "inline",
}
type Section = ({ t }: { t: TFunction }) => string;
export type MenuItem = {
@@ -140,6 +148,12 @@ export interface SelectionToolbarMenuDescriptor {
priority: number;
/** Toolbar alignment when this menu is active. Defaults to "center". */
align?: "center" | "start" | "end";
/**
* How the menu is presented. "toolbar" (default) renders a horizontal strip
* of buttons; "inline" renders a vertical menu anchored to the selection
* without requiring a trigger button.
*/
variant?: MenuType;
/**
* Returns the menu items to display for the current selection.
*
+5 -1
View File
@@ -567,6 +567,7 @@
"Replacement": "Replacement",
"Replace": "Replace",
"Replace all": "Replace all",
"Options": "Options",
"Go to link": "Go to link",
"Open link": "Open link",
"Remove link": "Remove link",
@@ -647,10 +648,13 @@
"Edit image URL": "Edit image URL",
"Default width": "Default width",
"Distribute columns": "Distribute columns",
"Delete table": "Delete table",
"Export as CSV": "Export as CSV",
"Delete table": "Delete table",
"Align": "Align",
"Sort": "Sort",
"Sort ascending": "Sort ascending",
"Sort descending": "Sort descending",
"Background": "Background",
"Toggle header": "Toggle header",
"Insert after": "Insert after",
"Insert before": "Insert before",
+32
View File
@@ -238,3 +238,35 @@ export function doc(
) {
return schema.nodes.doc.create(null, content);
}
/**
* A plain-object representation of a ProseMirror node, as returned by
* `Node.toJSON()`.
*/
export interface JSONNode {
type: string;
content?: JSONNode[];
attrs?: Record<string, unknown>;
text?: string;
}
/**
* Recursively collects all nodes of the given type from a `Node.toJSON()`
* tree, including the root node itself.
*
* @param node - the JSON node to search, may be undefined for convenience.
* @param type - the node type name to match.
* @returns array of matching nodes in document order.
*/
export function findNodes(
node: JSONNode | undefined,
type: string
): JSONNode[] {
if (!node) {
return [];
}
return [
...(node.type === type ? [node] : []),
...(node.content ?? []).flatMap((child) => findNodes(child, type)),
];
}
+44 -42
View File
@@ -4817,7 +4817,7 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.2.4":
"@radix-ui/react-slot@npm:1.2.4, @radix-ui/react-slot@npm:^1.2.3":
version: 1.2.4
resolution: "@radix-ui/react-slot@npm:1.2.4"
dependencies:
@@ -5887,9 +5887,9 @@ __metadata:
languageName: node
linkType: hard
"@simplewebauthn/server@npm:^13.2.3":
version: 13.3.0
resolution: "@simplewebauthn/server@npm:13.3.0"
"@simplewebauthn/server@npm:^13.3.1":
version: 13.3.1
resolution: "@simplewebauthn/server@npm:13.3.1"
dependencies:
"@hexagon/base64": "npm:^1.1.27"
"@levischuck/tiny-cbor": "npm:^0.2.2"
@@ -5899,7 +5899,7 @@ __metadata:
"@peculiar/asn1-schema": "npm:^2.6.0"
"@peculiar/asn1-x509": "npm:^2.6.1"
"@peculiar/x509": "npm:^1.14.3"
checksum: 10c0/ff85d4e6b54708ae2ea3be0dd4aa91cb9e27299281c755711caf388e0845e060bbf52b47a1ce6629faba8f5a2182c291bee22954471bbeaa8613a92738232907
checksum: 10c0/843f9f4e80bfcf389acb3ba6af48f87937e1de1d87c975a77d92ab1309c4619f3cd8eecaf50499d5355c05476506c35aaf3c7a76a4c4e662a1e3721b06656239
languageName: node
linkType: hard
@@ -7601,12 +7601,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.6":
version: 4.1.6
resolution: "@vitest/pretty-format@npm:4.1.6"
"@vitest/pretty-format@npm:4.1.8":
version: 4.1.8
resolution: "@vitest/pretty-format@npm:4.1.8"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10c0/f818a6abff9b7cf642edc2d0fe84d4f124911696bc7591f2af9ab6d88685b72133a1e9f87499e9b4dc2314dff85403ea66c64f7b408b2eb39f9880c6d3517ca0
checksum: 10c0/553c456692a4b9ae13cd116c234c74b4495e0f1a0d5c51ffc3fab8ea085e3550769967e29db79bdac0cf127b1bf88b7f70bfba3dcc72be6bddf834433e30cc91
languageName: node
linkType: hard
@@ -7639,11 +7639,11 @@ __metadata:
languageName: node
linkType: hard
"@vitest/ui@npm:^4.1.6":
version: 4.1.6
resolution: "@vitest/ui@npm:4.1.6"
"@vitest/ui@npm:^4.1.8":
version: 4.1.8
resolution: "@vitest/ui@npm:4.1.8"
dependencies:
"@vitest/utils": "npm:4.1.6"
"@vitest/utils": "npm:4.1.8"
fflate: "npm:^0.8.2"
flatted: "npm:^3.4.2"
pathe: "npm:^2.0.3"
@@ -7651,8 +7651,8 @@ __metadata:
tinyglobby: "npm:^0.2.15"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
vitest: 4.1.6
checksum: 10c0/4e3a7416862feafe1ba2b0ca140bead49a2549ed189b2d42b8ea0bbd8656e103783b192151c0b9986861bc5f05e0fd33626c46e1f7572e8da068b85bd6a773ea
vitest: 4.1.8
checksum: 10c0/c2b559c9633df6d9019cf52b55f1deffa11f12500c124bf0a4fb47991e26d92d42ad549ef434e714dbecf1469b630764d264f78dd0518fbd472e81f43437390e
languageName: node
linkType: hard
@@ -7667,14 +7667,14 @@ __metadata:
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.6":
version: 4.1.6
resolution: "@vitest/utils@npm:4.1.6"
"@vitest/utils@npm:4.1.8":
version: 4.1.8
resolution: "@vitest/utils@npm:4.1.8"
dependencies:
"@vitest/pretty-format": "npm:4.1.6"
"@vitest/pretty-format": "npm:4.1.8"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10c0/36437888088a1aae8565e62b9f145de9fb1599725574924477c655c7617ad677b575ac0eb3f2b3288854ed1aafff914a0417dffbb7f5244c821f157119701227
checksum: 10c0/acda9d3d640c1ebc81afb358ac30589d7d7d583af81e2d09419f0af9cbe41f3ce0b90527326943bf0da51614be5fc31afcd32259f6beb32b3417999d6ef380f3
languageName: node
linkType: hard
@@ -10045,10 +10045,10 @@ __metadata:
languageName: node
linkType: hard
"discord-api-types@npm:^0.38.46":
version: 0.38.46
resolution: "discord-api-types@npm:0.38.46"
checksum: 10c0/a8b3d6bae79c33f02bef2892fdb23d139ce0139dc4a2cc2c0a3d1dc0c540685136a3c0f5a0ea5cb2f505c934caaeaca57a0def12d6ff1a98e36c6e348120d9ab
"discord-api-types@npm:^0.38.48":
version: 0.38.48
resolution: "discord-api-types@npm:0.38.48"
checksum: 10c0/898e57378e6e30987d072f88d4d1c08c5e01f1b534c3c3390a117f1a79a9107e766fd3aaf8728c0afbd934f4dc63e76b1f458f7096d54409a68ae9f848b7c564
languageName: node
linkType: hard
@@ -15087,6 +15087,7 @@ __metadata:
"@radix-ui/react-one-time-password-field": "npm:^0.1.8"
"@radix-ui/react-popover": "npm:^1.1.15"
"@radix-ui/react-select": "npm:^2.2.6"
"@radix-ui/react-slot": "npm:^1.2.3"
"@radix-ui/react-switch": "npm:^1.2.6"
"@radix-ui/react-tabs": "npm:^1.1.13"
"@radix-ui/react-toolbar": "npm:^1.1.11"
@@ -15096,7 +15097,7 @@ __metadata:
"@sentry/node": "npm:^7.120.4"
"@sentry/react": "npm:^7.120.4"
"@simplewebauthn/browser": "npm:^13.3.0"
"@simplewebauthn/server": "npm:^13.2.3"
"@simplewebauthn/server": "npm:^13.3.1"
"@swc/core": "npm:^1.15.32"
"@tanstack/react-table": "npm:^8.21.3"
"@tanstack/react-virtual": "npm:^3.13.24"
@@ -15168,7 +15169,7 @@ __metadata:
"@types/yauzl": "npm:^2.10.3"
"@types/yazl": "npm:^2.4.6"
"@vitejs/plugin-react-oxc": "npm:^0.2.3"
"@vitest/ui": "npm:^4.1.6"
"@vitest/ui": "npm:^4.1.8"
addressparser: "npm:^1.0.1"
async-sema: "npm:^3.1.1"
babel-plugin-module-resolver: "npm:^5.0.3"
@@ -15189,7 +15190,7 @@ __metadata:
date-fns: "npm:^3.6.0"
dd-trace: "npm:^5.98.0"
diff: "npm:^5.2.2"
discord-api-types: "npm:^0.38.46"
discord-api-types: "npm:^0.38.48"
email-providers: "npm:^1.14.0"
emoji-mart: "npm:^5.6.0"
emoji-regex: "npm:^10.6.0"
@@ -15266,7 +15267,7 @@ __metadata:
png-chunks-extract: "npm:^1.0.0"
polished: "npm:^4.3.1"
postinstall-postinstall: "npm:^2.1.0"
prettier: "npm:^3.6.2"
prettier: "npm:^3.8.3"
prosemirror-changeset: "npm:2.4.1"
prosemirror-codemark: "npm:^0.4.2"
prosemirror-commands: "npm:^1.7.1"
@@ -15288,7 +15289,7 @@ __metadata:
react: "npm:^17.0.2"
react-avatar-editor: "npm:^13.0.2"
react-colorful: "npm:^5.7.0"
react-day-picker: "npm:^8.10.1"
react-day-picker: "npm:^8.10.2"
react-dnd: "npm:^16.0.1"
react-dnd-html5-backend: "npm:^16.0.1"
react-dom: "npm:^17.0.2"
@@ -15299,6 +15300,7 @@ __metadata:
react-merge-refs: "npm:^2.1.1"
react-portal: "npm:^4.3.0"
react-refresh: "npm:^0.18.0"
react-remove-scroll: "npm:^2.7.2"
react-router-dom: "npm:^5.3.4"
react-use-measure: "npm:^2.1.7"
react-virtualized-auto-sizer: "npm:^1.0.26"
@@ -16224,12 +16226,12 @@ __metadata:
languageName: node
linkType: hard
"prettier@npm:^3.6.2":
version: 3.7.4
resolution: "prettier@npm:3.7.4"
"prettier@npm:^3.8.3":
version: 3.8.3
resolution: "prettier@npm:3.8.3"
bin:
prettier: bin/prettier.cjs
checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389
checksum: 10c0/754816fd7593eb80f6376d7476d463e832c38a12f32775a82683adb6e35b772b1f484d65f19401507b983a8c8a7cd5a4a9f12006bd56491e8f35503473f77473
languageName: node
linkType: hard
@@ -16680,13 +16682,13 @@ __metadata:
languageName: node
linkType: hard
"react-day-picker@npm:^8.10.1":
version: 8.10.1
resolution: "react-day-picker@npm:8.10.1"
"react-day-picker@npm:^8.10.2":
version: 8.10.2
resolution: "react-day-picker@npm:8.10.2"
peerDependencies:
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/a0ff28c4b61b3882e6a825b19e5679e2fdf3256cf1be8eb0a0c028949815c1ae5a6561474c2c19d231c010c8e0e0b654d3a322610881e0655abca05a2e03d9df
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/701fe6dc0cc2de1430ddbf976c3a9e1f00b8b9970f1d11364047238d34e5f60f8089f505c8d9ec77faeef861b06a3b7b8ed6cc5d11d454abe46fe6b3a46270eb
languageName: node
linkType: hard
@@ -16853,7 +16855,7 @@ __metadata:
languageName: node
linkType: hard
"react-remove-scroll@npm:^2.6.3":
"react-remove-scroll@npm:^2.6.3, react-remove-scroll@npm:^2.7.2":
version: 2.7.2
resolution: "react-remove-scroll@npm:2.7.2"
dependencies:
@@ -18095,9 +18097,9 @@ __metadata:
linkType: hard
"shell-quote@npm:^1.8.1":
version: 1.8.3
resolution: "shell-quote@npm:1.8.3"
checksum: 10c0/bee87c34e1e986cfb4c30846b8e6327d18874f10b535699866f368ade11ea4ee45433d97bf5eada22c4320c27df79c3a6a7eb1bf3ecfc47f2c997d9e5e2672fd
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
checksum: 10c0/86c93678bc394cb81f5ddcdc87df9c95d279ef9652775cd1cd1eed361404169a8d8cbaacaeed232ab09919e36ee1e5363863570390d78571f8c22b7f6312fb40
languageName: node
linkType: hard