diff --git a/code/client/dev-dist/sw.js b/code/client/dev-dist/sw.js index 3efb91e5..501ea34b 100644 --- a/code/client/dev-dist/sw.js +++ b/code/client/dev-dist/sw.js @@ -67,7 +67,7 @@ if (!self.define) { }); }; } -define(['./workbox-a0f72815'], (function (workbox) { 'use strict'; +define(['./workbox-fda11f75'], (function (workbox) { 'use strict'; self.skipWaiting(); workbox.clientsClaim(); @@ -82,7 +82,7 @@ define(['./workbox-a0f72815'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.5aj285mkmh8" + "revision": "0.kad9pi711c" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/code/client/src/editor/crdt/fugue.ts b/code/client/src/editor/crdt/fugue.ts index 75907ef7..773cf9ed 100644 --- a/code/client/src/editor/crdt/fugue.ts +++ b/code/client/src/editor/crdt/fugue.ts @@ -1,12 +1,16 @@ -import { type DeleteOperation, type InsertOperation, type StyleOperation } from '@notespace/shared/crdt/types/operations'; +import { + type DeleteOperation, + type InsertOperation, + type StyleOperation, +} from '@notespace/shared/crdt/types/operations'; import { type Node, type Id } from '@notespace/shared/crdt/types/nodes'; import { type Style } from '@notespace/shared/crdt/types/styles'; import { FugueTree } from '@notespace/shared/crdt/FugueTree'; import { generateReplicaId } from './utils'; import { socket } from '@src/socket/socket'; import { type InsertNode } from '@editor/crdt/types'; -import {Cursor, Selection} from '@editor/slate/model/cursor' -import { isEmpty } from 'lodash'; +import { Cursor, Selection } from '@editor/slate/model/cursor'; +import { isEmpty, isEqual } from 'lodash'; /** * A local replica of a FugueTree. @@ -37,12 +41,11 @@ export class Fugue { * @param start * @param values */ - insertLocal({start}: Selection, ...values: InsertNode[]): InsertOperation[] { - return values.map((value, i) => { - const operation = this.insertOne({...start, column: start.column + i}, value); + insertLocal(start: Cursor, ...values: InsertNode[]): void { + values.forEach((value, i) => { + const operation = this.getInsertOperation({ ...start, column: start.column + i }, value); this.addNode(operation); - socket.emit('operation', operation); // FIXME: break data into data chunks - less network traffic - return operation; + socket.emit('operation', operation); // TODO: break data into data chunks - less network traffic }); } @@ -55,20 +58,17 @@ export class Fugue { } /** - * Inserts a new node in the tree based on the given operation. + * Gets the insert operation based on the given cursor and insert node. * @param start - the index where the new node should be inserted * @param value - the value of the new node * @param styles * @private * @returns the insert operation */ - private insertOne({ line, column } : Cursor, { value, styles }: InsertNode): InsertOperation { + private getInsertOperation({ line, column }: Cursor, { value, styles }: InsertNode): InsertOperation { const id = { sender: this.replicaId, counter: this.counter++ }; - const root = this.findNode('\n', line) || this.tree.root; - const leftOrigin = column === 0 ? root : this.tree.getByIndex(root, column - 1); - if (isEmpty(leftOrigin.rightChildren)) { return { type: 'insert', @@ -79,12 +79,10 @@ export class Fugue { styles, }; } - const rightOrigin = this.tree.getLeftmostDescendant(leftOrigin.rightChildren[0]); return { type: 'insert', id, value, parent: rightOrigin.id, side: 'L' }; } - /** * Inserts a new node in the tree based on the given operation. * @param id @@ -99,25 +97,39 @@ export class Fugue { /** * Deletes the nodes from the given start index to the given end index. - * @param start - * @param end (exclusive) + * @param selection */ - deleteLocal({start, end} : Selection): void { - const deleteElement = (id : Id) => { - const msg = this.deleteOne(id); - this.deleteNode(msg); - socket.emit('operation', msg); // FIXME: this should be done only once after all the deletes - less network traffic - }; - - const startRoot = this.findNode('\n', start.line); - const startNode = this.tree.getByIndex(startRoot, start.column); - - const endRoot = this.findNode('\n', end.line); - const endNode = this.tree.getByIndex(endRoot, end.column); - - for (const node of this.tree.traverse(startNode)){ - if (node === endNode) break; - deleteElement(node.id); + deleteLocal({ start, end }: Selection): void { + // delete from start to end + let lineCounter = 0; + let columnCounter = 0; + let deleteFlag = false; + for (const node of this.traverseTree()) { + // delete condition + if ( + lineCounter === start.line && + (columnCounter === start.column || (isEqual(start, end) && columnCounter === start.column - 1)) + ) { + deleteFlag = true; + } + // delete operation + if (deleteFlag) { + const { id } = node; + this.removeNode(id); + const operation: DeleteOperation = { type: 'delete', id }; + socket.emit('operation', operation); // TODO: break data into data chunks - less network traffic + } + // end condition + if (lineCounter === end.line && columnCounter === end.column) { + break; + } + // update counters + if (node.value === '\n') { + lineCounter++; + columnCounter = 0; + } else { + columnCounter++; + } } } @@ -126,25 +138,15 @@ export class Fugue { * @param operation */ deleteRemote(operation: DeleteOperation): void { - this.deleteNode(operation); + this.removeNode(operation.id); } /** - * Returns the delete operation - * @param index - * @private - * @returns the delete operation - */ - private deleteOne(id : Id): DeleteOperation { - return { type: 'delete', id }; - } - - /** - * Deletes the node based on the given operation. - * @param operation + * Deletes the node based on the given node id + * @param id * @private */ - private deleteNode({id}: DeleteOperation): void { + private removeNode(id: Id): void { this.tree.deleteNode(id); } @@ -160,8 +162,7 @@ export class Fugue { value: value, }; this.tree.updateStyle(id, style, value); - // TODO: swap to chunked operations - socket.emit('operation', styleOperation); + socket.emit('operation', styleOperation); // TODO: break data into data chunks - less network traffic } } @@ -175,27 +176,22 @@ export class Fugue { traverseTree = () => this.tree.traverse(this.tree.root); findNode(value: string, skip: number): Node { - let lastMatch: Node = this.tree.root - for (const node of this.traverseTree()){ - if(node.value === value && !node.isDeleted) { - lastMatch = node - if (--skip === 0) return lastMatch + let lastMatch: Node = this.tree.root; + for (const node of this.traverseTree()) { + if (node.value === value && !node.isDeleted) { + lastMatch = node; + if (--skip === 0) return lastMatch; } } - return lastMatch + return lastMatch; } - /** * Returns the string representation of the tree. * @returns the string representation of the tree. */ toString(): string { - const values: string[] = []; - for (const node of this.traverseTree()) { - values.push(node.value!); - } - return values.join(''); + return this.tree.toString(); } getElementId(index: number): Id { diff --git a/code/client/src/editor/crdt/types.ts b/code/client/src/editor/crdt/types.ts index c95492dc..f0122b19 100644 --- a/code/client/src/editor/crdt/types.ts +++ b/code/client/src/editor/crdt/types.ts @@ -3,4 +3,4 @@ import { type Style } from '@notespace/shared/crdt/types/styles'; export type InsertNode = { value: string; styles: Style[]; -} +}; diff --git a/code/client/src/editor/crdt/utils.ts b/code/client/src/editor/crdt/utils.ts index 88b0c5c5..1d411006 100644 --- a/code/client/src/editor/crdt/utils.ts +++ b/code/client/src/editor/crdt/utils.ts @@ -1,4 +1,4 @@ -import _, { range } from 'lodash'; +import { range } from 'lodash'; import type { Style } from '@notespace/shared/crdt/types/styles'; import type { InsertNode } from '@editor/crdt/types'; @@ -18,5 +18,3 @@ export function generateReplicaId() { export function insertNode(value: string, styles: Style[]): InsertNode { return { value, styles }; } - - diff --git a/code/client/src/editor/slate/SlateEditor.tsx b/code/client/src/editor/slate/SlateEditor.tsx index 281b608f..d8182558 100644 --- a/code/client/src/editor/slate/SlateEditor.tsx +++ b/code/client/src/editor/slate/SlateEditor.tsx @@ -9,14 +9,14 @@ import { withHistory } from 'slate-history'; import useEditor from '@editor/slate/hooks/useEditor'; import { withMarkdown } from '@editor/slate/plugins/markdown/withMarkdown'; import { withNormalize } from './plugins/normalize/withNormalize'; -import { toSlate } from '@editor/slate/utils'; +import { toSlate } from '@editor/slate/utils/toSlate'; const initialValue = [ { type: 'paragraph', children: [{ text: '' }], }, -] +]; function SlateEditor() { const editor = useEditor(withHistory, withReact, withMarkdown, withNormalize); @@ -25,7 +25,7 @@ function SlateEditor() { const { renderElement, renderLeaf } = useRenderers(); useEvents(fugue, () => { - editor.children = toSlate(fugue.traverseTree) + editor.children = toSlate(fugue.traverseTree); editor.onChange(); }); diff --git a/code/client/src/editor/slate/hooks/useInputHandlers.ts b/code/client/src/editor/slate/hooks/useInputHandlers.ts index 890edf48..9043e35c 100644 --- a/code/client/src/editor/slate/hooks/useInputHandlers.ts +++ b/code/client/src/editor/slate/hooks/useInputHandlers.ts @@ -2,9 +2,9 @@ import type React from 'react'; import { type Fugue } from '@editor/crdt/fugue'; import CustomEditor from '@editor/slate/model/CustomEditor'; import { type Editor } from 'slate'; -import useSelection from './useSelection'; -import {isEqual} from 'lodash' -import {insertNode } from '@src/editor/crdt/utils'; +import { getSelection } from '../utils/selection'; +import { isEqual } from 'lodash'; +import { insertNode } from '@src/editor/crdt/utils'; const hotkeys: Record = { b: 'bold', @@ -14,36 +14,37 @@ const hotkeys: Record = { }; function useInputHandlers(editor: Editor, fugue: Fugue) { - - function onKeyDown(e: React.KeyboardEvent) { + function onKeyDown(e: React.KeyboardEvent) { if (e.ctrlKey) return shortcutHandler(e); - - const {selection} = useSelection(editor); - const {start, end} = selection - + + const selection = getSelection(editor); + const { start, end } = selection; + switch (e.key) { case 'Enter': - fugue.insertLocal(selection, insertNode('\n', [])); + fugue.insertLocal(start, insertNode('\n', [])); break; - case 'Backspace': - if (isEqual({line: 0, column: 0}, start) && isEqual({line:0, column:0}, end)) break; + case 'Backspace': { + const startPosition = { line: 0, column: 0 }; + if (isEqual(startPosition, start) && isEqual(startPosition, end)) break; fugue.deleteLocal(selection); break; + } case 'Tab': e.preventDefault(); editor.insertText('\t'); - fugue.insertLocal(selection, insertNode('\t', [])); + fugue.insertLocal(start, insertNode('\t', [])); break; default: if (e.key.length !== 1) break; - fugue.insertLocal(selection, insertNode(e.key, [])); + fugue.insertLocal(start, insertNode(e.key, [])); break; } } function onPaste(e: React.ClipboardEvent) { - // const clipboardData = e.clipboardData?.getData('text'); - // if (!clipboardData) return; + const clipboardData = e.clipboardData?.getData('text'); + if (!clipboardData) return; // const selection = getSelection(); // fugue.insertLocal(start, insertNode(clipboardData, [])); } diff --git a/code/client/src/editor/slate/hooks/useSelection.ts b/code/client/src/editor/slate/hooks/useSelection.ts deleted file mode 100644 index 6fa76fcb..00000000 --- a/code/client/src/editor/slate/hooks/useSelection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Editor } from 'slate'; -import { Selection } from '../model/cursor'; -import { isEqual } from 'lodash'; - -function isSelected(editor: Editor) { - if (!editor.selection) return false; - const { anchor, focus } = editor.selection; - return isEqual(anchor.path, focus.path) || anchor.offset !== focus.offset; -} - -function getSelection(editor: Editor): Selection { - if (!editor.selection) return { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }; - const { anchor, focus } = editor.selection; - console.log(anchor, focus) - const {path: startLine, offset: startColumn} = focus - const {path: endLine, offset: endColumn} = anchor - return {start: { line: startLine[0], column: startColumn }, end: { line: endLine[0], column: endColumn }}; -} - -function useSelection(editor: Editor){ - return { - isSelected : isSelected(editor), - selection : getSelection(editor) - } -} - -export default useSelection; \ No newline at end of file diff --git a/code/client/src/editor/slate/model/CustomEditor.ts b/code/client/src/editor/slate/model/CustomEditor.ts index 6499cce0..cdf1f009 100644 --- a/code/client/src/editor/slate/model/CustomEditor.ts +++ b/code/client/src/editor/slate/model/CustomEditor.ts @@ -10,10 +10,10 @@ const CustomEditor = { const marks = Editor.marks(editor) as Partial>; return marks ? marks[format] : false; }, - toggleMark(editor: Editor, mark : string, fugue: Fugue) { + toggleMark(editor: Editor, mark: string, fugue: Fugue) { const isActive = CustomEditor.isMarkActive(editor, mark); - Transforms.setNodes(editor, { [mark]: !isActive}, { match: n => Editor.isBlock(editor, n) }); - + Transforms.setNodes(editor, { [mark]: !isActive }, { match: n => Editor.isBlock(editor, n) }); + const [start, end] = getAbsoluteSelection(editor)!; fugue.updateStyleLocal(start, end, !isActive, mark); }, diff --git a/code/client/src/editor/slate/model/cursor.ts b/code/client/src/editor/slate/model/cursor.ts index 7a8f44f2..0e90b7f5 100644 --- a/code/client/src/editor/slate/model/cursor.ts +++ b/code/client/src/editor/slate/model/cursor.ts @@ -1,9 +1,9 @@ export type Cursor = { - line: number; - column: number; -} - + line: number; + column: number; +}; + export type Selection = { - start: Cursor; - end: Cursor; -} \ No newline at end of file + start: Cursor; + end: Cursor; +}; diff --git a/code/client/src/editor/slate/model/types.ts b/code/client/src/editor/slate/model/types.ts index d0b29e3c..60c167c8 100644 --- a/code/client/src/editor/slate/model/types.ts +++ b/code/client/src/editor/slate/model/types.ts @@ -15,7 +15,6 @@ export interface CustomText extends CustomFormat { text: string; } - export interface CustomElement { type: BlockStyle; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/code/client/src/editor/slate/plugins/normalize/withNormalize.ts b/code/client/src/editor/slate/plugins/normalize/withNormalize.ts index 5e8aa711..35be95aa 100644 --- a/code/client/src/editor/slate/plugins/normalize/withNormalize.ts +++ b/code/client/src/editor/slate/plugins/normalize/withNormalize.ts @@ -1,5 +1,5 @@ import { Editor, Transforms } from 'slate'; -import _, { isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; // ensure editor always has at least one child. export function withNormalize(editor: Editor) { diff --git a/code/client/src/editor/slate/toolbar/Toolbar.tsx b/code/client/src/editor/slate/toolbar/Toolbar.tsx index c6f79ba8..cdb74ce1 100644 --- a/code/client/src/editor/slate/toolbar/Toolbar.tsx +++ b/code/client/src/editor/slate/toolbar/Toolbar.tsx @@ -1,9 +1,9 @@ import React, { useEffect } from 'react'; import { useFocused, useSlate } from 'slate-react'; -import CustomEditor from '@editor/slate/model/CustomEditor.ts'; -import useSelection from '@editor/slate/hooks/useSelection.ts'; +import CustomEditor from '@editor/slate/model/CustomEditor'; +import { isSelected } from '@editor/slate/utils/selection'; import { FaBold, FaItalic, FaUnderline, FaStrikethrough, FaCode } from 'react-icons/fa'; -import { type Fugue } from '@editor/crdt/fugue.ts'; +import { type Fugue } from '@editor/crdt/fugue'; interface MarkOption { value: string; @@ -25,7 +25,7 @@ interface ToolbarProps { function Toolbar({ fugue }: ToolbarProps) { const editor = useSlate(); const focused = useFocused(); - const {isSelected:selected} = useSelection(editor) + const selected = isSelected(editor); const [selectionBounds, setSelectionBounds] = React.useState(null); useEffect(() => { diff --git a/code/client/src/editor/slate/utils/selection.ts b/code/client/src/editor/slate/utils/selection.ts new file mode 100644 index 00000000..44d198f8 --- /dev/null +++ b/code/client/src/editor/slate/utils/selection.ts @@ -0,0 +1,20 @@ +import { Editor } from 'slate'; +import { Selection } from '../model/cursor.ts'; +import { isEqual } from 'lodash'; + +export function isSelected(editor: Editor) { + if (!editor.selection) return false; + const { anchor, focus } = editor.selection; + return isEqual(anchor.path, focus.path) || anchor.offset !== focus.offset; +} + +export function getSelection(editor: Editor): Selection { + if (!editor.selection) return { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }; + const { anchor, focus } = editor.selection; + const { path: startLine, offset: startColumn } = focus; + const { path: endLine, offset: endColumn } = anchor; + const start = { line: startLine[0], column: startColumn }; + const end = { line: endLine[0], column: endColumn }; + const isRightToLeft = start.line > end.line || (start.line === end.line && start.column > end.column); + return isRightToLeft ? { start: end, end: start } : { start, end }; +} diff --git a/code/client/src/editor/slate/utils.ts b/code/client/src/editor/slate/utils/toSlate.ts similarity index 84% rename from code/client/src/editor/slate/utils.ts rename to code/client/src/editor/slate/utils/toSlate.ts index 4ba0b4c0..68f93f5c 100644 --- a/code/client/src/editor/slate/utils.ts +++ b/code/client/src/editor/slate/utils/toSlate.ts @@ -1,16 +1,14 @@ -import type { Descendant, Editor } from 'slate'; -import type { Style, BlockStyle } from '@notespace/shared/crdt/types/styles'; +import type { Descendant } from 'slate'; +import type { Style, BlockStyle } from '../../../../../shared/crdt/types/styles'; import type { CustomText } from '@editor/slate/model/types.ts'; import { isEmpty, isEqual } from 'lodash'; import { createChildren, createDescendant } from '@editor/slate/model/utils.ts'; -import { Node } from '@notespace/shared/crdt/types/nodes'; -import { Selection } from '@editor/slate/model/cursor'; - +import { Node } from '../../../../../shared/crdt/types/nodes.ts'; export function toSlate(traverse: () => IterableIterator>): Descendant[] { const descendants: Descendant[] = []; let lastStyles: Style[] = []; - let lineCounter = 0 + let lineCounter = 0; let root: Node | null = null; for (const node of traverse()) { if (root === null) root = node; @@ -26,7 +24,7 @@ export function toSlate(traverse: () => IterableIterator>): Descendan // If there are no descendants or new line, add a new paragraph if (isEmpty(descendants) || node.value === '\n') { const children = node.value === '\n' ? createChildren('') : [textNode]; - const lineStyle = root.styles[lineCounter++] as BlockStyle; + const lineStyle = root!.styles[lineCounter++] as BlockStyle; descendants.push(createDescendant(lineStyle, children)); lastStyles = node.styles; continue; @@ -49,5 +47,3 @@ export function toSlate(traverse: () => IterableIterator>): Descendan } return descendants; } - - diff --git a/code/client/tests/editor/crdt/fugue.test.ts b/code/client/tests/editor/crdt/fugue.test.ts index 802a0871..b2849ce3 100644 --- a/code/client/tests/editor/crdt/fugue.test.ts +++ b/code/client/tests/editor/crdt/fugue.test.ts @@ -1,7 +1,13 @@ import { Fugue } from '@editor/crdt/fugue'; import { InsertOperation, DeleteOperation } from '@notespace/shared/crdt/types'; +import { Selection, Cursor } from '@editor/slate/model/cursor'; +import { InsertNode } from '@editor/crdt/types'; import { describe, it, expect, beforeEach } from 'vitest'; +const a: InsertNode = { value: 'a', styles: [] }; +const b: InsertNode = { value: 'b', styles: [] }; +const c: InsertNode = { value: 'c', styles: [] }; + describe('Fugue', () => { let fugue: Fugue; @@ -15,9 +21,9 @@ describe('Fugue', () => { }); it('should insert values locally', () => { - const insertedMessages: InsertOperation[] = fugue.insertLocal(0, 'a', 'b', 'c'); - expect(insertedMessages).toHaveLength(3); - expect(fugue.toString()).toContain('abc'); + const start: Cursor = { line: 0, column: 0 }; + fugue.insertLocal(start, a, b, c); + expect(fugue.toString()).toEqual('abc'); }); it('should insert values remotely', () => { @@ -33,8 +39,10 @@ describe('Fugue', () => { }); it('should delete values locally', () => { - fugue.insertLocal(0, 'a', 'b', 'c'); - fugue.deleteLocal(1, 3); + const start: Cursor = { line: 0, column: 0 }; + fugue.insertLocal(start, a, b, c); + const selection: Selection = { start: { line: 0, column: 1 }, end: { line: 0, column: 3 } }; + fugue.deleteLocal(selection); expect(fugue.toString()).toEqual('a'); }); diff --git a/code/server/tests/conflict-resolution/crdt.test.ts b/code/server/tests/conflict-resolution/crdt.test.ts index 57708c18..9f5350e9 100644 --- a/code/server/tests/conflict-resolution/crdt.test.ts +++ b/code/server/tests/conflict-resolution/crdt.test.ts @@ -4,7 +4,6 @@ import { InsertOperation, DeleteOperation } from '@notespace/shared/crdt/types/o import { Node } from '@notespace/shared/crdt/types/nodes'; import { FugueTree } from '@notespace/shared/crdt/FugueTree'; import request = require('supertest'); -import { treeToString } from '../utils'; import { Server } from 'socket.io'; import server from '../../src/server'; @@ -69,8 +68,7 @@ describe('Operations must be commutative', () => { const nodes = response.body as Record[]>; const tree = new FugueTree(); tree.setTree(new Map(Object.entries(nodes))); - const result = treeToString(tree); - expect(result).toBe('ab'); + expect(tree.toString()).toBe('ab'); }); }); @@ -112,8 +110,7 @@ describe('Operations must be idempotent', () => { const nodes = response.body as Record[]>; const tree = new FugueTree(); tree.setTree(new Map(Object.entries(nodes))); - const result = treeToString(tree); - expect(result).toBe('a'); + expect(tree.toString()).toBe('a'); done(); }, 500); }); diff --git a/code/server/tests/utils.ts b/code/server/tests/utils.ts deleted file mode 100644 index 91d9c708..00000000 --- a/code/server/tests/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FugueTree } from '@notespace/shared/crdt/FugueTree'; - -export function treeToString(tree: FugueTree): string { - let result = ''; - for (const node of tree.traverse(tree.root)) { - result += node.value; - } - return result; -} diff --git a/code/shared/crdt/FugueTree.ts b/code/shared/crdt/FugueTree.ts index 7377697d..d758a621 100644 --- a/code/shared/crdt/FugueTree.ts +++ b/code/shared/crdt/FugueTree.ts @@ -213,6 +213,10 @@ export class FugueTree { } } + toString() { + return Array.from(this.traverse(this.root)).map(node => node.value).join(''); + } + get root(): Node { return this._root; } diff --git a/code/shared/crdt/utils.ts b/code/shared/crdt/utils.ts index 8ee46bae..0971b9a5 100644 --- a/code/shared/crdt/utils.ts +++ b/code/shared/crdt/utils.ts @@ -34,4 +34,4 @@ export function treeNode( depth, styles, }; -} \ No newline at end of file +}