mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 01:06:30 -05:00
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:
parent
68780f54e1
commit
b497f2d8de
20 changed files with 432 additions and 53 deletions
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
})
|
|
@ -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) }
|
||||
}
|
||||
})
|
|
@ -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<AuthorshipUpdate>[] = []
|
||||
|
||||
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<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 {
|
||||
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)
|
||||
})
|
||||
|
|
|
@ -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)}`]
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)}`]
|
||||
}
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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<EditorPaneProps> = ({ 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()
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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<UserLineProps> = ({ username, displayName, activ
|
|||
|
||||
return (
|
||||
<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}
|
||||
<div className={'ms-auto d-flex align-items-center gap-1 h-100'}>
|
||||
{!username && <IconIncognito title={guestUserTitle} size={'16px'} className={'text-muted'} />}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 = () => {
|
|||
<SettingLine i18nKey={'editor.syncScroll'}>
|
||||
<SyncScrollSettingButtonGroup />
|
||||
</SettingLine>
|
||||
<SettingLine i18nKey={'editor.authorshipHighlightMode'}>
|
||||
<AuthorshipHighlightModeSettingButtonGroup />
|
||||
</SettingLine>
|
||||
<SettingLine i18nKey={'editor.lineWrapping'}>
|
||||
<LineWrappingSettingButtonGroup />
|
||||
</SettingLine>
|
||||
|
|
|
@ -10,11 +10,11 @@ import type { ButtonProps } from 'react-bootstrap'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> &
|
||||
type SettingsToggleButtonProps<T> = Omit<ButtonProps, 'onSelect'> &
|
||||
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<ButtonProps, 'onSelect'> &
|
|||
* @param props Other button props
|
||||
* @constructor
|
||||
*/
|
||||
export const SettingsToggleButton = ({
|
||||
export const SettingsToggleButton = <T,>({
|
||||
i18nKeyLabel,
|
||||
i18nKeyTooltip,
|
||||
selected,
|
||||
onSelect,
|
||||
value,
|
||||
...props
|
||||
}: DarkModeToggleButtonProps) => {
|
||||
}: SettingsToggleButtonProps<T>) => {
|
||||
const title = useTranslatedText(i18nKeyTooltip)
|
||||
|
||||
const onChange = useCallback(() => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -35,6 +35,9 @@ const editorConfigSlice = createSlice({
|
|||
},
|
||||
setEditorConfig: (state, action: PayloadAction<EditorConfig>) => {
|
||||
return action.payload
|
||||
},
|
||||
setAuthorshipHighlightMode: (state, action: PayloadAction<EditorConfig['authorshipHighlightMode']>) => {
|
||||
state.authorshipHighlightMode = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -11,4 +11,11 @@ export interface EditorConfig {
|
|||
lineWrapping: boolean
|
||||
indentWithTabs: boolean
|
||||
indentSpaces: number
|
||||
authorshipHighlightMode: AuthorshipHighlightMode
|
||||
}
|
||||
|
||||
export enum AuthorshipHighlightMode {
|
||||
NONE = 'none',
|
||||
UNDERLINE = 'underline',
|
||||
BACKGROUND = 'background'
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue