Adding tab support for text area

This commit is contained in:
Steve Ruiz 2021-07-02 13:04:45 +01:00
parent c4d9116426
commit 496606c10f
7 changed files with 259 additions and 37 deletions

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { useEffect } from 'react' import { useEffect } from 'react'
import state from 'state' import state from 'state'
import coopState from 'state/coop/coop-state'
export default function useLoadOnMount(roomId: string = undefined) { export default function useLoadOnMount(roomId: string = undefined) {
useEffect(() => { useEffect(() => {
@ -11,12 +12,13 @@ export default function useLoadOnMount(roomId: string = undefined) {
if (roomId !== undefined) { if (roomId !== undefined) {
state.send('RT_LOADED_ROOM', { id: roomId }) state.send('RT_LOADED_ROOM', { id: roomId })
coopState.send('JOINED_ROOM', { id: roomId })
} }
}) })
return () => { return () => {
state.send('UNMOUNTED') state.send('UNMOUNTED').send('RT_UNLOADED_ROOM', { id: roomId })
state.send('RT_UNLOADED_ROOM', { id: roomId }) coopState.send('LEFT_ROOM', { id: roomId })
} }
}, [roomId]) }, [roomId])
} }

View file

@ -128,4 +128,4 @@ class CoopClient {
} }
} }
export default new CoopClient() export default CoopClient

View file

@ -1,7 +1,7 @@
import { createSelectorHook, createState } from '@state-designer/react' import { createSelectorHook, createState } from '@state-designer/react'
import { CoopPresence } from 'types' import { CoopPresence } from 'types'
import { User } from '@liveblocks/client' import { User } from '@liveblocks/client'
import client from 'state/coop/client-liveblocks' import CoopClient from 'state/coop/client-liveblocks'
type ConnectionState = type ConnectionState =
| 'closed' | 'closed'
@ -13,27 +13,51 @@ type ConnectionState =
const coopState = createState({ const coopState = createState({
data: { data: {
client: undefined as CoopClient | undefined,
status: 'closed' as ConnectionState, status: 'closed' as ConnectionState,
others: {} as Record<string, User<CoopPresence>>, others: {} as Record<string, User<CoopPresence>>,
}, },
on: { initial: 'offline',
JOINED_ROOM: 'setOthers', states: {
LEFT_ROOM: 'disconnectFromRoom', offline: {
CHANGED_CONNECTION_STATUS: 'setStatus', on: {
OTHER_USER_ENTERED: 'addOtherUser', JOINED_ROOM: { to: 'online' },
OTHER_USER_LEFT: 'removeOtherUser', },
OTHER_USER_UPDATED: 'updateOtherUser', },
RESET_OTHER_USERS: 'resetOtherUsers', 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: { actions: {
connectToRoom(data, payload: { id: string }) { createClient(data) {
client.connect(payload.id) data.client = new CoopClient()
}, },
disconnectFromRoom() { connectToRoom(data, payload: { id: string }) {
client.disconnect() 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 }) { setStatus(data, payload: { status: ConnectionState }) {
data.status = payload.status data.status = payload?.status
}, },
setOthers(data, payload: { others: User<CoopPresence>[] }) { setOthers(data, payload: { others: User<CoopPresence>[] }) {
const { others } = payload const { others } = payload

View file

@ -3,7 +3,7 @@ import { deepClone, setToArray } from 'utils'
import tld from 'utils/tld' import tld from 'utils/tld'
import { freeze } from 'immer' import { freeze } from 'immer'
import session from './session' import session from './session'
import coopClient from 'state/coop/client-liveblocks' import coopState from 'state/coop/coop-state'
import state from './state' import state from './state'
import vec from 'utils/vec' import vec from 'utils/vec'
import * as Session from './sessions' import * as Session from './sessions'
@ -18,7 +18,10 @@ import * as Session from './sessions'
export function fastDrawUpdate(info: PointerInfo): void { export function fastDrawUpdate(info: PointerInfo): void {
const data = { ...state.data } const data = { ...state.data }
coopClient.moveCursor(data.currentPageId, info.point) coopState.send('MOVED_CURSOSR', {
pageId: data.currentPageId,
point: info.point,
})
session.update<Session.DrawSession>( session.update<Session.DrawSession>(
data, data,

View file

@ -1,5 +1,6 @@
import { uniqueId, isMobile } from 'utils/utils' import { uniqueId, isMobile } from 'utils/utils'
import vec from 'utils/vec' import vec from 'utils/vec'
import TextAreaUtils from 'utils/text-area'
import { TextShape, ShapeType } from 'types' import { TextShape, ShapeType } from 'types'
import { import {
defaultStyle, defaultStyle,
@ -42,7 +43,7 @@ mdiv.tabIndex = -1
document.body.appendChild(mdiv) document.body.appendChild(mdiv)
function normalizeText(text: string) { 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<TextShape>({ const text = registerShapeUtils<TextShape>({
@ -97,6 +98,18 @@ const text = registerShapeUtils<TextShape>({
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault() 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<TextShape>({
state.send('FOCUSED_EDITING_SHAPE', { id }) state.send('FOCUSED_EDITING_SHAPE', { id })
} }
function handlePointerDown(e: React.PointerEvent<HTMLTextAreaElement>) {
if (e.currentTarget.selectionEnd !== 0) {
e.currentTarget.selectionEnd = 0
}
}
const fontSize = getFontSize(shape.style.size) * shape.scale const fontSize = getFontSize(shape.style.size) * shape.scale
const lineHeight = fontSize * 1.4 const lineHeight = fontSize * 1.4
@ -141,8 +160,6 @@ const text = registerShapeUtils<TextShape>({
return ( return (
<foreignObject <foreignObject
id={id} id={id}
x={0}
y={0}
width={bounds.width} width={bounds.width}
height={bounds.height} height={bounds.height}
pointerEvents="none" pointerEvents="none"
@ -153,19 +170,20 @@ const text = registerShapeUtils<TextShape>({
font, font,
color: styles.stroke, color: styles.stroke,
}} }}
value={text} name="text"
tabIndex={0} defaultValue={text}
tabIndex={-1}
autoComplete="false" autoComplete="false"
autoCapitalize="false" autoCapitalize="false"
autoCorrect="false" autoCorrect="false"
autoSave="false" autoSave="false"
placeholder="" placeholder=""
name="text"
autoFocus={isMobile() ? true : false} autoFocus={isMobile() ? true : false}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onChange={handleChange} onChange={handleChange}
onPointerDown={handlePointerDown}
/> />
</foreignObject> </foreignObject>
) )

View file

@ -7,7 +7,6 @@ import history from './history'
import storage from './storage' import storage from './storage'
import session from './session' import session from './session'
import clipboard from './clipboard' import clipboard from './clipboard'
import coopClient from './coop/client-liveblocks'
import commands from './commands' import commands from './commands'
import { import {
vec, vec,
@ -167,7 +166,7 @@ const state = createState({
// Network-Related // Network-Related
RT_LOADED_ROOM: [ RT_LOADED_ROOM: [
'clearRoom', 'clearRoom',
{ if: 'hasRoom', do: ['resetDocumentState', 'connectToRoom'] }, { if: 'hasRoom', do: 'resetDocumentState' },
], ],
// RT_UNLOADED_ROOM: ['clearRoom', 'resetDocumentState'], // RT_UNLOADED_ROOM: ['clearRoom', 'resetDocumentState'],
// RT_DISCONNECTED_ROOM: ['clearRoom', 'resetDocumentState'], // RT_DISCONNECTED_ROOM: ['clearRoom', 'resetDocumentState'],
@ -647,7 +646,7 @@ const state = createState({
}, },
}, },
editingShape: { editingShape: {
onEnter: 'startEditSession', onEnter: ['startEditSession', 'clearHoveredId'],
onExit: ['completeSession', 'clearEditingId'], onExit: ['completeSession', 'clearEditingId'],
on: { on: {
EDITED_SHAPE: { do: 'updateEditSession' }, EDITED_SHAPE: { do: 'updateEditSession' },
@ -1221,10 +1220,6 @@ const state = createState({
// What if the page is in storage? // What if the page is in storage?
Object.assign(data.document[pageId].shapes[shape.id], shape) 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) { clearRoom(data) {
data.room = undefined 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) { resetPageState(data) {
const pageState = data.pageStates[data.currentPageId] const pageState = data.pageStates[data.currentPageId]
data.pageStates[data.currentPageId] = { ...pageState } data.pageStates[data.currentPageId] = { ...pageState }

185
utils/text-area.ts Normal file
View file

@ -0,0 +1,185 @@
// Adapted (mostly copied) the work of https://github.com/fregante
// Copyright (c) Federico Brigante <opensource@bfred.it> (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 cursors 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 fields 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 fields 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)
)
}
}