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 */
|
/* 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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { 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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
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