diff --git a/frontend/src/components/smart-editor/tabbed-editors/cursors.tsx b/frontend/src/components/smart-editor/tabbed-editors/cursors.tsx deleted file mode 100644 index e1d85cae7..000000000 --- a/frontend/src/components/smart-editor/tabbed-editors/cursors.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { createZustandStore } from '@udecode/plate-common'; -import { - CursorData, - CursorOverlay as CursorOverlayPrimitive, - CursorOverlayProps, - CursorProps, -} from '@udecode/plate-cursor'; -import { styled } from 'styled-components'; - -const cursorStore = createZustandStore('cursor')({ - cursors: {}, -}); - -const Cursor = ({ - caretPosition, - classNames, - data, - disableCaret, - disableSelection, - selectionRects, -}: CursorProps) => { - const { style, selectionStyle = style } = data ?? {}; - - return ( - <> - {disableSelection === true - ? null - : selectionRects.map((position, i) => ( - - ))} - {disableCaret === true || caretPosition === null ? null : ( - - )} - - ); -}; - -const StyledCaret = styled.div` - pointer-events: none; - position: absolute; - z-index: 10; - min-width: 20px; - background-color: red; -`; - -export const CursorOverlay = ({ cursors, ...props }: CursorOverlayProps) => { - const dynamicCursors = cursorStore.use.cursors(); - - const allCursors = { ...cursors, ...dynamicCursors }; - - return ; -}; diff --git a/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts new file mode 100644 index 000000000..3bd46de00 --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts @@ -0,0 +1,43 @@ +interface BaseColor { + red: number; + green: number; + blue: number; +} + +const MAP: Map = new Map(); + +// #C30000 +const RED: BaseColor = { red: 195, green: 0, blue: 0 }; +// #06893A +const GREEN: BaseColor = { red: 6, green: 137, blue: 58 }; +// #66CBEC +const LIGHT_BLUE: BaseColor = { red: 102, green: 203, blue: 236 }; +// #0067C5 +const BLUE: BaseColor = { red: 0, green: 103, blue: 197 }; +// #FF9100 +const ORANGE: BaseColor = { red: 255, green: 145, blue: 0 }; +// #005B82 +const DEEP_BLUE: BaseColor = { red: 0, green: 91, blue: 130 }; +// #634689 +const PURPLE: BaseColor = { red: 99, green: 70, blue: 137 }; + +const COLORS = [RED, GREEN, LIGHT_BLUE, BLUE, ORANGE, DEEP_BLUE, PURPLE]; +const COLOR_MAX = COLORS.length; + +export const getColor = (key: string, opacity: number): string => { + const existing = MAP.get(key); + + if (existing === undefined) { + const randomColorIndex = Math.floor(Math.random() * COLOR_MAX); + const baseColor = COLORS[randomColorIndex]!; + + MAP.set(key, baseColor); + + return formatColor(baseColor, opacity); + } + + return formatColor(existing, opacity); +}; + +const formatColor = (color: BaseColor, opacity: number): string => + `rgba(${color.red}, ${color.green}, ${color.blue}, ${opacity})`; diff --git a/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx new file mode 100644 index 000000000..0c24a1acb --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx @@ -0,0 +1,91 @@ +import { RelativeRange } from '@slate-yjs/core'; +import { UnknownObject } from '@udecode/plate-common'; +import { CursorData, CursorOverlayProps, CursorProps, useCursorOverlayPositions } from '@udecode/plate-cursor'; +import { useContext, useRef } from 'react'; +import { styled } from 'styled-components'; +import { StaticDataContext } from '@app/components/app/static-data-context'; +import { getColor } from '@app/components/smart-editor/tabbed-editors/cursors/cursor-colors'; + +export interface UserCursor extends CursorData, UnknownObject { + navn: string; + navIdent: string; +} + +interface YjsCursor { + selection: RelativeRange; + data: UserCursor; +} + +export const isYjsCursor = (value: unknown): value is YjsCursor => + typeof value === 'object' && value !== null && 'selection' in value && 'data' in value; + +const Cursor = ({ caretPosition, data, disableCaret, disableSelection, selectionRects }: CursorProps) => { + const { style, selectionStyle = style, navIdent, navn } = data ?? {}; + + const labelRef = useRef(null); + + const caretColor = getColor(navIdent ?? '', 1); + const selectionColor = getColor(navIdent ?? '', 0.2); + + return ( + <> + {disableSelection === true + ? null + : selectionRects.map((position, i) => ( + + ))} + {disableCaret === true || caretPosition === null ? null : ( + + + {navn} ({navIdent}) + + + )} + + ); +}; + +interface ColorProps { + $color: string; +} + +const StyledSelection = styled.div` + position: absolute; + z-index: 10; + background-color: ${({ $color }) => $color}; +`; + +const StyledCaret = styled.div` + pointer-events: none; + position: absolute; + z-index: 10; + width: 1px; + background-color: ${({ $color }) => $color}; +`; + +const CaretLabel = styled.div` + position: absolute; + bottom: 100%; + left: 0; + background-color: ${({ $color }) => $color}; + color: white; + font-size: 0.75em; + padding: 0.25em; + border-radius: var(--a-border-radius-medium); + white-space: nowrap; +`; + +export const CursorOverlay = (props: CursorOverlayProps) => { + const { user } = useContext(StaticDataContext); + const { cursors } = useCursorOverlayPositions(props); + + return ( + <> + {cursors + .filter(({ data }) => data?.navIdent !== user.navIdent) + .map((cursor) => ( + + ))} + + ); +}; diff --git a/frontend/src/components/smart-editor/tabbed-editors/editor.tsx b/frontend/src/components/smart-editor/tabbed-editors/editor.tsx index 57dcc08f1..af4010035 100644 --- a/frontend/src/components/smart-editor/tabbed-editors/editor.tsx +++ b/frontend/src/components/smart-editor/tabbed-editors/editor.tsx @@ -1,16 +1,21 @@ +/* eslint-disable max-lines */ import { ClockDashedIcon } from '@navikt/aksel-icons'; import { skipToken } from '@reduxjs/toolkit/query'; +import { relativeRangeToSlateRange } from '@slate-yjs/core'; import { Plate, isCollapsed, isText } from '@udecode/plate-common'; +import { CursorState } from '@udecode/plate-cursor'; import { Profiler, useContext, useEffect, useState } from 'react'; import { BasePoint, Path, Range } from 'slate'; import { styled } from 'styled-components'; +import { XmlText } from 'yjs'; +import { StaticDataContext } from '@app/components/app/static-data-context'; import { NewComment } from '@app/components/smart-editor/comments/new-comment'; import { SmartEditorContext } from '@app/components/smart-editor/context'; import { GodeFormuleringer } from '@app/components/smart-editor/gode-formuleringer/gode-formuleringer'; import { History } from '@app/components/smart-editor/history/history'; import { useCanEditDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; import { Content } from '@app/components/smart-editor/tabbed-editors/content'; -import { CursorOverlay } from '@app/components/smart-editor/tabbed-editors/cursors'; +import { CursorOverlay, UserCursor, isYjsCursor } from '@app/components/smart-editor/tabbed-editors/cursors/cursors'; import { PositionedRight } from '@app/components/smart-editor/tabbed-editors/positioned-right'; import { StickyRight } from '@app/components/smart-editor/tabbed-editors/sticky-right'; import { DocumentErrorComponent } from '@app/error-boundary/document-error'; @@ -25,7 +30,7 @@ import { StatusBar } from '@app/plate/status-bar/status-bar'; import { FloatingSaksbehandlerToolbar } from '@app/plate/toolbar/toolbars/floating-toolbar'; import { SaksbehandlerToolbar } from '@app/plate/toolbar/toolbars/saksbehandler-toolbar'; import { SaksbehandlerTableToolbar } from '@app/plate/toolbar/toolbars/table-toolbar'; -import { EditorValue, RichTextEditor } from '@app/plate/types'; +import { EditorValue, RichTextEditor, useMyPlateEditorRef } from '@app/plate/types'; import { useGetMySignatureQuery, useGetSignatureQuery } from '@app/redux-api/bruker'; import { useLazyGetDocumentQuery } from '@app/redux-api/oppgaver/queries/documents'; import { ISmartDocument } from '@app/types/documents/documents'; @@ -38,6 +43,7 @@ export const Editor = ({ smartDocument }: EditorProps) => { const { id, templateId } = smartDocument; const [getDocument, { isLoading }] = useLazyGetDocumentQuery(); const { newCommentSelection, showAnnotationsAtOrigin } = useContext(SmartEditorContext); + const { user } = useContext(StaticDataContext); const canEdit = useCanEditDocument(templateId); const [showHistory, setShowHistory] = useState(false); const { data: oppgave } = useOppgave(); @@ -69,7 +75,7 @@ export const Editor = ({ smartDocument }: EditorProps) => { initialValue={smartDocument.content} id={id} readOnly={!canEdit} - plugins={collaborationSaksbehandlerPlugins(oppgave.id, id, smartDocument)} + plugins={collaborationSaksbehandlerPlugins(oppgave.id, id, smartDocument, user)} decorate={([node, path]) => { if (newCommentSelection === null || isCollapsed(newCommentSelection) || !isText(node)) { return []; @@ -144,6 +150,61 @@ const EditorWithNewCommentAndFloatingToolbar = ({ id }: { id: string }) => { const [containerElement, setContainerElement] = useState(null); const lang = useSmartEditorSpellCheckLanguage(); + const editor = useMyPlateEditorRef(id); + + type CursorsState = Record>; + const [cursors, setCursors] = useState({}); + + useEffect(() => { + const onChange = ( + { added, removed, updated }: { added: number[]; removed: number[]; updated: number[] }, + doc: unknown, + ) => { + console.log('onChange doc', doc); + + if (added.length > 0) { + console.log('added', added); + } + + if (removed.length > 0) { + console.log('removed', removed); + } + + const states = editor.awareness.getStates(); + + setCursors((cursorsState) => { + const newCursorsState = { ...cursorsState }; + + for (const a of added.concat(updated)) { + const cursor = states.get(a); + + if (isYjsCursor(cursor)) { + newCursorsState[a] = { + ...cursor, + selection: relativeRangeToSlateRange( + editor.yjs.provider.document.get('content', XmlText), + editor, + cursor.selection, + ), + }; + } + } + + for (const r of removed) { + delete newCursorsState[r]; + } + + return newCursorsState; + }); + }; + + editor.awareness.on('change', onChange); + + return () => { + editor.awareness.off('change', onChange); + }; + }, [editor, editor.awareness]); + useEffect(() => { setSheetRef(containerElement); }, [containerElement, setSheetRef]); @@ -157,7 +218,7 @@ const EditorWithNewCommentAndFloatingToolbar = ({ id }: { id: string }) => { - + ); }; diff --git a/frontend/src/plate/plugins/plugin-sets/saksbehandler.ts b/frontend/src/plate/plugins/plugin-sets/saksbehandler.ts index fb5443360..3018cc3d2 100644 --- a/frontend/src/plate/plugins/plugin-sets/saksbehandler.ts +++ b/frontend/src/plate/plugins/plugin-sets/saksbehandler.ts @@ -51,6 +51,7 @@ import { defaultPlugins } from '@app/plate/plugins/plugin-sets/default'; import { createRedigerbarMaltekstPlugin } from '@app/plate/plugins/redigerbar-maltekst'; import { createRegelverkContainerPlugin, createRegelverkPlugin } from '@app/plate/plugins/regelverk'; import { createSignaturePlugin } from '@app/plate/plugins/signature'; +import { IUserData } from '@app/types/bruker'; import { ISmartDocument } from '@app/types/documents/documents'; const components: Record = { @@ -112,6 +113,7 @@ export const collaborationSaksbehandlerPlugins = ( behandlingId: string, dokumentId: string, smartDocument: ISmartDocument, + { navIdent, navn }: IUserData, ) => { const sharedRoot = new Y.XmlText(); sharedRoot.applyDelta(slateNodesToInsertDelta(smartDocument.content)); @@ -121,6 +123,9 @@ export const collaborationSaksbehandlerPlugins = ( ...saksbehandlerPlugins, createYjsPlugin({ options: { + cursorOptions: { + data: { navIdent, navn }, + }, hocuspocusProviderOptions: { url: `/collaboration/behandlinger/${behandlingId}/dokumenter/${dokumentId}`, name: dokumentId, diff --git a/frontend/src/plate/types.ts b/frontend/src/plate/types.ts index cde46207c..5433bb841 100644 --- a/frontend/src/plate/types.ts +++ b/frontend/src/plate/types.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ /* eslint-disable import/no-unused-modules */ +import { CursorEditor, YjsEditor } from '@slate-yjs/core'; import { AutoformatRule } from '@udecode/plate-autoformat'; import { PlateEditor, @@ -22,6 +23,7 @@ import { TTableElement, TTableRowElement, } from '@udecode/plate-table'; +import { CursorEditorProps, PlateYjsEditorProps } from '@udecode/plate-yjs'; import { ELEMENT_CURRENT_DATE, ELEMENT_EMPTY_VOID, @@ -275,5 +277,6 @@ export type EditorPlatePlugin

= PlatePlugin

; export type EditorAutoformatRule = AutoformatRule; -export const useMyPlateEditorRef = (id?: PlateId) => useEditorRef(id); +export const useMyPlateEditorRef = (id?: PlateId) => + useEditorRef(id); export const useMyPlateEditorState = (id?: PlateId) => useEditorState(id);