Adding tab support for text area
This commit is contained in:
parent
c4d9116426
commit
496606c10f
7 changed files with 259 additions and 37 deletions
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -128,4 +128,4 @@ class CoopClient {
|
|||
}
|
||||
}
|
||||
|
||||
export default new CoopClient()
|
||||
export default CoopClient
|
||||
|
|
|
@ -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<string, User<CoopPresence>>,
|
||||
},
|
||||
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<CoopPresence>[] }) {
|
||||
const { others } = payload
|
||||
|
|
|
@ -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<Session.DrawSession>(
|
||||
data,
|
||||
|
|
|
@ -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<TextShape>({
|
||||
|
@ -97,6 +98,18 @@ const text = registerShapeUtils<TextShape>({
|
|||
|
||||
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<TextShape>({
|
|||
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 lineHeight = fontSize * 1.4
|
||||
|
||||
|
@ -141,8 +160,6 @@ const text = registerShapeUtils<TextShape>({
|
|||
return (
|
||||
<foreignObject
|
||||
id={id}
|
||||
x={0}
|
||||
y={0}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
pointerEvents="none"
|
||||
|
@ -153,19 +170,20 @@ const text = registerShapeUtils<TextShape>({
|
|||
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}
|
||||
/>
|
||||
</foreignObject>
|
||||
)
|
||||
|
|
|
@ -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 }
|
||||
|
|
185
utils/text-area.ts
Normal file
185
utils/text-area.ts
Normal 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 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)
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue