From b497f2d8de91617f9e59f83fd431488662c0cda0 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Sun, 26 Nov 2023 14:02:49 +0100 Subject: [PATCH] wip: range authorships as ytext attributes Co-authored-by: Philip Molares Signed-off-by: Philip Molares Signed-off-by: Erik Michelson --- frontend/locales/en.json | 10 + .../authorship-highlight.module.scss | 14 ++ .../authorships-state-field.ts | 174 ++++++++++++++++++ .../authorships-update-effect.ts | 28 +++ .../document-sync/y-text-sync-view-plugin.ts | 93 +++++++++- .../create-realtime-color-css-class.ts | 10 + .../realtime-colors.module.scss} | 34 ++-- .../remote-cursors/create-cursor-css-class.ts | 10 - .../cursor-layers-extensions.ts | 4 +- .../remote-cursors/remote-cursor-marker.ts | 4 +- .../editor-page/editor-pane/editor-pane.tsx | 9 +- .../yjs/use-code-mirror-yjs-extension.ts | 10 +- .../user-line/user-line.tsx | 4 +- ...orship-highlights-setting-button-group.tsx | 47 +++++ .../editor/editor-settings-tab-content.tsx | 4 + .../utils/settings-toggle-button.tsx | 10 +- .../src/redux/editor-config/initial-state.ts | 4 +- frontend/src/redux/editor-config/methods.ts | 6 + frontend/src/redux/editor-config/slice.ts | 3 + frontend/src/redux/editor-config/types.ts | 7 + 20 files changed, 432 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorship-highlight.module.scss create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-state-field.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-update-effect.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/create-realtime-color-css-class.ts rename frontend/src/components/editor-page/editor-pane/codemirror-extensions/{remote-cursors/cursor-colors.module.scss => realtime-colors/realtime-colors.module.scss} (76%) delete mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts create mode 100644 frontend/src/components/global-dialogs/settings-dialog/editor/authorship-highlights-setting-button-group.tsx diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 44bc1e5e3..84e75f6b8 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -665,6 +665,16 @@ "indentSpaces": { "label": "Indentation size", "help": "Sets the amount of spaces for indentation." + }, + "authorshipHighlightMode": { + "label": "Authorship Highlight Mode", + "help": "Defines how authorship attribution should be displayed in the editor", + "none": "None", + "noneHelp": "Do not show authorship attribution", + "underline": "Underline", + "underlineHelp": "Underline text written by other authors", + "background": "Background", + "backgroundHelp": "Highlight text written by other authors with a background color" } }, "global": { diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorship-highlight.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorship-highlight.module.scss new file mode 100644 index 000000000..c42d2205a --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorship-highlight.module.scss @@ -0,0 +1,14 @@ +/*! + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +.authorship-highlight-mode-underline .authorship-highlight { + text-decoration: underline; + text-decoration-color: var(--color); +} + +.authorship-highlight-mode-background .authorship-highlight { + background-color: var(--color); +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-state-field.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-state-field.ts new file mode 100644 index 000000000..c3c8fb348 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-state-field.ts @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { Range, Transaction, StateEffect } from '@codemirror/state' +import { StateField } from '@codemirror/state' +import type { DecorationSet } from '@codemirror/view' +import { Decoration, EditorView } from '@codemirror/view' +import { Logger } from '../../../../../utils/logger' +import styles from './authorship-highlight.module.scss' +import { createRealtimeColorCssClass } from '../realtime-colors/create-realtime-color-css-class' +import type { AuthorshipUpdate } from './authorships-update-effect' +import { authorshipsUpdateEffect } from './authorships-update-effect' +import { v4 as uuid } from 'uuid' + +type MarkDecoration = { + attributes?: Record +} + +const logger = new Logger('AuthorshipsStateField') +const colorSet = new Set() + +const createMark = (from: number, to: number, userId: string): Range => { + logger.debug('createMark from', from, 'to', to, 'userId', userId) + colorSet.add(userId) + // ToDo: Build something more sensible + const styleIndex = [...colorSet].indexOf(userId) % 7 + const color = createRealtimeColorCssClass(styleIndex) + return Decoration.mark({ + class: `${styles['authorship-highlight']} ${color}`, + attributes: { + 'data-user-id': userId + } + }).range(from, to) +} + +/** + * Saves the currently visible {@link RemoteCursor remote cursors} + * and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched. + */ +export const authorshipsStateField = StateField.define({ + create(): DecorationSet { + return Decoration.none + }, + update(authorshipDecorations: DecorationSet, transaction: Transaction) { + logger.debug('decorationSet', authorshipDecorations, 'transaction', transaction) + authorshipDecorations = authorshipDecorations.map(transaction.changes) + + const localEffects: StateEffect[] = [] + if ( + transaction.isUserEvent('input') || + transaction.isUserEvent('delete') || + transaction.isUserEvent('move') || + transaction.isUserEvent('undo') || + transaction.isUserEvent('redo') + ) { + transaction.changes.iterChanges((fromA, toA, _, __, insert) => { + logger.debug('fromA', fromA, 'toA', toA, 'insert', insert) + localEffects.push( + authorshipsUpdateEffect.of({ + from: fromA, + to: toA + insert.length, + userId: window.localStorage.getItem('realtime-id') || uuid(), // TODO use proper value + isDeletion: transaction.isUserEvent('delete') + }) + ) + }) + } + + const remoteEffects = transaction.effects.filter((effect) => effect.is(authorshipsUpdateEffect)) + const effects = [...localEffects, ...remoteEffects] + + if (effects.length === 0) { + return authorshipDecorations + } + + effects.forEach((effect: StateEffect) => { + const addedDecorations: Range[] = [] + const effectUserId = effect.value.userId + const effectFrom = effect.value.from + const effectTo = effect.value.to + const effectLength = effectTo - effectFrom + const effectIsDeletion = effect.value.isDeletion + let effectHandled = false + logger.debug( + 'eff_from', + effectFrom, + 'eff_to', + effectTo, + 'eff_user', + effectUserId, + 'eff_len', + effectLength, + 'delete', + effectIsDeletion + ) + logger.debug('#decorations', authorshipDecorations.size) + authorshipDecorations = authorshipDecorations.update({ + filter: (decorationFrom: number, decorationTo: number, value) => { + if (effectHandled) { + return true + } + const decorationUserId = (value.spec as MarkDecoration).attributes?.['data-user-id'] ?? '' + const sameUserId = + decorationUserId === effectUserId && decorationUserId !== undefined && effectUserId !== null + logger.debug('dec_from', decorationFrom, 'dec_to', decorationTo, 'dec_user', decorationUserId) + + if (effectIsDeletion) { + // We don't need to do something if we delete(?) + effectHandled = true + return true + } + + if (effectUserId === null) { + logger.error('This should never happen. (Famous last words) [effectUserId === null]') + } + + const nonNullEffectUserId = effectUserId as string + + if (sameUserId) { + // The decoration is by the same user as the effect/change + + if (decorationFrom === effectTo || decorationTo === effectFrom) { + const extremum = effectIsDeletion ? Math.min : Math.max + logger.debug('before or after own text') + // We can extend the existing decoration by adding a new one with the adjusted length + addedDecorations.push( + createMark(Math.min(decorationFrom, effectFrom), extremum(decorationTo, effectTo), decorationUserId) + ) + effectHandled = true + return false + } + + logger.debug('In own text (extending)') + // the authorshipsUpdateEffect already updates the length of the decoration, so we only need to recreate it + // otherwise we would have another second decoration wrapped around the first one + addedDecorations.push(createMark(decorationFrom, decorationTo, decorationUserId)) + effectHandled = true + return true + } else { + // The decoration is by a different user than the effect/change + // Split the decoration by inserting a third decoration in the middle + logger.debug('in/before/after others text (splitting)') + if (decorationFrom < effectFrom) { + addedDecorations.push(createMark(decorationFrom, effectFrom, decorationUserId)) + } + if (effectFrom !== effectTo) { + addedDecorations.push(createMark(effectFrom, effectTo, nonNullEffectUserId)) + } + if (effectTo < decorationTo) { + addedDecorations.push(createMark(effectTo, decorationTo, decorationUserId)) + } + effectHandled = true + return false + } + }, + filterFrom: effectFrom, + filterTo: effectTo + }) + + if (addedDecorations.length === 0 && effectFrom !== effectTo && !effectIsDeletion) { + // on an empty decoration set add the effect + addedDecorations.push(createMark(effectFrom, effectTo, effectUserId as string)) + } + + authorshipDecorations = authorshipDecorations.update({ + add: addedDecorations + }) + }) + return authorshipDecorations + }, + provide: (decorationSet) => EditorView.decorations.from(decorationSet) +}) diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-update-effect.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-update-effect.ts new file mode 100644 index 000000000..25da62a2e --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/authorship-ranges/authorships-update-effect.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StateEffect } from '@codemirror/state' +import { Logger } from '../../../../../utils/logger' + +export interface AuthorshipUpdate { + from: number + to: number + userId: string | null + isDeletion: boolean +} + +const logger = new Logger('AuthorshipsUpdateEffect') + +/** + * Used to provide a new set of {@link Authorship authorships} to a codemirror state. + */ +export const authorshipsUpdateEffect = StateEffect.define({ + map: (value, change) => { + logger.debug('value', value) + logger.debug('change', change) + return { ...value, from: change.mapPos(value.from), to: change.mapPos(value.to) } + } +}) diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts index 48f3764cc..9fce3f329 100644 --- a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts @@ -1,36 +1,58 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ChangeSpec, Transaction } from '@codemirror/state' -import { Annotation } from '@codemirror/state' +import type { ChangeSpec, Transaction, Extension } from '@codemirror/state' +import { Annotation, StateEffect } from '@codemirror/state' import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view' import type { Text as YText, Transaction as YTransaction, YTextEvent } from 'yjs' +import { v4 as uuid } from 'uuid' +import { Logger } from '../../../../../utils/logger' +import { authorshipsUpdateEffect, type AuthorshipUpdate } from '../authorship-ranges/authorships-update-effect' +import { authorshipsStateField } from '../authorship-ranges/authorships-state-field' const syncAnnotation = Annotation.define() +const logger = new Logger('YTextSyncViewPlugin') + /** * Synchronizes the content of a codemirror with a {@link YText y.js text channel}. */ export class YTextSyncViewPlugin implements PluginValue { private readonly observer: YTextSyncViewPlugin['onYTextUpdate'] + // private stalledEffects: StateEffect[] = [] + constructor( private view: EditorView, private readonly yText: YText, + private readonly ownUserId: string = window.localStorage.getItem('realtime-id') || uuid(), // Todo remove default value pluginLoaded: () => void ) { this.observer = this.onYTextUpdate.bind(this) this.yText.observe(this.observer) pluginLoaded() + logger.debug('ownUserId', ownUserId) } private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void { + logger.debug('onYTextUpdate called') + logger.debug(event.delta) + if (transaction.origin === this) { return } - this.view.dispatch({ changes: this.calculateChanges(event), annotations: [syncAnnotation.of(this)] }) + const changes = this.calculateChanges(event) + const annotations = [syncAnnotation.of(this)] + //const effects = [...this.stalledEffects, ...this.calculateEffects(event)] + const effects = this.calculateEffects(event) + + logger.debug('changes', changes) + logger.debug('effects', effects) + + this.view.dispatch({ changes, annotations, effects }) + // this.stalledEffects = [] } private calculateChanges(event: YTextEvent): ChangeSpec[] { @@ -40,7 +62,7 @@ export class YTextSyncViewPlugin implements PluginValue { changes.push({ from: position, to: position, insert: delta.insert }) return [changes, position] } else if (delta.delete !== undefined) { - changes.push({ from: position, to: position + delta.delete, insert: '' }) + changes.push({ from: position, to: position + delta.delete }) return [changes, position + delta.delete] } else if (delta.retain !== undefined) { return [changes, position + delta.retain] @@ -53,13 +75,64 @@ export class YTextSyncViewPlugin implements PluginValue { return changes } + private calculateEffects(event: YTextEvent): StateEffect[] { + const [effects] = event.delta.reduce<[StateEffect[], number]>( + ([effects, position], delta) => { + if (delta.insert !== undefined && typeof delta.insert === 'string') { + effects.push( + authorshipsUpdateEffect.of({ + from: position, + to: position + delta.insert.length, + userId: delta.attributes?.authorId as string, + isDeletion: false + }) + ) + return [effects, position + delta.insert.length] + } else if (delta.delete !== undefined) { + effects.push( + authorshipsUpdateEffect.of({ + from: position, + to: position + delta.delete, + userId: null, + isDeletion: true + }) + ) + return [effects, position + delta.delete] + } else if (delta.retain !== undefined) { + return [effects, position + delta.retain] + } else { + return [effects, position] + } + }, + [[], 0] + ) + if (!this.view.state.field(authorshipsStateField, false)) { + return [...effects, StateEffect.appendConfig.of([authorshipsStateField])] + } + return effects + } + public update(update: ViewUpdate): void { if (!update.docChanged) { return } - update.transactions - .filter((transaction) => transaction.annotation(syncAnnotation) !== this) - .forEach((transaction) => this.applyTransaction(transaction)) + const ownTransactions = update.transactions.filter((transaction) => transaction.annotation(syncAnnotation) !== this) + + ownTransactions.forEach((transaction) => this.applyTransaction(transaction)) + /*ownTransactions.forEach((transaction) => { + // todo: Add own authorship decorations + transaction.changes.iterChanges((fromA, toA, _, __, insert) => { + logger.debug('fromA', fromA, 'toA', toA, 'insert', insert) + this.stalledEffects.push( + authorshipsUpdateEffect.of({ + from: fromA, + to: toA + insert.length, + userId: this.ownUserId, + localUpdate: true + }) + ) + }) + })*/ } private applyTransaction(transaction: Transaction): void { @@ -71,7 +144,9 @@ export class YTextSyncViewPlugin implements PluginValue { this.yText.delete(fromA + positionAdjustment, toA - fromA) } if (insertText.length > 0) { - this.yText.insert(fromA + positionAdjustment, insertText) + this.yText.insert(fromA + positionAdjustment, insertText, { + authorId: this.ownUserId + }) } positionAdjustment += insertText.length - (toA - fromA) }) diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/create-realtime-color-css-class.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/create-realtime-color-css-class.ts new file mode 100644 index 000000000..d67635d13 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/create-realtime-color-css-class.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import styles from './realtime-colors.module.scss' + +export const createRealtimeColorCssClass = (styleIndex: number): string => { + return styles[`color-${Math.max(Math.min(styleIndex, 7), 0)}`] +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/realtime-colors.module.scss similarity index 76% rename from frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss rename to frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/realtime-colors.module.scss index 517e9c1b5..c037ab490 100644 --- a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/realtime-colors/realtime-colors.module.scss @@ -4,34 +4,34 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -.cursor-0 { - --color: #780c0c; -} - -.cursor-1 { - --color: #ff1111; -} - -.cursor-2 { - --color: #1149ff; -} - -.cursor-3 { +.color-0 { --color: #11ff39; } -.cursor-4 { +.color-1 { + --color: #1149ff; +} + +.color-2 { + --color: #ff1111; +} + +.color-3 { + --color: #780c0c; +} + +.color-4 { --color: #cb11ff; } -.cursor-5 { +.color-5 { --color: #ffff00; } -.cursor-6 { +.color-6 { --color: #00fff2; } -.cursor-7 { +.color-7 { --color: #ff8000; } diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts deleted file mode 100644 index 3b8e69c76..000000000 --- a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import styles from './cursor-colors.module.scss' - -export const createCursorCssClass = (styleIndex: number): string => { - return styles[`cursor-${Math.max(Math.min(styleIndex, 7), 0)}`] -} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts index ad1b3de1c..b6016af32 100644 --- a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { createCursorCssClass } from './create-cursor-css-class' +import { createRealtimeColorCssClass } from '../realtime-colors/create-realtime-color-css-class' import { RemoteCursorMarker } from './remote-cursor-marker' import styles from './style.module.scss' import type { Extension, Transaction } from '@codemirror/state' @@ -89,7 +89,7 @@ export const createSelectionLayer = (): Extension => const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number) return RectangleMarker.forRange( view, - `${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`, + `${styles.cursor} ${createRealtimeColorCssClass(remoteCursor.styleIndex)}`, selectionRange ) }) diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts index f2eec22de..5e30f9c4d 100644 --- a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { concatCssClasses } from '../../../../../utils/concat-css-classes' -import { createCursorCssClass } from './create-cursor-css-class' +import { createRealtimeColorCssClass } from '../realtime-colors/create-realtime-color-css-class' import styles from './style.module.scss' import type { SelectionRange } from '@codemirror/state' import type { EditorView, LayerMarker, Rect } from '@codemirror/view' @@ -41,7 +41,7 @@ export class RemoteCursorMarker implements LayerMarker { element.style.setProperty('--name', `"${this.name}"`) const cursorOnRightSide = this.left > this.viewWidth / 2 const cursorOnDownSide = this.top < 20 - element.className = concatCssClasses(styles.cursor, createCursorCssClass(this.styleIndex), { + element.className = concatCssClasses(styles.cursor, createRealtimeColorCssClass(this.styleIndex), { [styles.right]: cursorOnRightSide, [styles.down]: cursorOnDownSide }) diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx index a743d3ac9..de8ca9433 100644 --- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx @@ -12,6 +12,7 @@ import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/_base-classes/code-block-markdown-extension/find-language-by-code-block-name' import type { ScrollProps } from '../synced-scroll/scroll-props' import styles from './extended-codemirror/codemirror.module.scss' +import authorshipHighlightModeStyles from './codemirror-extensions/authorship-ranges/authorship-highlight.module.scss' import { useCodeMirrorAutocompletionsExtension } from './hooks/codemirror-extensions/use-code-mirror-autocompletions-extension' import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension' import { useCodeMirrorLineWrappingExtension } from './hooks/codemirror-extensions/use-code-mirror-line-wrapping-extension' @@ -131,9 +132,13 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o useOnImageUploadFromRenderer() const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) + const authorshipHighlightMode = useApplicationState((state) => state.editorConfig.authorshipHighlightMode) const codeMirrorClassName = useMemo( - () => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`, - [ligaturesEnabled] + () => + `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']} ${ + authorshipHighlightModeStyles[`authorship-highlight-mode-${authorshipHighlightMode}`] + }`, + [ligaturesEnabled, authorshipHighlightMode] ) const darkModeActivated = useDarkModeState() diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts index 31f14df15..48a0e6fa5 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,6 +9,7 @@ import type { Extension } from '@codemirror/state' import { ViewPlugin } from '@codemirror/view' import type { RealtimeDoc, YDocSyncClientAdapter } from '@hedgedoc/commons' import { useEffect, useMemo, useState } from 'react' +import { authorshipsStateField } from '../../codemirror-extensions/authorship-ranges/authorships-state-field' /** * Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext}. @@ -21,6 +22,7 @@ export const useCodeMirrorYjsExtension = (doc: RealtimeDoc, syncAdapter: YDocSyn const [editorReady, setEditorReady] = useState(false) const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced) const connected = useApplicationState((state) => state.realtimeStatus.isConnected) + const ownUser = useApplicationState((state) => state.realtimeStatus.ownUser) useEffect(() => { if (editorReady && connected && !synchronized) { @@ -30,10 +32,12 @@ export const useCodeMirrorYjsExtension = (doc: RealtimeDoc, syncAdapter: YDocSyn return useMemo( () => [ + authorshipsStateField.extension, ViewPlugin.define( - (view) => new YTextSyncViewPlugin(view, doc.getMarkdownContentChannel(), () => setEditorReady(true)) + // ToDo: get ownUserId + (view) => new YTextSyncViewPlugin(view, doc.getMarkdownContentChannel(), undefined, () => setEditorReady(true)) ) ], - [doc] + [doc, ownUser] ) } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/user-line/user-line.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/user-line/user-line.tsx index e06a67576..4a535796f 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/user-line/user-line.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/user-line/user-line.tsx @@ -5,7 +5,7 @@ */ import { UserAvatar } from '../../../../../common/user-avatar/user-avatar' import { UserAvatarForUsername } from '../../../../../common/user-avatar/user-avatar-for-username' -import { createCursorCssClass } from '../../../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class' +import { createRealtimeColorCssClass } from '../../../../editor-pane/codemirror-extensions/realtime-colors/create-realtime-color-css-class' import { ActiveIndicator } from '../active-indicator' import styles from './user-line.module.scss' import React, { useMemo } from 'react' @@ -44,7 +44,7 @@ export const UserLine: React.FC = ({ username, displayName, activ return (
-
+
{avatar}
{!username && } diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/authorship-highlights-setting-button-group.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/authorship-highlights-setting-button-group.tsx new file mode 100644 index 000000000..b0ca40dee --- /dev/null +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/authorship-highlights-setting-button-group.tsx @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import React, { useCallback } from 'react' +import { SettingsToggleButton } from '../utils/settings-toggle-button' +import { ToggleButtonGroup } from 'react-bootstrap' +import { AuthorshipHighlightMode } from '../../../../redux/editor-config/types' +import { setAuthorshipHighlightMode } from '../../../../redux/editor-config/methods' + +/** + * Allows to change whether spellchecking is enabled or not in the editor. + */ +export const AuthorshipHighlightModeSettingButtonGroup: React.FC = () => { + const state = useApplicationState((state) => state.editorConfig.authorshipHighlightMode) + const onButtonSelect = useCallback((newValue: AuthorshipHighlightMode) => { + setAuthorshipHighlightMode(newValue) + }, []) + + return ( + + + + + + ) +} diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx index 49e42231e..e19f6606d 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx @@ -15,6 +15,7 @@ import { SpellcheckSettingButtonGroup } from './spellcheck-setting-button-group' import { IndentWithTabsSettingButtonGroup } from './indent-with-tabs-setting-button-group' import { useApplicationState } from '../../../../hooks/common/use-application-state' import { IndentSpacesSettingInput } from './indent-spaces-setting-input' +import { AuthorshipHighlightModeSettingButtonGroup } from './authorship-highlights-setting-button-group' /** * Shows the editor specific settings. @@ -34,6 +35,9 @@ export const EditorSettingsTabContent: React.FC = () => { + + + diff --git a/frontend/src/components/global-dialogs/settings-dialog/utils/settings-toggle-button.tsx b/frontend/src/components/global-dialogs/settings-dialog/utils/settings-toggle-button.tsx index ce35c9599..7792ae06f 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/utils/settings-toggle-button.tsx +++ b/frontend/src/components/global-dialogs/settings-dialog/utils/settings-toggle-button.tsx @@ -10,11 +10,11 @@ import type { ButtonProps } from 'react-bootstrap' import { Button } from 'react-bootstrap' import { Trans } from 'react-i18next' -type DarkModeToggleButtonProps = Omit & +type SettingsToggleButtonProps = Omit & PropsWithDataTestId & { - onSelect: (value: number) => void + onSelect: (value: T) => void selected: boolean - value: number + value: T i18nKeyLabel: string i18nKeyTooltip: string } @@ -29,14 +29,14 @@ type DarkModeToggleButtonProps = Omit & * @param props Other button props * @constructor */ -export const SettingsToggleButton = ({ +export const SettingsToggleButton = ({ i18nKeyLabel, i18nKeyTooltip, selected, onSelect, value, ...props -}: DarkModeToggleButtonProps) => { +}: SettingsToggleButtonProps) => { const title = useTranslatedText(i18nKeyTooltip) const onChange = useCallback(() => { diff --git a/frontend/src/redux/editor-config/initial-state.ts b/frontend/src/redux/editor-config/initial-state.ts index f8531ef66..4966a065b 100644 --- a/frontend/src/redux/editor-config/initial-state.ts +++ b/frontend/src/redux/editor-config/initial-state.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import type { EditorConfig } from './types' +import { AuthorshipHighlightMode } from './types' export const initialState: EditorConfig = { ligatures: true, @@ -12,5 +13,6 @@ export const initialState: EditorConfig = { spellCheck: true, lineWrapping: true, indentWithTabs: false, - indentSpaces: 2 + indentSpaces: 2, + authorshipHighlightMode: AuthorshipHighlightMode.UNDERLINE } diff --git a/frontend/src/redux/editor-config/methods.ts b/frontend/src/redux/editor-config/methods.ts index 2fc89b51e..d71356649 100644 --- a/frontend/src/redux/editor-config/methods.ts +++ b/frontend/src/redux/editor-config/methods.ts @@ -54,6 +54,12 @@ export const setEditorIndentSpaces = (indentSpaces: number): void => { saveToLocalStorage() } +export const setAuthorshipHighlightMode = (mode: EditorConfig['authorshipHighlightMode']): void => { + const action = editorConfigActionsCreator.setAuthorshipHighlightMode(mode) + store.dispatch(action) + saveToLocalStorage() +} + export const loadFromLocalStorage = (): void => { try { const config = { ...initialState } diff --git a/frontend/src/redux/editor-config/slice.ts b/frontend/src/redux/editor-config/slice.ts index 7cbf67c19..9261a6c86 100644 --- a/frontend/src/redux/editor-config/slice.ts +++ b/frontend/src/redux/editor-config/slice.ts @@ -35,6 +35,9 @@ const editorConfigSlice = createSlice({ }, setEditorConfig: (state, action: PayloadAction) => { return action.payload + }, + setAuthorshipHighlightMode: (state, action: PayloadAction) => { + state.authorshipHighlightMode = action.payload } } }) diff --git a/frontend/src/redux/editor-config/types.ts b/frontend/src/redux/editor-config/types.ts index 80ef77b2c..1b46d1991 100644 --- a/frontend/src/redux/editor-config/types.ts +++ b/frontend/src/redux/editor-config/types.ts @@ -11,4 +11,11 @@ export interface EditorConfig { lineWrapping: boolean indentWithTabs: boolean indentSpaces: number + authorshipHighlightMode: AuthorshipHighlightMode +} + +export enum AuthorshipHighlightMode { + NONE = 'none', + UNDERLINE = 'underline', + BACKGROUND = 'background' }