mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03: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
|
## General Guidelines
|
||||||
|
|
||||||
|
- Critical – Do not create new markdown (.md) files.
|
||||||
- Use early returns for readability.
|
- Use early returns for readability.
|
||||||
- Emphasize type safety and static analysis.
|
- Emphasize type safety and static analysis.
|
||||||
- Follow consistent Prettier formatting.
|
- Follow consistent Prettier formatting.
|
||||||
- Do not replace smart quotes ("") or ('') with simple quotes ("").
|
- Do not replace smart quotes ("") or ('') with simple quotes ("").
|
||||||
- Do not create new MD files.
|
|
||||||
|
|
||||||
## Dependencies and Upgrading
|
## Dependencies and Upgrading
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ yarn install
|
|||||||
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
|
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
|
||||||
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
|
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
|
||||||
- Use descriptive prop types with TypeScript interfaces.
|
- 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.
|
- Use styled-components for component styling.
|
||||||
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
|
- 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 Logger from "~/utils/Logger";
|
||||||
import { deleteAllDatabases } from "~/utils/developer";
|
import { deleteAllDatabases } from "~/utils/developer";
|
||||||
import history from "~/utils/history";
|
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({
|
export const copyId = createActionWithChildren({
|
||||||
name: ({ t }) => t("Copy ID"),
|
name: ({ t }) => t("Copy ID"),
|
||||||
@@ -222,6 +232,7 @@ export const developer = createActionWithChildren({
|
|||||||
iconInContextMenu: false,
|
iconInContextMenu: false,
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
children: [
|
children: [
|
||||||
|
goToDebug,
|
||||||
copyId,
|
copyId,
|
||||||
toggleDebugLogging,
|
toggleDebugLogging,
|
||||||
toggleDebugSafeArea,
|
toggleDebugSafeArea,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { differenceInMinutes } from "date-fns";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import type Document from "~/models/Document";
|
import type Document from "~/models/Document";
|
||||||
import type Event from "~/models/Event";
|
import Event from "~/models/Event";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import PaginatedList from "~/components/PaginatedList";
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
import EventListItem from "./EventListItem";
|
import EventListItem from "./EventListItem";
|
||||||
@@ -27,6 +28,26 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
|||||||
document,
|
document,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<StyledPaginatedList
|
<StyledPaginatedList
|
||||||
items={items}
|
items={items}
|
||||||
@@ -34,6 +55,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
|||||||
heading={heading}
|
heading={heading}
|
||||||
fetch={fetch}
|
fetch={fetch}
|
||||||
options={options}
|
options={options}
|
||||||
|
isDuplicate={isDuplicate}
|
||||||
renderItem={(item: Item) =>
|
renderItem={(item: Item) =>
|
||||||
item instanceof Revision ? (
|
item instanceof Revision ? (
|
||||||
<RevisionListItem key={item.id} item={item} document={document} />
|
<RevisionListItem key={item.id} item={item} document={document} />
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export interface PaginatedItem {
|
|||||||
* Props for the PaginatedList component
|
* Props for the PaginatedList component
|
||||||
* @template T Type of items in the list, must extend PaginatedItem
|
* @template T Type of items in the list, must extend PaginatedItem
|
||||||
*/
|
*/
|
||||||
interface Props<T extends PaginatedItem>
|
interface Props<
|
||||||
extends React.HTMLAttributes<HTMLDivElement> {
|
T extends PaginatedItem,
|
||||||
|
> extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
/**
|
/**
|
||||||
* Function to fetch paginated data. Should return a promise resolving to an array of items
|
* Function to fetch paginated data. Should return a promise resolving to an array of items
|
||||||
* @param options Pagination and other query options
|
* @param options Pagination and other query options
|
||||||
@@ -79,6 +80,12 @@ interface Props<T extends PaginatedItem>
|
|||||||
*/
|
*/
|
||||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
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
|
* Handler for escape key press
|
||||||
* @param ev Keyboard event object
|
* @param ev Keyboard event object
|
||||||
@@ -106,6 +113,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
renderError,
|
renderError,
|
||||||
renderHeading,
|
renderHeading,
|
||||||
|
isDuplicate,
|
||||||
onEscape,
|
onEscape,
|
||||||
listRef,
|
listRef,
|
||||||
...rest
|
...rest
|
||||||
@@ -221,10 +229,19 @@ const PaginatedList = <T extends PaginatedItem>({
|
|||||||
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
||||||
|
|
||||||
// Computed property equivalent
|
// Computed property equivalent
|
||||||
const itemsToRender = React.useMemo(
|
const itemsToRender = React.useMemo(() => {
|
||||||
() => items?.slice(0, renderCount) ?? [],
|
const sliced = items?.slice(0, renderCount) ?? [];
|
||||||
[items, 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 =
|
const showLoading =
|
||||||
isFetching &&
|
isFetching &&
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { LocationDescriptor } from "history";
|
import type { LocationDescriptor } from "history";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { EditIcon, TrashIcon } from "outline-icons";
|
import { EditIcon, TrashIcon } from "outline-icons";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import EventBoundary from "@shared/components/EventBoundary";
|
import EventBoundary from "@shared/components/EventBoundary";
|
||||||
import { hover } from "@shared/styles";
|
import { ellipsis, hover } from "@shared/styles";
|
||||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||||
import type Document from "~/models/Document";
|
import type Document from "~/models/Document";
|
||||||
import type Revision from "~/models/Revision";
|
import type Revision from "~/models/Revision";
|
||||||
@@ -21,10 +21,8 @@ import { ContextMenu } from "~/components/Menu/ContextMenu";
|
|||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import useClickIntent from "~/hooks/useClickIntent";
|
|
||||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import RevisionMenu from "~/menus/RevisionMenu";
|
import RevisionMenu from "~/menus/RevisionMenu";
|
||||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||||
import { EventItem, lineStyle } from "./EventListItem";
|
import { EventItem, lineStyle } from "./EventListItem";
|
||||||
@@ -38,10 +36,8 @@ type Props = {
|
|||||||
|
|
||||||
const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { revisions } = useStores();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const sidebarContext = useLocationSidebarContext();
|
const sidebarContext = useLocationSidebarContext();
|
||||||
const revisionLoadedRef = useRef(false);
|
|
||||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||||
|
|
||||||
const isLatestRevision = RevisionHelper.latestId(document.id) === item.id;
|
const isLatestRevision = RevisionHelper.latestId(document.id) === item.id;
|
||||||
@@ -60,19 +56,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
|||||||
ref.current?.focus();
|
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;
|
let meta, icon, to: LocationDescriptor | undefined;
|
||||||
|
|
||||||
if (item.deletedAt) {
|
if (item.deletedAt) {
|
||||||
@@ -80,18 +63,31 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
|||||||
meta = t("Revision deleted");
|
meta = t("Revision deleted");
|
||||||
} else {
|
} else {
|
||||||
icon = <EditIcon size={16} />;
|
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 ? (
|
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 = {
|
to = {
|
||||||
pathname: documentHistoryPath(
|
pathname: documentHistoryPath(
|
||||||
document,
|
document,
|
||||||
isLatestRevision ? "latest" : item.id
|
isLatestRevision ? "latest" : item.id
|
||||||
),
|
),
|
||||||
|
search: location.search,
|
||||||
state: {
|
state: {
|
||||||
sidebarContext,
|
sidebarContext,
|
||||||
retainScrollPosition: true,
|
retainScrollPosition: true,
|
||||||
@@ -153,7 +149,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
|||||||
<Avatar model={item.createdBy} size={AvatarSize.Large} />
|
<Avatar model={item.createdBy} size={AvatarSize.Large} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtitle={meta}
|
subtitle={<Meta>{meta}</Meta>}
|
||||||
actions={
|
actions={
|
||||||
isActive ? (
|
isActive ? (
|
||||||
<StyledEventBoundary>
|
<StyledEventBoundary>
|
||||||
@@ -161,8 +157,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
|||||||
</StyledEventBoundary>
|
</StyledEventBoundary>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
$menuOpen={menuOpen}
|
$menuOpen={menuOpen}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -172,6 +166,10 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Meta = styled.div`
|
||||||
|
${ellipsis()})
|
||||||
|
`;
|
||||||
|
|
||||||
const IconWrapper = styled(Text)`
|
const IconWrapper = styled(Text)`
|
||||||
height: 24px;
|
height: 24px;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export default class ComponentView {
|
|||||||
isSelected = false;
|
isSelected = false;
|
||||||
/** The DOM element that the node is rendered into. */
|
/** The DOM element that the node is rendered into. */
|
||||||
dom: HTMLElement | null;
|
dom: HTMLElement | null;
|
||||||
|
/** The base class name for the node's DOM element. */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
// See https://prosemirror.net/docs/ref/#view.NodeView
|
// See https://prosemirror.net/docs/ref/#view.NodeView
|
||||||
constructor(
|
constructor(
|
||||||
@@ -66,23 +68,60 @@ export default class ComponentView {
|
|||||||
? document.createElement("span")
|
? document.createElement("span")
|
||||||
: document.createElement("div");
|
: 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);
|
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.
|
// 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);
|
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) {
|
if (node.type !== this.node.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.node = node;
|
this.node = node;
|
||||||
|
this.decorations = decorations;
|
||||||
|
this.applyDecorationClasses();
|
||||||
this.renderer.updateProps(this.props);
|
this.renderer.updateProps(this.props);
|
||||||
return true;
|
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() {
|
selectNode() {
|
||||||
if (this.view.editable) {
|
if (this.view.editable) {
|
||||||
this.isSelected = true;
|
this.isSelected = true;
|
||||||
@@ -117,6 +156,7 @@ export default class ComponentView {
|
|||||||
isSelected: this.isSelected,
|
isSelected: this.isSelected,
|
||||||
isEditable: this.view.editable,
|
isEditable: this.view.editable,
|
||||||
getPos: this.getPos,
|
getPos: this.getPos,
|
||||||
|
decorations: this.decorations,
|
||||||
} as ComponentProps;
|
} as ComponentProps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -65,9 +65,9 @@ export type Props = {
|
|||||||
/** The user id of the current user */
|
/** The user id of the current user */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
/** The editor content, should only be changed if you wish to reset the content */
|
/** The editor content, should only be changed if you wish to reset the content */
|
||||||
value?: string | ProsemirrorData;
|
value?: string | ProsemirrorData | ProsemirrorNode;
|
||||||
/** The initial editor content as a markdown string or JSON object */
|
/** The initial editor content as a markdown string, JSON object, or ProsemirrorNode */
|
||||||
defaultValue: string | object;
|
defaultValue: string | ProsemirrorData | ProsemirrorNode;
|
||||||
/** Placeholder displayed when the editor is empty */
|
/** Placeholder displayed when the editor is empty */
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
/** Extensions to load into the editor */
|
/** 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);
|
const doc = this.createDocument(value || this.props.defaultValue);
|
||||||
|
|
||||||
return EditorState.create({
|
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
|
// Looks like Markdown
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return this.parser.parse(content) || undefined;
|
return this.parser.parse(content) || undefined;
|
||||||
|
|||||||
+8
-3
@@ -4,12 +4,17 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = window.env;
|
if (!window.env) {
|
||||||
|
|
||||||
if (!env) {
|
|
||||||
throw new Error(
|
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"
|
"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;
|
export default env;
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
|||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isTasks(): boolean {
|
get isTasks(): boolean {
|
||||||
return !!this.tasks.total;
|
return !!this.tasks?.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import User from "./User";
|
|||||||
import ParanoidModel from "./base/ParanoidModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
import Field from "./decorators/Field";
|
import Field from "./decorators/Field";
|
||||||
import Relation from "./decorators/Relation";
|
import Relation from "./decorators/Relation";
|
||||||
|
import type RevisionsStore from "~/stores/RevisionsStore";
|
||||||
|
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||||
|
|
||||||
class Revision extends ParanoidModel {
|
class Revision extends ParanoidModel {
|
||||||
static modelName = "Revision";
|
static modelName = "Revision";
|
||||||
@@ -71,6 +73,33 @@ class Revision extends ParanoidModel {
|
|||||||
get rtl() {
|
get rtl() {
|
||||||
return isRTL(this.title);
|
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;
|
export default Revision;
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
matchDocumentSlug as documentSlug,
|
matchDocumentSlug as documentSlug,
|
||||||
matchCollectionSlug as collectionSlug,
|
matchCollectionSlug as collectionSlug,
|
||||||
trashPath,
|
trashPath,
|
||||||
|
debugPath,
|
||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
|
import env from "~/env";
|
||||||
|
|
||||||
const SettingsRoutes = lazy(() => import("./settings"));
|
const SettingsRoutes = lazy(() => import("./settings"));
|
||||||
const Archive = lazy(() => import("~/scenes/Archive"));
|
const Archive = lazy(() => import("~/scenes/Archive"));
|
||||||
@@ -31,6 +33,8 @@ const Drafts = lazy(() => import("~/scenes/Drafts"));
|
|||||||
const Home = lazy(() => import("~/scenes/Home"));
|
const Home = lazy(() => import("~/scenes/Home"));
|
||||||
const Search = lazy(() => import("~/scenes/Search"));
|
const Search = lazy(() => import("~/scenes/Search"));
|
||||||
const Trash = lazy(() => import("~/scenes/Trash"));
|
const Trash = lazy(() => import("~/scenes/Trash"));
|
||||||
|
const Debug = lazy(() => import("~/scenes/Developer/Debug"));
|
||||||
|
const Changesets = lazy(() => import("~/scenes/Developer/Changesets"));
|
||||||
|
|
||||||
const RedirectDocument = ({
|
const RedirectDocument = ({
|
||||||
match,
|
match,
|
||||||
@@ -120,6 +124,16 @@ function AuthenticatedRoutes() {
|
|||||||
path={`${searchPath()}/:query?`}
|
path={`${searchPath()}/:query?`}
|
||||||
component={Search}
|
component={Search}
|
||||||
/>
|
/>
|
||||||
|
{env.isDevelopment && (
|
||||||
|
<>
|
||||||
|
<Route exact path={debugPath()} component={Debug} />
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={`${debugPath()}/changesets`}
|
||||||
|
component={Changesets}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Route path="/404" component={Error404} />
|
<Route path="/404" component={Error404} />
|
||||||
<SettingsRoutes />
|
<SettingsRoutes />
|
||||||
<Route component={Error404} />
|
<Route component={Error404} />
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -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(() => {
|
React.useEffect(() => {
|
||||||
async function fetchRevision() {
|
async function fetchRevision() {
|
||||||
if (revisionId && revisionId !== "latest") {
|
if (revisionId) {
|
||||||
try {
|
try {
|
||||||
await revisions.fetch(revisionId);
|
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
|
||||||
|
revisionId
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
}
|
}
|
||||||
@@ -115,19 +117,6 @@ function DataLoader({ match, children }: Props) {
|
|||||||
void fetchRevision();
|
void fetchRevision();
|
||||||
}, [revisions, revisionId]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
async function fetchViews() {
|
async function fetchViews() {
|
||||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Header
|
<Header
|
||||||
|
editorRef={this.editor}
|
||||||
document={document}
|
document={document}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
@@ -557,23 +558,22 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{revision ? (
|
<MeasuredContainer
|
||||||
<RevisionContainer docFullWidth={document.fullWidth}>
|
name="document"
|
||||||
|
as={EditorContainer}
|
||||||
|
docFullWidth={document.fullWidth}
|
||||||
|
showContents={showContents}
|
||||||
|
tocPosition={tocPos}
|
||||||
|
>
|
||||||
|
{revision ? (
|
||||||
<RevisionViewer
|
<RevisionViewer
|
||||||
|
ref={this.editor}
|
||||||
document={document}
|
document={document}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
id={revision.id}
|
id={revision.id}
|
||||||
/>
|
/>
|
||||||
</RevisionContainer>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
|
||||||
<MeasuredContainer
|
|
||||||
name="document"
|
|
||||||
as={EditorContainer}
|
|
||||||
docFullWidth={document.fullWidth}
|
|
||||||
showContents={showContents}
|
|
||||||
tocPosition={tocPos}
|
|
||||||
>
|
|
||||||
<Notices document={document} readOnly={readOnly} />
|
<Notices document={document} readOnly={readOnly} />
|
||||||
|
|
||||||
{showContents && (
|
{showContents && (
|
||||||
@@ -616,16 +616,16 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
</ReferencesWrapper>
|
</ReferencesWrapper>
|
||||||
) : null}
|
) : null}
|
||||||
</Editor>
|
</Editor>
|
||||||
</MeasuredContainer>
|
</>
|
||||||
{showContents && (
|
)}
|
||||||
<ContentsContainer
|
</MeasuredContainer>
|
||||||
docFullWidth={document.fullWidth}
|
{showContents && (
|
||||||
position={tocPos}
|
<ContentsContainer
|
||||||
>
|
docFullWidth={document.fullWidth}
|
||||||
<Contents />
|
position={tocPos}
|
||||||
</ContentsContainer>
|
>
|
||||||
)}
|
<Contents />
|
||||||
</>
|
</ContentsContainer>
|
||||||
)}
|
)}
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</Main>
|
</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)`
|
const Background = styled(Container)`
|
||||||
position: relative;
|
position: relative;
|
||||||
background: ${s("background")};
|
background: ${s("background")};
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ import PublicBreadcrumb from "./PublicBreadcrumb";
|
|||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
import { AppearanceAction } from "~/components/Sharing/components/Actions";
|
import { AppearanceAction } from "~/components/Sharing/components/Actions";
|
||||||
import useShare from "@shared/hooks/useShare";
|
import useShare from "@shared/hooks/useShare";
|
||||||
|
import { type Editor } from "~/editor";
|
||||||
|
import { ChangesNavigation } from "./ChangesNavigation";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
editorRef: React.RefObject<Editor>;
|
||||||
document: Document;
|
document: Document;
|
||||||
revision: Revision | undefined;
|
revision: Revision | undefined;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
@@ -59,6 +62,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function DocumentHeader({
|
function DocumentHeader({
|
||||||
|
editorRef,
|
||||||
document,
|
document,
|
||||||
revision,
|
revision,
|
||||||
isEditing,
|
isEditing,
|
||||||
@@ -303,14 +307,27 @@ function DocumentHeader({
|
|||||||
<NewChildDocumentMenu document={document} />
|
<NewChildDocumentMenu document={document} />
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{revision && revision.createdAt !== document.updatedAt && (
|
{revision && (
|
||||||
<Action>
|
<>
|
||||||
<Tooltip content={t("Restore version")} placement="bottom">
|
<Action>
|
||||||
<Button action={restoreRevision} neutral hideOnActionDisabled>
|
<ChangesNavigation
|
||||||
{t("Restore")}
|
revision={revision}
|
||||||
</Button>
|
editorRef={editorRef}
|
||||||
</Tooltip>
|
/>
|
||||||
</Action>
|
</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 && (
|
{can.publish && (
|
||||||
<Action>
|
<Action>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import useStores from "~/hooks/useStores";
|
|||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { documentPath } from "~/utils/routeHelpers";
|
||||||
import Sidebar from "./SidebarLayout";
|
import Sidebar from "./SidebarLayout";
|
||||||
import useMobile from "~/hooks/useMobile";
|
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 = [
|
const DocumentEvents = [
|
||||||
"documents.publish",
|
"documents.publish",
|
||||||
@@ -40,6 +44,53 @@ function History() {
|
|||||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||||
const isMobile = useMobile();
|
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 () => {
|
const fetchHistory = React.useCallback(async () => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return [];
|
return [];
|
||||||
@@ -144,22 +195,40 @@ function History() {
|
|||||||
useKeyDown("Escape", onCloseHistory);
|
useKeyDown("Escape", onCloseHistory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar title={t("History")} onClose={onCloseHistory}>
|
<Sidebar title={t("History")} onClose={onCloseHistory} scrollable={false}>
|
||||||
{document ? (
|
<Content>
|
||||||
<PaginatedEventList
|
<Text type="secondary" size="small" as="span">
|
||||||
aria-label={t("History")}
|
<Switch
|
||||||
fetch={fetchHistory}
|
label={t("Highlight changes")}
|
||||||
items={items}
|
checked={showChanges}
|
||||||
document={document}
|
onChange={handleShowChangesToggle}
|
||||||
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
|
/>
|
||||||
/>
|
</Text>
|
||||||
) : null}
|
</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>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyHistory = styled(Empty)`
|
const Content = styled.div`
|
||||||
padding: 0 12px;
|
margin: 0 16px 8px;
|
||||||
|
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 8px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(History);
|
export default observer(History);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import EditorContainer from "@shared/editor/components/Styles";
|
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
import type Document from "~/models/Document";
|
import type Document from "~/models/Document";
|
||||||
import type Revision from "~/models/Revision";
|
import type Revision from "~/models/Revision";
|
||||||
@@ -9,6 +8,11 @@ import Flex from "~/components/Flex";
|
|||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { documentPath } from "~/utils/routeHelpers";
|
||||||
import { Meta as DocumentMeta } from "./DocumentMeta";
|
import { Meta as DocumentMeta } from "./DocumentMeta";
|
||||||
import DocumentTitle from "./DocumentTitle";
|
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"> & {
|
type Props = Omit<EditorProps, "extensions"> & {
|
||||||
/** The ID of the revision */
|
/** The ID of the revision */
|
||||||
@@ -17,14 +21,38 @@ type Props = Omit<EditorProps, "extensions"> & {
|
|||||||
document: Document;
|
document: Document;
|
||||||
/** The revision to display */
|
/** The revision to display */
|
||||||
revision: Revision;
|
revision: Revision;
|
||||||
|
/** Whether to show changes from the previous revision */
|
||||||
|
showChanges?: boolean;
|
||||||
children?: React.ReactNode;
|
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 { 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 (
|
return (
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
@@ -41,10 +69,11 @@ function RevisionViewer(props: Props) {
|
|||||||
to={documentPath(document)}
|
to={documentPath(document)}
|
||||||
rtl={revision.rtl}
|
rtl={revision.rtl}
|
||||||
/>
|
/>
|
||||||
<EditorContainer
|
<Editor
|
||||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
ref={ref}
|
||||||
|
defaultValue={revision.data}
|
||||||
|
extensions={extensions}
|
||||||
dir={revision.dir}
|
dir={revision.dir}
|
||||||
rtl={revision.rtl}
|
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
{children}
|
{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",
|
Accept: "application/json",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
"x-editor-version": EDITOR_VERSION,
|
"x-editor-version": EDITOR_VERSION,
|
||||||
"x-api-version": "3",
|
"x-api-version": "4",
|
||||||
pragma: "no-cache",
|
pragma: "no-cache",
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export function trashPath(): string {
|
|||||||
return "/trash";
|
return "/trash";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function debugPath(): string {
|
||||||
|
return "/debug";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debugChangesetsPath(): string {
|
||||||
|
return "/debug/changesets";
|
||||||
|
}
|
||||||
|
|
||||||
export function settingsPath(...args: string[]): string {
|
export function settingsPath(...args: string[]): string {
|
||||||
return "/settings" + (args.length > 0 ? `/${args.join("/")}` : "");
|
return "/settings" + (args.length > 0 ? `/${args.join("/")}` : "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"png-chunks-extract": "^1.0.0",
|
"png-chunks-extract": "^1.0.0",
|
||||||
"polished": "^4.3.1",
|
"polished": "^4.3.1",
|
||||||
|
"prosemirror-changeset": "2.3.1",
|
||||||
"prosemirror-codemark": "^0.4.2",
|
"prosemirror-codemark": "^0.4.2",
|
||||||
"prosemirror-commands": "^1.7.1",
|
"prosemirror-commands": "^1.7.1",
|
||||||
"prosemirror-dropcursor": "^1.8.2",
|
"prosemirror-dropcursor": "^1.8.2",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Revision } from "@server/models";
|
|||||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||||
import presentUser from "./user";
|
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
|
// TODO: Remove this fallback once all revisions have been migrated
|
||||||
const { emoji, strippedTitle } = parseTitle(revision.title);
|
const { emoji, strippedTitle } = parseTitle(revision.title);
|
||||||
|
|
||||||
@@ -16,10 +16,10 @@ async function presentRevision(revision: Revision, diff?: string) {
|
|||||||
data: await DocumentHelper.toJSON(revision),
|
data: await DocumentHelper.toJSON(revision),
|
||||||
icon: revision.icon ?? emoji,
|
icon: revision.icon ?? emoji,
|
||||||
color: revision.color,
|
color: revision.color,
|
||||||
html: diff,
|
|
||||||
collaborators: (await revision.collaborators).map((user) =>
|
collaborators: (await revision.collaborators).map((user) =>
|
||||||
presentUser(user)
|
presentUser(user)
|
||||||
),
|
),
|
||||||
|
html,
|
||||||
createdAt: revision.createdAt,
|
createdAt: revision.createdAt,
|
||||||
createdBy: presentUser(revision.user),
|
createdBy: presentUser(revision.user),
|
||||||
createdById: revision.userId,
|
createdById: revision.userId,
|
||||||
|
|||||||
@@ -52,13 +52,18 @@ router.post(
|
|||||||
throw ValidationError("Either id or documentId must be provided");
|
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 = {
|
ctx.body = {
|
||||||
data: await presentRevision(
|
data: await presentRevision(
|
||||||
after,
|
after,
|
||||||
await DocumentHelper.diff(before, after, {
|
noHTML
|
||||||
includeTitle: false,
|
? undefined
|
||||||
includeStyles: false,
|
: await DocumentHelper.diff(before, after, {
|
||||||
})
|
includeTitle: false,
|
||||||
|
includeStyles: false,
|
||||||
|
})
|
||||||
),
|
),
|
||||||
policies: presentPolicies(user, [after]),
|
policies: presentPolicies(user, [after]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const fadeIn = keyframes`
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const pulse = keyframes`
|
export const pulse = (color: string) => keyframes`
|
||||||
0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
|
0% { box-shadow: 0 0 0 1px ${color} }
|
||||||
50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) }
|
50% { box-shadow: 0 0 0 4px ${color} }
|
||||||
100% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
|
100% { box-shadow: 0 0 0 1px ${color} }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const codeMarkCursor = () => css`
|
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`
|
const findAndReplaceStyle = () => css`
|
||||||
.find-result:not(:has(.mention)),
|
.find-result:not(:has(.mention)),
|
||||||
.find-result .mention {
|
.find-result .mention {
|
||||||
@@ -284,7 +405,7 @@ const findAndReplaceStyle = () => css`
|
|||||||
.find-result.current-result:not(:has(.mention)),
|
.find-result.current-result:not(:has(.mention)),
|
||||||
.find-result.current-result .mention {
|
.find-result.current-result .mention {
|
||||||
background: rgba(255, 213, 0, 0.75);
|
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;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.component-image {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
// Removes forced paragraph spaces below images, this is needed to images
|
// Removes forced paragraph spaces below images, this is needed to images
|
||||||
// being inline nodes that are displayed like blocks
|
// being inline nodes that are displayed like blocks
|
||||||
.component-image + img.ProseMirror-separator,
|
.component-image + img.ProseMirror-separator,
|
||||||
@@ -2107,19 +2232,18 @@ del {
|
|||||||
text-decoration: strikethrough;
|
text-decoration: strikethrough;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Remove once old email diff rendering is removed.
|
||||||
ins[data-operation-index] {
|
ins[data-operation-index] {
|
||||||
color: ${props.theme.textDiffInserted};
|
color: ${props.theme.textDiffInserted};
|
||||||
background-color: ${props.theme.textDiffInsertedBackground};
|
background-color: ${props.theme.textDiffInsertedBackground};
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
del[data-operation-index] {
|
del[data-operation-index] {
|
||||||
color: ${props.theme.textDiffDeleted};
|
color: ${props.theme.textDiffDeleted};
|
||||||
background-color: ${props.theme.textDiffDeletedBackground};
|
background-color: ${props.theme.textDiffDeletedBackground};
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
opacity: .5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2167,6 +2291,7 @@ const EditorContainer = styled.div<Props>`
|
|||||||
${mathStyle}
|
${mathStyle}
|
||||||
${codeMarkCursor}
|
${codeMarkCursor}
|
||||||
${codeBlockStyle}
|
${codeBlockStyle}
|
||||||
|
${diffStyle}
|
||||||
${findAndReplaceStyle}
|
${findAndReplaceStyle}
|
||||||
${emailStyle}
|
${emailStyle}
|
||||||
${textStyle}
|
${textStyle}
|
||||||
|
|||||||
@@ -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 { state } = view;
|
||||||
const insertNodeAtEnd = plugin.getState(state);
|
const insertNodeAtEnd = plugin.getState(state);
|
||||||
|
|
||||||
if (!insertNodeAtEnd) {
|
if (!insertNodeAtEnd || !view.editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
content: "block+",
|
content: "block+",
|
||||||
tableRole: "cell",
|
tableRole: "cell",
|
||||||
|
group: "cell",
|
||||||
isolating: true,
|
isolating: true,
|
||||||
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
|
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
|
||||||
toDOM(node) {
|
toDOM(node) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default class TableHeader extends Node {
|
|||||||
return {
|
return {
|
||||||
content: "block+",
|
content: "block+",
|
||||||
tableRole: "header_cell",
|
tableRole: "header_cell",
|
||||||
|
group: "cell",
|
||||||
isolating: true,
|
isolating: true,
|
||||||
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
|
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
|
||||||
toDOM(node) {
|
toDOM(node) {
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export class EditorStyleHelper {
|
|||||||
|
|
||||||
static readonly codeWord = "code-word";
|
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
|
// Tables
|
||||||
|
|
||||||
/** Table wrapper */
|
/** Table wrapper */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { Node as ProsemirrorNode } from "prosemirror-model";
|
import type { Node as ProsemirrorNode } from "prosemirror-model";
|
||||||
import type { EditorState } from "prosemirror-state";
|
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 * as React from "react";
|
||||||
import type { DefaultTheme } from "styled-components";
|
import type { DefaultTheme } from "styled-components";
|
||||||
import type { Primitive } from "utility-types";
|
import type { Primitive } from "utility-types";
|
||||||
@@ -52,6 +52,7 @@ export type ComponentProps = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isEditable: boolean;
|
isEditable: boolean;
|
||||||
getPos: () => number;
|
getPos: () => number;
|
||||||
|
decorations: Decoration[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface NodeMarkAttr {
|
export interface NodeMarkAttr {
|
||||||
|
|||||||
@@ -386,6 +386,8 @@
|
|||||||
"{{ hours }}h read": "{{ hours }}h read",
|
"{{ hours }}h read": "{{ hours }}h read",
|
||||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||||
"Revision deleted": "Revision deleted",
|
"Revision deleted": "Revision deleted",
|
||||||
|
"{{count}} people": "{{count}} person",
|
||||||
|
"{{count}} people_plural": "{{count}} people",
|
||||||
"Current version": "Current version",
|
"Current version": "Current version",
|
||||||
"{{userName}} edited": "{{userName}} edited",
|
"{{userName}} edited": "{{userName}} edited",
|
||||||
"Revision options": "Revision options",
|
"Revision options": "Revision options",
|
||||||
@@ -709,6 +711,12 @@
|
|||||||
"Add a description": "Add a description",
|
"Add a description": "Add a description",
|
||||||
"Signing in": "Signing in",
|
"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",
|
"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",
|
"Error creating comment": "Error creating comment",
|
||||||
"Add a comment": "Add a comment",
|
"Add a comment": "Add a comment",
|
||||||
"Add a reply": "Add a reply",
|
"Add a reply": "Add a reply",
|
||||||
@@ -756,6 +764,7 @@
|
|||||||
"Archived": "Archived",
|
"Archived": "Archived",
|
||||||
"Save draft": "Save draft",
|
"Save draft": "Save draft",
|
||||||
"Restore version": "Restore version",
|
"Restore version": "Restore version",
|
||||||
|
"Highlight changes": "Highlight changes",
|
||||||
"No history yet": "No history yet",
|
"No history yet": "No history yet",
|
||||||
"Source": "Source",
|
"Source": "Source",
|
||||||
"Created": "Created",
|
"Created": "Created",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
textDiffInserted: colors.almostBlack,
|
textDiffInserted: colors.almostBlack,
|
||||||
textDiffInsertedBackground: "rgba(18, 138, 41, 0.16)",
|
textDiffInsertedBackground: "rgba(18, 138, 41, 0.16)",
|
||||||
textDiffDeleted: colors.slateDark,
|
textDiffDeleted: colors.slateDark,
|
||||||
textDiffDeletedBackground: "#ffebe9",
|
textDiffDeletedBackground: "rgba(255, 180, 173, 0.25)",
|
||||||
placeholder: "#a2b2c3",
|
placeholder: "#a2b2c3",
|
||||||
sidebarBackground: colors.warmGrey,
|
sidebarBackground: colors.warmGrey,
|
||||||
sidebarHoverBackground: "hsl(212 31% 90% / 1)",
|
sidebarHoverBackground: "hsl(212 31% 90% / 1)",
|
||||||
@@ -188,7 +188,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
textSecondary: lighten(0.1, colors.slate),
|
textSecondary: lighten(0.1, colors.slate),
|
||||||
textTertiary: colors.slate,
|
textTertiary: colors.slate,
|
||||||
textDiffInserted: colors.almostWhite,
|
textDiffInserted: colors.almostWhite,
|
||||||
textDiffInsertedBackground: "rgba(63,185,80,0.3)",
|
textDiffInsertedBackground: "rgba(63,185,80,0.25)",
|
||||||
textDiffDeleted: darken(0.1, colors.almostWhite),
|
textDiffDeleted: darken(0.1, colors.almostWhite),
|
||||||
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
|
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
|
||||||
placeholder: "#596673",
|
placeholder: "#596673",
|
||||||
|
|||||||
+1
-1
@@ -553,7 +553,7 @@ export type ProsemirrorData = {
|
|||||||
attrs?: JSONObject;
|
attrs?: JSONObject;
|
||||||
marks?: {
|
marks?: {
|
||||||
type: string;
|
type: string;
|
||||||
attrs: JSONObject;
|
attrs?: JSONObject;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-11
@@ -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 headingToSlug from "../editor/lib/headingToSlug";
|
||||||
import textBetween from "../editor/lib/textBetween";
|
import textBetween from "../editor/lib/textBetween";
|
||||||
import type { ProsemirrorData } from "../types";
|
import type { ProsemirrorData } from "../types";
|
||||||
@@ -514,16 +515,20 @@ export class ProsemirrorHelper {
|
|||||||
* Returns the paragraphs from the data if there are only plain paragraphs
|
* Returns the paragraphs from the data if there are only plain paragraphs
|
||||||
* without any formatting. Otherwise returns undefined.
|
* 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
|
* @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[] = [];
|
const paragraphs: ProsemirrorData[] = [];
|
||||||
if (!data.content) {
|
if (!jsonData.content) {
|
||||||
return paragraphs;
|
return paragraphs;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const node of data.content) {
|
for (const node of jsonData.content) {
|
||||||
if (
|
if (
|
||||||
node.type === "paragraph" &&
|
node.type === "paragraph" &&
|
||||||
(!node.content ||
|
(!node.content ||
|
||||||
|
|||||||
@@ -17727,6 +17727,7 @@ __metadata:
|
|||||||
polished: "npm:^4.3.1"
|
polished: "npm:^4.3.1"
|
||||||
postinstall-postinstall: "npm:^2.1.0"
|
postinstall-postinstall: "npm:^2.1.0"
|
||||||
prettier: "npm:^3.6.2"
|
prettier: "npm:^3.6.2"
|
||||||
|
prosemirror-changeset: "npm:2.3.1"
|
||||||
prosemirror-codemark: "npm:^0.4.2"
|
prosemirror-codemark: "npm:^0.4.2"
|
||||||
prosemirror-commands: "npm:^1.7.1"
|
prosemirror-commands: "npm:^1.7.1"
|
||||||
prosemirror-dropcursor: "npm:^1.8.2"
|
prosemirror-dropcursor: "npm:^1.8.2"
|
||||||
@@ -18753,6 +18754,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"prosemirror-codemark@npm:^0.4.2":
|
||||||
version: 0.4.2
|
version: 0.4.2
|
||||||
resolution: "prosemirror-codemark@npm:0.4.2"
|
resolution: "prosemirror-codemark@npm:0.4.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user