Implement SearchBox (WIP)

This commit is contained in:
josdejong 2020-05-22 14:13:51 +02:00
parent b66a1cf0fa
commit 9c8febb1ff
9 changed files with 468 additions and 25 deletions

View File

@ -32,6 +32,7 @@
#testEditor {
width: 800px;
height: 500px;
max-width: 100%;
}
</style>
</head>

View File

@ -39,6 +39,14 @@
flex: 1;
}
.separator {
background: rgba(255, 255, 255, 0.2);
box-sizing: border-box;
width: 1px;
height: 24px;
margin: 5px;
}
.search-box {
display: flex;
align-items: center;
@ -60,6 +68,13 @@
.contents {
flex: 1;
overflow: auto;
position: relative;
.search {
position: absolute;
top: $search-box-offset;
right: $search-box-offset;
}
.bottom {
height: $input-padding;

View File

@ -1,14 +1,19 @@
<script>
import SearchBox from './SearchBox.svelte'
import Icon from 'svelte-awesome'
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
import { createHistory } from './history.js'
import Node from './JSONNode.svelte'
import { search } from './search'
import { keyComboFromEvent } from './utils/keyBindings.js'
import { search } from './utils/search.js'
import { immutableJSONPatch } from './utils/immutableJSONPatch'
export let json = {}
export let onChangeJson = () => {}
export let searchText = ''
export let onChangeJson = () => {
}
let showSearch = true // FIXME: change to false
let searchText = ''
const history = createHistory({
onChange: (state) => {
@ -26,7 +31,7 @@
history.clear()
}
export function patch (operations) {
export function patch(operations) {
console.log('patch', operations)
const patchResult = immutableJSONPatch(json, operations)
@ -46,11 +51,11 @@
}
}
function getPath () {
function getPath() {
return []
}
function doSearch (json, searchText) {
function doSearch(json, searchText) {
console.time('search')
const result = search(null, json, searchText)
console.timeEnd('search')
@ -59,12 +64,12 @@
$: searchResult = searchText ? doSearch(json, searchText) : undefined
function handleChangeKey (key, oldKey) {
function handleChangeKey(key, oldKey) {
// console.log('handleChangeKey', { key, oldKey })
// TODO: this should not happen?
}
function emitOnChange () {
function emitOnChange() {
// TODO: add more logic here to emit onChange, onChangeJson, onChangeText, etc.
onChangeJson(json)
}
@ -72,7 +77,7 @@
/**
* @param {JSONPatchDocument} operations
*/
function handlePatch (operations) {
function handlePatch(operations) {
// console.log('handlePatch', operations)
patch(operations)
@ -80,7 +85,11 @@
emitOnChange()
}
function handleUndo () {
function handleToggleSearch() {
showSearch = !showSearch
}
function handleUndo() {
if (history.getState().canUndo) {
const item = history.undo()
if (item) {
@ -90,7 +99,7 @@
}
}
function handleRedo () {
function handleRedo() {
if (history.getState().canRedo) {
const item = history.redo()
if (item) {
@ -100,20 +109,75 @@
}
}
function handleKeyDown (event) {
const combo = keyComboFromEvent(event)
if (combo === 'Ctrl+F' || combo === 'Command+F') {
event.preventDefault()
showSearch = true
}
if (combo === 'Ctrl+Z' || combo === 'Command+Z') {
event.preventDefault()
// TODO: find a better way to restore focus
const activeElement = document.activeElement
if (activeElement && activeElement.blur && activeElement.focus) {
activeElement.blur()
setTimeout(() => {
handleUndo()
setTimeout(() => activeElement.focus())
})
} else {
handleUndo()
}
}
if (combo === 'Ctrl+Shift+Z' || combo === 'Command+Shift+Z') {
event.preventDefault()
// TODO: find a better way to restore focus
const activeElement = document.activeElement
if (activeElement && activeElement.blur && activeElement.focus) {
activeElement.blur()
setTimeout(() => {
handleRedo()
setTimeout(() => activeElement.focus())
})
} else {
handleRedo()
}
}
}
</script>
<div class="jsoneditor">
<div class="jsoneditor" on:keydown={handleKeyDown}>
<div class="menu">
<button class="button undo" disabled={!historyState.canUndo} on:click={handleUndo}>
<button
class="button search"
on:click={handleToggleSearch}
title="Search (Ctrl+F)"
>
<Icon data={faSearch} />
</button>
<div class="separator"></div>
<button
class="button undo"
disabled={!historyState.canUndo}
on:click={handleUndo}
title="Undo (Ctrl+Z)"
>
<Icon data={faUndo} />
</button>
<button class="button redo" disabled={!historyState.canRedo} on:click={handleRedo}>
<button
class="button redo"
disabled={!historyState.canRedo}
on:click={handleRedo}
title="Redo (Ctrl+Shift+Z)"
>
<Icon data={faRedo} />
</button>
<div class="space"></div>
<div class="search-box">
<span class="search-icon"><Icon data={faSearch} /></span> Search: <input class="search-input" bind:value={searchText} />
</div>
</div>
<div class="contents">
<Node
@ -125,6 +189,15 @@
getParentPath={getPath}
/>
<div class='bottom'></div>
{#if showSearch}
<div class="search">
<SearchBox
text={searchText}
onChange={text => searchText = text}
onClose={() => showSearch = false}
/>
</div>
{/if}
</div>
</div>

View File

@ -2,7 +2,7 @@
import { getPlainText, setPlainText } from './utils/domUtils.js'
import Icon from 'svelte-awesome'
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'
import { SEARCH_PROPERTY, SEARCH_VALUE } from './search'
import { SEARCH_PROPERTY, SEARCH_VALUE } from './utils/search.js'
import classnames from 'classnames'
import debounce from 'lodash/debounce'
import { findUniqueName } from './utils/stringUtils.js'

57
src/SearchBox.scss Normal file
View File

@ -0,0 +1,57 @@
@import './styles.scss';
$search-size: 24px;
.search-box {
border: 2px solid $theme-color;
border-radius: $border-radius;
background: $white;
.search-form {
display: flex;
align-items: center;
padding: 0 5px;
.search-icon {
color: $light-gray;
display: block;
width: $search-size;
height: $search-size;
text-align: center;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
button.search-icon {
width: 20px;
cursor: pointer;
&:hover,
&:active {
color: $gray;
}
}
.search-input {
border: none;
height: $search-size;
padding: 0 $input-padding;
margin: 0;
flex: 1;
width: 100px;
}
.search-count {
color: $light-gray;
font-size: $font-size-small;
visibility: hidden;
padding: 0 $input-padding;
&.visible {
visibility: visible;
}
}
}
}

85
src/SearchBox.svelte Normal file
View File

@ -0,0 +1,85 @@
<script>
import { debounce } from 'lodash'
import Icon from 'svelte-awesome'
import { faSearch, faChevronDown, faChevronUp, faTimes } from '@fortawesome/free-solid-svg-icons'
import { keyComboFromEvent } from './utils/keyBindings.js'
const DEBOUNCE_DELAY = 300 // milliseconds TODO: make the debounce delay configurable?
export let text = ''
export let onChange = () => {}
export let onClose = () => {}
let activeResultIndex = 0
let resultCount = 0
$: onChangeDebounced = debounce(onChange, DEBOUNCE_DELAY)
function handleSubmit (event) {
event.preventDefault()
onChangeDebounced.cancel()
onChange(text)
}
function handleInput (event) {
text = event.target.value
onChangeDebounced(text)
// TODO: fire debounced onChange
}
function handleClose () {
onChange('')
onClose()
}
function handleKeyDown (event) {
const combo = keyComboFromEvent(event)
if (combo === 'Ctrl+Enter' || combo === 'Command+Enter') {
event.preventDefault()
// this.props.onFocusActive() // FIXME
}
if (combo === 'Escape') {
event.preventDefault()
handleClose()
}
}
function initSearchInput (element) {
element.select()
}
</script>
<div class="search-box">
<form class="search-form" on:submit={handleSubmit} on:keydown={handleKeyDown}>
<button class="search-icon">
<Icon data={faSearch} />
</button>
<label about="search input">
<input
class="search-input"
type="text"
value={text}
on:input={handleInput}
use:initSearchInput
/>
</label>
<div class="search-count" class:visible={text !== ''}>
{activeResultIndex}/{resultCount}
</div>
<button class="search-icon search-next">
<Icon data={faChevronDown} />
</button>
<button class="search-icon search-previous">
<Icon data={faChevronUp} />
</button>
<button class="search-icon search-clear" on:click={handleClose}>
<Icon data={faTimes} />
</button>
</form>
</div>
<style src="SearchBox.scss"></style>

View File

@ -27,5 +27,7 @@ $light-gray: #c0c0c0;
$line-height: 18px;
$indentation-width: 18px;
$input-padding: 5px;
$search-box-offset: 10px;
$border-radius: 3px;
$menu-padding: 5px;

215
src/utils/keyBindings.js Normal file
View File

@ -0,0 +1,215 @@
// inspiration: https://github.com/andrepolischuk/keycomb
// TODO: write unit tests for keyBindings
// FIXME: implement an escape sequence for the separator +
/**
* Get a named key from a key code.
* For example:
* keyFromCode(65) returns 'A'
* keyFromCode(13) returns 'Enter'
* @param {string} code
* @return {string}
*/
export function nameFromKeyCode(code) {
return codes[code] || ''
}
/**
* Get the active key combination from a keyboard event.
* For example returns "Ctrl+Shift+Up" or "Ctrl+A"
* @param {KeyboardEvent} event
* @return {string}
*/
export function keyComboFromEvent (event) {
let combi = []
if (event.ctrlKey) { combi.push('Ctrl') }
if (event.metaKey) { combi.push('Command') }
if (event.altKey) { combi.push(isMac ? 'Option' : 'Alt') }
if (event.shiftKey) { combi.push('Shift') }
const keyName = nameFromKeyCode(event.which)
if (!metaCodes[keyName]) { // prevent output like 'Ctrl+Ctrl'
combi.push(keyName)
}
return combi.join('+')
}
/**
* Create a function which can quickly find a keyBinding from a set of
* keyBindings.
* @param {Object.<String, String[]>} keyBindings
* @return {function} Returns a findKeyBinding function
*/
export function createFindKeyBinding (keyBindings) {
// turn the map with key bindings by name (multiple per binding) into a map by key combo
const keyCombos = {}
Object.keys(keyBindings).forEach ((name) => {
keyBindings[name].forEach(combo => keyCombos[normalizeKeyCombo(combo)] = name)
})
return function findKeyBinding (event) {
const keyCombo = keyComboFromEvent(event)
return keyCombos[normalizeKeyCombo(keyCombo)] || null
}
}
/**
* Normalize a key combo:
*
* - to upper case
* - replace aliases like "?" with "/"
*
* @param {string} combo
* @return {string}
*/
function normalizeKeyCombo (combo) {
const upper = combo.toUpperCase()
const last = upper[upper.length - 1]
if (last in aliases) {
return upper.substring(0, upper.length - 1) + aliases[last]
}
return upper
}
const isMac = window.navigator.platform.toUpperCase().indexOf('MAC') >= 0
const metaCodes = {
'Ctrl': true,
'Command': true,
'Alt': true,
'Option': true,
'Shift': true
}
const codes = {
'8': 'Backspace',
'9': 'Tab',
'13': 'Enter',
'16': 'Shift',
'17': 'Ctrl',
'18': 'Alt',
'19': 'Pause_Break',
'20': 'Caps_Lock',
'27': 'Escape',
'33': 'Page_Up',
'34': 'Page_Down',
'35': 'End',
'36': 'Home',
'37': 'Left',
'38': 'Up',
'39': 'Right',
'40': 'Down',
'45': 'Insert',
'46': 'Delete',
'48': '0',
'49': '1',
'50': '2',
'51': '3',
'52': '4',
'53': '5',
'54': '6',
'55': '7',
'56': '8',
'57': '9',
'65': 'A',
'66': 'B',
'67': 'C',
'68': 'D',
'69': 'E',
'70': 'F',
'71': 'G',
'72': 'H',
'73': 'I',
'74': 'J',
'75': 'K',
'76': 'L',
'77': 'M',
'78': 'N',
'79': 'O',
'80': 'P',
'81': 'Q',
'82': 'R',
'83': 'S',
'84': 'T',
'85': 'U',
'86': 'V',
'87': 'W',
'88': 'X',
'89': 'Y',
'90': 'Z',
'91': 'Left_Window_Key',
'92': 'Right_Window_Key',
'93': 'Select_Key',
'96': 'Numpad_0',
'97': 'Numpad_1',
'98': 'Numpad_2',
'99': 'Numpad_3',
'100': 'Numpad_4',
'101': 'Numpad_5',
'102': 'Numpad_6',
'103': 'Numpad_7',
'104': 'Numpad_8',
'105': 'Numpad_9',
'106': 'Numpad_*',
'107': 'Numpad_+',
'109': 'Numpad_-',
'110': 'Numpad_.',
'111': 'Numpad_/',
'112': 'F1',
'113': 'F2',
'114': 'F3',
'115': 'F4',
'116': 'F5',
'117': 'F6',
'118': 'F7',
'119': 'F8',
'120': 'F9',
'121': 'F10',
'122': 'F11',
'123': 'F12',
'144': 'Num_Lock',
'145': 'Scroll_Lock',
'186': ';',
'187': '=',
'188': ',',
'189': '-',
'190': '.',
'191': '/',
'192': '`',
'219': '[',
'220': '\\',
'221': ']',
'222': '\''
}
// all secondary characters of the keyboard buttons (used via Shift)
const aliases = {
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
'_': '-',
'+': '=',
'{': '[',
'}': ']',
'|': '\\',
':': ';',
'"': '',
'<': ',',
'>': '.',
'?': '/'
}

View File

@ -1,4 +1,4 @@
import { valueType } from './utils/typeUtils'
import { valueType } from './typeUtils.js'
export const SEARCH_PROPERTY = '$jse:search:property'
export const SEARCH_VALUE = '$jse:search:value'
@ -38,11 +38,6 @@ function createOrAdd(object, key, value) {
if (object) {
object[key] = value
return object
// return {
// ...object,
// [key]: value
// }
} else {
return {
[key]: value