wip: range authorships as ytext attributes

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2023-11-26 14:02:49 +01:00
parent 68780f54e1
commit b497f2d8de
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
20 changed files with 432 additions and 53 deletions

View file

@ -665,6 +665,16 @@
"indentSpaces": { "indentSpaces": {
"label": "Indentation size", "label": "Indentation size",
"help": "Sets the amount of spaces for indentation." "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": { "global": {

View file

@ -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);
}

View file

@ -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<string, string>
}
const logger = new Logger('AuthorshipsStateField')
const colorSet = new Set<string>()
const createMark = (from: number, to: number, userId: string): Range<Decoration> => {
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<DecorationSet>({
create(): DecorationSet {
return Decoration.none
},
update(authorshipDecorations: DecorationSet, transaction: Transaction) {
logger.debug('decorationSet', authorshipDecorations, 'transaction', transaction)
authorshipDecorations = authorshipDecorations.map(transaction.changes)
const localEffects: StateEffect<AuthorshipUpdate>[] = []
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<AuthorshipUpdate>(authorshipsUpdateEffect))
const effects = [...localEffects, ...remoteEffects]
if (effects.length === 0) {
return authorshipDecorations
}
effects.forEach((effect: StateEffect<AuthorshipUpdate>) => {
const addedDecorations: Range<Decoration>[] = []
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)
})

View file

@ -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<AuthorshipUpdate>({
map: (value, change) => {
logger.debug('value', value)
logger.debug('change', change)
return { ...value, from: change.mapPos(value.from), to: change.mapPos(value.to) }
}
})

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ChangeSpec, Transaction } from '@codemirror/state' import type { ChangeSpec, Transaction, Extension } from '@codemirror/state'
import { Annotation } from '@codemirror/state' import { Annotation, StateEffect } from '@codemirror/state'
import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view' import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
import type { Text as YText, Transaction as YTransaction, YTextEvent } from 'yjs' 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 syncAnnotation = Annotation.define()
const logger = new Logger('YTextSyncViewPlugin')
/** /**
* Synchronizes the content of a codemirror with a {@link YText y.js text channel}. * Synchronizes the content of a codemirror with a {@link YText y.js text channel}.
*/ */
export class YTextSyncViewPlugin implements PluginValue { export class YTextSyncViewPlugin implements PluginValue {
private readonly observer: YTextSyncViewPlugin['onYTextUpdate'] private readonly observer: YTextSyncViewPlugin['onYTextUpdate']
// private stalledEffects: StateEffect<AuthorshipUpdate>[] = []
constructor( constructor(
private view: EditorView, private view: EditorView,
private readonly yText: YText, private readonly yText: YText,
private readonly ownUserId: string = window.localStorage.getItem('realtime-id') || uuid(), // Todo remove default value
pluginLoaded: () => void pluginLoaded: () => void
) { ) {
this.observer = this.onYTextUpdate.bind(this) this.observer = this.onYTextUpdate.bind(this)
this.yText.observe(this.observer) this.yText.observe(this.observer)
pluginLoaded() pluginLoaded()
logger.debug('ownUserId', ownUserId)
} }
private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void { private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void {
logger.debug('onYTextUpdate called')
logger.debug(event.delta)
if (transaction.origin === this) { if (transaction.origin === this) {
return 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[] { private calculateChanges(event: YTextEvent): ChangeSpec[] {
@ -40,7 +62,7 @@ export class YTextSyncViewPlugin implements PluginValue {
changes.push({ from: position, to: position, insert: delta.insert }) changes.push({ from: position, to: position, insert: delta.insert })
return [changes, position] return [changes, position]
} else if (delta.delete !== undefined) { } 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] return [changes, position + delta.delete]
} else if (delta.retain !== undefined) { } else if (delta.retain !== undefined) {
return [changes, position + delta.retain] return [changes, position + delta.retain]
@ -53,13 +75,64 @@ export class YTextSyncViewPlugin implements PluginValue {
return changes return changes
} }
private calculateEffects(event: YTextEvent): StateEffect<AuthorshipUpdate | Extension>[] {
const [effects] = event.delta.reduce<[StateEffect<AuthorshipUpdate>[], 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 { public update(update: ViewUpdate): void {
if (!update.docChanged) { if (!update.docChanged) {
return return
} }
update.transactions const ownTransactions = update.transactions.filter((transaction) => transaction.annotation(syncAnnotation) !== this)
.filter((transaction) => transaction.annotation(syncAnnotation) !== this)
.forEach((transaction) => this.applyTransaction(transaction)) 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 { private applyTransaction(transaction: Transaction): void {
@ -71,7 +144,9 @@ export class YTextSyncViewPlugin implements PluginValue {
this.yText.delete(fromA + positionAdjustment, toA - fromA) this.yText.delete(fromA + positionAdjustment, toA - fromA)
} }
if (insertText.length > 0) { if (insertText.length > 0) {
this.yText.insert(fromA + positionAdjustment, insertText) this.yText.insert(fromA + positionAdjustment, insertText, {
authorId: this.ownUserId
})
} }
positionAdjustment += insertText.length - (toA - fromA) positionAdjustment += insertText.length - (toA - fromA)
}) })

View file

@ -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)}`]
}

View file

@ -4,34 +4,34 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
.cursor-0 { .color-0 {
--color: #780c0c;
}
.cursor-1 {
--color: #ff1111;
}
.cursor-2 {
--color: #1149ff;
}
.cursor-3 {
--color: #11ff39; --color: #11ff39;
} }
.cursor-4 { .color-1 {
--color: #1149ff;
}
.color-2 {
--color: #ff1111;
}
.color-3 {
--color: #780c0c;
}
.color-4 {
--color: #cb11ff; --color: #cb11ff;
} }
.cursor-5 { .color-5 {
--color: #ffff00; --color: #ffff00;
} }
.cursor-6 { .color-6 {
--color: #00fff2; --color: #00fff2;
} }
.cursor-7 { .color-7 {
--color: #ff8000; --color: #ff8000;
} }

View file

@ -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)}`]
}

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 { RemoteCursorMarker } from './remote-cursor-marker'
import styles from './style.module.scss' import styles from './style.module.scss'
import type { Extension, Transaction } from '@codemirror/state' 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) const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
return RectangleMarker.forRange( return RectangleMarker.forRange(
view, view,
`${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`, `${styles.cursor} ${createRealtimeColorCssClass(remoteCursor.styleIndex)}`,
selectionRange selectionRange
) )
}) })

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { concatCssClasses } from '../../../../../utils/concat-css-classes' 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 styles from './style.module.scss'
import type { SelectionRange } from '@codemirror/state' import type { SelectionRange } from '@codemirror/state'
import type { EditorView, LayerMarker, Rect } from '@codemirror/view' import type { EditorView, LayerMarker, Rect } from '@codemirror/view'
@ -41,7 +41,7 @@ export class RemoteCursorMarker implements LayerMarker {
element.style.setProperty('--name', `"${this.name}"`) element.style.setProperty('--name', `"${this.name}"`)
const cursorOnRightSide = this.left > this.viewWidth / 2 const cursorOnRightSide = this.left > this.viewWidth / 2
const cursorOnDownSide = this.top < 20 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.right]: cursorOnRightSide,
[styles.down]: cursorOnDownSide [styles.down]: cursorOnDownSide
}) })

View file

@ -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 { 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 type { ScrollProps } from '../synced-scroll/scroll-props'
import styles from './extended-codemirror/codemirror.module.scss' 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 { useCodeMirrorAutocompletionsExtension } from './hooks/codemirror-extensions/use-code-mirror-autocompletions-extension'
import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-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' import { useCodeMirrorLineWrappingExtension } from './hooks/codemirror-extensions/use-code-mirror-line-wrapping-extension'
@ -131,9 +132,13 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
useOnImageUploadFromRenderer() useOnImageUploadFromRenderer()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const authorshipHighlightMode = useApplicationState((state) => state.editorConfig.authorshipHighlightMode)
const codeMirrorClassName = useMemo( 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() const darkModeActivated = useDarkModeState()

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,6 +9,7 @@ import type { Extension } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view' import { ViewPlugin } from '@codemirror/view'
import type { RealtimeDoc, YDocSyncClientAdapter } from '@hedgedoc/commons' import type { RealtimeDoc, YDocSyncClientAdapter } from '@hedgedoc/commons'
import { useEffect, useMemo, useState } from 'react' 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}. * 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 [editorReady, setEditorReady] = useState(false)
const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced) const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced)
const connected = useApplicationState((state) => state.realtimeStatus.isConnected) const connected = useApplicationState((state) => state.realtimeStatus.isConnected)
const ownUser = useApplicationState((state) => state.realtimeStatus.ownUser)
useEffect(() => { useEffect(() => {
if (editorReady && connected && !synchronized) { if (editorReady && connected && !synchronized) {
@ -30,10 +32,12 @@ export const useCodeMirrorYjsExtension = (doc: RealtimeDoc, syncAdapter: YDocSyn
return useMemo( return useMemo(
() => [ () => [
authorshipsStateField.extension,
ViewPlugin.define( 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]
) )
} }

View file

@ -5,7 +5,7 @@
*/ */
import { UserAvatar } from '../../../../../common/user-avatar/user-avatar' import { UserAvatar } from '../../../../../common/user-avatar/user-avatar'
import { UserAvatarForUsername } from '../../../../../common/user-avatar/user-avatar-for-username' 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 { ActiveIndicator } from '../active-indicator'
import styles from './user-line.module.scss' import styles from './user-line.module.scss'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
@ -44,7 +44,7 @@ export const UserLine: React.FC<UserLineProps> = ({ username, displayName, activ
return ( return (
<div className={'d-flex h-100 w-100'}> <div className={'d-flex h-100 w-100'}>
<div className={`${styles['user-line-color-indicator']} ${createCursorCssClass(color)}`} /> <div className={`${styles['user-line-color-indicator']} ${createRealtimeColorCssClass(color)}`} />
{avatar} {avatar}
<div className={'ms-auto d-flex align-items-center gap-1 h-100'}> <div className={'ms-auto d-flex align-items-center gap-1 h-100'}>
{!username && <IconIncognito title={guestUserTitle} size={'16px'} className={'text-muted'} />} {!username && <IconIncognito title={guestUserTitle} size={'16px'} className={'text-muted'} />}

View file

@ -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 (
<ToggleButtonGroup type='radio' name={'authorship-highlight-mode'} value={state}>
<SettingsToggleButton
onSelect={onButtonSelect}
selected={state === AuthorshipHighlightMode.NONE}
value={AuthorshipHighlightMode.NONE}
i18nKeyLabel={'settings.editor.authorshipHighlightMode.none'}
i18nKeyTooltip={'settings.editor.authorshipHighlightMode.noneHelp'}
/>
<SettingsToggleButton
onSelect={onButtonSelect}
selected={state === AuthorshipHighlightMode.UNDERLINE}
value={AuthorshipHighlightMode.UNDERLINE}
i18nKeyLabel={'settings.editor.authorshipHighlightMode.underline'}
i18nKeyTooltip={'settings.editor.authorshipHighlightMode.underlineHelp'}
/>
<SettingsToggleButton
onSelect={onButtonSelect}
selected={state === AuthorshipHighlightMode.BACKGROUND}
value={AuthorshipHighlightMode.BACKGROUND}
i18nKeyLabel={'settings.editor.authorshipHighlightMode.background'}
i18nKeyTooltip={'settings.editor.authorshipHighlightMode.backgroundHelp'}
/>
</ToggleButtonGroup>
)
}

View file

@ -15,6 +15,7 @@ import { SpellcheckSettingButtonGroup } from './spellcheck-setting-button-group'
import { IndentWithTabsSettingButtonGroup } from './indent-with-tabs-setting-button-group' import { IndentWithTabsSettingButtonGroup } from './indent-with-tabs-setting-button-group'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { IndentSpacesSettingInput } from './indent-spaces-setting-input' import { IndentSpacesSettingInput } from './indent-spaces-setting-input'
import { AuthorshipHighlightModeSettingButtonGroup } from './authorship-highlights-setting-button-group'
/** /**
* Shows the editor specific settings. * Shows the editor specific settings.
@ -34,6 +35,9 @@ export const EditorSettingsTabContent: React.FC = () => {
<SettingLine i18nKey={'editor.syncScroll'}> <SettingLine i18nKey={'editor.syncScroll'}>
<SyncScrollSettingButtonGroup /> <SyncScrollSettingButtonGroup />
</SettingLine> </SettingLine>
<SettingLine i18nKey={'editor.authorshipHighlightMode'}>
<AuthorshipHighlightModeSettingButtonGroup />
</SettingLine>
<SettingLine i18nKey={'editor.lineWrapping'}> <SettingLine i18nKey={'editor.lineWrapping'}>
<LineWrappingSettingButtonGroup /> <LineWrappingSettingButtonGroup />
</SettingLine> </SettingLine>

View file

@ -10,11 +10,11 @@ import type { ButtonProps } from 'react-bootstrap'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> & type SettingsToggleButtonProps<T> = Omit<ButtonProps, 'onSelect'> &
PropsWithDataTestId & { PropsWithDataTestId & {
onSelect: (value: number) => void onSelect: (value: T) => void
selected: boolean selected: boolean
value: number value: T
i18nKeyLabel: string i18nKeyLabel: string
i18nKeyTooltip: string i18nKeyTooltip: string
} }
@ -29,14 +29,14 @@ type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> &
* @param props Other button props * @param props Other button props
* @constructor * @constructor
*/ */
export const SettingsToggleButton = ({ export const SettingsToggleButton = <T,>({
i18nKeyLabel, i18nKeyLabel,
i18nKeyTooltip, i18nKeyTooltip,
selected, selected,
onSelect, onSelect,
value, value,
...props ...props
}: DarkModeToggleButtonProps) => { }: SettingsToggleButtonProps<T>) => {
const title = useTranslatedText(i18nKeyTooltip) const title = useTranslatedText(i18nKeyTooltip)
const onChange = useCallback(() => { const onChange = useCallback(() => {

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { EditorConfig } from './types' import type { EditorConfig } from './types'
import { AuthorshipHighlightMode } from './types'
export const initialState: EditorConfig = { export const initialState: EditorConfig = {
ligatures: true, ligatures: true,
@ -12,5 +13,6 @@ export const initialState: EditorConfig = {
spellCheck: true, spellCheck: true,
lineWrapping: true, lineWrapping: true,
indentWithTabs: false, indentWithTabs: false,
indentSpaces: 2 indentSpaces: 2,
authorshipHighlightMode: AuthorshipHighlightMode.UNDERLINE
} }

View file

@ -54,6 +54,12 @@ export const setEditorIndentSpaces = (indentSpaces: number): void => {
saveToLocalStorage() saveToLocalStorage()
} }
export const setAuthorshipHighlightMode = (mode: EditorConfig['authorshipHighlightMode']): void => {
const action = editorConfigActionsCreator.setAuthorshipHighlightMode(mode)
store.dispatch(action)
saveToLocalStorage()
}
export const loadFromLocalStorage = (): void => { export const loadFromLocalStorage = (): void => {
try { try {
const config = { ...initialState } const config = { ...initialState }

View file

@ -35,6 +35,9 @@ const editorConfigSlice = createSlice({
}, },
setEditorConfig: (state, action: PayloadAction<EditorConfig>) => { setEditorConfig: (state, action: PayloadAction<EditorConfig>) => {
return action.payload return action.payload
},
setAuthorshipHighlightMode: (state, action: PayloadAction<EditorConfig['authorshipHighlightMode']>) => {
state.authorshipHighlightMode = action.payload
} }
} }
}) })

View file

@ -11,4 +11,11 @@ export interface EditorConfig {
lineWrapping: boolean lineWrapping: boolean
indentWithTabs: boolean indentWithTabs: boolean
indentSpaces: number indentSpaces: number
authorshipHighlightMode: AuthorshipHighlightMode
}
export enum AuthorshipHighlightMode {
NONE = 'none',
UNDERLINE = 'underline',
BACKGROUND = 'background'
} }