mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 09:16: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": {
|
"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": {
|
||||||
|
|
|
@ -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
|
* 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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
* 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;
|
||||||
}
|
}
|
|
@ -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
|
* 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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'} />}
|
||||||
|
|
|
@ -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 { 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>
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue