mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* fix: Remap internal links when duplicating a document tree Duplicating a document tree now assigns the identifiers of every duplicate before any content is written, so links and mentions between the documents being duplicated point at the copies rather than the originals. * fix: Remap fully qualified links when duplicating a document tree Links written as a full url to this installation are now remapped alongside relative ones, and stay fully qualified. Where a link is displayed as its own url the text is updated to match. Also trims surrounding whitespace in sanitizeUrl, which otherwise failed validation and prepended a second scheme to an already qualified url. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Remap links across trees when duplicating a collection The collection duplication task duplicated each root document separately, so only links within a single tree were remapped. It now duplicates the whole collection as one unit, with identifiers assigned across every tree before any content is written. * fix: Match document links by identifier rather than host when duplicating An installation can be reached through more than one host, so comparing a link's host against the configured url left fully qualified links to a document in the duplicated set unmapped. The document a link identifies now decides whether it is replaced, and the host it was written with is kept. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1528 lines
46 KiB
TypeScript
1528 lines
46 KiB
TypeScript
import emojiRegex from "emoji-regex";
|
|
import type { JSDOM } from "jsdom";
|
|
import { chunk, isMatch } from "es-toolkit/compat";
|
|
import { EditorState, type Plugin } from "prosemirror-state";
|
|
import {
|
|
DecorationSet,
|
|
EditorView,
|
|
type DecorationSource,
|
|
} from "prosemirror-view";
|
|
import { Node, Fragment } from "prosemirror-model";
|
|
import { renderToString } from "react-dom/server";
|
|
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
|
|
import {
|
|
prosemirrorToYDoc,
|
|
updateYFragment,
|
|
yDocToProsemirrorJSON,
|
|
} from "y-prosemirror";
|
|
import * as Y from "yjs";
|
|
import { toError, errToString } from "@shared/utils/error";
|
|
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";
|
|
import type { ProsemirrorData, UnfurlResponse } from "@shared/types";
|
|
import { AttachmentPreset, MentionType } from "@shared/types";
|
|
import {
|
|
attachmentRedirectRegex,
|
|
ProsemirrorHelper as SharedProsemirrorHelper,
|
|
} from "@shared/utils/ProsemirrorHelper";
|
|
|
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
|
import { isRTL } from "@shared/utils/rtl";
|
|
import { UrlHelper } from "@shared/utils/UrlHelper";
|
|
import { isInternalUrl } from "@shared/utils/urls";
|
|
import attachmentCreator from "@server/commands/attachmentCreator";
|
|
import { plugins, schema, parser } from "@server/editor";
|
|
import env from "@server/env";
|
|
import { ValidationError } from "@server/errors";
|
|
import Logger from "@server/logging/Logger";
|
|
import { trace } from "@server/logging/tracing";
|
|
import Attachment from "@server/models/Attachment";
|
|
import User from "@server/models/User";
|
|
import FileStorage from "@server/storage/files";
|
|
import type { APIContext } from "@server/types";
|
|
|
|
export type HTMLOptions = {
|
|
/** A title, if it should be included */
|
|
title?: string;
|
|
/** Whether to include style tags in the generated HTML (defaults to true) */
|
|
includeStyles?: boolean;
|
|
/** Whether to include mermaidjs scripts in the generated HTML (defaults to false) */
|
|
includeMermaid?: boolean;
|
|
/** Whether to include head tags in the generated HTML (defaults to true) */
|
|
includeHead?: boolean;
|
|
/** Whether to include styles to center diff (defaults to true) */
|
|
centered?: boolean;
|
|
/** The base URL to use for relative links */
|
|
baseUrl?: string;
|
|
/** Changes to highlight in the document */
|
|
changes?: readonly ExtendedChange[];
|
|
/** CSP nonce to apply to injected inline scripts */
|
|
cspNonce?: string;
|
|
};
|
|
|
|
/** The identifiers of a document that another document's content may link to. */
|
|
export type DocumentReference = {
|
|
/** The id of the document */
|
|
id: string;
|
|
/** The path to the document */
|
|
path: string;
|
|
};
|
|
|
|
export type MentionAttrs = {
|
|
type: MentionType;
|
|
label: string;
|
|
modelId: string;
|
|
actorId: string | undefined;
|
|
id: string;
|
|
href?: string;
|
|
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
|
};
|
|
|
|
const pluginsWithSafeDecorations = new WeakSet<Plugin>();
|
|
|
|
// KaTeX renders math server-side during HTML export, but relies on this
|
|
// stylesheet (loaded dynamically in the app) to position glyphs correctly.
|
|
const katexStylesheetUrl =
|
|
"https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css";
|
|
|
|
function isDecorationSource(value: unknown): value is DecorationSource {
|
|
if (typeof value !== "object" || value === null) {
|
|
return false;
|
|
}
|
|
|
|
if (!("forChild" in value) || typeof value.forChild !== "function") {
|
|
return false;
|
|
}
|
|
|
|
if ("members" in value && Array.isArray(value.members)) {
|
|
return value.members.every(
|
|
(member) =>
|
|
typeof member === "object" &&
|
|
member !== null &&
|
|
"localsInner" in member &&
|
|
typeof member.localsInner === "function"
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
@trace()
|
|
export class ProsemirrorHelper extends SharedProsemirrorHelper {
|
|
/**
|
|
* Maximum amount of visible text, in characters, to grow a mention email
|
|
* snippet outward when climbing toward surrounding context.
|
|
*/
|
|
static readonly mentionEmailMaxChars = 1000;
|
|
|
|
/**
|
|
* Returns the input text as a Y.Doc.
|
|
*
|
|
* @param markdown The text to parse
|
|
* @returns The content as a Y.Doc.
|
|
*/
|
|
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
|
|
const node =
|
|
typeof input === "object"
|
|
? ProsemirrorHelper.toProsemirror(input)
|
|
: parser.parse(input);
|
|
if (!node) {
|
|
return 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);
|
|
}
|
|
|
|
/**
|
|
* Returns the input Y.Doc encoded as a YJS state update.
|
|
*
|
|
* @param ydoc The Y.Doc to encode
|
|
* @returns The content as a YJS state update
|
|
*/
|
|
static toState(ydoc: Y.Doc) {
|
|
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
|
|
}
|
|
|
|
/**
|
|
* Converts a plain object or Markdown string into a Prosemirror Node.
|
|
*
|
|
* @param data The ProsemirrorData object or string to parse.
|
|
* @returns The content as a Prosemirror Node
|
|
*/
|
|
static toProsemirror(data: ProsemirrorData | string) {
|
|
if (typeof data === "string") {
|
|
return parser.parse(data);
|
|
}
|
|
return Node.fromJSON(schema, data);
|
|
}
|
|
|
|
/**
|
|
* Returns an array of attributes of all mentions in the node.
|
|
*
|
|
* @param node The node to parse mentions from
|
|
* @param options Attributes to use for filtering mentions
|
|
* @returns An array of mention attributes
|
|
*/
|
|
static parseMentions(doc: Node, options?: Partial<MentionAttrs>) {
|
|
const mentions: MentionAttrs[] = [];
|
|
const seenIds = new Set<string>();
|
|
|
|
doc.descendants((node: Node) => {
|
|
if (node.type.name === "mention") {
|
|
if (
|
|
!(options?.type && options.type !== node.attrs.type) &&
|
|
!(options?.modelId && options.modelId !== node.attrs.modelId) &&
|
|
!seenIds.has(node.attrs.id)
|
|
) {
|
|
seenIds.add(node.attrs.id);
|
|
mentions.push(node.attrs as MentionAttrs);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (!node.content.size) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return mentions;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of document IDs referenced through links or mentions in the node.
|
|
*
|
|
* @param node The node to parse document IDs from
|
|
* @returns An array of document IDs
|
|
*/
|
|
static parseDocumentIds(doc: Node) {
|
|
const seen = new Set<string>();
|
|
const identifiers: string[] = [];
|
|
|
|
doc.descendants((node: Node) => {
|
|
if (
|
|
node.type.name === "mention" &&
|
|
node.attrs.type === MentionType.Document &&
|
|
!seen.has(node.attrs.modelId)
|
|
) {
|
|
seen.add(node.attrs.modelId);
|
|
identifiers.push(node.attrs.modelId);
|
|
return true;
|
|
}
|
|
|
|
if (node.type.name === "text") {
|
|
for (const mark of node.marks) {
|
|
if (mark.type.name === "link") {
|
|
const slug = parseDocumentSlug(mark.attrs.href);
|
|
|
|
if (slug && !seen.has(slug)) {
|
|
seen.add(slug);
|
|
identifiers.push(slug);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node.content.size) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return identifiers;
|
|
}
|
|
|
|
/**
|
|
* Build an email snippet around a mention. A surrounding list is trimmed to
|
|
* the mentioned item plus one sibling on either side, a table to the
|
|
* mentioned row; otherwise the largest complete block that still fits the
|
|
* size budget is kept.
|
|
*
|
|
* @param doc The top-level doc node of a document / revision.
|
|
* @param mention The mention to build the snippet around.
|
|
* @returns A new top-level doc node with the chosen block as its only child,
|
|
* or undefined if the mention could not be found.
|
|
*/
|
|
static getNodeForMentionEmail(doc: Node, mention: MentionAttrs) {
|
|
// A mention is an inline node, so it always lives inside a textblock
|
|
// (paragraph or heading). Locate it once and resolve its position.
|
|
let mentionPos: number | undefined;
|
|
doc.descendants((node: Node, pos: number) => {
|
|
if (mentionPos !== undefined) {
|
|
return false;
|
|
}
|
|
if (node.type.name === "mention" && isMatch(node.attrs, mention)) {
|
|
mentionPos = pos;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (mentionPos === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const $pos = doc.resolve(mentionPos);
|
|
|
|
// Lists and tables can be long, so rather than show the whole container,
|
|
// trim a list to the mentioned item plus one sibling on either side, and a
|
|
// table to the mentioned row. Use the nearest such ancestor so nested
|
|
// structures show the content closest to the mention.
|
|
const listTypes = ["bullet_list", "ordered_list", "checkbox_list"];
|
|
for (let d = $pos.depth - 1; d >= 1; d--) {
|
|
const container = $pos.node(d);
|
|
const name = container.type.name;
|
|
|
|
if (listTypes.includes(name)) {
|
|
const index = $pos.index(d);
|
|
const start = Math.max(0, index - 1);
|
|
const end = Math.min(container.childCount, index + 2);
|
|
const items: Node[] = [];
|
|
for (let i = start; i < end; i++) {
|
|
items.push(container.child(i));
|
|
}
|
|
// Keep an ordered list's numbering aligned with the original document
|
|
// by advancing its start to match the first item shown.
|
|
const attrs =
|
|
name === "ordered_list" && start > 0
|
|
? {
|
|
...container.attrs,
|
|
order: (container.attrs.order ?? 1) + start,
|
|
}
|
|
: container.attrs;
|
|
const trimmed = container.type.create(
|
|
attrs,
|
|
Fragment.fromArray(items),
|
|
container.marks
|
|
);
|
|
return doc.copy(Fragment.fromArray([trimmed]));
|
|
}
|
|
|
|
if (name === "table") {
|
|
const row = container.child($pos.index(d));
|
|
return doc.copy(
|
|
Fragment.fromArray([container.copy(Fragment.fromArray([row]))])
|
|
);
|
|
}
|
|
}
|
|
|
|
// Always include at least the textblock the mention sits in, then climb the
|
|
// ancestor chain outward keeping the largest complete block that still fits
|
|
// the size budget. Each ancestor strictly contains the previous, so sizes
|
|
// grow monotonically and we can stop at the first that overflows.
|
|
let node = $pos.node($pos.depth);
|
|
for (let d = $pos.depth - 1; d >= 1; d--) {
|
|
const ancestor = $pos.node(d);
|
|
// textBetween rather than textContent so leaf text (mentions, etc.) is
|
|
// counted towards the budget.
|
|
const length = textBetween(ancestor, 0, ancestor.content.size).length;
|
|
if (length > ProsemirrorHelper.mentionEmailMaxChars) {
|
|
break;
|
|
}
|
|
node = ancestor;
|
|
}
|
|
|
|
// Return a new top-level "doc" node to maintain structure during serialization.
|
|
return doc.copy(Fragment.fromArray([node]));
|
|
}
|
|
|
|
/**
|
|
* Replaces links and mentions that point to the given documents so that they
|
|
* point to their replacements instead. A link is matched on the document it
|
|
* identifies rather than its host, as an installation can be reached through
|
|
* more than one, and a fully qualified link keeps the host it was written
|
|
* with.
|
|
*
|
|
* @param doc The prosemirror document or JSON data.
|
|
* @param documents A map keyed by both the id and urlId of each document to
|
|
* replace, with the id and path of its replacement.
|
|
* @returns The document data with references replaced.
|
|
*/
|
|
static replaceDocumentReferences(
|
|
doc: Node | ProsemirrorData,
|
|
documents: Map<string, DocumentReference>
|
|
): ProsemirrorData {
|
|
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
|
|
|
|
function replaceHref(href: string) {
|
|
let origin = "";
|
|
let path = href;
|
|
|
|
if (!href.startsWith("/")) {
|
|
try {
|
|
const url = new URL(href);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return href;
|
|
}
|
|
origin = url.origin;
|
|
path = `${url.pathname}${url.search}${url.hash}`;
|
|
} catch (_err) {
|
|
return href;
|
|
}
|
|
}
|
|
|
|
const match = /^\/doc\/([^/?#]+)(.*)$/.exec(path);
|
|
if (!match) {
|
|
return href;
|
|
}
|
|
|
|
// The identifier is either a document id or a `<slug>-<urlId>` pair.
|
|
const slug = UrlHelper.SLUG_URL_REGEX.exec(match[1]);
|
|
const replacement =
|
|
documents.get(match[1]) ?? (slug ? documents.get(slug[1]) : undefined);
|
|
|
|
return replacement ? `${origin}${replacement.path}${match[2]}` : href;
|
|
}
|
|
|
|
function replaceDocumentReferencesInner(node: ProsemirrorData) {
|
|
if (
|
|
node.type === "mention" &&
|
|
node.attrs?.type === MentionType.Document &&
|
|
typeof node.attrs.modelId === "string"
|
|
) {
|
|
const replacement = documents.get(node.attrs.modelId);
|
|
if (replacement) {
|
|
node.attrs.modelId = replacement.id;
|
|
}
|
|
}
|
|
|
|
node.marks?.forEach((mark) => {
|
|
if (mark.type === "link" && typeof mark.attrs?.href === "string") {
|
|
const href = replaceHref(mark.attrs.href);
|
|
|
|
// A link that has the url as its own text is displayed as the url,
|
|
// so the two are kept in sync.
|
|
if (node.text === mark.attrs.href) {
|
|
node.text = href;
|
|
}
|
|
|
|
mark.attrs.href = href;
|
|
}
|
|
});
|
|
|
|
node.content?.forEach(replaceDocumentReferencesInner);
|
|
|
|
return node;
|
|
}
|
|
|
|
return replaceDocumentReferencesInner(json);
|
|
}
|
|
|
|
static async replaceInternalUrls(
|
|
doc: Node | ProsemirrorData,
|
|
basePath: string
|
|
) {
|
|
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
|
|
|
|
if (basePath.endsWith("/")) {
|
|
throw new Error("internalUrlBase must not end with a slash");
|
|
}
|
|
|
|
function replaceUrl(url: string) {
|
|
// Only replace if the URL starts with /doc/ (or) /collection/ (not already in a share path)
|
|
if (url.startsWith("/doc/") || url.startsWith("/collection/")) {
|
|
return `${basePath}${url}`;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function replaceInternalUrlsInner(node: ProsemirrorData) {
|
|
if (typeof node.attrs?.href === "string") {
|
|
node.attrs.href = replaceUrl(node.attrs.href);
|
|
} else if (node.marks) {
|
|
node.marks.forEach((mark) => {
|
|
if (
|
|
typeof mark.attrs?.href === "string" &&
|
|
isInternalUrl(mark.attrs?.href)
|
|
) {
|
|
mark.attrs.href = replaceUrl(mark.attrs.href);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (node.content) {
|
|
node.content.forEach(replaceInternalUrlsInner);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
return replaceInternalUrlsInner(json);
|
|
}
|
|
|
|
/**
|
|
* Returns the document as a plain JSON object with attachment URLs signed.
|
|
*
|
|
* @param node The node to convert to JSON
|
|
* @param teamId The team ID to use for signing
|
|
* @param expiresIn The number of seconds until the signed URL expires
|
|
* @returns The content as a JSON object
|
|
*/
|
|
static async signAttachmentUrls(doc: Node, teamId: string, expiresIn = 60) {
|
|
const attachmentIds = ProsemirrorHelper.parseAttachmentIds(doc);
|
|
const attachments = await Attachment.findAll({
|
|
where: {
|
|
id: attachmentIds,
|
|
teamId,
|
|
},
|
|
});
|
|
|
|
const mapping = new Map<string, string>();
|
|
|
|
await Promise.all(
|
|
attachments.map(async (attachment) => {
|
|
const signedUrl = await FileStorage.getSignedUrl(
|
|
attachment.key,
|
|
expiresIn
|
|
);
|
|
mapping.set(attachment.redirectUrl, signedUrl);
|
|
})
|
|
);
|
|
|
|
const json = doc.toJSON() as ProsemirrorData;
|
|
|
|
function toRelativeHref(href: string): string | undefined {
|
|
try {
|
|
const url = new URL(href);
|
|
return url.toString().substring(url.origin.length);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getMapping(href: string) {
|
|
const signedUrl = mapping.get(href);
|
|
if (signedUrl) {
|
|
return signedUrl;
|
|
}
|
|
|
|
const relativeHref = toRelativeHref(href);
|
|
if (relativeHref) {
|
|
const signedUrl = mapping.get(relativeHref);
|
|
if (signedUrl) {
|
|
return signedUrl;
|
|
}
|
|
}
|
|
|
|
// Extract attachment ID from URLs that may have extra query params
|
|
// (e.g. /api/attachments.redirect?id=<uuid>&size=2)
|
|
const regex = new RegExp(attachmentRedirectRegex.source, "i");
|
|
const match = regex.exec(relativeHref ?? href);
|
|
if (match?.groups?.id) {
|
|
const canonicalUrl = `/api/attachments.redirect?id=${match.groups.id}`;
|
|
const signedUrl = mapping.get(canonicalUrl);
|
|
if (signedUrl) {
|
|
return signedUrl;
|
|
}
|
|
}
|
|
|
|
return href;
|
|
}
|
|
|
|
function replaceAttachmentUrls(node: ProsemirrorData) {
|
|
if (node.attrs?.src) {
|
|
node.attrs.src = getMapping(node.attrs.src as string);
|
|
} else if (node.attrs?.href) {
|
|
node.attrs.href = getMapping(node.attrs.href as string);
|
|
} else if (node.marks) {
|
|
node.marks.forEach((mark) => {
|
|
if (mark.attrs?.href) {
|
|
mark.attrs.href = getMapping(mark.attrs.href as string);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (node.content) {
|
|
node.content.forEach(replaceAttachmentUrls);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
return replaceAttachmentUrls(json);
|
|
}
|
|
|
|
/**
|
|
* Returns an array of attachment IDs in the node.
|
|
*
|
|
* @param node The node to parse attachments from
|
|
* @returns An array of attachment IDs
|
|
*/
|
|
static parseAttachmentIds(doc: Node) {
|
|
const urls: string[] = [];
|
|
|
|
doc.descendants((node) => {
|
|
for (const mark of node.marks) {
|
|
if (mark.type.name === "link" && mark.attrs.href) {
|
|
urls.push(mark.attrs.href);
|
|
}
|
|
}
|
|
|
|
if (
|
|
(node.type.name === "image" || node.type.name === "video") &&
|
|
node.attrs.src
|
|
) {
|
|
urls.push(node.attrs.src);
|
|
} else if (node.type.name === "attachment" && node.attrs.href) {
|
|
urls.push(node.attrs.href);
|
|
}
|
|
});
|
|
|
|
const ids = new Set<string>();
|
|
for (const url of urls) {
|
|
for (const match of url.matchAll(attachmentRedirectRegex)) {
|
|
if (match.groups?.id) {
|
|
ids.add(match.groups.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...ids];
|
|
}
|
|
|
|
/**
|
|
* Returns the node as HTML. This is a lossy conversion and should only be used
|
|
* for export.
|
|
*
|
|
* @param node The node to convert to HTML
|
|
* @param options Options for the HTML output
|
|
* @returns The content as a HTML string
|
|
*/
|
|
public static async toHTML(node: Node, options?: HTMLOptions) {
|
|
let view;
|
|
let cleanupEnv;
|
|
let dom: JSDOM | undefined;
|
|
|
|
// Loaded lazily to keep jsdom off the startup path — only HTML export needs it.
|
|
const { JSDOM } = await import("jsdom");
|
|
|
|
try {
|
|
const sheet = new ServerStyleSheet();
|
|
let html = "";
|
|
let styleTags = "";
|
|
|
|
const Centered = options?.centered
|
|
? styled.article`
|
|
max-width: calc(
|
|
${EditorStyleHelper.documentWidth} +
|
|
${EditorStyleHelper.documentGutter}
|
|
);
|
|
margin: 0 auto;
|
|
padding: 0 1em;
|
|
`
|
|
: "article";
|
|
|
|
const rtl = isRTL(node.textBetween(0, Math.min(node.content.size, 100)));
|
|
const content = <div id="content" className="ProseMirror exported" />;
|
|
const children = (
|
|
<>
|
|
{options?.title && <h1 dir={rtl ? "rtl" : "ltr"}>{options.title}</h1>}
|
|
{options?.includeStyles !== false ? (
|
|
<EditorContainer dir={rtl ? "rtl" : "ltr"} $rtl={rtl} staticHTML>
|
|
{content}
|
|
</EditorContainer>
|
|
) : (
|
|
content
|
|
)}
|
|
</>
|
|
);
|
|
|
|
// First render the containing document which has all the editor styles,
|
|
// global styles, layout and title.
|
|
try {
|
|
html = renderToString(
|
|
sheet.collectStyles(
|
|
<ThemeProvider theme={light}>
|
|
<>
|
|
{options?.includeStyles === false ? (
|
|
<article>{children}</article>
|
|
) : (
|
|
<>
|
|
<GlobalStyles staticHTML />
|
|
<Centered>{children}</Centered>
|
|
</>
|
|
)}
|
|
</>
|
|
</ThemeProvider>
|
|
)
|
|
);
|
|
styleTags = sheet.getStyleTags();
|
|
} catch (error) {
|
|
Logger.error(
|
|
"Failed to render styles on node HTML conversion",
|
|
toError(error)
|
|
);
|
|
} finally {
|
|
sheet.seal();
|
|
}
|
|
|
|
// Render the Prosemirror document using virtual DOM and serialize the
|
|
// result to a string
|
|
dom = new JSDOM(
|
|
`<!DOCTYPE html><meta charset="utf-8">${
|
|
options?.includeStyles === false ? "" : styleTags
|
|
}${html}`
|
|
);
|
|
const doc = dom.window.document;
|
|
const target = doc.getElementById("content");
|
|
|
|
cleanupEnv = this.patchGlobalEnv(dom.window);
|
|
|
|
const diffPlugins = options?.changes
|
|
? new Diff({ changes: options.changes }).plugins
|
|
: [];
|
|
const editorPlugins = [...plugins, ...diffPlugins];
|
|
|
|
for (const plugin of plugins) {
|
|
if (
|
|
!plugin.props.decorations ||
|
|
pluginsWithSafeDecorations.has(plugin)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
plugin.props.decorations = () => DecorationSet.empty;
|
|
pluginsWithSafeDecorations.add(plugin);
|
|
}
|
|
|
|
for (const plugin of diffPlugins) {
|
|
if (
|
|
!plugin.props.decorations ||
|
|
pluginsWithSafeDecorations.has(plugin)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const decorations = plugin.props.decorations.bind(plugin);
|
|
plugin.props.decorations = (state) => {
|
|
const result = decorations(state);
|
|
return isDecorationSource(result) ? result : DecorationSet.empty;
|
|
};
|
|
pluginsWithSafeDecorations.add(plugin);
|
|
}
|
|
|
|
const state = EditorState.create({
|
|
doc: node,
|
|
plugins: editorPlugins,
|
|
schema,
|
|
});
|
|
|
|
view = new EditorView(
|
|
{ mount: target as HTMLElement },
|
|
{
|
|
state,
|
|
editable: () => false,
|
|
}
|
|
);
|
|
|
|
// Convert relative urls to absolute
|
|
if (options?.baseUrl) {
|
|
const elements = doc.querySelectorAll("a[href]");
|
|
for (const el of elements) {
|
|
if ("href" in el && (el.href as string).startsWith("/")) {
|
|
el.href = new URL(el.href as string, options.baseUrl).toString();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject mermaidjs scripts if the document contains mermaid diagrams (supports both "mermaid" and "mermaidjs")
|
|
if (options?.includeMermaid) {
|
|
const mermaidElements = dom.window.document.querySelectorAll(
|
|
`[data-language="mermaid"] pre code, [data-language="mermaidjs"] pre code`
|
|
);
|
|
|
|
// Unwrap <pre> tags to enable Mermaid script to correctly render inner content
|
|
for (const el of mermaidElements) {
|
|
const parent = el.parentNode as HTMLElement;
|
|
if (parent) {
|
|
while (el.firstChild) {
|
|
parent.insertBefore(el.firstChild, el);
|
|
}
|
|
parent.removeChild(el);
|
|
parent.setAttribute("class", "mermaid");
|
|
}
|
|
}
|
|
|
|
const element = dom.window.document.createElement("script");
|
|
element.setAttribute("type", "module");
|
|
|
|
if (options?.cspNonce) {
|
|
element.setAttribute("nonce", options.cspNonce);
|
|
}
|
|
|
|
// Inject Mermaid script
|
|
if (mermaidElements.length) {
|
|
element.innerHTML = `
|
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
|
|
mermaid.registerLayoutLoaders(elkLayouts);
|
|
mermaid.initialize({
|
|
startOnLoad: true,
|
|
fontFamily: "inherit",
|
|
});
|
|
window.status = "ready";
|
|
`;
|
|
} else {
|
|
element.innerHTML = `
|
|
window.status = "ready";
|
|
`;
|
|
}
|
|
|
|
dom.window.document.body.appendChild(element);
|
|
}
|
|
|
|
// Include the KaTeX stylesheet if the document contains rendered math, so
|
|
// that formulas display correctly in the exported HTML/PDF.
|
|
if (doc.querySelector(".katex")) {
|
|
const link = doc.createElement("link");
|
|
link.setAttribute("rel", "stylesheet");
|
|
link.setAttribute("href", katexStylesheetUrl);
|
|
doc.head.appendChild(link);
|
|
}
|
|
|
|
const output = dom.serialize();
|
|
|
|
if (options?.includeHead === false) {
|
|
// replace everything upto and including "<body>"
|
|
const body = "<body>";
|
|
const bodyIndex = output.indexOf(body) + body.length;
|
|
if (bodyIndex !== -1) {
|
|
return output
|
|
.substring(bodyIndex)
|
|
.replace("</body>", "")
|
|
.replace("</html>", "");
|
|
}
|
|
}
|
|
|
|
return output;
|
|
} finally {
|
|
try {
|
|
view?.destroy();
|
|
} catch (err) {
|
|
Logger.error("Error destroying ProseMirror view", toError(err));
|
|
}
|
|
try {
|
|
dom?.window.close();
|
|
} catch (_err) {
|
|
// Best effort, closing the window releases its timers and resources.
|
|
}
|
|
cleanupEnv?.();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes mentions in the Prosemirror data, ensuring that mentions
|
|
* for deleted users are displayed as "@unknown" and updated names are
|
|
* displayed correctly.
|
|
*
|
|
* @param data The ProsemirrorData object to process
|
|
* @returns The processed ProsemirrorData with updated mentions
|
|
*/
|
|
static async processMentions(data: ProsemirrorData | Node) {
|
|
const json = "toJSON" in data ? (data.toJSON() as ProsemirrorData) : data;
|
|
|
|
// First pass: collect all user IDs from mentions
|
|
const userIds: string[] = [];
|
|
|
|
function collectUserIds(node: ProsemirrorData) {
|
|
if (
|
|
node.type === "mention" &&
|
|
node.attrs?.type === MentionType.User &&
|
|
node.attrs?.modelId
|
|
) {
|
|
userIds.push(node.attrs.modelId as string);
|
|
}
|
|
|
|
if (node.content) {
|
|
for (const child of node.content) {
|
|
collectUserIds(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
collectUserIds(json);
|
|
|
|
// Load all users in a single query
|
|
const uniqueUserIds = [...new Set(userIds)];
|
|
const users = uniqueUserIds.length
|
|
? await User.findAll({
|
|
where: {
|
|
id: uniqueUserIds,
|
|
},
|
|
attributes: ["id", "name"],
|
|
})
|
|
: [];
|
|
|
|
// Create a map for quick lookup
|
|
const userMap = new Map();
|
|
users.forEach((user) => {
|
|
userMap.set(user.id, user.name);
|
|
});
|
|
|
|
// Second pass: transform mentions with loaded user data
|
|
function transformMentions(node: ProsemirrorData) {
|
|
if (
|
|
node.type === "mention" &&
|
|
node.attrs?.type === MentionType.User &&
|
|
node.attrs?.modelId
|
|
) {
|
|
const userId = node.attrs.modelId as string;
|
|
node.attrs = {
|
|
...node.attrs,
|
|
label: userMap.get(userId) || "Unknown",
|
|
};
|
|
}
|
|
|
|
if (node.content) {
|
|
for (const child of node.content) {
|
|
transformMentions(child);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
return transformMentions(json);
|
|
}
|
|
|
|
/**
|
|
* Removes the first heading from the document if it is an H1.
|
|
*
|
|
* @param doc The Prosemirror document node.
|
|
* @returns A new document with the first H1 removed, or the original if no H1 found.
|
|
*/
|
|
static removeFirstHeading(doc: Node): Node {
|
|
const firstChild = doc.firstChild;
|
|
|
|
if (
|
|
firstChild &&
|
|
firstChild.type.name === "heading" &&
|
|
firstChild.attrs.level === 1
|
|
) {
|
|
const content: Node[] = [];
|
|
doc.forEach((node, _offset, index) => {
|
|
if (index > 0) {
|
|
content.push(node);
|
|
}
|
|
});
|
|
|
|
// If removing the heading leaves an empty document, return a doc with empty paragraph
|
|
if (content.length === 0) {
|
|
return doc.type.create(null, schema.nodes.paragraph.create());
|
|
}
|
|
|
|
return doc.copy(Fragment.fromArray(content));
|
|
}
|
|
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* Extracts an emoji from the beginning of the document's first text content.
|
|
* If found, returns the emoji and a new document with the emoji removed.
|
|
*
|
|
* @param doc The Prosemirror document node.
|
|
* @returns An object with the extracted emoji (or undefined) and the modified document.
|
|
*/
|
|
static extractEmojiFromStart(doc: Node): { emoji?: string; doc: Node } {
|
|
// Get the text content from the beginning of the document
|
|
let textContent = "";
|
|
let foundTextNode: Node | null = null;
|
|
|
|
doc.descendants((node) => {
|
|
if (foundTextNode) {
|
|
return false;
|
|
}
|
|
if (node.isText && node.text) {
|
|
textContent = node.text;
|
|
foundTextNode = node;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!textContent) {
|
|
return { doc };
|
|
}
|
|
|
|
const regex = emojiRegex();
|
|
const match = regex.exec(textContent.slice(0, 10));
|
|
|
|
if (!match || match.index !== 0) {
|
|
return { doc };
|
|
}
|
|
|
|
const emoji = match[0];
|
|
|
|
// Create a new document with the emoji removed from the text
|
|
const json = doc.toJSON();
|
|
|
|
function removeEmojiFromNode(
|
|
node: ProsemirrorData
|
|
): ProsemirrorData | null {
|
|
if (node.type === "text" && node.text && node.text.startsWith(emoji)) {
|
|
const text = node.text.slice(emoji.length);
|
|
// Removing the emoji can leave an empty text node (e.g. when the text
|
|
// node contained only the emoji). Prosemirror disallows empty text
|
|
// nodes, so drop the node entirely in that case.
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
return {
|
|
...node,
|
|
text,
|
|
};
|
|
}
|
|
if (node.content) {
|
|
let found = false;
|
|
const content: ProsemirrorData[] = [];
|
|
for (const child of node.content) {
|
|
if (found) {
|
|
content.push(child);
|
|
continue;
|
|
}
|
|
const result = removeEmojiFromNode(child);
|
|
if (result !== child) {
|
|
found = true;
|
|
}
|
|
if (result !== null) {
|
|
content.push(result);
|
|
}
|
|
}
|
|
return {
|
|
...node,
|
|
content,
|
|
};
|
|
}
|
|
return node;
|
|
}
|
|
|
|
const modifiedJson = removeEmojiFromNode(json as ProsemirrorData);
|
|
return {
|
|
emoji,
|
|
doc: modifiedJson ? Node.fromJSON(schema, modifiedJson) : doc,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Patches the global environment with properties from the JSDOM window,
|
|
* necessary for ProseMirror to run in a Node environment.
|
|
*
|
|
* @param domWindow The JSDOM window object.
|
|
* @returns A cleanup function to restore the global environment.
|
|
*/
|
|
public static patchGlobalEnv(domWindow: JSDOM["window"]) {
|
|
const g = global as unknown as Record<string, unknown>;
|
|
|
|
const globalParams = {
|
|
window: g.window,
|
|
document: g.document,
|
|
navigator: g.navigator,
|
|
getSelection: g.getSelection,
|
|
requestAnimationFrame: g.requestAnimationFrame,
|
|
cancelAnimationFrame: g.cancelAnimationFrame,
|
|
HTMLElement: g.HTMLElement,
|
|
Node: g.Node,
|
|
MutationObserver: g.MutationObserver,
|
|
};
|
|
|
|
const patch = (key: string, value: unknown) => {
|
|
try {
|
|
g[key] = value;
|
|
} catch (_err) {
|
|
// Ignore errors if property is read-only
|
|
}
|
|
};
|
|
|
|
patch("window", domWindow);
|
|
patch("document", domWindow.document);
|
|
patch("navigator", domWindow.navigator);
|
|
patch("getSelection", () => null);
|
|
patch("requestAnimationFrame", (fn: Function) => setTimeout(fn, 0));
|
|
patch("cancelAnimationFrame", (id: number) => clearTimeout(id));
|
|
patch("HTMLElement", domWindow.HTMLElement);
|
|
patch("Node", domWindow.Node);
|
|
patch("MutationObserver", domWindow.MutationObserver);
|
|
|
|
return () => {
|
|
Object.entries(globalParams).forEach(([key, value]) => {
|
|
try {
|
|
g[key] = value;
|
|
} catch (_err) {
|
|
// Ignore errors if property is read-only
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Replaces remote and base64 encoded images in the given Prosemirror node
|
|
* with attachment urls and uploads the images to the storage provider. Links
|
|
* pointing at a data URI are uploaded too, so that files which are not
|
|
* images — a PDF bundled alongside a document, for example — do not remain
|
|
* embedded in the document as base64.
|
|
*
|
|
* @param ctx The API context.
|
|
* @param doc The Prosemirror node to process.
|
|
* @param user The user context.
|
|
* @returns A new Prosemirror node with images and embedded files replaced.
|
|
*/
|
|
static async replaceImagesWithAttachments(
|
|
ctx: APIContext,
|
|
doc: Node,
|
|
user: User
|
|
): Promise<Node> {
|
|
const images = ProsemirrorHelper.getImages(doc);
|
|
const videos = ProsemirrorHelper.getVideos(doc);
|
|
const nodes = [...images, ...videos];
|
|
|
|
// Only data URIs are collected from links: an ordinary external link is a
|
|
// reference to a page, not a file the document should take a copy of.
|
|
const links: { href: string; name: string }[] = [];
|
|
doc.descendants((node) => {
|
|
const link = node.marks.find((mark) => mark.type.name === "link");
|
|
const href = String(link?.attrs.href ?? "");
|
|
if (href.startsWith("data:")) {
|
|
links.push({ href, name: node.textContent || "file" });
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!nodes.length && !links.length) {
|
|
return doc;
|
|
}
|
|
|
|
const sources = [
|
|
...nodes.map((node) => ({
|
|
href: String(node.attrs.src ?? ""),
|
|
name: String(node.attrs.alt ?? node.type.name),
|
|
})),
|
|
...links,
|
|
];
|
|
|
|
const timeoutPerImage = Math.floor(
|
|
Math.min(env.REQUEST_TIMEOUT / sources.length, 10000)
|
|
);
|
|
|
|
const urlToAttachment: Map<string, Attachment> = new Map();
|
|
const chunks = chunk(sources, 10);
|
|
|
|
for (const sourceChunk of chunks) {
|
|
await Promise.all(
|
|
sourceChunk.map(async (source) => {
|
|
const src = source.href;
|
|
|
|
// Skip invalid URLs
|
|
try {
|
|
new URL(src);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
// Skip internal URLs
|
|
if (isInternalUrl(src)) {
|
|
return;
|
|
}
|
|
|
|
// Skip already processed
|
|
if (urlToAttachment.has(src)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const attachment = await attachmentCreator({
|
|
name: source.name,
|
|
url: src,
|
|
preset: AttachmentPreset.DocumentAttachment,
|
|
user,
|
|
fetchOptions: {
|
|
timeout: timeoutPerImage,
|
|
},
|
|
ctx,
|
|
});
|
|
|
|
if (attachment) {
|
|
urlToAttachment.set(src, attachment);
|
|
}
|
|
} catch (err) {
|
|
Logger.warn("Failed to download image for attachment", {
|
|
error: errToString(err),
|
|
src,
|
|
});
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// Transform the document to replace image/video src attributes and the
|
|
// href of any link that was uploaded above.
|
|
const transformFragment = (fragment: Fragment): Fragment => {
|
|
const transformedNodes: Node[] = [];
|
|
|
|
fragment.forEach((node) => {
|
|
if (node.type.name === "image" || node.type.name === "video") {
|
|
const src = String(node.attrs.src ?? "");
|
|
const attachment = urlToAttachment.get(src);
|
|
|
|
if (attachment) {
|
|
const json = node.toJSON();
|
|
json.attrs = { ...json.attrs, src: attachment.redirectUrl };
|
|
transformedNodes.push(Node.fromJSON(schema, json));
|
|
} else {
|
|
transformedNodes.push(node);
|
|
}
|
|
} else {
|
|
const marks = node.marks.map((mark) => {
|
|
const attachment =
|
|
mark.type.name === "link"
|
|
? urlToAttachment.get(String(mark.attrs.href ?? ""))
|
|
: undefined;
|
|
|
|
return attachment
|
|
? mark.type.create({
|
|
...mark.attrs,
|
|
href: attachment.redirectUrl,
|
|
})
|
|
: mark;
|
|
});
|
|
|
|
const content =
|
|
node.content.size > 0 ? transformFragment(node.content) : undefined;
|
|
|
|
transformedNodes.push(
|
|
(content ? node.copy(content) : node).mark(marks)
|
|
);
|
|
}
|
|
});
|
|
|
|
return Fragment.fromArray(transformedNodes);
|
|
};
|
|
|
|
return doc.copy(transformFragment(doc.content));
|
|
}
|
|
|
|
/**
|
|
* Applies a comment mark to a document's Yjs state at the first occurrence
|
|
* of `anchorText` in the document's plain text content.
|
|
*
|
|
* Block boundaries are represented as a single newline; matches that span
|
|
* blocks apply the mark across the union of affected text ranges.
|
|
*
|
|
* `prefix` and `suffix` may be supplied to disambiguate when `anchorText`
|
|
* appears multiple times: the first occurrence whose immediately preceding
|
|
* text equals `prefix` and immediately following text equals `suffix` is
|
|
* used. Empty or omitted prefix/suffix imposes no constraint on that side.
|
|
*
|
|
* @param params.docState The current Yjs document state.
|
|
* @param params.anchorText The plain text substring to anchor the comment to.
|
|
* @param params.commentId The comment identifier.
|
|
* @param params.userId The user identifier.
|
|
* @param params.prefix Optional plain text immediately preceding the match.
|
|
* @param params.suffix Optional plain text immediately following the match.
|
|
* @returns Updated Yjs state and content, or null if the mark cannot be applied.
|
|
* @throws ValidationError when no match satisfies the prefix/suffix.
|
|
*/
|
|
static applyCommentMarkByText({
|
|
docState,
|
|
anchorText,
|
|
commentId,
|
|
userId,
|
|
prefix,
|
|
suffix,
|
|
}: {
|
|
docState: Uint8Array;
|
|
anchorText: string;
|
|
commentId: string;
|
|
userId: string;
|
|
prefix?: string;
|
|
suffix?: string;
|
|
}): { state: Buffer; content: ProsemirrorData } | null {
|
|
const yjsDoc = new Y.Doc();
|
|
Y.applyUpdate(yjsDoc, docState);
|
|
const doc = Node.fromJSON(schema, yDocToProsemirrorJSON(yjsDoc, "default"));
|
|
const range = ProsemirrorHelper.findTextRange(doc, anchorText, {
|
|
prefix,
|
|
suffix,
|
|
});
|
|
|
|
if (!range) {
|
|
throw ValidationError("anchorText was not found in the document");
|
|
}
|
|
|
|
try {
|
|
return ProsemirrorHelper.applyCommentMarkAtRange(
|
|
yjsDoc,
|
|
doc,
|
|
range.from,
|
|
range.to,
|
|
commentId,
|
|
userId
|
|
);
|
|
} catch (error) {
|
|
Logger.error("Error applying comment mark by text", error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies a comment mark to a node in a document's Yjs state, identified by
|
|
* a hash of the node's attributes, see `ProsemirrorHelper.getNodeHash`. The
|
|
* first matching node in document order is used, and the mark is stored in
|
|
* the node's `marks` attribute.
|
|
*
|
|
* @param params.docState The current Yjs document state.
|
|
* @param params.anchorNodeId The hash of the node to anchor the comment to.
|
|
* @param params.commentId The comment identifier.
|
|
* @param params.userId The user identifier.
|
|
* @returns Updated Yjs state and content, or null if the mark cannot be applied.
|
|
* @throws ValidationError when no node matches or the node cannot hold comments.
|
|
*/
|
|
static applyCommentMarkByNode({
|
|
docState,
|
|
anchorNodeId,
|
|
commentId,
|
|
userId,
|
|
}: {
|
|
docState: Uint8Array;
|
|
anchorNodeId: string;
|
|
commentId: string;
|
|
userId: string;
|
|
}): { state: Buffer; content: ProsemirrorData } | null {
|
|
const yjsDoc = new Y.Doc();
|
|
Y.applyUpdate(yjsDoc, docState);
|
|
const doc = Node.fromJSON(schema, yDocToProsemirrorJSON(yjsDoc, "default"));
|
|
const match = SharedProsemirrorHelper.findNodeByHash(doc, anchorNodeId);
|
|
|
|
if (!match) {
|
|
throw ValidationError("anchorNodeId was not found in the document");
|
|
}
|
|
if (!("marks" in (match.node.type.spec.attrs ?? {}))) {
|
|
throw ValidationError("This node cannot be commented on");
|
|
}
|
|
|
|
try {
|
|
const initialState = EditorState.create({
|
|
doc,
|
|
schema,
|
|
});
|
|
const stateTransform = initialState.tr.setNodeMarkup(
|
|
match.pos,
|
|
undefined,
|
|
{
|
|
...match.node.attrs,
|
|
marks: [
|
|
...(match.node.attrs.marks ?? []),
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId, draft: false, resolved: false },
|
|
},
|
|
],
|
|
}
|
|
);
|
|
const transformedState = initialState.apply(stateTransform);
|
|
|
|
return ProsemirrorHelper.applyDocToYDoc(yjsDoc, transformedState.doc);
|
|
} catch (error) {
|
|
Logger.error("Error applying comment mark by node", error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static applyCommentMarkAtRange(
|
|
yjsDoc: Y.Doc,
|
|
doc: Node,
|
|
rangeStart: number,
|
|
rangeEnd: number,
|
|
commentId: string,
|
|
userId: string
|
|
): { state: Buffer; content: ProsemirrorData } | null {
|
|
const docSize = doc.content.size;
|
|
if (rangeStart < 0 || rangeEnd > docSize || rangeStart > rangeEnd) {
|
|
Logger.warn("Invalid position range for comment anchor", {
|
|
rangeStart,
|
|
rangeEnd,
|
|
docSize,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const initialState = EditorState.create({
|
|
doc,
|
|
schema,
|
|
});
|
|
|
|
const markToAdd = schema.marks.comment.create({
|
|
id: commentId,
|
|
userId,
|
|
draft: false,
|
|
});
|
|
const stateTransform = initialState.tr.addMark(
|
|
rangeStart,
|
|
rangeEnd,
|
|
markToAdd
|
|
);
|
|
const transformedState = initialState.apply(stateTransform);
|
|
|
|
return ProsemirrorHelper.applyDocToYDoc(yjsDoc, transformedState.doc);
|
|
}
|
|
|
|
/**
|
|
* Mutate the existing yjsDoc in place so the resulting state is a
|
|
* continuation of the original document — same client IDs, same operation
|
|
* history — rather than a fresh Y.Doc whose content would merge as
|
|
* duplicates against any client still holding the original state.
|
|
*/
|
|
private static applyDocToYDoc(
|
|
yjsDoc: Y.Doc,
|
|
doc: Node
|
|
): { state: Buffer; content: ProsemirrorData } {
|
|
const yFragment = yjsDoc.get("default", Y.XmlFragment) as Y.XmlFragment;
|
|
if (!yFragment.doc) {
|
|
throw new Error("yFragment.doc not found");
|
|
}
|
|
updateYFragment(yFragment.doc, yFragment, doc, {
|
|
mapping: new Map(),
|
|
isOMark: new Map(),
|
|
});
|
|
|
|
return {
|
|
state: Buffer.from(Y.encodeStateAsUpdate(yjsDoc)),
|
|
content: Node.fromJSON(
|
|
schema,
|
|
yDocToProsemirrorJSON(yjsDoc, "default")
|
|
).toJSON() as ProsemirrorData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Locates an occurrence of `needle` in the document's plain text and
|
|
* returns the matching ProseMirror position range, or null if no match.
|
|
* Plain text is built using the editor's `textBetween` so leaf nodes
|
|
* with `spec.leafText` (e.g. mentions) participate in matching.
|
|
*
|
|
* When `prefix` or `suffix` is provided, the first occurrence whose
|
|
* immediately preceding / following plain text matches is selected.
|
|
* Empty or omitted values impose no constraint on that side.
|
|
*
|
|
* Atom nodes (whose plain content comes from `leafText`) cannot be
|
|
* sliced into; matches that fall inside an atom are clamped to the
|
|
* atom's full PM range.
|
|
*/
|
|
private static findTextRange(
|
|
doc: Node,
|
|
needle: string,
|
|
options: { prefix?: string; suffix?: string } = {}
|
|
): { from: number; to: number } | null {
|
|
if (!needle.length) {
|
|
return null;
|
|
}
|
|
|
|
const plain = textBetween(doc, 0, doc.content.size);
|
|
|
|
// Mirror textBetween's traversal so segment.plainStart aligns with the
|
|
// characters in `plain`. If textBetween's algorithm changes, this walk
|
|
// must change with it.
|
|
type Segment = {
|
|
plainStart: number;
|
|
pmFrom: number;
|
|
pmTo: number;
|
|
length: number;
|
|
isAtom: boolean;
|
|
};
|
|
const segments: Segment[] = [];
|
|
let plainPos = 0;
|
|
let first = true;
|
|
|
|
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
|
let nodeText = "";
|
|
let isLeafText = false;
|
|
|
|
if (node.type.spec.leafText) {
|
|
nodeText = node.type.spec.leafText(node);
|
|
isLeafText = true;
|
|
} else if (node.isText) {
|
|
nodeText = node.text ?? "";
|
|
}
|
|
|
|
if (node.isBlock && ((node.isLeaf && nodeText) || node.isTextblock)) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
plainPos += 1; // block separator '\n'
|
|
}
|
|
}
|
|
|
|
if (nodeText) {
|
|
segments.push({
|
|
plainStart: plainPos,
|
|
pmFrom: pos,
|
|
pmTo: pos + node.nodeSize,
|
|
length: nodeText.length,
|
|
isAtom: isLeafText,
|
|
});
|
|
plainPos += nodeText.length;
|
|
}
|
|
|
|
return !isLeafText;
|
|
});
|
|
|
|
const prefix = options.prefix ?? "";
|
|
const suffix = options.suffix ?? "";
|
|
|
|
let startIdx = -1;
|
|
let searchFrom = 0;
|
|
while (true) {
|
|
const candidate = plain.indexOf(needle, searchFrom);
|
|
if (candidate === -1) {
|
|
return null;
|
|
}
|
|
const candidateEnd = candidate + needle.length;
|
|
const prefixOk =
|
|
prefix.length === 0 ||
|
|
(candidate >= prefix.length &&
|
|
plain.substring(candidate - prefix.length, candidate) === prefix);
|
|
const suffixOk =
|
|
suffix.length === 0 ||
|
|
(candidateEnd + suffix.length <= plain.length &&
|
|
plain.substring(candidateEnd, candidateEnd + suffix.length) ===
|
|
suffix);
|
|
if (prefixOk && suffixOk) {
|
|
startIdx = candidate;
|
|
break;
|
|
}
|
|
searchFrom = candidate + 1;
|
|
}
|
|
|
|
const endIdx = startIdx + needle.length;
|
|
|
|
let from: number | null = null;
|
|
for (const s of segments) {
|
|
if (s.plainStart <= startIdx && startIdx < s.plainStart + s.length) {
|
|
from = s.isAtom ? s.pmFrom : s.pmFrom + (startIdx - s.plainStart);
|
|
break;
|
|
}
|
|
}
|
|
let to: number | null = null;
|
|
for (const s of segments) {
|
|
if (s.plainStart < endIdx && endIdx <= s.plainStart + s.length) {
|
|
to = s.isAtom ? s.pmTo : s.pmFrom + (endIdx - s.plainStart);
|
|
}
|
|
}
|
|
|
|
if (from === null || to === null) {
|
|
return null;
|
|
}
|
|
|
|
return { from, to };
|
|
}
|
|
}
|