@@ -27,35 +244,200 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => {
);
}
- const editor = hostGetEditor();
- const providerCount = editor?.providers ? Object.keys(editor.providers).length : 0;
+ if (load.loading) {
+ return (
+
+ );
+ }
+
+ const {WysiwygEditor, FormattingBar} = hostGetEditor() ?? {};
+ if (!WysiwygEditor) {
+ return null;
+ }
return (
-
- {isDraft ? (
+ {activeEditors.length > 0 && (
+
+
+
+ )}
+
+
+ {load.page ? (
) : (
)}
-
+
+
+
+
-
-
+
+
+
+ {documentMode === false && (
+
+
+
+ )}
+
+ {contentError && (
+
+
+
+ )}
+
+ {actionError != null && (
+
+
+
+ )}
+
+
+
+
+ {pinned && FormattingBar ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {showExitDialog && (
+
{
+ publish(false, true);
+ }}
+ onSaveDraft={saveDraftAndLeave}
+ busy={busy}
+ failed={actionError != null}
+ onDiscard={() => {
+ discard();
+ }}
+ onClose={() => setShowExitDialog(false)}
+ />
+ )}
+
+ {conflict && (
+ {
+ publish(true, conflict.exitAfter);
+ }}
+ onClose={() => setConflict(null)}
+ />
+ )}
);
};
diff --git a/webapp/src/components/page_editor/publish_conflict_dialog.module.scss b/webapp/src/components/page_editor/publish_conflict_dialog.module.scss
new file mode 100644
index 0000000..1faab91
--- /dev/null
+++ b/webapp/src/components/page_editor/publish_conflict_dialog.module.scss
@@ -0,0 +1,17 @@
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ gap: 8px;
+}
+
+.meta {
+ margin-top: 8px;
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+ font-size: 12px;
+}
+
+.error {
+ margin-top: 8px;
+ color: var(--error-text);
+}
diff --git a/webapp/src/components/page_editor/publish_conflict_dialog.tsx b/webapp/src/components/page_editor/publish_conflict_dialog.tsx
new file mode 100644
index 0000000..5b5b652
--- /dev/null
+++ b/webapp/src/components/page_editor/publish_conflict_dialog.tsx
@@ -0,0 +1,101 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import {PrimaryButton, SecondaryButton} from 'components/form_controls/button';
+import GenericModal from 'components/generic_modal/generic_modal';
+
+import type {Page} from 'types/docs';
+
+import styles from './publish_conflict_dialog.module.scss';
+
+type Props = {
+ currentPage: Page | null;
+
+ reason: string;
+
+ onForcePublish: () => void;
+ onClose: () => void;
+ busy?: boolean;
+ failed?: boolean;
+};
+
+const isConcurrentAutosave = (reason: string): boolean => reason.includes('concurrent_autosave');
+
+const PublishConflictDialog = ({currentPage, reason, onForcePublish, onClose, busy = false, failed = false}: Props) => {
+ const {formatMessage} = useIntl();
+ const autosaveConflict = isConcurrentAutosave(reason);
+
+ return (
+
+ }
+ footer={
+
+ }
+ >
+
+ {autosaveConflict ? (
+
+ ) : (
+
+ )}
+
+ {currentPage ? (
+
+
+
+ ) : null}
+ {failed && (
+
+
+
+ )}
+
+ );
+};
+
+export default PublishConflictDialog;
diff --git a/webapp/src/components/page_editor/toolbar_controls.module.scss b/webapp/src/components/page_editor/toolbar_controls.module.scss
new file mode 100644
index 0000000..4f85814
--- /dev/null
+++ b/webapp/src/components/page_editor/toolbar_controls.module.scss
@@ -0,0 +1,71 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+.control {
+ display: flex;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 7px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: rgba(var(--center-channel-color-rgb), var(--icon-opacity));
+ place-content: center;
+ place-items: center;
+
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), var(--icon-opacity-hover));
+ fill: currentcolor;
+ }
+
+ &:active,
+ &.active,
+ &.active:hover {
+ background: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ fill: currentcolor;
+ }
+}
+
+.menuWrapper {
+ position: relative;
+ display: flex;
+}
+
+.menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ z-index: 20;
+ min-width: 180px;
+ padding: 8px 0;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
+ border-radius: 4px;
+ background: var(--center-channel-bg);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.menuItem {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ padding: 8px 20px;
+ border: none;
+ background: transparent;
+ color: var(--center-channel-color);
+ font-size: 14px;
+ gap: 12px;
+ text-align: left;
+
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+}
+
+.swatch {
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
diff --git a/webapp/src/components/page_editor/toolbar_controls.tsx b/webapp/src/components/page_editor/toolbar_controls.tsx
new file mode 100644
index 0000000..89e73f4
--- /dev/null
+++ b/webapp/src/components/page_editor/toolbar_controls.tsx
@@ -0,0 +1,152 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {Editor} from '@tiptap/core';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {useIntl} from 'react-intl';
+
+import {
+ AlertCircleOutlineIcon,
+ AlertOutlineIcon,
+ CheckCircleOutlineIcon,
+ CloseCircleOutlineIcon,
+ InformationOutlineIcon,
+ PinOutlineIcon,
+} from '@mattermost/compass-icons/components';
+
+import type {CalloutType} from './callout_extension';
+import {CALLOUT_TYPES} from './callout_extension';
+import styles from './toolbar_controls.module.scss';
+
+const CALLOUT_ICONS: Record
= {
+ info: InformationOutlineIcon,
+ note: AlertCircleOutlineIcon,
+ success: CheckCircleOutlineIcon,
+ warning: AlertOutlineIcon,
+ error: CloseCircleOutlineIcon,
+};
+
+type PinProps = {
+ pinned: boolean;
+ onToggle: () => void;
+};
+
+export const PinToolbarControl = ({pinned, onToggle}: PinProps) => {
+ const {formatMessage} = useIntl();
+ const label = pinned ? formatMessage({id: 'docs.editor.unpinToolbar', defaultMessage: 'Unpin toolbar'}) : formatMessage({id: 'docs.editor.pinToolbar', defaultMessage: 'Pin toolbar to top'});
+
+ return (
+
+ );
+};
+
+type CalloutProps = {
+ getEditor: () => unknown;
+};
+
+export const CalloutControl = ({getEditor}: CalloutProps) => {
+ const {formatMessage} = useIntl();
+ const [open, setOpen] = useState(false);
+ const wrapperRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ return undefined;
+ }
+ const onDocumentClick = (e: MouseEvent) => {
+ if (!wrapperRef.current?.contains(e.target as globalThis.Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('click', onDocumentClick);
+ return () => document.removeEventListener('click', onDocumentClick);
+ }, [open]);
+
+ const onKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }, []);
+
+ const insert = useCallback((type: CalloutType) => {
+ const editor = getEditor() as Editor | null;
+ const chain = editor?.chain().focus();
+
+ let applied = false;
+ if (chain && typeof chain.toggleCallout === 'function') {
+ applied = chain.toggleCallout(type).run();
+ }
+ setOpen(false);
+ if (!applied) {
+ triggerRef.current?.focus();
+ }
+ }, [getEditor]);
+
+ const label = formatMessage({id: 'docs.editor.callout', defaultMessage: 'Insert callout'});
+
+ return (
+
+
+
+ {open && (
+
+ {CALLOUT_TYPES.map((type) => {
+ const Icon = CALLOUT_ICONS[type];
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+type Formatter = ReturnType['formatMessage'];
+
+const CALLOUT_LABELS: Record string> = {
+ info: (f) => f({id: 'docs.editor.calloutInfo', defaultMessage: 'Info'}),
+ note: (f) => f({id: 'docs.editor.calloutNote', defaultMessage: 'Note'}),
+ success: (f) => f({id: 'docs.editor.calloutSuccess', defaultMessage: 'Success'}),
+ warning: (f) => f({id: 'docs.editor.calloutWarning', defaultMessage: 'Warning'}),
+ error: (f) => f({id: 'docs.editor.calloutError', defaultMessage: 'Error'}),
+};
diff --git a/webapp/src/hooks/caret_anchored_suggestions.ts b/webapp/src/hooks/caret_anchored_suggestions.ts
new file mode 100644
index 0000000..a16e1f8
--- /dev/null
+++ b/webapp/src/hooks/caret_anchored_suggestions.ts
@@ -0,0 +1,109 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect} from 'react';
+
+const GAP = 4;
+const ESTIMATED_HEIGHT = 240;
+const SELECTOR = '.suggestion-list';
+
+export const useCaretAnchoredSuggestions = (surfaceRef: React.RefObject, enabled: boolean) => {
+ useEffect(() => {
+ const surface = enabled ? surfaceRef.current : null;
+ if (!surface) {
+ return undefined;
+ }
+
+ let list: HTMLElement | null = null;
+ let frame = 0;
+
+ const position = () => {
+ const selection = window.getSelection();
+ if (!list || !selection || selection.rangeCount === 0) {
+ return;
+ }
+
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+ const caret = rect.height === 0 ? (range.startContainer.parentElement?.getBoundingClientRect() ?? rect) : rect;
+
+ const surfaceRect = surface.getBoundingClientRect();
+ const height = list.offsetHeight || ESTIMATED_HEIGHT;
+ const flipAbove = window.innerHeight - caret.bottom < height && caret.top > height;
+ const above = caret.top - surfaceRect.top - (height + GAP);
+ const below = (caret.bottom - surfaceRect.top) + GAP;
+
+ const maxLeft = Math.max(0, surface.clientWidth - list.offsetWidth);
+ const left = Math.min(Math.max(0, caret.left - surfaceRect.left), maxLeft);
+
+ list.style.bottom = 'auto';
+ list.style.top = `${Math.round(flipAbove ? above : below)}px`;
+ list.style.left = `${Math.round(left)}px`;
+ };
+
+ const schedule = () => {
+ if (frame) {
+ return;
+ }
+ frame = requestAnimationFrame(() => {
+ frame = 0;
+ position();
+ });
+ };
+
+ const sync = () => {
+ const found = surface.querySelector(SELECTOR);
+ if (found !== list) {
+ list = found;
+ if (list) {
+ document.addEventListener('selectionchange', schedule);
+ window.addEventListener('resize', schedule);
+ surface.closest('[data-docs-scroll]')?.addEventListener('scroll', schedule);
+ } else {
+ document.removeEventListener('selectionchange', schedule);
+ window.removeEventListener('resize', schedule);
+ surface.closest('[data-docs-scroll]')?.removeEventListener('scroll', schedule);
+ }
+ }
+
+ if (list) {
+ schedule();
+ }
+ };
+
+ const onMutation = (records: MutationRecord[]) => {
+ let touched = false;
+ for (const record of records) {
+ for (const node of [...record.addedNodes, ...record.removedNodes]) {
+ if (node.nodeType === globalThis.Node.ELEMENT_NODE) {
+ touched = true;
+ break;
+ }
+ }
+ if (touched) {
+ break;
+ }
+ }
+
+ if (!touched) {
+ return;
+ }
+
+ sync();
+ };
+
+ const observer = new MutationObserver(onMutation);
+ observer.observe(surface, {childList: true, subtree: true});
+ sync();
+
+ return () => {
+ observer.disconnect();
+ document.removeEventListener('selectionchange', schedule);
+ window.removeEventListener('resize', schedule);
+ surface.closest('[data-docs-scroll]')?.removeEventListener('scroll', schedule);
+ if (frame) {
+ cancelAnimationFrame(frame);
+ }
+ };
+ }, [surfaceRef, enabled]);
+};
diff --git a/webapp/src/hooks/draft_autosave.test.tsx b/webapp/src/hooks/draft_autosave.test.tsx
new file mode 100644
index 0000000..8678881
--- /dev/null
+++ b/webapp/src/hooks/draft_autosave.test.tsx
@@ -0,0 +1,367 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {act, renderHook} from '@testing-library/react';
+import {updatePageDraft} from 'client/drafts';
+
+import type {Draft, DraftPatch} from 'types/drafts';
+
+import {AUTOSAVE_DEBOUNCE_MS, useDraftAutosave} from './draft_autosave';
+
+jest.mock('client/drafts', () => ({
+ updatePageDraft: jest.fn(),
+}));
+
+const mockUpdate = updatePageDraft as jest.MockedFunction;
+
+const savedDraft = {page_id: 'page1'} as Draft;
+
+const patchesSent = (): DraftPatch[] => mockUpdate.mock.calls.map((call) => call[2]);
+
+const setup = (overrides: Partial[0]> = {}) =>
+ renderHook((props: Parameters[0]) => useDraftAutosave(props), {
+ initialProps: {
+ spaceId: 'space1',
+ pageId: 'page1',
+ enabled: true,
+ ...overrides,
+ },
+ });
+
+const runDebounce = async () => {
+ await act(async () => {
+ jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS);
+ });
+};
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockUpdate.mockReset();
+ mockUpdate.mockResolvedValue(savedDraft);
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe('useDraftAutosave', () => {
+ it('debounces bursts into a single write', async () => {
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'a'});
+ result.current.queue({body: 'ab'});
+ result.current.queue({body: 'abc'});
+ });
+ expect(mockUpdate).not.toHaveBeenCalled();
+
+ await runDebounce();
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(patchesSent()[0]).toEqual({body: 'abc'});
+ });
+
+ it('coalesces different fields rather than replacing the pending patch', async () => {
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({title: 'Title'});
+ result.current.queue({body: 'Body'});
+ });
+ await runDebounce();
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(patchesSent()[0]).toEqual({title: 'Title', body: 'Body'});
+ });
+
+ it('repeats base_edit_at on every write for an existing page', async () => {
+ const {result} = setup({baseEditAt: 1234});
+
+ act(() => {
+ result.current.queue({body: 'first'});
+ });
+ await runDebounce();
+
+ act(() => {
+ result.current.queue({body: 'second'});
+ });
+ await runDebounce();
+
+ expect(patchesSent()).toEqual([
+ {body: 'first', base_edit_at: 1234},
+ {body: 'second', base_edit_at: 1234},
+ ]);
+ });
+
+ it('omits base_edit_at for a new-page draft, which has no baseline', async () => {
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'new'});
+ });
+ await runDebounce();
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(patchesSent()[0]).not.toHaveProperty('base_edit_at');
+ });
+
+ it('does not write while disabled, so a new page cannot autosave before its id exists', async () => {
+ const {result} = setup({enabled: false});
+
+ act(() => {
+ result.current.queue({body: 'a'});
+ });
+ await runDebounce();
+
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it('cancel drops a pending save so discard is not undone by the debounce', async () => {
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'doomed'});
+ result.current.cancel();
+ });
+ await runDebounce();
+
+ expect(mockUpdate).not.toHaveBeenCalled();
+ expect(result.current.status).toBe('saved');
+ });
+
+ it('ignores an in-flight save that resolves after cancel', async () => {
+ let resolveSave: (draft: Draft) => void = () => {};
+ mockUpdate.mockReturnValueOnce(new Promise((resolve) => {
+ resolveSave = resolve;
+ }));
+
+ const onSaved = jest.fn();
+ const {result} = setup({onSaved});
+
+ act(() => {
+ result.current.queue({body: 'inflight'});
+ });
+ await runDebounce();
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.cancel();
+ });
+ await act(async () => {
+ resolveSave(savedDraft);
+ });
+
+ expect(onSaved).not.toHaveBeenCalled();
+ expect(result.current.status).toBe('saved');
+ });
+
+ it('flush writes immediately so publish does not race the debounce', async () => {
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'pending'});
+ });
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(patchesSent()[0]).toEqual({body: 'pending'});
+ });
+
+ it('keeps the patch for retry when a save fails', async () => {
+ mockUpdate.mockRejectedValueOnce(new Error('offline'));
+ const onError = jest.fn();
+ const {result} = setup({onError});
+
+ act(() => {
+ result.current.queue({body: 'lost'});
+ });
+ await runDebounce();
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(result.current.status).toBe('unsaved');
+
+ await act(async () => {
+ await result.current.flush();
+ });
+ expect(patchesSent()[1]).toEqual({body: 'lost'});
+ });
+
+ it('stays dirty when edits arrive while a save is in flight', async () => {
+ let resolveSave: (draft: Draft) => void = () => {};
+ mockUpdate.mockReturnValueOnce(new Promise((resolve) => {
+ resolveSave = resolve;
+ }));
+
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'first'});
+ });
+ await runDebounce();
+
+ act(() => {
+ result.current.queue({body: 'second'});
+ });
+ await act(async () => {
+ resolveSave(savedDraft);
+ });
+
+ expect(result.current.status).toBe('unsaved');
+ });
+
+ it('flush reports failure so publish does not proceed on unsaved content', async () => {
+ mockUpdate.mockRejectedValueOnce(new Error('offline'));
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'lost'});
+ });
+
+ let flushed: boolean | undefined;
+ await act(async () => {
+ flushed = await result.current.flush();
+ });
+
+ expect(flushed).toBe(false);
+ expect(result.current.status).toBe('unsaved');
+ });
+
+ it('flush waits for a write already in flight instead of resolving early', async () => {
+ let resolveSave: (draft: Draft) => void = () => {};
+ mockUpdate.mockReturnValueOnce(new Promise((resolve) => {
+ resolveSave = resolve;
+ }));
+
+ const {result} = setup();
+
+ act(() => {
+ result.current.queue({body: 'inflight'});
+ });
+ await runDebounce();
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+
+ let settled = false;
+ let flushed: Promise = Promise.resolve(false);
+ act(() => {
+ flushed = result.current.flush().then((ok) => {
+ settled = true;
+ return ok;
+ });
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(settled).toBe(false);
+
+ await act(async () => {
+ resolveSave(savedDraft);
+ await flushed;
+ });
+ expect(settled).toBe(true);
+ });
+
+ it('does not merge a failed patch into a patch queued for another page', async () => {
+ let rejectSave: (error: Error) => void = () => {};
+ mockUpdate.mockReturnValueOnce(new Promise((_, reject) => {
+ rejectSave = reject;
+ }));
+
+ const {result, rerender} = setup();
+
+ act(() => {
+ result.current.queue({body: 'page1 body'});
+ });
+ await runDebounce();
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ rerender({spaceId: 'space1', pageId: 'page2', enabled: true});
+ });
+ act(() => {
+ result.current.queue({body: 'page2 body'});
+ });
+
+ await act(async () => {
+ rejectSave(new Error('offline'));
+ });
+
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ const page2Writes = mockUpdate.mock.calls.filter((call) => call[1] === 'page2');
+ expect(page2Writes).toHaveLength(1);
+ expect(page2Writes[0][2]).toEqual({body: 'page2 body'});
+
+ for (const call of mockUpdate.mock.calls) {
+ if (call[1] === 'page1') {
+ expect(call[2]).not.toMatchObject({body: 'page2 body'});
+ }
+ }
+ });
+
+ it('sends the baseline of the page it targets, not the page now on screen', async () => {
+ const {result, rerender} = setup({baseEditAt: 111});
+
+ act(() => {
+ result.current.queue({body: 'typed on page1'});
+ });
+
+ await act(async () => {
+ rerender({spaceId: 'space1', pageId: 'page2', enabled: true, baseEditAt: 222});
+ });
+
+ expect(mockUpdate.mock.calls[0][1]).toBe('page1');
+ expect(patchesSent()[0]).toEqual({body: 'typed on page1', base_edit_at: 111});
+ });
+
+ it('flushes the pending patch to the page being left when the id changes', async () => {
+ const {result, rerender} = setup();
+
+ act(() => {
+ result.current.queue({body: 'typed on page1'});
+ });
+
+ await act(async () => {
+ rerender({spaceId: 'space1', pageId: 'page2', enabled: true});
+ });
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(mockUpdate.mock.calls[0][1]).toBe('page1');
+ expect(patchesSent()[0]).toEqual({body: 'typed on page1'});
+ });
+
+ it('flushes the pending patch even when the next page reports itself as loading', async () => {
+ const {result, rerender} = setup();
+
+ act(() => {
+ result.current.queue({body: 'typed on page1'});
+ });
+
+ await act(async () => {
+ rerender({spaceId: 'space1', pageId: 'page2', enabled: false});
+ });
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(mockUpdate.mock.calls[0][1]).toBe('page1');
+ expect(patchesSent()[0]).toEqual({body: 'typed on page1'});
+ });
+
+ it('flushes the pending patch when the editor unmounts', async () => {
+ const {result, unmount} = setup();
+
+ act(() => {
+ result.current.queue({body: 'typed before leaving'});
+ });
+
+ await act(async () => {
+ unmount();
+ });
+
+ expect(mockUpdate).toHaveBeenCalledTimes(1);
+ expect(patchesSent()[0]).toEqual({body: 'typed before leaving'});
+ });
+});
diff --git a/webapp/src/hooks/draft_autosave.ts b/webapp/src/hooks/draft_autosave.ts
new file mode 100644
index 0000000..c095d29
--- /dev/null
+++ b/webapp/src/hooks/draft_autosave.ts
@@ -0,0 +1,154 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {updatePageDraft} from 'client/drafts';
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+
+import type {Draft, DraftPatch} from 'types/drafts';
+
+import {useLatest} from './utils';
+
+export const AUTOSAVE_DEBOUNCE_MS = 1000;
+
+export type AutosaveStatus = 'saved' | 'saving' | 'unsaved';
+
+type Options = {
+ spaceId: string;
+ pageId: string;
+
+ enabled: boolean;
+
+ baseEditAt?: number;
+
+ onSaved?: (draft: Draft) => void;
+ onError?: (error: unknown) => void;
+};
+
+export type DraftAutosave = {
+ status: AutosaveStatus;
+
+ queue: (patch: DraftPatch) => void;
+
+ flush: () => Promise;
+
+ cancel: () => void;
+};
+
+type Pending = {
+ spaceId: string;
+ pageId: string;
+
+ baseEditAt?: number;
+ patch: DraftPatch;
+};
+
+export function useDraftAutosave({spaceId, pageId, enabled, baseEditAt, onSaved, onError}: Options): DraftAutosave {
+ const [status, setStatus] = useState('saved');
+
+ const pendingRef = useRef(null);
+ const timerRef = useRef | null>(null);
+ const abortRef = useRef(null);
+ const chainRef = useRef>(Promise.resolve(true));
+
+ const generationRef = useRef(0);
+
+ const latest = useLatest({spaceId, pageId, enabled, baseEditAt, onSaved, onError});
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ const doWrite = useCallback(async (force: boolean): Promise => {
+ const entry = pendingRef.current;
+ const {enabled: on, onSaved: saved, onError: failed} = latest.current;
+ if (!entry || (!on && !force)) {
+ return true;
+ }
+ const baseline = entry.baseEditAt;
+
+ pendingRef.current = null;
+ const generation = generationRef.current;
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setStatus('saving');
+
+ try {
+ const body: DraftPatch = baseline ? {...entry.patch, base_edit_at: baseline} : entry.patch;
+ const draft = await updatePageDraft(entry.spaceId, entry.pageId, body, controller.signal);
+ if (generation !== generationRef.current) {
+ return false;
+ }
+ saved?.(draft);
+
+ setStatus(pendingRef.current ? 'unsaved' : 'saved');
+ return true;
+ } catch (error) {
+ if (generation !== generationRef.current || controller.signal.aborted) {
+ return false;
+ }
+
+ const queuedSince = pendingRef.current as Pending | null;
+ const sameTarget = !queuedSince ||
+ (queuedSince.spaceId === entry.spaceId && queuedSince.pageId === entry.pageId);
+
+ if (sameTarget) {
+ pendingRef.current = {
+ ...entry,
+ patch: {...entry.patch, ...(queuedSince?.patch ?? {})},
+ };
+ }
+ setStatus('unsaved');
+ failed?.(error);
+ return false;
+ } finally {
+ if (abortRef.current === controller) {
+ abortRef.current = null;
+ }
+ }
+ }, [latest]);
+
+ const write = useCallback((force = false): Promise => {
+ const run = () => doWrite(force);
+ const next = chainRef.current.then(run, run);
+ chainRef.current = next;
+ return next;
+ }, [doWrite]);
+
+ const queue = useCallback((patch: DraftPatch) => {
+ const {spaceId: space, pageId: page, baseEditAt: baseline} = latest.current;
+ const prior = pendingRef.current?.spaceId === space && pendingRef.current?.pageId === page ? pendingRef.current.patch : {};
+
+ pendingRef.current = {spaceId: space, pageId: page, baseEditAt: baseline, patch: {...prior, ...patch}};
+ setStatus('unsaved');
+ clearTimer();
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+
+ write();
+ }, AUTOSAVE_DEBOUNCE_MS);
+ }, [clearTimer, write, latest]);
+
+ const flush = useCallback((): Promise => {
+ clearTimer();
+ return write();
+ }, [clearTimer, write]);
+
+ const cancel = useCallback(() => {
+ generationRef.current += 1;
+ clearTimer();
+ pendingRef.current = null;
+ abortRef.current?.abort();
+ abortRef.current = null;
+ setStatus('saved');
+ }, [clearTimer]);
+
+ useEffect(() => () => {
+ clearTimer();
+ write(true);
+ }, [clearTimer, write, spaceId, pageId]);
+
+ return useMemo(() => ({status, queue, flush, cancel}), [status, queue, flush, cancel]);
+}
diff --git a/webapp/src/hooks/page_draft.test.tsx b/webapp/src/hooks/page_draft.test.tsx
new file mode 100644
index 0000000..d05e736
--- /dev/null
+++ b/webapp/src/hooks/page_draft.test.tsx
@@ -0,0 +1,75 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {renderHook, waitFor} from '@testing-library/react';
+import {getPageDraft} from 'client/drafts';
+import {getPage} from 'client/pages';
+import {RestError} from 'client/rest';
+
+import type {Page} from 'types/docs';
+import type {Draft} from 'types/drafts';
+
+import {usePageDraft} from './page_draft';
+
+jest.mock('client/drafts', () => ({
+ getPageDraft: jest.fn(),
+}));
+
+jest.mock('client/pages', () => ({
+ getPage: jest.fn(),
+}));
+
+const mockGetDraft = getPageDraft as jest.MockedFunction;
+const mockGetPage = getPage as jest.MockedFunction;
+
+const page = (id: string, editAt: number) => ({id, title: `${id} title`, body: `${id} body`, edit_at: editAt} as Page);
+const draft = (pageId: string, baseEditAt: number) => ({page_id: pageId, title: `${pageId} draft`, body: `${pageId} draft body`, base_edit_at: baseEditAt} as Draft);
+
+const notFound = () => Promise.reject(new RestError('/pages', 404, 'not found', null));
+
+beforeEach(() => {
+ mockGetDraft.mockReset();
+ mockGetPage.mockReset();
+});
+
+describe('usePageDraft', () => {
+ it('uses the draft baseline rather than the page edit_at when a draft exists', async () => {
+ mockGetDraft.mockResolvedValue(draft('page1', 100));
+ mockGetPage.mockResolvedValue(page('page1', 500));
+
+ const {result} = renderHook(() => usePageDraft('space1', 'page1'));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.baseEditAt).toBe(100);
+ });
+
+ it('falls back to the page edit_at when no draft exists', async () => {
+ mockGetDraft.mockImplementation(notFound);
+ mockGetPage.mockResolvedValue(page('page1', 500));
+
+ const {result} = renderHook(() => usePageDraft('space1', 'page1'));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.baseEditAt).toBe(500);
+ });
+
+ it('reports loading on the first render after the page id changes', async () => {
+ mockGetDraft.mockImplementation(notFound);
+ mockGetPage.mockImplementation((_spaceId, pageId) => Promise.resolve(page(pageId, 500)));
+
+ const {result, rerender} = renderHook(({pageId}) => usePageDraft('space1', pageId), {
+ initialProps: {pageId: 'page1'},
+ });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.body).toBe('page1 body');
+
+ rerender({pageId: 'page2'});
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.body).toBe('');
+ expect(result.current.baseEditAt).toBeUndefined();
+
+ await waitFor(() => expect(result.current.body).toBe('page2 body'));
+ });
+});
diff --git a/webapp/src/hooks/page_draft.ts b/webapp/src/hooks/page_draft.ts
new file mode 100644
index 0000000..463584e
--- /dev/null
+++ b/webapp/src/hooks/page_draft.ts
@@ -0,0 +1,94 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getPageDraft} from 'client/drafts';
+import {getPage} from 'client/pages';
+import {RestError} from 'client/rest';
+import {useEffect, useState} from 'react';
+
+import type {Page} from 'types/docs';
+
+export type PageDraftLoad = {
+ loading: boolean;
+ error: unknown;
+
+ title: string;
+ body: string;
+
+ page: Page | null;
+
+ fromDraft: boolean;
+
+ notFound: boolean;
+
+ baseEditAt?: number;
+};
+
+const initial: PageDraftLoad = {
+ loading: true,
+ error: null,
+ title: '',
+ body: '',
+ page: null,
+ fromDraft: false,
+ notFound: false,
+};
+
+const isNotFound = (error: unknown): boolean => error instanceof RestError && error.status === 404;
+
+type Resolved = PageDraftLoad & {key: string};
+
+const keyOf = (spaceId: string, pageId: string): string => `${spaceId}/${pageId}`;
+
+export function usePageDraft(spaceId: string, pageId: string): PageDraftLoad {
+ const [state, setState] = useState(() => ({...initial, key: keyOf(spaceId, pageId)}));
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const key = keyOf(spaceId, pageId);
+ setState({...initial, key});
+
+ const load = async () => {
+ const [draftResult, pageResult] = await Promise.allSettled([
+ getPageDraft(spaceId, pageId, controller.signal),
+ getPage(spaceId, pageId, controller.signal),
+ ]);
+
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ const draft = draftResult.status === 'fulfilled' ? draftResult.value : null;
+ const page = pageResult.status === 'fulfilled' ? pageResult.value : null;
+
+ const fatal = [draftResult, pageResult].
+ filter((result): result is PromiseRejectedResult => result.status === 'rejected').
+ map((result) => result.reason).
+ find((reason) => !isNotFound(reason));
+
+ if (fatal) {
+ setState({...initial, key, loading: false, error: fatal});
+ return;
+ }
+
+ setState({
+ key,
+ loading: false,
+ error: null,
+
+ title: draft?.title || page?.title || '',
+ body: draft?.body || page?.body || '',
+ page,
+ fromDraft: Boolean(draft),
+ notFound: !draft && !page,
+ baseEditAt: draft?.base_edit_at ?? page?.edit_at,
+ });
+ };
+
+ load();
+
+ return () => controller.abort();
+ }, [spaceId, pageId]);
+
+ return state.key === keyOf(spaceId, pageId) ? state : initial;
+}
diff --git a/webapp/src/hooks/page_presence.ts b/webapp/src/hooks/page_presence.ts
new file mode 100644
index 0000000..3f3c169
--- /dev/null
+++ b/webapp/src/hooks/page_presence.ts
@@ -0,0 +1,73 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getPageActiveEditors} from 'client/drafts';
+import {subscribeToPagePresence} from 'client/presence_events';
+import {useEffect, useMemo, useState} from 'react';
+
+import type {PageActiveEditors} from 'types/drafts';
+
+export function usePagePresence(spaceId: string, pageId: string, currentUserId: string): string[] {
+ const [snapshot, setSnapshot] = useState(null);
+
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setSnapshot(null);
+
+ getPageActiveEditors(spaceId, pageId, controller.signal).
+ then((next) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setSnapshot((current) => (current && current.snapshot_at >= next.snapshot_at ? current : next));
+ }).
+ catch(() => {
+ });
+
+ return () => controller.abort();
+ }, [spaceId, pageId]);
+
+ useEffect(() => subscribeToPagePresence((event) => {
+ if (event.page_id !== pageId) {
+ return;
+ }
+
+ setSnapshot((current) => {
+ if (current && event.snapshot_at < current.snapshot_at) {
+ return current;
+ }
+ return {
+ active_editors: event.active_editors,
+ snapshot_at: event.snapshot_at,
+ active_timeout_ms: event.active_timeout_ms,
+ };
+ });
+ }), [pageId]);
+
+ useEffect(() => {
+ if (!snapshot || snapshot.active_timeout_ms <= 0 || snapshot.active_editors.length === 0) {
+ return undefined;
+ }
+
+ const expiresIn = (snapshot.snapshot_at + snapshot.active_timeout_ms) - Date.now();
+ if (expiresIn <= 0) {
+ setNow(Date.now());
+ return undefined;
+ }
+ const timer = setTimeout(() => setNow(Date.now()), expiresIn);
+ return () => clearTimeout(timer);
+ }, [snapshot]);
+
+ return useMemo(() => {
+ if (!snapshot) {
+ return [];
+ }
+ if (snapshot.active_timeout_ms > 0 && now - snapshot.snapshot_at > snapshot.active_timeout_ms) {
+ return [];
+ }
+
+ return snapshot.active_editors.filter((id) => id !== currentUserId);
+ }, [snapshot, now, currentUserId]);
+}
diff --git a/webapp/src/hooks/pinned_toolbar.ts b/webapp/src/hooks/pinned_toolbar.ts
new file mode 100644
index 0000000..5c6729f
--- /dev/null
+++ b/webapp/src/hooks/pinned_toolbar.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useCallback, useEffect, useRef, useState} from 'react';
+
+const STORAGE_KEY = 'docs_toolbar_pinned';
+
+const readStored = (): boolean => {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+};
+
+const writeStored = (pinned: boolean): boolean => {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, String(pinned));
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const usePinnedToolbar = (): [boolean, () => void] => {
+ const [pinned, setPinned] = useState(readStored);
+
+ const toggle = useCallback(() => setPinned((prev) => !prev), []);
+
+ const firstRender = useRef(true);
+ useEffect(() => {
+ if (firstRender.current) {
+ firstRender.current = false;
+ return;
+ }
+ writeStored(pinned);
+ }, [pinned]);
+
+ return [pinned, toggle];
+};
diff --git a/webapp/src/hooks/user.ts b/webapp/src/hooks/user.ts
index d19b0c2..b4272d8 100644
--- a/webapp/src/hooks/user.ts
+++ b/webapp/src/hooks/user.ts
@@ -25,3 +25,7 @@ export function useCurrentUser(): {name: string} {
return {name};
}
+
+export function useCurrentUserId(): string {
+ return useSelector((state: GlobalState) => getCurrentUser(state)?.id ?? '');
+}
diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx
index e6e5884..fdfd594 100644
--- a/webapp/src/index.tsx
+++ b/webapp/src/index.tsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {publishPagePresence} from 'client/presence_events';
+import type {PagePresenceEvent} from 'client/presence_events';
import manifest from 'manifest';
import type {Reducer} from 'redux';
import {DOCS_BASE_URL, DOCS_SWITCHER_LINK_URL} from 'routing/paths';
@@ -20,6 +22,8 @@ const SWITCHER_ICON = 'file-text-outline';
const DocsHeaderCentre = () => null;
+const PAGE_PRESENCE_EVENT = `custom_${manifest.id}_page_presence_updated`;
+
export default class Plugin {
public async initialize(registry: PluginRegistry) {
registry.registerTranslations({
@@ -39,6 +43,10 @@ export default class Plugin {
// out).
registry.registerReducer(reducer as Reducer);
+ registry.registerWebSocketEventHandler(PAGE_PRESENCE_EVENT, (msg) => {
+ publishPagePresence(msg.data);
+ });
+
registry.registerProduct({
baseURL: DOCS_BASE_URL,
switcherIcon: SWITCHER_ICON,
diff --git a/webapp/src/types/drafts.ts b/webapp/src/types/drafts.ts
new file mode 100644
index 0000000..f50c01a
--- /dev/null
+++ b/webapp/src/types/drafts.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {Page} from './docs';
+
+export type Draft = {
+ user_id: string;
+ space_id: string;
+ page_id: string;
+ parent_id: string;
+ title: string;
+ body: string;
+ file_ids: string[];
+ props: Record;
+ create_at: number;
+ update_at: number;
+
+ last_active_at: number;
+
+ base_edit_at: number;
+};
+
+export type DraftSummary = Omit;
+
+export type DraftPatch = {
+ title?: string;
+ body?: string;
+ parent_id?: string;
+ file_ids?: string[];
+ props?: Record;
+
+ base_edit_at?: number;
+};
+
+export type PageActiveEditors = {
+ active_editors: string[];
+ snapshot_at: number;
+
+ active_timeout_ms: number;
+};
+
+export const ConflictReason = {
+ ConcurrentEdit: 'concurrent_edit',
+ ConcurrentAutosave: 'concurrent_autosave',
+} as const;
+
+export type ConflictReasonType = typeof ConflictReason[keyof typeof ConflictReason];
+
+export type PublishConflict = {
+ error: {
+ id: string;
+ message: string;
+ status_code: number;
+ };
+ current_page: Page | null;
+};