`
${mathStyle}
${codeMarkCursor}
${codeBlockStyle}
+ ${diffStyle}
${findAndReplaceStyle}
${emailStyle}
${textStyle}
diff --git a/shared/editor/extensions/Diff.ts b/shared/editor/extensions/Diff.ts
new file mode 100644
index 0000000000..1d056109d2
--- /dev/null
+++ b/shared/editor/extensions/Diff.ts
@@ -0,0 +1,342 @@
+import { observable } from "mobx";
+import type { Command } from "prosemirror-state";
+import { Plugin, PluginKey } from "prosemirror-state";
+import { Decoration, DecorationSet } from "prosemirror-view";
+import type { Node, ResolvedPos } from "prosemirror-model";
+import { DOMSerializer, Fragment } from "prosemirror-model";
+import scrollIntoView from "scroll-into-view-if-needed";
+import Extension from "../lib/Extension";
+import type { ExtendedChange } from "../lib/ChangesetHelper";
+import { cn } from "../styles/utils";
+import { EditorStyleHelper } from "../styles/EditorStyleHelper";
+
+const pluginKey = new PluginKey("diffs");
+
+export default class Diff extends Extension {
+ get name() {
+ return "diff";
+ }
+
+ get defaultOptions() {
+ return {
+ changes: null,
+ insertionClassName: EditorStyleHelper.diffInsertion,
+ deletionClassName: EditorStyleHelper.diffDeletion,
+ nodeInsertionClassName: EditorStyleHelper.diffNodeInsertion,
+ nodeDeletionClassName: EditorStyleHelper.diffNodeDeletion,
+ modificationClassName: EditorStyleHelper.diffModification,
+ nodeModificationClassName: EditorStyleHelper.diffNodeModification,
+ currentChangeClassName: EditorStyleHelper.diffCurrentChange,
+ };
+ }
+
+ public commands() {
+ return {
+ /**
+ * Navigate to the next change in the document.
+ */
+ nextChange: () => this.goToChange(1),
+
+ /**
+ * Navigate to the previous change in the document.
+ */
+ prevChange: () => this.goToChange(-1),
+ };
+ }
+
+ /**
+ * Get the current change index being viewed.
+ *
+ * @returns the index of the current change, or -1 if no change is selected.
+ */
+ public getCurrentChangeIndex(): number {
+ return this.currentChangeIndex;
+ }
+
+ /**
+ * Get the total number of individual changes.
+ *
+ * @returns the total count of all inserted, deleted, and modified items.
+ */
+ public getTotalChangesCount(): number {
+ const { changes } = this.options as { changes: ExtendedChange[] | null };
+ if (!changes) {
+ return 0;
+ }
+
+ return changes.reduce(
+ (total, change) =>
+ total +
+ change.inserted.length +
+ change.deleted.length +
+ change.modified.length,
+ 0
+ );
+ }
+
+ private goToChange(direction: number): Command {
+ return (state, dispatch) => {
+ const totalChanges = this.getTotalChangesCount();
+
+ if (totalChanges === 0) {
+ return false;
+ }
+
+ if (direction > 0) {
+ if (this.currentChangeIndex >= totalChanges - 1) {
+ this.currentChangeIndex = 0;
+ } else {
+ this.currentChangeIndex += 1;
+ }
+ } else {
+ if (this.currentChangeIndex === 0) {
+ this.currentChangeIndex = totalChanges - 1;
+ } else {
+ this.currentChangeIndex -= 1;
+ }
+ }
+
+ dispatch?.(state.tr.setMeta(pluginKey, {}));
+
+ const element = window.document.querySelector(
+ `.${this.options.currentChangeClassName}`
+ );
+ if (element) {
+ scrollIntoView(element, {
+ scrollMode: "if-needed",
+ block: "center",
+ });
+ }
+ return true;
+ };
+ }
+
+ get allowInReadOnly(): boolean {
+ return true;
+ }
+
+ get plugins() {
+ return [
+ new Plugin({
+ key: pluginKey,
+ state: {
+ init: () => DecorationSet.empty,
+ apply: (tr) => this.createDecorations(tr.doc),
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+ // Allow meta transactions to bypass filtering
+ filterTransaction: (tr) =>
+ tr.getMeta("codeHighlighting") || tr.getMeta(pluginKey)
+ ? true
+ : false,
+ }),
+ ];
+ }
+
+ private createDecorations(doc: Node) {
+ const { changes } = this.options as { changes: ExtendedChange[] | null };
+ const decorations: Decoration[] = [];
+
+ /**
+ * Determines if a slice should use node decoration instead of inline decoration.
+ */
+ const shouldUseNodeDecoration = (
+ slice:
+ | { content: { childCount: number; firstChild: Node | null } }
+ | null
+ | undefined
+ ): boolean => {
+ if (slice?.content.childCount === 1) {
+ const node = slice.content.firstChild;
+ if (
+ node &&
+ !node.isText &&
+ ((node.isBlock && node.type.name !== "paragraph") ||
+ (node.isInline && node.isAtom))
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Adds the appropriate decoration for a change.
+ */
+ const addChangeDecoration = (
+ pos: number,
+ end: number,
+ className: string,
+ useNodeDecoration: boolean
+ ): void => {
+ if (useNodeDecoration) {
+ decorations.push(
+ Decoration.node(pos, end, {
+ class: className,
+ })
+ );
+ } else {
+ decorations.push(
+ Decoration.inline(pos, end, {
+ class: className,
+ })
+ );
+ }
+ };
+
+ /**
+ * Recursively unwrap nodes that are redundant or invalid given the
+ * current context.
+ */
+ const unwrap = ($pos: ResolvedPos, fragment: Fragment): Node[] => {
+ const result: Node[] = [];
+ fragment.forEach((node: Node) => {
+ let isRedundant = false;
+
+ for (let d = 0; d <= $pos.depth; d++) {
+ const ancestor = $pos.node(d);
+ const ancestorRole = ancestor.type.spec.tableRole;
+ const nodeRole = node.type.spec.tableRole;
+
+ if (
+ ancestor.type.name === node.type.name ||
+ (ancestorRole === "row" &&
+ (nodeRole === "cell" || nodeRole === "header_cell")) ||
+ (ancestorRole === "table" && nodeRole === "row")
+ ) {
+ isRedundant = true;
+ break;
+ }
+ }
+
+ if (node.isBlock && (isRedundant || $pos.parent.type.inlineContent)) {
+ result.push(...unwrap($pos, node.content));
+ } else {
+ result.push(node);
+ }
+ });
+ return result;
+ };
+
+ // Add insertion, deletion, and modification decorations
+ let individualChangeIndex = 0;
+ changes?.forEach((change) => {
+ let pos = change.fromB;
+
+ change.deleted.forEach((deletion) => {
+ const isCurrent = individualChangeIndex === this.currentChangeIndex;
+ if (!deletion.data.slice) {
+ return;
+ }
+
+ const $pos = doc.resolve(change.fromB);
+ const parentRole = $pos.parent.type.spec.tableRole;
+ const parentGroup = $pos.parent.type.spec.group;
+ let tag = $pos.parent.type.inlineContent ? "span" : "div";
+
+ if (parentRole === "table") {
+ tag = "tr";
+ } else if (parentRole === "row") {
+ tag = "td";
+ } else if (parentGroup?.includes("list")) {
+ tag = "li";
+ }
+
+ const useNodeDecoration = shouldUseNodeDecoration(deletion.data.slice);
+
+ // Check if we're deleting a single paragraph - if so, use tag
+ // and unwrap the paragraph content to avoid nested
tags
+ let contentToSerialize = deletion.data.slice.content;
+ if (deletion.data.slice.content.childCount === 1) {
+ const deletedNode = deletion.data.slice.content.firstChild;
+ if (deletedNode?.type.name === "paragraph") {
+ tag = "p";
+ // Unwrap the paragraph to get just its inline content
+ contentToSerialize = deletedNode.content;
+ }
+ }
+
+ const dom = document.createElement(tag);
+ dom.setAttribute(
+ "class",
+ cn({
+ [this.options.currentChangeClassName]: isCurrent,
+ [this.options.deletionClassName]: !useNodeDecoration,
+ [this.options.nodeDeletionClassName]: useNodeDecoration,
+ })
+ );
+
+ const fragment = Fragment.from(unwrap($pos, contentToSerialize));
+
+ dom.appendChild(
+ DOMSerializer.fromSchema(doc.type.schema).serializeFragment(fragment)
+ );
+
+ decorations.push(
+ Decoration.widget(change.fromB, () => dom, {
+ side: -1,
+ })
+ );
+ individualChangeIndex++;
+ });
+
+ change.inserted.forEach((insertion) => {
+ const isCurrent = individualChangeIndex === this.currentChangeIndex;
+ const end = pos + insertion.length;
+ const useNodeDecoration = shouldUseNodeDecoration(
+ insertion.data.step.slice
+ );
+
+ const className = cn({
+ [this.options.currentChangeClassName]: isCurrent,
+ [this.options.insertionClassName]: !useNodeDecoration,
+ [this.options.nodeInsertionClassName]: useNodeDecoration,
+ });
+
+ addChangeDecoration(pos, end, className, useNodeDecoration);
+ pos = end;
+ individualChangeIndex++;
+ });
+
+ // Add modification decorations
+ change.modified.forEach((modification) => {
+ const isCurrent = individualChangeIndex === this.currentChangeIndex;
+ // A modification slice may contain multiple nodes (e.g., multiple table cells)
+ // We need to add a decoration for each node individually
+ if (!modification.data.slice) {
+ return;
+ }
+
+ modification.data.slice.content.forEach((node: Node) => {
+ const nodeSize = node.nodeSize;
+ const end = pos + nodeSize;
+
+ // Check if this specific node should use node decoration
+ const useNodeDecoration =
+ !node.isText &&
+ ((node.isBlock && node.type.name !== "paragraph") ||
+ (node.isInline && node.isAtom));
+
+ const className = cn({
+ [this.options.currentChangeClassName]: isCurrent,
+ [this.options.modificationClassName]: !useNodeDecoration,
+ [this.options.nodeModificationClassName]: useNodeDecoration,
+ });
+
+ addChangeDecoration(pos, end, className, useNodeDecoration);
+ pos = end;
+ });
+ individualChangeIndex++;
+ });
+ });
+
+ return DecorationSet.create(doc, decorations);
+ }
+
+ @observable
+ private currentChangeIndex = -1;
+}
diff --git a/shared/editor/extensions/TrailingNode.ts b/shared/editor/extensions/TrailingNode.ts
index a40e422cce..bcaf645fea 100644
--- a/shared/editor/extensions/TrailingNode.ts
+++ b/shared/editor/extensions/TrailingNode.ts
@@ -28,7 +28,7 @@ export default class TrailingNode extends Extension {
const { state } = view;
const insertNodeAtEnd = plugin.getState(state);
- if (!insertNodeAtEnd) {
+ if (!insertNodeAtEnd || !view.editable) {
return;
}
diff --git a/shared/editor/lib/ChangesetHelper.ts b/shared/editor/lib/ChangesetHelper.ts
new file mode 100644
index 0000000000..b3542f3aae
--- /dev/null
+++ b/shared/editor/lib/ChangesetHelper.ts
@@ -0,0 +1,277 @@
+import type { Mark, Slice } from "prosemirror-model";
+import { Node, Schema } from "prosemirror-model";
+import type { Change, TokenEncoder } from "prosemirror-changeset";
+import { ChangeSet, simplifyChanges } from "prosemirror-changeset";
+import { ReplaceStep, type Step } from "prosemirror-transform";
+import ExtensionManager from "./ExtensionManager";
+import { recreateTransform } from "./prosemirror-recreate-transform";
+import { richExtensions, withComments } from "../nodes";
+import type { ProsemirrorData } from "../../types";
+
+/**
+ * Represents a modification (attribute change) in the document.
+ */
+export type Modification = {
+ length: number;
+ data: {
+ step: Step;
+ slice: Slice | null;
+ oldAttrs: Record;
+ newAttrs: Record;
+ };
+};
+
+/**
+ * Extended Change type that includes modifications.
+ */
+export interface ExtendedChange extends Change {
+ modified: readonly Modification[];
+}
+
+export type DiffChanges = {
+ changes: readonly ExtendedChange[];
+ doc: Node;
+};
+
+class AttributeEncoder implements TokenEncoder {
+ public encodeCharacter(char: number, marks: Mark[]): string | number {
+ return `${char}:${this.encodeMarks(marks)}`;
+ }
+
+ public encodeNodeStart(node: Node): string {
+ const nodeName = node.type.name;
+ const marks = node.marks;
+
+ // Add node attributes if they exist
+ let nodeStr = nodeName;
+
+ // Enable more attribute encoding as tested
+ if (Object.keys(node.attrs).length) {
+ nodeStr += ":" + JSON.stringify(node.attrs);
+ }
+
+ if (!marks.length) {
+ return nodeStr;
+ }
+
+ return `${nodeStr}:${this.encodeMarks(marks)}`;
+ }
+
+ // See: https://github.com/ProseMirror/prosemirror-changeset/blob/23f67c002e5489e454a0473479e407decb238afe/src/diff.ts#L26
+ public encodeNodeEnd({ type }: Node): number {
+ let cache: Record =
+ type.schema.cached.changeSetIDs ||
+ (type.schema.cached.changeSetIDs = Object.create(null));
+ let id = cache[type.name];
+ if (id === null) {
+ cache[type.name] = id =
+ Object.keys(type.schema.nodes).indexOf(type.name) + 1;
+ }
+ return id;
+ }
+
+ public compareTokens(a: string | number, b: string | number): boolean {
+ return a === b;
+ }
+
+ private encodeMarks(marks: readonly Mark[]): string {
+ return marks
+ .map((m) => {
+ let result = m.type.name;
+ if (Object.keys(m.attrs).length) {
+ result += ":" + JSON.stringify(m.attrs);
+ }
+ return result;
+ })
+ .sort()
+ .join(",");
+ }
+}
+
+export class ChangesetHelper {
+ /**
+ * Calculates a changeset between two revisions of a document.
+ *
+ * @param revision - The current revision data.
+ * @param previousRevision - The previous revision data to compare against.
+ * @returns An object containing the simplified changes and the new document.
+ */
+ public static getChangeset(
+ revision?: ProsemirrorData | null,
+ previousRevision?: ProsemirrorData | null
+ ): DiffChanges | null {
+ if (!revision || !previousRevision) {
+ // This is the first revision, nothing to compare against
+ return null;
+ }
+
+ try {
+ // Create schema from extensions
+ const extensionManager = new ExtensionManager(
+ withComments(richExtensions)
+ );
+ const schema = new Schema({
+ nodes: extensionManager.nodes,
+ marks: extensionManager.marks,
+ });
+
+ // Parse documents from JSON (old = previous revision, new = current revision)
+ const docOld = Node.fromJSON(schema, previousRevision);
+ const docNew = Node.fromJSON(schema, revision);
+
+ // Calculate the transform and changeset
+ const tr = recreateTransform(docOld, docNew, {
+ complexSteps: false,
+ wordDiffs: true,
+ simplifyDiff: true,
+ });
+
+ // Map steps to capture the actual content being replaced from the document
+ // state at that specific step. This ensures deleted content is correctly
+ // captured for diff rendering.
+ const changeset = ChangeSet.create<{
+ step: Step;
+ slice: Slice | null;
+ }>(docOld, undefined, this.attributeEncoder).addSteps(
+ tr.doc,
+ tr.mapping.maps,
+ tr.steps.map((step, i) => ({
+ step,
+ slice:
+ step instanceof ReplaceStep
+ ? tr.docs[i].slice(step.from, step.to)
+ : null,
+ }))
+ );
+
+ let changes = simplifyChanges(changeset.changes, docNew);
+
+ // Post-process changes to detect modifications (attribute-only changes)
+ const extendedChanges: ExtendedChange[] = changes.map((change) => {
+ const modified: Modification[] = [];
+ const matchedDeletionIndices = new Set();
+ const matchedInsertionIndices = new Set();
+
+ // Each deletion entry contains both old (step.slice) and new (slice) content
+ // Check if the deletion represents a modification by comparing these
+ for (let i = 0; i < change.deleted.length; i++) {
+ const deletion = change.deleted[i];
+
+ if (!deletion.data.slice || !deletion.data.step.slice) {
+ continue;
+ }
+
+ // deletion.data.step.slice = OLD content (what was in the document)
+ // deletion.data.slice = NEW content (what it changed to)
+ const oldSlice = deletion.data.step.slice;
+ const newSlice = deletion.data.slice;
+
+ // Check if both slices have the same number of nodes
+ if (
+ oldSlice.content.childCount === newSlice.content.childCount &&
+ oldSlice.content.childCount > 0
+ ) {
+ let isModification = true;
+ const nodes: Array<{
+ oldNode: Node;
+ newNode: Node;
+ }> = [];
+
+ // Check each corresponding node pair
+ for (let index = 0; index < oldSlice.content.childCount; index++) {
+ const oldNode = oldSlice.content.child(index);
+ const newNode = newSlice.content.child(index);
+
+ // For modifications, we allow:
+ // 1. Same node type with different attributes (e.g., code_block language change)
+ // 2. Related node types with same semantic group (e.g., td <-> th share "tableCell" group)
+ const isSameType = oldNode.type.name === newNode.type.name;
+
+ // Check if nodes share a common semantic group (excluding generic "block"/"inline")
+ const getSemanticGroups = (node: Node): Set => {
+ const groups = node.type.spec.group?.split(" ") || [];
+ return new Set(
+ groups.filter((g) => g !== "block" && g !== "inline")
+ );
+ };
+
+ const oldGroups = getSemanticGroups(oldNode);
+ const newGroups = getSemanticGroups(newNode);
+ const hasSharedGroup = Array.from(oldGroups).some((g) =>
+ newGroups.has(g)
+ );
+ const isRelatedNodeType = !isSameType && hasSharedGroup;
+
+ try {
+ if (
+ oldNode.textContent !== newNode.textContent ||
+ (!isSameType && !isRelatedNodeType)
+ ) {
+ isModification = false;
+ } else if (
+ isSameType &&
+ JSON.stringify(oldNode.attrs) ===
+ JSON.stringify(newNode.attrs)
+ ) {
+ // Same type and same attributes = not a modification
+ isModification = false;
+ }
+
+ nodes.push({ oldNode, newNode });
+ } catch {
+ isModification = false;
+ }
+ }
+
+ if (isModification) {
+ modified.push({
+ length: deletion.length,
+ data: {
+ step: deletion.data.step,
+ slice: deletion.data.slice,
+ oldAttrs: nodes.length === 1 ? nodes[0].oldNode.attrs : {},
+ newAttrs: nodes.length === 1 ? nodes[0].newNode.attrs : {},
+ },
+ });
+
+ // Mark this deletion for removal
+ matchedDeletionIndices.add(i);
+
+ // Also find and mark corresponding insertion for removal
+ for (let j = 0; j < change.inserted.length; j++) {
+ const insertion = change.inserted[j];
+ if (
+ insertion.length === deletion.length &&
+ !matchedInsertionIndices.has(j)
+ ) {
+ matchedInsertionIndices.add(j);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ ...change,
+ deleted: change.deleted.filter(
+ (_, index) => !matchedDeletionIndices.has(index)
+ ),
+ inserted: change.inserted.filter(
+ (_, index) => !matchedInsertionIndices.has(index)
+ ),
+ modified,
+ };
+ });
+
+ return {
+ changes: extendedChanges,
+ doc: tr.doc,
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ private static attributeEncoder = new AttributeEncoder();
+}
diff --git a/shared/editor/nodes/TableCell.ts b/shared/editor/nodes/TableCell.ts
index 6d7d6ff592..da30651f2e 100644
--- a/shared/editor/nodes/TableCell.ts
+++ b/shared/editor/nodes/TableCell.ts
@@ -20,6 +20,7 @@ export default class TableCell extends Node {
return {
content: "block+",
tableRole: "cell",
+ group: "cell",
isolating: true,
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
toDOM(node) {
diff --git a/shared/editor/nodes/TableHeader.ts b/shared/editor/nodes/TableHeader.ts
index b4cd3782ee..3b88b9e7eb 100644
--- a/shared/editor/nodes/TableHeader.ts
+++ b/shared/editor/nodes/TableHeader.ts
@@ -25,6 +25,7 @@ export default class TableHeader extends Node {
return {
content: "block+",
tableRole: "header_cell",
+ group: "cell",
isolating: true,
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
toDOM(node) {
diff --git a/shared/editor/styles/EditorStyleHelper.ts b/shared/editor/styles/EditorStyleHelper.ts
index 62a12cece8..47901990ad 100644
--- a/shared/editor/styles/EditorStyleHelper.ts
+++ b/shared/editor/styles/EditorStyleHelper.ts
@@ -26,6 +26,22 @@ export class EditorStyleHelper {
static readonly codeWord = "code-word";
+ // Diffs
+
+ static readonly diffInsertion = "diff-insertion";
+
+ static readonly diffDeletion = "diff-deletion";
+
+ static readonly diffNodeInsertion = "diff-node-insertion";
+
+ static readonly diffNodeDeletion = "diff-node-deletion";
+
+ static readonly diffModification = "diff-modification";
+
+ static readonly diffNodeModification = "diff-node-modification";
+
+ static readonly diffCurrentChange = "current-diff";
+
// Tables
/** Table wrapper */
diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts
index 2846c71498..141ed746b7 100644
--- a/shared/editor/types/index.ts
+++ b/shared/editor/types/index.ts
@@ -1,7 +1,7 @@
import type { TFunction } from "i18next";
import type { Node as ProsemirrorNode } from "prosemirror-model";
import type { EditorState } from "prosemirror-state";
-import type { EditorView } from "prosemirror-view";
+import type { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import type { DefaultTheme } from "styled-components";
import type { Primitive } from "utility-types";
@@ -52,6 +52,7 @@ export type ComponentProps = {
isSelected: boolean;
isEditable: boolean;
getPos: () => number;
+ decorations: Decoration[];
};
export interface NodeMarkAttr {
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index eed8af8174..61cfe7414f 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -386,6 +386,8 @@
"{{ hours }}h read": "{{ hours }}h read",
"{{ minutes }}m read": "{{ minutes }}m read",
"Revision deleted": "Revision deleted",
+ "{{count}} people": "{{count}} person",
+ "{{count}} people_plural": "{{count}} people",
"Current version": "Current version",
"{{userName}} edited": "{{userName}} edited",
"Revision options": "Revision options",
@@ -709,6 +711,12 @@
"Add a description": "Add a description",
"Signing in": "Signing in",
"You can safely close this window once the Outline desktop app has opened": "You can safely close this window once the Outline desktop app has opened",
+ "{{ current }} of {{ count }} changes": "{{ current }} of {{ count }} changes",
+ "{{ current }} of {{ count }} changes_plural": "{{ current }} of {{ count }} changes",
+ "{{ count }} changes": "{{ count }} change",
+ "{{ count }} changes_plural": "{{ count }} changes",
+ "Previous change": "Previous change",
+ "Next change": "Next change",
"Error creating comment": "Error creating comment",
"Add a comment": "Add a comment",
"Add a reply": "Add a reply",
@@ -756,6 +764,7 @@
"Archived": "Archived",
"Save draft": "Save draft",
"Restore version": "Restore version",
+ "Highlight changes": "Highlight changes",
"No history yet": "No history yet",
"Source": "Source",
"Created": "Created",
diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts
index 130a5c1379..7a5864c1ea 100644
--- a/shared/styles/theme.ts
+++ b/shared/styles/theme.ts
@@ -126,7 +126,7 @@ export const buildLightTheme = (input: Partial): DefaultTheme => {
textDiffInserted: colors.almostBlack,
textDiffInsertedBackground: "rgba(18, 138, 41, 0.16)",
textDiffDeleted: colors.slateDark,
- textDiffDeletedBackground: "#ffebe9",
+ textDiffDeletedBackground: "rgba(255, 180, 173, 0.25)",
placeholder: "#a2b2c3",
sidebarBackground: colors.warmGrey,
sidebarHoverBackground: "hsl(212 31% 90% / 1)",
@@ -188,7 +188,7 @@ export const buildDarkTheme = (input: Partial): DefaultTheme => {
textSecondary: lighten(0.1, colors.slate),
textTertiary: colors.slate,
textDiffInserted: colors.almostWhite,
- textDiffInsertedBackground: "rgba(63,185,80,0.3)",
+ textDiffInsertedBackground: "rgba(63,185,80,0.25)",
textDiffDeleted: darken(0.1, colors.almostWhite),
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
placeholder: "#596673",
diff --git a/shared/types.ts b/shared/types.ts
index 931fa0367d..0cdb7fb005 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -553,7 +553,7 @@ export type ProsemirrorData = {
attrs?: JSONObject;
marks?: {
type: string;
- attrs: JSONObject;
+ attrs?: JSONObject;
}[];
};
diff --git a/shared/typings/prosemirror-model.d.ts b/shared/typings/prosemirror-model.d.ts
deleted file mode 100644
index eddca0393d..0000000000
--- a/shared/typings/prosemirror-model.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { PlainTextSerializer } from "../editor/types";
-import "prosemirror-model";
-
-declare module "prosemirror-model" {
- interface Slice {
- // this method is missing in the DefinitelyTyped type definition, so we
- // must patch it here.
- // https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51
- removeBetween(from: number, to: number): Slice;
- }
-}
diff --git a/shared/utils/ProsemirrorHelper.ts b/shared/utils/ProsemirrorHelper.ts
index d689d5069c..d53e90c530 100644
--- a/shared/utils/ProsemirrorHelper.ts
+++ b/shared/utils/ProsemirrorHelper.ts
@@ -1,4 +1,5 @@
-import type { Node, Schema } from "prosemirror-model";
+import type { Schema } from "prosemirror-model";
+import { Node } from "prosemirror-model";
import headingToSlug from "../editor/lib/headingToSlug";
import textBetween from "../editor/lib/textBetween";
import type { ProsemirrorData } from "../types";
@@ -514,16 +515,20 @@ export class ProsemirrorHelper {
* Returns the paragraphs from the data if there are only plain paragraphs
* without any formatting. Otherwise returns undefined.
*
- * @param data The ProsemirrorData object
+ * @param data The ProsemirrorData object or ProsemirrorNode
* @returns An array of paragraph nodes or undefined
*/
- static getPlainParagraphs(data: ProsemirrorData) {
+ static getPlainParagraphs(data: ProsemirrorData | Node) {
+ // Convert ProsemirrorNode to JSON if needed
+ const jsonData =
+ data instanceof Node ? (data.toJSON() as ProsemirrorData) : data;
+
const paragraphs: ProsemirrorData[] = [];
- if (!data.content) {
+ if (!jsonData.content) {
return paragraphs;
}
- for (const node of data.content) {
+ for (const node of jsonData.content) {
if (
node.type === "paragraph" &&
(!node.content ||
diff --git a/yarn.lock b/yarn.lock
index 9f16697131..c962695625 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17727,6 +17727,7 @@ __metadata:
polished: "npm:^4.3.1"
postinstall-postinstall: "npm:^2.1.0"
prettier: "npm:^3.6.2"
+ prosemirror-changeset: "npm:2.3.1"
prosemirror-codemark: "npm:^0.4.2"
prosemirror-commands: "npm:^1.7.1"
prosemirror-dropcursor: "npm:^1.8.2"
@@ -18753,6 +18754,15 @@ __metadata:
languageName: node
linkType: hard
+"prosemirror-changeset@npm:2.3.1":
+ version: 2.3.1
+ resolution: "prosemirror-changeset@npm:2.3.1"
+ dependencies:
+ prosemirror-transform: "npm:^1.0.0"
+ checksum: 10c0/efd6578ee4535d72d11c032b49921f14b3f7ccae680eb14c8d9f6cc1fbec00299c598475af0ab432864976bdbb7f94f011193278b2d19eadda83b754fe6d8a35
+ languageName: node
+ linkType: hard
+
"prosemirror-codemark@npm:^0.4.2":
version: 0.4.2
resolution: "prosemirror-codemark@npm:0.4.2"