diff --git a/hooks/useLoadOnMount.ts b/hooks/useLoadOnMount.ts index 628cbd705..6c8d9c387 100644 --- a/hooks/useLoadOnMount.ts +++ b/hooks/useLoadOnMount.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { useEffect } from 'react' import state from 'state' +import coopState from 'state/coop/coop-state' export default function useLoadOnMount(roomId: string = undefined) { useEffect(() => { @@ -11,12 +12,13 @@ export default function useLoadOnMount(roomId: string = undefined) { if (roomId !== undefined) { state.send('RT_LOADED_ROOM', { id: roomId }) + coopState.send('JOINED_ROOM', { id: roomId }) } }) return () => { - state.send('UNMOUNTED') - state.send('RT_UNLOADED_ROOM', { id: roomId }) + state.send('UNMOUNTED').send('RT_UNLOADED_ROOM', { id: roomId }) + coopState.send('LEFT_ROOM', { id: roomId }) } }, [roomId]) } diff --git a/state/coop/client-liveblocks.ts b/state/coop/client-liveblocks.ts index 8072d1368..3d1277b58 100644 --- a/state/coop/client-liveblocks.ts +++ b/state/coop/client-liveblocks.ts @@ -128,4 +128,4 @@ class CoopClient { } } -export default new CoopClient() +export default CoopClient diff --git a/state/coop/coop-state.ts b/state/coop/coop-state.ts index ba72918eb..01a240c4c 100644 --- a/state/coop/coop-state.ts +++ b/state/coop/coop-state.ts @@ -1,7 +1,7 @@ import { createSelectorHook, createState } from '@state-designer/react' import { CoopPresence } from 'types' import { User } from '@liveblocks/client' -import client from 'state/coop/client-liveblocks' +import CoopClient from 'state/coop/client-liveblocks' type ConnectionState = | 'closed' @@ -13,27 +13,51 @@ type ConnectionState = const coopState = createState({ data: { + client: undefined as CoopClient | undefined, status: 'closed' as ConnectionState, others: {} as Record>, }, - on: { - JOINED_ROOM: 'setOthers', - LEFT_ROOM: 'disconnectFromRoom', - CHANGED_CONNECTION_STATUS: 'setStatus', - OTHER_USER_ENTERED: 'addOtherUser', - OTHER_USER_LEFT: 'removeOtherUser', - OTHER_USER_UPDATED: 'updateOtherUser', - RESET_OTHER_USERS: 'resetOtherUsers', + initial: 'offline', + states: { + offline: { + on: { + JOINED_ROOM: { to: 'online' }, + }, + }, + online: { + onEnter: ['createClient', 'setOthers'], + on: { + MOVED_CURSOR: 'updateCursor', + JOINED_ROOM: 'setOthers', + CHANGED_CONNECTION_STATUS: 'setStatus', + OTHER_USER_ENTERED: 'addOtherUser', + OTHER_USER_LEFT: 'removeOtherUser', + OTHER_USER_UPDATED: 'updateOtherUser', + RESET_OTHER_USERS: 'resetOtherUsers', + LEFT_ROOM: 'disconnectFromRoom', + }, + }, + }, + conditions: { + hasClient(data) { + return data.client !== undefined + }, }, actions: { - connectToRoom(data, payload: { id: string }) { - client.connect(payload.id) + createClient(data) { + data.client = new CoopClient() }, - disconnectFromRoom() { - client.disconnect() + connectToRoom(data, payload: { id: string }) { + data.client.connect(payload.id) + }, + disconnectFromRoom(data) { + data.client.disconnect() + }, + updateCursor(data, payload: { pageId: string; point: number[] }) { + data.client.moveCursor(payload.pageId, payload.point) }, setStatus(data, payload: { status: ConnectionState }) { - data.status = payload.status + data.status = payload?.status }, setOthers(data, payload: { others: User[] }) { const { others } = payload diff --git a/state/hacks.ts b/state/hacks.ts index 62d33b399..e47639b3f 100644 --- a/state/hacks.ts +++ b/state/hacks.ts @@ -3,7 +3,7 @@ import { deepClone, setToArray } from 'utils' import tld from 'utils/tld' import { freeze } from 'immer' import session from './session' -import coopClient from 'state/coop/client-liveblocks' +import coopState from 'state/coop/coop-state' import state from './state' import vec from 'utils/vec' import * as Session from './sessions' @@ -18,7 +18,10 @@ import * as Session from './sessions' export function fastDrawUpdate(info: PointerInfo): void { const data = { ...state.data } - coopClient.moveCursor(data.currentPageId, info.point) + coopState.send('MOVED_CURSOSR', { + pageId: data.currentPageId, + point: info.point, + }) session.update( data, diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index fd807cad7..890718730 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -1,5 +1,6 @@ import { uniqueId, isMobile } from 'utils/utils' import vec from 'utils/vec' +import TextAreaUtils from 'utils/text-area' import { TextShape, ShapeType } from 'types' import { defaultStyle, @@ -42,7 +43,7 @@ mdiv.tabIndex = -1 document.body.appendChild(mdiv) function normalizeText(text: string) { - return text.replace(/\t/g, ' ').replace(/\r?\n|\r/g, '\n') + return text.replace(/\r?\n|\r/g, '\n') } const text = registerShapeUtils({ @@ -97,6 +98,18 @@ const text = registerShapeUtils({ if (e.key === 'Tab') { e.preventDefault() + if (e.shiftKey) { + TextAreaUtils.unindent(e.currentTarget) + } else { + TextAreaUtils.indent(e.currentTarget) + } + + state.send('EDITED_SHAPE', { + id, + change: { + text: normalizeText(e.currentTarget.value), + }, + }) } } @@ -109,6 +122,12 @@ const text = registerShapeUtils({ state.send('FOCUSED_EDITING_SHAPE', { id }) } + function handlePointerDown(e: React.PointerEvent) { + if (e.currentTarget.selectionEnd !== 0) { + e.currentTarget.selectionEnd = 0 + } + } + const fontSize = getFontSize(shape.style.size) * shape.scale const lineHeight = fontSize * 1.4 @@ -141,8 +160,6 @@ const text = registerShapeUtils({ return ( ({ font, color: styles.stroke, }} - value={text} - tabIndex={0} + name="text" + defaultValue={text} + tabIndex={-1} autoComplete="false" autoCapitalize="false" autoCorrect="false" autoSave="false" placeholder="" - name="text" autoFocus={isMobile() ? true : false} onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} onChange={handleChange} + onPointerDown={handlePointerDown} /> ) diff --git a/state/state.ts b/state/state.ts index d29d12b7a..160ae09a4 100644 --- a/state/state.ts +++ b/state/state.ts @@ -7,7 +7,6 @@ import history from './history' import storage from './storage' import session from './session' import clipboard from './clipboard' -import coopClient from './coop/client-liveblocks' import commands from './commands' import { vec, @@ -167,7 +166,7 @@ const state = createState({ // Network-Related RT_LOADED_ROOM: [ 'clearRoom', - { if: 'hasRoom', do: ['resetDocumentState', 'connectToRoom'] }, + { if: 'hasRoom', do: 'resetDocumentState' }, ], // RT_UNLOADED_ROOM: ['clearRoom', 'resetDocumentState'], // RT_DISCONNECTED_ROOM: ['clearRoom', 'resetDocumentState'], @@ -647,7 +646,7 @@ const state = createState({ }, }, editingShape: { - onEnter: 'startEditSession', + onEnter: ['startEditSession', 'clearHoveredId'], onExit: ['completeSession', 'clearEditingId'], on: { EDITED_SHAPE: { do: 'updateEditSession' }, @@ -1221,10 +1220,6 @@ const state = createState({ // What if the page is in storage? Object.assign(data.document[pageId].shapes[shape.id], shape) }, - sendRtCursorMove(data, payload: PointerInfo) { - const point = tld.screenToWorld(payload.point, data) - coopClient.moveCursor(data.currentPageId, point) - }, clearRoom(data) { data.room = undefined }, @@ -1266,11 +1261,6 @@ const state = createState({ }, } }, - connectToRoom(data, payload: { id: string }) { - data.room = { id: payload.id, status: 'connecting', peers: {} } - coopClient.connect(payload.id) - }, - resetPageState(data) { const pageState = data.pageStates[data.currentPageId] data.pageStates[data.currentPageId] = { ...pageState } diff --git a/utils/text-area.ts b/utils/text-area.ts new file mode 100644 index 000000000..a41c33512 --- /dev/null +++ b/utils/text-area.ts @@ -0,0 +1,185 @@ +// Adapted (mostly copied) the work of https://github.com/fregante +// Copyright (c) Federico Brigante (bfred.it) + +type ReplacerCallback = (substring: string, ...args: any[]) => string + +const INDENT = ' ' + +export default class TextAreaUtils { + static insertTextFirefox( + field: HTMLTextAreaElement | HTMLInputElement, + text: string + ): void { + // Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈 + field.setRangeText( + text, + field.selectionStart || 0, + field.selectionEnd || 0, + 'end' // Without this, the cursor is either at the beginning or `text` remains selected + ) + + field.dispatchEvent( + new InputEvent('input', { + data: text, + inputType: 'insertText', + isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required + }) + ) + } + + /** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */ + static insert( + field: HTMLTextAreaElement | HTMLInputElement, + text: string + ): void { + const document = field.ownerDocument + const initialFocus = document.activeElement + if (initialFocus !== field) { + field.focus() + } + + if (!document.execCommand('insertText', false, text)) { + TextAreaUtils.insertTextFirefox(field, text) + } + + if (initialFocus === document.body) { + field.blur() + } else if (initialFocus instanceof HTMLElement && initialFocus !== field) { + initialFocus.focus() + } + } + + /** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */ + static set( + field: HTMLTextAreaElement | HTMLInputElement, + text: string + ): void { + field.select() + TextAreaUtils.insert(field, text) + } + + /** Get the selected text in a field or an empty string if nothing is selected. */ + static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string { + return field.value.slice(field.selectionStart, field.selectionEnd) + } + + /** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */ + static wrapSelection( + field: HTMLTextAreaElement | HTMLInputElement, + wrap: string, + wrapEnd?: string + ): void { + const { selectionStart, selectionEnd } = field + const selection = TextAreaUtils.getSelection(field) + TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap)) + + // Restore the selection around the previously-selected text + field.selectionStart = selectionStart + wrap.length + field.selectionEnd = selectionEnd + wrap.length + } + + /** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */ + static replace( + field: HTMLTextAreaElement | HTMLInputElement, + searchValue: string | RegExp, + replacer: string | ReplacerCallback + ): void { + /** Remembers how much each match offset should be adjusted */ + let drift = 0 + + field.value.replace(searchValue, (...args): string => { + // Select current match to replace it later + const matchStart = drift + (args[args.length - 2] as number) + const matchLength = args[0].length + field.selectionStart = matchStart + field.selectionEnd = matchStart + matchLength + + const replacement = + typeof replacer === 'string' ? replacer : replacer(...args) + TextAreaUtils.insert(field, replacement) + + // Select replacement. Without this, the cursor would be after the replacement + field.selectionStart = matchStart + drift += replacement.length - matchLength + return replacement + }) + } + + static findLineEnd(value: string, currentEnd: number): number { + // Go to the beginning of the last line + const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1 + + // There's nothing to unindent after the last cursor, so leave it as is + if (value.charAt(lastLineStart) !== '\t') { + return currentEnd + } + + return lastLineStart + 1 // Include the first character, which will be a tab + } + + static indent(element: HTMLTextAreaElement): void { + const { selectionStart, selectionEnd, value } = element + const selectedText = value.slice(selectionStart, selectionEnd) + // The first line should be indented, even if it starts with `\n` + // The last line should only be indented if includes any character after `\n` + const lineBreakCount = /\n/g.exec(selectedText)?.length + + if (lineBreakCount > 0) { + // Select full first line to replace everything at once + const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1 + + const newSelection = element.value.slice(firstLineStart, selectionEnd - 1) + const indentedText = newSelection.replace( + /^|\n/g, // Match all line starts + `$&${INDENT}` + ) + const replacementsCount = indentedText.length - newSelection.length + + // Replace newSelection with indentedText + element.setSelectionRange(firstLineStart, selectionEnd - 1) + TextAreaUtils.insert(element, indentedText) + + // Restore selection position, including the indentation + element.setSelectionRange( + selectionStart + 1, + selectionEnd + replacementsCount + ) + } else { + TextAreaUtils.insert(element, INDENT) + } + } + + // The first line should always be unindented + // The last line should only be unindented if the selection includes any characters after `\n` + static unindent(element: HTMLTextAreaElement): void { + const { selectionStart, selectionEnd, value } = element + + // Select the whole first line because it might contain \t + const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1 + const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd) + + const newSelection = element.value.slice( + firstLineStart, + minimumSelectionEnd + ) + const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1') + const replacementsCount = newSelection.length - indentedText.length + + // Replace newSelection with indentedText + element.setSelectionRange(firstLineStart, minimumSelectionEnd) + TextAreaUtils.insert(element, indentedText) + + // Restore selection position, including the indentation + const firstLineIndentation = /\t| {1,2}/.exec( + value.slice(firstLineStart, selectionStart) + ) + + const difference = firstLineIndentation ? firstLineIndentation[0].length : 0 + + const newSelectionStart = selectionStart - difference + element.setSelectionRange( + selectionStart - difference, + Math.max(newSelectionStart, selectionEnd - replacementsCount) + ) + } +}