Skip to content

Commit 410f997

Browse files
committedOct 31, 2023
fix: editor sometimes losing track on whether it has focus
1 parent 6588a01 commit 410f997

File tree

13 files changed

+127
-147
lines changed

13 files changed

+127
-147
lines changed
 

‎src/lib/components/JSONEditor.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@
312312
mode = newMode
313313
314314
await tick()
315-
focus()
315+
await focus()
316316
317317
onChangeMode(newMode)
318318
}

‎src/lib/components/controls/EditableDiv.svelte

+32-20
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
import { keyComboFromEvent } from '$lib/utils/keyBindings.js'
77
import { createDebug } from '$lib/utils/debug.js'
88
import { noop } from 'lodash-es'
9-
import { UPDATE_SELECTION } from '$lib/constants.js'
109
import type { OnFind, OnPaste } from '$lib/types'
10+
import { UpdateSelectionAfterChange } from '$lib/types'
1111
import { classnames } from '$lib/utils/cssUtils.js'
1212
1313
const debug = createDebug('jsoneditor:EditableDiv')
1414
1515
export let value: string
1616
export let shortText = false
17-
export let onChange: (newValue: string, updateSelection: string) => void
17+
export let onChange: (newValue: string, updateSelection: UpdateSelectionAfterChange) => void
1818
export let onCancel: () => void
1919
export let onFind: OnFind
2020
export let onPaste: OnPaste = noop
@@ -30,16 +30,20 @@
3030
setDomValue(value)
3131
3232
// focus
33-
setTimeout(() => {
34-
if (domValue) {
35-
setCursorToEnd(domValue)
36-
37-
// The refresh method can be used to update the classnames for example
38-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
39-
// @ts-ignore
40-
domValue.refresh = handleValueInput
41-
}
42-
})
33+
if (domValue) {
34+
setCursorToEnd(domValue)
35+
36+
// The refresh method can be used to update the classnames for example
37+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
38+
// @ts-ignore
39+
domValue.refresh = handleValueInput
40+
41+
// The cancel method can be used to cancel editing, without firing a change
42+
// when the contents did change in the meantime. It is the same as pressing ESC
43+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
44+
// @ts-ignore
45+
domValue.cancel = handleCancel
46+
}
4347
})
4448
4549
onDestroy(() => {
@@ -48,7 +52,7 @@
4852
debug('onDestroy', { closed, value, newValue })
4953
5054
if (!closed && newValue !== value) {
51-
onChange(newValue, UPDATE_SELECTION.NO)
55+
onChange(newValue, UpdateSelectionAfterChange.no)
5256
}
5357
})
5458
@@ -78,24 +82,28 @@
7882
valueClass = onValueClass(newValue)
7983
}
8084
85+
function handleCancel() {
86+
// cancel changes (needed to prevent triggering a change onDestroy)
87+
closed = true
88+
89+
onCancel()
90+
}
91+
8192
function handleValueKeyDown(event: KeyboardEvent) {
8293
event.stopPropagation()
8394
8495
const combo = keyComboFromEvent(event)
8596
8697
if (combo === 'Escape') {
87-
// cancel changes (needed to prevent triggering a change onDestroy)
88-
closed = true
89-
90-
onCancel()
98+
handleCancel()
9199
}
92100
93101
if (combo === 'Enter' || combo === 'Tab') {
94102
// apply changes
95103
closed = true
96104
97105
const newValue = getDomValue()
98-
onChange(newValue, UPDATE_SELECTION.NEXT_INSIDE)
106+
onChange(newValue, UpdateSelectionAfterChange.nextInside)
99107
}
100108
101109
if (combo === 'Ctrl+F') {
@@ -133,9 +141,13 @@
133141
if (document.hasFocus() && !closed) {
134142
closed = true
135143
if (newValue !== value) {
136-
onChange(newValue, UPDATE_SELECTION.SELF)
144+
onChange(newValue, UpdateSelectionAfterChange.self)
137145
} else {
138-
onCancel()
146+
// Note that we do not fire an onCancel here: a blur action
147+
// is caused by the user clicking somewhere else. If we apply
148+
// onCancel now, we would override the selection that the user
149+
// wants by clicking somewhere else in the editor (since `blur`
150+
// is occurring *after* `mousedown`).
139151
}
140152
}
141153
}

‎src/lib/components/controls/contextmenu/ContextMenu.svelte

+9-11
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,16 @@
2121
export let items: ContextMenuItem[]
2222
export let tip: string | undefined
2323
24-
let refContextMenu
24+
let refContextMenu: HTMLDivElement
2525
2626
onMount(() => {
27-
setTimeout(() => {
28-
const firstEnabledButton = [...refContextMenu.querySelectorAll('button')].find(
29-
(button) => !button.disabled
30-
)
27+
const firstEnabledButton = Array.from(refContextMenu.querySelectorAll('button')).find(
28+
(button) => !button.disabled
29+
)
3130
32-
if (firstEnabledButton) {
33-
firstEnabledButton.focus()
34-
}
35-
})
31+
if (firstEnabledButton) {
32+
firstEnabledButton.focus()
33+
}
3634
})
3735
3836
const directionByCombo: Record<string, 'Up' | 'Down' | 'Left' | 'Right'> = {
@@ -44,9 +42,9 @@
4442
4543
function handleKeyDown(event) {
4644
const combo = keyComboFromEvent(event)
47-
const direction = directionByCombo[combo]
45+
const direction: 'Up' | 'Down' | 'Left' | 'Right' | undefined = directionByCombo[combo]
4846
49-
if (typeof direction === 'string') {
47+
if (direction && event.target) {
5048
event.preventDefault()
5149
5250
const buttons: HTMLButtonElement[] = Array.from(

‎src/lib/components/controls/createFocusTracker.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ export function createFocusTracker({
4242
// The focusIn handler will cancel any pending blur timer in those cases
4343
clearTimeout(blurTimeoutHandle)
4444
blurTimeoutHandle = setTimeout(() => {
45-
debug('blur')
46-
focus = false
47-
onBlur()
45+
if (!hasFocus()) {
46+
debug('blur')
47+
focus = false
48+
onBlur()
49+
}
4850
}) as unknown as number
4951
}
5052
}

‎src/lib/components/modals/repair/JSONRepairComponent.svelte

+1-3
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,7 @@
5353
if (domTextArea && error) {
5454
const position = error.position != null ? error.position : 0
5555
domTextArea.setSelectionRange(position, position)
56-
setTimeout(() => {
57-
domTextArea.focus()
58-
})
56+
domTextArea.focus()
5957
}
6058
}
6159

‎src/lib/components/modes/tablemode/TableMode.svelte

+17-19
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@
7373
findParentWithNodeName,
7474
getDataPathFromTarget,
7575
getWindow,
76-
isChildOfNodeName
76+
isChildOfNodeName,
77+
isEditableDivRef
7778
} from '$lib/utils/domUtils.js'
7879
import { createDebug } from '$lib/utils/debug.js'
7980
import {
@@ -697,6 +698,7 @@
697698
}
698699
699700
export function focus() {
701+
debug('focus')
700702
// with just .focus(), sometimes the input doesn't react on onpaste events
701703
// in Chrome when having a large document open and then doing cut/paste.
702704
// Calling both .focus() and .select() did solve this issue.
@@ -710,7 +712,7 @@
710712
scrollTop = event.target['scrollTop']
711713
}
712714
713-
function handleMouseDown(event: MouseEvent) {
715+
function handleMouseDown(event: MouseEvent & { target: HTMLDivElement }) {
714716
const path = event?.target ? getDataPathFromTarget(event.target as HTMLElement) : undefined
715717
if (path) {
716718
// when clicking inside the current selection, editing a value, do nothing
@@ -726,15 +728,10 @@
726728
event.preventDefault()
727729
}
728730
729-
// TODO: ugly to have two setTimeout here. Without it, hiddenInput will blur
730-
setTimeout(() => {
731-
setTimeout(() => {
732-
// for example when clicking on the empty area in the main menu
733-
if (!hasFocus && !isChildOfNodeName(event.target, 'BUTTON')) {
734-
focus()
735-
}
736-
})
737-
})
731+
// for example when clicking on the empty area in the main menu
732+
if (!isChildOfNodeName(event.target, 'BUTTON') && !event.target.isContentEditable) {
733+
focus()
734+
}
738735
}
739736
740737
function createDefaultSelection(): JSONSelection | null {
@@ -1017,7 +1014,7 @@
10171014
return false
10181015
}
10191016
1020-
function handleContextMenuFromTableMenu(event: Event) {
1017+
function handleContextMenuFromTableMenu(event: Event & { target: HTMLDivElement }) {
10211018
if (readOnly) {
10221019
return
10231020
}
@@ -1093,9 +1090,10 @@
10931090
const { path, contents } = pastedJson
10941091
10951092
// exit edit mode
1096-
updateSelection(createValueSelection(path, false))
1097-
1098-
await tick()
1093+
const refEditableDiv = refContents?.querySelector('.jse-editable-div') || null
1094+
if (isEditableDivRef(refEditableDiv)) {
1095+
refEditableDiv.cancel()
1096+
}
10991097
11001098
// replace the value with the JSON object/array
11011099
const operations: JSONPatchDocument = [
@@ -1107,6 +1105,9 @@
11071105
]
11081106
11091107
handlePatch(operations)
1108+
1109+
// TODO: get rid of the setTimeout here
1110+
setTimeout(focus)
11101111
}
11111112
11121113
function handlePasteFromMenu() {
@@ -1128,6 +1129,7 @@
11281129
function handleClearPastedJson() {
11291130
debug('clear pasted json')
11301131
pastedJson = undefined
1132+
focus()
11311133
}
11321134
11331135
function handleRequestRepair() {
@@ -1880,10 +1882,6 @@
18801882
onClick: handleClearPastedJson
18811883
}
18821884
]}
1883-
onClose={() => {
1884-
// TODO: the need for setTimeout is ugly
1885-
setTimeout(focus)
1886-
}}
18871885
/>
18881886
{/if}
18891887

‎src/lib/components/modes/treemode/JSONKey.svelte

+7-5
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import SearchResultHighlighter from './highlight/SearchResultHighlighter.svelte'
1212
import EditableDiv from '../../controls/EditableDiv.svelte'
1313
import { addNewLineSuffix } from '$lib/utils/domUtils.js'
14-
import { UPDATE_SELECTION } from '$lib/constants.js'
1514
import type { ExtendedSearchResultItem, JSONSelection, TreeModeContext } from '$lib/types.js'
15+
import { UpdateSelectionAfterChange } from '$lib/types.js'
1616
import type { JSONPath } from 'immutable-json-patch'
1717
import ContextMenuPointer from '../../../components/controls/contextmenu/ContextMenuPointer.svelte'
1818
import { classnames } from '$lib/utils/cssUtils.js'
@@ -28,7 +28,9 @@
2828
$: isKeySelected = selection ? isKeySelection(selection) && isEqual(selection.path, path) : false
2929
$: isEditingKey = isKeySelected && isEditingSelection(selection)
3030
31-
function handleKeyDoubleClick(event) {
31+
function handleKeyDoubleClick(
32+
event: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }
33+
) {
3234
if (!isEditingKey && !context.readOnly) {
3335
event.preventDefault()
3436
context.onSelect(createKeySelection(path, true))
@@ -41,17 +43,17 @@
4143
})
4244
}
4345
44-
function handleChangeValue(newKey, updateSelection) {
46+
function handleChangeValue(newKey: string, updateSelection: UpdateSelectionAfterChange) {
4547
const updatedKey = onUpdateKey(key, context.normalization.unescapeValue(newKey))
4648
const updatedPath = initial(path).concat(updatedKey)
4749
4850
context.onSelect(
49-
updateSelection === UPDATE_SELECTION.NEXT_INSIDE
51+
updateSelection === UpdateSelectionAfterChange.nextInside
5052
? createValueSelection(updatedPath, false)
5153
: createKeySelection(updatedPath, false)
5254
)
5355
54-
if (updateSelection !== UPDATE_SELECTION.SELF) {
56+
if (updateSelection !== UpdateSelectionAfterChange.self) {
5557
context.focus()
5658
}
5759
}

‎src/lib/components/modes/treemode/TreeMode.svelte

+28-69
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@
8383
findParentWithNodeName,
8484
getWindow,
8585
isChildOf,
86-
isChildOfNodeName
86+
isChildOfNodeName,
87+
isEditableDivRef
8788
} from '$lib/utils/domUtils.js'
8889
import {
8990
convertValue,
@@ -1334,7 +1335,7 @@
13341335
*/
13351336
export async function scrollTo(path: JSONPath, scrollToWhenVisible = true): Promise<void> {
13361337
documentState = expandPath(json, documentState, path)
1337-
await tick() // await rerender
1338+
await tick() // await rerender (else the element we want to scroll to does not yet exist)
13381339
13391340
const elem = findElement(path)
13401341
@@ -1570,11 +1571,7 @@
15701571
}
15711572
15721573
// set focus to the hidden input, so we can capture quick keys like Ctrl+X, Ctrl+C, Ctrl+V
1573-
setTimeout(() => {
1574-
if (!activeElementIsChildOf(refJsonEditor)) {
1575-
focus()
1576-
}
1577-
})
1574+
focus()
15781575
}
15791576
15801577
function handleExpandAll() {
@@ -1781,59 +1778,26 @@
17811778
17821779
if (combo === 'Ctrl+Z') {
17831780
event.preventDefault()
1784-
1785-
// TODO: find a better way to restore focus
1786-
// TODO: implement a proper TypeScript solution to check whether this is an element with blur, focus, select
1787-
const activeElement = document.activeElement as HTMLInputElement
1788-
if (activeElement && activeElement.blur && activeElement.select) {
1789-
activeElement.blur()
1790-
setTimeout(() => {
1791-
handleUndo()
1792-
setTimeout(() => activeElement?.select())
1793-
})
1794-
} else {
1795-
handleUndo()
1796-
}
1781+
handleUndo()
17971782
}
17981783
17991784
if (combo === 'Ctrl+Shift+Z') {
18001785
event.preventDefault()
1801-
1802-
// TODO: find a better way to restore focus
1803-
// TODO: implement a proper TypeScript solution to check whether this is an element with blur, focus, select
1804-
const activeElement = document.activeElement as HTMLInputElement
1805-
if (activeElement && activeElement.blur && activeElement.select) {
1806-
activeElement.blur()
1807-
setTimeout(() => {
1808-
handleRedo()
1809-
setTimeout(() => activeElement?.select())
1810-
})
1811-
} else {
1812-
handleRedo()
1813-
}
1786+
handleRedo()
18141787
}
18151788
}
18161789
1817-
function handleMouseDown(event) {
1790+
function handleMouseDown(event: MouseEvent & { target: HTMLDivElement }) {
18181791
debug('handleMouseDown', event)
18191792
1820-
// TODO: ugly to have two setTimeout here. Without it, hiddenInput will blur
1821-
setTimeout(() => {
1822-
setTimeout(() => {
1823-
if (!hasFocus && !isChildOfNodeName(event.target, 'BUTTON')) {
1824-
// for example when clicking on the empty area in the main menu
1825-
focus()
1826-
1827-
if (
1828-
!documentState.selection &&
1829-
json === undefined &&
1830-
(text === '' || text === undefined)
1831-
) {
1832-
createDefaultSelection()
1833-
}
1834-
}
1835-
})
1836-
})
1793+
if (!isChildOfNodeName(event.target, 'BUTTON') && !event.target.isContentEditable) {
1794+
// for example when clicking on the empty area in the main menu
1795+
focus()
1796+
1797+
if (!documentState.selection && json === undefined && (text === '' || text === undefined)) {
1798+
createDefaultSelection()
1799+
}
1800+
}
18371801
}
18381802
18391803
function openContextMenu({
@@ -1966,11 +1930,13 @@
19661930
}
19671931
19681932
const { path, contents } = pastedJson
1933+
pastedJson = undefined
19691934
19701935
// exit edit mode
1971-
updateSelection(createValueSelection(path, false))
1972-
1973-
await tick()
1936+
const refEditableDiv = refContents?.querySelector('.jse-editable-div') || null
1937+
if (isEditableDivRef(refEditableDiv)) {
1938+
refEditableDiv.cancel()
1939+
}
19741940
19751941
// replace the value with the JSON object/array
19761942
const operations: JSONPatchDocument = [
@@ -1986,11 +1952,15 @@
19861952
state: expandRecursive(patchedJson, patchedState, path)
19871953
}
19881954
})
1955+
1956+
// TODO: get rid of the setTimeout here
1957+
setTimeout(focus)
19891958
}
19901959
19911960
function handleClearPastedJson() {
19921961
debug('clear pasted json')
19931962
pastedJson = undefined
1963+
focus()
19941964
}
19951965
19961966
function handleRequestRepair() {
@@ -2035,17 +2005,10 @@
20352005
refHiddenInput.blur()
20362006
}
20372007
2038-
// This is ugly: we need to wait until the EditableDiv has triggered onSelect,
2039-
// and have onSelect call refHiddenInput.focus(). After that we can call blur()
2040-
// to remove the focus.
2041-
// TODO: find a better solution
2042-
tick().then(() => {
2043-
setTimeout(() => {
2044-
if (refHiddenInput) {
2045-
refHiddenInput.blur()
2046-
}
2047-
})
2048-
})
2008+
debug('blur (outside editor)')
2009+
if (refHiddenInput) {
2010+
refHiddenInput.blur()
2011+
}
20492012
}
20502013
}
20512014
}
@@ -2240,10 +2203,6 @@
22402203
onClick: handleClearPastedJson
22412204
}
22422205
]}
2243-
onClose={() => {
2244-
// TODO: the need for setTimeout is ugly
2245-
setTimeout(focus)
2246-
}}
22472206
/>
22482207
{/if}
22492208

‎src/lib/constants.ts

-7
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,6 @@ export const JSON_STATUS_INVALID = 'invalid'
6868
export const CONTEXT_MENU_HEIGHT = (40 + 2) * 8 // px
6969
export const CONTEXT_MENU_WIDTH = 260 // px
7070

71-
// TODO: change UPDATE_SELECTION into an enum
72-
export const UPDATE_SELECTION = {
73-
NO: 'NO',
74-
SELF: 'SELF',
75-
NEXT_INSIDE: 'NEXT_INSIDE'
76-
}
77-
7871
export const SORT_DIRECTION_NAMES = {
7972
[SortDirection.asc]: 'ascending',
8073
[SortDirection.desc]: 'descending'

‎src/lib/plugins/value/components/BooleanToggle.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
}
2929
])
3030
31-
setTimeout(focus)
31+
focus()
3232
}
3333
</script>
3434

‎src/lib/plugins/value/components/EditableValue.svelte

+6-8
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
import { createValueSelection, getFocusPath } from '$lib/logic/selection.js'
88
import { getValueClass } from '$lib/plugins/value/components/utils/getValueClass.js'
99
import EditableDiv from '../../../components/controls/EditableDiv.svelte'
10-
import { UPDATE_SELECTION } from '$lib/constants.js'
1110
import type {
1211
FindNextInside,
1312
JSONParser,
1413
OnFind,
14+
OnJSONSelect,
1515
OnPasteJson,
1616
OnPatch,
17-
OnJSONSelect,
1817
ValueNormalization
1918
} from '$lib/types.js'
19+
import { UpdateSelectionAfterChange } from '$lib/types.js'
2020
import { isEqual } from 'lodash-es'
2121
2222
export let path: JSONPath
@@ -35,7 +35,7 @@
3535
return enforceString ? value : stringConvert(value, parser)
3636
}
3737
38-
function handleChangeValue(newValue: string, updateSelection: string) {
38+
function handleChangeValue(newValue: string, updateSelection: UpdateSelectionAfterChange) {
3939
onPatch(
4040
[
4141
{
@@ -53,7 +53,7 @@
5353
}
5454
5555
const selection =
56-
updateSelection === UPDATE_SELECTION.NEXT_INSIDE
56+
updateSelection === UpdateSelectionAfterChange.nextInside
5757
? findNextInside(path)
5858
: createValueSelection(path, false)
5959
@@ -66,9 +66,7 @@
6666
}
6767
)
6868
69-
if (updateSelection !== UPDATE_SELECTION.SELF) {
70-
focus()
71-
}
69+
focus()
7270
}
7371
7472
function handleCancelChange() {
@@ -82,7 +80,7 @@
8280
if (isObjectOrArray(pastedJson)) {
8381
onPasteJson({
8482
path,
85-
contents: pastedJson
83+
contents: pastedJson as JSONValue
8684
})
8785
}
8886
} catch (err) {

‎src/lib/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,12 @@ export enum SortDirection {
574574
desc = 'desc'
575575
}
576576

577+
export enum UpdateSelectionAfterChange {
578+
no = 'no',
579+
self = 'self',
580+
nextInside = 'nextInside'
581+
}
582+
577583
export interface TableCellIndex {
578584
rowIndex: number
579585
columnIndex: number

‎src/lib/utils/domUtils.ts

+14
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,17 @@ export function findNearestElement<T extends Element>({
410410

411411
return undefined
412412
}
413+
414+
export interface EditableDivElement extends HTMLDivElement {
415+
refresh: () => void
416+
cancel: () => void
417+
}
418+
419+
export function isEditableDivRef(element: Element | null): element is EditableDivElement {
420+
return (
421+
!!element &&
422+
element.nodeName === 'DIV' &&
423+
typeof (element as unknown as Record<string, unknown>).refresh === 'function' &&
424+
typeof (element as unknown as Record<string, unknown>).cancel === 'function'
425+
)
426+
}

0 commit comments

Comments
 (0)
Please sign in to comment.