diff --git a/AGENTS.md b/AGENTS.md index bad40846e8..31d2f3c600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,11 @@ You're an expert in the following areas: ## General Guidelines +- Critical – Do not create new markdown (.md) files. - Use early returns for readability. - Emphasize type safety and static analysis. - Follow consistent Prettier formatting. - Do not replace smart quotes ("") or ('') with simple quotes (""). -- Do not create new MD files. ## Dependencies and Upgrading @@ -78,7 +78,7 @@ yarn install - Event handlers should be prefixed with "handle", like "handleClick" for onClick. - Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately. - Use descriptive prop types with TypeScript interfaces. -- You do not need to import React unless it is used directly. +- Do not import React unless it is used directly. - Use styled-components for component styling. - Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML. diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx index c9c07ee41c..b057901f79 100644 --- a/app/actions/definitions/developer.tsx +++ b/app/actions/definitions/developer.tsx @@ -17,7 +17,17 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags"; import Logger from "~/utils/Logger"; import { deleteAllDatabases } from "~/utils/developer"; import history from "~/utils/history"; -import { homePath } from "~/utils/routeHelpers"; +import { homePath, debugPath } from "~/utils/routeHelpers"; + +export const goToDebug = createAction({ + name: "Go to debug screen", + icon: , + section: DeveloperSection, + visible: () => env.ENVIRONMENT === "development", + perform: () => { + history.push(debugPath()); + }, +}); export const copyId = createActionWithChildren({ name: ({ t }) => t("Copy ID"), @@ -222,6 +232,7 @@ export const developer = createActionWithChildren({ iconInContextMenu: false, section: DeveloperSection, children: [ + goToDebug, copyId, toggleDebugLogging, toggleDebugSafeArea, diff --git a/app/components/PaginatedEventList.tsx b/app/components/PaginatedEventList.tsx index 86dcef652f..fb65c2b2f5 100644 --- a/app/components/PaginatedEventList.tsx +++ b/app/components/PaginatedEventList.tsx @@ -1,7 +1,8 @@ +import { differenceInMinutes } from "date-fns"; import * as React from "react"; import styled from "styled-components"; import type Document from "~/models/Document"; -import type Event from "~/models/Event"; +import Event from "~/models/Event"; import Revision from "~/models/Revision"; import PaginatedList from "~/components/PaginatedList"; import EventListItem from "./EventListItem"; @@ -27,6 +28,26 @@ const PaginatedEventList = React.memo(function PaginatedEventList({ document, ...rest }: Props) { + const isDuplicate = React.useCallback((item: Item, previousItem: Item) => { + if (item instanceof Event && previousItem instanceof Event) { + return ( + Math.abs( + differenceInMinutes( + new Date(item.createdAt), + new Date(previousItem.createdAt) + ) + ) < 10 && + item.name === previousItem.name && + item.actorId === previousItem.actorId && + item.userId === previousItem.userId && + item.documentId === previousItem.documentId && + item.collectionId === previousItem.collectionId + ); + } + + return false; + }, []); + return ( (function PaginatedEventList({ heading={heading} fetch={fetch} options={options} + isDuplicate={isDuplicate} renderItem={(item: Item) => item instanceof Revision ? ( diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx index 775bf794d8..bb9145ac81 100644 --- a/app/components/PaginatedList.tsx +++ b/app/components/PaginatedList.tsx @@ -27,8 +27,9 @@ export interface PaginatedItem { * Props for the PaginatedList component * @template T Type of items in the list, must extend PaginatedItem */ -interface Props - extends React.HTMLAttributes { +interface Props< + T extends PaginatedItem, +> extends React.HTMLAttributes { /** * Function to fetch paginated data. Should return a promise resolving to an array of items * @param options Pagination and other query options @@ -79,6 +80,12 @@ interface Props */ renderHeading?: (name: React.ReactElement | string) => React.ReactNode; + /** + * Function to determine if an item is a duplicate of the previous item. + * If it returns true, the item will not be rendered. + */ + isDuplicate?: (item: T, previousItem: T) => boolean; + /** * Handler for escape key press * @param ev Keyboard event object @@ -106,6 +113,7 @@ const PaginatedList = ({ renderItem, renderError, renderHeading, + isDuplicate, onEscape, listRef, ...rest @@ -221,10 +229,19 @@ const PaginatedList = ({ }, [fetch, options, reset, fetchResults, prevFetch, prevOptions]); // Computed property equivalent - const itemsToRender = React.useMemo( - () => items?.slice(0, renderCount) ?? [], - [items, renderCount] - ); + const itemsToRender = React.useMemo(() => { + const sliced = items?.slice(0, renderCount) ?? []; + if (!isDuplicate) { + return sliced; + } + + return sliced.filter((item, index) => { + if (index === 0) { + return true; + } + return !isDuplicate(item, sliced[index - 1]); + }); + }, [items, renderCount, isDuplicate]); const showLoading = isFetching && diff --git a/app/components/RevisionListItem.tsx b/app/components/RevisionListItem.tsx index 6a9f09fc87..6cd71bd529 100644 --- a/app/components/RevisionListItem.tsx +++ b/app/components/RevisionListItem.tsx @@ -1,12 +1,12 @@ import type { LocationDescriptor } from "history"; import { observer } from "mobx-react"; import { EditIcon, TrashIcon } from "outline-icons"; -import { useCallback, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import styled from "styled-components"; import EventBoundary from "@shared/components/EventBoundary"; -import { hover } from "@shared/styles"; +import { ellipsis, hover } from "@shared/styles"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; import type Document from "~/models/Document"; import type Revision from "~/models/Revision"; @@ -21,10 +21,8 @@ import { ContextMenu } from "~/components/Menu/ContextMenu"; import Time from "~/components/Time"; import { ActionContextProvider } from "~/hooks/useActionContext"; import useBoolean from "~/hooks/useBoolean"; -import useClickIntent from "~/hooks/useClickIntent"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import { useMenuAction } from "~/hooks/useMenuAction"; -import useStores from "~/hooks/useStores"; import RevisionMenu from "~/menus/RevisionMenu"; import { documentHistoryPath } from "~/utils/routeHelpers"; import { EventItem, lineStyle } from "./EventListItem"; @@ -38,10 +36,8 @@ type Props = { const RevisionListItem = ({ item, document, ...rest }: Props) => { const { t } = useTranslation(); - const { revisions } = useStores(); const location = useLocation(); const sidebarContext = useLocationSidebarContext(); - const revisionLoadedRef = useRef(false); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const isLatestRevision = RevisionHelper.latestId(document.id) === item.id; @@ -60,19 +56,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { ref.current?.focus(); }; - const prefetchRevision = useCallback(async () => { - if (!document.isDeleted && !item.deletedAt && !revisionLoadedRef.current) { - if (isLatestRevision) { - return; - } - await revisions.fetch(item.id, { force: true }); - revisionLoadedRef.current = true; - } - }, [document.isDeleted, item.deletedAt, isLatestRevision, revisions]); - - const { handleMouseEnter, handleMouseLeave } = - useClickIntent(prefetchRevision); - let meta, icon, to: LocationDescriptor | undefined; if (item.deletedAt) { @@ -80,18 +63,31 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { meta = t("Revision deleted"); } else { icon = ; + + let collaboratorText: string | undefined; + if (item.collaborators && item.collaborators.length === 2) { + collaboratorText = `${item.collaborators[0].name} and ${item.collaborators[1].name}`; + } else if (item.collaborators && item.collaborators.length > 2) { + collaboratorText = t("{{count}} people", { + count: item.collaborators.length, + }); + } else { + collaboratorText = item.createdBy?.name; + } + meta = isLatestRevision ? ( <> - {t("Current version")} · {item.createdBy?.name} + {t("Current version")} · {collaboratorText} ) : ( - t("{{userName}} edited", { userName: item.createdBy?.name }) + t("{{userName}} edited", { userName: collaboratorText }) ); to = { pathname: documentHistoryPath( document, isLatestRevision ? "latest" : item.id ), + search: location.search, state: { sidebarContext, retainScrollPosition: true, @@ -153,7 +149,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { ) } - subtitle={meta} + subtitle={{meta}} actions={ isActive ? ( @@ -161,8 +157,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { ) : undefined } - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} ref={ref} $menuOpen={menuOpen} {...rest} @@ -172,6 +166,10 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { ); }; +const Meta = styled.div` + ${ellipsis()}) +`; + const IconWrapper = styled(Text)` height: 24px; min-width: 24px; diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx index b55d5ed2fe..fa2d8db93a 100644 --- a/app/editor/components/ComponentView.tsx +++ b/app/editor/components/ComponentView.tsx @@ -42,6 +42,8 @@ export default class ComponentView { isSelected = false; /** The DOM element that the node is rendered into. */ dom: HTMLElement | null; + /** The base class name for the node's DOM element. */ + className?: string; // See https://prosemirror.net/docs/ref/#view.NodeView constructor( @@ -66,23 +68,60 @@ export default class ComponentView { ? document.createElement("span") : document.createElement("div"); - this.dom.classList.add(`component-${node.type.name}`); + this.className = `component-${node.type.name}`; + this.dom.classList.add(this.className); this.renderer = new NodeViewRenderer(this.dom, this.component, this.props); // Add the renderer to the editor's set of renderers so that it is included in the React tree. this.editor.renderers.add(this.renderer); + + // Apply decoration classes to the DOM element. + this.applyDecorationClasses(); } - update(node: ProsemirrorNode) { + update(node: ProsemirrorNode, decorations: Decoration[]) { if (node.type !== this.node.type) { return false; } this.node = node; + this.decorations = decorations; + this.applyDecorationClasses(); this.renderer.updateProps(this.props); return true; } + /** + * Apply decoration classes to the DOM element. + * Extracts classes from inline decorations that overlap with this node's position. + */ + private applyDecorationClasses() { + if (!this.dom) { + return; + } + + // Remove all existing decoration classes. + this.dom.classList.forEach((className) => { + if (className !== this.className) { + this.dom?.classList.remove(className); + } + }); + + // Apply classes from inline decorations. + this.decorations.forEach((decoration) => { + // For inline decorations, attrs contain the class property. + const attrs = (decoration as any).type?.attrs; + if (attrs?.class) { + const classes = attrs.class.split(" "); + classes.forEach((className: string) => { + if (className && this.dom) { + this.dom.classList.add(className); + } + }); + } + }); + } + selectNode() { if (this.view.editable) { this.isSelected = true; @@ -117,6 +156,7 @@ export default class ComponentView { isSelected: this.isSelected, isEditable: this.view.editable, getPos: this.getPos, + decorations: this.decorations, } as ComponentProps; } } diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 4e4fad996e..76356aa7e6 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -65,9 +65,9 @@ export type Props = { /** The user id of the current user */ userId?: string; /** The editor content, should only be changed if you wish to reset the content */ - value?: string | ProsemirrorData; - /** The initial editor content as a markdown string or JSON object */ - defaultValue: string | object; + value?: string | ProsemirrorData | ProsemirrorNode; + /** The initial editor content as a markdown string, JSON object, or ProsemirrorNode */ + defaultValue: string | ProsemirrorData | ProsemirrorNode; /** Placeholder displayed when the editor is empty */ placeholder: string; /** Extensions to load into the editor */ @@ -395,7 +395,7 @@ export class Editor extends React.PureComponent< }); } - private createState(value?: string | object) { + private createState(value?: string | ProsemirrorData | ProsemirrorNode) { const doc = this.createDocument(value || this.props.defaultValue); return EditorState.create({ @@ -417,7 +417,12 @@ export class Editor extends React.PureComponent< }); } - private createDocument(content: string | object) { + private createDocument(content: string | object | ProsemirrorNode) { + // Already a ProsemirrorNode + if (content instanceof ProsemirrorNode) { + return content; + } + // Looks like Markdown if (typeof content === "string") { return this.parser.parse(content) || undefined; diff --git a/app/env.ts b/app/env.ts index fabe1e1cd5..47f6e36faa 100644 --- a/app/env.ts +++ b/app/env.ts @@ -4,12 +4,17 @@ declare global { } } -const env = window.env; - -if (!env) { +if (!window.env) { throw new Error( "Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed" ); } +const env: Record = { + ...window.env, + isDevelopment: window.env.ENVIRONMENT === "development", + isTest: window.env.ENVIRONMENT === "test", + isProduction: window.env.ENVIRONMENT === "production", +}; + export default env; diff --git a/app/models/Document.ts b/app/models/Document.ts index e67c2fe71f..69446ffc2e 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -414,7 +414,7 @@ export default class Document extends ArchivableModel implements Searchable { @computed get isTasks(): boolean { - return !!this.tasks.total; + return !!this.tasks?.total; } @computed diff --git a/app/models/Revision.ts b/app/models/Revision.ts index 9f52022f48..62ee1f34a7 100644 --- a/app/models/Revision.ts +++ b/app/models/Revision.ts @@ -6,6 +6,8 @@ import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; +import type RevisionsStore from "~/stores/RevisionsStore"; +import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper"; class Revision extends ParanoidModel { static modelName = "Revision"; @@ -71,6 +73,33 @@ class Revision extends ParanoidModel { get rtl() { return isRTL(this.title); } + + /** + * Returns the previous revision (chronologically earlier) for comparison. + * + * Revisions are sorted by creation date (newest first), so the "previous" revision + * is the one that comes after the current revision in the sorted list. + * + * @returns The previous revision or null if this is the first revision. + */ + @computed + get before(): Revision | null { + const allRevisions = (this.store as RevisionsStore).getByDocumentId( + this.documentId + ); + + const currentIndex = allRevisions.findIndex( + (r: Revision) => r.id === this.id + ); + return currentIndex >= 0 && currentIndex < allRevisions.length - 1 + ? allRevisions[currentIndex + 1] + : null; + } + + @computed + get changeset() { + return ChangesetHelper.getChangeset(this.data, this.before?.data); + } } export default Revision; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 4bcc09085d..e1a9655492 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -21,7 +21,9 @@ import { matchDocumentSlug as documentSlug, matchCollectionSlug as collectionSlug, trashPath, + debugPath, } from "~/utils/routeHelpers"; +import env from "~/env"; const SettingsRoutes = lazy(() => import("./settings")); const Archive = lazy(() => import("~/scenes/Archive")); @@ -31,6 +33,8 @@ const Drafts = lazy(() => import("~/scenes/Drafts")); const Home = lazy(() => import("~/scenes/Home")); const Search = lazy(() => import("~/scenes/Search")); const Trash = lazy(() => import("~/scenes/Trash")); +const Debug = lazy(() => import("~/scenes/Developer/Debug")); +const Changesets = lazy(() => import("~/scenes/Developer/Changesets")); const RedirectDocument = ({ match, @@ -120,6 +124,16 @@ function AuthenticatedRoutes() { path={`${searchPath()}/:query?`} component={Search} /> + {env.isDevelopment && ( + <> + + + + )} diff --git a/app/scenes/Developer/Changesets.tsx b/app/scenes/Developer/Changesets.tsx new file mode 100644 index 0000000000..0b91c74b6e --- /dev/null +++ b/app/scenes/Developer/Changesets.tsx @@ -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( + "show-changeset-json", + false + ); + const [showBeforeAfterDocs, setShowBeforeAfterDocs] = + usePersistedState("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 ( + + + + setShowChangeset(checked)} + labelPosition="right" + /> + setShowBeforeAfterDocs(checked)} + labelPosition="right" + /> + + + {examples.map((example) => ( + + history.push({ + search: `?id=${example.id}`, + }) + } + $active={selectedExample.id === example.id} + border={false} + /> + ))} + + + + {mockDocument && mockDiffRevision ? ( + <> + + {showBeforeAfterDocs && mockBeforeRevision && mockAfterRevision && ( + <> + + + + )} + {showChangeset && ( + <> + Changeset +
+                  {JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
+                
+ + )} + + ) : null} +
+
+ ); +} + +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); diff --git a/app/scenes/Developer/Debug.tsx b/app/scenes/Developer/Debug.tsx new file mode 100644 index 0000000000..73bab0f71f --- /dev/null +++ b/app/scenes/Developer/Debug.tsx @@ -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 ( + + Debug +
    +
  • + Changeset playground +
  • +
+
+ ); +} diff --git a/app/scenes/Developer/components/ExampleData.ts b/app/scenes/Developer/components/ExampleData.ts new file mode 100644 index 0000000000..d89d1f8eea --- /dev/null +++ b/app/scenes/Developer/components/ExampleData.ts @@ -0,0 +1,1784 @@ +import { type ProsemirrorData } from "@shared/types"; + +export interface Example { + id: string; + name: string; + before: ProsemirrorData; + after: ProsemirrorData; +} + +/** + * A collection of example ProseMirror documents to demonstrate diffing capabilities. + * These examples cover various node types and complexity levels supported by the Outline editor. + * Node and mark names are matched against those defined in the shared editor schema. + */ +export const examples: Example[] = [ + { + id: "simple-text", + name: "Simple text change", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "The quick brown fox jumps over the lazy dog.", + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "The fast brown fox leaps over the sleeping dog.", + }, + ], + }, + ], + }, + }, + { + id: "paragraph-addition", + name: "Paragraph addition", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is the first paragraph.", + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is the first paragraph.", + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is a newly added second paragraph.", + }, + ], + }, + ], + }, + }, + { + id: "formatting-change", + name: "Formatting changes", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This text is normal.", + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "strong" }], + text: "This text is bold.", + }, + { + type: "text", + text: " And this is ", + }, + { + type: "text", + marks: [{ type: "em" }], + text: "italic", + }, + { + type: "text", + text: ". ", + }, + { + type: "text", + marks: [{ type: "strikethrough" }], + text: "Deleted content", + }, + { + type: "text", + text: " and ", + }, + { + type: "text", + marks: [{ type: "code_inline" }], + text: "inline code", + }, + { + type: "text", + text: ".", + }, + ], + }, + ], + }, + }, + { + id: "nested-list", + name: "Nested list", + before: { + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item 1" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item 2" }], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item 1" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item 2" }], + }, + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Nested item 1" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Nested item 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "list-changes", + name: "Bullet list", + before: { + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item one" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item two" }], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item one (modified)" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item three (added)" }], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "ordered-list", + name: "Ordered list", + before: { + type: "doc", + content: [ + { + type: "ordered_list", + attrs: { order: 1 }, + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First" }], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "ordered_list", + attrs: { order: 1 }, + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Second" }], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "checkboxes", + name: "Checkboxes", + before: { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Todo item" }], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Completed item" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "New todo" }], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "checkbox-checked", + name: "Checkbox checked", + before: { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Task to complete" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Task to stay the same" }], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Task to complete" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Task to stay the same" }], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "code-block", + name: "Code block", + before: { + type: "doc", + content: [ + { + type: "code_block", + attrs: { language: "javascript" }, + content: [{ type: "text", text: "const x = 1;\nconsole.log(x);" }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "code_block", + attrs: { language: "typescript" }, + content: [ + { + type: "text", + text: "const x: number = 1;\nconst y: number = 2;\nconsole.log(x + y);", + }, + ], + }, + ], + }, + }, + { + id: "table-changes", + name: "Table changes", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Name" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Role" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Tom" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Engineer" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Full Name" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Job Role" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Tom Moor" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Software Engineer" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Jeroen" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Product" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "table-add-row", + name: "Table: add row", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "New Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "New Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "table-add-column", + name: "Table: add column", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 3" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 3" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "table-remove-row", + name: "Table: remove row", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 3" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 4" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "table-remove-column", + name: "Table: remove column", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 3" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 3" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "table-header-to-cells", + name: "Table: header row to cells", + before: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "th", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "td", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + id: "embed-addition", + name: "Embed addition", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is the first paragraph." }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "This is the second paragraph." }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is the first paragraph." }], + }, + { + type: "embed", + attrs: { + href: "https://www.youtube.com/watch?v=FwpEk-fhZjY", + }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "This is the second paragraph." }], + }, + ], + }, + }, + { + id: "images", + name: "Images", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "An image" }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "An image" }, + { + type: "image", + attrs: { + src: "https://www.getoutline.com/images/screenshot.png", + alt: "Outline", + }, + }, + ], + }, + ], + }, + }, + { + id: "mentions", + name: "Mentions", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "A paragraph with mentions to a ", + }, + { + type: "mention", + attrs: { + type: "user", + label: "user", + modelId: "user-1", + }, + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "A paragraph with mentions to a ", + }, + { + type: "mention", + attrs: { + type: "group", + label: "group", + modelId: "group-1", + }, + }, + { + type: "text", + text: ", a ", + }, + { + type: "mention", + attrs: { + type: "document", + label: "document", + modelId: "doc-1", + }, + }, + { + type: "text", + text: ", and a ", + }, + { + type: "mention", + attrs: { + type: "collection", + label: "collection", + modelId: "collection-1", + }, + }, + { + type: "text", + text: ".", + }, + ], + }, + ], + }, + }, + { + id: "notices", + name: "Notice nodes", + before: { + type: "doc", + content: [ + { + type: "container_notice", + attrs: { style: "info" }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is an important piece of information.", + }, + ], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "container_notice", + attrs: { style: "warning" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a critical warning!" }], + }, + ], + }, + { + type: "container_notice", + attrs: { style: "success" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a success message." }], + }, + ], + }, + { + type: "container_notice", + attrs: { style: "tip" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a helpful tip." }], + }, + ], + }, + ], + }, + }, + { + id: "math", + name: "Math", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "An equation: ", + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "An equation: ", + }, + { + type: "math_inline", + content: [{ type: "text", text: "a^2 + b^2 = c^2" }], + }, + ], + }, + { + type: "math_block", + content: [{ type: "text", text: "E = mc^2" }], + }, + ], + }, + }, + { + id: "pdf-attachment", + name: "PDF attachment", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A PDF." }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A PDF." }], + }, + { + type: "attachment", + attrs: { + href: "https://www.getoutline.com/dummy.pdf", + title: "dummy.pdf", + size: 12345, + preview: true, + contentType: "application/pdf", + }, + }, + ], + }, + }, + { + id: "video", + name: "Video", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A video." }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A video." }], + }, + { + type: "video", + attrs: { + src: "https://www.getoutline.com/dummy.mp4", + title: "dummy.mp4", + width: 640, + height: 480, + }, + }, + ], + }, + }, + { + id: "collapsed-heading", + name: "Collapsed heading", + before: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Heading" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Some content." }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1, collapsed: true }, + content: [{ type: "text", text: "Heading" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Some content." }], + }, + ], + }, + }, + { + id: "custom-emoji", + name: "Custom emoji", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A custom emoji." }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "A custom emoji: " }, + { + type: "emoji", + attrs: { "data-name": "c4f345ab-1c37-4348-ab68-1a423aad47e3" }, + }, + ], + }, + ], + }, + }, + { + id: "blockquote", + name: "Blockquote", + before: { + type: "doc", + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Old quote text" }], + }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "New quote text" }], + }, + ], + }, + ], + }, + }, + { + id: "horizontal-rule", + name: "Horizontal rule", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Text above" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Text below" }], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Text above" }], + }, + { + type: "hr", + attrs: { markup: "---" }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "Text below" }], + }, + ], + }, + }, + { + id: "mentions-links", + name: "Mentions & links", + before: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Check out this document for more info." }, + ], + }, + ], + }, + after: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Hey ", + }, + { + type: "mention", + attrs: { + id: "user-1", + label: "Tom", + modelId: "user-1", + type: "user", + }, + }, + { + type: "text", + text: ", check out ", + }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { href: "https://www.getoutline.com" }, + }, + ], + text: "this document", + }, + { + type: "text", + text: " for more info.", + }, + ], + }, + ], + }, + }, +]; diff --git a/app/scenes/Document/components/ChangesNavigation.tsx b/app/scenes/Document/components/ChangesNavigation.tsx new file mode 100644 index 0000000000..6c568c6430 --- /dev/null +++ b/app/scenes/Document/components/ChangesNavigation.tsx @@ -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; +}; + +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 && ( + + + {currentChangeIndex >= 0 + ? t("{{ current }} of {{ count }} changes", { + current: currentChangeIndex + 1, + count: totalChanges, + }) + : t("{{ count }} changes", { + count: totalChanges, + })} + + + } + onClick={() => editorRef.current?.commands.prevChange()} + aria-label={t("Previous change")} + /> + + + } + onClick={() => editorRef.current?.commands.nextChange()} + aria-label={t("Next change")} + /> + + + )} + + ); +}); + +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; +`; diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 71971f89e4..2e9b4407ea 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -104,9 +104,11 @@ function DataLoader({ match, children }: Props) { React.useEffect(() => { async function fetchRevision() { - if (revisionId && revisionId !== "latest") { + if (revisionId) { try { - await revisions.fetch(revisionId); + await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"]( + revisionId + ); } catch (err) { setError(err); } @@ -115,19 +117,6 @@ function DataLoader({ match, children }: Props) { void fetchRevision(); }, [revisions, revisionId]); - React.useEffect(() => { - async function fetchRevision() { - if (document && revisionId === "latest") { - try { - await revisions.fetchLatest(document.id); - } catch (err) { - setError(err); - } - } - } - void fetchRevision(); - }, [document, revisionId, revisions]); - React.useEffect(() => { async function fetchViews() { if (document?.id && !document?.isDeleted && !revisionId) { diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index ba5d234a5e..74b95c4d83 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -528,6 +528,7 @@ class DocumentScene extends React.Component { /> )}
{ } > - {revision ? ( - + + {revision ? ( - - ) : ( - <> - + ) : ( + <> {showContents && ( @@ -616,16 +616,16 @@ class DocumentScene extends React.Component { ) : null} - - {showContents && ( - - - - )} - + + )} + + {showContents && ( + + + )} @@ -724,21 +724,6 @@ const EditorContainer = styled.div` `}; `; -type RevisionContainerProps = { - docFullWidth: boolean; -}; - -const RevisionContainer = styled.div` - // Adds space to the gutter to make room for icon - padding: 0 40px; - - ${breakpoint("tablet")` - grid-row: 1; - grid-column: ${({ docFullWidth }: RevisionContainerProps) => - docFullWidth ? "1 / -1" : 2}; - `} -`; - const Background = styled(Container)` position: relative; background: ${s("background")}; diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 4067b5e00d..7dc2228515 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -40,8 +40,11 @@ import PublicBreadcrumb from "./PublicBreadcrumb"; import ShareButton from "./ShareButton"; import { AppearanceAction } from "~/components/Sharing/components/Actions"; import useShare from "@shared/hooks/useShare"; +import { type Editor } from "~/editor"; +import { ChangesNavigation } from "./ChangesNavigation"; type Props = { + editorRef: React.RefObject; document: Document; revision: Revision | undefined; isDraft: boolean; @@ -59,6 +62,7 @@ type Props = { }; function DocumentHeader({ + editorRef, document, revision, isEditing, @@ -303,14 +307,27 @@ function DocumentHeader({ )} - {revision && revision.createdAt !== document.updatedAt && ( - - - - - + {revision && ( + <> + + + + + + + + + )} {can.publish && ( diff --git a/app/scenes/Document/components/History.tsx b/app/scenes/Document/components/History.tsx index 3be656da09..d28f2be3ce 100644 --- a/app/scenes/Document/components/History.tsx +++ b/app/scenes/Document/components/History.tsx @@ -16,6 +16,10 @@ import useStores from "~/hooks/useStores"; import { documentPath } from "~/utils/routeHelpers"; import Sidebar from "./SidebarLayout"; import useMobile from "~/hooks/useMobile"; +import Switch from "~/components/Switch"; +import Text from "@shared/components/Text"; +import usePersistedState from "~/hooks/usePersistedState"; +import Scrollable from "~/components/Scrollable"; const DocumentEvents = [ "documents.publish", @@ -40,6 +44,53 @@ function History() { const [eventsOffset, setEventsOffset] = React.useState(0); const isMobile = useMobile(); + const [defaultShowChanges, setDefaultShowChanges] = + usePersistedState("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) => { + const params = new URLSearchParams(history.location.search); + + Object.entries(changes).forEach(([key, value]) => { + if (value === null) { + params.delete(key); + } else { + params.set(key, value); + } + }); + + const search = params.toString(); + history.replace({ + pathname: history.location.pathname, + search: search ? `?${search}` : "", + state: history.location.state, + }); + }, + [history] + ); + + // Handler for toggling the "Show Changes" switch, updating state and URL parameter + const handleShowChangesToggle = React.useCallback( + (checked: boolean) => { + setShowChanges(checked); + setDefaultShowChanges(checked); + updateLocation({ changes: checked ? "true" : null }); + }, + [history] + ); + + // Ensure that the URL parameter is in sync with the persisted state on mount + React.useEffect(() => { + if (defaultShowChanges) { + updateLocation({ changes: "true" }); + } + }, [defaultShowChanges]); + const fetchHistory = React.useCallback(async () => { if (!document) { return []; @@ -144,22 +195,40 @@ function History() { useKeyDown("Escape", onCloseHistory); return ( - - {document ? ( - {t("No history yet")}} - /> - ) : null} + + + + + + + + {document ? ( + + {t("No history yet")} + + } + /> + ) : null} + ); } -const EmptyHistory = styled(Empty)` - padding: 0 12px; +const Content = styled.div` + margin: 0 16px 8px; + border: 1px solid ${(props) => props.theme.inputBorder}; + border-radius: 8px; + padding: 8px 8px 0; `; export default observer(History); diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index 6516c2768a..9dbe954693 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -1,6 +1,5 @@ import { observer } from "mobx-react"; import * as React from "react"; -import EditorContainer from "@shared/editor/components/Styles"; import { colorPalette } from "@shared/utils/collections"; import type Document from "~/models/Document"; import type Revision from "~/models/Revision"; @@ -9,6 +8,11 @@ import Flex from "~/components/Flex"; import { documentPath } from "~/utils/routeHelpers"; import { Meta as DocumentMeta } from "./DocumentMeta"; import DocumentTitle from "./DocumentTitle"; +import Editor from "~/components/Editor"; +import { richExtensions, withComments } from "@shared/editor/nodes"; +import Diff from "@shared/editor/extensions/Diff"; +import useQuery from "~/hooks/useQuery"; +import { type Editor as TEditor } from "~/editor"; type Props = Omit & { /** The ID of the revision */ @@ -17,14 +21,38 @@ type Props = Omit & { document: Document; /** The revision to display */ revision: Revision; + /** Whether to show changes from the previous revision */ + showChanges?: boolean; children?: React.ReactNode; }; /** - * Displays revision HTML pre-rendered on the server. + * Displays a revision with diff highlighting showing changes from the previous revision. + * + * This component shows the content of a specific revision with visual diff indicators + * that highlight what changed compared to the revision that came before it. Insertions + * are shown with a highlight background, and deletions are shown with strikethrough. + * + * @param props - Component props including the revision to display and current document */ -function RevisionViewer(props: Props) { +function RevisionViewer(props: Props, ref: React.Ref) { 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 ( @@ -41,10 +69,11 @@ function RevisionViewer(props: Props) { to={documentPath(document)} rtl={revision.rtl} /> - {children} @@ -52,4 +81,4 @@ function RevisionViewer(props: Props) { ); } -export default observer(RevisionViewer); +export default observer(React.forwardRef(RevisionViewer)); diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index dc214925f9..b31c304153 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -104,7 +104,7 @@ class ApiClient { Accept: "application/json", "cache-control": "no-cache", "x-editor-version": EDITOR_VERSION, - "x-api-version": "3", + "x-api-version": "4", pragma: "no-cache", ...options?.headers, }; diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index b364996415..f5c633cc58 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -27,6 +27,14 @@ export function trashPath(): string { return "/trash"; } +export function debugPath(): string { + return "/debug"; +} + +export function debugChangesetsPath(): string { + return "/debug/changesets"; +} + export function settingsPath(...args: string[]): string { return "/settings" + (args.length > 0 ? `/${args.join("/")}` : ""); } diff --git a/package.json b/package.json index 0f0ebc2d16..c540343a76 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "pluralize": "^8.0.0", "png-chunks-extract": "^1.0.0", "polished": "^4.3.1", + "prosemirror-changeset": "2.3.1", "prosemirror-codemark": "^0.4.2", "prosemirror-commands": "^1.7.1", "prosemirror-dropcursor": "^1.8.2", diff --git a/server/presenters/revision.ts b/server/presenters/revision.ts index 59d552d535..72921ad63f 100644 --- a/server/presenters/revision.ts +++ b/server/presenters/revision.ts @@ -4,7 +4,7 @@ import type { Revision } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import presentUser from "./user"; -async function presentRevision(revision: Revision, diff?: string) { +async function presentRevision(revision: Revision, html?: string) { // TODO: Remove this fallback once all revisions have been migrated const { emoji, strippedTitle } = parseTitle(revision.title); @@ -16,10 +16,10 @@ async function presentRevision(revision: Revision, diff?: string) { data: await DocumentHelper.toJSON(revision), icon: revision.icon ?? emoji, color: revision.color, - html: diff, collaborators: (await revision.collaborators).map((user) => presentUser(user) ), + html, createdAt: revision.createdAt, createdBy: presentUser(revision.user), createdById: revision.userId, diff --git a/server/routes/api/revisions/revisions.ts b/server/routes/api/revisions/revisions.ts index e9c02cadec..df70bedec1 100644 --- a/server/routes/api/revisions/revisions.ts +++ b/server/routes/api/revisions/revisions.ts @@ -52,13 +52,18 @@ router.post( throw ValidationError("Either id or documentId must be provided"); } + // Client no longer needs expensive HTML calculation + const noHTML = Number(ctx.headers["x-api-version"] ?? 0) >= 4; + ctx.body = { data: await presentRevision( after, - await DocumentHelper.diff(before, after, { - includeTitle: false, - includeStyles: false, - }) + noHTML + ? undefined + : await DocumentHelper.diff(before, after, { + includeTitle: false, + includeStyles: false, + }) ), policies: presentPolicies(user, [after]), }; diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 6cf7570cbc..36fcbcf841 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -23,10 +23,10 @@ export const fadeIn = keyframes` to { opacity: 1; } `; -export const pulse = keyframes` - 0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) } - 50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) } - 100% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) } +export const pulse = (color: string) => keyframes` + 0% { box-shadow: 0 0 0 1px ${color} } + 50% { box-shadow: 0 0 0 4px ${color} } + 100% { box-shadow: 0 0 0 1px ${color} } `; const codeMarkCursor = () => css` @@ -275,6 +275,127 @@ const codeBlockStyle = (props: Props) => css` } `; +const diffStyle = (props: Props) => css` + .${EditorStyleHelper.diffNodeInsertion}, + .${EditorStyleHelper.diffInsertion}:not([class^="component-"]), + .${EditorStyleHelper.diffInsertion} > * { + color: ${props.theme.textDiffInserted}; + background-color: ${props.theme.textDiffInsertedBackground}; + text-decoration: none; + + &.${EditorStyleHelper.diffCurrentChange} { + outline-color: ${lighten(0.2, props.theme.textDiffInserted)}; + background-color: ${lighten(0.2, props.theme.textDiffInsertedBackground)}; + animation: ${pulse(lighten(0.2, props.theme.textDiffInsertedBackground))} + 150ms 1; + } + } + + .${EditorStyleHelper.diffNodeInsertion} { + &[class*="component-"] { + outline: 4px solid ${props.theme.textDiffInsertedBackground}; + } + + td, + th { + border-color: ${props.theme.textDiffInsertedBackground}; + } + } + + .${EditorStyleHelper.diffNodeInsertion}[class*="component-"], + .${EditorStyleHelper.diffNodeInsertion}.math-node, + ul.${EditorStyleHelper.diffNodeInsertion}, + li.${EditorStyleHelper.diffNodeInsertion} { + border-radius: ${EditorStyleHelper.blockRadius}; + } + + td.${EditorStyleHelper.diffNodeInsertion}, + th.${EditorStyleHelper.diffNodeInsertion} { + border-color: ${props.theme.textDiffInsertedBackground}; + } + + .${EditorStyleHelper.diffNodeDeletion}, + .${EditorStyleHelper.diffDeletion}:not([class^="component-"]), + .${EditorStyleHelper.diffDeletion} > * { + color: ${props.theme.textDiffDeleted}; + background-color: ${props.theme.textDiffDeletedBackground}; + text-decoration: line-through; + + &.${EditorStyleHelper.diffCurrentChange} { + outline-color: ${lighten(0.2, props.theme.textDiffDeletedBackground)}; + background-color: ${lighten(0.2, props.theme.textDiffDeletedBackground)}; + animation: ${pulse(lighten(0.2, props.theme.textDiffDeletedBackground))} + 150ms 1; + } + } + + .${EditorStyleHelper.diffNodeDeletion} { + &[class*="component-"] { + outline: 4px solid ${props.theme.textDiffDeletedBackground}; + } + + .mention { + background-color: ${props.theme.textDiffDeletedBackground}; + } + + td, + th { + border-color: ${props.theme.textDiffDeletedBackground}; + } + } + + .${EditorStyleHelper.diffNodeDeletion}[class*="component-"], + .${EditorStyleHelper.diffNodeDeletion}.math-node, + ul.${EditorStyleHelper.diffNodeDeletion}, + li.${EditorStyleHelper.diffNodeDeletion} { + border-radius: ${EditorStyleHelper.blockRadius}; + } + + td.${EditorStyleHelper.diffNodeDeletion}, + th.${EditorStyleHelper.diffNodeDeletion} { + border-color: ${props.theme.textDiffDeletedBackground}; + } + + .${EditorStyleHelper.diffNodeModification}, + .${EditorStyleHelper.diffModification}:not([class^="component-"]), + .${EditorStyleHelper.diffModification} > * { + color: ${props.theme.text}; + background-color: ${transparentize(0.7, "#FFA500")}; + text-decoration: none; + + &.${EditorStyleHelper.diffCurrentChange} { + outline-color: ${lighten(0.1, "#FFA500")}; + background-color: ${transparentize(0.5, "#FFA500")}; + animation: ${pulse(transparentize(0.5, "#FFA500"))} 150ms 1; + } + } + + .${EditorStyleHelper.diffNodeModification} { + background-color: ${transparentize(0.7, "#FFA500")}; + + &[class*="component-"] { + outline: 4px solid ${transparentize(0.5, "#FFA500")}; + } + + td, + th { + border-color: ${transparentize(0.5, "#FFA500")}; + } + } + + .${EditorStyleHelper.diffNodeModification}[class*="component-"], + .${EditorStyleHelper.diffNodeModification}.math-node, + ul.${EditorStyleHelper.diffNodeModification}, + li.${EditorStyleHelper.diffNodeModification} { + border-radius: ${EditorStyleHelper.blockRadius}; + } + + td.${EditorStyleHelper.diffNodeModification}, + th.${EditorStyleHelper.diffNodeModification} { + border-color: ${transparentize(0.5, "#FFA500")}; + } +`; + const findAndReplaceStyle = () => css` .find-result:not(:has(.mention)), .find-result .mention { @@ -284,7 +405,7 @@ const findAndReplaceStyle = () => css` .find-result.current-result:not(:has(.mention)), .find-result.current-result .mention { background: rgba(255, 213, 0, 0.75); - animation: ${pulse} 150ms 1; + animation: ${pulse("rgba(255, 213, 0, 0.75)")} 150ms 1; } } `; @@ -805,6 +926,10 @@ img.ProseMirror-separator { margin: 0 !important; } +.component-image { + display: block; +} + // Removes forced paragraph spaces below images, this is needed to images // being inline nodes that are displayed like blocks .component-image + img.ProseMirror-separator, @@ -2107,19 +2232,18 @@ del { text-decoration: strikethrough; } +// TODO: Remove once old email diff rendering is removed. ins[data-operation-index] { color: ${props.theme.textDiffInserted}; background-color: ${props.theme.textDiffInsertedBackground}; text-decoration: none; } - del[data-operation-index] { color: ${props.theme.textDiffDeleted}; background-color: ${props.theme.textDiffDeletedBackground}; text-decoration: none; - img { - opacity: .5; + opacity: 0.5; } } @@ -2167,6 +2291,7 @@ const EditorContainer = styled.div` ${mathStyle} ${codeMarkCursor} ${codeBlockStyle} + ${diffStyle} ${findAndReplaceStyle} ${emailStyle} ${textStyle} diff --git a/shared/editor/extensions/Diff.ts b/shared/editor/extensions/Diff.ts new file mode 100644 index 0000000000..1d056109d2 --- /dev/null +++ b/shared/editor/extensions/Diff.ts @@ -0,0 +1,342 @@ +import { observable } from "mobx"; +import type { Command } from "prosemirror-state"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import type { Node, ResolvedPos } from "prosemirror-model"; +import { DOMSerializer, Fragment } from "prosemirror-model"; +import scrollIntoView from "scroll-into-view-if-needed"; +import Extension from "../lib/Extension"; +import type { ExtendedChange } from "../lib/ChangesetHelper"; +import { cn } from "../styles/utils"; +import { EditorStyleHelper } from "../styles/EditorStyleHelper"; + +const pluginKey = new PluginKey("diffs"); + +export default class Diff extends Extension { + get name() { + return "diff"; + } + + get defaultOptions() { + return { + changes: null, + insertionClassName: EditorStyleHelper.diffInsertion, + deletionClassName: EditorStyleHelper.diffDeletion, + nodeInsertionClassName: EditorStyleHelper.diffNodeInsertion, + nodeDeletionClassName: EditorStyleHelper.diffNodeDeletion, + modificationClassName: EditorStyleHelper.diffModification, + nodeModificationClassName: EditorStyleHelper.diffNodeModification, + currentChangeClassName: EditorStyleHelper.diffCurrentChange, + }; + } + + public commands() { + return { + /** + * Navigate to the next change in the document. + */ + nextChange: () => this.goToChange(1), + + /** + * Navigate to the previous change in the document. + */ + prevChange: () => this.goToChange(-1), + }; + } + + /** + * Get the current change index being viewed. + * + * @returns the index of the current change, or -1 if no change is selected. + */ + public getCurrentChangeIndex(): number { + return this.currentChangeIndex; + } + + /** + * Get the total number of individual changes. + * + * @returns the total count of all inserted, deleted, and modified items. + */ + public getTotalChangesCount(): number { + const { changes } = this.options as { changes: ExtendedChange[] | null }; + if (!changes) { + return 0; + } + + return changes.reduce( + (total, change) => + total + + change.inserted.length + + change.deleted.length + + change.modified.length, + 0 + ); + } + + private goToChange(direction: number): Command { + return (state, dispatch) => { + const totalChanges = this.getTotalChangesCount(); + + if (totalChanges === 0) { + return false; + } + + if (direction > 0) { + if (this.currentChangeIndex >= totalChanges - 1) { + this.currentChangeIndex = 0; + } else { + this.currentChangeIndex += 1; + } + } else { + if (this.currentChangeIndex === 0) { + this.currentChangeIndex = totalChanges - 1; + } else { + this.currentChangeIndex -= 1; + } + } + + dispatch?.(state.tr.setMeta(pluginKey, {})); + + const element = window.document.querySelector( + `.${this.options.currentChangeClassName}` + ); + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + block: "center", + }); + } + return true; + }; + } + + get allowInReadOnly(): boolean { + return true; + } + + get plugins() { + return [ + new Plugin({ + key: pluginKey, + state: { + init: () => DecorationSet.empty, + apply: (tr) => this.createDecorations(tr.doc), + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + // Allow meta transactions to bypass filtering + filterTransaction: (tr) => + tr.getMeta("codeHighlighting") || tr.getMeta(pluginKey) + ? true + : false, + }), + ]; + } + + private createDecorations(doc: Node) { + const { changes } = this.options as { changes: ExtendedChange[] | null }; + const decorations: Decoration[] = []; + + /** + * Determines if a slice should use node decoration instead of inline decoration. + */ + const shouldUseNodeDecoration = ( + slice: + | { content: { childCount: number; firstChild: Node | null } } + | null + | undefined + ): boolean => { + if (slice?.content.childCount === 1) { + const node = slice.content.firstChild; + if ( + node && + !node.isText && + ((node.isBlock && node.type.name !== "paragraph") || + (node.isInline && node.isAtom)) + ) { + return true; + } + } + return false; + }; + + /** + * Adds the appropriate decoration for a change. + */ + const addChangeDecoration = ( + pos: number, + end: number, + className: string, + useNodeDecoration: boolean + ): void => { + if (useNodeDecoration) { + decorations.push( + Decoration.node(pos, end, { + class: className, + }) + ); + } else { + decorations.push( + Decoration.inline(pos, end, { + class: className, + }) + ); + } + }; + + /** + * Recursively unwrap nodes that are redundant or invalid given the + * current context. + */ + const unwrap = ($pos: ResolvedPos, fragment: Fragment): Node[] => { + const result: Node[] = []; + fragment.forEach((node: Node) => { + let isRedundant = false; + + for (let d = 0; d <= $pos.depth; d++) { + const ancestor = $pos.node(d); + const ancestorRole = ancestor.type.spec.tableRole; + const nodeRole = node.type.spec.tableRole; + + if ( + ancestor.type.name === node.type.name || + (ancestorRole === "row" && + (nodeRole === "cell" || nodeRole === "header_cell")) || + (ancestorRole === "table" && nodeRole === "row") + ) { + isRedundant = true; + break; + } + } + + if (node.isBlock && (isRedundant || $pos.parent.type.inlineContent)) { + result.push(...unwrap($pos, node.content)); + } else { + result.push(node); + } + }); + return result; + }; + + // Add insertion, deletion, and modification decorations + let individualChangeIndex = 0; + changes?.forEach((change) => { + let pos = change.fromB; + + change.deleted.forEach((deletion) => { + const isCurrent = individualChangeIndex === this.currentChangeIndex; + if (!deletion.data.slice) { + return; + } + + const $pos = doc.resolve(change.fromB); + const parentRole = $pos.parent.type.spec.tableRole; + const parentGroup = $pos.parent.type.spec.group; + let tag = $pos.parent.type.inlineContent ? "span" : "div"; + + if (parentRole === "table") { + tag = "tr"; + } else if (parentRole === "row") { + tag = "td"; + } else if (parentGroup?.includes("list")) { + tag = "li"; + } + + const useNodeDecoration = shouldUseNodeDecoration(deletion.data.slice); + + // Check if we're deleting a single paragraph - if so, use

tag + // and unwrap the paragraph content to avoid nested

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