feat: Improved revision viewer (#10824)

This commit is contained in:
Tom Moor
2025-12-28 08:56:32 -05:00
committed by GitHub
parent 222d86d084
commit fd49602e40
39 changed files with 3262 additions and 160 deletions

View File

@@ -30,11 +30,11 @@ You're an expert in the following areas:
## General Guidelines
- Critical Do not create new markdown (.md) files.
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
- Do not create new MD files.
## Dependencies and Upgrading
@@ -78,7 +78,7 @@ yarn install
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- You do not need to import React unless it is used directly.
- Do not import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.

View File

@@ -17,7 +17,17 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
import { homePath, debugPath } from "~/utils/routeHelpers";
export const goToDebug = createAction({
name: "Go to debug screen",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
history.push(debugPath());
},
});
export const copyId = createActionWithChildren({
name: ({ t }) => t("Copy ID"),
@@ -222,6 +232,7 @@ export const developer = createActionWithChildren({
iconInContextMenu: false,
section: DeveloperSection,
children: [
goToDebug,
copyId,
toggleDebugLogging,
toggleDebugSafeArea,

View File

@@ -1,7 +1,8 @@
import { differenceInMinutes } from "date-fns";
import * as React from "react";
import styled from "styled-components";
import type Document from "~/models/Document";
import type Event from "~/models/Event";
import Event from "~/models/Event";
import Revision from "~/models/Revision";
import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
@@ -27,6 +28,26 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
document,
...rest
}: Props) {
const isDuplicate = React.useCallback((item: Item, previousItem: Item) => {
if (item instanceof Event && previousItem instanceof Event) {
return (
Math.abs(
differenceInMinutes(
new Date(item.createdAt),
new Date(previousItem.createdAt)
)
) < 10 &&
item.name === previousItem.name &&
item.actorId === previousItem.actorId &&
item.userId === previousItem.userId &&
item.documentId === previousItem.documentId &&
item.collectionId === previousItem.collectionId
);
}
return false;
}, []);
return (
<StyledPaginatedList
items={items}
@@ -34,6 +55,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
isDuplicate={isDuplicate}
renderItem={(item: Item) =>
item instanceof Revision ? (
<RevisionListItem key={item.id} item={item} document={document} />

View File

@@ -27,8 +27,9 @@ export interface PaginatedItem {
* Props for the PaginatedList component
* @template T Type of items in the list, must extend PaginatedItem
*/
interface Props<T extends PaginatedItem>
extends React.HTMLAttributes<HTMLDivElement> {
interface Props<
T extends PaginatedItem,
> extends React.HTMLAttributes<HTMLDivElement> {
/**
* Function to fetch paginated data. Should return a promise resolving to an array of items
* @param options Pagination and other query options
@@ -79,6 +80,12 @@ interface Props<T extends PaginatedItem>
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
/**
* Function to determine if an item is a duplicate of the previous item.
* If it returns true, the item will not be rendered.
*/
isDuplicate?: (item: T, previousItem: T) => boolean;
/**
* Handler for escape key press
* @param ev Keyboard event object
@@ -106,6 +113,7 @@ const PaginatedList = <T extends PaginatedItem>({
renderItem,
renderError,
renderHeading,
isDuplicate,
onEscape,
listRef,
...rest
@@ -221,10 +229,19 @@ const PaginatedList = <T extends PaginatedItem>({
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(
() => items?.slice(0, renderCount) ?? [],
[items, renderCount]
);
const itemsToRender = React.useMemo(() => {
const sliced = items?.slice(0, renderCount) ?? [];
if (!isDuplicate) {
return sliced;
}
return sliced.filter((item, index) => {
if (index === 0) {
return true;
}
return !isDuplicate(item, sliced[index - 1]);
});
}, [items, renderCount, isDuplicate]);
const showLoading =
isFetching &&

View File

@@ -1,12 +1,12 @@
import type { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import { EditIcon, TrashIcon } from "outline-icons";
import { useCallback, useMemo, useRef } from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { hover } from "@shared/styles";
import { ellipsis, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
@@ -21,10 +21,8 @@ import { ContextMenu } from "~/components/Menu/ContextMenu";
import Time from "~/components/Time";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useClickIntent from "~/hooks/useClickIntent";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import { useMenuAction } from "~/hooks/useMenuAction";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryPath } from "~/utils/routeHelpers";
import { EventItem, lineStyle } from "./EventListItem";
@@ -38,10 +36,8 @@ type Props = {
const RevisionListItem = ({ item, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = useRef(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isLatestRevision = RevisionHelper.latestId(document.id) === item.id;
@@ -60,19 +56,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
ref.current?.focus();
};
const prefetchRevision = useCallback(async () => {
if (!document.isDeleted && !item.deletedAt && !revisionLoadedRef.current) {
if (isLatestRevision) {
return;
}
await revisions.fetch(item.id, { force: true });
revisionLoadedRef.current = true;
}
}, [document.isDeleted, item.deletedAt, isLatestRevision, revisions]);
const { handleMouseEnter, handleMouseLeave } =
useClickIntent(prefetchRevision);
let meta, icon, to: LocationDescriptor | undefined;
if (item.deletedAt) {
@@ -80,18 +63,31 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
let collaboratorText: string | undefined;
if (item.collaborators && item.collaborators.length === 2) {
collaboratorText = `${item.collaborators[0].name} and ${item.collaborators[1].name}`;
} else if (item.collaborators && item.collaborators.length > 2) {
collaboratorText = t("{{count}} people", {
count: item.collaborators.length,
});
} else {
collaboratorText = item.createdBy?.name;
}
meta = isLatestRevision ? (
<>
{t("Current version")} &middot; {item.createdBy?.name}
{t("Current version")} &middot; {collaboratorText}
</>
) : (
t("{{userName}} edited", { userName: item.createdBy?.name })
t("{{userName}} edited", { userName: collaboratorText })
);
to = {
pathname: documentHistoryPath(
document,
isLatestRevision ? "latest" : item.id
),
search: location.search,
state: {
sidebarContext,
retainScrollPosition: true,
@@ -153,7 +149,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
<Avatar model={item.createdBy} size={AvatarSize.Large} />
)
}
subtitle={meta}
subtitle={<Meta>{meta}</Meta>}
actions={
isActive ? (
<StyledEventBoundary>
@@ -161,8 +157,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
</StyledEventBoundary>
) : undefined
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
$menuOpen={menuOpen}
{...rest}
@@ -172,6 +166,10 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
);
};
const Meta = styled.div`
${ellipsis()})
`;
const IconWrapper = styled(Text)`
height: 24px;
min-width: 24px;

View File

@@ -42,6 +42,8 @@ export default class ComponentView {
isSelected = false;
/** The DOM element that the node is rendered into. */
dom: HTMLElement | null;
/** The base class name for the node's DOM element. */
className?: string;
// See https://prosemirror.net/docs/ref/#view.NodeView
constructor(
@@ -66,23 +68,60 @@ export default class ComponentView {
? document.createElement("span")
: document.createElement("div");
this.dom.classList.add(`component-${node.type.name}`);
this.className = `component-${node.type.name}`;
this.dom.classList.add(this.className);
this.renderer = new NodeViewRenderer(this.dom, this.component, this.props);
// Add the renderer to the editor's set of renderers so that it is included in the React tree.
this.editor.renderers.add(this.renderer);
// Apply decoration classes to the DOM element.
this.applyDecorationClasses();
}
update(node: ProsemirrorNode) {
update(node: ProsemirrorNode, decorations: Decoration[]) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.decorations = decorations;
this.applyDecorationClasses();
this.renderer.updateProps(this.props);
return true;
}
/**
* Apply decoration classes to the DOM element.
* Extracts classes from inline decorations that overlap with this node's position.
*/
private applyDecorationClasses() {
if (!this.dom) {
return;
}
// Remove all existing decoration classes.
this.dom.classList.forEach((className) => {
if (className !== this.className) {
this.dom?.classList.remove(className);
}
});
// Apply classes from inline decorations.
this.decorations.forEach((decoration) => {
// For inline decorations, attrs contain the class property.
const attrs = (decoration as any).type?.attrs;
if (attrs?.class) {
const classes = attrs.class.split(" ");
classes.forEach((className: string) => {
if (className && this.dom) {
this.dom.classList.add(className);
}
});
}
});
}
selectNode() {
if (this.view.editable) {
this.isSelected = true;
@@ -117,6 +156,7 @@ export default class ComponentView {
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
decorations: this.decorations,
} as ComponentProps;
}
}

View File

@@ -65,9 +65,9 @@ export type Props = {
/** The user id of the current user */
userId?: string;
/** The editor content, should only be changed if you wish to reset the content */
value?: string | ProsemirrorData;
/** The initial editor content as a markdown string or JSON object */
defaultValue: string | object;
value?: string | ProsemirrorData | ProsemirrorNode;
/** The initial editor content as a markdown string, JSON object, or ProsemirrorNode */
defaultValue: string | ProsemirrorData | ProsemirrorNode;
/** Placeholder displayed when the editor is empty */
placeholder: string;
/** Extensions to load into the editor */
@@ -395,7 +395,7 @@ export class Editor extends React.PureComponent<
});
}
private createState(value?: string | object) {
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
const doc = this.createDocument(value || this.props.defaultValue);
return EditorState.create({
@@ -417,7 +417,12 @@ export class Editor extends React.PureComponent<
});
}
private createDocument(content: string | object) {
private createDocument(content: string | object | ProsemirrorNode) {
// Already a ProsemirrorNode
if (content instanceof ProsemirrorNode) {
return content;
}
// Looks like Markdown
if (typeof content === "string") {
return this.parser.parse(content) || undefined;

View File

@@ -4,12 +4,17 @@ declare global {
}
}
const env = window.env;
if (!env) {
if (!window.env) {
throw new Error(
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
);
}
const env: Record<string, any> = {
...window.env,
isDevelopment: window.env.ENVIRONMENT === "development",
isTest: window.env.ENVIRONMENT === "test",
isProduction: window.env.ENVIRONMENT === "production",
};
export default env;

View File

@@ -414,7 +414,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get isTasks(): boolean {
return !!this.tasks.total;
return !!this.tasks?.total;
}
@computed

View File

@@ -6,6 +6,8 @@ import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type RevisionsStore from "~/stores/RevisionsStore";
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
class Revision extends ParanoidModel {
static modelName = "Revision";
@@ -71,6 +73,33 @@ class Revision extends ParanoidModel {
get rtl() {
return isRTL(this.title);
}
/**
* Returns the previous revision (chronologically earlier) for comparison.
*
* Revisions are sorted by creation date (newest first), so the "previous" revision
* is the one that comes after the current revision in the sorted list.
*
* @returns The previous revision or null if this is the first revision.
*/
@computed
get before(): Revision | null {
const allRevisions = (this.store as RevisionsStore).getByDocumentId(
this.documentId
);
const currentIndex = allRevisions.findIndex(
(r: Revision) => r.id === this.id
);
return currentIndex >= 0 && currentIndex < allRevisions.length - 1
? allRevisions[currentIndex + 1]
: null;
}
@computed
get changeset() {
return ChangesetHelper.getChangeset(this.data, this.before?.data);
}
}
export default Revision;

View File

@@ -21,7 +21,9 @@ import {
matchDocumentSlug as documentSlug,
matchCollectionSlug as collectionSlug,
trashPath,
debugPath,
} from "~/utils/routeHelpers";
import env from "~/env";
const SettingsRoutes = lazy(() => import("./settings"));
const Archive = lazy(() => import("~/scenes/Archive"));
@@ -31,6 +33,8 @@ const Drafts = lazy(() => import("~/scenes/Drafts"));
const Home = lazy(() => import("~/scenes/Home"));
const Search = lazy(() => import("~/scenes/Search"));
const Trash = lazy(() => import("~/scenes/Trash"));
const Debug = lazy(() => import("~/scenes/Developer/Debug"));
const Changesets = lazy(() => import("~/scenes/Developer/Changesets"));
const RedirectDocument = ({
match,
@@ -120,6 +124,16 @@ function AuthenticatedRoutes() {
path={`${searchPath()}/:query?`}
component={Search}
/>
{env.isDevelopment && (
<>
<Route exact path={debugPath()} component={Debug} />
<Route
exact
path={`${debugPath()}/changesets`}
component={Changesets}
/>
</>
)}
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />

View File

@@ -0,0 +1,197 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import ListItem from "~/components/List/Item";
import Scene from "~/components/Scene";
import RevisionViewer from "~/scenes/Document/components/RevisionViewer";
import stores from "~/stores";
import { examples } from "./components/ExampleData";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import usePersistedState from "~/hooks/usePersistedState";
import Scrollable from "~/components/Scrollable";
import Switch from "~/components/Switch";
import { action } from "mobx";
/**
* Changesets scene for developer playground.
* Provides a way to test and visualize different ProseMirror diff scenarios.
*/
function Changesets() {
const { ui } = useStores();
const history = useHistory();
const query = useQuery();
const [showChangeset, setShowChangeset] = usePersistedState<boolean>(
"show-changeset-json",
false
);
const [showBeforeAfterDocs, setShowBeforeAfterDocs] =
usePersistedState<boolean>("show-before-after-docs", false);
const id = query.get("id");
const selectedExample = examples.find((e) => e.id === id) ?? examples[0];
/**
* We use a side effect to sync the mock models in the store when the example changes.
* This ensures that MobX reactions in RevisionViewer and the model computed properties
* (like `changeset`) are triggered correctly.
*/
React.useEffect(
action(() => {
stores.revisions.data.clear();
stores.documents.data.clear();
// Mock the main document (after state)
stores.documents.add({
id: "mock-document-id",
title: selectedExample.name,
urlId: "mock-document-id",
createdAt: "2024-01-01T12:00:00.000Z",
updatedAt: "2024-01-02T12:00:00.000Z",
data: selectedExample.after,
});
// Mock the "before" revision
stores.revisions.add({
id: "mock-before-revision-" + id,
documentId: "mock-document-id",
title: "Before",
createdAt: "2024-01-01T12:00:00.000Z",
data: selectedExample.before,
});
// Mock the "after" revision
stores.revisions.add({
id: "mock-after-revision-" + id,
documentId: "mock-document-id",
title: "After",
createdAt: "2024-01-02T12:00:00.000Z",
data: selectedExample.after,
});
// Mock the revision that will be used for diffing
// Revisions are sorted by createdAt desc in the store.
// The "before" version must be older than the "after" version.
stores.revisions.add({
id: "mock-diff-revision-" + id,
documentId: "mock-document-id",
title: selectedExample.name,
createdAt: "2024-01-02T12:00:00.000Z",
data: selectedExample.after,
});
}),
[selectedExample, id]
);
const mockDocument = stores.documents.get("mock-document-id");
const mockDiffRevision = stores.revisions.get("mock-diff-revision-" + id);
const mockBeforeRevision = stores.revisions.get("mock-before-revision-" + id);
const mockAfterRevision = stores.revisions.get("mock-after-revision-" + id);
return (
<Scene title="Changeset Playground" centered>
<Sidebar
style={{ left: (ui.sidebarCollapsed ? 16 : ui.sidebarWidth) + 8 }}
column
>
<Flex style={{ padding: "0 8px 32px" }} shrink={false} column>
<Switch
label="Show JSON"
checked={showChangeset}
onChange={(checked) => setShowChangeset(checked)}
labelPosition="right"
/>
<Switch
label="Show Before/After Docs"
checked={showBeforeAfterDocs}
onChange={(checked) => setShowBeforeAfterDocs(checked)}
labelPosition="right"
/>
</Flex>
<Scrollable>
{examples.map((example) => (
<ExampleItem
key={example.id}
title={example.name}
onClick={() =>
history.push({
search: `?id=${example.id}`,
})
}
$active={selectedExample.id === example.id}
border={false}
/>
))}
</Scrollable>
</Sidebar>
<Flex auto column>
{mockDocument && mockDiffRevision ? (
<>
<RevisionViewer
key={mockDiffRevision.id} // Force remount on example change
document={mockDocument}
revision={mockDiffRevision}
id={mockDiffRevision.id}
showChanges={true}
/>
{showBeforeAfterDocs && mockBeforeRevision && mockAfterRevision && (
<>
<RevisionViewer
document={mockDocument}
revision={mockBeforeRevision}
id={mockBeforeRevision.id}
showChanges={false}
/>
<RevisionViewer
document={mockDocument}
revision={mockAfterRevision}
id={mockAfterRevision.id}
showChanges={false}
/>
</>
)}
{showChangeset && (
<>
<Heading>Changeset</Heading>
<Pre>
{JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
</Pre>
</>
)}
</>
) : null}
</Flex>
</Scene>
);
}
const Sidebar = styled(Flex)`
position: absolute;
top: 110px;
bottom: 0;
`;
const ExampleItem = styled(ListItem)<{ $active: boolean }>`
padding: 4px 8px;
min-height: 0;
margin: 1px 0 0 0;
border-radius: 4px;
background: ${(props) =>
props.$active ? props.theme.sidebarActiveBackground : "transparent"};
`;
const Pre = styled.pre`
background: ${(props) => props.theme.codeBackground};
color: ${(props) => props.theme.code};
padding: 16px;
margin: 16px 0;
border-radius: 4px;
font-size: 12px;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
`;
export default observer(Changesets);

View File

@@ -0,0 +1,17 @@
import { Link } from "react-router-dom";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { debugChangesetsPath } from "~/utils/routeHelpers";
export default function Debug() {
return (
<Scene title="Debug">
<Heading>Debug</Heading>
<ul style={{ paddingLeft: 16 }}>
<li>
<Link to={debugChangesetsPath()}>Changeset playground</Link>
</li>
</ul>
</Scene>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import { observer } from "mobx-react";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import Diff from "@shared/editor/extensions/Diff";
import { CaretDownIcon, CaretUpIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Button from "~/components/Button";
import Tooltip from "~/components/Tooltip";
import { type Editor } from "~/editor";
import useQuery from "~/hooks/useQuery";
import type Revision from "~/models/Revision";
type Props = {
revision: Revision;
editorRef: React.RefObject<Editor>;
};
export const ChangesNavigation = observer(function ChangesNavigation_({
editorRef,
}: Props) {
const { t } = useTranslation();
const query = useQuery();
const showChanges = query.get("changes");
if (!showChanges) {
return null;
}
const diffExtension = editorRef.current?.extensions.extensions.find(
(ext) => ext instanceof Diff
) as Diff | undefined;
const currentChangeIndex = diffExtension?.getCurrentChangeIndex() ?? -1;
const totalChanges = diffExtension?.getTotalChangesCount() ?? 0;
return (
<>
{totalChanges > 0 && (
<Flex gap={4} align="center">
<NavigationLabel>
{currentChangeIndex >= 0
? t("{{ current }} of {{ count }} changes", {
current: currentChangeIndex + 1,
count: totalChanges,
})
: t("{{ count }} changes", {
count: totalChanges,
})}
</NavigationLabel>
<Tooltip content={t("Previous change")} placement="bottom">
<NavigationButton
icon={<CaretUpIcon />}
onClick={() => editorRef.current?.commands.prevChange()}
aria-label={t("Previous change")}
/>
</Tooltip>
<Tooltip content={t("Next change")} placement="bottom">
<NavigationButton
icon={<CaretDownIcon />}
onClick={() => editorRef.current?.commands.nextChange()}
aria-label={t("Next change")}
/>
</Tooltip>
</Flex>
)}
</>
);
});
const NavigationButton = styled(Button).attrs({
borderOnHover: true,
neutral: true,
})`
width: 32px;
height: 32px;
`;
const NavigationLabel = styled.span`
color: ${s("textSecondary")};
font-size: 14px;
font-weight: 500;
margin-right: 8px;
user-select: none;
`;

View File

@@ -104,9 +104,11 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchRevision() {
if (revisionId && revisionId !== "latest") {
if (revisionId) {
try {
await revisions.fetch(revisionId);
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
revisionId
);
} catch (err) {
setError(err);
}
@@ -115,19 +117,6 @@ function DataLoader({ match, children }: Props) {
void fetchRevision();
}, [revisions, revisionId]);
React.useEffect(() => {
async function fetchRevision() {
if (document && revisionId === "latest") {
try {
await revisions.fetchLatest(document.id);
} catch (err) {
setError(err);
}
}
}
void fetchRevision();
}, [document, revisionId, revisions]);
React.useEffect(() => {
async function fetchViews() {
if (document?.id && !document?.isDeleted && !revisionId) {

View File

@@ -528,6 +528,7 @@ class DocumentScene extends React.Component<Props> {
/>
)}
<Header
editorRef={this.editor}
document={document}
revision={revision}
isDraft={document.isDraft}
@@ -557,23 +558,22 @@ class DocumentScene extends React.Component<Props> {
</EditorContainer>
}
>
{revision ? (
<RevisionContainer docFullWidth={document.fullWidth}>
<MeasuredContainer
name="document"
as={EditorContainer}
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPos}
>
{revision ? (
<RevisionViewer
ref={this.editor}
document={document}
revision={revision}
id={revision.id}
/>
</RevisionContainer>
) : (
<>
<MeasuredContainer
name="document"
as={EditorContainer}
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPos}
>
) : (
<>
<Notices document={document} readOnly={readOnly} />
{showContents && (
@@ -616,16 +616,16 @@ class DocumentScene extends React.Component<Props> {
</ReferencesWrapper>
) : null}
</Editor>
</MeasuredContainer>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPos}
>
<Contents />
</ContentsContainer>
)}
</>
</>
)}
</MeasuredContainer>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPos}
>
<Contents />
</ContentsContainer>
)}
</React.Suspense>
</Main>
@@ -724,21 +724,6 @@ const EditorContainer = styled.div<EditorContainerProps>`
`};
`;
type RevisionContainerProps = {
docFullWidth: boolean;
};
const RevisionContainer = styled.div<RevisionContainerProps>`
// Adds space to the gutter to make room for icon
padding: 0 40px;
${breakpoint("tablet")`
grid-row: 1;
grid-column: ${({ docFullWidth }: RevisionContainerProps) =>
docFullWidth ? "1 / -1" : 2};
`}
`;
const Background = styled(Container)`
position: relative;
background: ${s("background")};

View File

@@ -40,8 +40,11 @@ import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
import useShare from "@shared/hooks/useShare";
import { type Editor } from "~/editor";
import { ChangesNavigation } from "./ChangesNavigation";
type Props = {
editorRef: React.RefObject<Editor>;
document: Document;
revision: Revision | undefined;
isDraft: boolean;
@@ -59,6 +62,7 @@ type Props = {
};
function DocumentHeader({
editorRef,
document,
revision,
isEditing,
@@ -303,14 +307,27 @@ function DocumentHeader({
<NewChildDocumentMenu document={document} />
</Action>
)}
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button action={restoreRevision} neutral hideOnActionDisabled>
{t("Restore")}
</Button>
</Tooltip>
</Action>
{revision && (
<>
<Action>
<ChangesNavigation
revision={revision}
editorRef={editorRef}
/>
</Action>
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button
action={restoreRevision}
disabled={revision.createdAt === document.updatedAt}
neutral
hideOnActionDisabled
>
{t("Restore")}
</Button>
</Tooltip>
</Action>
</>
)}
{can.publish && (
<Action>

View File

@@ -16,6 +16,10 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
import Switch from "~/components/Switch";
import Text from "@shared/components/Text";
import usePersistedState from "~/hooks/usePersistedState";
import Scrollable from "~/components/Scrollable";
const DocumentEvents = [
"documents.publish",
@@ -40,6 +44,53 @@ function History() {
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const [defaultShowChanges, setDefaultShowChanges] =
usePersistedState<boolean>("history-show-changes", true);
const searchParams = new URLSearchParams(history.location.search);
const [showChanges, setShowChanges] = React.useState(
searchParams.get("changes") === "true" || defaultShowChanges
);
const updateLocation = React.useCallback(
(changes: Record<string, string | null>) => {
const params = new URLSearchParams(history.location.search);
Object.entries(changes).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
});
const search = params.toString();
history.replace({
pathname: history.location.pathname,
search: search ? `?${search}` : "",
state: history.location.state,
});
},
[history]
);
// Handler for toggling the "Show Changes" switch, updating state and URL parameter
const handleShowChangesToggle = React.useCallback(
(checked: boolean) => {
setShowChanges(checked);
setDefaultShowChanges(checked);
updateLocation({ changes: checked ? "true" : null });
},
[history]
);
// Ensure that the URL parameter is in sync with the persisted state on mount
React.useEffect(() => {
if (defaultShowChanges) {
updateLocation({ changes: "true" });
}
}, [defaultShowChanges]);
const fetchHistory = React.useCallback(async () => {
if (!document) {
return [];
@@ -144,22 +195,40 @@ function History() {
useKeyDown("Escape", onCloseHistory);
return (
<Sidebar title={t("History")} onClose={onCloseHistory}>
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={fetchHistory}
items={items}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
/>
) : null}
<Sidebar title={t("History")} onClose={onCloseHistory} scrollable={false}>
<Content>
<Text type="secondary" size="small" as="span">
<Switch
label={t("Highlight changes")}
checked={showChanges}
onChange={handleShowChangesToggle}
/>
</Text>
</Content>
<Scrollable hiddenScrollbars topShadow>
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={fetchHistory}
items={items}
document={document}
empty={
<Content>
<Empty>{t("No history yet")}</Empty>
</Content>
}
/>
) : null}
</Scrollable>
</Sidebar>
);
}
const EmptyHistory = styled(Empty)`
padding: 0 12px;
const Content = styled.div`
margin: 0 16px 8px;
border: 1px solid ${(props) => props.theme.inputBorder};
border-radius: 8px;
padding: 8px 8px 0;
`;
export default observer(History);

View File

@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import EditorContainer from "@shared/editor/components/Styles";
import { colorPalette } from "@shared/utils/collections";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
@@ -9,6 +8,11 @@ import Flex from "~/components/Flex";
import { documentPath } from "~/utils/routeHelpers";
import { Meta as DocumentMeta } from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
import Editor from "~/components/Editor";
import { richExtensions, withComments } from "@shared/editor/nodes";
import Diff from "@shared/editor/extensions/Diff";
import useQuery from "~/hooks/useQuery";
import { type Editor as TEditor } from "~/editor";
type Props = Omit<EditorProps, "extensions"> & {
/** The ID of the revision */
@@ -17,14 +21,38 @@ type Props = Omit<EditorProps, "extensions"> & {
document: Document;
/** The revision to display */
revision: Revision;
/** Whether to show changes from the previous revision */
showChanges?: boolean;
children?: React.ReactNode;
};
/**
* Displays revision HTML pre-rendered on the server.
* Displays a revision with diff highlighting showing changes from the previous revision.
*
* This component shows the content of a specific revision with visual diff indicators
* that highlight what changed compared to the revision that came before it. Insertions
* are shown with a highlight background, and deletions are shown with strikethrough.
*
* @param props - Component props including the revision to display and current document
*/
function RevisionViewer(props: Props) {
function RevisionViewer(props: Props, ref: React.Ref<TEditor>) {
const { document, children, revision } = props;
const query = useQuery();
const showChanges = props.showChanges ?? query.has("changes");
/**
* Create editor extensions with the Diff extension configured to render
* the calculated changes as decorations in the editor.
*/
const extensions = React.useMemo(
() => [
...withComments(richExtensions),
...(showChanges && revision.changeset?.changes
? [new Diff({ changes: revision.changeset?.changes })]
: []),
],
[revision.changeset, showChanges]
);
return (
<Flex auto column>
@@ -41,10 +69,11 @@ function RevisionViewer(props: Props) {
to={documentPath(document)}
rtl={revision.rtl}
/>
<EditorContainer
dangerouslySetInnerHTML={{ __html: revision.html }}
<Editor
ref={ref}
defaultValue={revision.data}
extensions={extensions}
dir={revision.dir}
rtl={revision.rtl}
readOnly
/>
{children}
@@ -52,4 +81,4 @@ function RevisionViewer(props: Props) {
);
}
export default observer(RevisionViewer);
export default observer(React.forwardRef(RevisionViewer));

View File

@@ -104,7 +104,7 @@ class ApiClient {
Accept: "application/json",
"cache-control": "no-cache",
"x-editor-version": EDITOR_VERSION,
"x-api-version": "3",
"x-api-version": "4",
pragma: "no-cache",
...options?.headers,
};

View File

@@ -27,6 +27,14 @@ export function trashPath(): string {
return "/trash";
}
export function debugPath(): string {
return "/debug";
}
export function debugChangesetsPath(): string {
return "/debug/changesets";
}
export function settingsPath(...args: string[]): string {
return "/settings" + (args.length > 0 ? `/${args.join("/")}` : "");
}

View File

@@ -195,6 +195,7 @@
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-changeset": "2.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",

View File

@@ -4,7 +4,7 @@ import type { Revision } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import presentUser from "./user";
async function presentRevision(revision: Revision, diff?: string) {
async function presentRevision(revision: Revision, html?: string) {
// TODO: Remove this fallback once all revisions have been migrated
const { emoji, strippedTitle } = parseTitle(revision.title);
@@ -16,10 +16,10 @@ async function presentRevision(revision: Revision, diff?: string) {
data: await DocumentHelper.toJSON(revision),
icon: revision.icon ?? emoji,
color: revision.color,
html: diff,
collaborators: (await revision.collaborators).map((user) =>
presentUser(user)
),
html,
createdAt: revision.createdAt,
createdBy: presentUser(revision.user),
createdById: revision.userId,

View File

@@ -52,13 +52,18 @@ router.post(
throw ValidationError("Either id or documentId must be provided");
}
// Client no longer needs expensive HTML calculation
const noHTML = Number(ctx.headers["x-api-version"] ?? 0) >= 4;
ctx.body = {
data: await presentRevision(
after,
await DocumentHelper.diff(before, after, {
includeTitle: false,
includeStyles: false,
})
noHTML
? undefined
: await DocumentHelper.diff(before, after, {
includeTitle: false,
includeStyles: false,
})
),
policies: presentPolicies(user, [after]),
};

View File

@@ -23,10 +23,10 @@ export const fadeIn = keyframes`
to { opacity: 1; }
`;
export const pulse = keyframes`
0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) }
100% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
export const pulse = (color: string) => keyframes`
0% { box-shadow: 0 0 0 1px ${color} }
50% { box-shadow: 0 0 0 4px ${color} }
100% { box-shadow: 0 0 0 1px ${color} }
`;
const codeMarkCursor = () => css`
@@ -275,6 +275,127 @@ const codeBlockStyle = (props: Props) => css`
}
`;
const diffStyle = (props: Props) => css`
.${EditorStyleHelper.diffNodeInsertion},
.${EditorStyleHelper.diffInsertion}:not([class^="component-"]),
.${EditorStyleHelper.diffInsertion} > * {
color: ${props.theme.textDiffInserted};
background-color: ${props.theme.textDiffInsertedBackground};
text-decoration: none;
&.${EditorStyleHelper.diffCurrentChange} {
outline-color: ${lighten(0.2, props.theme.textDiffInserted)};
background-color: ${lighten(0.2, props.theme.textDiffInsertedBackground)};
animation: ${pulse(lighten(0.2, props.theme.textDiffInsertedBackground))}
150ms 1;
}
}
.${EditorStyleHelper.diffNodeInsertion} {
&[class*="component-"] {
outline: 4px solid ${props.theme.textDiffInsertedBackground};
}
td,
th {
border-color: ${props.theme.textDiffInsertedBackground};
}
}
.${EditorStyleHelper.diffNodeInsertion}[class*="component-"],
.${EditorStyleHelper.diffNodeInsertion}.math-node,
ul.${EditorStyleHelper.diffNodeInsertion},
li.${EditorStyleHelper.diffNodeInsertion} {
border-radius: ${EditorStyleHelper.blockRadius};
}
td.${EditorStyleHelper.diffNodeInsertion},
th.${EditorStyleHelper.diffNodeInsertion} {
border-color: ${props.theme.textDiffInsertedBackground};
}
.${EditorStyleHelper.diffNodeDeletion},
.${EditorStyleHelper.diffDeletion}:not([class^="component-"]),
.${EditorStyleHelper.diffDeletion} > * {
color: ${props.theme.textDiffDeleted};
background-color: ${props.theme.textDiffDeletedBackground};
text-decoration: line-through;
&.${EditorStyleHelper.diffCurrentChange} {
outline-color: ${lighten(0.2, props.theme.textDiffDeletedBackground)};
background-color: ${lighten(0.2, props.theme.textDiffDeletedBackground)};
animation: ${pulse(lighten(0.2, props.theme.textDiffDeletedBackground))}
150ms 1;
}
}
.${EditorStyleHelper.diffNodeDeletion} {
&[class*="component-"] {
outline: 4px solid ${props.theme.textDiffDeletedBackground};
}
.mention {
background-color: ${props.theme.textDiffDeletedBackground};
}
td,
th {
border-color: ${props.theme.textDiffDeletedBackground};
}
}
.${EditorStyleHelper.diffNodeDeletion}[class*="component-"],
.${EditorStyleHelper.diffNodeDeletion}.math-node,
ul.${EditorStyleHelper.diffNodeDeletion},
li.${EditorStyleHelper.diffNodeDeletion} {
border-radius: ${EditorStyleHelper.blockRadius};
}
td.${EditorStyleHelper.diffNodeDeletion},
th.${EditorStyleHelper.diffNodeDeletion} {
border-color: ${props.theme.textDiffDeletedBackground};
}
.${EditorStyleHelper.diffNodeModification},
.${EditorStyleHelper.diffModification}:not([class^="component-"]),
.${EditorStyleHelper.diffModification} > * {
color: ${props.theme.text};
background-color: ${transparentize(0.7, "#FFA500")};
text-decoration: none;
&.${EditorStyleHelper.diffCurrentChange} {
outline-color: ${lighten(0.1, "#FFA500")};
background-color: ${transparentize(0.5, "#FFA500")};
animation: ${pulse(transparentize(0.5, "#FFA500"))} 150ms 1;
}
}
.${EditorStyleHelper.diffNodeModification} {
background-color: ${transparentize(0.7, "#FFA500")};
&[class*="component-"] {
outline: 4px solid ${transparentize(0.5, "#FFA500")};
}
td,
th {
border-color: ${transparentize(0.5, "#FFA500")};
}
}
.${EditorStyleHelper.diffNodeModification}[class*="component-"],
.${EditorStyleHelper.diffNodeModification}.math-node,
ul.${EditorStyleHelper.diffNodeModification},
li.${EditorStyleHelper.diffNodeModification} {
border-radius: ${EditorStyleHelper.blockRadius};
}
td.${EditorStyleHelper.diffNodeModification},
th.${EditorStyleHelper.diffNodeModification} {
border-color: ${transparentize(0.5, "#FFA500")};
}
`;
const findAndReplaceStyle = () => css`
.find-result:not(:has(.mention)),
.find-result .mention {
@@ -284,7 +405,7 @@ const findAndReplaceStyle = () => css`
.find-result.current-result:not(:has(.mention)),
.find-result.current-result .mention {
background: rgba(255, 213, 0, 0.75);
animation: ${pulse} 150ms 1;
animation: ${pulse("rgba(255, 213, 0, 0.75)")} 150ms 1;
}
}
`;
@@ -805,6 +926,10 @@ img.ProseMirror-separator {
margin: 0 !important;
}
.component-image {
display: block;
}
// Removes forced paragraph spaces below images, this is needed to images
// being inline nodes that are displayed like blocks
.component-image + img.ProseMirror-separator,
@@ -2107,19 +2232,18 @@ del {
text-decoration: strikethrough;
}
// TODO: Remove once old email diff rendering is removed.
ins[data-operation-index] {
color: ${props.theme.textDiffInserted};
background-color: ${props.theme.textDiffInsertedBackground};
text-decoration: none;
}
del[data-operation-index] {
color: ${props.theme.textDiffDeleted};
background-color: ${props.theme.textDiffDeletedBackground};
text-decoration: none;
img {
opacity: .5;
opacity: 0.5;
}
}
@@ -2167,6 +2291,7 @@ const EditorContainer = styled.div<Props>`
${mathStyle}
${codeMarkCursor}
${codeBlockStyle}
${diffStyle}
${findAndReplaceStyle}
${emailStyle}
${textStyle}

View File

@@ -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 <p> tag
// and unwrap the paragraph content to avoid nested <p> 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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
newAttrs: Record<string, unknown>;
};
};
/**
* 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<string | number> {
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<string, number> =
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<number>();
const matchedInsertionIndices = new Set<number>();
// 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<string> => {
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();
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 */

View File

@@ -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 {

View File

@@ -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",

View File

@@ -126,7 +126,7 @@ export const buildLightTheme = (input: Partial<Colors>): 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<Colors>): 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",

View File

@@ -553,7 +553,7 @@ export type ProsemirrorData = {
attrs?: JSONObject;
marks?: {
type: string;
attrs: JSONObject;
attrs?: JSONObject;
}[];
};

View File

@@ -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;
}
}

View File

@@ -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 ||

View File

@@ -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"