mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
d351296e6c
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>
162 lines
5.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|