UNPKG

substance

Version:

Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).

524 lines (477 loc) 15.8 kB
import { debounce, uuid, platform, getKeyForPath } from '../util' import { documentHelpers, Marker } from '../model' const UPDATE_DELAY = 200 export default class FindAndReplaceManager { constructor (editorSession) { this._editorSession = editorSession this._doc = editorSession.getDocument() this._dirty = new Set() // Note: this is debounced to avoid slow down while typing by running searches too eagerly // Only for testing search updates are done synchronously this._updateSearchDebounced = debounce(this._updateSearch.bind(this, true), UPDATE_DELAY) const editorState = this._editorSession.getEditorState() // during update stage we watch for changes on properties with matches // to keep the internal state up2date editorState.addObserver(['document'], this._onUpdate, this, { stage: 'update' }) // HACK: without this we see strange errors. As a temporary fix leave it here // but should try tor find the source of the problem ASAP editorState.addObserver(['document'], this._onRender, this, { stage: 'render' }) } dispose () { this._editorSession.getEditorState().removeObserver(this) } openDialog (enableReplace) { enableReplace = Boolean(enableReplace) const state = this._getState() if (state.enabled) { // update state if 'showReplace' has changed if (state.showReplace !== enableReplace) { state.showReplace = Boolean(enableReplace) this._updateState(state) } } else { state.enabled = true state.showReplace = Boolean(enableReplace) // resetting dirty flags as we do a full search initially this._dirty = new Set() this._performSearch() } this._propgateUpdates() } closeDialog () { const state = this._getState() if (!state.enabled) return state.enabled = false this._clearHighlights() // Note: recovering the selection here this._updateState(state, 'recoverSelection') this._propgateUpdates() } next () { this._next() this._propgateUpdates() } previous () { const state = this._getState() this._nav('back') this._updateState(state) this._propgateUpdates() } setSearchPattern (pattern) { const state = this._getState() if (state.pattern !== pattern) { state.pattern = pattern this._performSearch() this._propgateUpdates() } } setReplacePattern (replacePattern) { const state = this._getState() if (state.replacePattern !== replacePattern) { state.replacePattern = replacePattern this._updateState(state) this._propgateUpdates() } } replaceNext () { const state = this._getState() // ATTENTION: special handling after manual changes, while search dialog is open // in this case we do a forced 'next()' when using 'replaceNext()' if (state._forceNav) { state._forceNav = false this._next() return } if (state.replacePattern) { let hasReplaced = false if (state.cursor >= 0) { const m = this._getMatchAt(state.cursor) if (m) { this._editorSession.transaction(tx => { this._replace(tx, m, state) }, { action: 'replace' }) // ATTENTION: we are not changing the search result on changes with action type: 'replace' // Instead we are doing it here manually: // updating the result for the current text property // and propagating changes so that so that text properties are updated this._updateSearchForProperty(getKeyForPath(m.path)) this._editorSession.getEditorState().propagateUpdates() // set the cursor back and scroll to the next state.cursor-- this._nav('forward') this._updateState(state) hasReplaced = true } } if (!hasReplaced) { // otherwise seek to the next match position first this._next() } this._propgateUpdates() } } replaceAll () { const state = this._getState() if (!state.matches) return let allMatches = [] state.matches.forEach(_matches => { allMatches = allMatches.concat(_matches) }) this._editorSession.transaction(tx => { for (let idx = allMatches.length - 1; idx >= 0; idx--) { this._replace(tx, allMatches[idx], state) } }, { action: 'replace-all' }) state.matches = new Map() state.count = 0 state.cursor = -1 this._updateState(state) this._propgateUpdates() } toggleCaseSensitivity () { this._toggleOption('caseSensitive') this._propgateUpdates() } toggleRegexSearch () { this._toggleOption('regexSearch') this._propgateUpdates() } toggleFullWordSearch () { this._toggleOption('fullWord') this._propgateUpdates() } _getMarkersManager () { return this._editorSession.markersManager } _getState () { const editorState = this._editorSession.getEditorState() return editorState.findAndReplace || FindAndReplaceManager.defaultState() } _toggleOption (optionName) { const state = this._getState() state[optionName] = !state[optionName] this._performSearch() } _performSearch () { const state = this._getState() if (state.pattern) { this._searchAndHighlight() } else { this._clear() } state.cursor = -1 this._updateState(state) // ATTENTION: scrolling to the first match (if available) // this needs to be done after rolling out the state update // so that the markers have been rendered already if (state.count > 0) { this._next() } } _next () { const state = this._getState() this._nav('forward') this._updateState(state) } _updateState (state, recoverSelection) { const editorState = this._editorSession.getEditorState() // HACK: touching editorState.selection because we want that the applications recovers the selection if (recoverSelection) { editorState._setDirty('selection') } // console.log('Updating editorState.findAndReplace', state) editorState.findAndReplace = state } _propgateUpdates () { const editorState = this._editorSession.getEditorState() // TODO: we need to figure out if this is a problem // only in tests this is called synchronously // leading to extra updates e.g. when the content is changed while // the FNR dialog is open if (!editorState._isUpdating()) { editorState.propagateUpdates() } } _searchAndHighlight () { // re-start the search this._clearHighlights() this._search() this._addHighlights() this._propgateUpdates() } _search () { const state = this._getState() const matches = new Map() let count = 0 const pattern = state.pattern const opts = state if (pattern) { const tps = this._getTextProperties() for (const tp of tps) { // console.log('... searching for matches in ', tp.getPath()) const _matches = this._searchInProperty(tp, pattern, opts) // if (_matches.length > 0) console.log('found %s matches', _matches.length) count += _matches.length if (_matches.length > 0) { matches.set(getKeyForPath(tp.getPath()), _matches) } } } state.matches = matches state.count = count } _updateSearch (propagate) { const state = this._getState() if (!state.enabled || !state.pattern || this._dirty.size === 0) return for (const key of this._dirty) { // ATTENTION: this updates state.count this._updateSearchForProperty(key) } // HACK: need to make sure that the selection is recovered here this._updateState(state, 'recoverSelection') this._dirty = new Set() if (propagate) { this._propgateUpdates() } } _updateSearchForProperty (key) { const markersManager = this._getMarkersManager() const state = this._getState() const matches = state.matches let count = state.count let _matches = matches.get(key) if (_matches) { count -= _matches.length } const path = key.split('.') markersManager.clearMarkers(path, m => m.type === 'find-marker') const tp = this._getTextProperty(key) if (tp) { _matches = this._searchInProperty(tp, state.pattern, state) count += _matches.length matches.set(key, _matches) this._addHighlightsForProperty(path, _matches) } else { matches.delete(key) } state.count = count } _searchInProperty (tp, pattern, opts) { const path = tp.getPath() return _findInText(tp.getText(), pattern, opts).map(m => { // add an id so that we can find it later, e.g. for scroll-to m.id = uuid() m.path = path m.textProperty = tp return m }) } /* In case of a regexp search the replacement string allows for the following patterns - "$$": Inserts a "$". - "$&": Inserts the matched substring. - "$`": Inserts the portion of the string that precedes the matched substring. - "$'": Inserts the portion of the string that follows the matched substring. - "$n": Where n is a positive integer less than 100, inserts the nth parenthesized submatch string, provided the first argument was a RegExp object. Note that this is 1-indexed. */ _replace (tx, m, options) { tx.setSelection({ type: 'property', path: m.path, startOffset: m.start, endOffset: m.end }) let newText // TODO: we should allow to use regex in replace string too // for that we would take the string from the match // and apply native String replace to g if (options.regexSearch) { const text = documentHelpers.getTextForSelection(tx, tx.selection) const findRe = new RegExp(options.pattern) newText = text.replace(findRe, options.replacePattern) } else { newText = options.replacePattern } tx.insertText(newText) } _clear () { const state = this._getState() this._clearHighlights() state.matches = new Map() state.count = 0 } _clearHighlights () { const markersManager = this._getMarkersManager() const state = this._getState() if (state.matches) { state.matches.forEach((_, key) => { const path = key.split('.') markersManager.clearMarkers(path, m => m.type === 'find-marker') }) } } _addHighlights () { const state = this._getState() if (state.matches) { state.matches.forEach((matches, key) => { const path = key.split('.') this._addHighlightsForProperty(path, matches) }) } } // TODO: don't know yet how we want to update Markers incrementally _addHighlightsForProperty (path, matches) { const markersManager = this._getMarkersManager() matches.forEach(m => { markersManager.addMarker(new Marker(this._doc, { type: 'find-marker', id: m.id, start: { path, offset: m.start }, end: { path, offset: m.end } })) }) } _getTextProperties () { const rootComponent = this._editorSession.getRootComponent() if (rootComponent) { // EXPERIMENTAL: we need to retrieve all *editable* text properties in the correct order // which is not possible just from the model (without further knowledge) // However, doing it via DOM search is probably rather slow return rootComponent.findAll('.sc-text-property') } else { console.error('FindAndReplaceManager: no root component has been assigned yet.') } } _getTextProperty (id) { const rootComponent = this._editorSession.getRootComponent() if (rootComponent) { // EXPERIMENTAL: same as _getTextProperties() return rootComponent.find(`.sc-text-property[data-path="${id}"]`) } else { console.error('FindAndReplaceManager: no root component has been assigned yet.') } } _nav (direction) { const state = this._getState() const [cursor, match] = this._getNext(direction) if (match) { state.cursor = cursor this._scrollToMatch(match) } } _getNext (direction) { // TODO: support a selection relative navigation // as a first iteration we will do this independently from the selection const state = this._getState() let idx if (direction === 'forward') { idx = Math.min(state.count - 1, state.cursor + 1) } else { idx = Math.max(0, state.cursor - 1) } return [idx, this._getMatchAt(idx)] } _getMatchAt (idx) { // Note: because we are storing matching grouped by properties // this is a but nasty const state = this._getState() if (state.matches) { for (const [, matches] of state.matches) { if (idx >= matches.length) { idx -= matches.length } else { return matches[idx] } } } } _scrollToMatch (match) { const state = this._getState() // HACKIDHACK: instead of relying on rerendering, we toggle the hightlight here // which is also much faster, and still pretty safe, because we throw markers on every change if (state.marker) state.marker.el.removeClass('sm-active') const tp = match.textProperty const marker = tp.find(`.sm-find-marker[data-id="${match.id}"]`) // FIXME: when doing replace it seems that we are not good yet with navigating through the matches // this guard should not be necessary if everything is working if (marker) { marker.el.addClass('sm-active') state.marker = marker tp.send('scrollElementIntoView', marker.el) } } _onUpdate (change) { // skip changes caused by replaceNext() and replaceAll() if ( change.info.action === 'replace' || change.info.action === 'replace-all' || change.info.action === 'nop' ) return for (const op of change.primitiveOps) { if (op.isUpdate() && op.diff._isTextOperation) { this._dirty.add(getKeyForPath(op.path)) } } const state = this._getState() if (!state.enabled) return // HACK: this is a bit hacky but should work. When the user has changed the text we leave a mark in the state // so that we can force a 'next()' when 'replaceNext()' is called state._forceNav = true // Note: when running tests updating the search result synchronously if (platform.test) { this._updateSearch() } else { // otherwise this is done debounced this._updateSearchDebounced() } } _onRender (change) { // HACK: There seems to be a problem with registering observers in the editorState // without registering this hook we see strange errors at other places // Probably related to a bug in the observer registration/deregistration // during propagation of AppState changes } static defaultState () { return { enabled: false, pattern: '', showReplace: false, replacePattern: '', caseSensitive: false, fullWord: false, regexSearch: false, matches: null, count: 0, cursor: 0 } } } function _createRegExForPattern (pattern) { return pattern.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&') // eslint-disable-line no-useless-escape } function _findInText (text, pattern, opts = {}) { if (!opts.regexSearch) { pattern = _createRegExForPattern(pattern) } if (opts.fullWord) { pattern = '\\b' + pattern + '\\b' } const matches = [] try { const matcher = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi') let match while ((match = matcher.exec(text))) { matches.push({ start: match.index, end: matcher.lastIndex }) } } catch (err) {} return matches }