Files
openproject/frontend/src/react/components/OpBlockNoteEditor.tsx
T
Wieland Lindenthal d351296e6c Fix CTRL+Z in documents
https://community.openproject.org/wp/STC-779

Three independent defects together caused Y.UndoManager state to be lost
in the documents module:

1. `useCreateBlockNote(editorParams, [activeUser])` used `[activeUser]`
   as the recreation key. BlockNote's `useCreateBlockNote(options, deps)`
   uses `deps` as the sole `useMemo` key (options is intentionally NOT in
   deps). Since `block-note-element.ts` parses a fresh `activeUser` from
   the host attribute via `JSON.parse` on every `renderCallback` invocation,
   any path that re-rendered the React tree handed in a new object
   reference and rebuilt the editor (and its UndoManager).

2. `LiveCollaborationManager.initializeYjsProvider` was not idempotent.
   Stimulus's `init-yjs-provider` controller can fire `connect()` a second
   time without firing `disconnect()` (HMR replay, Turbo morph, parent
   re-attach). The duplicate call destroyed the live Y.Doc + provider and
   rebuilt both, remounting the editor and wiping its history. The
   controller now adopts the existing session via
   `getCurrentSessionFor(documentName)` instead of constructing a duplicate.

3. The `React.StrictMode` wrap in `block-note-element.ts` caused
   BlockNoteView to destroy and recreate the ProseMirror view between
   StrictMode's two dev-mode mounts. `y-prosemirror`'s `yUndoPlugin`
   destroys the `Y.UndoManager` on view-destroy (removing its
   `afterTransaction` handler from the Y.Doc), but the plugin state retains
   the now-dead UndoManager reference. After the second mount the editor
   reused the destroyed UndoManager, no handler was re-attached, no stack
   items were recorded, and Ctrl+Z was a no-op. StrictMode is dev-only and
   incompatible with the current y-prosemirror lifecycle, so it is removed
   from the BlockNote tree.

Verified on release/17.5: typing produces stack items, Ctrl+Z reverts to
the previous state, Ctrl+Shift+Z reapplies, and the Y.Doc has exactly one
`afterTransaction` observer (the live UndoManager's). Synthetic duplicate
`connect()` no longer remounts the editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 16:36:26 +02:00

162 lines
5.8 KiB
TypeScript

/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import { BlockNoteEditorOptions, BlockNoteSchema } from '@blocknote/core';
import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture';
import { User } from '@blocknote/core/comments';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { BlockNoteView } from '@blocknote/mantine';
import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import {
initializeOpBlockNoteExtensions,
openProjectWorkPackageBlockSpec,
openProjectWorkPackageInlineSpec,
workPackageSlashMenu,
useInlineWpEvents,
useHashWpMenu,
} from 'op-blocknote-extensions';
import { useCallback, useEffect, useMemo } from 'react';
import * as Y from 'yjs';
import { useBlockNoteAttachments } from '../hooks/useBlockNoteAttachments';
import { useBlockNoteLocale } from '../hooks/useBlockNoteLocale';
import { useOpTheme } from '../hooks/useOpTheme';
interface CollaborativeUser {
name:string;
color:string;
}
export interface OpBlockNoteEditorProps {
activeUser:User;
readOnly:boolean;
openProjectUrl:string;
attachmentsUploadUrl:string;
attachmentsCollectionKey:string;
captureExternalLinks:boolean;
hocuspocusProvider?:HocuspocusProvider;
doc:Y.Doc;
}
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(),
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
},
});
function generateRandomColor() {
return '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
}
export function OpBlockNoteEditor({
activeUser,
readOnly,
openProjectUrl,
attachmentsUploadUrl,
attachmentsCollectionKey,
captureExternalLinks,
hocuspocusProvider,
doc,
}:OpBlockNoteEditorProps) {
const { localeString, localeDictionary } = useBlockNoteLocale(window.I18n.locale);
const { enabled: attachmentsEnabled, uploadFile } = useBlockNoteAttachments(attachmentsCollectionKey, attachmentsUploadUrl);
useEffect(() => {
initializeOpBlockNoteExtensions({ baseUrl: openProjectUrl, locale: localeString });
}, [openProjectUrl, localeString]);
const editorParams = useMemo<Partial<BlockNoteEditorOptions<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>>>(() => {
const baseCollaboration = {
fragment: doc.getXmlFragment('document-store'),
user: {
name: activeUser.username,
color: hocuspocusProvider ? generateRandomColor() : '#333333',
...(hocuspocusProvider && { id: activeUser.id }),
} as unknown as CollaborativeUser,
};
return {
schema,
collaboration: {
...baseCollaboration,
provider: hocuspocusProvider ?? null,
...(hocuspocusProvider && { showCursorLabels: 'activity' as const }),
},
dictionary: localeDictionary,
...(attachmentsEnabled && { uploadFile }),
// When external link capture is enabled, intercept clicks on external
// links via a ProseMirror plugin and route through /external_redirect.
...(captureExternalLinks && {
extensions: [ExternalLinkCaptureExtension],
}),
};
}, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile, captureExternalLinks]);
// Create the editor exactly once per mount. `useCreateBlockNote(options, deps)` uses `deps`
// as the sole `useMemo` key — `options` is intentionally NOT in deps. `[activeUser]` rebuilt
// the editor (wiping `Y.UndoManager` history) whenever a fresh `activeUser` reference
// reached this component, e.g. on Stimulus reconnect / Turbo morph.
const editor = useCreateBlockNote(editorParams, []);
useInlineWpEvents(editor);
type EditorType = typeof editor;
const theme = useOpTheme();
const getCustomSlashMenuItems = useCallback((editorInstance:EditorType) => [
...getDefaultReactSlashMenuItems(editorInstance),
workPackageSlashMenu(editorInstance),
], []);
const { getHashItems, HashWpMenu } = useHashWpMenu(editor);
return (
<>
<BlockNoteView
editor={editor}
slashMenu={false}
theme={theme}
editable={!readOnly}
className={'block-note-editor-container'}
>
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query:string) => Promise.resolve(filterSuggestionItems(getCustomSlashMenuItems(editor), query))}
/>
<SuggestionMenuController
triggerCharacter="#"
getItems={getHashItems}
suggestionMenuComponent={HashWpMenu}
/>
</BlockNoteView>
</>
);
}