mirror of
https://github.com/outline/outline.git
synced 2025-12-29 23:09:55 -06:00
feat: Improved revision viewer (#10824)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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")} · {item.createdBy?.name}
|
||||
{t("Current version")} · {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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
app/env.ts
11
app/env.ts
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
197
app/scenes/Developer/Changesets.tsx
Normal file
197
app/scenes/Developer/Changesets.tsx
Normal 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);
|
||||
17
app/scenes/Developer/Debug.tsx
Normal file
17
app/scenes/Developer/Debug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1784
app/scenes/Developer/components/ExampleData.ts
Normal file
1784
app/scenes/Developer/components/ExampleData.ts
Normal file
File diff suppressed because it is too large
Load Diff
84
app/scenes/Document/components/ChangesNavigation.tsx
Normal file
84
app/scenes/Document/components/ChangesNavigation.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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("/")}` : "");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
342
shared/editor/extensions/Diff.ts
Normal file
342
shared/editor/extensions/Diff.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
277
shared/editor/lib/ChangesetHelper.ts
Normal file
277
shared/editor/lib/ChangesetHelper.ts
Normal 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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -553,7 +553,7 @@ export type ProsemirrorData = {
|
||||
attrs?: JSONObject;
|
||||
marks?: {
|
||||
type: string;
|
||||
attrs: JSONObject;
|
||||
attrs?: JSONObject;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
11
shared/typings/prosemirror-model.d.ts
vendored
11
shared/typings/prosemirror-model.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
10
yarn.lock
10
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"
|
||||
|
||||
Reference in New Issue
Block a user